agent-ecosystem/agent-teams-controller/src/legacy/teamctl.cli.js
iliya a3844a085f refactor: standardize agent block handling and improve messaging format
- Introduced a new `wrapAgentBlock` function to standardize the formatting of agent-only messages across the application.
- Updated the `requestReview` method to utilize the new agent block format, enhancing consistency in review request messages.
- Refactored legacy agent block handling to support both new XML-like and legacy fenced formats, ensuring backward compatibility.
- Enhanced tests to validate the new agent block formatting and ensure proper extraction of agent-only content from messages.
2026-03-07 21:59:38 +02:00

1572 lines
57 KiB
JavaScript

#!/usr/bin/env node
'use strict';
// Team tools (v1.0.0)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const TOOL_VERSION = '1.0.0';
const AGENT_BLOCK_TAG = 'info_for_agent';
const AGENT_BLOCK_OPEN = '<' + AGENT_BLOCK_TAG + '>';
const AGENT_BLOCK_CLOSE = '</' + AGENT_BLOCK_TAG + '>';
function nowIso() {
return new Date().toISOString();
}
function wrapAgentBlock(text) {
const trimmed = typeof text === 'string' ? text.trim() : '';
if (!trimmed) return '';
return AGENT_BLOCK_OPEN + '\n' + trimmed + '\n' + AGENT_BLOCK_CLOSE;
}
function makeId() {
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random());
}
function formatError(err) {
if (!err) return 'Unknown error';
if (typeof err === 'string') return err;
if (err instanceof Error) return err.message || String(err);
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}
function die(message, code = 1) {
process.stderr.write(String(message).trimEnd() + '\n');
process.exit(code);
}
function isSafePathSegment(value) {
const v = String(value == null ? '' : value);
if (v.length === 0 || v.trim().length === 0) return false;
if (v === '.' || v === '..') return false;
if (v.includes('/') || v.includes('\\\\')) return false;
if (v.includes('..')) return false;
if (v.includes('\0')) return false;
return true;
}
function assertSafePathSegment(label, value) {
const v = String(value == null ? '' : value);
if (!isSafePathSegment(v)) {
die('Invalid ' + String(label));
}
return v;
}
function getTaskJsonPath(paths, taskId) {
const id = assertSafePathSegment('taskId', taskId);
return path.join(paths.tasksDir, id + '.json');
}
function parseArgs(argv) {
const out = { _: [], flags: {} };
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (token === '--') {
out._.push(...argv.slice(i + 1));
break;
}
if (token === '-h' || token === '--help') {
out.flags.help = true;
continue;
}
if (token.startsWith('--')) {
const eq = token.indexOf('=');
if (eq !== -1) {
const key = token.slice(2, eq);
const value = token.slice(eq + 1);
out.flags[key] = value;
continue;
}
const key = token.slice(2);
const next = argv[i + 1];
if (next != null && !String(next).startsWith('-')) {
out.flags[key] = next;
i++;
} else {
out.flags[key] = true;
}
continue;
}
if (token.startsWith('-') && token.length > 1) {
// minimal short-flag support
for (const ch of token.slice(1)) {
if (ch === 'h') out.flags.help = true;
}
continue;
}
out._.push(token);
}
return out;
}
function getHomeDir() {
if (process.env.HOME) return process.env.HOME;
if (process.env.USERPROFILE) return process.env.USERPROFILE;
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
return process.env.HOMEDRIVE + process.env.HOMEPATH;
}
try { return require('os').homedir(); } catch { return ''; }
}
function getClaudeDir(flags) {
const raw =
(typeof flags['claude-dir'] === 'string' && flags['claude-dir']) ||
(typeof flags['claudeDir'] === 'string' && flags['claudeDir']) ||
(typeof flags['claude_path'] === 'string' && flags['claude_path']) ||
'';
if (raw) return path.resolve(raw);
const inferred = inferClaudeDirFromScriptPath();
if (inferred) return inferred;
const home = getHomeDir();
if (!home) die('HOME/USERPROFILE is not set');
return path.join(home, '.claude');
}
function inferClaudeDirFromScriptPath() {
// Expected: <claudeDir>/tools/teamctl.js
const toolsDir = path.dirname(__filename);
if (path.basename(toolsDir) !== 'tools') return null;
return path.dirname(toolsDir) || null;
}
function inferTeamNameFromScriptPath() {
// From ~/.claude/tools/ the team name cannot be inferred — --team is required
return null;
}
function getTeamName(flags) {
const explicit =
(typeof flags.team === 'string' && flags.team.trim()) ||
(typeof flags['teamName'] === 'string' && flags['teamName'].trim()) ||
'';
if (explicit) return assertSafePathSegment('team', explicit);
const inferred = inferTeamNameFromScriptPath();
if (inferred) return inferred;
die('Missing --team (and could not infer team name from script path)');
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readJson(filePath, fallback) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
if (err && err.code === 'ENOENT') return fallback;
throw err;
}
}
function atomicWrite(filePath, data) {
ensureDir(path.dirname(filePath));
const tmp =
String(filePath) +
'.tmp.' +
String(process.pid) +
'.' +
String(Math.random().toString(16).slice(2));
fs.writeFileSync(tmp, data, 'utf8');
// On Windows, fs.renameSync can throw EPERM/EACCES when another process
// is concurrently renaming to the same target. Retry with backoff.
const maxRetries = process.platform === 'win32' ? 5 : 1;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
fs.renameSync(tmp, filePath);
return;
} catch (e) {
if (attempt < maxRetries - 1 && e && (e.code === 'EPERM' || e.code === 'EACCES')) {
// Busy wait — small random delay to reduce contention
const ms = Math.floor(Math.random() * 50) + 10;
const end = Date.now() + ms;
while (Date.now() < end) { /* spin */ }
continue;
}
// Clean up temp file on final failure
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
throw e;
}
}
}
// ---------------------------------------------------------------------------
// Attachments (task + comment)
// ---------------------------------------------------------------------------
const TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB
function sanitizeFilename(original) {
const raw = String(original == null ? '' : original).trim();
const parts = raw.split(/[\\/]/);
const base = (parts.length ? parts[parts.length - 1] : raw).trim();
const cleaned = base
.replace(/\0/g, '')
.replace(/[\r\n\t]/g, ' ')
.replace(/[\\/]/g, '_')
.trim();
if (!cleaned) return 'attachment';
return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
}
function readFileHeader(filePath, maxBytes) {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(maxBytes);
const bytes = fs.readSync(fd, buf, 0, maxBytes, 0);
return buf.slice(0, bytes);
} finally {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
function startsWithBytes(buf, bytes) {
if (!buf || buf.length < bytes.length) return false;
for (let i = 0; i < bytes.length; i++) {
if (buf[i] !== bytes[i]) return false;
}
return true;
}
function detectMimeTypeFromPathAndHeader(filePath, filename) {
const name = String(filename || '').toLowerCase();
const ext = path.extname(name);
// Fast path by extension for common types.
if (ext === '.png') return 'image/png';
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.gif') return 'image/gif';
if (ext === '.webp') return 'image/webp';
if (ext === '.pdf') return 'application/pdf';
if (ext === '.txt') return 'text/plain';
if (ext === '.md') return 'text/markdown';
if (ext === '.json') return 'application/json';
if (ext === '.zip') return 'application/zip';
// Sniff magic bytes for a few important formats.
let header;
try {
header = readFileHeader(filePath, 16);
} catch {
return 'application/octet-stream';
}
if (startsWithBytes(header, [0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a])) return 'image/png'; // PNG
if (startsWithBytes(header, [0xff,0xd8,0xff])) return 'image/jpeg'; // JPEG
if (header.length >= 6) {
const sig6 = header.slice(0, 6).toString('ascii');
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return 'image/gif';
}
if (header.length >= 12) {
const riff = header.slice(0, 4).toString('ascii');
const webp = header.slice(8, 12).toString('ascii');
if (riff === 'RIFF' && webp === 'WEBP') return 'image/webp';
}
if (header.length >= 5 && header.slice(0, 5).toString('ascii') === '%PDF-') return 'application/pdf';
if (startsWithBytes(header, [0x50,0x4b,0x03,0x04])) return 'application/zip';
return 'application/octet-stream';
}
function getTaskAttachmentsDir(paths, taskId) {
const id = assertSafePathSegment('taskId', taskId);
return path.join(paths.teamDir, TASK_ATTACHMENTS_DIR, id);
}
function getStoredAttachmentPath(paths, taskId, attachmentId, filename) {
const safeName = sanitizeFilename(filename);
return path.join(getTaskAttachmentsDir(paths, taskId), String(attachmentId) + '--' + safeName);
}
function ensureSourceFileReadable(srcPath) {
const st = fs.statSync(srcPath);
if (!st.isFile()) die('Not a file: ' + String(srcPath));
if (st.size > MAX_TASK_ATTACHMENT_BYTES) {
die(
'Attachment too large: ' +
(st.size / (1024 * 1024)).toFixed(1) +
' MB (max ' +
String(MAX_TASK_ATTACHMENT_BYTES / (1024 * 1024)) +
' MB)'
);
}
return st;
}
function copyOrLinkFile(srcPath, destPath, mode, allowFallback) {
const m = String(mode || 'copy').toLowerCase();
if (m === 'link') {
try {
fs.linkSync(srcPath, destPath);
return { mode: 'link', fallbackUsed: false };
} catch (e) {
if (!allowFallback) throw e;
// Fall back to copy (cross-device link, permissions, etc.)
try {
fs.copyFileSync(srcPath, destPath);
return { mode: 'copy', fallbackUsed: true };
} catch (e2) {
// Bubble up most useful error
throw e2 || e;
}
}
}
fs.copyFileSync(srcPath, destPath);
return { mode: 'copy', fallbackUsed: false };
}
function saveTaskAttachmentFile(paths, taskId, flags) {
const rawFile = (typeof flags.file === 'string' && flags.file.trim())
? flags.file.trim()
: (typeof flags.path === 'string' && flags.path.trim())
? flags.path.trim()
: '';
if (!rawFile) die('Missing --file <path>');
const srcPath = path.resolve(rawFile);
ensureSourceFileReadable(srcPath);
const filename = (typeof flags.filename === 'string' && flags.filename.trim())
? flags.filename.trim()
: path.basename(srcPath);
const mimeType = (typeof flags['mime-type'] === 'string' && flags['mime-type'].trim())
? flags['mime-type'].trim()
: (typeof flags.mimeType === 'string' && flags.mimeType.trim())
? flags.mimeType.trim()
: detectMimeTypeFromPathAndHeader(srcPath, filename);
const attachmentId = makeId();
const dir = getTaskAttachmentsDir(paths, taskId);
ensureDir(dir);
const destPath = getStoredAttachmentPath(paths, taskId, attachmentId, filename);
const allowFallback = !(flags['no-fallback'] === true);
if (fs.existsSync(destPath)) die('Attachment destination already exists');
const result = copyOrLinkFile(srcPath, destPath, flags.mode, allowFallback);
// Verify write/link
const st = fs.statSync(destPath);
if (!st.isFile() || st.size < 0) die('Attachment write verification failed');
const meta = {
id: attachmentId,
filename: filename,
mimeType: mimeType,
size: st.size,
addedAt: nowIso(),
};
return { meta: meta, storedPath: destPath, storageMode: result.mode, fallbackUsed: result.fallbackUsed };
}
function addAttachmentToTask(paths, taskId, meta) {
var lastErr;
for (var attempt = 0; attempt < 8; attempt++) {
try {
const ref = readTask(paths, taskId);
const task = ref.task;
const taskPath = ref.taskPath;
const existing = Array.isArray(task.attachments) ? task.attachments : [];
if (existing.some(function(a) { return a && a.id === meta.id; })) return;
task.attachments = existing.concat([meta]);
writeTask(taskPath, task);
// Verify meta persisted (best-effort)
const verify = readJson(taskPath, null);
if (verify && Array.isArray(verify.attachments) && verify.attachments.some(function(a) { return a && a.id === meta.id; })) {
return;
}
// Verification failed (concurrent overwrite) — retry
} catch (e) {
lastErr = e;
if (attempt === 7) throw e;
}
}
throw lastErr;
}
function addAttachmentToComment(paths, taskId, commentId, meta) {
var lastErr;
for (var attempt = 0; attempt < 8; attempt++) {
try {
const ref = readTask(paths, taskId);
const task = ref.task;
const taskPath = ref.taskPath;
const comments = Array.isArray(task.comments) ? task.comments : [];
const idx = comments.findIndex(function(c) { return c && String(c.id) === String(commentId); });
if (idx < 0) die('Comment not found: ' + String(commentId));
const comment = comments[idx];
const existing = Array.isArray(comment.attachments) ? comment.attachments : [];
if (!existing.some(function(a) { return a && a.id === meta.id; })) {
comment.attachments = existing.concat([meta]);
}
// Persist update (single atomic write)
task.comments = comments;
writeTask(taskPath, task);
// Verify
const verify = readJson(taskPath, null);
if (verify && Array.isArray(verify.comments)) {
const vc = verify.comments.find(function(c) { return c && String(c.id) === String(commentId); });
if (vc && Array.isArray(vc.attachments) && vc.attachments.some(function(a) { return a && a.id === meta.id; })) {
return;
}
}
// Retry on verification failure
} catch (e) {
lastErr = e;
if (attempt === 7) throw e;
}
}
throw lastErr;
}
function normalizeStatus(value) {
const v = String(value || '').trim();
if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v;
return null;
}
function normalizeColumn(value) {
const v = String(value || '').trim();
if (v === 'review' || v === 'approved') return v;
return null;
}
function getPaths(flags, teamName) {
const claudeDir = getClaudeDir(flags);
const safeTeam = assertSafePathSegment('team', teamName);
const teamDir = path.join(claudeDir, 'teams', safeTeam);
const tasksDir = path.join(claudeDir, 'tasks', safeTeam);
const kanbanPath = path.join(teamDir, 'kanban-state.json');
const processesPath = path.join(teamDir, 'processes.json');
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
}
function inferLeadName(paths) {
const config = readJson(path.join(paths.teamDir, 'config.json'), null);
if (!config || !Array.isArray(config.members)) return 'team-lead';
const lead = config.members.find(function (m) {
return m.role && String(m.role).toLowerCase().includes('lead');
});
return lead ? String(lead.name) : (config.members[0] ? String(config.members[0].name) : 'team-lead');
}
function readTask(paths, taskId) {
const taskPath = getTaskJsonPath(paths, taskId);
const task = readJson(taskPath, null);
if (!task) die('Task not found: ' + String(taskId));
return { taskPath, task };
}
function writeTask(taskPath, task) {
atomicWrite(taskPath, JSON.stringify(task, null, 2));
const verify = readJson(taskPath, null);
if (!verify) die('Task write verification failed');
return verify;
}
function applyWorkIntervalsForStatusTransition(task, prevStatus, nextStatus, now) {
var wasInProgress = prevStatus === 'in_progress';
var isInProgress = nextStatus === 'in_progress';
var intervals = Array.isArray(task.workIntervals) ? task.workIntervals.slice() : [];
var last = intervals.length ? intervals[intervals.length - 1] : null;
if (!wasInProgress && isInProgress) {
if (!last || typeof last.completedAt === 'string') {
intervals.push({ startedAt: now });
}
} else if (wasInProgress && !isInProgress) {
// Close the most recent open interval (if any).
for (var i = intervals.length - 1; i >= 0; i--) {
if (intervals[i] && typeof intervals[i].startedAt === 'string' && !intervals[i].completedAt) {
intervals[i].completedAt = now;
break;
}
}
}
if (intervals.length > 0) task.workIntervals = intervals;
else delete task.workIntervals;
}
function appendStatusTransition(task, fromStatus, toStatus, timestamp, actor) {
var entry = { from: fromStatus, to: toStatus, timestamp: timestamp };
if (actor) entry.actor = actor;
var history = Array.isArray(task.statusHistory) ? task.statusHistory.slice() : [];
history.push(entry);
task.statusHistory = history;
}
function setTaskStatus(paths, taskId, status, actor) {
const normalized = normalizeStatus(status);
if (!normalized) die('Invalid status: ' + String(status));
const { taskPath, task } = readTask(paths, taskId);
var prev = task.status;
var now = nowIso();
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
appendStatusTransition(task, prev, normalized, now, actor);
task.status = normalized;
writeTask(taskPath, task);
}
function setTaskOwner(paths, taskId, owner) {
const { taskPath, task } = readTask(paths, taskId);
if (owner) {
task.owner = owner;
} else {
delete task.owner;
}
writeTask(taskPath, task);
return task;
}
function addTaskComment(paths, taskId, flags) {
var text = typeof flags.text === 'string' ? flags.text.trim() : '';
if (!text) die('Missing --text');
var from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
var ref;
var task;
var taskPath;
var commentId;
var comment;
var existing;
var lastErr;
for (var attempt = 0; attempt < 8; attempt++) {
try {
ref = readTask(paths, taskId);
task = ref.task;
taskPath = ref.taskPath;
if (task.needsClarification === 'lead' && from !== task.owner) {
delete task.needsClarification;
}
existing = Array.isArray(task.comments) ? task.comments : [];
commentId = makeId();
comment = {
id: commentId,
author: from,
text: text,
type: 'regular',
createdAt: nowIso(),
};
task.comments = existing.concat([comment]);
writeTask(taskPath, task);
return { commentId: commentId, taskId: String(taskId), subject: task.subject, owner: task.owner };
} catch (e) {
lastErr = e;
if (attempt === 7) throw e;
}
}
throw lastErr;
}
function setNeedsClarification(paths, taskId, value) {
var allowed = { lead: true, user: true, clear: true };
if (!allowed[value]) die('Invalid value: ' + value + '. Use: lead, user, clear');
var ref = readTask(paths, taskId);
if (value === 'clear') {
delete ref.task.needsClarification;
} else {
ref.task.needsClarification = value;
}
writeTask(ref.taskPath, ref.task);
}
function listTaskIds(tasksDir) {
let entries = [];
try {
entries = fs.readdirSync(tasksDir);
} catch (err) {
if (err && err.code === 'ENOENT') return [];
throw err;
}
const ids = [];
for (const file of entries) {
if (!file.endsWith('.json')) continue;
if (file.startsWith('.')) continue;
const num = Number(file.replace(/\.json$/, ''));
if (!Number.isFinite(num)) continue;
ids.push(String(num));
}
ids.sort((a, b) => Number(a) - Number(b));
return ids;
}
function getNextTaskId(paths) {
const ids = listTaskIds(paths.tasksDir);
const maxFromFiles = ids.length ? Number(ids[ids.length - 1]) : 0;
const hwmPath = path.join(paths.tasksDir, '.highwatermark');
const hwmRaw = readJson(hwmPath, null);
const maxFromHwm = typeof hwmRaw === 'number' ? hwmRaw : Number(String(hwmRaw || '0').trim());
const max = Math.max(maxFromFiles, Number.isFinite(maxFromHwm) ? maxFromHwm : 0);
return String(max + 1);
}
function updateHighwatermark(paths, taskId) {
const hwmPath = path.join(paths.tasksDir, '.highwatermark');
atomicWrite(hwmPath, String(taskId));
}
function parseIdList(value) {
if (!value || value === true) return [];
var ids = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
for (var k = 0; k < ids.length; k++) {
if (!/^\d+$/.test(ids[k])) die('Invalid task ID in list: ' + ids[k]);
}
return ids;
}
function taskExists(paths, taskId) {
try {
fs.accessSync(getTaskJsonPath(paths, taskId), fs.constants.F_OK);
return true;
} catch (e) { return false; }
}
function readTaskObject(paths, taskId) {
var taskPath = getTaskJsonPath(paths, taskId);
var t = readJson(taskPath, null);
if (!t) die('Task not found: #' + taskId);
return { task: t, taskPath: taskPath };
}
function wouldCreateBlockCycle(paths, sourceId, targetId) {
var visited = {};
var stack = [String(targetId)];
while (stack.length > 0) {
var current = stack.pop();
if (current === String(sourceId)) return true;
if (visited[current]) continue;
visited[current] = true;
try {
if (!isSafePathSegment(current)) continue;
var t = readJson(getTaskJsonPath(paths, current), null);
if (t && Array.isArray(t.blockedBy)) {
for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i]));
}
} catch (e) { /* skip */ }
}
return false;
}
function linkTasks(paths, taskId, targetId, type) {
var id = String(taskId), target = String(targetId);
if (id === target) die('Cannot link a task to itself');
if (!taskExists(paths, id)) die('Task not found: #' + id);
if (!taskExists(paths, target)) die('Task not found: #' + target);
if (type === 'blocked-by') {
if (wouldCreateBlockCycle(paths, id, target))
die('Circular dependency: #' + target + ' already depends on #' + id);
var refA = readTaskObject(paths, id);
var bb = Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : [];
if (!bb.includes(target)) { refA.task.blockedBy = bb.concat([target]); atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2)); }
var refB = readTaskObject(paths, target);
var bl = Array.isArray(refB.task.blocks) ? refB.task.blocks : [];
if (!bl.includes(id)) { refB.task.blocks = bl.concat([id]); atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2)); }
} else if (type === 'blocks') {
linkTasks(paths, target, id, 'blocked-by');
return;
} else if (type === 'related') {
var rA = readTaskObject(paths, id);
var relA = Array.isArray(rA.task.related) ? rA.task.related : [];
if (!relA.includes(target)) { rA.task.related = relA.concat([target]); atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2)); }
var rB = readTaskObject(paths, target);
var relB = Array.isArray(rB.task.related) ? rB.task.related : [];
if (!relB.includes(id)) { rB.task.related = relB.concat([id]); atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2)); }
}
}
function unlinkTasks(paths, taskId, targetId, type) {
var id = String(taskId), target = String(targetId);
if (!taskExists(paths, id)) die('Task not found: #' + id);
if (type === 'blocked-by') {
var refA = readTaskObject(paths, id);
refA.task.blockedBy = (Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : []).filter(function(x) { return x !== target; });
atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2));
if (taskExists(paths, target)) {
var refB = readTaskObject(paths, target);
refB.task.blocks = (Array.isArray(refB.task.blocks) ? refB.task.blocks : []).filter(function(x) { return x !== id; });
atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2));
}
} else if (type === 'blocks') {
unlinkTasks(paths, target, id, 'blocked-by');
return;
} else if (type === 'related') {
var rA = readTaskObject(paths, id);
rA.task.related = (Array.isArray(rA.task.related) ? rA.task.related : []).filter(function(x) { return x !== target; });
atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2));
if (taskExists(paths, target)) {
var rB = readTaskObject(paths, target);
rB.task.related = (Array.isArray(rB.task.related) ? rB.task.related : []).filter(function(x) { return x !== id; });
atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2));
}
}
}
function createTask(paths, flags) {
const subject = typeof flags.subject === 'string' ? flags.subject.trim() : '';
if (!subject) die('Missing --subject');
const description =
typeof flags.description === 'string'
? flags.description
: typeof flags.desc === 'string'
? flags.desc
: '';
const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined;
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
const activeForm =
typeof flags.activeForm === 'string'
? flags.activeForm
: typeof flags['active-form'] === 'string'
? flags['active-form']
: undefined;
var blockedByIds = parseIdList(flags['blocked-by']);
var relatedIds = parseIdList(flags.related);
// Default status rule:
// - explicit --status always wins
// - tasks with dependencies should start as pending, even if assigned (owner)
// - otherwise, assigned tasks default to in_progress, unassigned to pending
const status =
normalizeStatus(explicitStatus) ||
(blockedByIds.length > 0 ? 'pending' : owner ? 'in_progress' : 'pending');
for (var v = 0; v < blockedByIds.length; v++) { if (!taskExists(paths, blockedByIds[v])) die('Blocked-by task not found: #' + blockedByIds[v]); }
for (var w = 0; w < relatedIds.length; w++) { if (!taskExists(paths, relatedIds[w])) die('Related task not found: #' + relatedIds[w]); }
ensureDir(paths.tasksDir);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
let nextId;
let task;
let taskPath;
while (true) {
nextId = getNextTaskId(paths);
taskPath = getTaskJsonPath(paths, nextId);
var createdAt = nowIso();
task = {
id: nextId,
subject,
description: String(description || subject),
activeForm: activeForm ? String(activeForm) : undefined,
owner,
createdBy: from,
status,
createdAt: createdAt,
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
statusHistory: [{ from: null, to: status, timestamp: createdAt, actor: from }],
blocks: [],
blockedBy: blockedByIds,
related: relatedIds.length > 0 ? relatedIds : undefined,
};
try {
const fd = fs.openSync(taskPath, 'wx');
fs.closeSync(fd);
atomicWrite(taskPath, JSON.stringify(task, null, 2));
const verify = readJson(taskPath, null);
if (!verify) die('Task write verification failed');
break;
} catch (e) {
if (e && e.code === 'EEXIST') continue;
throw e;
}
}
updateHighwatermark(paths, nextId);
// Set reverse links for blockedBy (target.blocks += nextId)
for (var bi = 0; bi < blockedByIds.length; bi++) {
var dep = readTaskObject(paths, blockedByIds[bi]);
var depBl = Array.isArray(dep.task.blocks) ? dep.task.blocks : [];
if (!depBl.includes(nextId)) { dep.task.blocks = depBl.concat([nextId]); atomicWrite(dep.taskPath, JSON.stringify(dep.task, null, 2)); }
}
// Set reverse links for related (bidirectional)
for (var ri = 0; ri < relatedIds.length; ri++) {
var rel = readTaskObject(paths, relatedIds[ri]);
var relL = Array.isArray(rel.task.related) ? rel.task.related : [];
if (!relL.includes(nextId)) { rel.task.related = relL.concat([nextId]); atomicWrite(rel.taskPath, JSON.stringify(rel.task, null, 2)); }
}
return task;
}
function readKanbanState(paths, teamName) {
const fallback = { teamName, reviewers: [], tasks: {} };
const parsed = readJson(paths.kanbanPath, fallback);
if (!parsed || typeof parsed !== 'object') return fallback;
const reviewers = Array.isArray(parsed.reviewers)
? parsed.reviewers.filter((r) => typeof r === 'string' && r.trim())
: [];
const tasks = parsed.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {};
return { teamName, reviewers, tasks };
}
function writeKanbanState(paths, state) {
atomicWrite(paths.kanbanPath, JSON.stringify(state, null, 2));
}
function setKanbanColumn(paths, teamName, taskId, column) {
const normalized = normalizeColumn(column);
if (!normalized) die('Invalid column: ' + String(column));
const state = readKanbanState(paths, teamName);
if (normalized === 'review') {
state.tasks[String(taskId)] = {
column: 'review',
reviewer: null,
movedAt: nowIso(),
};
} else {
state.tasks[String(taskId)] = {
column: 'approved',
movedAt: nowIso(),
};
}
writeKanbanState(paths, state);
}
function clearKanban(paths, teamName, taskId) {
const state = readKanbanState(paths, teamName);
delete state.tasks[String(taskId)];
writeKanbanState(paths, state);
}
function sendInboxMessage(paths, teamName, flags) {
const to = typeof flags.to === 'string' ? flags.to.trim() : '';
if (!to) die('Missing --to');
const text = typeof flags.text === 'string' ? flags.text : '';
if (!text) die('Missing --text');
const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const safeTo = assertSafePathSegment('to', to);
const inboxPath = path.join(paths.teamDir, 'inboxes', safeTo + '.json');
ensureDir(path.dirname(inboxPath));
const messageId = makeId();
const payload = {
from,
to,
text,
timestamp: nowIso(),
read: false,
summary,
messageId,
};
if (flags.source) payload.source = flags.source;
var lastErr;
for (var attempt = 0; attempt < 8; attempt++) {
try {
var existing = readJson(inboxPath, []);
var list = Array.isArray(existing) ? existing : [];
list.push(payload);
atomicWrite(inboxPath, JSON.stringify(list, null, 2));
var verify = readJson(inboxPath, []);
if (Array.isArray(verify) && verify.some(function (m) { return m && m.messageId === messageId; })) {
return { deliveredToInbox: true, messageId: messageId };
}
// Verification failed (concurrent write overwrote us) — retry
} catch (e) {
lastErr = e;
if (attempt === 7) throw e;
}
}
// If all retries exhausted without verification success, die
die('Inbox write verification failed after retries' + (lastErr ? ': ' + formatError(lastErr) : ''));
}
function reviewApprove(paths, teamName, taskId, flags) {
setKanbanColumn(paths, teamName, taskId, 'approved');
const { taskPath, task } = readTask(paths, taskId);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
// Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : [];
var reviewCommentId = makeId();
task.comments = existing.concat([{
id: reviewCommentId,
author: from,
text: note || 'Approved',
type: 'review_approved',
createdAt: nowIso(),
}]);
writeTask(taskPath, task);
const notify = flags.notify === true || flags['notify-owner'] === true;
if (!notify || !task.owner) return;
const inboxText = note
? 'Task #' + String(taskId) + ' approved.\n\n' + note
: 'Task #' + String(taskId) + ' approved.';
sendInboxMessage(paths, teamName, {
to: task.owner,
text: inboxText,
summary: 'Approved #' + String(taskId),
from,
source: 'system_notification',
});
}
function reviewRequestChanges(paths, teamName, taskId, flags) {
const comment = typeof flags.comment === 'string' ? flags.comment.trim() : '';
const { taskPath, task } = readTask(paths, taskId);
if (!task.owner) die('No owner found for task ' + String(taskId));
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
clearKanban(paths, teamName, taskId);
var now = nowIso();
var prevStatus = task.status;
applyWorkIntervalsForStatusTransition(task, prevStatus, 'in_progress', now);
appendStatusTransition(task, prevStatus, 'in_progress', now, from);
task.status = 'in_progress';
// Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : [];
var reviewCommentId = makeId();
task.comments = existing.concat([{
id: reviewCommentId,
author: from,
text: comment || 'Reviewer requested changes.',
type: 'review_request',
createdAt: now,
}]);
writeTask(taskPath, task);
const inboxText =
'Task #' +
String(taskId) +
' needs fixes.\n\n' +
(comment || 'Reviewer requested changes.') +
'\n\n' +
'Please fix and mark it as completed when ready.';
sendInboxMessage(paths, teamName, {
to: task.owner,
text: inboxText,
summary: 'Fix request for #' + String(taskId),
from,
source: 'system_notification',
});
}
function readProcessesSafe(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (err && err.code === 'EPERM') return true;
return false;
}
}
function processRegister(paths, flags) {
const pid = Number(flags.pid);
if (!Number.isInteger(pid) || pid <= 0) die('Invalid --pid (must be > 0)');
const label = typeof flags.label === 'string' ? flags.label.trim() : '';
if (!label) die('Missing --label');
const rawPort = flags.port != null ? Number(flags.port) : undefined;
const port = rawPort != null && Number.isInteger(rawPort) && rawPort >= 1 && rawPort <= 65535 ? rawPort : undefined;
const url = typeof flags.url === 'string' && flags.url.trim() ? flags.url.trim() : undefined;
const claudeProcessId = typeof flags['claude-process-id'] === 'string' ? flags['claude-process-id'].trim() : undefined;
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
const command = typeof flags.command === 'string' ? flags.command.trim() : undefined;
const list = readProcessesSafe(paths.processesPath);
const existingIdx = list.findIndex(function (p) { return p.pid === pid; });
const entry = {
id: existingIdx >= 0 ? list[existingIdx].id : makeId(),
port: port,
url: url,
label: label,
pid: pid,
claudeProcessId: claudeProcessId,
registeredBy: from,
command: command,
registeredAt: existingIdx >= 0 ? list[existingIdx].registeredAt : nowIso(),
};
if (existingIdx >= 0) {
list[existingIdx] = entry;
} else {
list.push(entry);
}
atomicWrite(paths.processesPath, JSON.stringify(list, null, 2));
var portStr = port ? ' port=' + String(port) : '';
process.stdout.write('OK process registered pid=' + String(pid) + portStr + '\n');
}
function processUnregister(paths, flags) {
const list = readProcessesSafe(paths.processesPath);
const pid = flags.pid ? Number(flags.pid) : undefined;
const id = typeof flags.id === 'string' ? flags.id.trim() : undefined;
if (!pid && !id) die('Missing --pid or --id');
const idx = list.findIndex(function (p) {
if (pid) return p.pid === pid;
return p.id === id;
});
if (idx < 0) die('Process not found');
const removed = list.splice(idx, 1)[0];
atomicWrite(paths.processesPath, JSON.stringify(list, null, 2));
process.stdout.write('OK process unregistered pid=' + String(removed.pid) + '\n');
}
function processList(paths) {
const list = readProcessesSafe(paths.processesPath);
const result = list.map(function (p) {
return Object.assign({}, p, { alive: isProcessAlive(p.pid) });
});
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
function taskBriefing(paths, teamName, flags) {
var forMember = typeof flags['for'] === 'string' ? flags['for'].trim() : '';
if (!forMember) die('Missing --for <member-name>');
var kanban = readKanbanState(paths, teamName);
var ids = listTaskIds(paths.tasksDir);
var allTasks = [];
for (var i = 0; i < ids.length; i++) {
try {
if (!isSafePathSegment(ids[i])) continue;
var taskPath = getTaskJsonPath(paths, ids[i]);
var t = readJson(taskPath, null);
if (t && !String(t.id).startsWith('_internal') && !(t.metadata && t.metadata._internal === true)) {
try { t._mtime = fs.statSync(taskPath).mtime.toISOString(); } catch (_e) { t._mtime = ''; }
allTasks.push(t);
}
} catch (e) { /* skip unreadable */ }
}
function getEffectiveColumn(task) {
var ks = kanban.tasks[String(task.id)];
if (ks) return ks.column;
if (task.status === 'pending') return 'todo';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'completed') return 'done';
return task.status;
}
var relevant = allTasks.filter(function (t) {
var col = getEffectiveColumn(t);
return col !== 'approved' && t.status !== 'deleted';
});
var myTasks = { todo: [], in_progress: [], done: [], review: [] };
var otherTasks = { todo: [], in_progress: [], done: [], review: [] };
for (var j = 0; j < relevant.length; j++) {
var task = relevant[j];
var col = getEffectiveColumn(task);
var bucket = (task.owner === forMember) ? myTasks : otherTasks;
if (col === 'todo') bucket.todo.push(task);
else if (col === 'in_progress') bucket.in_progress.push(task);
else if (col === 'done') bucket.done.push(task);
else if (col === 'review') bucket.review.push(task);
}
function sortByMtime(arr) {
return arr.sort(function (a, b) {
var da = a._mtime || '';
var db = b._mtime || '';
return da < db ? 1 : da > db ? -1 : 0;
});
}
myTasks.done = sortByMtime(myTasks.done).slice(0, 15);
otherTasks.done = sortByMtime(otherTasks.done).slice(0, 15);
var lines = [];
lines.push('=== Task Briefing for ' + forMember + ' ===');
lines.push('');
function formatTask(t) {
var parts = [];
parts.push('#' + t.id + ' [' + getEffectiveColumn(t).toUpperCase() + '] ' + t.subject);
if (t.owner) parts.push(' Owner: ' + t.owner);
if (t.description && t.description !== t.subject) {
parts.push(' Description: ' + t.description.slice(0, 500));
}
if (t.blocks && t.blocks.length > 0) {
parts.push(' Blocks: ' + t.blocks.map(function(id) { return '#' + id; }).join(', '));
}
if (t.blockedBy && t.blockedBy.length > 0) {
parts.push(' Blocked by: ' + t.blockedBy.map(function(id) { return '#' + id; }).join(', '));
}
if (t.related && t.related.length > 0) {
parts.push(' Related: ' + t.related.map(function(id) { return '#' + id; }).join(', '));
}
if (t.needsClarification) {
parts.push(' *** NEEDS CLARIFICATION: from ' + t.needsClarification.toUpperCase() + ' ***');
}
if (Array.isArray(t.comments) && t.comments.length > 0) {
parts.push(' Comments (' + t.comments.length + '):');
for (var c = 0; c < t.comments.length; c++) {
var cm = t.comments[c];
var ts = cm.createdAt ? ' (' + cm.createdAt + ')' : '';
parts.push(' [' + (cm.author || '?') + ts + '] ' + (cm.text || '').slice(0, 300));
}
}
return parts.join('\n');
}
function renderSection(label, tasks) {
if (tasks.length === 0) return;
lines.push('--- ' + label + ' (' + tasks.length + ') ---');
for (var k = 0; k < tasks.length; k++) {
lines.push(formatTask(tasks[k]));
lines.push('');
}
}
lines.push('== YOUR TASKS ==');
renderSection('IN PROGRESS', myTasks.in_progress);
renderSection('TODO', myTasks.todo);
renderSection('REVIEW', myTasks.review);
renderSection('DONE (recent)', myTasks.done);
if (myTasks.in_progress.length + myTasks.todo.length + myTasks.review.length + myTasks.done.length === 0) {
lines.push('(no tasks assigned to you)');
lines.push('');
}
lines.push('== TEAM BOARD (others) ==');
renderSection('IN PROGRESS', otherTasks.in_progress);
renderSection('TODO', otherTasks.todo);
renderSection('REVIEW', otherTasks.review);
renderSection('DONE (recent)', otherTasks.done);
if (otherTasks.in_progress.length + otherTasks.todo.length + otherTasks.review.length + otherTasks.done.length === 0) {
lines.push('(no other tasks on the board)');
lines.push('');
}
process.stdout.write(lines.join('\n') + '\n');
}
function printHelp() {
const inferred = inferTeamNameFromScriptPath();
const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : '';
process.stdout.write(
[
'teamctl.js v' + String(TOOL_VERSION) + teamHint,
'',
'Usage:',
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
' node teamctl.js task complete <id> [--team <team>]',
' node teamctl.js task start <id> [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--blocked-by 2,3] [--related 5] [--status ...] [--notify --from "member"] [--team <team>]',
' node teamctl.js task link <id> --blocked-by <targetId> [--team <team>]',
' node teamctl.js task link <id> --blocks <targetId> [--team <team>]',
' node teamctl.js task link <id> --related <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --blocked-by <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --blocks <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --related <targetId> [--team <team>]',
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
' node teamctl.js task attach <id> --file <path> [--mode copy|link] [--filename <name>] [--mime-type <type>] [--no-fallback] [--team <team>]',
' node teamctl.js task comment-attach <id> <commentId> --file <path> [--mode copy|link] [--filename <name>] [--mime-type <type>] [--no-fallback] [--team <team>]',
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
' node teamctl.js task briefing --for <member-name> [--team <team>]',
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
' node teamctl.js kanban clear <id> [--team <team>]',
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
' node teamctl.js review request-changes <id> --comment "..." [--from "member"] [--team <team>]',
' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team <team>]',
' node teamctl.js process register --pid <pid> --label <label> [--port <port>] [--url <url>] [--claude-process-id <id>] [--from <member>] [--command <cmd>] [--team <team>]',
' node teamctl.js process unregister --pid <pid> [--team <team>]',
' node teamctl.js process unregister --id <uuid> [--team <team>]',
' node teamctl.js process list [--team <team>]',
'',
'Options:',
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
' --claude-dir <path> Override ~/.claude location',
' --mode <copy|link> For attachments: copy into storage (default) or try hardlink to avoid duplication',
' --no-fallback For --mode link: fail instead of falling back to copy',
'',
].join('\n')
);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.flags.help || args._.length === 0) {
printHelp();
return;
}
const domain = args._[0];
const action = args._[1];
const rest = args._.slice(2);
const teamName = getTeamName(args.flags);
const paths = getPaths(args.flags, teamName);
var actor = typeof args.flags.from === 'string' && args.flags.from.trim()
? args.flags.from.trim()
: inferLeadName(paths);
if (domain === 'task') {
if (action === 'set-status') {
const id = rest[0] || args.flags.id;
const status = rest[1] || args.flags.status;
if (!id || !status) die('Usage: task set-status <id> <status>');
setTaskStatus(paths, String(id), status, actor);
process.stdout.write('OK task #' + String(id) + ' status=' + String(status) + '\n');
return;
}
if (action === 'complete' || action === 'done') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: task complete <id>');
setTaskStatus(paths, String(id), 'completed', actor);
process.stdout.write('OK task #' + String(id) + ' status=completed\n');
return;
}
if (action === 'start') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: task start <id>');
setTaskStatus(paths, String(id), 'in_progress', actor);
process.stdout.write('OK task #' + String(id) + ' status=in_progress\n');
return;
}
if (action === 'create') {
const task = createTask(paths, args.flags);
const notify = args.flags.notify === true || args.flags['notify-owner'] === true;
if (notify && task.owner) {
const from =
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
// Skip inbox notification when lead assigns a task to themselves (solo teams)
if (task.owner.toLowerCase() !== from.toLowerCase()) {
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
if (rawDesc && rawDesc !== task.subject) {
parts.push('\nDescription:\n' + rawDesc);
}
const prompt = typeof args.flags.prompt === 'string' ? args.flags.prompt.trim() : '';
if (prompt) {
parts.push('\nInstructions:\n' + prompt);
}
parts.push(
'\n' +
wrapAgentBlock(
[
'Update task status using:',
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
].join('\n')
)
);
sendInboxMessage(paths, teamName, {
to: task.owner,
text: parts.join('\n'),
summary: 'New task #' + String(task.id) + ' assigned',
from,
source: 'system_notification',
});
}
}
process.stdout.write(JSON.stringify(task, null, 2) + '\n');
return;
}
if (action === 'get') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: task get <id>');
const { task } = readTask(paths, String(id));
process.stdout.write(JSON.stringify(task, null, 2) + '\n');
return;
}
if (action === 'list') {
const ids = listTaskIds(paths.tasksDir);
const tasks = [];
for (const id of ids) {
try {
tasks.push(readJson(getTaskJsonPath(paths, id), null));
} catch {}
}
process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n');
return;
}
if (action === 'comment') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: task comment <id> --text "..."');
const result = addTaskComment(paths, String(id), args.flags);
const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
// Notify task owner via inbox — but SKIP self-notification to prevent loop
if (result.owner && result.owner !== from) {
try {
sendInboxMessage(paths, teamName, {
to: result.owner,
text: 'Comment on task #' + String(result.taskId) + ' "' + String(result.subject) + '":\n\n' + (typeof args.flags.text === 'string' ? args.flags.text.trim() : ''),
summary: 'Comment on #' + String(result.taskId),
from: from,
source: 'system_notification',
});
} catch (e) { /* best-effort */ }
}
process.stdout.write('OK comment added to task #' + String(id) + '\n');
return;
}
if (action === 'attach') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: task attach <id> --file <path>');
// Save file to storage first, then update task metadata
const saved = saveTaskAttachmentFile(paths, String(id), args.flags);
try {
addAttachmentToTask(paths, String(id), saved.meta);
} catch (e) {
// Best-effort cleanup of orphaned file on failure
try { fs.unlinkSync(saved.storedPath); } catch { /* ignore */ }
throw e;
}
if (saved.fallbackUsed) {
process.stderr.write('WARN: link failed; fell back to copy\n');
}
process.stdout.write(JSON.stringify(saved.meta, null, 2) + '\n');
return;
}
if (action === 'comment-attach') {
const id = rest[0] || args.flags.id;
const commentId = rest[1] || args.flags['comment-id'] || args.flags.commentId;
if (!id || !commentId) die('Usage: task comment-attach <id> <commentId> --file <path>');
const saved = saveTaskAttachmentFile(paths, String(id), args.flags);
try {
addAttachmentToComment(paths, String(id), String(commentId), saved.meta);
} catch (e) {
// Best-effort cleanup of orphaned file on failure
try { fs.unlinkSync(saved.storedPath); } catch { /* ignore */ }
throw e;
}
if (saved.fallbackUsed) {
process.stderr.write('WARN: link failed; fell back to copy\n');
}
process.stdout.write(JSON.stringify(saved.meta, null, 2) + '\n');
return;
}
if (action === 'set-clarification') {
const id = rest[0] || args.flags.id;
const val = rest[1] || args.flags.value;
if (!id || !val) die('Usage: task set-clarification <id> <lead|user|clear>');
setNeedsClarification(paths, String(id), String(val));
process.stdout.write('OK task #' + String(id) + ' needsClarification=' + (val === 'clear' ? 'cleared' : String(val)) + '\n');
return;
}
if (action === 'set-owner' || action === 'assign') {
const id = rest[0] || args.flags.id;
const owner = rest[1] || args.flags.owner;
if (!id) die('Usage: task set-owner <id> <member|clear>');
if (!owner) die('Usage: task set-owner <id> <member|clear>');
const effectiveOwner = owner === 'clear' || owner === 'none' ? null : String(owner);
const task = setTaskOwner(paths, String(id), effectiveOwner);
process.stdout.write('OK task #' + String(id) + ' owner=' + (effectiveOwner || 'cleared') + '\n');
const notify = args.flags.notify === true;
if (notify && effectiveOwner) {
const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
const parts = ['Task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
if (task.description && task.description !== task.subject) {
parts.push('\nDescription:\n' + String(task.description).slice(0, 500));
}
parts.push(
'\n' +
wrapAgentBlock(
[
'Update task status using:',
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
].join('\n')
)
);
sendInboxMessage(paths, teamName, {
to: effectiveOwner,
text: parts.join('\n'),
summary: 'Task #' + String(task.id) + ' assigned',
from,
source: 'system_notification',
});
}
return;
}
if (action === 'briefing') {
taskBriefing(paths, teamName, args.flags);
return;
}
if (action === 'link') {
var linkId = rest[0] || args.flags.id;
if (!linkId) die('Usage: task link <id> --blocked-by|--blocks|--related <targetId>');
var linkBbF = args.flags['blocked-by'], linkBlF = args.flags.blocks, linkRelF = args.flags.related;
var linkCnt = (linkBbF ? 1 : 0) + (linkBlF ? 1 : 0) + (linkRelF ? 1 : 0);
if (linkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related');
var linkTp = linkBbF ? 'blocked-by' : linkBlF ? 'blocks' : 'related';
var linkTv = linkBbF || linkBlF || linkRelF;
linkTasks(paths, String(linkId), String(linkTv), linkTp);
process.stdout.write('OK task #' + linkId + ' ' + linkTp + ' #' + linkTv + '\n');
return;
}
if (action === 'unlink') {
var unlinkId = rest[0] || args.flags.id;
if (!unlinkId) die('Usage: task unlink <id> --blocked-by|--blocks|--related <targetId>');
var unlinkBbF = args.flags['blocked-by'], unlinkBlF = args.flags.blocks, unlinkRelF = args.flags.related;
var unlinkCnt = (unlinkBbF ? 1 : 0) + (unlinkBlF ? 1 : 0) + (unlinkRelF ? 1 : 0);
if (unlinkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related');
var unlinkTp = unlinkBbF ? 'blocked-by' : unlinkBlF ? 'blocks' : 'related';
var unlinkTv = unlinkBbF || unlinkBlF || unlinkRelF;
unlinkTasks(paths, String(unlinkId), String(unlinkTv), unlinkTp);
process.stdout.write('OK task #' + unlinkId + ' unlinked ' + unlinkTp + ' #' + unlinkTv + '\n');
return;
}
die('Unknown task action: ' + String(action));
}
if (domain === 'kanban') {
if (action === 'set-column') {
const id = rest[0] || args.flags.id;
const column = rest[1] || args.flags.column;
if (!id || !column) die('Usage: kanban set-column <id> <review|approved>');
setKanbanColumn(paths, teamName, String(id), column);
process.stdout.write(
'OK kanban #' + String(id) + ' column=' + String(column) + '\n'
);
return;
}
if (action === 'clear' || action === 'remove') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: kanban clear <id>');
clearKanban(paths, teamName, String(id));
process.stdout.write('OK kanban #' + String(id) + ' cleared\n');
return;
}
if (action === 'reviewers') {
const sub = rest[0];
const name = rest[1];
const state = readKanbanState(paths, teamName);
if (sub === 'list') {
process.stdout.write(JSON.stringify(state.reviewers, null, 2) + '\n');
return;
}
if ((sub === 'add' || sub === 'remove') && (!name || !String(name).trim())) {
die('Usage: kanban reviewers add|remove <name>');
}
const trimmed = String(name || '').trim();
const before = new Set(state.reviewers);
if (sub === 'add') before.add(trimmed);
else if (sub === 'remove') before.delete(trimmed);
else die('Usage: kanban reviewers list|add|remove ...');
state.reviewers = [...before];
writeKanbanState(paths, state);
process.stdout.write('OK reviewers ' + String(sub) + '\n');
return;
}
die('Unknown kanban action: ' + String(action));
}
if (domain === 'review') {
if (action === 'approve') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: review approve <id>');
reviewApprove(paths, teamName, String(id), args.flags);
process.stdout.write('OK review #' + String(id) + ' approved\n');
return;
}
if (action === 'request-changes') {
const id = rest[0] || args.flags.id;
if (!id) die('Usage: review request-changes <id> --comment "..."');
reviewRequestChanges(paths, teamName, String(id), args.flags);
process.stdout.write('OK review #' + String(id) + ' requested changes\n');
return;
}
die('Unknown review action: ' + String(action));
}
if (domain === 'message') {
if (action === 'send') {
// Strip source from agent flags — only internal callers may set it
var msgFlags = Object.assign({}, args.flags);
delete msgFlags.source;
const result = sendInboxMessage(paths, teamName, msgFlags);
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return;
}
die('Unknown message action: ' + String(action));
}
if (domain === 'process') {
if (action === 'register') {
processRegister(paths, args.flags);
return;
}
if (action === 'unregister' || action === 'remove') {
processUnregister(paths, args.flags);
return;
}
if (action === 'list') {
processList(paths);
return;
}
die('Unknown process action: ' + String(action));
}
die('Unknown domain: ' + String(domain) + '. Available domains: task, kanban, review, message, process. Run with --help for usage.');
}
const legacyTeamctl = {
parseArgs,
getPaths,
inferLeadName,
readTask,
listTaskIds,
createTask,
setTaskStatus,
setTaskOwner,
addTaskComment,
saveTaskAttachmentFile,
addAttachmentToTask,
addAttachmentToComment,
setNeedsClarification,
linkTasks,
unlinkTasks,
taskBriefing,
readKanbanState,
writeKanbanState,
setKanbanColumn,
clearKanban,
sendInboxMessage,
reviewApprove,
reviewRequestChanges,
readProcessesSafe,
isProcessAlive,
processRegister,
processUnregister,
processList,
printHelp,
main,
};
if (require.main === module) {
main().catch((err) => {
die(formatError(err));
});
} else {
module.exports = legacyTeamctl;
}