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
@@ -26,6 +26,7 @@ import { evaluatePolicies } from './policy-evaluator.js';
26
26
  import { buildTimeoutBlockedReason, evaluateTimeouts } from './timeout-evaluator.js';
27
27
  import {
28
28
  captureBaseline,
29
+ captureDirtyWorkspaceSnapshot,
29
30
  observeChanges,
30
31
  attributeObservedChangesToTurn,
31
32
  buildConflictCandidateFiles,
@@ -35,6 +36,7 @@ import {
35
36
  compareDeclaredVsObserved,
36
37
  deriveAcceptedRef,
37
38
  checkCleanBaseline,
39
+ isOperationalPath,
38
40
  } from './repo-observer.js';
39
41
  import { getMaxConcurrentTurns } from './normalized-config.js';
40
42
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
@@ -60,6 +62,7 @@ import {
60
62
  summarizeVerificationReplay,
61
63
  } from './verification-replay.js';
62
64
  import { executeGateActions } from './gate-actions.js';
65
+ import { detectPendingCheckpoint } from './turn-checkpoint.js';
63
66
 
64
67
  // ── Constants ────────────────────────────────────────────────────────────────
65
68
 
@@ -403,6 +406,32 @@ export function getActiveTurn(state) {
403
406
  return turns.length === 1 ? turns[0] : null;
404
407
  }
405
408
 
409
+ /**
410
+ * BUG-18: Detect state/bundle desync — every active turn referenced in
411
+ * state.json must have a corresponding dispatch bundle directory on disk.
412
+ *
413
+ * @param {string} root - project root
414
+ * @param {object} state - governed state
415
+ * @returns {{ ok: boolean, desynced: Array<{ turn_id: string, role: string, expected_path: string }> }}
416
+ */
417
+ export function detectStateBundleDesync(root, state) {
418
+ const activeTurns = getActiveTurns(state);
419
+ const desynced = [];
420
+
421
+ for (const [turnId, turn] of Object.entries(activeTurns)) {
422
+ const bundleDir = join(root, '.agentxchain', 'dispatch', 'turns', turnId);
423
+ if (!existsSync(bundleDir)) {
424
+ desynced.push({
425
+ turn_id: turnId,
426
+ role: turn.assigned_role || 'unknown',
427
+ expected_path: `.agentxchain/dispatch/turns/${turnId}`,
428
+ });
429
+ }
430
+ }
431
+
432
+ return { ok: desynced.length === 0, desynced };
433
+ }
434
+
406
435
  function resolveRecoveryTurnId(state, preferredTurnId = null) {
407
436
  const activeTurns = getActiveTurns(state);
408
437
  if (preferredTurnId && activeTurns[preferredTurnId]) {
@@ -2059,7 +2088,7 @@ export function initializeGovernedRun(root, config, options = {}) {
2059
2088
  * @param {string} roleId - the role to assign
2060
2089
  * @returns {{ ok: boolean, error?: string, warnings?: string[], state?: object }}
2061
2090
  */
2062
- export function assignGovernedTurn(root, config, roleId) {
2091
+ export function assignGovernedTurn(root, config, roleId, options = {}) {
2063
2092
  let state = readState(root);
2064
2093
  if (!state) {
2065
2094
  return { ok: false, error: 'No governed state.json found' };
@@ -2161,6 +2190,10 @@ export function assignGovernedTurn(root, config, roleId) {
2161
2190
  const writeAuthority = role.write_authority || 'review_only';
2162
2191
  const cleanCheck = checkCleanBaseline(root, writeAuthority);
2163
2192
  if (!cleanCheck.clean) {
2193
+ const pendingCheckpoint = detectPendingCheckpoint(root, cleanCheck.dirty_files || []);
2194
+ if (pendingCheckpoint.required) {
2195
+ return { ok: false, error: pendingCheckpoint.message, error_code: 'checkpoint_required' };
2196
+ }
2164
2197
  return { ok: false, error: cleanCheck.reason };
2165
2198
  }
2166
2199
 
@@ -2243,6 +2276,9 @@ export function assignGovernedTurn(root, config, roleId) {
2243
2276
  assigned_sequence: nextSequence,
2244
2277
  concurrent_with: concurrentWith,
2245
2278
  };
2279
+ if (options.intakeContext) {
2280
+ newTurn.intake_context = options.intakeContext;
2281
+ }
2246
2282
 
2247
2283
  // Attach delegation context if this turn fulfills a pending delegation
2248
2284
  const delegationQueue = state.delegation_queue || [];
@@ -2288,12 +2324,15 @@ export function assignGovernedTurn(root, config, roleId) {
2288
2324
  phase: updatedState.phase,
2289
2325
  status: updatedState.status,
2290
2326
  turn: { turn_id: turnId, role_id: roleId },
2327
+ intent_id: options.intakeContext?.intent_id || null,
2291
2328
  });
2292
2329
 
2293
- // Session checkpoint — non-fatal, written after every successful turn assignment
2330
+ // Session checkpoint — non-fatal, written after every successful turn assignment.
2331
+ // Pass the captured baseline so session.json agrees with state.json (BUG-2 fix).
2294
2332
  writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
2295
2333
  role: roleId,
2296
2334
  dispatch_dir: `.agentxchain/dispatch/turns/${turnId}`,
2335
+ turn_baseline: baseline,
2297
2336
  });
2298
2337
 
2299
2338
  const assignedTurn = updatedState.active_turns[turnId];
@@ -2304,6 +2343,274 @@ export function assignGovernedTurn(root, config, roleId) {
2304
2343
  return result;
2305
2344
  }
2306
2345
 
2346
+ /**
2347
+ * Refresh a turn's baseline dirty_snapshot to include files that became dirty
2348
+ * between assignment and dispatch. This prevents acceptance from blaming the
2349
+ * turn for operator edits made while the turn was pending (BUG-1 fix).
2350
+ *
2351
+ * Merges new dirty entries into the existing snapshot — existing entries are
2352
+ * preserved, new entries are added. The baseline.clean flag is updated to
2353
+ * reflect the merged snapshot.
2354
+ *
2355
+ * @param {string} root - project root
2356
+ * @param {string} [turnId] - specific turn to refresh (defaults to single active turn)
2357
+ * @returns {{ ok: boolean, refreshed_files?: string[], error?: string }}
2358
+ */
2359
+ export function refreshTurnBaselineSnapshot(root, turnId) {
2360
+ const state = readState(root);
2361
+ if (!state) return { ok: false, error: 'No governed state found' };
2362
+
2363
+ const activeTurns = getActiveTurns(state);
2364
+ const resolvedTurnId = turnId || Object.keys(activeTurns)[0];
2365
+ if (!resolvedTurnId) return { ok: false, error: 'No active turn to refresh' };
2366
+
2367
+ const turn = activeTurns[resolvedTurnId];
2368
+ if (!turn) return { ok: false, error: `Turn ${resolvedTurnId} not found in active turns` };
2369
+ if (!turn.baseline) return { ok: false, error: 'Turn has no baseline to refresh' };
2370
+
2371
+ const currentSnapshot = captureDirtyWorkspaceSnapshot(root);
2372
+ const existingSnapshot = turn.baseline.dirty_snapshot || {};
2373
+ const merged = { ...existingSnapshot };
2374
+ const refreshedFiles = [];
2375
+
2376
+ for (const [filePath, marker] of Object.entries(currentSnapshot)) {
2377
+ if (!(filePath in merged)) {
2378
+ merged[filePath] = marker;
2379
+ refreshedFiles.push(filePath);
2380
+ }
2381
+ }
2382
+
2383
+ if (refreshedFiles.length === 0) {
2384
+ return { ok: true, refreshed_files: [] };
2385
+ }
2386
+
2387
+ // Update the turn's baseline with the merged snapshot
2388
+ const updatedTurn = {
2389
+ ...turn,
2390
+ baseline: {
2391
+ ...turn.baseline,
2392
+ dirty_snapshot: merged,
2393
+ // Recalculate clean: filter out operational and baseline-exempt paths
2394
+ clean: Object.keys(merged).filter(f => !isOperationalPath(f)).length === 0,
2395
+ },
2396
+ };
2397
+
2398
+ const updatedState = {
2399
+ ...state,
2400
+ active_turns: {
2401
+ ...activeTurns,
2402
+ [resolvedTurnId]: updatedTurn,
2403
+ },
2404
+ };
2405
+ writeState(root, updatedState);
2406
+
2407
+ return { ok: true, refreshed_files: refreshedFiles };
2408
+ }
2409
+
2410
+ /**
2411
+ * Reissue an active turn against current repo state.
2412
+ *
2413
+ * Invalidates the current turn, archives its state, captures a fresh baseline
2414
+ * from current HEAD/workspace, and creates a new turn with the same role and
2415
+ * phase. Covers baseline drift, runtime drift, authority drift, and operator-
2416
+ * initiated reissue. (BUG-7 fix)
2417
+ *
2418
+ * @param {string} root - project root
2419
+ * @param {object} config - normalized config
2420
+ * @param {object} opts
2421
+ * @param {string} [opts.turnId] - specific turn to reissue
2422
+ * @param {string} [opts.reason] - reason for reissue
2423
+ * @returns {{ ok: boolean, state?: object, newTurn?: object, baselineDelta?: object, error?: string }}
2424
+ */
2425
+ /**
2426
+ * Detect runtime/authority drift for all active turns against current config.
2427
+ * Returns an array of drift descriptors. Empty array = no drift.
2428
+ */
2429
+ export function detectActiveTurnBindingDrift(state, config) {
2430
+ const activeTurns = getActiveTurns(state);
2431
+ const drifts = [];
2432
+
2433
+ for (const turn of Object.values(activeTurns)) {
2434
+ const roleId = turn.assigned_role;
2435
+ const role = config.roles?.[roleId];
2436
+ if (!role) continue;
2437
+
2438
+ const currentRuntimeId = role.runtime_id || role.runtime;
2439
+ const currentAuthority = role.write_authority;
2440
+ const turnRuntimeId = turn.runtime_id;
2441
+
2442
+ const runtimeChanged = currentRuntimeId && turnRuntimeId && currentRuntimeId !== turnRuntimeId;
2443
+ const authorityChanged = currentAuthority && turn.write_authority && currentAuthority !== turn.write_authority;
2444
+
2445
+ // Check runtime type change even if runtime_id matches
2446
+ const currentRuntimeType = config.runtimes?.[currentRuntimeId]?.type;
2447
+ const turnRuntimeType = config.runtimes?.[turnRuntimeId]?.type;
2448
+ const runtimeTypeChanged = currentRuntimeType && turnRuntimeType && currentRuntimeType !== turnRuntimeType;
2449
+
2450
+ if (runtimeChanged || authorityChanged || runtimeTypeChanged) {
2451
+ drifts.push({
2452
+ turn_id: turn.turn_id,
2453
+ role_id: roleId,
2454
+ runtime_changed: Boolean(runtimeChanged || runtimeTypeChanged),
2455
+ old_runtime: turnRuntimeId,
2456
+ new_runtime: currentRuntimeId,
2457
+ authority_changed: Boolean(authorityChanged),
2458
+ old_authority: turn.write_authority,
2459
+ new_authority: currentAuthority,
2460
+ recovery_command: `agentxchain reissue-turn --turn ${turn.turn_id} --reason "config rebinding"`,
2461
+ });
2462
+ }
2463
+ }
2464
+
2465
+ return drifts;
2466
+ }
2467
+
2468
+ export function reissueTurn(root, config, opts = {}) {
2469
+ const state = readState(root);
2470
+ if (!state) return { ok: false, error: 'No governed state found' };
2471
+
2472
+ const activeTurns = getActiveTurns(state);
2473
+ const turnId = opts.turnId || Object.keys(activeTurns)[0];
2474
+ if (!turnId) return { ok: false, error: 'No active turn to reissue' };
2475
+
2476
+ const oldTurn = activeTurns[turnId];
2477
+ if (!oldTurn) return { ok: false, error: `Turn ${turnId} not found in active turns` };
2478
+
2479
+ const roleId = oldTurn.assigned_role;
2480
+ const role = config.roles?.[roleId];
2481
+ if (!role) return { ok: false, error: `Role "${roleId}" not found in config` };
2482
+
2483
+ const reason = opts.reason || 'operator-initiated reissue';
2484
+ const now = new Date().toISOString();
2485
+
2486
+ // Capture old baseline for delta computation
2487
+ const oldBaseline = oldTurn.baseline || {};
2488
+ const oldRuntimeId = oldTurn.runtime_id;
2489
+
2490
+ // Resolve current runtime binding (may have changed in config)
2491
+ const currentRuntimeId = role.runtime;
2492
+ const currentRuntime = config.runtimes?.[currentRuntimeId];
2493
+ if (!currentRuntime) {
2494
+ return { ok: false, error: `Runtime "${currentRuntimeId}" not found in config for role "${roleId}"` };
2495
+ }
2496
+
2497
+ // Capture fresh baseline
2498
+ const newBaseline = captureBaseline(root);
2499
+
2500
+ // Archive the old turn as a history entry
2501
+ appendJsonl(root, HISTORY_PATH, {
2502
+ turn_id: oldTurn.turn_id,
2503
+ run_id: state.run_id,
2504
+ role: roleId,
2505
+ phase: state.phase,
2506
+ status: 'reissued',
2507
+ summary: `Turn reissued: ${reason}`,
2508
+ files_changed: [],
2509
+ assigned_sequence: oldTurn.assigned_sequence,
2510
+ accepted_at: now,
2511
+ duration_ms: oldTurn.started_at ? Date.now() - new Date(oldTurn.started_at).getTime() : 0,
2512
+ reissue_reason: reason,
2513
+ });
2514
+
2515
+ // Decision ledger entry
2516
+ appendJsonl(root, LEDGER_PATH, {
2517
+ timestamp: now,
2518
+ decision: 'turn_reissued',
2519
+ turn_id: oldTurn.turn_id,
2520
+ role: roleId,
2521
+ phase: state.phase,
2522
+ reason,
2523
+ old_baseline: {
2524
+ head_ref: oldBaseline.head_ref,
2525
+ clean: oldBaseline.clean,
2526
+ },
2527
+ new_baseline: {
2528
+ head_ref: newBaseline.head_ref,
2529
+ clean: newBaseline.clean,
2530
+ },
2531
+ });
2532
+
2533
+ // Create the new turn
2534
+ const newTurnId = `turn_${randomBytes(8).toString('hex')}`;
2535
+ const timeoutMinutes = 20;
2536
+ const nextSequence = (state.turn_sequence || 0) + 1;
2537
+
2538
+ const newTurn = {
2539
+ turn_id: newTurnId,
2540
+ assigned_role: roleId,
2541
+ status: 'running',
2542
+ attempt: (oldTurn.attempt || 1) + 1,
2543
+ started_at: now,
2544
+ deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
2545
+ runtime_id: currentRuntimeId,
2546
+ baseline: newBaseline,
2547
+ assigned_sequence: nextSequence,
2548
+ concurrent_with: Object.keys(activeTurns).filter(id => id !== turnId),
2549
+ reissued_from: oldTurn.turn_id,
2550
+ };
2551
+
2552
+ // Copy delegation context if present
2553
+ if (oldTurn.delegation_context) {
2554
+ newTurn.delegation_context = oldTurn.delegation_context;
2555
+ }
2556
+
2557
+ // Remove old turn, add new turn
2558
+ const newActiveTurns = { ...activeTurns };
2559
+ delete newActiveTurns[turnId];
2560
+ newActiveTurns[newTurnId] = newTurn;
2561
+
2562
+ const updatedState = {
2563
+ ...state,
2564
+ turn_sequence: nextSequence,
2565
+ active_turns: newActiveTurns,
2566
+ };
2567
+
2568
+ writeState(root, updatedState);
2569
+
2570
+ // Emit event
2571
+ emitRunEvent(root, 'turn_reissued', {
2572
+ run_id: state.run_id,
2573
+ phase: state.phase,
2574
+ status: state.status,
2575
+ turn: { turn_id: newTurnId, role_id: roleId },
2576
+ intent_id: oldTurn.intake_context?.intent_id || null,
2577
+ payload: {
2578
+ old_turn_id: oldTurn.turn_id,
2579
+ reason,
2580
+ old_head: oldBaseline.head_ref,
2581
+ new_head: newBaseline.head_ref,
2582
+ old_runtime: oldRuntimeId,
2583
+ new_runtime: currentRuntimeId,
2584
+ },
2585
+ });
2586
+
2587
+ // Session checkpoint
2588
+ writeSessionCheckpoint(root, updatedState, 'turn_reissued', {
2589
+ role: roleId,
2590
+ turn_baseline: newBaseline,
2591
+ });
2592
+
2593
+ // Compute baseline delta for display
2594
+ const baselineDelta = {
2595
+ head_changed: oldBaseline.head_ref !== newBaseline.head_ref,
2596
+ old_head: oldBaseline.head_ref,
2597
+ new_head: newBaseline.head_ref,
2598
+ runtime_changed: oldRuntimeId !== currentRuntimeId,
2599
+ old_runtime: oldRuntimeId,
2600
+ new_runtime: currentRuntimeId,
2601
+ dirty_files_changed: JSON.stringify(oldBaseline.dirty_snapshot || {}) !== JSON.stringify(newBaseline.dirty_snapshot || {}),
2602
+ added_dirty_files: Object.keys(newBaseline.dirty_snapshot || {}).filter(f => !(f in (oldBaseline.dirty_snapshot || {}))),
2603
+ removed_dirty_files: Object.keys(oldBaseline.dirty_snapshot || {}).filter(f => !(f in (newBaseline.dirty_snapshot || {}))),
2604
+ };
2605
+
2606
+ return {
2607
+ ok: true,
2608
+ state: attachLegacyCurrentTurnAlias(updatedState),
2609
+ newTurn,
2610
+ baselineDelta,
2611
+ };
2612
+ }
2613
+
2307
2614
  /**
2308
2615
  * Estimate the budget for a single turn based on role/runtime configuration.
2309
2616
  * Used for DEC-PARALLEL-011 budget reservation.
@@ -2366,6 +2673,51 @@ export function acceptGovernedTurn(root, config, opts = {}) {
2366
2673
  }
2367
2674
  }
2368
2675
 
2676
+ /**
2677
+ * Transition an active turn to failed_acceptance and emit the corresponding
2678
+ * event. Called from acceptance failure paths so the turn is never left stuck
2679
+ * in 'running' after the subprocess has exited (BUG-3 + BUG-4 fix).
2680
+ */
2681
+ function transitionToFailedAcceptance(root, state, turn, reason, details = {}) {
2682
+ const activeTurns = getActiveTurns(state);
2683
+ const updatedTurn = {
2684
+ ...turn,
2685
+ status: 'failed_acceptance',
2686
+ failed_at: new Date().toISOString(),
2687
+ failure_reason: reason,
2688
+ };
2689
+ const updatedState = {
2690
+ ...state,
2691
+ active_turns: {
2692
+ ...activeTurns,
2693
+ [turn.turn_id]: updatedTurn,
2694
+ },
2695
+ };
2696
+ writeState(root, updatedState);
2697
+
2698
+ // BUG-4: emit acceptance_failed event
2699
+ emitRunEvent(root, 'acceptance_failed', {
2700
+ run_id: state.run_id,
2701
+ phase: state.phase,
2702
+ status: state.status,
2703
+ turn: { turn_id: turn.turn_id, role_id: turn.assigned_role },
2704
+ intent_id: turn.intake_context?.intent_id || null,
2705
+ payload: {
2706
+ reason,
2707
+ error_code: details.error_code || 'acceptance_failed',
2708
+ stage: details.stage || 'unknown',
2709
+ ...details.extra,
2710
+ },
2711
+ });
2712
+
2713
+ // Session checkpoint for recovery
2714
+ writeSessionCheckpoint(root, updatedState, 'acceptance_failed', {
2715
+ role: turn.assigned_role,
2716
+ });
2717
+
2718
+ return updatedState;
2719
+ }
2720
+
2369
2721
  function _acceptGovernedTurnLocked(root, config, opts) {
2370
2722
  // Re-read state under lock (a sibling acceptance may have committed)
2371
2723
  let state = readState(root);
@@ -2433,7 +2785,25 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2433
2785
  }
2434
2786
 
2435
2787
  const turnStagingPath = getTurnStagingResultPath(currentTurn.turn_id);
2436
- const resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
2788
+ let resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
2789
+ // BUG-22: verify legacy staging file belongs to the active turn before consuming
2790
+ if (resolvedStagingPath === STAGING_PATH) {
2791
+ try {
2792
+ const legacyAbs = join(root, STAGING_PATH);
2793
+ if (existsSync(legacyAbs)) {
2794
+ const raw = JSON.parse(readFileSync(legacyAbs, 'utf8'));
2795
+ if (raw.turn_id && raw.turn_id !== currentTurn.turn_id) {
2796
+ return {
2797
+ ok: false,
2798
+ error: `Stale staging data: ${STAGING_PATH} contains turn_id "${raw.turn_id}" but active turn is "${currentTurn.turn_id}". Remove the stale file or use the turn-scoped staging path.`,
2799
+ error_code: 'stale_staging',
2800
+ };
2801
+ }
2802
+ }
2803
+ } catch {
2804
+ // Parse error handled by downstream validation
2805
+ }
2806
+ }
2437
2807
  const stagedTurn = loadHookStagedTurn(root, resolvedStagingPath);
2438
2808
  const validationState = attachLegacyCurrentTurnAlias({
2439
2809
  ...state,
@@ -2526,9 +2896,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2526
2896
  }
2527
2897
 
2528
2898
  if (!validation.ok) {
2899
+ const failError = `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`;
2900
+ transitionToFailedAcceptance(root, state, currentTurn, failError, {
2901
+ error_code: 'validation_failed',
2902
+ stage: validation.stage,
2903
+ extra: { errors: validation.errors },
2904
+ });
2529
2905
  return {
2530
2906
  ok: false,
2531
- error: `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`,
2907
+ error: failError,
2532
2908
  validation,
2533
2909
  };
2534
2910
  }
@@ -2541,9 +2917,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2541
2917
  if (dec.overrides) {
2542
2918
  const overrideCheck = validateOverride(root, { ...dec, role: dec.role || turnResult.role }, config);
2543
2919
  if (!overrideCheck.ok) {
2920
+ const overrideError = `Override validation failed: ${overrideCheck.error}`;
2921
+ transitionToFailedAcceptance(root, state, currentTurn, overrideError, {
2922
+ error_code: 'override_validation_failed',
2923
+ stage: 'override_validation',
2924
+ });
2544
2925
  return {
2545
2926
  ok: false,
2546
- error: `Override validation failed: ${overrideCheck.error}`,
2927
+ error: overrideError,
2547
2928
  error_code: 'override_validation_failed',
2548
2929
  };
2549
2930
  }
@@ -2598,9 +2979,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2598
2979
  },
2599
2980
  );
2600
2981
  if (diffComparison.errors.length > 0) {
2982
+ const mismatchError = `Observed artifact mismatch: ${diffComparison.errors.join('; ')}`;
2983
+ transitionToFailedAcceptance(root, state, currentTurn, mismatchError, {
2984
+ error_code: 'artifact_mismatch',
2985
+ stage: 'artifact_observation',
2986
+ extra: { undeclared_files: diffComparison.errors, warnings: diffComparison.warnings },
2987
+ });
2601
2988
  return {
2602
2989
  ok: false,
2603
- error: `Observed artifact mismatch: ${diffComparison.errors.join('; ')}`,
2990
+ error: mismatchError,
2604
2991
  validation: {
2605
2992
  ...validation,
2606
2993
  ok: false,
@@ -2612,6 +2999,57 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2612
2999
  };
2613
3000
  }
2614
3001
 
3002
+ // ── Intent coverage validation (BUG-14) ──────────────────────────────────
3003
+ // When a turn is bound to an injected intent, verify the turn result
3004
+ // addresses each acceptance item. Default: strict for p0, lenient for others.
3005
+ const intakeCtx = currentTurn.intake_context;
3006
+ if (intakeCtx && Array.isArray(intakeCtx.acceptance_contract) && intakeCtx.acceptance_contract.length > 0) {
3007
+ const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx);
3008
+ const priority = intakeCtx.priority || currentTurn.intake_context?.priority || 'p0';
3009
+ const intentCoverageMode = config.intent_coverage_mode
3010
+ || (priority === 'p0' ? 'strict' : 'lenient');
3011
+
3012
+ if (intentCoverage.unaddressed.length > 0) {
3013
+ if (intentCoverageMode === 'strict') {
3014
+ const coverageError = `Intent coverage incomplete: ${intentCoverage.unaddressed.length} acceptance item(s) not addressed: ${intentCoverage.unaddressed.join('; ')}`;
3015
+ transitionToFailedAcceptance(root, state, currentTurn, coverageError, {
3016
+ error_code: 'intent_coverage_incomplete',
3017
+ stage: 'intent_coverage',
3018
+ extra: {
3019
+ intent_id: intakeCtx.intent_id,
3020
+ addressed: intentCoverage.addressed,
3021
+ unaddressed: intentCoverage.unaddressed,
3022
+ },
3023
+ });
3024
+ return {
3025
+ ok: false,
3026
+ error: coverageError,
3027
+ validation: {
3028
+ ...validation,
3029
+ ok: false,
3030
+ stage: 'intent_coverage',
3031
+ error_class: 'intent_coverage_error',
3032
+ errors: [`Unaddressed acceptance items: ${intentCoverage.unaddressed.join('; ')}`],
3033
+ warnings: [],
3034
+ },
3035
+ };
3036
+ }
3037
+ // Lenient mode — emit warning event but allow acceptance to proceed
3038
+ emitRunEvent(root, 'turn_incomplete_intent_coverage', {
3039
+ run_id: state.run_id,
3040
+ phase: state.phase,
3041
+ status: state.status,
3042
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3043
+ intent_id: intakeCtx.intent_id,
3044
+ payload: {
3045
+ addressed: intentCoverage.addressed,
3046
+ unaddressed: intentCoverage.unaddressed,
3047
+ mode: 'lenient',
3048
+ },
3049
+ });
3050
+ }
3051
+ }
3052
+
2615
3053
  const observedArtifact = buildObservedArtifact(observation, baseline);
2616
3054
  const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
2617
3055
  const artifactType = turnResult.artifact?.type || 'review';
@@ -2632,9 +3070,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2632
3070
 
2633
3071
  if (policyResult.blocks.length > 0) {
2634
3072
  const blockMessages = policyResult.blocks.map((v) => v.message);
3073
+ const policyError = `Policy violation: ${blockMessages.join('; ')}`;
3074
+ transitionToFailedAcceptance(root, state, currentTurn, policyError, {
3075
+ error_code: 'policy_violation',
3076
+ stage: 'policy_evaluation',
3077
+ extra: { violations: policyResult.violations },
3078
+ });
2635
3079
  return {
2636
3080
  ok: false,
2637
- error: `Policy violation: ${blockMessages.join('; ')}`,
3081
+ error: policyError,
2638
3082
  error_code: 'policy_violation',
2639
3083
  policy_violations: policyResult.violations,
2640
3084
  };
@@ -2848,6 +3292,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2848
3292
  normalized_verification: normalizedVerification,
2849
3293
  ...(verificationReplay ? { verification_replay: summarizeVerificationReplay(verificationReplay) } : {}),
2850
3294
  artifact: turnResult.artifact || {},
3295
+ intent_id: currentTurn.intake_context?.intent_id || null,
2851
3296
  observed_artifact: observedArtifact,
2852
3297
  proposed_next_role: turnResult.proposed_next_role,
2853
3298
  phase_transition_request: turnResult.phase_transition_request,
@@ -3442,6 +3887,106 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3442
3887
  }
3443
3888
  }
3444
3889
 
3890
+ // ── BUG-19: Post-acceptance gate reconciliation ────────────────────────
3891
+ // If a previous gate failure is cached in last_gate_failure, re-evaluate
3892
+ // whether the conditions are now satisfied after this turn's artifacts.
3893
+ // This prevents stale gate failures from surviving after a turn fixes them.
3894
+ // Only clear if ALL failure conditions are now resolved.
3895
+ if (updatedState.last_gate_failure && updatedState.status !== 'completed' && updatedState.status !== 'blocked') {
3896
+ const staleGate = updatedState.last_gate_failure;
3897
+ let allConditionsResolved = true;
3898
+
3899
+ // Check if missing_files are now present
3900
+ if (Array.isArray(staleGate.missing_files) && staleGate.missing_files.length > 0) {
3901
+ const stillMissing = staleGate.missing_files.filter(f => !existsSync(join(root, f)));
3902
+ if (stillMissing.length > 0) {
3903
+ allConditionsResolved = false;
3904
+ }
3905
+ }
3906
+
3907
+ // Check if missing_verification is still an issue
3908
+ // Verification failures can only be resolved by the specific gate re-evaluation
3909
+ // during a phase_transition_request, not by post-acceptance reconciliation,
3910
+ // because verification is turn-specific — the prior turn's verification status
3911
+ // is what the gate evaluated, and a different turn's pass doesn't retroactively
3912
+ // fix the prior turn's failure.
3913
+ if (staleGate.missing_verification) {
3914
+ allConditionsResolved = false;
3915
+ }
3916
+
3917
+ // Only clear if there were resolvable conditions and they are all resolved
3918
+ const hadResolvableConditions = Array.isArray(staleGate.missing_files) && staleGate.missing_files.length > 0;
3919
+ if (allConditionsResolved && hadResolvableConditions) {
3920
+ updatedState.last_gate_failure = null;
3921
+ if (staleGate.gate_id) {
3922
+ updatedState.phase_gate_status = {
3923
+ ...(updatedState.phase_gate_status || {}),
3924
+ [staleGate.gate_id]: 'cleared_by_reconciliation',
3925
+ };
3926
+ }
3927
+ ledgerEntries.push({
3928
+ type: 'gate_reconciliation',
3929
+ gate_id: staleGate.gate_id,
3930
+ gate_type: staleGate.gate_type,
3931
+ phase: updatedState.phase,
3932
+ reason: 'post_acceptance_reconciliation',
3933
+ previously_missing_files: staleGate.missing_files || [],
3934
+ reconciled_at: now,
3935
+ reconciled_by_turn: currentTurn.turn_id,
3936
+ });
3937
+ }
3938
+ }
3939
+
3940
+ // ── BUG-20: Post-acceptance intent satisfaction ─────────────────────────
3941
+ // When a turn bound to an injected intent is accepted successfully, transition
3942
+ // the intent to 'completed' so it disappears from the pending queue.
3943
+ if (currentTurn.intake_context?.intent_id) {
3944
+ const intentId = currentTurn.intake_context.intent_id;
3945
+ try {
3946
+ const intentPath = join(root, '.agentxchain', 'intake', 'intents', `${intentId}.json`);
3947
+ if (existsSync(intentPath)) {
3948
+ const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
3949
+ if (intent.status === 'executing') {
3950
+ intent.status = 'completed';
3951
+ intent.completed_at = now;
3952
+ intent.run_completed_at = updatedState.completed_at || now;
3953
+ intent.run_final_turn = currentTurn.turn_id;
3954
+ intent.updated_at = now;
3955
+ intent.satisfying_turn = currentTurn.turn_id;
3956
+ if (!Array.isArray(intent.history)) intent.history = [];
3957
+ intent.history.push({
3958
+ from: 'executing',
3959
+ to: 'completed',
3960
+ at: now,
3961
+ turn_id: currentTurn.turn_id,
3962
+ role: currentTurn.assigned_role,
3963
+ run_id: updatedState.run_id,
3964
+ reason: 'turn accepted — acceptance contract satisfied',
3965
+ });
3966
+ writeFileSync(intentPath, JSON.stringify(intent, null, 2));
3967
+
3968
+ // Create observation scaffold (same as resolve path)
3969
+ const obsDir = join(root, '.agentxchain', 'intake', 'observations', intentId);
3970
+ mkdirSync(obsDir, { recursive: true });
3971
+
3972
+ // Emit intent_satisfied event
3973
+ emitRunEvent(root, 'intent_satisfied', {
3974
+ run_id: updatedState.run_id,
3975
+ phase: updatedState.phase,
3976
+ status: updatedState.status,
3977
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3978
+ intent_id: intentId,
3979
+ payload: {
3980
+ satisfying_turn: currentTurn.turn_id,
3981
+ },
3982
+ });
3983
+ }
3984
+ }
3985
+ } catch {
3986
+ // Non-fatal — intent satisfaction is advisory
3987
+ }
3988
+ }
3989
+
3445
3990
  // ── Transaction journal: prepare before committing writes ──────────────
3446
3991
  const transactionId = generateId('txn');
3447
3992
  const journal = {
@@ -3558,6 +4103,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3558
4103
  phase: updatedState.phase,
3559
4104
  status: updatedState.status,
3560
4105
  turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4106
+ intent_id: currentTurn.intake_context?.intent_id || null,
3561
4107
  payload: turnAcceptedPayload,
3562
4108
  });
3563
4109
 
@@ -3576,6 +4122,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3576
4122
  phase: updatedState.phase,
3577
4123
  status: 'blocked',
3578
4124
  turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4125
+ intent_id: currentTurn.intake_context?.intent_id || null,
3579
4126
  payload: { category: updatedState.blocked_reason?.category || 'needs_human' },
3580
4127
  });
3581
4128
  }
