From 7a7e2e1f120a3fcb73e4cc690ec19b2ace600376 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 8 Mar 2026 22:50:57 +0200 Subject: [PATCH] refactor: remove legacy teamctl CLI support and update package description - Removed the legacy teamctl CLI integration, including the associated source code and references in the main module. - Updated the package description to reflect the current functionality without legacy support. - Cleaned up build scripts by removing unnecessary executable permissions and legacy file handling. - Adjusted tests and documentation to remove references to the deprecated CLI. --- agent-teams-controller/package.json | 5 +- agent-teams-controller/scripts/build.mjs | 8 +- agent-teams-controller/src/cli.js | 4 - agent-teams-controller/src/index.js | 12 - .../src/legacy/teamctl.cli.js | 1572 ----------------- .../test/legacyTeamctl.test.js | 13 - .../diff-view/phase-1-read-only-diff.md | 2 +- .../diff-view/phase-3-per-task-scoping.md | 12 +- docs/research/diff-view-research.md | 8 +- docs/research/markdown-rendering-pipeline.md | 6 +- .../components/team/activity/ActivityItem.tsx | 2 +- .../team/dialogs/TaskCommentsSection.tsx | 4 +- 12 files changed, 19 insertions(+), 1629 deletions(-) delete mode 100755 agent-teams-controller/src/cli.js delete mode 100644 agent-teams-controller/src/legacy/teamctl.cli.js delete mode 100644 agent-teams-controller/test/legacyTeamctl.test.js diff --git a/agent-teams-controller/package.json b/agent-teams-controller/package.json index bbeec560..ce27fce1 100644 --- a/agent-teams-controller/package.json +++ b/agent-teams-controller/package.json @@ -2,12 +2,9 @@ "name": "agent-teams-controller", "version": "1.0.0", "private": true, - "description": "Controller package for Claude agent teams operations and legacy teamctl CLI compatibility", + "description": "Controller package for Claude agent teams operations", "type": "commonjs", "main": "src/index.js", - "bin": { - "teamctl": "src/cli.js" - }, "files": [ "dist" ], diff --git a/agent-teams-controller/scripts/build.mjs b/agent-teams-controller/scripts/build.mjs index 3ad516b3..bf2ce410 100644 --- a/agent-teams-controller/scripts/build.mjs +++ b/agent-teams-controller/scripts/build.mjs @@ -1,4 +1,4 @@ -import { chmod, copyFile, mkdir, readdir, rm, stat } from 'node:fs/promises'; +import { copyFile, mkdir, readdir, rm } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -29,9 +29,3 @@ async function copyRecursive(sourceDir, targetDir) { await rm(distDir, { recursive: true, force: true }); await mkdir(distDir, { recursive: true }); await copyRecursive(srcDir, distDir); - -for (const executablePath of ['cli.js', path.join('legacy', 'teamctl.cli.js')]) { - const absPath = path.join(distDir, executablePath); - const info = await stat(absPath); - await chmod(absPath, info.mode | 0o111); -} diff --git a/agent-teams-controller/src/cli.js b/agent-teams-controller/src/cli.js deleted file mode 100755 index 3c5f147d..00000000 --- a/agent-teams-controller/src/cli.js +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -require('./legacy/teamctl.cli.js'); diff --git a/agent-teams-controller/src/index.js b/agent-teams-controller/src/index.js index 069fc028..464aade2 100644 --- a/agent-teams-controller/src/index.js +++ b/agent-teams-controller/src/index.js @@ -1,17 +1,5 @@ -const fs = require('fs'); -const path = require('path'); const controller = require('./controller.js'); -function getLegacyTeamctlCliPath() { - return path.join(__dirname, 'legacy', 'teamctl.cli.js'); -} - -function readLegacyTeamctlCliSource() { - return fs.readFileSync(getLegacyTeamctlCliPath(), 'utf8'); -} - module.exports = { ...controller, - getLegacyTeamctlCliPath, - readLegacyTeamctlCliSource, }; diff --git a/agent-teams-controller/src/legacy/teamctl.cli.js b/agent-teams-controller/src/legacy/teamctl.cli.js deleted file mode 100644 index 29dc925a..00000000 --- a/agent-teams-controller/src/legacy/teamctl.cli.js +++ /dev/null @@ -1,1572 +0,0 @@ -#!/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 = ''; - -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: /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 '); - - 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 '); - - 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 [--team ]', - ' node teamctl.js task complete [--team ]', - ' node teamctl.js task start [--team ]', - ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--blocked-by 2,3] [--related 5] [--status ...] [--notify --from "member"] [--team ]', - ' node teamctl.js task link --blocked-by [--team ]', - ' node teamctl.js task link --blocks [--team ]', - ' node teamctl.js task link --related [--team ]', - ' node teamctl.js task unlink --blocked-by [--team ]', - ' node teamctl.js task unlink --blocks [--team ]', - ' node teamctl.js task unlink --related [--team ]', - ' node teamctl.js task set-owner [--notify --from "member"] [--team ]', - ' node teamctl.js task comment --text "..." [--from "member"] [--team ]', - ' node teamctl.js task attach --file [--mode copy|link] [--filename ] [--mime-type ] [--no-fallback] [--team ]', - ' node teamctl.js task comment-attach --file [--mode copy|link] [--filename ] [--mime-type ] [--no-fallback] [--team ]', - ' node teamctl.js task set-clarification [--from "member"] [--team ]', - ' node teamctl.js task briefing --for [--team ]', - ' node teamctl.js kanban set-column [--team ]', - ' node teamctl.js kanban clear [--team ]', - ' node teamctl.js review approve [--notify-owner --from "member" --note "..."] [--team ]', - ' node teamctl.js review request-changes --comment "..." [--from "member"] [--team ]', - ' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team ]', - ' node teamctl.js process register --pid --label