agentxchain 2.93.0 → 2.95.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.
@@ -61,6 +61,7 @@ import { superviseCommand } from '../src/commands/supervise.js';
61
61
  import { validateCommand } from '../src/commands/validate.js';
62
62
  import { verifyExportCommand, verifyProtocolCommand, verifyTurnCommand } from '../src/commands/verify.js';
63
63
  import { replayTurnCommand } from '../src/commands/replay.js';
64
+ import { replayExportCommand } from '../src/commands/replay-export.js';
64
65
  import { kickoffCommand } from '../src/commands/kickoff.js';
65
66
  import { rebindCommand } from '../src/commands/rebind.js';
66
67
  import { branchCommand } from '../src/commands/branch.js';
@@ -410,6 +411,14 @@ replayCmd
410
411
  .option('--timeout <ms>', 'Per-command replay timeout in milliseconds', '30000')
411
412
  .action(replayTurnCommand);
412
413
 
414
+ replayCmd
415
+ .command('export <export-file>')
416
+ .description('Browse a completed export in the dashboard for offline post-mortem analysis')
417
+ .option('-j, --json', 'Output session info as JSON')
418
+ .option('--port <port>', 'Dashboard port', '3847')
419
+ .option('--no-open', 'Do not auto-open browser')
420
+ .action(replayExportCommand);
421
+
413
422
  program
414
423
  .command('migrate')
415
424
  .description('Migrate a legacy v3 project to governed format')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.93.0",
3
+ "version": "2.95.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,166 @@
1
+ /**
2
+ * CLI command: agentxchain replay export <export-file>
3
+ *
4
+ * Starts the dashboard bridge-server serving a completed export's state
5
+ * for offline post-mortem analysis. The dashboard is fully read-only:
6
+ * no file watcher, no gate approval, no WebSocket push.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
10
+ import { dirname, join, resolve } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import { tmpdir } from 'os';
13
+ import { randomBytes } from 'crypto';
14
+ import chalk from 'chalk';
15
+
16
+ import { createBridgeServer } from '../lib/dashboard/bridge-server.js';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+
20
+ export async function replayExportCommand(exportFile, opts = {}) {
21
+ if (!exportFile) {
22
+ console.error(chalk.red('Usage: agentxchain replay export <export-file>'));
23
+ process.exit(2);
24
+ }
25
+
26
+ const exportPath = resolve(exportFile);
27
+ if (!existsSync(exportPath)) {
28
+ console.error(chalk.red(`Export file not found: ${exportPath}`));
29
+ process.exit(2);
30
+ }
31
+
32
+ let exportData;
33
+ try {
34
+ exportData = JSON.parse(readFileSync(exportPath, 'utf8'));
35
+ } catch (err) {
36
+ console.error(chalk.red(`Failed to parse export file: ${err.message}`));
37
+ process.exit(2);
38
+ }
39
+
40
+ if (!exportData.files || typeof exportData.files !== 'object') {
41
+ console.error(chalk.red('Export file missing "files" object. Not a valid agentxchain export.'));
42
+ process.exit(2);
43
+ }
44
+
45
+ // Create temp workspace with exported files
46
+ const tempId = randomBytes(8).toString('hex');
47
+ const tempRoot = join(tmpdir(), `agentxchain-replay-${tempId}`);
48
+ const tempAgentxchainDir = join(tempRoot, '.agentxchain');
49
+
50
+ try {
51
+ mkdirSync(tempRoot, { recursive: true });
52
+ mkdirSync(tempAgentxchainDir, { recursive: true });
53
+
54
+ // Write all embedded files from the export
55
+ let fileCount = 0;
56
+ for (const [relPath, content] of Object.entries(exportData.files)) {
57
+ const absPath = join(tempRoot, relPath);
58
+ mkdirSync(dirname(absPath), { recursive: true });
59
+ writeFileSync(absPath, typeof content === 'string' ? content : JSON.stringify(content, null, 2));
60
+ fileCount++;
61
+ }
62
+
63
+ // Ensure agentxchain.json exists (needed by some dashboard endpoints)
64
+ const configPath = join(tempRoot, 'agentxchain.json');
65
+ if (!existsSync(configPath)) {
66
+ // Synthesize a minimal config from export summary
67
+ const minimalConfig = {
68
+ protocol_version: exportData.summary?.protocol_version || 6,
69
+ protocol_mode: 'governed',
70
+ version: 4,
71
+ project: { name: exportData.summary?.project_name || 'replay-export' },
72
+ roles: exportData.summary?.roles || {},
73
+ runtimes: {},
74
+ workflow: exportData.summary?.workflow || {},
75
+ };
76
+ writeFileSync(configPath, JSON.stringify(minimalConfig, null, 2));
77
+ }
78
+
79
+ const dashboardDir = join(__dirname, '..', '..', 'dashboard');
80
+ if (!existsSync(dashboardDir)) {
81
+ console.error(chalk.red('Dashboard assets not found.'));
82
+ cleanup(tempRoot);
83
+ process.exit(1);
84
+ }
85
+
86
+ const port = parseInt(opts.port, 10) || 3847;
87
+ const bridge = createBridgeServer({
88
+ agentxchainDir: tempAgentxchainDir,
89
+ dashboardDir,
90
+ port,
91
+ replayMode: true,
92
+ });
93
+
94
+ const { port: actualPort } = await bridge.start();
95
+ const url = `http://localhost:${actualPort}`;
96
+
97
+ const runId = exportData.summary?.run_id || null;
98
+ const schemaVersion = exportData.schema_version || null;
99
+
100
+ if (opts.json) {
101
+ console.log(JSON.stringify({
102
+ port: actualPort,
103
+ url,
104
+ export_file: exportPath,
105
+ run_id: runId,
106
+ export_schema_version: schemaVersion,
107
+ files_restored: fileCount,
108
+ temp_dir: tempRoot,
109
+ }, null, 2));
110
+ } else {
111
+ console.log('');
112
+ console.log(chalk.bold(` Replay Export Dashboard`));
113
+ console.log(chalk.dim(' ' + '─'.repeat(40)));
114
+ console.log(` ${chalk.dim('Export:')} ${exportPath}`);
115
+ console.log(` ${chalk.dim('Run ID:')} ${runId || '—'}`);
116
+ console.log(` ${chalk.dim('Schema:')} ${schemaVersion || '—'}`);
117
+ console.log(` ${chalk.dim('Files:')} ${fileCount} restored`);
118
+ console.log(` ${chalk.dim('URL:')} ${chalk.cyan(url)}`);
119
+ console.log('');
120
+ console.log(chalk.dim(' Read-only mode — no live updates, no gate approval.'));
121
+ console.log(chalk.dim(' Press Ctrl+C to stop.'));
122
+ console.log('');
123
+ }
124
+
125
+ if (opts.open !== false && !opts.json) {
126
+ try {
127
+ const { exec } = await import('child_process');
128
+ const openCmd = process.platform === 'darwin' ? 'open'
129
+ : process.platform === 'win32' ? 'start'
130
+ : 'xdg-open';
131
+ exec(`${openCmd} ${url}`);
132
+ } catch {
133
+ // Browser open is best-effort
134
+ }
135
+ }
136
+
137
+ let shuttingDown = false;
138
+ const shutdown = async () => {
139
+ if (shuttingDown) return;
140
+ shuttingDown = true;
141
+ if (!opts.json) {
142
+ console.log('\nShutting down replay dashboard...');
143
+ }
144
+ await bridge.stop();
145
+ cleanup(tempRoot);
146
+ process.exit(0);
147
+ };
148
+ process.on('SIGINT', shutdown);
149
+ process.on('SIGTERM', shutdown);
150
+ } catch (err) {
151
+ cleanup(tempRoot);
152
+ if (err.code === 'EADDRINUSE') {
153
+ console.error(chalk.red(`Port ${opts.port || 3847} is already in use. Try --port <number>.`));
154
+ process.exit(1);
155
+ }
156
+ throw err;
157
+ }
158
+ }
159
+
160
+ function cleanup(tempRoot) {
161
+ try {
162
+ rmSync(tempRoot, { recursive: true, force: true });
163
+ } catch {
164
+ // Best-effort cleanup
165
+ }
166
+ }
@@ -16,9 +16,11 @@ import { readFileSync, existsSync } from 'fs';
16
16
  import { join, extname, resolve, sep } from 'path';
17
17
  import { readResource } from './state-reader.js';
18
18
  import { FileWatcher } from './file-watcher.js';
19
+ import { readRunEvents, RUN_EVENTS_PATH } from '../run-events.js';
19
20
  import { approvePendingDashboardGate } from './actions.js';
20
21
  import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
21
22
  import { readCoordinatorTimeoutStatus } from './coordinator-timeout-status.js';
23
+ import { readAggregatedCoordinatorEvents, watchChildRepoEvents } from './coordinator-event-aggregation.js';
22
24
  import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
23
25
  import { readConnectorHealthSnapshot } from './connectors.js';
24
26
  import { readTimeoutStatus } from './timeout-status.js';
@@ -213,11 +215,22 @@ function resolveDashboardAssetPath(dashboardDir, pathname) {
213
215
 
214
216
  // ── Bridge Server ───────────────────────────────────────────────────────────
215
217
 
216
- export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }) {
218
+ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847, replayMode = false }) {
217
219
  const workspacePath = resolve(agentxchainDir, '..');
218
220
  const wsClients = new Set();
221
+ /** @type {Map<import('net').Socket, Set<string>|null>} null = all events */
222
+ const wsEventSubscriptions = new Map();
219
223
  const watcher = new FileWatcher(agentxchainDir);