@@ -3765,15 +4312,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3765
4312
  conflict_context: conflictContext,
3766
4313
  };
3767
4314
 
4315
+ // BUG-8 fix: ALWAYS refresh the baseline on retry, not just for conflict rejects.
4316
+ // A retry is a new attempt — it must start from current repo state, not the
4317
+ // moment the failed attempt was originally dispatched.
4318
+ const retryStartedAt = new Date().toISOString();
4319
+ retryTurn.baseline = captureBaseline(root);
4320
+ retryTurn.started_at = retryStartedAt;
4321
+ retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
4322
+
3768
4323
  if (isConflictReject) {
3769
- const retryStartedAt = new Date().toISOString();
3770
- retryTurn.baseline = captureBaseline(root);
3771
4324
  retryTurn.assigned_sequence = Math.max(
3772
4325
  state.turn_sequence || 0,
3773
4326
  currentTurn.assigned_sequence || 0,
3774
4327
  );
3775
- retryTurn.started_at = retryStartedAt;
3776
- retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
3777
4328
  retryTurn.concurrent_with = Object.keys(getActiveTurns(state)).filter((turnId) => turnId !== currentTurn.turn_id);
3778
4329
  }
3779
4330
 
@@ -3796,6 +4347,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3796
4347
  phase: updatedState.phase,
