agentxchain 2.128.0 → 2.129.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.
@@ -4,6 +4,7 @@ import { resolve } from 'path';
4
4
  import { findProjectRoot, loadProjectContext } from '../lib/config.js';
5
5
  import {
6
6
  attachChainToMission,
7
+ bindCoordinatorToMission,
7
8
  buildMissionListSummary,
8
9
  buildMissionSnapshot,
9
10
  createMission,
@@ -12,6 +13,8 @@ import {
12
13
  loadMissionArtifact,
13
14
  loadMissionSnapshot,
14
15
  } from '../lib/missions.js';
16
+ import { loadCoordinatorConfig } from '../lib/coordinator-config.js';
17
+ import { initializeCoordinatorRun } from '../lib/coordinator-state.js';
15
18
  import {
16
19
  approvePlanArtifact,
17
20
  createPlanArtifact,
@@ -48,6 +51,22 @@ export async function missionStartCommand(opts) {
48
51
  process.exit(1);
49
52
  }
50
53
 
54
+ // Multi-repo: validate coordinator config before creating mission (fail-fast)
55
+ let coordinatorConfig = null;
56
+ let coordinatorWorkspacePath = null;
57
+ if (opts.multi) {
58
+ coordinatorWorkspacePath = resolve(opts.coordinatorWorkspace || opts.coordinatorConfig || root);
59
+ coordinatorConfig = loadCoordinatorConfig(coordinatorWorkspacePath);
60
+ if (!coordinatorConfig.ok) {
61
+ console.error(chalk.red('Coordinator config validation failed:'));
62
+ console.error(chalk.dim(` Expected agentxchain-multi.json at: ${coordinatorWorkspacePath}`));
63
+ for (const err of coordinatorConfig.errors || []) {
64
+ console.error(chalk.red(` ${err}`));
65
+ }
66
+ process.exit(1);
67
+ }
68
+ }
69
+
51
70
  const result = createMission(root, {
52
71
  missionId: opts.id,
53
72
  title,
@@ -58,6 +77,37 @@ export async function missionStartCommand(opts) {
58
77
  process.exit(1);
59
78
  }
60
79
 
80
+ // Multi-repo: initialize coordinator and bind to mission
81
+ if (opts.multi && coordinatorConfig) {
82
+ const initResult = initializeCoordinatorRun(coordinatorWorkspacePath, coordinatorConfig.config);
83
+ if (!initResult.ok) {
84
+ // Atomic rollback: delete the mission artifact
85
+ const { getMissionsDir } = await import('../lib/missions.js');
86
+ const { unlinkSync, existsSync: fileExists } = await import('fs');
87
+ const { join: joinPath } = await import('path');
88
+ const missionFile = joinPath(getMissionsDir(root), `${result.mission.mission_id}.json`);
89
+ if (fileExists(missionFile)) {
90
+ try { unlinkSync(missionFile); } catch { /* best effort */ }
91
+ }
92
+ console.error(chalk.red('Coordinator initialization failed:'));
93
+ for (const err of initResult.errors || []) {
94
+ console.error(chalk.red(` ${err}`));
95
+ }
96
+ process.exit(1);
97
+ }
98
+
99
+ const bindResult = bindCoordinatorToMission(root, result.mission.mission_id, {
100
+ super_run_id: initResult.super_run_id,
101
+ config_path: opts.coordinatorConfig || null,
102
+ workspace_path: coordinatorWorkspacePath,
103
+ });
104
+ if (!bindResult.ok) {
105
+ console.error(chalk.yellow(`Mission created but coordinator binding failed: ${bindResult.error}`));
106
+ } else {
107
+ result.mission = bindResult.mission;
108
+ }
109
+ }
110
+
61
111
  const snapshot = buildMissionSnapshot(root, result.mission);
62
112
  if (opts.plan) {
63
113
  try {
@@ -1288,6 +1338,54 @@ function renderMissionPlanError(error) {
1288
1338
  }
1289
1339
  }
1290
1340
 
1341
+ // ── Mission Bind-Coordinator Command ───────────────────────────────────────
1342
+
1343
+ export async function missionBindCoordinatorCommand(missionId, opts) {
1344
+ const root = findProjectRoot(opts.dir || process.cwd());
1345
+ if (!root) {
1346
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
1347
+ process.exit(1);
1348
+ }
1349
+
1350
+ const superRunId = String(opts.superRunId || '').trim();
1351
+ const configPath = String(opts.coordinatorConfig || '').trim();
1352
+ if (!superRunId) {
1353
+ console.error(chalk.red('--super-run-id is required.'));
1354
+ process.exit(1);
1355
+ }
1356
+
1357
+ const mission = missionId
1358
+ ? loadMissionArtifact(root, missionId)
1359
+ : loadLatestMissionArtifact(root);
1360
+ if (!mission) {
1361
+ console.error(chalk.red(missionId ? `Mission not found: ${missionId}` : 'No mission found.'));
1362
+ process.exit(1);
1363
+ }
1364
+
1365
+ const workspacePath = resolve(opts.coordinatorWorkspace || root);
1366
+ const result = bindCoordinatorToMission(root, mission.mission_id, {
1367
+ super_run_id: superRunId,
1368
+ config_path: configPath || null,
1369
+ workspace_path: workspacePath,
1370
+ });
1371
+
1372
+ if (!result.ok) {
1373
+ console.error(chalk.red(result.error));
1374
+ process.exit(1);
1375
+ }
1376
+
1377
+ const snapshot = buildMissionSnapshot(root, result.mission);
1378
+ if (opts.json) {
1379
+ console.log(JSON.stringify(snapshot, null, 2));
1380
+ return;
1381
+ }
1382
+
1383
+ console.log(chalk.green(`Bound coordinator ${superRunId} to ${snapshot.mission_id}`));
1384
+ renderMissionSnapshot(snapshot);
1385
+ }
1386
+
1387
+ // ── Rendering ──────────────────────────────────────────────────────────────
1388
+
1291
1389
  function renderMissionSnapshot(snapshot) {
1292
1390
  const latestPlan = snapshot.latest_plan || null;
1293
1391
 
@@ -1309,6 +1407,36 @@ function renderMissionSnapshot(snapshot) {
1309
1407
  console.log(` Missing chains: ${snapshot.missing_chain_ids.join(', ')}`);
1310
1408
  }
1311
1409
 
1410
+ // Coordinator (multi-repo) section
1411
+ if (snapshot.coordinator_status) {
1412
+ const cs = snapshot.coordinator_status;
1413
+ console.log('');
1414
+ console.log(chalk.bold(' Coordinator (multi-repo):'));
1415
+ if (cs.unreachable) {
1416
+ console.log(` Super Run: ${cs.super_run_id || '—'}`);
1417
+ console.log(` Status: ${chalk.red('unreachable')}`);
1418
+ } else {
1419
+ console.log(` Super Run: ${cs.super_run_id || '—'}`);
1420
+ console.log(` Status: ${formatCoordinatorStatus(cs.status)}`);
1421
+ console.log(` Phase: ${cs.phase || '—'}`);
1422
+ if (cs.repo_runs && Object.keys(cs.repo_runs).length > 0) {
1423
+ console.log(' Repos:');
1424
+ for (const [repoId, repo] of Object.entries(cs.repo_runs)) {
1425
+ console.log(` ${pad(repoId, 16)} ${pad(repo.status || '—', 14)} ${pad(repo.phase || '—', 18)} ${repo.run_id || '—'}`);
1426
+ }
1427
+ }
1428
+ if (cs.pending_barriers && cs.pending_barriers.length > 0) {
1429
+ console.log(' Barriers:');
1430
+ for (const barrier of cs.pending_barriers) {
1431
+ console.log(` ${pad(barrier.id, 28)} ${pad(barrier.type || '—', 24)} ${barrier.status || '—'}`);
1432
+ }
1433
+ }
1434
+ if (cs.blocked_reason) {
1435
+ console.log(` Blocked: ${chalk.yellow(cs.blocked_reason)}`);
1436
+ }
1437
+ }
1438
+ }
1439
+
1312
1440
  if (latestPlan) {
1313
1441
  console.log('');
1314
1442
  console.log(chalk.bold(' Latest plan:'));
@@ -1374,6 +1502,20 @@ function formatMissionStatus(status) {
1374
1502
  }
1375
1503
  }
1376
1504
 
1505
+ function formatCoordinatorStatus(status) {
1506
+ if (!status) return '—';
1507
+ switch (status) {
1508
+ case 'active':
1509
+ return chalk.green('active');
1510
+ case 'blocked':
1511
+ return chalk.red('blocked');
1512
+ case 'completed':
1513
+ return chalk.cyan('completed');
1514
+ default:
1515
+ return status;
1516
+ }
1517
+ }
1518
+
1377
1519
  function formatTimestamp(value) {
1378
1520
  if (!value) return '—';
1379
1521
  try {
@@ -0,0 +1,122 @@
1
+ /**
2
+ * reissue-turn command — unified turn invalidation + reissue against current state.
3
+ *
4
+ * Covers all drift recovery scenarios:
5
+ * - Baseline drift (HEAD changed after dispatch)
6
+ * - Runtime drift (agentxchain.json rebinding after dispatch)
7
+ * - Authority drift (write_authority changed on assigned role)
8
+ * - Operator-initiated (explicit redo from current state)
9
+ *
10
+ * BUG-7 fix: single command, multiple trigger reasons.
11
+ */
12
+
13
+ import chalk from 'chalk';
14
+ import { readFileSync } from 'fs';
15
+ import { join } from 'path';
16
+ import { loadProjectContext, loadProjectState } from '../lib/config.js';
17
+ import {
18
+ getActiveTurns,
19
+ getActiveTurn,
20
+ reissueTurn,
21
+ } from '../lib/governed-state.js';
22
+ import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
23
+
24
+ export async function reissueTurnCommand(opts) {
25
+ const context = loadProjectContext();
26
+ if (!context) {
27
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
28
+ process.exit(1);
29
+ }
30
+
31
+ const { root, config } = context;
32
+
33
+ if (config.protocol_mode !== 'governed') {
34
+ console.log(chalk.red('The reissue-turn command is only available for governed projects.'));
35
+ process.exit(1);
36
+ }
37
+
38
+ let state = loadProjectState(root, config);
39
+ if (!state) {
40
+ console.log(chalk.red('No governed state.json found.'));
41
+ process.exit(1);
42
+ }
43
+
44
+ // Resolve target turn
45
+ const activeTurns = getActiveTurns(state);
46
+ const activeCount = Object.keys(activeTurns).length;
47
+
48
+ if (activeCount === 0) {
49
+ console.log(chalk.red('No active turns to reissue.'));
50
+ process.exit(1);
51
+ }
52
+
53
+ let targetTurn;
54
+ if (opts.turn) {
55
+ targetTurn = activeTurns[opts.turn];
56
+ if (!targetTurn) {
57
+ console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
58
+ process.exit(1);
59
+ }
60
+ } else if (activeCount === 1) {
61
+ targetTurn = Object.values(activeTurns)[0];
62
+ } else {
63
+ console.log(chalk.red('Multiple active turns exist. Use --turn <id> to specify which to reissue.'));
64
+ for (const turn of Object.values(activeTurns)) {
65
+ console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${turn.status})`);
66
+ }
67
+ process.exit(1);
68
+ }
69
+
70
+ const reason = opts.reason || 'operator-initiated reissue';
71
+
72
+ console.log(chalk.cyan(`Reissuing turn: ${targetTurn.turn_id} (${targetTurn.assigned_role})`));
73
+ console.log(chalk.dim(`Reason: ${reason}`));
74
+
75
+ const result = reissueTurn(root, config, {
76
+ turnId: targetTurn.turn_id,
77
+ reason,
78
+ });
79
+
80
+ if (!result.ok) {
81
+ console.log(chalk.red(`Failed to reissue turn: ${result.error}`));
82
+ process.exit(1);
83
+ }
84
+
85
+ // Write dispatch bundle for the reissued turn
86
+ const bundleResult = writeDispatchBundle(root, result.state, config, {
87
+ turnId: result.newTurn.turn_id,
88
+ });
89
+
90
+ if (!bundleResult.ok) {
91
+ console.log(chalk.red(`Turn reissued but dispatch bundle failed: ${bundleResult.error}`));
92
+ process.exit(1);
93
+ }
94
+
95
+ // Print summary
96
+ console.log('');
97
+ console.log(chalk.green(' Turn Reissued'));
98
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
99
+ console.log('');
100
+ console.log(` ${chalk.dim('Old turn:')} ${targetTurn.turn_id}`);
101
+ console.log(` ${chalk.dim('New turn:')} ${result.newTurn.turn_id}`);
102
+ console.log(` ${chalk.dim('Role:')} ${result.newTurn.assigned_role}`);
103
+ console.log(` ${chalk.dim('Attempt:')} ${result.newTurn.attempt}`);
104
+ console.log(` ${chalk.dim('Reason:')} ${reason}`);
105
+
106
+ // Show baseline delta
107
+ if (result.baselineDelta) {
108
+ const delta = result.baselineDelta;
109
+ if (delta.head_changed) {
110
+ console.log(` ${chalk.dim('HEAD:')} ${chalk.yellow(delta.old_head?.slice(0, 12) || '?')} → ${chalk.green(delta.new_head?.slice(0, 12) || '?')}`);
111
+ }
112
+ if (delta.runtime_changed) {
113
+ console.log(` ${chalk.dim('Runtime:')} ${chalk.yellow(delta.old_runtime || '?')} → ${chalk.green(delta.new_runtime || '?')}`);
114
+ }
115
+ if (delta.dirty_files_changed) {
116
+ console.log(` ${chalk.dim('Workspace:')} ${delta.added_dirty_files?.length || 0} new dirty file(s), ${delta.removed_dirty_files?.length || 0} resolved`);
117
+ }
118
+ }
119
+
120
+ console.log('');
121
+ console.log(chalk.dim('Run: agentxchain step --resume to dispatch the reissued turn.'));
122
+ }
@@ -107,11 +107,31 @@ function buildRejectionValidation(root, state, config, opts) {
107
107
  return resolution;
108
108
  }
109
109
 
110
+ // BUG-9 fix: --reassign should work for any rejected turn, not just conflicted ones.
111
+ // For drift-induced failures with no conflict_state, redirect to reissue-turn.
110
112
  if (opts.reassign && !resolution.turn.conflict_state) {
111
- return {
112
- ok: false,
113
- error: '--reassign is only valid for turns with persisted conflict_state.',
114
- };
113
+ // Detect if baseline drift exists
114
+ const currentHead = (() => {
115
+ try {
116
+ return require('child_process').execSync('git rev-parse HEAD', {
117
+ cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
118
+ }).trim();
119
+ } catch { return null; }
120
+ })();
121
+ const turnHead = resolution.turn.baseline?.head_ref;
122
+ const hasDrift = currentHead && turnHead && currentHead !== turnHead;
123
+
124
+ if (hasDrift) {
125
+ console.log(chalk.yellow(`Baseline drift detected: HEAD moved from ${turnHead?.slice(0, 12)} to ${currentHead?.slice(0, 12)}.`));
126
+ console.log(chalk.dim(`Use: agentxchain reissue-turn --turn ${resolution.turn.turn_id} --reason "baseline drift"`));
127
+ return {
128
+ ok: false,
129
+ error: `--reassign detected baseline drift. Use reissue-turn instead for a clean reissue from current HEAD.`,
130
+ };
131
+ }
132
+
133
+ // No drift, no conflict_state — just do a normal reject + reassign
134
+ // (treat it as a fresh retry with refreshed baseline, which BUG-8 now handles)
115
135
  }
116
136
 
117
137
  if (resolution.turn.conflict_state) {
@@ -265,10 +265,17 @@ export async function restartCommand(opts) {
265
265
  console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
266
266
  console.log(chalk.dim('These turns will be available for the next agent to complete.'));
267
267
 
268
- // Fail closed if retained turn + irreconcilable drift
268
+ // Fail closed if retained turn + irreconcilable drift — BUG-10 fix: surface actionable recovery
269
269
  if (driftWarnings.length > 0) {
270
270
  console.log(chalk.yellow('Active turns exist with repo drift since checkpoint. Reconnecting with warnings.'));
271
- console.log(chalk.dim('Inspect the drift before continuing work on the retained turns.'));
271
+ console.log('');
272
+ console.log(chalk.dim('Recovery options:'));
273
+ for (const turnId of turnIds) {
274
+ const turn = activeTurns[turnId];
275
+ console.log(` ${chalk.cyan(`agentxchain reissue-turn --turn ${turnId} --reason "baseline drift"`)} — reissue ${turn.assigned_role} from current HEAD`);
276
+ }
277
+ console.log(` ${chalk.cyan('agentxchain reject-turn --reason "baseline drift"')} — reject and retry with refreshed baseline`);
278
+ console.log(` ${chalk.dim('Continue as-is if the drift does not affect the retained turns.')}`);
272
279
  }
273
280
  }
274
281
 
@@ -39,6 +39,7 @@ import {
39
39
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
40
40
  import { runHooks } from '../lib/hook-runner.js';
41
41
  import { summarizeRunProvenance } from '../lib/run-provenance.js';
42
+ import { consumeNextApprovedIntent } from '../lib/intake.js';
42
43
 
43
44
  export async function resumeCommand(opts) {
44
45
  const context = loadProjectContext();
@@ -265,17 +266,27 @@ export async function resumeCommand(opts) {
265
266
  process.exit(1);
266
267
  }
267
268
 
268
- // Assign the turn
269
- const assignResult = assignGovernedTurn(root, config, roleId);
270
- if (!assignResult.ok) {
271
- if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
272
- printAssignmentHookFailure(assignResult, roleId, config);
269
+ const shouldBindIntent = opts.intent !== false;
270
+ const consumed = shouldBindIntent ? consumeNextApprovedIntent(root, { role: roleId }) : { ok: false };
271
+ if (consumed.ok) {
272
+ state = loadProjectState(root, config);
273
+ if (!state) {
274
+ console.log(chalk.red('Failed to reload governed state after intake binding.'));
275
+ process.exit(1);
273
276
  }
274
- console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
275
- process.exit(1);
277
+ console.log(chalk.green(`Bound approved intent to next turn: ${consumed.intentId}`));
278
+ } else {
279
+ const assignResult = assignGovernedTurn(root, config, roleId);
280
+ if (!assignResult.ok) {
281
+ if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
282
+ printAssignmentHookFailure(assignResult, roleId, config);
283
+ }
284
+ console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
285
+ process.exit(1);
286
+ }
287
+ printAssignmentWarnings(assignResult);
288
+ state = assignResult.state;
276
289
  }
277
- printAssignmentWarnings(assignResult);
278
- state = assignResult.state;
279
290
 
280
291
  // Write dispatch bundle
281
292
  const bundleResult = writeDispatchBundle(root, state, config);
@@ -8,7 +8,7 @@ import {
8
8
  deriveRecoveryDescriptor,
9
9
  deriveRuntimeBlockedGuidance,
10
10
  } from '../lib/blocked-state.js';
11
- import { getActiveTurn, getActiveTurnCount, getActiveTurns } from '../lib/governed-state.js';
11
+ import { getActiveTurn, getActiveTurnCount, getActiveTurns, detectActiveTurnBindingDrift } from '../lib/governed-state.js';
12
12
  import { getContinuityStatus } from '../lib/continuity-status.js';
13
13
  import { getConnectorHealth } from '../lib/connector-health.js';
14
14
  import { readRepoDecisions, summarizeRepoDecisions } from '../lib/repo-decisions.js';
@@ -21,7 +21,7 @@ import { deriveConflictedTurnResolutionActions } from '../lib/conflict-actions.j
21
21
  import { summarizeLatestGateActionAttempt } from '../lib/gate-actions.js';
22
22
  import { findCurrentHumanEscalation } from '../lib/human-escalations.js';
23
23
  import { getDashboardPid, getDashboardSession } from './dashboard.js';
24
- import { readPreemptionMarker } from '../lib/intake.js';
24
+ import { readPreemptionMarker, findPendingApprovedIntents } from '../lib/intake.js';
25
25
  import { readContinuousSession } from '../lib/continuous-run.js';
26
26
  import { readAllDispatchProgress } from '../lib/dispatch-progress.js';
27
27
 
@@ -133,6 +133,7 @@ function renderGovernedStatus(context, opts) {
133
133
  const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
134
134
  const humanEscalation = findCurrentHumanEscalation(root, state);
135
135
  const preemptionMarker = readPreemptionMarker(root);
136
+ const pendingIntents = findPendingApprovedIntents(root);
136
137
  const continuousSession = readContinuousSession(root);
137
138
  const gateActionAttempt = state?.pending_phase_transition
138
139
  ? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
@@ -175,10 +176,12 @@ function renderGovernedStatus(context, opts) {
175
176
  dispatch_progress: dispatchProgress,
176
177
  human_escalation: humanEscalation,
177
178
  preemption_marker: preemptionMarker,
179
+ pending_intents: pendingIntents,
178
180
  continuous_session: continuousSession,
179
181
  gate_action_attempt: gateActionAttempt,
180
182
  workflow_kit_artifacts: workflowKitArtifacts,
181
183
  dashboard_session: dashboardSessionObj,
184
+ binding_drift: detectActiveTurnBindingDrift(state, config),
182
185
  }, null, 2));
183
186
  return;
184
187
  }
@@ -204,6 +207,21 @@ function renderGovernedStatus(context, opts) {
204
207
  console.log('');
205
208
  }
206
209
 
210
+ // Pending injected intents (BUG-15)
211
+ if (pendingIntents.length > 0) {
212
+ console.log(chalk.yellow.bold(' 📋 Pending injected intents (will drive next turn):'));
213
+ for (const pi of pendingIntents) {
214
+ const priorityColor = pi.priority === 'p0' ? chalk.red.bold : pi.priority === 'p1' ? chalk.yellow.bold : chalk.dim;
215
+ const charterSnippet = pi.charter
216
+ ? (pi.charter.length > 60 ? pi.charter.slice(0, 57) + '...' : pi.charter)
217
+ : '(no charter)';
218
+ console.log(` ${priorityColor(`[${pi.priority}]`)} ${chalk.dim(pi.intent_id)} — ${charterSnippet}`);
219
+ console.log(chalk.dim(` Acceptance: ${pi.acceptance_count} item${pi.acceptance_count !== 1 ? 's' : ''}`));
220
+ }
221
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
222
+ console.log('');
223
+ }
224
+
207
225
  // Continuous session banner
208
226
  if (continuousSession) {
209
227
  console.log(chalk.cyan.bold(' 🔄 Continuous Vision-Driven Session'));
@@ -272,12 +290,14 @@ function renderGovernedStatus(context, opts) {
272
290
  if (activeTurnCount > 1) {
273
291
  console.log(` ${chalk.dim('Turns:')} ${activeTurnCount} active`);
274
292
  for (const turn of Object.values(activeTurns)) {
275
- const marker = turn.status === 'conflicted'
293
+ const marker = (turn.status === 'conflicted' || turn.status === 'failed_acceptance')
276
294
  ? chalk.red('✗')
277
295
  : chalk.yellow('●');
278
296
  const statusLabel = turn.status === 'conflicted'
279
297
  ? chalk.red('conflicted')
280
- : turn.status;
298
+ : turn.status === 'failed_acceptance'
299
+ ? chalk.red('failed_acceptance')
300
+ : turn.status;
281
301
  let elapsedTag = '';
282
302
  if (turn.started_at) {
283
303
  const elMs = Date.now() - new Date(turn.started_at).getTime();
@@ -318,6 +338,11 @@ function renderGovernedStatus(context, opts) {
318
338
  console.log(` ${chalk.dim('Resolve:')} ${chalk.cyan(reassignAction.command)}`);
319
339
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
320
340
  }
341
+ if (turn.status === 'failed_acceptance') {
342
+ console.log(` ${chalk.dim('Reason:')} ${turn.failure_reason || 'unknown'}`);
343
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(`agentxchain reject-turn --turn ${turn.turn_id}`)} — reject and retry`);
344
+ console.log(` ${chalk.dim(' or:')} ${chalk.cyan(`agentxchain accept-turn --turn ${turn.turn_id}`)} — re-attempt acceptance`);
345
+ }
321
346
  }
322
347
  } else if (singleActiveTurn) {
323
348
  console.log(` ${chalk.dim('Turn:')} ${singleActiveTurn.turn_id}`);
@@ -372,6 +397,23 @@ function renderGovernedStatus(context, opts) {
372
397
  console.log(` ${chalk.dim('Turn:')} ${chalk.yellow('No active turn')}`);
373
398
  }
374
399
 
400
+ // Runtime/authority binding drift detection (B-7)
401
+ const bindingDrifts = detectActiveTurnBindingDrift(state, config);
402
+ if (bindingDrifts.length > 0) {
403
+ console.log('');
404
+ console.log(` ${chalk.red.bold('⚠ Stale binding detected')}`);
405
+ for (const drift of bindingDrifts) {
406
+ if (drift.runtime_changed) {
407
+ console.log(` ${chalk.dim('Turn:')} ${drift.turn_id} (${drift.role_id})`);
408
+ console.log(` ${chalk.dim('Runtime:')} ${chalk.yellow(drift.old_runtime)} → ${chalk.green(drift.new_runtime)} (config changed)`);
409
+ }
410
+ if (drift.authority_changed) {
411
+ console.log(` ${chalk.dim('Authority:')} ${chalk.yellow(drift.old_authority)} → ${chalk.green(drift.new_authority)} (config changed)`);
412
+ }
413
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(drift.recovery_command)}`);
414
+ }
415
+ }
416
+
375
417
  // Queued phase/completion requests
376
418
  if (state?.queued_phase_transition) {
377
419
  const qt = state.queued_phase_transition;
@@ -35,6 +35,7 @@ import {
35
35
  getActiveTurnCount,
36
36
  getActiveTurns,
37
37
  reactivateGovernedRun,
38
+ refreshTurnBaselineSnapshot,
38
39
  STATE_PATH,
39
40
  } from '../lib/governed-state.js';
40
41
  import { getMaxConcurrentTurns } from '../lib/normalized-config.js';
@@ -68,6 +69,7 @@ import { finalizeDispatchManifest, verifyDispatchManifest } from '../lib/dispatc
68
69
  import { resolveGovernedRole } from '../lib/role-resolution.js';
69
70
  import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
70
71
  import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
72
+ import { consumeNextApprovedIntent } from '../lib/intake.js';
71
73
 
72
74
  export async function stepCommand(opts) {
73
75
  const context = loadProjectContext();
@@ -213,6 +215,17 @@ export async function stepCommand(opts) {
213
215
  process.exit(1);
214
216
  }
215
217
 
218
+ // If the target turn failed acceptance, print recovery guidance (BUG-3 fix)
219
+ if (targetTurn.status === 'failed_acceptance') {
220
+ console.log(chalk.red(`Turn ${targetTurn.turn_id} (${targetTurn.assigned_role}) failed acceptance.`));
221
+ console.log(chalk.dim(`Reason: ${targetTurn.failure_reason || 'unknown'}`));
222
+ console.log('');
223
+ console.log(chalk.dim('Recovery options:'));
224
+ console.log(` ${chalk.cyan(`agentxchain reject-turn --turn ${targetTurn.turn_id}`)} — reject and retry`);
225
+ console.log(` ${chalk.cyan(`agentxchain accept-turn --turn ${targetTurn.turn_id}`)} — re-attempt acceptance after fixing`);
226
+ process.exit(1);
227
+ }
228
+
216
229
  console.log(chalk.yellow(`Re-dispatching blocked turn: ${targetTurn.turn_id}`));
217
230
  const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
218
231
  if (!reactivated.ok) {
@@ -222,6 +235,10 @@ export async function stepCommand(opts) {
222
235
  state = reactivated.state;
223
236
  skipAssignment = true;
224
237
 
238
+ // BUG-1 fix: refresh baseline snapshot to capture files dirtied between assignment and dispatch
239
+ refreshTurnBaselineSnapshot(root, targetTurn.turn_id);
240
+ state = JSON.parse(readFileSync(join(root, '.agentxchain/state.json'), 'utf8'));
241
+
225
242
  const bundleResult = writeDispatchBundle(root, state, config);
226
243
  if (!bundleResult.ok) {
227
244
  console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
@@ -245,6 +262,10 @@ export async function stepCommand(opts) {
245
262
  state = reactivated.state;
246
263
  skipAssignment = true;
247
264
 
265
+ // BUG-1 fix: refresh baseline snapshot to capture files dirtied between assignment and dispatch
266
+ refreshTurnBaselineSnapshot(root, pausedTurn.turn_id);
267
+ state = JSON.parse(readFileSync(join(root, '.agentxchain/state.json'), 'utf8'));
268
+
248
269
  const bundleResult = writeDispatchBundle(root, state, config);
249
270
  if (!bundleResult.ok) {
250
271
  console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
@@ -294,16 +315,26 @@ export async function stepCommand(opts) {
294
315
  process.exit(1);
295
316
  }
296
317
 
297
- const assignResult = assignGovernedTurn(root, config, roleId);
298
- if (!assignResult.ok) {
299
- if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
300
- printAssignmentHookFailure(assignResult, roleId, config);
318
+ const shouldBindIntent = opts.intent !== false;
319
+ const consumed = shouldBindIntent ? consumeNextApprovedIntent(root, { role: roleId }) : { ok: false };
320
+ if (consumed.ok) {
321
+ state = loadProjectState(root, config);
322
+ if (!state) {
323
+ console.log(chalk.red('Failed to reload governed state after intake binding.'));
324
+ process.exit(1);
301
325
  }
302
- console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
303
- process.exit(1);
326
+ } else {
327
+ const assignResult = assignGovernedTurn(root, config, roleId);
328
+ if (!assignResult.ok) {
329
+ if (assignResult.error_code?.startsWith('hook_') || assignResult.error_code === 'hook_blocked') {
330
+ printAssignmentHookFailure(assignResult, roleId, config);
331
+ }
332
+ console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
333
+ process.exit(1);
334
+ }
335
+ printAssignmentWarnings(assignResult);
336
+ state = assignResult.state;
304
337
  }
305
- printAssignmentWarnings(assignResult);
306
- state = assignResult.state;
307
338
 
308
339
  const bundleResult = writeDispatchBundle(root, state, config);
309
340
  if (!bundleResult.ok) {
@@ -587,13 +618,21 @@ export async function stepCommand(opts) {
587
618
  console.log(chalk.yellow(`The subprocess must independently read from .agentxchain/dispatch/turns/${turn.turn_id}/PROMPT.md`));
588
619
  console.log(chalk.dim('To enable automatic prompt delivery, set prompt_transport to "argv" or "stdin" in the runtime config.'));
589
620
  }
621
+ // BUG-6: always show log file path so operators know where to watch
622
+ const logPath = `.agentxchain/dispatch/turns/${turn.turn_id}/stdout.log`;
623
+ console.log(chalk.dim(`Log: ${logPath}`));
624
+ if (!opts.stream && !opts.verbose) {
625
+ console.log(chalk.dim(` Watch live: tail -f ${logPath}`));
626
+ }
590
627
  console.log(chalk.dim('Press Ctrl+C to abort and leave the turn assigned.'));
591
628
  console.log('');
592
629
 
630
+ // BUG-6: stream subprocess output by default (--stream or --verbose), suppress with --quiet
631
+ const shouldStream = opts.stream || opts.verbose || false;
593
632
  const cliResult = await dispatchLocalCli(root, state, config, {
594
633
  signal: controller.signal,
595
- onStdout: opts.verbose ? (text) => process.stdout.write(chalk.dim(text)) : undefined,
596
- onStderr: opts.verbose ? (text) => process.stderr.write(chalk.yellow(text)) : undefined,
634
+ onStdout: shouldStream ? (text) => process.stdout.write(chalk.dim(text)) : undefined,
635
+ onStderr: shouldStream ? (text) => process.stderr.write(chalk.yellow(text)) : undefined,
597
636
  verifyManifest: true,
598
637
  });
599
638