220
224
  const mutationToken = randomBytes(24).toString('hex');
225
+ let lastEventsFileSize = 0;
226
+
227
+ // Initialize events file size tracking
228
+ try {
229
+ const eventsPath = join(agentxchainDir, 'events.jsonl');
230
+ if (existsSync(eventsPath)) {
231
+ lastEventsFileSize = readFileSync(eventsPath).length;
232
+ }
233
+ } catch {}
221
234
 
222
235
  // Broadcast invalidation events to all connected WebSocket clients
223
236
  watcher.on('invalidate', ({ resource }) => {
@@ -225,8 +238,53 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
225
238
  for (const socket of wsClients) {
226
239
  sendWsFrame(socket, msg);
227
240
  }
241
+
242
+ // For events.jsonl changes, also push actual event data
243
+ if (resource === '/api/events') {
244
+ try {
245
+ const eventsPath = join(agentxchainDir, 'events.jsonl');
246
+ if (!existsSync(eventsPath)) return;
247
+ const content = readFileSync(eventsPath, 'utf8');
248
+ if (content.length <= lastEventsFileSize) {
249
+ // File was truncated — reset and push all
250
+ if (content.length < lastEventsFileSize) lastEventsFileSize = 0;
251
+ else return;
252
+ }
253
+ const newContent = content.slice(lastEventsFileSize);
254
+ lastEventsFileSize = content.length;
255
+ const lines = newContent.split('\n').filter(Boolean);
256
+ for (const line of lines) {
257
+ try {
258
+ const evt = JSON.parse(line);
259
+ for (const socket of wsClients) {
260
+ const filter = wsEventSubscriptions.get(socket);
261
+ if (filter && !filter.has(evt.event_type)) continue;
262
+ sendWsFrame(socket, JSON.stringify({ type: 'event', event: evt }));
263
+ }
264
+ } catch {}
265
+ }
266
+ } catch {}
267
+ }
228
268
  });
229
269
 