3797
4348
  status: updatedState.status,
3798
4349
  turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4350
+ intent_id: currentTurn.intake_context?.intent_id || null,
3799
4351
  payload: {
3800
4352
  attempt: currentAttempt,
3801
4353
  retrying: true,
@@ -3869,6 +4421,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3869
4421
  phase: updatedState.phase,
3870
4422
  status: 'blocked',
3871
4423
  turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4424
+ intent_id: currentTurn.intake_context?.intent_id || null,
3872
4425
  payload: {
3873
4426
  attempt: currentAttempt,
3874
4427
  retrying: false,
@@ -3882,6 +4435,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3882
4435
  run_id: updatedState.run_id,
3883
4436
  phase: updatedState.phase,
3884
4437
  status: 'blocked',
4438
+ intent_id: currentTurn.intake_context?.intent_id || null,
3885
4439
  payload: { category: 'retries_exhausted' },
3886
4440
  });
3887
4441
 
@@ -4268,6 +4822,65 @@ function deriveNextRecommendedRole(turnResult, state, config) {
4268
4822
  return routing?.entry_role || null;
4269
4823
  }
4270
4824
 
4825
+ // ── Intent coverage evaluation (BUG-14) ──────────────────────────────────────
4826
+ //
4827
+ // Checks whether a turn result addresses the acceptance items from an injected
4828
+ // intent. Uses a hybrid approach: structural (`intent_response` field) first,
4829
+ // with semantic fallback scanning `summary`, `decisions`, and `files_changed`.
4830
+
4831
+ function evaluateIntentCoverage(turnResult, intakeContext) {
4832
+ const acceptanceItems = intakeContext.acceptance_contract || [];
4833
+ const addressed = [];
4834
+ const unaddressed = [];
4835
+
4836
+ // Build the structural response map if present
4837
+ const responseMap = new Map();
4838
+ if (Array.isArray(turnResult.intent_response)) {
4839
+ for (const entry of turnResult.intent_response) {
4840
+ if (entry && typeof entry.item === 'string' && typeof entry.status === 'string') {
4841
+ responseMap.set(entry.item.toLowerCase().trim(), entry);
4842
+ }
4843
+ }
4844
+ }
4845
+
4846
+ // Build a searchable corpus from the turn result for semantic fallback
4847
+ const corpus = [
4848
+ turnResult.summary || '',
4849
+ ...(turnResult.decisions || []).map(d => `${d.statement || ''} ${d.rationale || ''}`),
4850
+ ...(turnResult.objections || []).map(o => o.statement || ''),
4851
+ ...(turnResult.files_changed || []),
4852
+ ...(turnResult.artifacts_created || []),
4853
+ ...(Array.isArray(turnResult.intent_response) ? turnResult.intent_response.map(r => `${r.item || ''} ${r.detail || ''}`) : []),
4854
+ ].join('\n').toLowerCase();
4855
+
4856
+ for (const item of acceptanceItems) {
4857
+ const normalizedItem = item.toLowerCase().trim();
4858
+
4859
+ // Check 1: Structural — intent_response field with explicit status
4860
+ const structuralEntry = responseMap.get(normalizedItem);
4861
+ if (structuralEntry && ['addressed', 'deferred', 'rejected'].includes(structuralEntry.status)) {
4862
+ addressed.push(item);
4863
+ continue;
4864
+ }
4865
+
4866
+ // Check 2: Semantic fallback — significant keyword overlap
4867
+ const words = normalizedItem.split(/\s+/).filter(w => w.length > 3);
4868
+ if (words.length === 0) {
4869
+ addressed.push(item);
4870
+ continue;
4871
+ }
4872
+ const matchedWords = words.filter(w => corpus.includes(w));
4873
+ const coverage = matchedWords.length / words.length;
4874
+ if (coverage >= 0.5) {
4875
+ addressed.push(item);
4876
+ } else {
4877
+ unaddressed.push(item);
4878
+ }
4879
+ }
4880
+
4881
+ return { addressed, unaddressed };
4882
+ }
4883
+
4271
4884
  export {
4272
4885
  STATE_PATH,
4273
4886
  HISTORY_PATH,