diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef32e3ea..23bee75d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [main] paths: - 'src/**' + - 'agent-teams-controller/**' + - 'mcp-server/**' - 'test/**' - 'package.json' - 'pnpm-lock.yaml' @@ -17,6 +19,8 @@ on: branches: [main] paths: - 'src/**' + - 'agent-teams-controller/**' + - 'mcp-server/**' - 'test/**' - 'package.json' - 'pnpm-lock.yaml' @@ -55,13 +59,13 @@ jobs: eslint-${{ runner.os }}- - name: Typecheck - run: pnpm typecheck + run: pnpm typecheck:workspace - name: Lint run: pnpm lint - name: Build - run: pnpm build + run: pnpm build:workspace test: strategy: @@ -87,4 +91,4 @@ jobs: run: pnpm install --no-frozen-lockfile - name: Test - run: pnpm test + run: pnpm test:workspace diff --git a/agent-teams-controller/.gitignore b/agent-teams-controller/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/agent-teams-controller/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/agent-teams-controller/package.json b/agent-teams-controller/package.json new file mode 100644 index 00000000..bbeec560 --- /dev/null +++ b/agent-teams-controller/package.json @@ -0,0 +1,22 @@ +{ + "name": "agent-teams-controller", + "version": "1.0.0", + "private": true, + "description": "Controller package for Claude agent teams operations and legacy teamctl CLI compatibility", + "type": "commonjs", + "main": "src/index.js", + "bin": { + "teamctl": "src/cli.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "node ./scripts/build.mjs", + "test": "vitest run --config vitest.config.js", + "test:watch": "vitest --config vitest.config.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/agent-teams-controller/scripts/build.mjs b/agent-teams-controller/scripts/build.mjs new file mode 100644 index 00000000..3ad516b3 --- /dev/null +++ b/agent-teams-controller/scripts/build.mjs @@ -0,0 +1,37 @@ +import { chmod, copyFile, mkdir, readdir, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(__dirname, '..'); +const srcDir = path.join(packageRoot, 'src'); +const distDir = path.join(packageRoot, 'dist'); + +async function copyRecursive(sourceDir, targetDir) { + await mkdir(targetDir, { recursive: true }); + const entries = await readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await copyRecursive(sourcePath, targetPath); + continue; + } + + if (entry.isFile()) { + await copyFile(sourcePath, targetPath); + } + } +} + +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 new file mode 100644 index 00000000..3c5f147d --- /dev/null +++ b/agent-teams-controller/src/cli.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +'use strict'; + +require('./legacy/teamctl.cli.js'); diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js new file mode 100644 index 00000000..9e701255 --- /dev/null +++ b/agent-teams-controller/src/controller.js @@ -0,0 +1,38 @@ +const { createControllerContext } = require('./internal/context.js'); +const tasks = require('./internal/tasks.js'); +const kanban = require('./internal/kanban.js'); +const review = require('./internal/review.js'); +const messages = require('./internal/messages.js'); +const processes = require('./internal/processes.js'); + +function bindModule(context, moduleApi) { + return Object.fromEntries( + Object.entries(moduleApi).map(([name, fn]) => [ + name, + (...args) => fn(context, ...args), + ]) + ); +} + +function createController(options) { + const context = createControllerContext(options); + + return { + context, + tasks: bindModule(context, tasks), + kanban: bindModule(context, kanban), + review: bindModule(context, review), + messages: bindModule(context, messages), + processes: bindModule(context, processes), + }; +} + +module.exports = { + createController, + createControllerContext, + tasks, + kanban, + review, + messages, + processes, +}; diff --git a/agent-teams-controller/src/index.js b/agent-teams-controller/src/index.js new file mode 100644 index 00000000..069fc028 --- /dev/null +++ b/agent-teams-controller/src/index.js @@ -0,0 +1,17 @@ +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/internal/capture.js b/agent-teams-controller/src/internal/capture.js new file mode 100644 index 00000000..ecbfd597 --- /dev/null +++ b/agent-teams-controller/src/internal/capture.js @@ -0,0 +1,31 @@ +function captureStreamOutput(stream, fn) { + let output = ''; + const originalWrite = stream.write.bind(stream); + + stream.write = ((chunk, encoding, callback) => { + output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(encoding || 'utf8'); + if (typeof callback === 'function') { + callback(); + } + return true; + }); + + try { + const result = fn(); + if (result && typeof result.then === 'function') { + return result.finally(() => { + stream.write = originalWrite; + }).then((value) => ({ value, output })); + } + + stream.write = originalWrite; + return { value: result, output }; + } catch (error) { + stream.write = originalWrite; + throw error; + } +} + +module.exports = { + captureStreamOutput, +}; diff --git a/agent-teams-controller/src/internal/context.js b/agent-teams-controller/src/internal/context.js new file mode 100644 index 00000000..94e720fe --- /dev/null +++ b/agent-teams-controller/src/internal/context.js @@ -0,0 +1,24 @@ +const legacy = require('../legacy/teamctl.cli.js'); + +function createControllerContext(options = {}) { + const teamName = String(options.teamName || '').trim(); + if (!teamName) { + throw new Error('Missing teamName'); + } + + const flags = {}; + if (typeof options.claudeDir === 'string' && options.claudeDir.trim()) { + flags['claude-dir'] = options.claudeDir.trim(); + } + + const paths = legacy.getPaths(flags, teamName); + return { + teamName, + claudeDir: paths.claudeDir, + paths, + }; +} + +module.exports = { + createControllerContext, +}; diff --git a/agent-teams-controller/src/internal/kanban.js b/agent-teams-controller/src/internal/kanban.js new file mode 100644 index 00000000..073b3a73 --- /dev/null +++ b/agent-teams-controller/src/internal/kanban.js @@ -0,0 +1,49 @@ +const legacy = require('../legacy/teamctl.cli.js'); + +function getKanbanState(context) { + return legacy.readKanbanState(context.paths, context.teamName); +} + +function setKanbanColumn(context, taskId, column) { + legacy.setKanbanColumn(context.paths, context.teamName, String(taskId), String(column)); + return getKanbanState(context); +} + +function clearKanban(context, taskId) { + legacy.clearKanban(context.paths, context.teamName, String(taskId)); + return getKanbanState(context); +} + +function listReviewers(context) { + return getKanbanState(context).reviewers; +} + +function addReviewer(context, reviewer) { + const state = getKanbanState(context); + const next = new Set(state.reviewers); + next.add(String(reviewer)); + legacy.writeKanbanState(context.paths, { + ...state, + reviewers: [...next], + }); + return listReviewers(context); +} + +function removeReviewer(context, reviewer) { + const state = getKanbanState(context); + const next = state.reviewers.filter((entry) => entry !== reviewer); + legacy.writeKanbanState(context.paths, { + ...state, + reviewers: next, + }); + return listReviewers(context); +} + +module.exports = { + getKanbanState, + setKanbanColumn, + clearKanban, + listReviewers, + addReviewer, + removeReviewer, +}; diff --git a/agent-teams-controller/src/internal/messages.js b/agent-teams-controller/src/internal/messages.js new file mode 100644 index 00000000..f13d94ee --- /dev/null +++ b/agent-teams-controller/src/internal/messages.js @@ -0,0 +1,9 @@ +const legacy = require('../legacy/teamctl.cli.js'); + +function sendMessage(context, flags) { + return legacy.sendInboxMessage(context.paths, context.teamName, flags); +} + +module.exports = { + sendMessage, +}; diff --git a/agent-teams-controller/src/internal/processes.js b/agent-teams-controller/src/internal/processes.js new file mode 100644 index 00000000..c33d4611 --- /dev/null +++ b/agent-teams-controller/src/internal/processes.js @@ -0,0 +1,27 @@ +const legacy = require('../legacy/teamctl.cli.js'); +const { captureStreamOutput } = require('./capture.js'); + +function registerProcess(context, flags) { + captureStreamOutput(process.stdout, () => legacy.processRegister(context.paths, flags)); + return listProcesses(context).find((entry) => entry.pid === Number(flags.pid)) || null; +} + +function unregisterProcess(context, flags) { + captureStreamOutput(process.stdout, () => legacy.processUnregister(context.paths, flags)); + return listProcesses(context); +} + +function listProcesses(context) { + return legacy.readProcessesSafe(context.paths.processesPath).map((entry) => ({ + ...entry, + alive: Number.isFinite(Number(entry && entry.pid)) + ? legacy.isProcessAlive(Number(entry.pid)) + : false, + })); +} + +module.exports = { + registerProcess, + unregisterProcess, + listProcesses, +}; diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js new file mode 100644 index 00000000..dce440b9 --- /dev/null +++ b/agent-teams-controller/src/internal/review.js @@ -0,0 +1,17 @@ +const legacy = require('../legacy/teamctl.cli.js'); +const tasks = require('./tasks.js'); + +function approveReview(context, taskId, flags = {}) { + legacy.reviewApprove(context.paths, context.teamName, String(taskId), flags); + return tasks.getTask(context, taskId); +} + +function requestChanges(context, taskId, flags = {}) { + legacy.reviewRequestChanges(context.paths, context.teamName, String(taskId), flags); + return tasks.getTask(context, taskId); +} + +module.exports = { + approveReview, + requestChanges, +}; diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js new file mode 100644 index 00000000..8a847851 --- /dev/null +++ b/agent-teams-controller/src/internal/tasks.js @@ -0,0 +1,95 @@ +const legacy = require('../legacy/teamctl.cli.js'); +const { captureStreamOutput } = require('./capture.js'); + +function createTask(context, flags) { + return legacy.createTask(context.paths, flags); +} + +function getTask(context, taskId) { + return legacy.readTask(context.paths, String(taskId)).task; +} + +function listTasks(context) { + return legacy.listTaskIds(context.paths.tasksDir).map((taskId) => getTask(context, taskId)); +} + +function setTaskStatus(context, taskId, status, actor) { + legacy.setTaskStatus(context.paths, String(taskId), String(status), actor); + return getTask(context, taskId); +} + +function startTask(context, taskId, actor) { + return setTaskStatus(context, taskId, 'in_progress', actor); +} + +function completeTask(context, taskId, actor) { + return setTaskStatus(context, taskId, 'completed', actor); +} + +function setTaskOwner(context, taskId, owner) { + return legacy.setTaskOwner( + context.paths, + String(taskId), + owner == null || owner === 'clear' || owner === 'none' ? null : String(owner) + ); +} + +function addTaskComment(context, taskId, flags) { + const result = legacy.addTaskComment(context.paths, String(taskId), flags); + return { + ...result, + task: getTask(context, taskId), + }; +} + +function attachTaskFile(context, taskId, flags) { + const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags); + legacy.addAttachmentToTask(context.paths, String(taskId), saved.meta); + return saved.meta; +} + +function attachCommentFile(context, taskId, commentId, flags) { + const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags); + legacy.addAttachmentToComment(context.paths, String(taskId), String(commentId), saved.meta); + return saved.meta; +} + +function setNeedsClarification(context, taskId, value) { + const normalized = value == null ? 'clear' : String(value); + legacy.setNeedsClarification(context.paths, String(taskId), normalized); + return getTask(context, taskId); +} + +function linkTask(context, taskId, targetId, linkType) { + legacy.linkTasks(context.paths, String(taskId), String(targetId), String(linkType)); + return getTask(context, taskId); +} + +function unlinkTask(context, taskId, targetId, linkType) { + legacy.unlinkTasks(context.paths, String(taskId), String(targetId), String(linkType)); + return getTask(context, taskId); +} + +async function taskBriefing(context, memberName) { + const { output } = await captureStreamOutput(process.stdout, () => + legacy.taskBriefing(context.paths, context.teamName, { for: memberName }) + ); + return output; +} + +module.exports = { + createTask, + getTask, + listTasks, + setTaskStatus, + startTask, + completeTask, + setTaskOwner, + addTaskComment, + attachTaskFile, + attachCommentFile, + setNeedsClarification, + linkTask, + unlinkTask, + taskBriefing, +}; diff --git a/agent-teams-controller/src/legacy/teamctl.cli.js b/agent-teams-controller/src/legacy/teamctl.cli.js new file mode 100644 index 00000000..95efa6f8 --- /dev/null +++ b/agent-teams-controller/src/legacy/teamctl.cli.js @@ -0,0 +1,1557 @@ +#!/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'; + +function nowIso() { + return new Date().toISOString(); +} + +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