270
+ // Set up child-repo event watchers for coordinator event aggregation
271
+ let childRepoWatcher = null;
272
+ try {
273
+ const watchResult = watchChildRepoEvents(workspacePath, (_repoId, newEvents) => {
274
+ for (const evt of newEvents) {
275
+ const msg = JSON.stringify({ type: 'coordinator_event', repo_id: evt.repo_id, event: evt });
276
+ for (const socket of wsClients) {
277
+ const filter = wsEventSubscriptions.get(socket);
278
+ if (filter && !filter.has('coordinator_event')) continue;
279
+ sendWsFrame(socket, msg);
280
+ }
281
+ }
282
+ });
283
+ if (watchResult.ok) {
284
+ childRepoWatcher = watchResult;
285
+ }
286
+ } catch {}
287
+
230
288
  const server = createServer(async (req, res) => {
231
289
  const method = req.method || 'GET';
232
290
  const isApproveGateRequest = method === 'POST' && req.url && new URL(req.url, `http://${req.headers.host}`).pathname === '/api/actions/approve-gate';
@@ -247,15 +305,21 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
247
305
  if (pathname === '/api/session') {
248
306
  writeJson(res, 200, {
249
307
  session_version: '1',
250
- mutation_token: mutationToken,
308
+ mutation_token: replayMode ? null : mutationToken,
309
+ replay_mode: replayMode,
251
310
  capabilities: {
252
- approve_gate: true,
311
+ approve_gate: !replayMode,
253
312
  },
254
313
  });
255
314
  return;
256
315
  }
257
316
 
258
317
  if (pathname === '/api/actions/approve-gate') {
318
+ if (replayMode) {
319
+ writeJson(res, 403, { ok: false, code: 'replay_mode', error: 'Replay mode: gate approval is not available on exported snapshots.' });
320
+ return;
321
+ }
322
+
259
323
  if (method !== 'POST') {
260
324
  writeJson(res, 405, { ok: false, code: 'method_not_allowed', error: 'Use POST for dashboard actions.' }, { Allow: 'POST' });
261
325
  return;
@@ -290,6 +354,24 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
290
354
  return;
291
355
  }
292
356
 
357
+ if (pathname === '/api/coordinator/events') {
358
+ const type = url.searchParams.get('type') || undefined;
359
+ const since = url.searchParams.get('since') || undefined;
360
+ const repoId = url.searchParams.get('repo_id') || undefined;
361
+ const limitParam = url.searchParams.get('limit');
362
+ const limit = limitParam != null ? parseInt(limitParam, 10) : 100;
363
+ const result = readAggregatedCoordinatorEvents(workspacePath, {
364
+ type, since, repo_id: repoId, limit: limit === 0 ? undefined : limit,
365
+ });
366
+ if (!result.ok) {
367
+ const isMissingConfig = typeof result.error === 'string' && result.error.includes('config_missing:');
368
+ writeJson(res, isMissingConfig ? 404 : 500, { error: result.error });
369
+ return;
370
+ }
371
+ writeJson(res, 200, result.events);
372
+ return;
373
+ }
374
+
293
375
  if (pathname === '/api/workflow-kit-artifacts') {
294
376
  const result = readWorkflowKitArtifacts(workspacePath);
295
377
  writeJson(res, result.status, result.body);
@@ -308,6 +390,20 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
308
390
  return;
309
391
  }
310
392
 
393
+ if (pathname === '/api/events') {
394
+ const type = url.searchParams.get('type') || undefined;
395
+ const since = url.searchParams.get('since') || undefined;
396
+ const runId = url.searchParams.get('run_id') || undefined;
397
+ const limitParam = url.searchParams.get('limit');
398
+ const limit = limitParam != null ? parseInt(limitParam, 10) : 50;
399
+ let events = readRunEvents(workspacePath, { type, since, limit: limit === 0 ? undefined : limit });
400
+ if (runId) {
401
+ events = events.filter(e => e.run_id === runId);
402
+ }
403
+ writeJson(res, 200, events);
404
+ return;
405
+ }
406
+
311
407
  if (pathname === '/api/run-history') {
312
408
  const url = new URL(req.url, `http://${req.headers.host}`);
313
409
  const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
@@ -365,11 +461,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
365
461
  if (!ws) return;
366
462
 
367
463
  wsClients.add(ws);
464
+ wsEventSubscriptions.set(ws, null); // null = all events
368
465
 
369
- ws.on('close', () => wsClients.delete(ws));
370
- ws.on('error', () => wsClients.delete(ws));
466
+ ws.on('close', () => { wsClients.delete(ws); wsEventSubscriptions.delete(ws); });
467
+ ws.on('error', () => { wsClients.delete(ws); wsEventSubscriptions.delete(ws); });
371
468
 
372
- // Handle incoming frames (for ping/pong and close detection)
469
+ // Handle incoming frames (for ping/pong, close detection, and subscribe)
373
470
  ws.on('data', (data) => {
374
471
  const frame = parseClientFrame(data);
375
472
  if (!frame) return;
@@ -377,15 +474,30 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
377
474
  if (frame.opcode === 0x08) {
378
475
  // Close frame
379
476
  wsClients.delete(ws);
477
+ wsEventSubscriptions.delete(ws);
380
478
  sendWsControlFrame(ws, 0x08, frame.payload);
381
479
  try { ws.end(); } catch {}
382
480
  } else if (frame.opcode === 0x09) {
383
481
  // Ping → Pong
384
482
  sendWsControlFrame(ws, 0x0a, frame.payload);
385
483
  } else if (frame.opcode === 0x01) {
484
+ // Text frame — check for subscribe message
485
+ try {
486
+ const msg = JSON.parse(frame.payload.toString('utf8'));
487
+ if (msg.type === 'subscribe' && Array.isArray(msg.event_types)) {
488
+ wsEventSubscriptions.set(ws, new Set(msg.event_types));
489
+ sendWsFrame(ws, JSON.stringify({ type: 'subscribed', event_types: msg.event_types }));
490
+ return;
491
+ }
492
+ if (msg.type === 'subscribe' && !msg.event_types) {
493
+ wsEventSubscriptions.set(ws, null); // reset to all
494
+ sendWsFrame(ws, JSON.stringify({ type: 'subscribed', event_types: null }));
495
+ return;
496
+ }
497
+ } catch {}
386
498
  sendWsError(
387
499
  ws,
388
- 'Dashboard WebSocket is read-only. Use the authenticated HTTP approve-gate action instead.'
500
+ 'Dashboard WebSocket is read-only except for event subscription. Use the authenticated HTTP approve-gate action for mutations.'
389
501
  );
390
502
  }
391
503
  });
@@ -404,6 +516,7 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
404
516
  function stop() {
405
517
  return new Promise((resolve) => {
406
518
  watcher.stop();
519
+ if (childRepoWatcher?.stop) childRepoWatcher.stop();
407
520
  for (const socket of wsClients) {
408
521
  try { socket.destroy(); } catch {}
409
522
  }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Coordinator event aggregation — merges lifecycle events from all child
3
+ * repos in a multi-repo coordinator run into a single time-ordered stream.
4
+ *
5
+ * See: .planning/COORDINATOR_EVENT_AGGREGATION_SPEC.md
6
+ */
7
+
8
+ import { existsSync, readFileSync, watchFile, unwatchFile, statSync } from 'fs';
9
+ import { join, resolve } from 'path';
10
+ import { loadCoordinatorConfig } from '../coordinator-config.js';
11
+ import { RUN_EVENTS_PATH } from '../run-events.js';
12
+
13
+ /**
14
+ * Read and merge events from all child repos defined in the coordinator config.
15
+ *
16
+ * @param {string} workspacePath - Coordinator workspace root
17
+ * @param {object} [opts] - Filter options
18
+ * @param {string} [opts.type] - Comma-separated event types
19
+ * @param {string} [opts.since] - ISO-8601 timestamp
20
+ * @param {number} [opts.limit] - Max events (from end, default 100)
21
+ * @param {string} [opts.repo_id] - Filter to one repo
22
+ * @returns {{ ok: boolean, events?: object[], error?: string }}
23
+ */
24
+ export function readAggregatedCoordinatorEvents(workspacePath, opts = {}) {
25
+ const configResult = loadCoordinatorConfig(workspacePath);
26
+ if (!configResult.ok) {
27
+ return { ok: false, error: configResult.errors.join('; ') };
28
+ }
29
+
30
+ const config = configResult.config;
31
+ let allEvents = [];
32
+
33
+ for (const [repoId, repo] of Object.entries(config.repos)) {
34
+ if (opts.repo_id && opts.repo_id !== repoId) continue;
35
+
36
+ const repoPath = resolve(workspacePath, repo.path);
37
+ const eventsPath = join(repoPath, RUN_EVENTS_PATH);
38
+
39
+ if (!existsSync(eventsPath)) continue;
40
+
41
+ let raw;
42
+ try {
43
+ raw = readFileSync(eventsPath, 'utf8');
44
+ } catch {
45
+ continue;
46
+ }
47
+
48
+ const lines = raw.split('\n').filter(Boolean);
49
+ for (const line of lines) {
50
+ try {
51
+ const evt = JSON.parse(line);
52
+ evt.repo_id = repoId;
53
+ allEvents.push(evt);
54
+ } catch {
55
+ // Skip malformed lines
56
+ }
57
+ }
58
+ }
59
+
60
+ // Apply type filter
61
+ if (opts.type) {
62
+ const types = new Set(opts.type.split(',').map(t => t.trim()));
63
+ allEvents = allEvents.filter(e => types.has(e.event_type));
64
+ }
65
+
66
+ // Apply since filter
67
+ if (opts.since) {
68
+ const sinceMs = new Date(opts.since).getTime();
69
+ if (!Number.isNaN(sinceMs)) {
70
+ allEvents = allEvents.filter(e => new Date(e.timestamp).getTime() > sinceMs);
71
+ }
72
+ }
73
+
74
+ // Sort by timestamp ascending, ties broken by event_id
75
+ allEvents.sort((a, b) => {
76
+ const tDiff = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
77
+ if (tDiff !== 0) return tDiff;
78
+ return (a.event_id || '').localeCompare(b.event_id || '');
79
+ });
80
+
81
+ // Apply limit (from end)
82
+ const limit = opts.limit ?? 100;
83
+ if (limit > 0 && allEvents.length > limit) {
84
+ allEvents = allEvents.slice(-limit);
85
+ }
86
+
87
+ return { ok: true, events: allEvents };
88
+ }
89
+
90
+ /**
91
+ * Set up file watchers on child repo events.jsonl files for real-time
92
+ * WebSocket push. Returns a controller with a stop() method.
93
+ *
94
+ * @param {string} workspacePath - Coordinator workspace root
95
+ * @param {function} onNewEvents - Callback: (repoId, events[]) => void
96
+ * @returns {{ ok: boolean, stop?: function, error?: string }}
97
+ */
98
+ export function watchChildRepoEvents(workspacePath, onNewEvents) {
99
+ const configResult = loadCoordinatorConfig(workspacePath);
100
+ if (!configResult.ok) {
101
+ return { ok: false, error: configResult.errors.join('; ') };
102
+ }
103
+
104
+ const config = configResult.config;
105
+ const trackedFiles = new Map(); // eventsPath → { repoId, lastSize }
106
+ const watchedPaths = [];
107
+
108
+ for (const [repoId, repo] of Object.entries(config.repos)) {
109
+ const repoPath = resolve(workspacePath, repo.path);
110
+ const eventsPath = join(repoPath, RUN_EVENTS_PATH);
111
+
112
+ let initialSize = 0;
113
+ try {
114
+ if (existsSync(eventsPath)) {
115
+ initialSize = statSync(eventsPath).size;
116
+ }
117
+ } catch {}
118
+
119
+ trackedFiles.set(eventsPath, { repoId, lastSize: initialSize });
120
+
121
+ // Use fs.watchFile (polling) for reliability across platforms
122
+ try {
123
+ watchFile(eventsPath, { interval: 500 }, (curr) => {
124
+ const tracked = trackedFiles.get(eventsPath);
125
+ if (!tracked) return;
126
+
127
+ const currentSize = curr.size;
128
+ if (currentSize <= tracked.lastSize) {
129
+ // File truncated or unchanged
130
+ if (currentSize < tracked.lastSize) {
131
+ tracked.lastSize = 0;
132
+ } else {
133
+ return;
134
+ }
135
+ }
136
+
137
+ try {
138
+ const content = readFileSync(eventsPath, 'utf8');
139
+ const newContent = content.slice(tracked.lastSize);
140
+ tracked.lastSize = content.length;
141
+
142
+ const lines = newContent.split('\n').filter(Boolean);
143
+ const newEvents = [];
144
+ for (const line of lines) {
145
+ try {
146
+ const evt = JSON.parse(line);
147
+ evt.repo_id = tracked.repoId;
148
+ newEvents.push(evt);
149
+ } catch {}
150
+ }
151
+
152
+ if (newEvents.length > 0) {
153
+ onNewEvents(tracked.repoId, newEvents);
154
+ }
155
+ } catch {}
156
+ });
157
+ watchedPaths.push(eventsPath);
158
+ } catch {}
159
+ }
160
+
161
+ function stop() {
162
+ for (const path of watchedPaths) {
163
+ try { unwatchFile(path); } catch {}
164
+ }
165
+ trackedFiles.clear();
166
+ }
167
+
168
+ return { ok: true, stop };
169
+ }
@@ -17,6 +17,7 @@ const HISTORY_FILE = 'history.jsonl';
17
17
  const LEDGER_FILE = 'decision-ledger.jsonl';
18
18
  const HOOK_AUDIT_FILE = 'hook-audit.jsonl';
19
19
  const HOOK_ANNOTATIONS_FILE = 'hook-annotations.jsonl';
20
+ const EVENTS_FILE = 'events.jsonl';
20
21
  const MULTIREPO_DIR = 'multirepo';
21
22
  const BARRIERS_FILE = 'barriers.json';
22
23
  const BARRIER_LEDGER_FILE = 'barrier-ledger.jsonl';
@@ -38,6 +39,7 @@ export const RESOURCE_MAP = {
38
39
  '/api/coordinator/barrier-ledger': join(MULTIREPO_DIR, BARRIER_LEDGER_FILE),
39
40
  '/api/coordinator/hooks/audit': join(MULTIREPO_DIR, HOOK_AUDIT_FILE),
40
41
  '/api/coordinator/hooks/annotations': join(MULTIREPO_DIR, HOOK_ANNOTATIONS_FILE),
42
+ '/api/events': EVENTS_FILE,
41
43
  };
42
44
 
43
45
  /**
@@ -575,6 +575,11 @@ function renderContext(state, config, root, turn, role) {
575
575
  lines.push(` - ${req}`);
576
576
  }
577
577
  }
578
+ if (Array.isArray(dc.required_decision_ids) && dc.required_decision_ids.length > 0) {
579
+ lines.push(`- **Required decisions:** ${dc.required_decision_ids.join(', ')}`);
580
+ lines.push('');
581
+ lines.push('Your accepted turn must emit these decision IDs in `decisions[]` before the parent review may advance the phase or complete the run.');
582
+ }
578
583
  lines.push('');
579
584
  lines.push('Focus exclusively on the charter above. Do not expand scope beyond the delegation.');
580
585
  lines.push('');
@@ -599,9 +604,14 @@ function renderContext(state, config, root, turn, role) {
599
604
  if (result.verification?.status) {
600
605
  lines.push(`- **Verification:** ${result.verification.status}`);
601
606
  }
607
+ if (Array.isArray(result.required_decision_ids) && result.required_decision_ids.length > 0) {
608
+ lines.push(`- **Required decisions:** ${result.required_decision_ids.join(', ')}`);
609
+ lines.push(`- **Satisfied decisions:** ${(result.satisfied_decision_ids || []).join(', ') || 'none'}`);
610
+ lines.push(`- **Missing decisions:** ${(result.missing_decision_ids || []).join(', ') || 'none'}`);
611
+ }
602
612
  lines.push('');
603
613
  }
604
- lines.push('Evaluate whether each delegation met its acceptance contract.');
614
+ lines.push('Evaluate whether each delegation met its acceptance contract and returned any required named decisions.');
605
615
  lines.push('Your turn result should assess the delegation outcomes and decide next steps.');
606
616
  lines.push('');
607
617
  }
@@ -110,6 +110,100 @@ function verifyFilesMap(files, errors) {
110
110
  }
111
111
  }
112
112
 
113
+ function compareEventOrder(a, b) {
114
+ const left = Date.parse(a?.timestamp || '');
115
+ const right = Date.parse(b?.timestamp || '');
116
+ const leftTime = Number.isNaN(left) ? Number.POSITIVE_INFINITY : left;
117
+ const rightTime = Number.isNaN(right) ? Number.POSITIVE_INFINITY : right;
118
+ if (leftTime !== rightTime) {
119
+ return leftTime - rightTime;
120
+ }
121
+ return String(a?.event_id || '').localeCompare(String(b?.event_id || ''));
122
+ }
123
+
124
+ function buildExpectedAggregatedEventsSummary(repos) {
125
+ const events = [];
126
+ const reposWithEvents = new Set();
127
+
128
+ for (const [repoId, repoEntry] of Object.entries(repos || {})) {
129
+ if (!repoEntry?.ok || !repoEntry.export || typeof repoEntry.export !== 'object' || Array.isArray(repoEntry.export)) {
130
+ continue;
131
+ }
132
+
133
+ const repoEvents = repoEntry.export.files?.['.agentxchain/events.jsonl']?.data;
134
+ if (!Array.isArray(repoEvents)) {
135
+ continue;
136
+ }
137
+
138
+ for (const event of repoEvents) {
139
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
140
+ continue;
141
+ }
142
+ events.push({
143
+ ...event,
144
+ repo_id: repoId,
145
+ });
146
+ reposWithEvents.add(repoId);
147
+ }
148
+ }
149
+
150
+ events.sort(compareEventOrder);
151
+
152
+ const eventTypeCounts = {};
153
+ for (const event of events) {
154
+ const type = event.event_type || event.type || 'unknown';
155
+ eventTypeCounts[type] = (eventTypeCounts[type] || 0) + 1;
156
+ }
157
+
158
+ return {
159
+ total_events: events.length,
160
+ repos_with_events: [...reposWithEvents].sort(),
161
+ event_type_counts: eventTypeCounts,
162
+ events,
163
+ };
164
+ }
165
+
166
+ function verifyAggregatedEventsSummary(artifact, errors) {
167
+ const summary = artifact.summary?.aggregated_events;
168
+ if (summary === undefined || summary === null) {
169
+ return;
170
+ }
171
+
172
+ if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
173
+ addError(errors, 'summary.aggregated_events', 'must be an object when present');
174
+ return;
175
+ }
176
+
177
+ const failedRepoIds = Object.entries(artifact.repos || {})
178
+ .filter(([, repoEntry]) => repoEntry && typeof repoEntry === 'object' && !Array.isArray(repoEntry) && repoEntry.ok === false)
179
+ .map(([repoId]) => repoId);
180
+
181
+ for (const repoId of failedRepoIds) {
182
+ if (summary.repos_with_events?.includes(repoId)) {
183
+ addError(
184
+ errors,
185
+ 'summary.aggregated_events.repos_with_events',
186
+ `cannot include repo "${repoId}" when repos.${repoId}.ok is false because no nested export proof is available`,
187
+ );
188
+ }
189
+ }
190
+
191
+ const expected = buildExpectedAggregatedEventsSummary(artifact.repos);
192
+
193
+ if (summary.total_events !== expected.total_events) {
194
+ addError(errors, 'summary.aggregated_events.total_events', 'must match reconstructed aggregated event count');
195
+ }
196
+ if (!isDeepStrictEqual(summary.repos_with_events, expected.repos_with_events)) {
197
+ addError(errors, 'summary.aggregated_events.repos_with_events', 'must match reconstructed contributing repo ids');
198
+ }
199
+ if (!isDeepStrictEqual(summary.event_type_counts, expected.event_type_counts)) {
200
+ addError(errors, 'summary.aggregated_events.event_type_counts', 'must match reconstructed event type counts');
201
+ }
202
+ if (!isDeepStrictEqual(summary.events, expected.events)) {
203
+ addError(errors, 'summary.aggregated_events.events', 'must match reconstructed sorted aggregated event list');
204
+ }
205
+ }
206
+
113
207
  function countJsonl(files, relPath) {
114
208
  return Array.isArray(files?.[relPath]?.data) ? files[relPath].data.length : 0;
115
209
  }
@@ -343,6 +437,8 @@ function verifyCoordinatorExport(artifact, errors) {
343
437
  addError(errors, `${repoPath}.export`, nestedError);
344
438
  }
345
439
  }
440
+
441
+ verifyAggregatedEventsSummary(artifact, errors);
346
442
  }
347
443
 
348
444
  export function verifyExportArtifact(artifact) {
package/src/lib/export.js CHANGED
@@ -9,6 +9,7 @@ import { loadCoordinatorState } from './coordinator-state.js';
9
9
  import { normalizeRunProvenance } from './run-provenance.js';
10
10
  import { getDashboardPid, getDashboardSession } from '../commands/dashboard.js';
11
11
  import { readRepoDecisions } from './repo-decisions.js';
12
+ import { RUN_EVENTS_PATH } from './run-events.js';
12
13
 
13
14
  const EXPORT_SCHEMA_VERSION = '0.3';
14
15
 
@@ -279,6 +280,9 @@ export function buildDelegationSummary(files) {
279
280
  delegation_id: del.id,
280
281
  to_role: del.to_role,
281
282
  charter: del.charter,
283
+ required_decision_ids: Array.isArray(del.required_decision_ids) ? del.required_decision_ids : [],
284
+ satisfied_decision_ids: Array.isArray(reviewResult?.satisfied_decision_ids) ? reviewResult.satisfied_decision_ids : [],
285
+ missing_decision_ids: Array.isArray(reviewResult?.missing_decision_ids) ? reviewResult.missing_decision_ids : [],
282
286
  status: reviewResult?.status || child?.status || 'pending',
283
287
  child_turn_id: child?.turn_id || null,
284
288
  };
@@ -474,6 +478,62 @@ export function buildRunExport(startDir = process.cwd()) {
474
478
  };
475
479
  }
476
480
 
481
+ /**
482
+ * Build aggregated child-repo lifecycle events summary for coordinator exports.
483
+ * Reads events.jsonl from each child repo, tags with repo_id, merges, sorts.
484
+ */
485
+ function buildAggregatedEventsSummary(workspaceRoot, repoEntries) {
486
+ let allEvents = [];
487
+ const reposWithEvents = new Set();
488
+
489
+ for (const [repoId, repoDef] of repoEntries) {
490
+ const repoPath = resolve(workspaceRoot, repoDef?.path || '');
491
+ const eventsPath = join(repoPath, RUN_EVENTS_PATH);
492
+
493
+ if (!existsSync(eventsPath)) continue;
494
+
495
+ let raw;
496
+ try {
497
+ raw = readFileSync(eventsPath, 'utf8');
498
+ } catch {
499
+ continue;
500
+ }
501
+
502
+ const lines = raw.split('\n').filter(Boolean);
503
+ for (const line of lines) {
504
+ try {
505
+ const evt = JSON.parse(line);
506
+ evt.repo_id = repoId;
507
+ allEvents.push(evt);
508
+ reposWithEvents.add(repoId);
509
+ } catch {
510
+ // Skip malformed lines
511
+ }
512
+ }
513
+ }
514
+
515
+ // Sort by timestamp ascending, ties broken by event_id
516
+ allEvents.sort((a, b) => {
517
+ const tDiff = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
518
+ if (tDiff !== 0) return tDiff;
519
+ return (a.event_id || '').localeCompare(b.event_id || '');
520
+ });
521
+
522
+ // Count event types
523
+ const eventTypeCounts = {};
524
+ for (const evt of allEvents) {
525
+ const t = evt.event_type || evt.type || 'unknown';
526
+ eventTypeCounts[t] = (eventTypeCounts[t] || 0) + 1;
527
+ }
528
+
529
+ return {
530
+ total_events: allEvents.length,
531
+ repos_with_events: [...reposWithEvents].sort(),
532
+ event_type_counts: eventTypeCounts,
533
+ events: allEvents,
534
+ };
535
+ }
536
+
477
537
  export function buildCoordinatorExport(startDir = process.cwd()) {
478
538
  const workspaceRoot = resolve(startDir);
479
539
  const configPath = join(workspaceRoot, COORDINATOR_CONFIG_FILE);
@@ -584,6 +644,7 @@ export function buildCoordinatorExport(startDir = process.cwd()) {
584
644
  barrier_count: barrierCount,
585
645
  history_entries: countJsonl(files, '.agentxchain/multirepo/history.jsonl'),
586
646
  decision_entries: countJsonl(files, '.agentxchain/multirepo/decision-ledger.jsonl'),
647
+ aggregated_events: buildAggregatedEventsSummary(workspaceRoot, repoEntries),
587
648
  },
588
649
  files,
589
650
  config: rawConfig,
@@ -2152,6 +2152,7 @@ export function assignGovernedTurn(root, config, roleId) {
2152
2152
  parent_role: pendingDelegation.parent_role,
2153
2153
  charter: pendingDelegation.charter,
2154
2154
  acceptance_contract: pendingDelegation.acceptance_contract,
2155
+ required_decision_ids: pendingDelegation.required_decision_ids || [],
2155
2156
  };
2156
2157
  // Mark the delegation as active
2157
2158
  pendingDelegation.status = 'active';
@@ -2742,6 +2743,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2742
2743
  to_role: delegation.to_role,
2743
2744
  charter: delegation.charter,
2744
2745
  acceptance_contract: delegation.acceptance_contract,
2746
+ required_decision_ids: delegation.required_decision_ids || [],
2745
2747
  })),
2746
2748
  }
2747
2749
  : {}),
@@ -2753,6 +2755,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2753
2755
  parent_role: currentTurn.delegation_context.parent_role,
2754
2756
  charter: currentTurn.delegation_context.charter,
2755
2757
  acceptance_contract: currentTurn.delegation_context.acceptance_contract,
2758
+ required_decision_ids: currentTurn.delegation_context.required_decision_ids || [],
2756
2759
  },
2757
2760
  }
2758
2761
  : {}),
