agentxchain 2.110.0 → 2.111.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.
@@ -121,6 +121,7 @@ import { diffCommand } from '../src/commands/diff.js';
121
121
  import { eventsCommand } from '../src/commands/events.js';
122
122
  import { connectorCheckCommand } from '../src/commands/connector.js';
123
123
  import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
124
+ import { chainLatestCommand, chainListCommand, chainShowCommand } from '../src/commands/chain.js';
124
125
 
125
126
  const __dirname = dirname(fileURLToPath(import.meta.url));
126
127
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -392,6 +393,32 @@ program
392
393
  .option('-d, --dir <path>', 'Project directory')
393
394
  .action(eventsCommand);
394
395
 
396
+ const chainCmd = program
397
+ .command('chain')
398
+ .description('Inspect run-chaining history and reports');
399
+
400
+ chainCmd
401
+ .command('latest')
402
+ .description('Show the most recent chain report')
403
+ .option('-j, --json', 'Output as JSON')
404
+ .option('-d, --dir <path>', 'Project directory')
405
+ .action(chainLatestCommand);
406
+
407
+ chainCmd
408
+ .command('list')
409
+ .description('List all chain reports')
410
+ .option('-j, --json', 'Output as JSON')
411
+ .option('-l, --limit <n>', 'Max chain reports to show (default: 20)')
412
+ .option('-d, --dir <path>', 'Project directory')
413
+ .action(chainListCommand);
414
+
415
+ chainCmd
416
+ .command('show <chain_id>')
417
+ .description('Show a specific chain report by ID')
418
+ .option('-j, --json', 'Output as JSON')
419
+ .option('-d, --dir <path>', 'Project directory')
420
+ .action(chainShowCommand);
421
+
395
422
  program
396
423
  .command('validate')
397
424
  .description('Validate project protocol artifacts')
@@ -516,6 +543,10 @@ program
516
543
  .option('--continue-from <run_id>', 'Continue from a prior terminal run (sets trigger=continuation)')
517
544
  .option('--recover-from <run_id>', 'Recover from a prior blocked run (sets trigger=recovery)')
518
545
  .option('--inherit-context', 'Inherit read-only summary context from the parent run (requires --continue-from or --recover-from)')
546
+ .option('--chain', 'Auto-chain runs: when a run completes, start a continuation automatically')
547
+ .option('--max-chains <n>', 'Maximum continuation runs in chain mode (default: 5)', parseInt)
548
+ .option('--chain-on <statuses>', 'Comma-separated terminal statuses that trigger chaining (default: completed)')
549
+ .option('--chain-cooldown <seconds>', 'Seconds to wait between chained runs (default: 5)', parseInt)
519
550
  .action(runCommand);
520
551
 
521
552
  program
package/dashboard/app.js CHANGED
@@ -15,6 +15,7 @@ import { render as renderCrossRepo } from './components/cross-repo.js';
15
15
  import { render as renderDelegations } from './components/delegations.js';
16
16
  import { render as renderBlockers } from './components/blockers.js';
17
17
  import { render as renderArtifacts } from './components/artifacts.js';
18
+ import { render as renderChain } from './components/chain.js';
18
19
  import { render as renderRunHistory } from './components/run-history.js';
19
20
  import { render as renderTimeouts } from './components/timeouts.js';
20
21
  import { render as renderCoordinatorTimeouts } from './components/coordinator-timeouts.js';
