agentxchain 2.47.0 → 2.49.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.
@@ -106,6 +106,8 @@ import { intakeResolveCommand } from '../src/commands/intake-resolve.js';
106
106
  import { intakeStatusCommand } from '../src/commands/intake-status.js';
107
107
  import { demoCommand } from '../src/commands/demo.js';
108
108
  import { historyCommand } from '../src/commands/history.js';
109
+ import { eventsCommand } from '../src/commands/events.js';
110
+ import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand } from '../src/commands/schedule.js';
109
111
 
110
112
  const __dirname = dirname(fileURLToPath(import.meta.url));
111
113
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -257,6 +259,35 @@ program
257
259
  .option('-v, --verbose', 'Show stack traces on failure')
258
260
  .action(demoCommand);
259
261
 
262
+ const scheduleCmd = program
263
+ .command('schedule')
264
+ .description('Run governed schedules for repo-local lights-out execution');
265
+
266
+ scheduleCmd
267
+ .command('list')
268
+ .description('List configured governed schedules and due status')
269
+ .option('--schedule <id>', 'Show a single schedule')
270
+ .option('--at <iso8601>', 'Evaluate due status at a fixed time')
271
+ .option('-j, --json', 'Output as JSON')
272
+ .action(scheduleListCommand);
273
+
274
+ scheduleCmd
275
+ .command('run-due')
276
+ .description('Execute every due governed schedule once')
277
+ .option('--schedule <id>', 'Run one configured schedule only')
278
+ .option('--at <iso8601>', 'Evaluate due status at a fixed time')
279
+ .option('-j, --json', 'Output as JSON')
280
+ .action(scheduleRunDueCommand);
281
+
282
+ scheduleCmd
283
+ .command('daemon')
284
+ .description('Poll for due governed schedules and run them locally')
285
+ .option('--schedule <id>', 'Run one configured schedule only')
286
+ .option('--poll-seconds <n>', 'Polling interval in seconds', '60')
287
+ .option('--max-cycles <n>', 'Stop after N cycles (test helper)')
288
+ .option('-j, --json', 'Output as JSON')
289
+ .action(scheduleDaemonCommand);
290
+
260
291
  program
261
292
  .command('history')
262
293
  .description('Show cross-run history of governed runs in this project')
@@ -267,6 +298,17 @@ program
267
298
  .option('-d, --dir <path>', 'Project directory')
268
299
  .action(historyCommand);
269
300
 
301
+ program
302
+ .command('events')
303
+ .description('Show repo-local run lifecycle events')
304
+ .option('-f, --follow', 'Stream events as they occur')
305
+ .option('-t, --type <type>', 'Filter by event type (comma-separated)')
306
+ .option('--since <timestamp>', 'Show events after ISO-8601 timestamp')
307
+ .option('-j, --json', 'Output raw JSONL')
308
+ .option('-l, --limit <n>', 'Max events to show (default: 50, 0 = all)')
309
+ .option('-d, --dir <path>', 'Project directory')
310
+ .action(eventsCommand);
311
+
270
312
  program
271
313
  .command('validate')
272
314
  .description('Validate project protocol artifacts')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.47.0",