@@ -2833,6 +2836,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2833
2836
  to_role: del.to_role,
2834
2837
  charter: del.charter,
2835
2838
  acceptance_contract: del.acceptance_contract,
2839
+ required_decision_ids: del.required_decision_ids || [],
2836
2840
  status: 'pending',
2837
2841
  child_turn_id: null,
2838
2842
  created_at: now,
@@ -2859,12 +2863,23 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2859
2863
  // Build delegation review context
2860
2864
  const delegationResults = parentDelegations.map(d => {
2861
2865
  const childHistory = nextHistoryEntries.find(h => h.turn_id === d.child_turn_id);
2866
+ const childDecisionIds = Array.isArray(childHistory?.decisions)
2867
+ ? childHistory.decisions
2868
+ .map((decision) => decision?.id)
2869
+ .filter((id) => typeof id === 'string')
2870
+ : [];
2871
+ const requiredDecisionIds = Array.isArray(d.required_decision_ids) ? d.required_decision_ids : [];
2872
+ const satisfiedDecisionIds = requiredDecisionIds.filter((id) => childDecisionIds.includes(id));
2873
+ const missingDecisionIds = requiredDecisionIds.filter((id) => !childDecisionIds.includes(id));
2862
2874
  return {
2863
2875
  delegation_id: d.delegation_id,
2864
2876
  child_turn_id: d.child_turn_id,
2865
2877
  to_role: d.to_role,
2866
2878
  charter: d.charter,
2867
2879
  acceptance_contract: d.acceptance_contract,
2880
+ required_decision_ids: requiredDecisionIds,
2881
+ satisfied_decision_ids: satisfiedDecisionIds,
2882
+ missing_decision_ids: missingDecisionIds,
2868
2883
  summary: childHistory?.summary || '(no summary)',
2869
2884
  status: d.status,
2870
2885
  files_changed: childHistory?.files_changed || [],
package/src/lib/report.js CHANGED
@@ -33,6 +33,9 @@ function normalizeDelegationSummary(summary) {
33
33
  delegation_id: delegation.delegation_id,
34
34
  to_role: delegation.to_role,
35
35
  charter: delegation.charter,
36
+ required_decision_ids: Array.isArray(delegation.required_decision_ids) ? delegation.required_decision_ids : [],
37
+ satisfied_decision_ids: Array.isArray(delegation.satisfied_decision_ids) ? delegation.satisfied_decision_ids : [],
38
+ missing_decision_ids: Array.isArray(delegation.missing_decision_ids) ? delegation.missing_decision_ids : [],
36
39
  status: delegation.status,
37
40
  child_turn_id: delegation.child_turn_id,
38
41
  });
@@ -625,6 +628,19 @@ function extractCoordinatorTimeline(artifact) {
625
628
  });
626
629
  }
