agentxchain 2.128.0 → 2.130.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.
Files changed (38) hide show
  1. package/README.md +2 -0
  2. package/bin/agentxchain.js +38 -4
  3. package/package.json +1 -1
  4. package/scripts/verify-post-publish.sh +55 -5
  5. package/src/commands/accept-turn.js +14 -0
  6. package/src/commands/checkpoint-turn.js +35 -0
  7. package/src/commands/connector.js +17 -2
  8. package/src/commands/doctor.js +151 -1
  9. package/src/commands/events.js +7 -1
  10. package/src/commands/init.js +42 -11
  11. package/src/commands/inject.js +1 -1
  12. package/src/commands/mission.js +803 -7
  13. package/src/commands/reissue-turn.js +122 -0
  14. package/src/commands/reject-turn.js +60 -6
  15. package/src/commands/restart.js +81 -10
  16. package/src/commands/resume.js +20 -9
  17. package/src/commands/run.js +13 -0
  18. package/src/commands/status.js +58 -4
  19. package/src/commands/step.js +49 -10
  20. package/src/commands/validate.js +78 -20
  21. package/src/lib/cli-version.js +106 -0
  22. package/src/lib/connector-probe.js +146 -5
  23. package/src/lib/continuous-run.js +22 -87
  24. package/src/lib/coordinator-dispatch.js +25 -0
  25. package/src/lib/dispatch-bundle.js +39 -0
  26. package/src/lib/governed-state.js +624 -11
  27. package/src/lib/governed-templates.js +1 -0
  28. package/src/lib/intake.js +233 -77
  29. package/src/lib/mission-plans.js +510 -6
  30. package/src/lib/missions.js +65 -6
  31. package/src/lib/normalized-config.js +50 -15
  32. package/src/lib/repo-observer.js +8 -2
  33. package/src/lib/run-events.js +5 -0
  34. package/src/lib/run-loop.js +25 -0
  35. package/src/lib/runner-interface.js +2 -0
  36. package/src/lib/session-checkpoint.js +18 -2
  37. package/src/lib/turn-checkpoint.js +221 -0
  38. package/src/templates/governed/full-local-cli.json +71 -0
@@ -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) {
@@ -133,8 +153,21 @@ function buildRejectionValidation(root, state, config, opts) {
133
153
  [resolution.turn.turn_id]: resolution.turn,
134
154
  },
135
155
  };
156
+ const stagingPath = resolveStagingPath(root, resolution.turn.turn_id);
157
+ // BUG-22: If resolveStagingPath returns null, a stale result from another turn
158
+ // was detected. Reject with a clear diagnostic instead of consuming it.
159
+ if (stagingPath === null) {
160
+ return {
161
+ ok: true,
162
+ turn: resolution.turn,
163
+ validationResult: {
164
+ errors: [`Stale staging data: .agentxchain/staging/turn-result.json belongs to a different turn. Clean up with: rm .agentxchain/staging/turn-result.json`],
165
+ failed_stage: 'stale_staging',
166
+ },
167
+ };
168
+ }
136
169
  const validation = validateStagedTurnResult(root, projectedState, config, {
137
- stagingPath: resolveStagingPath(root, resolution.turn.turn_id),
170
+ stagingPath,
138
171
  });