3
+ "version": "2.49.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * agentxchain events — repo-local run event stream reader.
3
+ *
4
+ * Reads and optionally follows the `.agentxchain/events.jsonl` log,
5
+ * giving operators structured visibility into governed run lifecycle
6
+ * without requiring webhooks or a dashboard.
7
+ */
8
+
9
+ import { resolve } from 'path';
10
+ import { existsSync, watchFile, unwatchFile } from 'fs';
11
+ import { readFileSync } from 'fs';
12
+ import chalk from 'chalk';
13
+ import { readRunEvents, RUN_EVENTS_PATH, VALID_RUN_EVENTS } from '../lib/run-events.js';
14
+
15
+ /**
16
+ * @param {object} opts
17
+ * @param {boolean} [opts.follow] - Stream events as they arrive
18
+ * @param {string} [opts.type] - Comma-separated event types
19
+ * @param {string} [opts.since] - ISO-8601 timestamp filter
20
+ * @param {boolean} [opts.json] - Raw JSONL output
21
+ * @param {number} [opts.limit] - Max events to show (default 50)
22
+ * @param {string} [opts.dir] - Project directory
23
+ */
24
+ export async function eventsCommand(opts) {
25
+ const root = findProjectRoot(opts.dir || process.cwd());
26
+ if (!root) {
27
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
28
+ process.exit(1);
29
+ }
30
+
31
+ const limit = opts.limit != null ? parseInt(opts.limit, 10) : 50;
32
+ const events = readRunEvents(root, {
33
+ type: opts.type,
34
+ since: opts.since,
35
+ limit: limit === 0 ? undefined : limit,
36
+ });
37
+
38
+ if (opts.json) {
39
+ for (const evt of events) {
40
+ console.log(JSON.stringify(evt));
41
+ }
42
+ } else {
43
+ if (events.length === 0 && !opts.follow) {
44
+ console.log(chalk.dim('No events found.'));
45
+ if (opts.type) console.log(chalk.dim(` (filtered by type: ${opts.type})`));
46
+ return;
47
+ }
48
+ for (const evt of events) {
49
+ printEvent(evt);
50
+ }
51
+ }
52
+
53
+ if (opts.follow) {
54
+ return followEvents(root, opts);
55
+ }
56
+ }
57
+
58
+ function printEvent(evt) {
59
+ const ts = evt.timestamp ? new Date(evt.timestamp).toLocaleTimeString() : '—';
60
+ const type = colorEventType(evt.event_type);
61
+ const runId = evt.run_id ? evt.run_id.slice(0, 12) : '—';
62
+ const phase = evt.phase || '—';
63
+ const turnInfo = evt.turn?.role_id ? ` [${evt.turn.role_id}]` : '';
64
+ console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}`);
65
+ }
66
+
67
+ function colorEventType(type) {
68
+ const colors = {
69
+ run_started: chalk.green,
70
+ run_completed: chalk.green.bold,
71
+ run_blocked: chalk.red,
72
+ turn_dispatched: chalk.blue,
73
+ turn_accepted: chalk.green,
74
+ turn_rejected: chalk.yellow,
75
+ phase_entered: chalk.magenta,
76
+ escalation_raised: chalk.red.bold,
77
+ escalation_resolved: chalk.green,
78
+ gate_pending: chalk.yellow,
79
+ gate_approved: chalk.green,
80
+ };
81
+ const colorFn = colors[type] || chalk.white;
82
+ return colorFn(pad(type, 22));
83
+ }
84
+
85
+ function pad(str, len) {
86
+ return (str || '').padEnd(len);
87
+ }
88
+
89
+ function followEvents(root, opts) {
90
+ const filePath = resolve(root, RUN_EVENTS_PATH);
91
+ let lastSize = 0;
92
+
93
+ try {
94
+ if (existsSync(filePath)) {
95
+ lastSize = readFileSync(filePath).length;
96
+ }
97
+ } catch {}
98
+
99
+ console.log(chalk.dim('Watching for events... (Ctrl+C to stop)'));
100
+
101
+ return new Promise(() => {
102
+ const checkForNewEvents = () => {
103
+ try {
104
+ if (!existsSync(filePath)) return;
105
+ const content = readFileSync(filePath, 'utf8');
106
+ if (content.length <= lastSize) return;
107
+
108
+ const newContent = content.slice(lastSize);
109
+ lastSize = content.length;
110
+
111
+ const lines = newContent.split('\n').filter(Boolean);
112
+ for (const line of lines) {
113
+ try {
114
+ const evt = JSON.parse(line);
115
+ if (opts.type) {
116
+ const types = new Set(opts.type.split(',').map(t => t.trim()));
117
+ if (!types.has(evt.event_type)) continue;
118
+ }
119
+ if (opts.json) {
120
+ console.log(JSON.stringify(evt));
121
+ } else {
122
+ printEvent(evt);
123
+ }
124
+ } catch {}
125
+ }
126
+ } catch {}
127
+ };
128
+
129
+ watchFile(filePath, { interval: 200 }, checkForNewEvents);
130
+
131
+ process.on('SIGINT', () => {
132
+ unwatchFile(filePath, checkForNewEvents);
133
+ process.exit(0);
134
+ });
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Walk up to find the nearest directory containing agentxchain.json.
140
+ */
141
+ function findProjectRoot(start) {
142
+ let dir = resolve(start);
143
+ while (true) {
144
+ if (existsSync(resolve(dir, 'agentxchain.json'))) return dir;
145
+ if (existsSync(resolve(dir, '.agentxchain', 'state.json'))) return dir;
146
+ const parent = resolve(dir, '..');
147
+ if (parent === dir) return null;
148
+ dir = parent;
149
+ }
150
+ }
@@ -49,12 +49,18 @@ export async function runCommand(opts) {
49
49
  process.exit(1);
50
50
  }
51
51
 
52
+ const execution = await executeGovernedRun(context, opts);
53
+ process.exit(execution.exitCode);
54
+ }
55
+
56
+ export async function executeGovernedRun(context, opts = {}) {
52
57
  const { root, config, rawConfig } = context;
58
+ const log = opts.log || console.log;
53
59
 
54
60
  if (config.protocol_mode !== 'governed') {
55
- console.log(chalk.red('The run command is only available for governed projects.'));
56
- console.log(chalk.dim('Legacy projects use: agentxchain start'));
57
- process.exit(1);
61
+ log(chalk.red('The run command is only available for governed projects.'));
62
+ log(chalk.dim('Legacy projects use: agentxchain start'));
63
+ return { exitCode: 1, result: null };
58
64
  }
59
65
 
60
66
  // ── Provenance flag validation ──────────────────────────────────────────
@@ -62,17 +68,21 @@ export async function runCommand(opts) {
62
68
  const recoverFrom = opts.recoverFrom;
63
69
 
64
70
  if (continueFrom && recoverFrom) {
65
- console.log(chalk.red('Cannot specify both --continue-from and --recover-from'));
66
- process.exit(1);
71
+ log(chalk.red('Cannot specify both --continue-from and --recover-from'));
72
+ return { exitCode: 1, result: null };
67
73
  }
68
74
 
69
- let provenance = undefined;
75
+ let provenance = opts.provenance;
70
76
  if (continueFrom || recoverFrom) {
77
+ if (provenance) {
78
+ log(chalk.red('Cannot combine internal provenance overrides with --continue-from or --recover-from'));
79
+ return { exitCode: 1, result: null };
80
+ }
71
81
  const parentId = continueFrom || recoverFrom;
72
82
  const validation = validateParentRun(root, parentId);
73
83
  if (!validation.ok) {
74
- console.log(chalk.red(validation.error));
75
- process.exit(1);
84
+ log(chalk.red(validation.error));
85
+ return { exitCode: 1, result: null };
76
86
  }
77
87
  provenance = {
78
88
  trigger: continueFrom ? 'continuation' : 'recovery',
@@ -89,21 +99,36 @@ export async function runCommand(opts) {
89
99
  : null;
90
100
 
91
101
  if (overrideResolution?.error) {
92
- console.log(chalk.red(overrideResolution.error));
102
+ log(chalk.red(overrideResolution.error));
93
103
  if (overrideResolution.availableRoles.length) {
94
- console.log(chalk.dim(`Available roles: ${overrideResolution.availableRoles.join(', ')}`));
104
+ log(chalk.dim(`Available roles: ${overrideResolution.availableRoles.join(', ')}`));
105
+ }
106
+ return { exitCode: 1, result: null };
107
+ }
108
+
109
+ if (opts.requireFreshStart) {
110
+ const state = loadProjectState(root, config);
111
+ const allowedStatuses = new Set(opts.allowedFreshStatuses || ['idle', 'completed']);
112
+ const currentStatus = state?.status || 'missing';
113
+ if (currentStatus !== 'missing' && !allowedStatuses.has(currentStatus)) {
114
+ return {
115
+ exitCode: 0,
116
+ skipped: true,
117
+ skipReason: `state_${currentStatus}`,
118
+ state,
119
+ result: null,
120
+ };
95
121
  }
96
- process.exit(1);
97
122
  }
98
123
 
99
124
  // ── Dry run ───────────────────────────────────────────────────────────────
100
125
  if (opts.dryRun) {
101
126
  const dryRunState = loadProjectState(root, config);
102
127
  const roleId = overrideResolution?.roleId || resolveRole(null, dryRunState, config);
103
- console.log(chalk.cyan('Dry run — no execution'));
104
- console.log(` First role: ${roleId || chalk.dim('(unresolved)')}`);
105
- console.log(` Max turns: ${maxTurns}`);
106
- console.log(` Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`);
128
+ log(chalk.cyan('Dry run — no execution'));
129
+ log(` First role: ${roleId || chalk.dim('(unresolved)')}`);
130
+ log(` Max turns: ${maxTurns}`);
131
+ log(` Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`);
107
132
  const roleIds = Object.keys(config.roles || {});
108
133
  for (const rid of roleIds) {
109
134
  const role = config.roles[rid];
@@ -111,9 +136,9 @@ export async function runCommand(opts) {
111
136
  const rt = config.runtimes?.[rtId];
112
137
  const rtType = rt?.type || role.runtime_class || 'manual';
113
138
  const supported = rtType !== 'manual';
114
- console.log(` ${supported ? chalk.green('✓') : chalk.red('✗')} ${rid} → ${rtType}${supported ? '' : ' (not supported in run mode)'}`);
139
+ log(` ${supported ? chalk.green('✓') : chalk.red('✗')} ${rid} → ${rtType}${supported ? '' : ' (not supported in run mode)'}`);
115
140
  }
116
- process.exit(0);
141
+ return { exitCode: 0, result: null };
117
142
  }
118
143
 
119
144
  // ── SIGINT handling ─────────────────────────────────────────────────────
@@ -128,13 +153,13 @@ export async function runCommand(opts) {
128
153
  }
129
154
  aborted = true;
130
155
  controller.abort();
131
- console.log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
156
+ log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
132
157
  });
133
158
 
134
159
  // ── Run header ──────────────────────────────────────────────────────────
135
- console.log(chalk.cyan.bold('agentxchain run'));
136
- console.log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
137
- console.log('');
160
+ log(chalk.cyan.bold('agentxchain run'));
161
+ log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
162
+ log('');
138
163
 
139
164
  // ── Track first-call for --role override ────────────────────────────────
140
165
  let firstSelectRole = true;
@@ -165,7 +190,7 @@ export async function runCommand(opts) {
165
190
 
166
191
  // Manual adapter is not supported in run mode
167
192
  if (runtimeType === 'manual') {
168
- console.log(chalk.yellow(`Skipping manual role "${roleId}" — use agentxchain step for manual dispatch.`));
193
+ log(chalk.yellow(`Skipping manual role "${roleId}" — use agentxchain step for manual dispatch.`));
169
194
  return { accept: false, reason: 'manual adapter is not supported in run mode — use agentxchain step' };
170
195
  }
171
196
 
@@ -204,7 +229,7 @@ export async function runCommand(opts) {
204
229
  // ── Route to adapter ──────────────────────────────────────────────
205
230
  const adapterOpts = {
206
231
  signal: controller.signal,
207
- onStatus: (msg) => console.log(chalk.dim(` ${msg}`)),
232
+ onStatus: (msg) => log(chalk.dim(` ${msg}`)),
208
233
  verifyManifest: true,
209
234
  };
210
235
 
@@ -216,18 +241,18 @@ export async function runCommand(opts) {
216
241
  let adapterResult;
217
242
 
218
243
  if (runtimeType === 'api_proxy') {
219
- console.log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
244
+ log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
220
245
  adapterResult = await dispatchApiProxy(projectRoot, state, cfg, adapterOpts);
221
246
  } else if (runtimeType === 'mcp') {
222
247
  const transport = resolveMcpTransport(runtime);
223
- console.log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
248
+ log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
224
249
  adapterResult = await dispatchMcp(projectRoot, state, cfg, adapterOpts);
225
250
  } else if (runtimeType === 'local_cli') {
226
251
  const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
227
- console.log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
252
+ log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
228
253
  adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
229
254
  } else if (runtimeType === 'remote_agent') {
230
- console.log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
255
+ log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
231
256
  adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
232
257
  } else {
233
258
  return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
@@ -286,14 +311,14 @@ export async function runCommand(opts) {
286
311
 
287
312
  async approveGate(gateType, state) {
288
313
  if (autoApprove) {
289
- console.log(chalk.yellow(` Auto-approved ${gateType} gate`));
314
+ log(chalk.yellow(` Auto-approved ${gateType} gate`));
290
315
  return true;
291
316
  }
292
317
 
293
318
  // Non-TTY → fail-closed
294
319
  if (!process.stdin.isTTY) {
295
- console.log(chalk.yellow(` Gate pause: ${gateType} — stdin is not a TTY, failing closed.`));
296
- console.log(chalk.dim(' Use --auto-approve for non-interactive mode.'));
320
+ log(chalk.yellow(` Gate pause: ${gateType} — stdin is not a TTY, failing closed.`));
321
+ log(chalk.dim(' Use --auto-approve for non-interactive mode.'));
297
322
  return false;
298
323
  }
299
324
 
@@ -301,9 +326,9 @@ export async function runCommand(opts) {
301
326
  ? state.pending_phase_transition?.target || '(next phase)'
302
327
  : 'run completion';
303
328
 
304
- console.log('');
305
- console.log(chalk.yellow.bold(`Gate pause: ${gateType}`));
306
- console.log(chalk.dim(` Phase: ${state.phase} → ${target}`));
329
+ log('');
330
+ log(chalk.yellow.bold(`Gate pause: ${gateType}`));
331
+ log(chalk.dim(` Phase: ${state.phase} → ${target}`));
307
332
 
308
333
  const answer = await promptUser(` Approve? [y/N] `);
309
334
  const approved = /^y(es)?$/i.test(answer.trim());
@@ -313,31 +338,31 @@ export async function runCommand(opts) {
313
338
  onEvent(event) {
314
339
  switch (event.type) {
315
340
  case 'turn_assigned':
316
- console.log(chalk.cyan(`Turn assigned: ${event.turn?.turn_id} → ${event.role}`));
341
+ log(chalk.cyan(`Turn assigned: ${event.turn?.turn_id} → ${event.role}`));
317
342
  break;
318
343
  case 'turn_accepted':
319
- console.log(chalk.green(`Turn accepted: ${event.turn?.turn_id}`));
344
+ log(chalk.green(`Turn accepted: ${event.turn?.turn_id}`));
320
345
  break;
321
346
  case 'turn_rejected':
322
- console.log(chalk.yellow(`Turn rejected: ${event.turn?.turn_id} — ${event.reason || 'no reason'}`));
347
+ log(chalk.yellow(`Turn rejected: ${event.turn?.turn_id} — ${event.reason || 'no reason'}`));
323
348
  break;
324
349
  case 'gate_paused':
325
- console.log(chalk.yellow(`Gate paused: ${event.gateType}`));
350
+ log(chalk.yellow(`Gate paused: ${event.gateType}`));
326
351
  break;
327
352
  case 'gate_approved':
328
- console.log(chalk.green(`Gate approved: ${event.gateType}`));
353
+ log(chalk.green(`Gate approved: ${event.gateType}`));
329
354
  break;
330
355
  case 'gate_held':
331
- console.log(chalk.yellow(`Gate held: ${event.gateType} — run paused`));
356
+ log(chalk.yellow(`Gate held: ${event.gateType} — run paused`));
332
357
  break;
333
358
  case 'blocked':
334
- console.log(chalk.red(`Run blocked`));
359
+ log(chalk.red(`Run blocked`));
335
360
  break;
336
361
  case 'completed':
337
- console.log(chalk.green.bold('Run completed'));
362
+ log(chalk.green.bold('Run completed'));
338
363
  break;
339
364
  case 'caller_stopped':
340
- console.log(chalk.yellow('Run stopped by caller'));
365
+ log(chalk.yellow('Run stopped by caller'));
341
366
  break;
342
367
  }
343
368
  },
@@ -347,38 +372,38 @@ export async function runCommand(opts) {
347
372
  const runLoopOpts = {
348
373
  maxTurns,
349
374
  startNewRunFromCompleted: true,
350
- startNewRunFromBlocked: Boolean(provenance),
375
+ startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
351
376
  };
352
377
  if (provenance) runLoopOpts.provenance = provenance;
353
378
  const result = await runLoop(root, config, callbacks, runLoopOpts);
354
379
 
355
380
  // ── Summary ─────────────────────────────────────────────────────────────
356
- console.log('');
357
- console.log(chalk.dim('─── Run Summary ───'));
358
- console.log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
359
- console.log(` Turns: ${result.turns_executed}`);
360
- console.log(` Gates: ${result.gates_approved} approved`);
361
- console.log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
381
+ log('');
382
+ log(chalk.dim('─── Run Summary ───'));
383
+ log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
384
+ log(` Turns: ${result.turns_executed}`);
385
+ log(` Gates: ${result.gates_approved} approved`);
386
+ log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
362
387
 
363
388
  if (result.errors.length) {
364
389
  for (const err of result.errors) {
365
- console.log(chalk.red(` ${err}`));
390
+ log(chalk.red(` ${err}`));
366
391
  }
367
392
  }
368
393
 
369
394
  if (qaMissingCredentialsFallback) {
370
- printManualQaFallback();
395
+ printManualQaFallback(log);
371
396
  }
372
397
 
373
398
  // Recovery guidance for blocked/rejected states
374
399
  if (result.state && (result.stop_reason === 'blocked' || result.stop_reason === 'reject_exhausted' || result.stop_reason === 'dispatch_error')) {
375
400
  const recovery = deriveRecoveryDescriptor(result.state);
376
401
  if (recovery) {
377
- console.log('');
378
- console.log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
379
- console.log(chalk.dim(` Action: ${recovery.recovery_action}`));
402
+ log('');
403
+ log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
404
+ log(chalk.dim(` Action: ${recovery.recovery_action}`));
380
405
  if (recovery.detail) {
381
- console.log(chalk.dim(` Detail: ${recovery.detail}`));
406
+ log(chalk.dim(` Detail: ${recovery.detail}`));
382
407
  }
383
408
  }
384
409
  }
@@ -399,22 +424,25 @@ export async function runCommand(opts) {
399
424
  const reportPath = join(reportsDir, `report-${runId}.md`);
400
425
  writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
401
426
 
402
- console.log('');
403
- console.log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
427
+ log('');
428
+ log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
404
429
  } else {
405
- console.log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
430
+ log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
406
431
  }
407
432
  } catch (err) {
408
- console.log(chalk.dim(` Governance report failed: ${err.message}`));
433
+ log(chalk.dim(` Governance report failed: ${err.message}`));
409
434
  }
410
435
  }
411
436
 
412
437
  // ── Exit code ───────────────────────────────────────────────────────────
413
438
  const successReasons = new Set(['completed', 'gate_held', 'caller_stopped', 'max_turns_reached']);
414
- if (result.ok || successReasons.has(result.stop_reason)) {
415
- process.exit(0);
416
- }
417
- process.exit(1);
439
+ return {
440
+ exitCode: result.ok || successReasons.has(result.stop_reason) ? 0 : 1,
441
+ result,
442
+ skipped: false,
443
+ skipReason: null,
444
+ provenance: provenance || null,
445
+ };
418
446
  }
419
447
 
420
448
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -452,10 +480,10 @@ function shouldPrintManualQaFallback({ roleId, runtimeId, classified, rawConfig
452
480
  && rawConfig?.runtimes?.['manual-qa']?.type === 'manual';
453
481
  }
454
482
 
455
- function printManualQaFallback() {
456
- console.log('');
457
- console.log(chalk.dim(' No-key QA fallback:'));
458
- console.log(chalk.dim(' - Edit agentxchain.json and change roles.qa.runtime from "api-qa" to "manual-qa"'));
459
- console.log(chalk.dim(' - Then recover the retained QA turn with: agentxchain step --resume'));
460
- console.log(chalk.dim(' - Guide: https://agentxchain.dev/docs/getting-started'));
483
+ function printManualQaFallback(log = console.log) {
484
+ log('');
485
+ log(chalk.dim(' No-key QA fallback:'));
486
+ log(chalk.dim(' - Edit agentxchain.json and change roles.qa.runtime from "api-qa" to "manual-qa"'));
487
+ log(chalk.dim(' - Then recover the retained QA turn with: agentxchain step --resume'));
488
+ log(chalk.dim(' - Guide: https://agentxchain.dev/docs/getting-started'));
461
489
  }