627
630
 
631
+ function extractAggregatedEventTimeline(artifact) {
632
+ const aggEvents = artifact.summary?.aggregated_events;
633
+ if (!aggEvents || !Array.isArray(aggEvents.events) || aggEvents.events.length === 0) return [];
634
+ return aggEvents.events.map((evt) => ({
635
+ repo_id: evt.repo_id || null,
636
+ type: evt.event_type || evt.type || 'unknown',
637
+ timestamp: evt.timestamp || null,
638
+ run_id: evt.run_id || null,
639
+ event_id: evt.event_id || null,
640
+ summary: `[${evt.repo_id || '?'}] ${evt.event_type || evt.type || 'unknown'} at ${evt.timestamp || '?'}`,
641
+ }));
642
+ }
643
+
628
644
  function computeCoordinatorTiming(artifact, coordinatorTimeline) {
629
645
  const coordinatorState = extractFileData(artifact, '.agentxchain/multirepo/state.json');
630
646
  const createdAtFromHistory = coordinatorTimeline
@@ -1118,6 +1134,7 @@ function buildCoordinatorSubject(artifact) {
1118
1134
  repo_error_count: repoErrorCount,
1119
1135
  },
1120
1136
  coordinator_timeline: coordinatorTimeline,
1137
+ aggregated_event_timeline: extractAggregatedEventTimeline(artifact),
1121
1138
  barrier_summary: barrierSummary,
1122
1139
  barrier_ledger_timeline: barrierLedgerTimeline,
1123
1140
  decision_digest: decisionDigest,
@@ -1290,6 +1307,11 @@ export function formatGovernanceReportText(report) {
1290
1307
  lines.push(` - ${chain.parent_role} (${chain.parent_turn_id}) | outcome: ${chain.outcome} | review: ${chain.review_turn_id || 'pending'}`);
1291
1308
  for (const delegation of chain.delegations) {
1292
1309
  lines.push(` ${delegation.delegation_id} -> ${delegation.to_role} | ${delegation.status} | child: ${delegation.child_turn_id || 'pending'} | ${delegation.charter}`);
1310
+ if (delegation.required_decision_ids?.length > 0) {
1311
+ lines.push(` required decisions: ${delegation.required_decision_ids.join(', ')}`);
1312
+ lines.push(` satisfied decisions: ${delegation.satisfied_decision_ids.join(', ') || 'none'}`);
1313
+ lines.push(` missing decisions: ${delegation.missing_decision_ids.join(', ') || 'none'}`);
1314
+ }
1293
1315
  }
1294
1316
  }
1295
1317
  }