139
172
  if (!validation.ok) {
140
173
  return {
@@ -193,8 +226,29 @@ function resolveTargetTurn(state, turnId) {
193
226
  }
194
227
 
195
228
  function resolveStagingPath(root, turnId) {
229
+ // BUG-22: Prefer turn-scoped staging path. Only fall back to legacy global
230
+ // staging if the global result's turn_id matches the active turn.
196
231
  const turnScopedPath = getTurnStagingResultPath(turnId);
197
- return existsSync(join(root, turnScopedPath)) ? turnScopedPath : '.agentxchain/staging/turn-result.json';
232
+ if (existsSync(join(root, turnScopedPath))) {
233
+ return turnScopedPath;
234
+ }
235
+
236
+ const legacyPath = '.agentxchain/staging/turn-result.json';
237
+ const legacyAbs = join(root, legacyPath);
238
+ if (existsSync(legacyAbs)) {
239
+ try {
240
+ const raw = JSON.parse(require('fs').readFileSync(legacyAbs, 'utf8'));
241
+ if (raw.turn_id && raw.turn_id !== turnId) {
242
+ // Stale result from a different turn — do not consume
243
+ return null;
244
+ }
245
+ } catch {
246
+ // Parse error — let the validator handle it
247
+ }
248
+ return legacyPath;
249
+ }
250
+
251
+ return legacyPath; // File doesn't exist — validator will report "not found"
198
252
  }
199
253
 
200
254
  function printDispatchBundleWarnings(bundleResult) {
@@ -19,10 +19,15 @@ import {
19
19
  getActiveTurns,
20
20
  getActiveTurnCount,
21
21
  reactivateGovernedRun,
22
+ detectStateBundleDesync,
22
23
  STATE_PATH,
23
24
  HISTORY_PATH,
24
25
  LEDGER_PATH,
25
26
  } from '../lib/governed-state.js';
27
+ import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
28
+ import { getDispatchTurnDir } from '../lib/turn-paths.js';
29
+ import { consumeNextApprovedIntent } from '../lib/intake.js';
30
+ import { loadProjectState } from '../lib/config.js';
26
31
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
27
32
  import { deriveRecommendedContinuityAction } from '../lib/continuity-status.js';
28
33
  import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
@@ -213,6 +218,25 @@ export async function restartCommand(opts) {
213
218
  process.exit(1);
214
219
  }
215
220
 
221
+ // ── BUG-18: State/bundle integrity check ─────────────────────────────────
222
+ const desync = detectStateBundleDesync(root, state);
223
+ if (!desync.ok) {
224
+ console.log(chalk.red('State/bundle integrity failure detected:'));
225
+ for (const entry of desync.desynced) {
226
+ console.log(chalk.red(` Active turn ${entry.turn_id} (${entry.role}) has no dispatch bundle at ${entry.expected_path}`));
227
+ }
228
+ console.log('');
229
+ console.log(chalk.dim('This is a ghost turn — state references an active turn but the dispatch files are missing.'));
230
+ console.log(chalk.dim('Recovery options:'));
231
+ for (const entry of desync.desynced) {
232
+ console.log(` ${chalk.cyan(`agentxchain reissue-turn --turn ${entry.turn_id} --reason "missing dispatch bundle"`)}`);
233
+ }
234
+ console.log(` ${chalk.cyan('agentxchain reject-turn --reason "ghost turn — missing dispatch bundle"')}`);
235
+ console.log('');
236
+ console.log(chalk.dim('Run `agentxchain doctor` for a full diagnostic.'));
237
+ process.exit(1);
238
+ }
239
+
216
240
  // ── Repo-drift detection ────────────────────────────────────────────────
217
241
  const driftWarnings = [];
218
242
  if (checkpoint?.baseline_ref) {
@@ -265,10 +289,17 @@ export async function restartCommand(opts) {
265
289
  console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
266
290
  console.log(chalk.dim('These turns will be available for the next agent to complete.'));
267
291
 
268
- // Fail closed if retained turn + irreconcilable drift
292
+ // Fail closed if retained turn + irreconcilable drift — BUG-10 fix: surface actionable recovery
269
293
  if (driftWarnings.length > 0) {
270
294
  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.'));
295
+ console.log('');
296
+ console.log(chalk.dim('Recovery options:'));
297
+ for (const turnId of turnIds) {
298
+ const turn = activeTurns[turnId];
299
+ console.log(` ${chalk.cyan(`agentxchain reissue-turn --turn ${turnId} --reason "baseline drift"`)} — reissue ${turn.assigned_role} from current HEAD`);
300
+ }
301
+ console.log(` ${chalk.cyan('agentxchain reject-turn --reason "baseline drift"')} — reject and retry with refreshed baseline`);
302
+ console.log(` ${chalk.dim('Continue as-is if the drift does not affect the retained turns.')}`);
272
303
  }
273
304
  }
274
305
 
@@ -309,21 +340,61 @@ export async function restartCommand(opts) {
309
340
 
310
341
  // Assign next turn if no active turn exists
311
342
  if (activeTurnCount === 0) {
312
- const assignment = assignGovernedTurn(root, config, roleId);
313
- if (!assignment.ok) {
314
- console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
315
- process.exit(1);
343
+ // BUG-21 fix: consume approved intents (same as resume path) so intent_id
344
+ // propagates into turn metadata and all lifecycle events.
345
+ const consumed = consumeNextApprovedIntent(root, { role: roleId });
346
+ let assignedState;
347
+ let turnId;
348
+ let assignedRole = roleId;
349
+
350
+ if (consumed.ok) {
351
+ // Intake path handled the turn assignment with intakeContext
352
+ assignedState = loadProjectState(root, config);
353
+ if (!assignedState) {
354
+ console.log(chalk.red('Failed to reload governed state after intake binding.'));
355
+ process.exit(1);
356
+ }
357
+ turnId = consumed.turn_id;
358
+ assignedRole = consumed.role || roleId;
359
+ console.log(chalk.green(`Bound approved intent to next turn: ${consumed.intentId}`));
360
+ } else {
361
+ // No approved intents — plain assignment
362
+ const assignment = assignGovernedTurn(root, config, roleId);
363
+ if (!assignment.ok) {
364
+ console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
365
+ process.exit(1);
366
+ }
367
+ for (const warning of assignment.warnings || []) {
368
+ console.log(chalk.yellow(`Warning: ${warning}`));
369
+ }
370
+ assignedState = assignment.state;
371
+ turnId = assignment.turn?.turn_id || assignment.turn?.id;
372
+ assignedRole = assignment.turn?.assigned_role || roleId;
316
373
  }
317
- for (const warning of assignment.warnings || []) {
318
- console.log(chalk.yellow(`Warning: ${warning}`));
374
+
375
+ // BUG-17 fix: write dispatch bundle AFTER state assignment succeeds.
376
+ // The bundle must exist on disk before we report success, otherwise the
377
+ // operator sees a "ghost turn" in state with no dispatch directory.
378
+ if (turnId) {
379
+ const bundleResult = writeDispatchBundle(root, assignedState, config, { turnId });
380
+ if (!bundleResult.ok) {
381
+ console.log(chalk.red(`Turn assigned but dispatch bundle write failed: ${bundleResult.error}`));
382
+ console.log(chalk.dim('The turn is assigned in state but has no dispatch context.'));
383
+ console.log(chalk.dim('Run `agentxchain reissue-turn` to reissue with a fresh bundle.'));
384
+ process.exit(1);
385
+ }
386
+ for (const bw of bundleResult.warnings || []) {
387
+ console.log(chalk.yellow(`Dispatch bundle warning: ${bw}`));
388
+ }
319
389
  }
320
390
 
321
391
  // assignGovernedTurn already writes a checkpoint at turn_assigned
322
392
 
323
393
  console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
324
394
  console.log(chalk.dim(` Phase: ${phase}`));
325
- console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
326
- console.log(chalk.dim(` Role: ${assignment.turn?.role || roleId || 'routing default'}`));
395
+ console.log(chalk.dim(` Turn: ${turnId || 'assigned'}`));
396
+ console.log(chalk.dim(` Role: ${assignedRole || 'routing default'}`));
397
+ console.log(chalk.dim(` Dispatch: ${getDispatchTurnDir(turnId || 'unknown')}/`));
327
398
  if (checkpoint) {
328
399
  console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
329
400
  }
@@ -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);
@@ -48,6 +48,7 @@ import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
48
48
  import { resolveContinuousOptions, executeContinuousRun } from '../lib/continuous-run.js';
49
49
  import { createDispatchProgressTracker } from '../lib/dispatch-progress.js';
50
50
  import { emitRunEvent } from '../lib/run-events.js';
51
+ import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
51
52
 
52
53
  export async function runCommand(opts) {
53
54
  const context = loadProjectContext();
@@ -148,6 +149,7 @@ export async function executeGovernedRun(context, opts = {}) {
148
149
 
149
150
  const maxTurns = opts.maxTurns || 50;
150
151
  const autoApprove = !!opts.autoApprove;
152
+ const autoCheckpoint = opts.autoCheckpoint === true;
151
153
  const verbose = !!opts.verbose;
152
154
  const overrideResolution = opts.role
153
155
  ? resolveGovernedRole({ override: opts.role, state: null, config })
@@ -499,6 +501,17 @@ export async function executeGovernedRun(context, opts = {}) {
499
501
  return approved;
500
502
  },
501
503
 
504
+ async afterAccept({ turn }) {
505
+ if (!autoCheckpoint) {
506
+ return { ok: true };
507
+ }
508
+ const checkpoint = checkpointAcceptedTurn(root, { turnId: turn.turn_id });
509
+ if (!checkpoint.ok) {
510
+ return { ok: false, error: checkpoint.error || `checkpoint failed for ${turn.turn_id}` };
511
+ }
512
+ return { ok: true };
513
+ },
514
+
502
515
  onEvent(event) {
503
516
  switch (event.type) {
504
517
  case 'turn_assigned':
@@ -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, detectStateBundleDesync } 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,13 @@ 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),
185
+ bundle_integrity: detectStateBundleDesync(root, state),
182
186
  }, null, 2));
183
187
  return;
184
188
  }
@@ -204,6 +208,21 @@ function renderGovernedStatus(context, opts) {
204
208
  console.log('');
205
209
  }
206
210
 
211
+ // Pending injected intents (BUG-15)
212
+ if (pendingIntents.length > 0) {
213
+ console.log(chalk.yellow.bold(' 📋 Pending injected intents (will drive next turn):'));
214
+ for (const pi of pendingIntents) {
215
+ const priorityColor = pi.priority === 'p0' ? chalk.red.bold : pi.priority === 'p1' ? chalk.yellow.bold : chalk.dim;
216
+ const charterSnippet = pi.charter
217
+ ? (pi.charter.length > 60 ? pi.charter.slice(0, 57) + '...' : pi.charter)
218
+ : '(no charter)';
219
+ console.log(` ${priorityColor(`[${pi.priority}]`)} ${chalk.dim(pi.intent_id)} — ${charterSnippet}`);
220
+ console.log(chalk.dim(` Acceptance: ${pi.acceptance_count} item${pi.acceptance_count !== 1 ? 's' : ''}`));
221
+ }
222
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
223
+ console.log('');
224
+ }
225
+
207
226
  // Continuous session banner
208
227
  if (continuousSession) {
209
228
  console.log(chalk.cyan.bold(' 🔄 Continuous Vision-Driven Session'));
@@ -266,18 +285,31 @@ function renderGovernedStatus(context, opts) {
266
285
  renderConnectorHealthStatus(connectorHealth);
267
286
  renderRecentEventSummary(recentEventSummary);
268
287
 
288
+ // BUG-18: State/bundle integrity check
289
+ const desync = detectStateBundleDesync(root, state);
290
+ if (!desync.ok) {
291
+ console.log(chalk.red.bold(' ⚠ Ghost turn(s) detected — dispatch bundle missing'));
292
+ for (const entry of desync.desynced) {
293
+ console.log(chalk.red(` ${entry.turn_id} (${entry.role}): ${entry.expected_path} not found`));
294
+ }
295
+ console.log(chalk.dim(' Run `agentxchain reissue-turn` to recover, or `agentxchain doctor` for diagnostics.'));
296
+ console.log('');
297
+ }
298
+
269
299
  const activeTurnCount = getActiveTurnCount(state);
270
300
  const singleActiveTurn = getActiveTurn(state);
271
301
  const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
272
302
  if (activeTurnCount > 1) {
273
303
  console.log(` ${chalk.dim('Turns:')} ${activeTurnCount} active`);
274
304
  for (const turn of Object.values(activeTurns)) {
275
- const marker = turn.status === 'conflicted'
305
+ const marker = (turn.status === 'conflicted' || turn.status === 'failed_acceptance')
276
306
  ? chalk.red('✗')
277
307
  : chalk.yellow('●');
278
308
  const statusLabel = turn.status === 'conflicted'
279
309
  ? chalk.red('conflicted')
280
- : turn.status;
310
+ : turn.status === 'failed_acceptance'
311
+ ? chalk.red('failed_acceptance')
312
+ : turn.status;
281
313
  let elapsedTag = '';
282
314
  if (turn.started_at) {
283
315
  const elMs = Date.now() - new Date(turn.started_at).getTime();
@@ -318,6 +350,11 @@ function renderGovernedStatus(context, opts) {
318
350
  console.log(` ${chalk.dim('Resolve:')} ${chalk.cyan(reassignAction.command)}`);
319
351
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
320
352
  }
353
+ if (turn.status === 'failed_acceptance') {
354
+ console.log(` ${chalk.dim('Reason:')} ${turn.failure_reason || 'unknown'}`);
355
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(`agentxchain reject-turn --turn ${turn.turn_id}`)} — reject and retry`);
356
+ console.log(` ${chalk.dim(' or:')} ${chalk.cyan(`agentxchain accept-turn --turn ${turn.turn_id}`)} — re-attempt acceptance`);
357
+ }
321
358
  }
322
359
  } else if (singleActiveTurn) {
323
360
  console.log(` ${chalk.dim('Turn:')} ${singleActiveTurn.turn_id}`);
@@ -372,6 +409,23 @@ function renderGovernedStatus(context, opts) {
372
409
  console.log(` ${chalk.dim('Turn:')} ${chalk.yellow('No active turn')}`);
373
410
  }
374
411
 
412
+ // Runtime/authority binding drift detection (B-7)
413
+ const bindingDrifts = detectActiveTurnBindingDrift(state, config);
414
+ if (bindingDrifts.length > 0) {
415
+ console.log('');
416
+ console.log(` ${chalk.red.bold('⚠ Stale binding detected')}`);
417
+ for (const drift of bindingDrifts) {
418
+ if (drift.runtime_changed) {
419
+ console.log(` ${chalk.dim('Turn:')} ${drift.turn_id} (${drift.role_id})`);
420
+ console.log(` ${chalk.dim('Runtime:')} ${chalk.yellow(drift.old_runtime)} → ${chalk.green(drift.new_runtime)} (config changed)`);
421
+ }
422
+ if (drift.authority_changed) {
423
+ console.log(` ${chalk.dim('Authority:')} ${chalk.yellow(drift.old_authority)} → ${chalk.green(drift.new_authority)} (config changed)`);
424
+ }
425
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(drift.recovery_command)}`);
426
+ }
427
+ }
428
+
375
429
  // Queued phase/completion requests
376
430
  if (state?.queued_phase_transition) {
377
431
  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