import { getToolsBasePath } from '@main/utils/pathDecoder'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import * as fs from 'fs'; import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; const TOOL_FILE_NAME = 'teamctl.js'; const TOOL_VERSION = 11; function buildTeamCtlScript(): string { const script = String.raw`#!/usr/bin/env node 'use strict'; // Team tools (v${TOOL_VERSION}) const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const TOOL_VERSION = ${TOOL_VERSION}; function nowIso() { return new Date().toISOString(); } 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 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() { return process.env.HOME || process.env.USERPROFILE || ''; } 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 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 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'); fs.renameSync(tmp, filePath); } 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 teamDir = path.join(claudeDir, 'teams', teamName); const tasksDir = path.join(claudeDir, 'tasks', teamName); 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 = path.join(paths.tasksDir, String(taskId) + '.json'); 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 setTaskStatus(paths, taskId, status) { const normalized = normalizeStatus(status); if (!normalized) die('Invalid status: ' + String(status)); const { taskPath, task } = readTask(paths, taskId); 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() : 'agent'; 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 = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random()); comment = { id: commentId, author: from, text: text, 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 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 status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending'); const activeForm = typeof flags.activeForm === 'string' ? flags.activeForm : typeof flags['active-form'] === 'string' ? flags['active-form'] : undefined; 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 = path.join(paths.tasksDir, String(nextId) + '.json'); task = { id: nextId, subject, description: String(description || subject), activeForm: activeForm ? String(activeForm) : undefined, owner, createdBy: from, status, blocks: [], blockedBy: [], }; 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); 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 inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json'); ensureDir(path.dirname(inboxPath)); const messageId = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random()); const payload = { from, text, timestamp: nowIso(), read: false, summary, messageId, }; 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 notify = flags.notify === true || flags['notify-owner'] === true; if (!notify) return; const { task } = readTask(paths, taskId); if (!task.owner) return; const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); const note = typeof flags.note === 'string' ? flags.note.trim() : ''; const text = note ? 'Task #' + String(taskId) + ' approved.\n\n' + note : 'Task #' + String(taskId) + ' approved.'; sendInboxMessage(paths, teamName, { to: task.owner, text, summary: 'Approved #' + String(taskId), from, }); } 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)); clearKanban(paths, teamName, taskId); task.status = 'in_progress'; writeTask(taskPath, task); const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); const text = '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, summary: 'Fix request for #' + String(taskId), from, }); } 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 : (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random())), 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 { var taskPath = path.join(paths.tasksDir, ids[i] + '.json'); 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.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"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team ]', ' node teamctl.js task set-owner [--notify --from "member"] [--team ]', ' node teamctl.js task comment --text "..." [--from "member"] [--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