@@ -1496,6 +1518,17 @@ export function formatGovernanceReportText(report) {
1496
1518
  }
1497
1519
  }
1498
1520
 
1521
+ const aggregated_event_timeline = report.subject.aggregated_event_timeline;
1522
+ if (aggregated_event_timeline && aggregated_event_timeline.length > 0) {
1523
+ lines.push('', 'Aggregated Child Repo Events:');
1524
+ for (const evt of aggregated_event_timeline) {
1525
+ const ts = evt.timestamp ? ` [${evt.timestamp}]` : '';
1526
+ lines.push(` [${evt.repo_id || '?'}] ${evt.type}${ts}`);
1527
+ }
1528
+ } else {
1529
+ lines.push('', 'Aggregated Child Repo Events:', ' No child repo events.');
1530
+ }
1531
+
1499
1532
  if (barrier_summary && barrier_summary.length > 0) {
1500
1533
  lines.push('', 'Barrier Summary:');
1501
1534
  for (const b of barrier_summary) {
@@ -1773,7 +1806,7 @@ export function formatGovernanceReportMarkdown(report) {
1773
1806
  if (run.delegation_summary?.delegation_chains?.length > 0) {
1774
1807
  lines.push('', '## Delegation Summary', '');
1775
1808
  lines.push(`- Total delegations issued: ${run.delegation_summary.total_delegations_issued}`, '');
1776
- lines.push('| Parent Role | Parent Turn | Outcome | Review Turn | Delegation | Child Turn | Status | Charter |', '|-------------|-------------|---------|-------------|------------|------------|--------|---------|');
1809
+ lines.push('| Parent Role | Parent Turn | Outcome | Review Turn | Delegation | Child Turn | Status | Required Decisions | Missing Decisions | Charter |', '|-------------|-------------|---------|-------------|------------|------------|--------|--------------------|-------------------|---------|');
1777
1810
  for (const chain of run.delegation_summary.delegation_chains) {
1778
1811
  for (let i = 0; i < chain.delegations.length; i++) {
1779
1812
  const delegation = chain.delegations[i];
@@ -1782,7 +1815,9 @@ export function formatGovernanceReportMarkdown(report) {
1782
1815
  const outcome = i === 0 ? `\`${chain.outcome}\`` : '';
1783
1816
  const reviewTurn = i === 0 ? `\`${chain.review_turn_id || 'pending'}\`` : '';
1784
1817
  const charter = delegation.charter.replace(/\|/g, '\\|');
1785
- lines.push(`| ${parentRole} | ${parentTurn} | ${outcome} | ${reviewTurn} | \`${delegation.delegation_id}\` \`${delegation.to_role}\` | \`${delegation.child_turn_id || 'pending'}\` | \`${delegation.status}\` | ${charter} |`);
1818
+ const requiredDecisions = (delegation.required_decision_ids || []).join(', ').replace(/\|/g, '\\|') || '';
1819
+ const missingDecisions = (delegation.missing_decision_ids || []).join(', ').replace(/\|/g, '\\|') || '—';
1820
+ lines.push(`| ${parentRole} | ${parentTurn} | ${outcome} | ${reviewTurn} | \`${delegation.delegation_id}\` → \`${delegation.to_role}\` | \`${delegation.child_turn_id || 'pending'}\` | \`${delegation.status}\` | ${requiredDecisions} | ${missingDecisions} | ${charter} |`);
1786
1821
  }
1787
1822
  }
1788
1823
  }
@@ -1996,6 +2031,18 @@ export function formatGovernanceReportMarkdown(report) {
1996
2031
  }
1997
2032
  }
1998
2033
 
2034
+ const aggregated_event_timeline = report.subject.aggregated_event_timeline;
2035
+ if (aggregated_event_timeline && aggregated_event_timeline.length > 0) {
2036
+ mdLines.push('', '## Aggregated Child Repo Events', '', '| Timestamp | Repo | Event Type | Summary |', '|-----------|------|------------|---------|');
2037
+ for (const evt of aggregated_event_timeline) {
2038
+ const ts = evt.timestamp ? `\`${evt.timestamp}\`` : 'n/a';
2039
+ const escapedSummary = evt.summary.replace(/\|/g, '\\|');
2040
+ mdLines.push(`| ${ts} | \`${evt.repo_id || '?'}\` | \`${evt.type}\` | ${escapedSummary} |`);
2041
+ }
2042
+ } else {
2043
+ mdLines.push('', '## Aggregated Child Repo Events', '', 'No child repo events.');
2044
+ }
2045
+
1999
2046
  if (barrier_summary && barrier_summary.length > 0) {
2000
2047
  mdLines.push('', '## Barrier Summary', '', '| Barrier | Workstream | Type | Status | Satisfied |', '|---------|------------|------|--------|-----------|');
2001
2048
  for (const b of barrier_summary) {
@@ -2392,11 +2439,13 @@ function renderRunHtml(report) {
2392
2439
  `<code>${esc(d.delegation_id)}</code> &rarr; <code>${esc(d.to_role)}</code>`,
2393
2440
  `<code>${esc(d.child_turn_id || 'pending')}</code>`,
2394
2441
  badge(d.status),
2442
+ esc((d.required_decision_ids || []).join(', ') || '\u2014'),
2443
+ esc((d.missing_decision_ids || []).join(', ') || '\u2014'),
2395
2444
  esc(d.charter),
2396
2445
  ]);
2397
2446
  }
2398
2447
  }
2399
- delHtml += htmlTable(['Parent Role', 'Parent Turn', 'Outcome', 'Review Turn', 'Delegation', 'Child Turn', 'Status', 'Charter'], rows);
2448
+ delHtml += htmlTable(['Parent Role', 'Parent Turn', 'Outcome', 'Review Turn', 'Delegation', 'Child Turn', 'Status', 'Required Decisions', 'Missing Decisions', 'Charter'], rows);
2400
2449
  sections.push(`<div class="section">${htmlSection('Delegation Summary', delHtml)}</div>`);
2401
2450
  }
2402
2451
 
@@ -2603,6 +2652,22 @@ function renderCoordinatorHtml(report) {
2603
2652
  sections.push(`<div class="section">${htmlSection('Coordinator Timeline', htmlTable(['#', 'Type', 'Time', 'Summary'], tlRows))}</div>`);
2604
2653
  }
2605
2654
 
2655
+ // Aggregated Child Repo Events
2656
+ {
2657
+ const aggTimeline = report.subject.aggregated_event_timeline;
2658
+ if (aggTimeline?.length > 0) {
2659
+ const aggRows = aggTimeline.map((evt) => [
2660
+ `<code>${esc(evt.timestamp || 'n/a')}</code>`,
2661
+ `<span class="badge" style="background:#4a90d9">${esc(evt.repo_id || '?')}</span>`,
2662
+ `<code>${esc(evt.type)}</code>`,
2663
+ esc(evt.summary),
2664
+ ]);
2665
+ sections.push(`<div class="section">${htmlSection('Aggregated Child Repo Events', htmlTable(['Timestamp', 'Repo', 'Event Type', 'Summary'], aggRows))}</div>`);
2666
+ } else {
2667
+ sections.push(`<div class="section">${htmlSection('Aggregated Child Repo Events', '<p>No child repo events.</p>')}</div>`);
2668
+ }
2669
+ }
2670
+
2606
2671
  // Barrier Summary
2607
2672
  if (barrier_summary?.length > 0) {
2608
2673
  const bRows = barrier_summary.map((b) => [
@@ -268,6 +268,16 @@
268
268
  "items": { "type": "string", "minLength": 1 },
269
269
  "minItems": 1,
270
270
  "description": "What the delegate must achieve for this delegation to be considered complete."
271
+ },
272
+ "required_decision_ids": {
273
+ "type": "array",
274
+ "items": {
275
+ "type": "string",
276
+ "pattern": "^DEC-\\d+$"
277
+ },
278
+ "minItems": 1,
279
+ "uniqueItems": true,
280
+ "description": "Optional named decisions the delegated child must emit before the parent review turn may advance phase/run lifecycle."
271
281
  }
272
282
  }
273
283
  }
@@ -263,6 +263,16 @@ function validateSchema(tr) {
263
263
  }
264
264
  }
