chati-dev 4.1.5 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI runner for Agent Team execution (Article XXI).
4
+ *
5
+ * Called by the orchestrator via the Bash tool to spawn a team of agents
6
+ * that communicate via a shared task list and mailbox.
7
+ *
8
+ * Usage:
9
+ * node run-team.js --team-id TM-20260411-pln \
10
+ * --team-type planning \
11
+ * --project-dir /path/to/project \
12
+ * --previous-agent brief \
13
+ * --provider claude \
14
+ * --timeout 1800000
15
+ *
16
+ * Outputs consolidated JSON to stdout for the orchestrator to parse.
17
+ * Same output shape as run-parallel.js + team_id + echo_events.
18
+ */
19
+
20
+ import { fileURLToPath } from 'url';
21
+ import { existsSync, readFileSync } from 'fs';
22
+ import { join } from 'path';
23
+ import { buildAgentPrompt } from './prompt-builder.js';
24
+ import { spawnParallelGroup, spawnTerminal } from './spawner.js';
25
+ import { TerminalMonitor } from './monitor.js';
26
+ import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './collector.js';
27
+ import { parseAgentOutput } from './handoff-parser.js';
28
+ import { estimateTokens, COST_PER_1K } from './cost-tracker.js';
29
+ import { getRateLimiter } from './rate-limiter.js';
30
+ import {
31
+ readTaskList, getTeamProgress, writeMailboxMessage, computeEchoSimilarity,
32
+ } from './team-task-list.js';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Constants
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const TEAM_MEMBERS = {
39
+ planning: ['detail', 'architect', 'ux'],
40
+ build: ['dev', 'qa-implementation'],
41
+ };
42
+
43
+ // Task IDs match the Shared Task List template (team-planning-tasks.yaml / team-build-tasks.yaml)
44
+ const TEAM_TASK_IDS = {
45
+ planning: ['TT-PLN-001', 'TT-PLN-002', 'TT-PLN-003'],
46
+ build: ['TT-BLD-001-DEV', 'TT-BLD-001-QA'],
47
+ };
48
+
49
+ // Team-level echo threshold: compares task OUTPUT similarity between consecutive
50
+ // outputs from the same agent. Higher threshold (0.92) because task outputs are
51
+ // structurally similar by nature — only near-identical outputs indicate a stuck loop.
52
+ // This is DISTINCT from the QA-level echo threshold (0.85 per Article XXII §4)
53
+ // which compares DEFECT DESCRIPTIONS against the Decision Trail.
54
+ const ECHO_THRESHOLD = 0.92;
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // CLI argument parsing
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function parseArgs(argv) {
61
+ const args = {};
62
+ for (let i = 2; i < argv.length; i++) {
63
+ const arg = argv[i];
64
+ if (arg.startsWith('--')) {
65
+ const key = arg.slice(2);
66
+ const next = argv[i + 1];
67
+ if (next && !next.startsWith('--')) {
68
+ args[key] = next;
69
+ i++;
70
+ } else {
71
+ args[key] = 'true';
72
+ }
73
+ }
74
+ }
75
+ return args;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Main
80
+ // ---------------------------------------------------------------------------
81
+
82
+ async function main() {
83
+ const args = parseArgs(process.argv);
84
+
85
+ const teamId = args['team-id'];
86
+ const teamType = args['team-type'];
87
+ const projectDir = args['project-dir'] || process.cwd();
88
+ const previousAgent = args['previous-agent'] || null;
89
+ const provider = args.provider || 'claude';
90
+ const timeout = parseInt(args.timeout, 10) || 1_800_000; // default 30 minutes
91
+
92
+ if (!teamId || !teamType) {
93
+ outputError('Missing required arguments: --team-id and --team-type');
94
+ process.exit(1);
95
+ }
96
+
97
+ const members = TEAM_MEMBERS[teamType];
98
+ const taskIds = TEAM_TASK_IDS[teamType];
99
+
100
+ if (!members) {
101
+ outputError(`Unknown team type: ${teamType}. Valid: planning, build`);
102
+ process.exit(1);
103
+ }
104
+
105
+ const teamDir = join(projectDir, '.chati', 'teams', teamId);
106
+ const mailboxDir = join(teamDir, 'mailbox');
107
+ const taskListPath = join(teamDir, 'tasks.yaml');
108
+
109
+ // Load session state (minimal parse)
110
+ let sessionState = {};
111
+ try {
112
+ const sessionPath = join(projectDir, '.chati', 'session.yaml');
113
+ if (existsSync(sessionPath)) {
114
+ const raw = readFileSync(sessionPath, 'utf-8');
115
+ for (const line of raw.split('\n')) {
116
+ const m = line.trim().match(/^([\w_-]+):\s*(.+)/);
117
+ if (m) sessionState[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
118
+ }
119
+ }
120
+ } catch { /* optional */ }
121
+
122
+ // Build prompts for all team members
123
+ const configs = [];
124
+ const startTime = Date.now();
125
+
126
+ for (let i = 0; i < members.length; i++) {
127
+ try {
128
+ const promptResult = buildAgentPrompt({
129
+ agent: members[i],
130
+ taskId: taskIds[i],
131
+ projectDir,
132
+ previousAgent,
133
+ sessionState,
134
+ provider,
135
+ });
136
+
137
+ configs.push({
138
+ agent: members[i],
139
+ taskId: taskIds[i],
140
+ model: promptResult.model,
141
+ provider: promptResult.provider,
142
+ prompt: promptResult.prompt,
143
+ workingDir: projectDir,
144
+ timeout,
145
+ // Team-specific env vars injected into the spawned process
146
+ contextPayload: {
147
+ CHATI_TEAM_ID: teamId,
148
+ CHATI_TEAM_MEMBER: members[i],
149
+ CHATI_TEAM_TASK_LIST: taskListPath,
150
+ CHATI_TEAM_MAILBOX: mailboxDir,
151
+ },
152
+ });
153
+ } catch (err) {
154
+ outputError(`Failed to build prompt for ${members[i]}: ${err.message}`);
155
+ process.exit(1);
156
+ }
157
+ }
158
+
159
+ // Check rate limit
160
+ const limiter = getRateLimiter(provider);
161
+ const rateStats = limiter.getStats();
162
+ const availableSlots = rateStats.limit - rateStats.used;
163
+ if (availableSlots < configs.length) {
164
+ console.error(`[chati] Rate limiter: ${availableSlots} slots for ${configs.length} team members. May throttle.`);
165
+ }
166
+
167
+ // Spawn team members
168
+ let group;
169
+ let fallbackUsed = false;
170
+ const echoEvents = [];
171
+
172
+ try {
173
+ group = spawnParallelGroup(configs);
174
+ } catch (err) {
175
+ console.error(`[chati] Team spawn failed: ${err.message}. Signaling fallback.`);
176
+ process.stdout.write(JSON.stringify({
177
+ status: 'spawn_failed',
178
+ fallback_required: true,
179
+ error: err.message,
180
+ team_id: teamId,
181
+ }, null, 2) + '\n');
182
+ process.exit(1);
183
+ }
184
+
185
+ // Monitor until all members complete
186
+ const monitor = new TerminalMonitor({ pollInterval: 2000, timeout });
187
+
188
+ for (const terminal of group.terminals) {
189
+ monitor.addTerminal(terminal);
190
+ }
191
+
192
+ await new Promise((resolve) => {
193
+ const safetyTimer = setTimeout(() => {
194
+ monitor.stopMonitoring();
195
+ resolve();
196
+ }, timeout + 10_000);
197
+
198
+ monitor.onComplete(() => {
199
+ clearTimeout(safetyTimer);
200
+ monitor.stopMonitoring();
201
+ resolve();
202
+ });
203
+
204
+ monitor.startMonitoring();
205
+ });
206
+
207
+ const elapsed = Date.now() - startTime;
208
+
209
+ // Echo detection: compare task outputs for cyclical patterns
210
+ try {
211
+ const taskList = readTaskList(taskListPath);
212
+ if (taskList && taskList.tasks) {
213
+ const completedTasks = taskList.tasks.filter(t => t.status === 'done');
214
+ for (let i = 1; i < completedTasks.length; i++) {
215
+ for (let j = 0; j < i; j++) {
216
+ const t1 = completedTasks[i];
217
+ const t2 = completedTasks[j];
218
+ if (t1.assigned_to === t2.assigned_to) {
219
+ const desc1 = JSON.stringify(t1);
220
+ const desc2 = JSON.stringify(t2);
221
+ const similarity = computeEchoSimilarity(desc1, desc2);
222
+ if (similarity >= ECHO_THRESHOLD) {
223
+ echoEvents.push({
224
+ detected_at: new Date().toISOString(),
225
+ member: t1.assigned_to,
226
+ similarity: Math.round(similarity * 100) / 100,
227
+ action_taken: 'echo_logged',
228
+ });
229
+ // Notify member via mailbox
230
+ writeMailboxMessage(mailboxDir, 'orchestrator', t1.assigned_to, 'echo_detected', {
231
+ similarity,
232
+ message: `You have submitted nearly identical outputs. Apply Article XX: diagnose root cause, change approach, do not repeat previous attempt.`,
233
+ });
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+ } catch { /* echo detection is non-critical */ }
240
+
241
+ // Cost estimation
242
+ const costEstimates = configs.map((cfg, i) => {
243
+ const terminal = group.terminals[i];
244
+ const inputTokens = estimateTokens(cfg.prompt || '');
245
+ const outputTokens = estimateTokens((terminal?.stdout || []).join(''));
246
+ const modelKey = cfg.model || 'sonnet';
247
+ const rate = COST_PER_1K[modelKey] || COST_PER_1K.sonnet || 0.015;
248
+ return {
249
+ agent: cfg.agent,
250
+ model: modelKey,
251
+ provider: cfg.provider || 'claude',
252
+ inputTokens,
253
+ outputTokens,
254
+ estimatedCost: ((inputTokens + outputTokens) / 1000) * rate,
255
+ };
256
+ });
257
+
258
+ // Collect and merge results
259
+ const rawResults = collectResults(group.groupId, group.terminals);
260
+
261
+ const agentResults = rawResults.results.map(r => {
262
+ const parsed = parseAgentOutput(r.stdout);
263
+ return {
264
+ ...r,
265
+ handoff: parsed.found ? parsed.handoff : null,
266
+ handoffFound: parsed.found,
267
+ };
268
+ });
269
+
270
+ const mergeInput = agentResults.map(r => ({
271
+ agent: r.agent,
272
+ status: r.handoff?.status || (r.exitCode === 0 ? 'complete' : 'failed'),
273
+ outputs: r.handoff?.outputs || [],
274
+ decisions: r.handoff?.decisions || {},
275
+ blockers: r.handoff?.blockers || [],
276
+ summary: r.handoff?.summary || '',
277
+ }));
278
+
279
+ const merged = mergeHandoffs(mergeInput);
280
+ const nextAgent = determineNextAgent(teamType);
281
+ const consolidated = buildConsolidatedHandoff(merged, nextAgent);
282
+
283
+ // Team progress
284
+ const progress = getTeamProgress(taskListPath);
285
+
286
+ // Output consolidated result
287
+ const output = {
288
+ status: rawResults.summary.failed === 0 ? 'complete' : 'partial',
289
+ team_id: teamId,
290
+ team_type: teamType,
291
+ groupId: group.groupId,
292
+ agents: members.map((a, i) => ({
293
+ agent: a,
294
+ model: configs[i].model,
295
+ status: agentResults[i]?.handoff?.status || (agentResults[i]?.exitCode === 0 ? 'complete' : 'failed'),
296
+ score: agentResults[i]?.handoff?.score || null,
297
+ exitCode: agentResults[i]?.exitCode,
298
+ handoffFound: agentResults[i]?.handoffFound,
299
+ })),
300
+ mergedHandoff: consolidated,
301
+ team_progress: progress,
302
+ echo_events: echoEvents,
303
+ summary: rawResults.summary,
304
+ elapsed,
305
+ performance: {
306
+ sequentialEstimate: elapsed * members.length,
307
+ parallelActual: elapsed,
308
+ timeSaved: elapsed * (members.length - 1),
309
+ },
310
+ costEstimate: costEstimates,
311
+ fallbackUsed,
312
+ };
313
+
314
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
315
+ process.exit(rawResults.summary.failed > 0 ? 1 : 0);
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Helpers
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /**
323
+ * Determine the next sequential agent after a team completes.
324
+ * Planning Team → phases
325
+ * Build Team → devops
326
+ */
327
+ function determineNextAgent(teamType) {
328
+ if (teamType === 'planning') return 'phases';
329
+ if (teamType === 'build') return 'devops';
330
+ return null;
331
+ }
332
+
333
+ function outputError(message) {
334
+ process.stdout.write(JSON.stringify({
335
+ status: 'error',
336
+ error: message,
337
+ fallback_required: true,
338
+ }) + '\n');
339
+ }
340
+
341
+ // Guard pattern
342
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
343
+ main().catch(err => {
344
+ outputError(err.message);
345
+ process.exit(1);
346
+ });
347
+ }
348
+
349
+ export { parseArgs, determineNextAgent };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @fileoverview Team Task List and Mailbox utilities.
3
+ *
4
+ * Pure utility functions for managing shared task lists and
5
+ * inter-agent mailbox communication within Agent Teams (Article XXI).
6
+ *
7
+ * Uses file-lock.js for atomic writes to prevent race conditions
8
+ * when multiple team members update concurrently.
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
12
+ import { join, dirname } from 'path';
13
+ import yaml from 'js-yaml';
14
+ import { withLock } from '../utils/file-lock.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Task List Management
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Initialize a team task list from a template.
22
+ * @param {string} destPath - Destination path for the task list YAML
23
+ * @param {string} templatePath - Source template path
24
+ * @param {object} overrides - Optional field overrides (team_id, etc.)
25
+ * @returns {{ created: boolean, path: string }}
26
+ */
27
+ export function initTaskList(destPath, templatePath, overrides = {}) {
28
+ mkdirSync(dirname(destPath), { recursive: true });
29
+
30
+ if (!existsSync(templatePath)) {
31
+ throw new Error(`Team task template not found: ${templatePath}`);
32
+ }
33
+
34
+ const template = yaml.load(readFileSync(templatePath, 'utf-8'));
35
+ const taskList = {
36
+ ...template,
37
+ ...overrides,
38
+ created_at: new Date().toISOString(),
39
+ last_updated: new Date().toISOString(),
40
+ };
41
+
42
+ writeFileSync(destPath, yaml.dump(taskList, { lineWidth: -1, noRefs: true }), 'utf-8');
43
+ return { created: true, path: destPath };
44
+ }
45
+
46
+ /**
47
+ * Read and parse a team task list.
48
+ * @param {string} path - Path to task list YAML
49
+ * @returns {object|null} Parsed task list or null if not found
50
+ */
51
+ export function readTaskList(path) {
52
+ if (!existsSync(path)) return null;
53
+ return yaml.load(readFileSync(path, 'utf-8'));
54
+ }
55
+
56
+ /**
57
+ * Update a task's status atomically using file locking.
58
+ * @param {string} path - Path to task list YAML
59
+ * @param {string} taskId - Task ID to update
60
+ * @param {object} updates - Fields to merge (status, score, etc.)
61
+ * @returns {Promise<{ updated: boolean, task: object|null }>}
62
+ */
63
+ export async function updateTaskStatus(path, taskId, updates) {
64
+ return withLock(path, () => {
65
+ const taskList = readTaskList(path);
66
+ if (!taskList || !taskList.tasks) return { updated: false, task: null };
67
+
68
+ const task = taskList.tasks.find(t => t.id === taskId);
69
+ if (!task) return { updated: false, task: null };
70
+
71
+ Object.assign(task, updates, { last_updated: new Date().toISOString() });
72
+ if (updates.status === 'done' && !task.completed_at) {
73
+ task.completed_at = new Date().toISOString();
74
+ }
75
+
76
+ taskList.last_updated = new Date().toISOString();
77
+ writeFileSync(path, yaml.dump(taskList, { lineWidth: -1, noRefs: true }), 'utf-8');
78
+ return { updated: true, task };
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Get team progress summary from task list.
84
+ * @param {string} path - Path to task list YAML
85
+ * @returns {{ total: number, done: number, pending: number, blocked: number, inProgress: number, memberProgress: object }}
86
+ */
87
+ export function getTeamProgress(path) {
88
+ const taskList = readTaskList(path);
89
+ if (!taskList || !taskList.tasks) {
90
+ return { total: 0, done: 0, pending: 0, blocked: 0, inProgress: 0, memberProgress: {} };
91
+ }
92
+
93
+ const tasks = taskList.tasks;
94
+ const memberProgress = {};
95
+
96
+ for (const task of tasks) {
97
+ const assignee = task.assigned_to;
98
+ if (!assignee) continue;
99
+ if (!memberProgress[assignee]) {
100
+ memberProgress[assignee] = { status: 'pending', score: null, tasks: 0, completed: 0 };
101
+ }
102
+ memberProgress[assignee].tasks++;
103
+ if (task.status === 'done') {
104
+ memberProgress[assignee].completed++;
105
+ memberProgress[assignee].score = task.score;
106
+ } else if (task.status === 'in_progress') {
107
+ memberProgress[assignee].status = 'in_progress';
108
+ }
109
+ }
110
+
111
+ // Post-loop: derive correct status for each member
112
+ for (const member of Object.values(memberProgress)) {
113
+ if (member.completed === member.tasks && member.tasks > 0) {
114
+ member.status = 'done';
115
+ } else if (member.completed > 0 && member.completed < member.tasks) {
116
+ member.status = 'in_progress';
117
+ }
118
+ }
119
+
120
+ return {
121
+ total: tasks.length,
122
+ done: tasks.filter(t => t.status === 'done').length,
123
+ pending: tasks.filter(t => t.status === 'pending').length,
124
+ blocked: tasks.filter(t => t.status === 'blocked').length,
125
+ inProgress: tasks.filter(t => t.status === 'in_progress').length,
126
+ memberProgress,
127
+ };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Mailbox Management
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Write a message to the team mailbox.
136
+ * Messages are JSON files named {timestamp}-{from}-{type}.json
137
+ * @param {string} mailboxDir - Path to team mailbox directory
138
+ * @param {string} from - Sender agent name
139
+ * @param {string} to - Recipient agent name (or 'all' for broadcast)
140
+ * @param {string} type - Message type (cross_review_request, task_ready_for_review, etc.)
141
+ * @param {object} payload - Message content
142
+ * @returns {{ written: boolean, path: string }}
143
+ */
144
+ export function writeMailboxMessage(mailboxDir, from, to, type, payload) {
145
+ mkdirSync(mailboxDir, { recursive: true });
146
+
147
+ const timestamp = Date.now();
148
+ const filename = `${timestamp}-${from}-${type}.json`;
149
+ const messagePath = join(mailboxDir, filename);
150
+
151
+ const message = {
152
+ id: `MSG-${timestamp}-${from.slice(0, 3)}`,
153
+ from,
154
+ to,
155
+ type,
156
+ timestamp: new Date().toISOString(),
157
+ payload,
158
+ read: false,
159
+ };
160
+
161
+ writeFileSync(messagePath, JSON.stringify(message, null, 2), 'utf-8');
162
+ return { written: true, path: messagePath };
163
+ }
164
+
165
+ /**
166
+ * Read all inbox messages for a specific agent.
167
+ * @param {string} mailboxDir - Path to team mailbox directory
168
+ * @param {string} member - Agent name to read messages for
169
+ * @returns {object[]} Array of message objects addressed to this member (or 'all')
170
+ */
171
+ export function readInbox(mailboxDir, member) {
172
+ if (!existsSync(mailboxDir)) return [];
173
+
174
+ const files = readdirSync(mailboxDir).filter(f => f.endsWith('.json')).sort();
175
+ const messages = [];
176
+
177
+ for (const file of files) {
178
+ try {
179
+ const msg = JSON.parse(readFileSync(join(mailboxDir, file), 'utf-8'));
180
+ if (msg.to === member || msg.to === 'all') {
181
+ messages.push(msg);
182
+ }
183
+ } catch { /* skip malformed messages */ }
184
+ }
185
+
186
+ return messages;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Echo Detection
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Compute trigram-based Jaccard similarity between two texts.
195
+ * Used for Echo Detection (Article XXII) to detect cyclical defects.
196
+ * @param {string} text1 - First text (normalized)
197
+ * @param {string} text2 - Second text (normalized)
198
+ * @returns {number} Similarity ratio 0.0 - 1.0
199
+ */
200
+ export function computeEchoSimilarity(text1, text2) {
201
+ if (!text1 || !text2) return 0;
202
+
203
+ const normalize = (t) => t.toLowerCase().replace(/\s+/g, ' ').trim();
204
+ const trigrams = (t) => {
205
+ const n = normalize(t);
206
+ const set = new Set();
207
+ for (let i = 0; i <= n.length - 3; i++) {
208
+ set.add(n.slice(i, i + 3));
209
+ }
210
+ return set;
211
+ };
212
+
213
+ const a = trigrams(text1);
214
+ const b = trigrams(text2);
215
+
216
+ if (a.size === 0 && b.size === 0) return 1;
217
+ if (a.size === 0 || b.size === 0) return 0;
218
+
219
+ let intersection = 0;
220
+ for (const gram of a) {
221
+ if (b.has(gram)) intersection++;
222
+ }
223
+
224
+ const union = a.size + b.size - intersection;
225
+ return union === 0 ? 0 : intersection / union;
226
+ }