@@ -35,6 +36,7 @@ const VIEWS = {
35
36
  'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
36
37
  blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
37
38
  artifacts: { fetch: ['workflowKitArtifacts'], render: renderArtifacts },
39
+ chain: { fetch: ['chainReports'], render: renderChain },
38
40
  'run-history': { fetch: ['runHistory'], render: renderRunHistory },
39
41
  timeouts: { fetch: ['timeouts'], render: renderTimeouts },
40
42
  'coordinator-timeouts': { fetch: ['coordinatorTimeouts'], render: renderCoordinatorTimeouts },
@@ -58,6 +60,7 @@ const API_MAP = {
58
60
  coordinatorBlockers: '/api/coordinator/blockers',
59
61
  coordinatorRepoStatusRows: '/api/coordinator/repo-status',
60
62
  workflowKitArtifacts: '/api/workflow-kit-artifacts',
63
+ chainReports: '/api/chain-reports',
61
64
  connectors: '/api/connectors',
62
65
  runHistory: '/api/run-history',
63
66
  timeouts: '/api/timeouts',
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Chain view — renders run-chaining visibility from /api/chain-reports.
3
+ *
4
+ * Pure render function: takes snapshot data from the bridge server and returns
5
+ * HTML for latest-chain lineage plus recent chain-session history.
6
+ */
7
+
8
+ function esc(str) {
9
+ if (str == null) return '';
10
+ return String(str)
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ function badge(label, color = 'var(--text-dim)') {
19
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
20
+ }
21
+
22
+ function formatStatus(status) {
23
+ switch (status) {
24
+ case 'completed':
25
+ return badge('completed', 'var(--green)');
26
+ case 'blocked':
27
+ return badge('blocked', 'var(--yellow)');
28
+ case 'failed':
29
+ return badge('failed', 'var(--red)');
30
+ default:
31
+ return badge(status || 'unknown', 'var(--text-dim)');
32
+ }
33
+ }
34
+
35
+ function formatTerminalReason(reason) {
36
+ switch (reason) {
37
+ case 'chain_limit_reached':
38
+ return badge('chain limit reached', '#38bdf8');
39
+ case 'non_chainable_status':
40
+ return badge('non-chainable status', 'var(--yellow)');
41
+ case 'operator_abort':
42
+ return badge('operator abort', 'var(--red)');
43
+ case 'parent_validation_failed':
44
+ return badge('parent validation failed', 'var(--red)');
45
+ default:
46
+ return badge(reason || 'unknown', 'var(--text-dim)');
47
+ }
48
+ }
49
+
50
+ function formatDuration(ms) {
51
+ if (ms == null) return '—';
52
+ if (ms < 1000) return `${ms}ms`;
53
+ const seconds = Math.floor(ms / 1000);
54
+ if (seconds < 60) return `${seconds}s`;
55
+ const minutes = Math.floor(seconds / 60);
56
+ const remainingSeconds = seconds % 60;
57
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
58
+ const hours = Math.floor(minutes / 60);
59
+ const remainingMinutes = minutes % 60;
60
+ return `${hours}h ${remainingMinutes}m`;
61
+ }
62
+
63
+ function formatDate(iso) {
64
+ if (!iso) return '—';
65
+ try {
66
+ const date = new Date(iso);
67
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
68
+ + ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
69
+ } catch {
70
+ return esc(iso);
71
+ }
72
+ }
73
+
74
+ function truncateId(value, len = 12) {
75
+ if (!value) return '—';
76
+ return value.length > len ? `${value.slice(0, len)}…` : value;
77
+ }
78
+
79
+ function formatContextSummary(summary) {
80
+ if (!summary) return '—';
81
+
82
+ const parts = [];
83
+ if (Array.isArray(summary.parent_roles_used) && summary.parent_roles_used.length > 0) {
84
+ parts.push(`${summary.parent_roles_used.length} roles`);
85
+ }
86
+ if (summary.parent_phases_completed_count > 0) {
87
+ parts.push(`${summary.parent_phases_completed_count} phases`);
88
+ }
89
+ if (summary.recent_decisions_count > 0) {
90
+ parts.push(`${summary.recent_decisions_count} decisions`);
91
+ }
92
+ if (summary.recent_accepted_turns_count > 0) {
93
+ parts.push(`${summary.recent_accepted_turns_count} turns`);
94
+ }
95
+
96
+ return parts.length > 0 ? esc(parts.join(', ')) : '—';
97
+ }
98
+
99
+ function renderLatestRunsTable(report) {
100
+ if (!Array.isArray(report?.runs) || report.runs.length === 0) {
101
+ return `<p class="section-subtitle">No runs recorded for this chain.</p>`;
102
+ }
103
+
104
+ let html = `<div class="section"><h3>Latest Chain Lineage</h3>
105
+ <table class="data-table">
106
+ <thead>
107
+ <tr>
108
+ <th>#</th>
109
+ <th>Run ID</th>
110
+ <th>Status</th>
111
+ <th>Trigger</th>
112
+ <th>Turns</th>
113
+ <th>Duration</th>
114
+ <th>Parent</th>
115
+ <th>Inherited Context</th>
116
+ </tr>
117
+ </thead>
118
+ <tbody>`;
119
+
120
+ report.runs.forEach((run, index) => {
121
+ html += `<tr>
122
+ <td style="color:var(--text-dim)">${index + 1}</td>
123
+ <td class="mono" title="${esc(run.run_id || '')}">${esc(truncateId(run.run_id))}</td>
124
+ <td>${formatStatus(run.status)}</td>
125
+ <td>${esc(run.provenance_trigger || '—')}</td>
126
+ <td>${run.turns ?? '—'}</td>
127
+ <td>${formatDuration(run.duration_ms)}</td>
128
+ <td class="mono" title="${esc(run.parent_run_id || '')}">${esc(truncateId(run.parent_run_id))}</td>
129
+ <td>${formatContextSummary(run.inherited_context_summary)}</td>
130
+ </tr>`;
131
+ });
132
+
133
+ html += '</tbody></table></div>';
134
+ return html;
135
+ }
136
+
137
+ function renderRecentChainsTable(reports) {
138
+ let html = `<div class="section"><h3>Recent Chain Sessions</h3>
139
+ <table class="data-table">
140
+ <thead>
141
+ <tr>
142
+ <th>#</th>
143
+ <th>Chain ID</th>
144
+ <th>Runs</th>
145
+ <th>Turns</th>
146
+ <th>Terminal</th>
147
+ <th>Duration</th>
148
+ <th>Started</th>
149
+ </tr>
150
+ </thead>
151
+ <tbody>`;
152
+
153
+ reports.forEach((report, index) => {
154
+ html += `<tr>
155
+ <td style="color:var(--text-dim)">${index + 1}</td>
156
+ <td class="mono" title="${esc(report.chain_id || '')}">${esc(truncateId(report.chain_id, 14))}</td>
157
+ <td>${report.runs?.length || 0}</td>
158
+ <td>${report.total_turns ?? '—'}</td>
159
+ <td>${formatTerminalReason(report.terminal_reason)}</td>
160
+ <td>${formatDuration(report.total_duration_ms)}</td>
161
+ <td>${formatDate(report.started_at)}</td>
162
+ </tr>`;
163
+ });
164
+
165
+ html += '</tbody></table></div>';
166
+ return html;
167
+ }
168
+
169
+ export function render({ chainReports }) {
170
+ if (!chainReports || typeof chainReports !== 'object') {
171
+ return `<div class="placeholder"><h2>Chain</h2><p>No chain data available. Run a chained governed session to populate this view.</p></div>`;
172
+ }
173
+
174
+ const reports = Array.isArray(chainReports.reports) ? chainReports.reports : [];
175
+ const latest = chainReports.latest || reports[0] || null;
176
+
177
+ if (!latest || reports.length === 0) {
178
+ return `<div class="placeholder"><h2>Chain</h2><p>No chain reports found. Run <code>agentxchain run --chain</code> to record automatic continuation lineage.</p></div>`;
179
+ }
180
+
181
+ let html = `<div class="chain-view"><div class="run-header"><div class="run-meta">`;
182
+ html += `<span class="turn-count">latest chain ${esc(latest.chain_id || '—')}</span>`;
183
+ html += badge(`${latest.runs?.length || 0} runs`, '#38bdf8');
184
+ html += badge(`${latest.total_turns || 0} turns`, 'var(--green)');
185
+ html += formatTerminalReason(latest.terminal_reason);
186
+ html += `</div></div>`;
187
+
188
+ html += `<div class="section"><h3>Latest Chain Summary</h3><dl class="detail-list">`;
189
+ html += `<dt>Started</dt><dd>${esc(latest.started_at || '—')}</dd>`;
190
+ html += `<dt>Completed</dt><dd>${esc(latest.completed_at || '—')}</dd>`;
191
+ html += `<dt>Total Duration</dt><dd>${formatDuration(latest.total_duration_ms)}</dd>`;
192
+ html += `<dt>Total Turns</dt><dd>${latest.total_turns ?? '—'}</dd>`;
193
+ html += `<dt>Terminal Reason</dt><dd>${esc(latest.terminal_reason || '—')}</dd>`;
194
+ html += `</dl></div>`;
195
+
196
+ html += renderLatestRunsTable(latest);
197
+ html += renderRecentChainsTable(reports);
198
+ html += '</div>';
199
+ return html;
200
+ }
@@ -405,6 +405,7 @@
405
405
  <a href="#gate">Gates</a>
406
406
  <a href="#blockers">Blockers</a>
407
407
  <a href="#artifacts">Artifacts</a>
408
+ <a href="#chain">Chain</a>
408
409
  <a href="#run-history">Run History</a>
409
410
  <a href="#timeouts">Timeouts</a>
410
411
  <a href="#coordinator-timeouts">Coordinator Timeouts</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.110.0",
3
+ "version": "2.111.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,252 @@
1
+ /**
2
+ * agentxchain chain — operator-facing read surface for chain reports.
3
+ *
4
+ * Surfaces chain report metadata so operators can inspect lights-out
5
+ * run-chaining history without opening raw JSON files.
6
+ */
7
+
8
+ import chalk from 'chalk';
9
+ import { findProjectRoot } from '../lib/config.js';
10
+ import {
11
+ loadAllChainReports,
12
+ loadChainReport,
13
+ loadLatestChainReport,
14
+ } from '../lib/chain-reports.js';
15
+
16
+ /**
17
+ * agentxchain chain latest — show the most recent chain report.
18
+ *
19
+ * @param {object} opts - { json?: boolean, dir?: string }
20
+ */
21
+ export async function chainLatestCommand(opts) {
22
+ const root = findProjectRoot(opts.dir || process.cwd());
23
+ if (!root) {
24
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
25
+ process.exit(1);
26
+ }
27
+
28
+ const report = loadLatestChainReport(root);
29
+ if (!report) {
30
+ console.log(chalk.dim('No chain reports found.'));
31
+ console.log(chalk.dim(' Run `agentxchain run --chain` to enable auto-chaining.'));
32
+ return;
33
+ }
34
+
35
+ if (opts.json) {
36
+ console.log(JSON.stringify(report, null, 2));
37
+ return;
38
+ }
39
+
40
+ renderChainReport(report);
41
+ }
42
+
43
+ /**
44
+ * agentxchain chain list — list all chain reports.
45
+ *
46
+ * @param {object} opts - { json?: boolean, limit?: number, dir?: string }
47
+ */
48
+ export async function chainListCommand(opts) {
49
+ const root = findProjectRoot(opts.dir || process.cwd());
50
+ if (!root) {
51
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
52
+ process.exit(1);
53
+ }
54
+
55
+ const reports = loadAllChainReports(root);
56
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
57
+ const limited = reports.slice(0, limit);
58
+
59
+ if (opts.json) {
60
+ console.log(JSON.stringify(limited, null, 2));
61
+ return;
62
+ }
63
+
64
+ if (limited.length === 0) {
65
+ console.log(chalk.dim('No chain reports found.'));
66
+ console.log(chalk.dim(' Run `agentxchain run --chain` to enable auto-chaining.'));
67
+ return;
68
+ }
69
+
70
+ // Table header
71
+ const header = [
72
+ pad('#', 4),
73
+ pad('Chain ID', 16),
74
+ pad('Runs', 6),
75
+ pad('Turns', 7),
76
+ pad('Terminal Reason', 28),
77
+ pad('Duration', 12),
78
+ pad('Started', 22),
79
+ ].join(' ');
80
+
81
+ console.log(chalk.bold(header));
82
+ console.log(chalk.dim('─'.repeat(header.length)));
83
+
84
+ limited.forEach((report, i) => {
85
+ const idx = String(i + 1);
86
+ const chainId = report.chain_id || '—';
87
+ const runs = String(report.runs?.length || 0);
88
+ const turns = String(report.total_turns || 0);
89
+ const terminal = formatTerminalReason(report.terminal_reason);
90
+ const duration = report.total_duration_ms != null
91
+ ? formatDuration(report.total_duration_ms)
92
+ : '—';
93
+ const started = report.started_at
94
+ ? new Date(report.started_at).toLocaleString()
95
+ : '—';
96
+
97
+ console.log([
98
+ pad(idx, 4),
99
+ pad(chainId, 16),
100
+ pad(runs, 6),
101
+ pad(turns, 7),
102
+ pad(terminal, 28),
103
+ pad(duration, 12),
104
+ pad(started, 22),
105
+ ].join(' '));
106
+ });
107
+
108
+ console.log(chalk.dim(`\n${limited.length} chain(s) shown${reports.length > limit ? ` (${reports.length} total)` : ''}`));
109
+ }
110
+
111
+ /**
112
+ * agentxchain chain show <chain_id> — show a specific chain report.
113
+ *
114
+ * @param {string} chainId
115
+ * @param {object} opts - { json?: boolean, dir?: string }
116
+ */
117
+ export async function chainShowCommand(chainId, opts) {
118
+ const root = findProjectRoot(opts.dir || process.cwd());
119
+ if (!root) {
120
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
121
+ process.exit(1);
122
+ }
123
+
124
+ const report = loadChainReport(root, chainId);
125
+ if (!report) {
126
+ console.error(chalk.red(`Chain report not found: ${chainId}`));
127
+ console.log(chalk.dim(' Use `agentxchain chain list` to see available chain reports.'));
128
+ process.exit(1);
129
+ }
130
+
131
+ if (opts.json) {
132
+ console.log(JSON.stringify(report, null, 2));
133
+ return;
134
+ }
135
+
136
+ renderChainReport(report);
137
+ }
138
+
139
+ // ── Rendering ─────────────────────────────────────────────────────────────────
140
+
141
+ function renderChainReport(report) {
142
+ console.log(chalk.bold(`Chain Report: ${report.chain_id}`));
143
+ console.log('');
144
+ console.log(` Total runs: ${report.runs?.length || 0}`);
145
+ console.log(` Total turns: ${report.total_turns || 0}`);
146
+ console.log(` Duration: ${formatDuration(report.total_duration_ms || 0)}`);
147
+ console.log(` Terminal: ${formatTerminalReason(report.terminal_reason)}`);
148
+ console.log(` Started: ${report.started_at || '—'}`);
149
+ console.log(` Completed: ${report.completed_at || '—'}`);
150
+ console.log('');
151
+
152
+ if (!report.runs || report.runs.length === 0) {
153
+ console.log(chalk.dim(' No runs recorded.'));
154
+ return;
155
+ }
156
+
157
+ // Run table header
158
+ const runHeader = [
159
+ pad('#', 4),
160
+ pad('Run ID', 14),
161
+ pad('Status', 12),
162
+ pad('Trigger', 14),
163
+ pad('Turns', 7),
164
+ pad('Duration', 12),
165
+ pad('Parent', 14),
166
+ pad('Ctx', 40),
167
+ ].join(' ');
168
+
169
+ console.log(chalk.bold(' Runs:'));
170
+ console.log(` ${chalk.dim(runHeader)}`);
171
+ console.log(` ${chalk.dim('─'.repeat(runHeader.length))}`);
172
+
173
+ report.runs.forEach((run, i) => {
174
+ const idx = String(i + 1);
175
+ const runId = (run.run_id || '—').slice(0, 12);
176
+ const status = formatStatus(run.status);
177
+ const trigger = run.provenance_trigger || '—';
178
+ const turns = String(run.turns || 0);
179
+ const duration = run.duration_ms != null ? formatDuration(run.duration_ms) : '—';
180
+ const parent = run.parent_run_id ? run.parent_run_id.slice(0, 12) : '—';
181
+ const ctx = formatInheritedContextSummary(run.inherited_context_summary);
182
+
183
+ console.log(` ${[
184
+ pad(idx, 4),
185
+ pad(runId, 14),
186
+ pad(status, 12),
187
+ pad(trigger, 14),
188
+ pad(turns, 7),
189
+ pad(duration, 12),
190
+ pad(parent, 14),
191
+ pad(ctx, 40),
192
+ ].join(' ')}`);
193
+ });
194
+ }
195
+
196
+ function formatInheritedContextSummary(summary) {
197
+ if (!summary) return '—';
198
+
199
+ const parts = [];
200
+ if (summary.parent_roles_used?.length) {
201
+ parts.push(`${summary.parent_roles_used.length} roles`);
202
+ }
203
+ if (summary.parent_phases_completed_count > 0) {
204
+ parts.push(`${summary.parent_phases_completed_count} phases`);
205
+ }
206
+ if (summary.recent_decisions_count > 0) {
207
+ parts.push(`${summary.recent_decisions_count} decisions`);
208
+ }
209
+ if (summary.recent_accepted_turns_count > 0) {
210
+ parts.push(`${summary.recent_accepted_turns_count} turns`);
211
+ }
212
+
213
+ return parts.length > 0 ? parts.join(', ') : '—';
214
+ }
215
+
216
+ function formatTerminalReason(reason) {
217
+ if (!reason) return '—';
218
+ switch (reason) {
219
+ case 'chain_limit_reached': return chalk.cyan('chain limit reached');
220
+ case 'non_chainable_status': return chalk.yellow('non-chainable status');
221
+ case 'operator_abort': return chalk.red('operator abort');
222
+ case 'parent_validation_failed': return chalk.red('parent validation failed');
223
+ case 'completed': return chalk.green('completed');
224
+ case 'blocked': return chalk.yellow('blocked');
225
+ default: return reason;
226
+ }
227
+ }
228
+
229
+ function formatStatus(status) {
230
+ if (status === 'completed') return chalk.green('completed');
231
+ if (status === 'blocked') return chalk.yellow('blocked');
232
+ if (status === 'failed') return chalk.red('failed');
233
+ return status || '—';
234
+ }
235
+
236
+ function formatDuration(ms) {
237
+ if (ms < 1000) return `${ms}ms`;
238
+ const seconds = Math.floor(ms / 1000);
239
+ if (seconds < 60) return `${seconds}s`;
240
+ const minutes = Math.floor(seconds / 60);
241
+ const remainingSeconds = seconds % 60;
242
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
243
+ const hours = Math.floor(minutes / 60);
244
+ const remainingMinutes = minutes % 60;
245
+ return `${hours}h ${remainingMinutes}m`;
246
+ }
247
+
248
+ function pad(str, width) {
249
+ return String(str).padEnd(width);
250
+ }
251
+
252
+ // ── Data Loading ──────────────────────────────────────────────────────────────
@@ -215,9 +215,28 @@ function formatValue(value, label = '') {
215
215
  if (typeof value === 'boolean') return value ? 'yes' : 'no';
216
216
  if (label === 'Cost' || label === 'Budget') return `$${value.toFixed(4)}`;
217
217
  if (label === 'Duration') return formatDuration(value);
218
+ if (label === 'Blocked reason' && value && typeof value === 'object') {
219
+ return formatBlockedReason(value);
220
+ }
221
+ if (typeof value === 'object') return JSON.stringify(value);
218
222
  return String(value);
219
223
  }
220
224
 
225
+ function formatBlockedReason(reason) {
226
+ const category = reason.category || 'unknown';
227
+ const gateAction = reason.gate_action;
228
+ if (category === 'gate_action_failed' && gateAction) {
229
+ const actionLabel = gateAction.action_label || gateAction.command || 'unknown action';
230
+ if (gateAction.timed_out) {
231
+ return `gate_action_failed: ${actionLabel} timed out after ${gateAction.timeout_ms}ms`;
232
+ }
233
+ const exit = gateAction.exit_code != null ? ` (exit ${gateAction.exit_code})` : '';
234
+ return `gate_action_failed: ${actionLabel} failed${exit}`;
235
+ }
236
+ const detail = reason.detail || reason.recovery?.detail || '';
237
+ return detail ? `${category}: ${detail}` : category;
238
+ }
239
+
221
240
  function formatDelta(delta, label) {
222
241
  if (delta == null || delta === 0) return '';
223
242
  if (label === 'Cost' || label === 'Budget') {
@@ -44,6 +44,7 @@ import {
44
44
  getDispatchTurnDir,
45
45
  getTurnStagingResultPath,
46
46
  } from '../lib/turn-paths.js';
47
+ import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
47
48
 
48
49
  export async function runCommand(opts) {
49
50
  const context = loadProjectContext();
@@ -52,6 +53,14 @@ export async function runCommand(opts) {
52
53
  process.exit(1);
53
54
  }
54
55
 
56
+ const chainOpts = resolveChainOptions(opts, context.config);
57
+ if (chainOpts.enabled) {
58
+ console.log(chalk.cyan.bold('agentxchain run --chain'));
59
+ console.log(chalk.dim(` Chain mode: enabled (max ${chainOpts.maxChains} continuations, on: ${chainOpts.chainOn.join(',')}, cooldown: ${chainOpts.cooldownSeconds}s)`));
60
+ const { exitCode } = await executeChainedRun(context, opts, chainOpts, executeGovernedRun);
61
+ process.exit(exitCode);
62
+ }
63
+
55
64
  const execution = await executeGovernedRun(context, opts);
56
65
  process.exit(execution.exitCode);
57
66
  }
@@ -167,8 +176,7 @@ export async function executeGovernedRun(context, opts = {}) {
167
176
  let aborted = false;
168
177
  let sigintCount = 0;
169
178
  const controller = new AbortController();
170
-
171
- process.on('SIGINT', () => {
179
+ const onSigint = () => {
172
180
  sigintCount++;
173
181
  if (sigintCount >= 2) {
174
182
  process.exit(130);
@@ -176,37 +184,39 @@ export async function executeGovernedRun(context, opts = {}) {
176
184
  aborted = true;
177
185
  controller.abort();
178
186
  log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
179
- });
187
+ };
188
+ process.on('SIGINT', onSigint);
180
189
 
181
- // ── Run header ──────────────────────────────────────────────────────────
182
- log(chalk.cyan.bold('agentxchain run'));
183
- log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
184
- if (provenance) {
185
- const provenanceSummary = summarizeRunProvenance(provenance);
186
- if (provenanceSummary) {
187
- log(` ${chalk.dim('Origin:')} ${chalk.magenta(provenanceSummary)}`);
190
+ try {
191
+ // ── Run header ──────────────────────────────────────────────────────────
192
+ log(chalk.cyan.bold('agentxchain run'));
193
+ log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
194
+ if (provenance) {
195
+ const provenanceSummary = summarizeRunProvenance(provenance);
196
+ if (provenanceSummary) {
197
+ log(` ${chalk.dim('Origin:')} ${chalk.magenta(provenanceSummary)}`);
198
+ }
188
199
  }
189
- }
190
- if (inheritedContext) {
191
- const ic = inheritedContext;
192
- const phasesCount = ic.parent_phases_completed?.length || 0;
193
- const decisionsCount = ic.recent_decisions?.length || 0;
194
- const turnsCount = ic.recent_accepted_turns?.length || 0;
195
- const parts = [];
196
- if (phasesCount) parts.push(`${phasesCount} phase${phasesCount !== 1 ? 's' : ''}`);
197
- if (decisionsCount) parts.push(`${decisionsCount} decision${decisionsCount !== 1 ? 's' : ''}`);
198
- if (turnsCount) parts.push(`${turnsCount} turn${turnsCount !== 1 ? 's' : ''}`);
199
- const detail = parts.length ? `${parts.join(', ')}` : '';
200
- log(` ${chalk.dim('Inherits:')} ${chalk.magenta(`parent ${ic.parent_run_id} (${ic.parent_status || 'unknown'})${detail}`)}`);
201
- }
202
- log('');
200
+ if (inheritedContext) {
201
+ const ic = inheritedContext;
202
+ const phasesCount = ic.parent_phases_completed?.length || 0;
203
+ const decisionsCount = ic.recent_decisions?.length || 0;
204
+ const turnsCount = ic.recent_accepted_turns?.length || 0;
205
+ const parts = [];
206
+ if (phasesCount) parts.push(`${phasesCount} phase${phasesCount !== 1 ? 's' : ''}`);
207
+ if (decisionsCount) parts.push(`${decisionsCount} decision${decisionsCount !== 1 ? 's' : ''}`);
208
+ if (turnsCount) parts.push(`${turnsCount} turn${turnsCount !== 1 ? 's' : ''}`);
209
+ const detail = parts.length ? ` ${parts.join(', ')}` : '';
210
+ log(` ${chalk.dim('Inherits:')} ${chalk.magenta(`parent ${ic.parent_run_id} (${ic.parent_status || 'unknown'})${detail}`)}`);
211
+ }
212
+ log('');
203
213
 
204
- // ── Track first-call for --role override ────────────────────────────────
205
- let firstSelectRole = true;
206
- let qaMissingCredentialsFallback = null;
214
+ // ── Track first-call for --role override ────────────────────────────────
215
+ let firstSelectRole = true;
216
+ let qaMissingCredentialsFallback = null;
207
217
 
208
- // ── Callbacks ───────────────────────────────────────────────────────────
209
- const callbacks = {
218
+ // ── Callbacks ───────────────────────────────────────────────────────────
219
+ const callbacks = {
210
220
  selectRole(state, cfg) {
211
221
  if (aborted) return null;
212
222
 
@@ -407,84 +417,87 @@ export async function executeGovernedRun(context, opts = {}) {
407
417
  break;
408
418
  }
409
419
  },
410
- };
411
-
412
- // ── Execute ─────────────────────────────────────────────────────────────
413
- const runLoopOpts = {
414
- maxTurns,
415
- startNewRunFromCompleted: true,
416
- startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
417
- };
418
- if (provenance) runLoopOpts.provenance = provenance;
419
- if (inheritedContext) runLoopOpts.inheritedContext = inheritedContext;
420
- const result = await runLoop(root, config, callbacks, runLoopOpts);
420
+ };
421
421
 
422
- // ── Summary ─────────────────────────────────────────────────────────────
423
- log('');
424
- log(chalk.dim('─── Run Summary ───'));
425
- log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
426
- log(` Turns: ${result.turns_executed}`);
427
- log(` Gates: ${result.gates_approved} approved`);
428
- log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
429
-
430
- if (result.errors.length) {
431
- for (const err of result.errors) {
432
- log(chalk.red(` ${err}`));
422
+ // ── Execute ─────────────────────────────────────────────────────────────
423
+ const runLoopOpts = {
424
+ maxTurns,
425
+ startNewRunFromCompleted: true,
426
+ startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
427
+ };
428
+ if (provenance) runLoopOpts.provenance = provenance;
429
+ if (inheritedContext) runLoopOpts.inheritedContext = inheritedContext;
430
+ const result = await runLoop(root, config, callbacks, runLoopOpts);
431
+
432
+ // ── Summary ─────────────────────────────────────────────────────────────
433
+ log('');
434
+ log(chalk.dim('─── Run Summary ───'));
435
+ log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
436
+ log(` Turns: ${result.turns_executed}`);
437
+ log(` Gates: ${result.gates_approved} approved`);
438
+ log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
439
+
440
+ if (result.errors.length) {
441
+ for (const err of result.errors) {
442
+ log(chalk.red(` ${err}`));
443
+ }
433
444
  }
434
- }
435
445
 
436
- if (qaMissingCredentialsFallback) {
437
- printManualQaFallback(log);
438
- }
446
+ if (qaMissingCredentialsFallback) {
447
+ printManualQaFallback(log);
448
+ }
439
449
 
440
- // Recovery guidance for blocked/rejected states
441
- if (result.state && (result.stop_reason === 'blocked' || result.stop_reason === 'reject_exhausted' || result.stop_reason === 'dispatch_error')) {
442
- const recovery = deriveRecoveryDescriptor(result.state, config);
443
- if (recovery) {
444
- log('');
445
- log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
446
- log(chalk.dim(` Action: ${recovery.recovery_action}`));
447
- if (recovery.detail) {
448
- log(chalk.dim(` Detail: ${recovery.detail}`));
450
+ // Recovery guidance for blocked/rejected states
451
+ if (result.state && (result.stop_reason === 'blocked' || result.stop_reason === 'reject_exhausted' || result.stop_reason === 'dispatch_error')) {
452
+ const recovery = deriveRecoveryDescriptor(result.state, config);
453
+ if (recovery) {
454
+ log('');
455
+ log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
456
+ log(chalk.dim(` Action: ${recovery.recovery_action}`));
457
+ if (recovery.detail) {
458
+ log(chalk.dim(` Detail: ${recovery.detail}`));
459
+ }
449
460
  }
450
461
  }
451
- }
452
-
453
- // ── Auto governance report ──────────────────────────────────────────────
454
- if (opts.report !== false && result.state) {
455
- try {
456
- const reportsDir = join(root, '.agentxchain', 'reports');
457
- mkdirSync(reportsDir, { recursive: true });
458
-
459
- const exportResult = buildRunExport(root);
460
- if (exportResult.ok) {
461
- const runId = result.state.run_id || 'unknown';
462
- const exportPath = join(reportsDir, `export-${runId}.json`);
463
- writeFileSync(exportPath, JSON.stringify(exportResult.export, null, 2));
464
-
465
- const reportResult = buildGovernanceReport(exportResult.export, { input: exportPath });
466
- const reportPath = join(reportsDir, `report-${runId}.md`);
467
- writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
468
462
 
469
- log('');
470
- log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
471
- } else {
472
- log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
463
+ // ── Auto governance report ──────────────────────────────────────────────
464
+ if (opts.report !== false && result.state) {
465
+ try {
466
+ const reportsDir = join(root, '.agentxchain', 'reports');
467
+ mkdirSync(reportsDir, { recursive: true });
468
+
469
+ const exportResult = buildRunExport(root);
470
+ if (exportResult.ok) {
471
+ const runId = result.state.run_id || 'unknown';
472
+ const exportPath = join(reportsDir, `export-${runId}.json`);
473
+ writeFileSync(exportPath, JSON.stringify(exportResult.export, null, 2));
474
+
475
+ const reportResult = buildGovernanceReport(exportResult.export, { input: exportPath });
476
+ const reportPath = join(reportsDir, `report-${runId}.md`);
477
+ writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
478
+
479
+ log('');
480
+ log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
481
+ } else {
482
+ log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
483
+ }
484
+ } catch (err) {
485
+ log(chalk.dim(` Governance report failed: ${err.message}`));
473
486
  }
474
- } catch (err) {
475
- log(chalk.dim(` Governance report failed: ${err.message}`));
476
487
  }
477
- }
478
488
 
479
- // ── Exit code ───────────────────────────────────────────────────────────
480
- const successReasons = new Set(['completed', 'gate_held', 'caller_stopped', 'max_turns_reached']);
481
- return {
482
- exitCode: result.ok || successReasons.has(result.stop_reason) ? 0 : 1,
483
- result,
484
- skipped: false,
485
- skipReason: null,
486
- provenance: provenance || null,
487
- };
489
+ // ── Exit code ───────────────────────────────────────────────────────────
490
+ const successReasons = new Set(['completed', 'gate_held', 'caller_stopped', 'max_turns_reached']);
491
+ return {
492
+ exitCode: result.ok || successReasons.has(result.stop_reason) ? 0 : 1,
493
+ result,
494
+ skipped: false,
495
+ skipReason: null,
496
+ provenance: provenance || null,
497
+ };
498
+ } finally {
499
+ process.removeListener('SIGINT', onSigint);
500
+ }
488
501
  }
489
502
 
490
503
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -0,0 +1,54 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export function getChainReportsDir(root) {
5
+ return join(root, '.agentxchain', 'reports');
6
+ }
7
+
8
+ export function loadAllChainReports(root) {
9
+ const reportsDir = getChainReportsDir(root);
10
+ if (!existsSync(reportsDir)) return [];
11
+
12
+ const files = readdirSync(reportsDir)
13
+ .filter((file) => file.startsWith('chain-') && file.endsWith('.json'))
14
+ .sort()
15
+ .reverse();
16
+
17
+ const reports = [];
18
+ for (const file of files) {
19
+ try {
20
+ const content = readFileSync(join(reportsDir, file), 'utf8');
21
+ reports.push(JSON.parse(content));
22
+ } catch {
23
+ // Advisory artifact only. Skip malformed files instead of failing the surface.
24
+ }
25
+ }
26
+
27
+ reports.sort((a, b) => {
28
+ const aTime = a.started_at ? new Date(a.started_at).getTime() : 0;
29
+ const bTime = b.started_at ? new Date(b.started_at).getTime() : 0;
30
+ return bTime - aTime;
31
+ });
32
+
33
+ return reports;
34
+ }
35
+
36
+ export function loadLatestChainReport(root) {
37
+ const reports = loadAllChainReports(root);
38
+ return reports.length > 0 ? reports[0] : null;
39
+ }
40
+
41
+ export function loadChainReport(root, chainId) {
42
+ const reportsDir = getChainReportsDir(root);
43
+ const exactPath = join(reportsDir, `${chainId}.json`);
44
+ if (existsSync(exactPath)) {
45
+ try {
46
+ return JSON.parse(readFileSync(exactPath, 'utf8'));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ const reports = loadAllChainReports(root);
53
+ return reports.find((report) => report.chain_id === chainId) || null;
54
+ }
@@ -29,6 +29,7 @@ import { queryRunHistory } from '../run-history.js';
29
29
  import { loadProjectContext, loadProjectState } from '../config.js';
30
30
  import { evaluateApprovalSlaReminders } from '../notification-runner.js';
31
31
  import { readGateActionSnapshot } from './gate-action-reader.js';
32
+ import { readChainReportSnapshot } from './chain-report-reader.js';
32
33
 
33
34
  const MIME_TYPES = {
34
35
  '.html': 'text/html; charset=utf-8',
@@ -462,6 +463,13 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
462
463
  return;
463
464
  }
464
465
 
466
+ if (pathname === '/api/chain-reports') {
467
+ const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
468
+ const result = readChainReportSnapshot(workspacePath, { limit });
469
+ writeJson(res, result.status, result.body);
470
+ return;
471
+ }
472
+
465
473
  if (pathname === '/api/gate-actions') {
466
474
  const result = readGateActionSnapshot(workspacePath);
467
475
  writeJson(res, result.status, result.body);
@@ -0,0 +1,15 @@
1
+ import { loadAllChainReports, loadLatestChainReport } from '../chain-reports.js';
2
+
3
+ export function readChainReportSnapshot(workspacePath, { limit } = {}) {
4
+ const reports = loadAllChainReports(workspacePath);
5
+ const effectiveLimit = Number.isInteger(limit) && limit > 0 ? limit : reports.length;
6
+
7
+ return {
8
+ ok: true,
9
+ status: 200,
10
+ body: {
11
+ latest: loadLatestChainReport(workspacePath),
12
+ reports: reports.slice(0, effectiveLimit),
13
+ },
14
+ };
15
+ }
@@ -62,6 +62,7 @@ FILE_TO_RESOURCE[normalizeRelativePath(REPO_DECISIONS_FILE)] = '/api/repo-decisi
62
62
  export const WATCH_DIRECTORIES = [
63
63
  '',
64
64
  MULTIREPO_DIR,
65
+ 'reports',
65
66
  ];
66
67
 
67
68
  export function normalizeRelativePath(filePath) {
@@ -69,7 +70,11 @@ export function normalizeRelativePath(filePath) {
69
70
  }
70
71
 
71
72
  export function resourceForRelativePath(filePath) {
72
- return FILE_TO_RESOURCE[normalizeRelativePath(filePath)] || null;
73
+ const normalized = normalizeRelativePath(filePath);
74
+ if (normalized.startsWith('reports/chain-') && normalized.endsWith('.json')) {
75
+ return '/api/chain-reports';
76
+ }
77
+ return FILE_TO_RESOURCE[normalized] || null;
73
78
  }
74
79
 
75
80
  /**
@@ -216,6 +216,9 @@ function normalizeRunExport(artifact) {
216
216
  budget_warn_mode: budgetStatus.warn_mode === true,
217
217
  budget_exhausted: budgetStatus.exhausted === true,
218
218
  phase_gate_status: normalizeGateStatusMap(phaseGateStatus),
219
+ blocked_category: state.blocked_reason?.category || null,
220
+ blocked_gate_action_timed_out: state.blocked_reason?.gate_action?.timed_out === true,
221
+ blocked_gate_action_timeout_ms: typeof state.blocked_reason?.gate_action?.timeout_ms === 'number' ? state.blocked_reason.gate_action.timeout_ms : null,
219
222
  delegation_missing_decisions: normalizeDelegationMissingMap(summary.delegation_summary),
220
223
  };
221
224
  }
@@ -563,11 +566,17 @@ function detectRunRegressions(left, right) {
563
566
  const leftGate = (left.phase_gate_status || {})[gateId] || null;
564
567
  const rightGate = (right.phase_gate_status || {})[gateId] || null;
565
568
  if (leftGate && rightGate && GATE_PASSED_STATES.has(leftGate) && GATE_FAILED_STATES.has(rightGate)) {
569
+ let causeDetail = '';
570
+ if (right.blocked_category === 'gate_action_failed') {
571
+ causeDetail = right.blocked_gate_action_timed_out
572
+ ? ` (gate action timed out after ${right.blocked_gate_action_timeout_ms}ms)`
573
+ : ' (gate action failed)';
574
+ }
566
575
  regressions.push({
567
576
  id: `REG-GATE-${String(++counter).padStart(3, '0')}`,
568
577
  category: 'gate',
569
578
  severity: 'error',
570
- message: `Gate "${gateId}" regressed from ${leftGate} to ${rightGate}`,
579
+ message: `Gate "${gateId}" regressed from ${leftGate} to ${rightGate}${causeDetail}`,
571
580
  field: `phase_gate_status.${gateId}`,
572
581
  left: leftGate,
573
582
  right: rightGate,
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Run Chain — auto-chaining governed runs for lights-out operation.
3
+ *
4
+ * When a governed run completes (or reaches another chainable terminal status),
5
+ * this module automatically starts a new run that inherits context from the
6
+ * previous one. Removes the manual `--continue-from` step and enables
7
+ * continuous governed delivery.
8
+ *
9
+ * Spec: .planning/RUN_CHAIN_SPEC.md
10
+ */
11
+
12
+ import { randomUUID } from 'crypto';
13
+ import { mkdirSync, writeFileSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { recordRunHistory, validateParentRun } from './run-history.js';
16
+
17
+ const DEFAULT_MAX_CHAINS = 5;
18
+ const DEFAULT_CHAIN_ON = ['completed'];
19
+ const DEFAULT_COOLDOWN_SECONDS = 5;
20
+
21
+ /**
22
+ * Resolve chain options from CLI flags and config, with CLI flags taking precedence.
23
+ *
24
+ * @param {object} opts - CLI options
25
+ * @param {object} config - agentxchain.json config
26
+ * @returns {{ enabled: boolean, maxChains: number, chainOn: string[], cooldownSeconds: number }}
27
+ */
28
+ export function resolveChainOptions(opts, config) {
29
+ const configChain = config?.run_loop?.chain || {};
30
+
31
+ const enabled = opts.chain ?? configChain.enabled ?? false;
32
+ const maxChains = opts.maxChains ?? configChain.max_chains ?? DEFAULT_MAX_CHAINS;
33
+ const cooldownSeconds = opts.chainCooldown ?? configChain.cooldown_seconds ?? DEFAULT_COOLDOWN_SECONDS;
34
+
35
+ let chainOn;
36
+ if (opts.chainOn) {
37
+ chainOn = typeof opts.chainOn === 'string'
38
+ ? opts.chainOn.split(',').map(s => s.trim()).filter(Boolean)
39
+ : opts.chainOn;
40
+ } else if (Array.isArray(configChain.chain_on)) {
41
+ chainOn = configChain.chain_on;
42
+ } else {
43
+ chainOn = DEFAULT_CHAIN_ON;
44
+ }
45
+
46
+ return { enabled, maxChains, chainOn, cooldownSeconds };
47
+ }
48
+
49
+ /**
50
+ * Execute a chained sequence of governed runs.
51
+ *
52
+ * @param {object} context - { root, config }
53
+ * @param {object} opts - CLI options (passed to executeGovernedRun)
54
+ * @param {object} chainOpts - resolved chain options
55
+ * @param {Function} executeGovernedRun - the run executor function
56
+ * @param {Function} [log] - logging function
57
+ * @returns {Promise<{ exitCode: number, chainReport: object }>}
58
+ */
59
+ export async function executeChainedRun(context, opts, chainOpts, executeGovernedRun, log = console.log) {
60
+ const chainId = `chain-${randomUUID().slice(0, 8)}`;
61
+ const chainOnSet = new Set(chainOpts.chainOn);
62
+ const maxRuns = chainOpts.maxChains + 1; // initial + continuations
63
+ const startedAt = new Date().toISOString();
64
+
65
+ const chainReport = {
66
+ chain_id: chainId,
67
+ started_at: startedAt,
68
+ runs: [],
69
+ total_turns: 0,
70
+ total_duration_ms: 0,
71
+ terminal_reason: null,
72
+ };
73
+
74
+ let previousRunId = null;
75
+ let lastExitCode = 0;
76
+ let aborted = false;
77
+
78
+ // Capture SIGINT to prevent chaining after current run
79
+ const onSigint = () => { aborted = true; };
80
+ process.on('SIGINT', onSigint);
81
+
82
+ try {
83
+ for (let i = 0; i < maxRuns; i++) {
84
+ const runNumber = i + 1;
85
+ log('');
86
+ log(` \u2500\u2500 Chain run ${runNumber}/${maxRuns} ${'─'.repeat(50)}`);
87
+
88
+ // Build continuation options for runs after the first
89
+ const runOpts = { ...opts };
90
+ if (previousRunId) {
91
+ runOpts.continueFrom = previousRunId;
92
+ runOpts.inheritContext = true;
93
+ }
94
+
95
+ const runStart = Date.now();
96
+ const execution = await executeGovernedRun(context, runOpts);
97
+ const runDuration = Date.now() - runStart;
98
+
99
+ const runId = execution.result?.state?.run_id || `unknown-${i}`;
100
+ const stopReason = execution.result?.stop_reason || (execution.result?.ok ? 'completed' : 'unknown');
101
+ const turnsExecuted = execution.result?.turns_executed || 0;
102
+
103
+ chainReport.runs.push(buildRunReportEntry(execution, runDuration, {
104
+ fallbackRunId: runId,
105
+ fallbackStatus: stopReason,
106
+ fallbackTurns: turnsExecuted,
107
+ }));
108
+ chainReport.total_turns += turnsExecuted;
109
+ chainReport.total_duration_ms += runDuration;
110
+
111
+ lastExitCode = execution.exitCode;
112
+ previousRunId = runId;
113
+
114
+ // Check abort
115
+ if (aborted) {
116
+ chainReport.terminal_reason = 'operator_abort';
117
+ break;
118
+ }
119
+
120
+ // Check if this is the last possible run
121
+ if (i === maxRuns - 1) {
122
+ chainReport.terminal_reason = 'chain_limit_reached';
123
+ break;
124
+ }
125
+
126
+ // Check if terminal status is chainable
127
+ if (!chainOnSet.has(stopReason)) {
128
+ chainReport.terminal_reason = 'non_chainable_status';
129
+ break;
130
+ }
131
+
132
+ // Validate parent run exists for continuation
133
+ let validation = validateParentRun(context.root, runId);
134
+ if (!validation.ok && execution.result?.state && (stopReason === 'completed' || stopReason === 'blocked')) {
135
+ const repair = recordRunHistory(context.root, execution.result.state, context.config, stopReason);
136
+ if (repair.ok) {
137
+ validation = validateParentRun(context.root, runId);
138
+ }
139
+ }
140
+ if (!validation.ok) {
141
+ log(` Chain: cannot continue — ${validation.error}`);
142
+ chainReport.terminal_reason = 'parent_validation_failed';
143
+ break;
144
+ }
145
+
146
+ // Cooldown
147
+ const continuationsRemaining = Math.max(0, maxRuns - (runNumber + 1));
148
+ log('');
149
+ log(` Chain: run ${stopReason} \u2192 starting continuation (${continuationsRemaining} remaining)...`);
150
+ if (chainOpts.cooldownSeconds > 0) {
151
+ log(` Waiting ${chainOpts.cooldownSeconds}s...`);
152
+ await sleep(chainOpts.cooldownSeconds * 1000);
153
+ }
154
+
155
+ // Check abort again after cooldown
156
+ if (aborted) {
157
+ chainReport.terminal_reason = 'operator_abort';
158
+ break;
159
+ }
160
+ }
161
+ } finally {
162
+ process.removeListener('SIGINT', onSigint);
163
+ }
164
+
165
+ // If terminal_reason not set, derive from last run
166
+ if (!chainReport.terminal_reason) {
167
+ const lastRun = chainReport.runs[chainReport.runs.length - 1];
168
+ chainReport.terminal_reason = lastRun?.status || 'unknown';
169
+ }
170
+
171
+ chainReport.completed_at = new Date().toISOString();
172
+
173
+ // Write chain report
174
+ writeChainReport(context.root, chainReport);
175
+
176
+ // Print chain summary
177
+ printChainSummary(chainReport, log);
178
+
179
+ return { exitCode: lastExitCode, chainReport };
180
+ }
181
+
182
+ /**
183
+ * Write chain report to .agentxchain/reports/.
184
+ */
185
+ function writeChainReport(root, report) {
186
+ try {
187
+ const reportsDir = join(root, '.agentxchain', 'reports');
188
+ mkdirSync(reportsDir, { recursive: true });
189
+ const reportPath = join(reportsDir, `${report.chain_id}.json`);
190
+ writeFileSync(reportPath, JSON.stringify(report, null, 2));
191
+ } catch {
192
+ // Non-fatal — chain report is advisory
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Print chain summary to terminal.
198
+ */
199
+ function printChainSummary(report, log) {
200
+ log('');
201
+ log(' \u2500\u2500\u2500 Chain Summary \u2500\u2500\u2500');
202
+ log(` Total runs: ${report.runs.length}`);
203
+ log(` Total turns: ${report.total_turns}`);
204
+ log(` Duration: ${formatDuration(report.total_duration_ms)}`);
205
+ log(` Terminal: ${report.terminal_reason}`);
206
+ }
207
+
208
+ /**
209
+ * Format milliseconds to a human-readable duration.
210
+ */
211
+ function formatDuration(ms) {
212
+ if (ms < 1000) return `${ms}ms`;
213
+ const seconds = Math.floor(ms / 1000);
214
+ if (seconds < 60) return `${seconds}s`;
215
+ const minutes = Math.floor(seconds / 60);
216
+ const remainingSeconds = seconds % 60;
217
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
218
+ const hours = Math.floor(minutes / 60);
219
+ const remainingMinutes = minutes % 60;
220
+ return `${hours}h ${remainingMinutes}m`;
221
+ }
222
+
223
+ function sleep(ms) {
224
+ return new Promise(resolve => setTimeout(resolve, ms));
225
+ }
226
+
227
+ function buildRunReportEntry(execution, runDuration, fallback = {}) {
228
+ const state = execution?.result?.state || null;
229
+ const provenance = state?.provenance || null;
230
+
231
+ return {
232
+ run_id: state?.run_id || fallback.fallbackRunId || 'unknown',
233
+ status: execution?.result?.stop_reason || fallback.fallbackStatus || 'unknown',
234
+ turns: execution?.result?.turns_executed || fallback.fallbackTurns || 0,
235
+ duration_ms: runDuration,
236
+ provenance_trigger: provenance?.trigger || null,
237
+ parent_run_id: provenance?.parent_run_id || null,
238
+ inherited_context_summary: summarizeInheritedContext(state?.inherited_context || null),
239
+ };
240
+ }
241
+
242
+ function summarizeInheritedContext(inheritedContext) {
243
+ if (!inheritedContext) return null;
244
+
245
+ return {
246
+ parent_run_id: inheritedContext.parent_run_id || null,
247
+ parent_status: inheritedContext.parent_status || null,
248
+ inherited_at: inheritedContext.inherited_at || null,
249
+ parent_roles_used: Array.isArray(inheritedContext.parent_roles_used)
250
+ ? inheritedContext.parent_roles_used
251
+ : [],
252
+ parent_phases_completed_count: Array.isArray(inheritedContext.parent_phases_completed)
253
+ ? inheritedContext.parent_phases_completed.length
254
+ : 0,
255
+ recent_decisions_count: Array.isArray(inheritedContext.recent_decisions)
256
+ ? inheritedContext.recent_decisions.length
257
+ : 0,
258
+ recent_accepted_turns_count: Array.isArray(inheritedContext.recent_accepted_turns)
259
+ ? inheritedContext.recent_accepted_turns.length
260
+ : 0,
261
+ };
262
+ }