265
265
 
266
+ if ('delegations' in tr) {
267
+ if (!Array.isArray(tr.delegations)) {
268
+ errors.push('delegations must be an array.');
269
+ } else {
270
+ for (let i = 0; i < tr.delegations.length; i++) {
271
+ errors.push(...validateDelegation(tr.delegations[i], i));
272
+ }
273
+ }
274
+ }
275
+
266
276
  errors.push(...collectUnfilledTemplatePlaceholderErrors(tr));
267
277
 
268
278
  return errors;
@@ -382,6 +392,54 @@ function validateObjection(obj, index) {
382
392
  return errors;
383
393
  }
384
394
 
395
+ function validateDelegation(del, index) {
396
+ const errors = [];
397
+ const prefix = `delegations[${index}]`;
398
+
399
+ if (del === null || typeof del !== 'object' || Array.isArray(del)) {
400
+ return [`${prefix} must be an object.`];
401
+ }
402
+ if (typeof del.id !== 'string' || !/^del-\d{3}$/.test(del.id)) {
403
+ errors.push(`${prefix}.id must match pattern del-NNN.`);
404
+ }
405
+ if (typeof del.to_role !== 'string' || !/^[a-z0-9_-]+$/.test(del.to_role)) {
406
+ errors.push(`${prefix}.to_role must match pattern ^[a-z0-9_-]+$.`);
407
+ }
408
+ if (typeof del.charter !== 'string' || !del.charter.trim()) {
409
+ errors.push(`${prefix}.charter must be a non-empty string.`);
410
+ }
411
+ if (!Array.isArray(del.acceptance_contract) || del.acceptance_contract.length === 0) {
412
+ errors.push(`${prefix}.acceptance_contract must be a non-empty array.`);
413
+ } else {
414
+ for (let i = 0; i < del.acceptance_contract.length; i++) {
415
+ if (typeof del.acceptance_contract[i] !== 'string' || !del.acceptance_contract[i].trim()) {
416
+ errors.push(`${prefix}.acceptance_contract[${i}] must be a non-empty string.`);
417
+ }
418
+ }
419
+ }
420
+ if (del.required_decision_ids !== undefined) {
421
+ if (!Array.isArray(del.required_decision_ids) || del.required_decision_ids.length === 0) {
422
+ errors.push(`${prefix}.required_decision_ids must be a non-empty array when provided.`);
423
+ } else {
424
+ const seen = new Set();
425
+ for (let i = 0; i < del.required_decision_ids.length; i++) {
426
+ const id = del.required_decision_ids[i];
427
+ if (typeof id !== 'string' || !/^DEC-\d+$/.test(id)) {
428
+ errors.push(`${prefix}.required_decision_ids[${i}] must match pattern DEC-NNN.`);
429
+ continue;
430
+ }
431
+ if (seen.has(id)) {
432
+ errors.push(`${prefix}.required_decision_ids contains duplicate "${id}".`);
433
+ continue;
434
+ }
435
+ seen.add(id);
436
+ }
437
+ }
438
+ }
439
+
440
+ return errors;
441
+ }
442
+
385
443
  // ── Stage B: Assignment Validation ───────────────────────────────────────────
386
444
 
387
445
  function validateAssignment(tr, state) {
@@ -563,6 +621,7 @@ function validateProtocol(tr, state, config) {
563
621
 
564
622
  const role = config.roles?.[tr.role];
565
623
  const writeAuthority = role?.write_authority;
624
+ const activeTurn = getActiveTurn(state) || state?.current_turn || null;
566
625
 
567
626
  // Challenge requirement: review_only roles MUST raise at least one objection
568
627
  if (config.rules?.challenge_required !== false) {
@@ -628,7 +687,6 @@ function validateProtocol(tr, state, config) {
628
687
  }
629
688
 
630
689
  // No recursive delegation: if this turn is a delegation review, it cannot delegate further
631
- const activeTurn = state?.active_turns ? Object.values(state.active_turns)[0] : null;
632
690
  if (activeTurn?.delegation_context) {
633
691
  errors.push('Delegation review turns cannot contain further delegations.');
634
692
  }
@@ -663,6 +721,26 @@ function validateProtocol(tr, state, config) {
663
721
  }
664
722
  }
665
723
 
724
+ if (activeTurn?.delegation_review) {
725
+ const unmetDecisionContracts = (activeTurn.delegation_review.results || [])
726
+ .filter((result) => Array.isArray(result?.missing_decision_ids) && result.missing_decision_ids.length > 0);
727
+ if (unmetDecisionContracts.length > 0) {
728
+ const detail = unmetDecisionContracts
729
+ .map((result) => `${result.delegation_id}: ${result.missing_decision_ids.join(', ')}`)
730
+ .join('; ');
731
+ if (tr.phase_transition_request) {
732
+ errors.push(
733
+ `Delegation review cannot request phase transition while required child decisions are missing: ${detail}.`
734
+ );
735
+ }
736
+ if (tr.run_completion_request) {
737
+ errors.push(
738
+ `Delegation review cannot request run completion while required child decisions are missing: ${detail}.`
739
+ );
740
+ }
741
+ }
742
+ }
743
+
666
744
  return { errors, warnings };
667
745
  }
668
746