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.
@@ -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';
@@ -2059,7 +2061,7 @@ export function initializeGovernedRun(root, config, options = {}) {
2059
2061
  * @param {string} roleId - the role to assign
2060
2062
  * @returns {{ ok: boolean, error?: string, warnings?: string[], state?: object }}
2061
2063
  */
2062
- export function assignGovernedTurn(root, config, roleId) {
2064
+ export function assignGovernedTurn(root, config, roleId, options = {}) {
2063
2065
  let state = readState(root);
2064
2066
  if (!state) {
2065
2067
  return { ok: false, error: 'No governed state.json found' };
@@ -2243,6 +2245,9 @@ export function assignGovernedTurn(root, config, roleId) {
2243
2245
  assigned_sequence: nextSequence,
2244
2246
  concurrent_with: concurrentWith,
2245
2247
  };
2248
+ if (options.intakeContext) {
2249
+ newTurn.intake_context = options.intakeContext;
2250
+ }
2246
2251
 
2247
2252
  // Attach delegation context if this turn fulfills a pending delegation
2248
2253
  const delegationQueue = state.delegation_queue || [];
@@ -2288,12 +2293,15 @@ export function assignGovernedTurn(root, config, roleId) {
2288
2293
  phase: updatedState.phase,
2289
2294
  status: updatedState.status,
2290
2295
  turn: { turn_id: turnId, role_id: roleId },
2296
+ intent_id: options.intakeContext?.intent_id || null,
2291
2297
  });
2292
2298
 
2293
- // Session checkpoint — non-fatal, written after every successful turn assignment
2299
+ // Session checkpoint — non-fatal, written after every successful turn assignment.
2300
+ // Pass the captured baseline so session.json agrees with state.json (BUG-2 fix).
2294
2301
  writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
2295
2302
  role: roleId,
2296
2303
  dispatch_dir: `.agentxchain/dispatch/turns/${turnId}`,
2304
+ turn_baseline: baseline,
2297
2305
  });
2298
2306
 
2299
2307
  const assignedTurn = updatedState.active_turns[turnId];
@@ -2304,6 +2312,274 @@ export function assignGovernedTurn(root, config, roleId) {
2304
2312
  return result;
2305
2313
  }
2306
2314
 
2315
+ /**
2316
+ * Refresh a turn's baseline dirty_snapshot to include files that became dirty
2317
+ * between assignment and dispatch. This prevents acceptance from blaming the
2318
+ * turn for operator edits made while the turn was pending (BUG-1 fix).
2319
+ *
2320
+ * Merges new dirty entries into the existing snapshot — existing entries are
2321
+ * preserved, new entries are added. The baseline.clean flag is updated to
2322
+ * reflect the merged snapshot.
2323
+ *
2324
+ * @param {string} root - project root
2325
+ * @param {string} [turnId] - specific turn to refresh (defaults to single active turn)
2326
+ * @returns {{ ok: boolean, refreshed_files?: string[], error?: string }}
2327
+ */
2328
+ export function refreshTurnBaselineSnapshot(root, turnId) {
2329
+ const state = readState(root);
2330
+ if (!state) return { ok: false, error: 'No governed state found' };
2331
+
2332
+ const activeTurns = getActiveTurns(state);
2333
+ const resolvedTurnId = turnId || Object.keys(activeTurns)[0];
2334
+ if (!resolvedTurnId) return { ok: false, error: 'No active turn to refresh' };
2335
+
2336
+ const turn = activeTurns[resolvedTurnId];
2337
+ if (!turn) return { ok: false, error: `Turn ${resolvedTurnId} not found in active turns` };
2338
+ if (!turn.baseline) return { ok: false, error: 'Turn has no baseline to refresh' };
2339
+
2340
+ const currentSnapshot = captureDirtyWorkspaceSnapshot(root);
2341
+ const existingSnapshot = turn.baseline.dirty_snapshot || {};
2342
+ const merged = { ...existingSnapshot };
2343
+ const refreshedFiles = [];
2344
+
2345
+ for (const [filePath, marker] of Object.entries(currentSnapshot)) {
2346
+ if (!(filePath in merged)) {
2347
+ merged[filePath] = marker;
2348
+ refreshedFiles.push(filePath);
2349
+ }
2350
+ }
2351
+
2352
+ if (refreshedFiles.length === 0) {
2353
+ return { ok: true, refreshed_files: [] };
2354
+ }
2355
+
2356
+ // Update the turn's baseline with the merged snapshot
2357
+ const updatedTurn = {
2358
+ ...turn,
2359
+ baseline: {
2360
+ ...turn.baseline,
2361
+ dirty_snapshot: merged,
2362
+ // Recalculate clean: filter out operational and baseline-exempt paths
2363
+ clean: Object.keys(merged).filter(f => !isOperationalPath(f)).length === 0,
2364
+ },
2365
+ };
2366
+
2367
+ const updatedState = {
2368
+ ...state,
2369
+ active_turns: {
2370
+ ...activeTurns,
2371
+ [resolvedTurnId]: updatedTurn,
2372
+ },
2373
+ };
2374
+ writeState(root, updatedState);
2375
+
2376
+ return { ok: true, refreshed_files: refreshedFiles };
2377
+ }
2378
+
2379
+ /**
2380
+ * Reissue an active turn against current repo state.
2381
+ *
2382
+ * Invalidates the current turn, archives its state, captures a fresh baseline
2383
+ * from current HEAD/workspace, and creates a new turn with the same role and
2384
+ * phase. Covers baseline drift, runtime drift, authority drift, and operator-
2385
+ * initiated reissue. (BUG-7 fix)
2386
+ *
2387
+ * @param {string} root - project root
2388
+ * @param {object} config - normalized config
2389
+ * @param {object} opts
2390
+ * @param {string} [opts.turnId] - specific turn to reissue
2391
+ * @param {string} [opts.reason] - reason for reissue
2392
+ * @returns {{ ok: boolean, state?: object, newTurn?: object, baselineDelta?: object, error?: string }}
2393
+ */
2394
+ /**
2395
+ * Detect runtime/authority drift for all active turns against current config.
2396
+ * Returns an array of drift descriptors. Empty array = no drift.
2397
+ */
2398
+ export function detectActiveTurnBindingDrift(state, config) {
2399
+ const activeTurns = getActiveTurns(state);
2400
+ const drifts = [];
2401
+
2402
+ for (const turn of Object.values(activeTurns)) {
2403
+ const roleId = turn.assigned_role;
2404
+ const role = config.roles?.[roleId];
2405
+ if (!role) continue;
2406
+
2407
+ const currentRuntimeId = role.runtime_id || role.runtime;
2408
+ const currentAuthority = role.write_authority;
2409
+ const turnRuntimeId = turn.runtime_id;
2410
+
2411
+ const runtimeChanged = currentRuntimeId && turnRuntimeId && currentRuntimeId !== turnRuntimeId;
2412
+ const authorityChanged = currentAuthority && turn.write_authority && currentAuthority !== turn.write_authority;
2413
+
2414
+ // Check runtime type change even if runtime_id matches
2415
+ const currentRuntimeType = config.runtimes?.[currentRuntimeId]?.type;
2416
+ const turnRuntimeType = config.runtimes?.[turnRuntimeId]?.type;
2417
+ const runtimeTypeChanged = currentRuntimeType && turnRuntimeType && currentRuntimeType !== turnRuntimeType;
2418
+
2419
+ if (runtimeChanged || authorityChanged || runtimeTypeChanged) {
2420
+ drifts.push({
2421
+ turn_id: turn.turn_id,
2422
+ role_id: roleId,
2423
+ runtime_changed: Boolean(runtimeChanged || runtimeTypeChanged),
2424
+ old_runtime: turnRuntimeId,
2425
+ new_runtime: currentRuntimeId,
2426
+ authority_changed: Boolean(authorityChanged),
2427
+ old_authority: turn.write_authority,
2428
+ new_authority: currentAuthority,
2429
+ recovery_command: `agentxchain reissue-turn --turn ${turn.turn_id} --reason "config rebinding"`,
2430
+ });
2431
+ }
2432
+ }
2433
+
2434
+ return drifts;
2435
+ }
2436
+
2437
+ export function reissueTurn(root, config, opts = {}) {
2438
+ const state = readState(root);
2439
+ if (!state) return { ok: false, error: 'No governed state found' };
2440
+
2441
+ const activeTurns = getActiveTurns(state);
2442
+ const turnId = opts.turnId || Object.keys(activeTurns)[0];
2443
+ if (!turnId) return { ok: false, error: 'No active turn to reissue' };
2444
+
2445
+ const oldTurn = activeTurns[turnId];
2446
+ if (!oldTurn) return { ok: false, error: `Turn ${turnId} not found in active turns` };
2447
+
2448
+ const roleId = oldTurn.assigned_role;
2449
+ const role = config.roles?.[roleId];
2450
+ if (!role) return { ok: false, error: `Role "${roleId}" not found in config` };
2451
+
2452
+ const reason = opts.reason || 'operator-initiated reissue';
2453
+ const now = new Date().toISOString();
2454
+
2455
+ // Capture old baseline for delta computation
2456
+ const oldBaseline = oldTurn.baseline || {};
2457
+ const oldRuntimeId = oldTurn.runtime_id;
2458
+
2459
+ // Resolve current runtime binding (may have changed in config)
2460
+ const currentRuntimeId = role.runtime;
2461
+ const currentRuntime = config.runtimes?.[currentRuntimeId];
2462
+ if (!currentRuntime) {
2463
+ return { ok: false, error: `Runtime "${currentRuntimeId}" not found in config for role "${roleId}"` };
2464
+ }
2465
+
2466
+ // Capture fresh baseline
2467
+ const newBaseline = captureBaseline(root);
2468
+
2469
+ // Archive the old turn as a history entry
2470
+ appendJsonl(root, HISTORY_PATH, {
2471
+ turn_id: oldTurn.turn_id,
2472
+ run_id: state.run_id,
2473
+ role: roleId,
2474
+ phase: state.phase,
2475
+ status: 'reissued',
2476
+ summary: `Turn reissued: ${reason}`,
2477
+ files_changed: [],
2478
+ assigned_sequence: oldTurn.assigned_sequence,
2479
+ accepted_at: now,
2480
+ duration_ms: oldTurn.started_at ? Date.now() - new Date(oldTurn.started_at).getTime() : 0,
2481
+ reissue_reason: reason,
2482
+ });
2483
+
2484
+ // Decision ledger entry
2485
+ appendJsonl(root, LEDGER_PATH, {
2486
+ timestamp: now,
2487
+ decision: 'turn_reissued',
2488
+ turn_id: oldTurn.turn_id,
2489
+ role: roleId,
2490
+ phase: state.phase,
2491
+ reason,
2492
+ old_baseline: {
2493
+ head_ref: oldBaseline.head_ref,
2494
+ clean: oldBaseline.clean,
2495
+ },
2496
+ new_baseline: {
2497
+ head_ref: newBaseline.head_ref,
2498
+ clean: newBaseline.clean,
2499
+ },
2500
+ });
2501
+
2502
+ // Create the new turn
2503
+ const newTurnId = `turn_${randomBytes(8).toString('hex')}`;
2504
+ const timeoutMinutes = 20;
2505
+ const nextSequence = (state.turn_sequence || 0) + 1;
2506
+
2507
+ const newTurn = {
2508
+ turn_id: newTurnId,
2509
+ assigned_role: roleId,
2510
+ status: 'running',
2511
+ attempt: (oldTurn.attempt || 1) + 1,
2512
+ started_at: now,
2513
+ deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
2514
+ runtime_id: currentRuntimeId,
2515
+ baseline: newBaseline,
2516
+ assigned_sequence: nextSequence,
2517
+ concurrent_with: Object.keys(activeTurns).filter(id => id !== turnId),
2518
+ reissued_from: oldTurn.turn_id,
2519
+ };
2520
+
2521
+ // Copy delegation context if present
2522
+ if (oldTurn.delegation_context) {
2523
+ newTurn.delegation_context = oldTurn.delegation_context;
2524
+ }
2525
+
2526
+ // Remove old turn, add new turn
2527
+ const newActiveTurns = { ...activeTurns };
2528
+ delete newActiveTurns[turnId];
2529
+ newActiveTurns[newTurnId] = newTurn;
2530
+
2531
+ const updatedState = {
2532
+ ...state,
2533
+ turn_sequence: nextSequence,
2534
+ active_turns: newActiveTurns,
2535
+ };
2536
+
2537
+ writeState(root, updatedState);
2538
+
2539
+ // Emit event
2540
+ emitRunEvent(root, 'turn_reissued', {
2541
+ run_id: state.run_id,
2542
+ phase: state.phase,
2543
+ status: state.status,
2544
+ turn: { turn_id: newTurnId, role_id: roleId },
2545
+ intent_id: oldTurn.intake_context?.intent_id || null,
2546
+ payload: {
2547
+ old_turn_id: oldTurn.turn_id,
2548
+ reason,
2549
+ old_head: oldBaseline.head_ref,
2550
+ new_head: newBaseline.head_ref,
2551
+ old_runtime: oldRuntimeId,
2552
+ new_runtime: currentRuntimeId,
2553
+ },
2554
+ });
2555
+
2556
+ // Session checkpoint
2557
+ writeSessionCheckpoint(root, updatedState, 'turn_reissued', {
2558
+ role: roleId,
2559
+ turn_baseline: newBaseline,
2560
+ });
2561
+
2562
+ // Compute baseline delta for display
2563
+ const baselineDelta = {
2564
+ head_changed: oldBaseline.head_ref !== newBaseline.head_ref,
2565
+ old_head: oldBaseline.head_ref,
2566
+ new_head: newBaseline.head_ref,
2567
+ runtime_changed: oldRuntimeId !== currentRuntimeId,
2568
+ old_runtime: oldRuntimeId,
2569
+ new_runtime: currentRuntimeId,
2570
+ dirty_files_changed: JSON.stringify(oldBaseline.dirty_snapshot || {}) !== JSON.stringify(newBaseline.dirty_snapshot || {}),
2571
+ added_dirty_files: Object.keys(newBaseline.dirty_snapshot || {}).filter(f => !(f in (oldBaseline.dirty_snapshot || {}))),
2572
+ removed_dirty_files: Object.keys(oldBaseline.dirty_snapshot || {}).filter(f => !(f in (newBaseline.dirty_snapshot || {}))),
2573
+ };
2574
+
2575
+ return {
2576
+ ok: true,
2577
+ state: attachLegacyCurrentTurnAlias(updatedState),
2578
+ newTurn,
2579
+ baselineDelta,
2580
+ };
2581
+ }
2582
+
2307
2583
  /**
2308
2584
  * Estimate the budget for a single turn based on role/runtime configuration.
2309
2585
  * Used for DEC-PARALLEL-011 budget reservation.
@@ -2366,6 +2642,51 @@ export function acceptGovernedTurn(root, config, opts = {}) {
2366
2642
  }
2367
2643
  }
2368
2644
 
2645
+ /**
2646
+ * Transition an active turn to failed_acceptance and emit the corresponding
2647
+ * event. Called from acceptance failure paths so the turn is never left stuck
2648
+ * in 'running' after the subprocess has exited (BUG-3 + BUG-4 fix).
2649
+ */
2650
+ function transitionToFailedAcceptance(root, state, turn, reason, details = {}) {
2651
+ const activeTurns = getActiveTurns(state);
2652
+ const updatedTurn = {
2653
+ ...turn,
2654
+ status: 'failed_acceptance',
2655
+ failed_at: new Date().toISOString(),
2656
+ failure_reason: reason,
2657
+ };
2658
+ const updatedState = {
2659
+ ...state,
2660
+ active_turns: {
2661
+ ...activeTurns,
2662
+ [turn.turn_id]: updatedTurn,
2663
+ },
2664
+ };
2665
+ writeState(root, updatedState);
2666
+
2667
+ // BUG-4: emit acceptance_failed event
2668
+ emitRunEvent(root, 'acceptance_failed', {
2669
+ run_id: state.run_id,
2670
+ phase: state.phase,
2671
+ status: state.status,
2672
+ turn: { turn_id: turn.turn_id, role_id: turn.assigned_role },
2673
+ intent_id: turn.intake_context?.intent_id || null,
2674
+ payload: {
2675
+ reason,
2676
+ error_code: details.error_code || 'acceptance_failed',
2677
+ stage: details.stage || 'unknown',
2678
+ ...details.extra,
2679
+ },
2680
+ });
2681
+
2682
+ // Session checkpoint for recovery
2683
+ writeSessionCheckpoint(root, updatedState, 'acceptance_failed', {
2684
+ role: turn.assigned_role,
2685
+ });
2686
+
2687
+ return updatedState;
2688
+ }
2689
+
2369
2690
  function _acceptGovernedTurnLocked(root, config, opts) {
2370
2691
  // Re-read state under lock (a sibling acceptance may have committed)
2371
2692
  let state = readState(root);
@@ -2526,9 +2847,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2526
2847
  }
2527
2848
 
2528
2849
  if (!validation.ok) {
2850
+ const failError = `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`;
2851
+ transitionToFailedAcceptance(root, state, currentTurn, failError, {
2852
+ error_code: 'validation_failed',
2853
+ stage: validation.stage,
2854
+ extra: { errors: validation.errors },
2855
+ });
2529
2856
  return {
2530
2857
  ok: false,
2531
- error: `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`,
2858
+ error: failError,
2532
2859
  validation,
2533
2860
  };
2534
2861
  }
@@ -2541,9 +2868,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2541
2868
  if (dec.overrides) {
2542
2869
  const overrideCheck = validateOverride(root, { ...dec, role: dec.role || turnResult.role }, config);
2543
2870
  if (!overrideCheck.ok) {
2871
+ const overrideError = `Override validation failed: ${overrideCheck.error}`;
2872
+ transitionToFailedAcceptance(root, state, currentTurn, overrideError, {
2873
+ error_code: 'override_validation_failed',
2874
+ stage: 'override_validation',
2875
+ });
2544
2876
  return {
2545
2877
  ok: false,
2546
- error: `Override validation failed: ${overrideCheck.error}`,
2878
+ error: overrideError,
2547
2879
  error_code: 'override_validation_failed',
2548
2880
  };
2549
2881
  }
@@ -2598,9 +2930,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2598
2930
  },
2599
2931
  );
2600
2932
  if (diffComparison.errors.length > 0) {
2933
+ const mismatchError = `Observed artifact mismatch: ${diffComparison.errors.join('; ')}`;
2934
+ transitionToFailedAcceptance(root, state, currentTurn, mismatchError, {
2935
+ error_code: 'artifact_mismatch',
2936
+ stage: 'artifact_observation',
2937
+ extra: { undeclared_files: diffComparison.errors, warnings: diffComparison.warnings },
2938
+ });
2601
2939
  return {
2602
2940
  ok: false,
2603
- error: `Observed artifact mismatch: ${diffComparison.errors.join('; ')}`,
2941
+ error: mismatchError,
2604
2942
  validation: {
2605
2943
  ...validation,
2606
2944
  ok: false,
@@ -2612,6 +2950,57 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2612
2950
  };
2613
2951
  }
2614
2952
 
2953
+ // ── Intent coverage validation (BUG-14) ──────────────────────────────────
2954
+ // When a turn is bound to an injected intent, verify the turn result
2955
+ // addresses each acceptance item. Default: strict for p0, lenient for others.
2956
+ const intakeCtx = currentTurn.intake_context;
2957
+ if (intakeCtx && Array.isArray(intakeCtx.acceptance_contract) && intakeCtx.acceptance_contract.length > 0) {
2958
+ const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx);
2959
+ const priority = intakeCtx.priority || currentTurn.intake_context?.priority || 'p0';
2960
+ const intentCoverageMode = config.intent_coverage_mode
2961
+ || (priority === 'p0' ? 'strict' : 'lenient');
2962
+
2963
+ if (intentCoverage.unaddressed.length > 0) {
2964
+ if (intentCoverageMode === 'strict') {
2965
+ const coverageError = `Intent coverage incomplete: ${intentCoverage.unaddressed.length} acceptance item(s) not addressed: ${intentCoverage.unaddressed.join('; ')}`;
2966
+ transitionToFailedAcceptance(root, state, currentTurn, coverageError, {
2967
+ error_code: 'intent_coverage_incomplete',
2968
+ stage: 'intent_coverage',
2969
+ extra: {
2970
+ intent_id: intakeCtx.intent_id,
2971
+ addressed: intentCoverage.addressed,
2972
+ unaddressed: intentCoverage.unaddressed,
2973
+ },
2974
+ });
2975
+ return {
2976
+ ok: false,
2977
+ error: coverageError,
2978
+ validation: {
2979
+ ...validation,
2980
+ ok: false,
2981
+ stage: 'intent_coverage',
2982
+ error_class: 'intent_coverage_error',
2983
+ errors: [`Unaddressed acceptance items: ${intentCoverage.unaddressed.join('; ')}`],
2984
+ warnings: [],
2985
+ },
2986
+ };
2987
+ }
2988
+ // Lenient mode — emit warning event but allow acceptance to proceed
2989
+ emitRunEvent(root, 'turn_incomplete_intent_coverage', {
2990
+ run_id: state.run_id,
2991
+ phase: state.phase,
2992
+ status: state.status,
2993
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
2994
+ intent_id: intakeCtx.intent_id,
2995
+ payload: {
2996
+ addressed: intentCoverage.addressed,
2997
+ unaddressed: intentCoverage.unaddressed,
2998
+ mode: 'lenient',
2999
+ },
3000
+ });
3001
+ }
3002
+ }
3003
+
2615
3004
  const observedArtifact = buildObservedArtifact(observation, baseline);
2616
3005
  const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
2617
3006
  const artifactType = turnResult.artifact?.type || 'review';
@@ -2632,9 +3021,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2632
3021
 
2633
3022
  if (policyResult.blocks.length > 0) {
2634
3023
  const blockMessages = policyResult.blocks.map((v) => v.message);
3024
+ const policyError = `Policy violation: ${blockMessages.join('; ')}`;
3025
+ transitionToFailedAcceptance(root, state, currentTurn, policyError, {
3026
+ error_code: 'policy_violation',
3027
+ stage: 'policy_evaluation',
3028
+ extra: { violations: policyResult.violations },
3029
+ });
2635
3030
  return {
2636
3031
  ok: false,
2637
- error: `Policy violation: ${blockMessages.join('; ')}`,
3032
+ error: policyError,
2638
3033
  error_code: 'policy_violation',
2639
3034
  policy_violations: policyResult.violations,
2640
3035
  };
@@ -2848,6 +3243,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2848
3243
  normalized_verification: normalizedVerification,
2849
3244
  ...(verificationReplay ? { verification_replay: summarizeVerificationReplay(verificationReplay) } : {}),
2850
3245
  artifact: turnResult.artifact || {},
3246
+ intent_id: currentTurn.intake_context?.intent_id || null,
2851
3247
  observed_artifact: observedArtifact,
2852
3248
  proposed_next_role: turnResult.proposed_next_role,
2853
3249
  phase_transition_request: turnResult.phase_transition_request,
@@ -3558,6 +3954,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3558
3954
  phase: updatedState.phase,
3559
3955
  status: updatedState.status,
3560
3956
  turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3957
+ intent_id: currentTurn.intake_context?.intent_id || null,
3561
3958
  payload: turnAcceptedPayload,
3562
3959
  });
3563
3960
 
@@ -3576,6 +3973,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3576
3973
  phase: updatedState.phase,
3577
3974
  status: 'blocked',
3578
3975
  turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
3976
+ intent_id: currentTurn.intake_context?.intent_id || null,
3579
3977
  payload: { category: updatedState.blocked_reason?.category || 'needs_human' },
3580
3978
  });
3581
3979
  }
@@ -3765,15 +4163,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3765
4163
  conflict_context: conflictContext,
3766
4164
  };
3767
4165
 
4166
+ // BUG-8 fix: ALWAYS refresh the baseline on retry, not just for conflict rejects.
4167
+ // A retry is a new attempt — it must start from current repo state, not the
4168
+ // moment the failed attempt was originally dispatched.
4169
+ const retryStartedAt = new Date().toISOString();
4170
+ retryTurn.baseline = captureBaseline(root);
4171
+ retryTurn.started_at = retryStartedAt;
4172
+ retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
4173
+
3768
4174
  if (isConflictReject) {
3769
- const retryStartedAt = new Date().toISOString();
3770
- retryTurn.baseline = captureBaseline(root);
3771
4175
  retryTurn.assigned_sequence = Math.max(
3772
4176
  state.turn_sequence || 0,
3773
4177
  currentTurn.assigned_sequence || 0,
3774
4178
  );
3775
- retryTurn.started_at = retryStartedAt;
3776
- retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
3777
4179
  retryTurn.concurrent_with = Object.keys(getActiveTurns(state)).filter((turnId) => turnId !== currentTurn.turn_id);
3778
4180
  }
3779
4181
 
@@ -3796,6 +4198,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3796
4198
  phase: updatedState.phase,
3797
4199
  status: updatedState.status,
3798
4200
  turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4201
+ intent_id: currentTurn.intake_context?.intent_id || null,
3799
4202
  payload: {
3800
4203
  attempt: currentAttempt,
3801
4204
  retrying: true,
@@ -3869,6 +4272,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3869
4272
  phase: updatedState.phase,
3870
4273
  status: 'blocked',
3871
4274
  turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4275
+ intent_id: currentTurn.intake_context?.intent_id || null,
3872
4276
  payload: {
3873
4277
  attempt: currentAttempt,
3874
4278
  retrying: false,
@@ -3882,6 +4286,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
3882
4286
  run_id: updatedState.run_id,
3883
4287
  phase: updatedState.phase,
3884
4288
  status: 'blocked',
4289
+ intent_id: currentTurn.intake_context?.intent_id || null,
3885
4290
  payload: { category: 'retries_exhausted' },
3886
4291
  });
3887
4292
 
@@ -4268,6 +4673,65 @@ function deriveNextRecommendedRole(turnResult, state, config) {
4268
4673
  return routing?.entry_role || null;
4269
4674
  }
4270
4675
 
4676
+ // ── Intent coverage evaluation (BUG-14) ──────────────────────────────────────
4677
+ //
4678
+ // Checks whether a turn result addresses the acceptance items from an injected
4679
+ // intent. Uses a hybrid approach: structural (`intent_response` field) first,
4680
+ // with semantic fallback scanning `summary`, `decisions`, and `files_changed`.
4681
+
4682
+ function evaluateIntentCoverage(turnResult, intakeContext) {
4683
+ const acceptanceItems = intakeContext.acceptance_contract || [];
4684
+ const addressed = [];
4685
+ const unaddressed = [];
4686
+
4687
+ // Build the structural response map if present
4688
+ const responseMap = new Map();
4689
+ if (Array.isArray(turnResult.intent_response)) {
4690
+ for (const entry of turnResult.intent_response) {
4691
+ if (entry && typeof entry.item === 'string' && typeof entry.status === 'string') {
4692
+ responseMap.set(entry.item.toLowerCase().trim(), entry);
4693
+ }
4694
+ }
4695
+ }
4696
+
4697
+ // Build a searchable corpus from the turn result for semantic fallback
4698
+ const corpus = [
4699
+ turnResult.summary || '',
4700
+ ...(turnResult.decisions || []).map(d => `${d.statement || ''} ${d.rationale || ''}`),
4701
+ ...(turnResult.objections || []).map(o => o.statement || ''),
4702
+ ...(turnResult.files_changed || []),
4703
+ ...(turnResult.artifacts_created || []),
4704
+ ...(Array.isArray(turnResult.intent_response) ? turnResult.intent_response.map(r => `${r.item || ''} ${r.detail || ''}`) : []),
4705
+ ].join('\n').toLowerCase();
4706
+
4707
+ for (const item of acceptanceItems) {
4708
+ const normalizedItem = item.toLowerCase().trim();
4709
+
4710
+ // Check 1: Structural — intent_response field with explicit status
4711
+ const structuralEntry = responseMap.get(normalizedItem);
4712
+ if (structuralEntry && ['addressed', 'deferred', 'rejected'].includes(structuralEntry.status)) {
4713
+ addressed.push(item);
4714
+ continue;
4715
+ }
4716
+
4717
+ // Check 2: Semantic fallback — significant keyword overlap
4718
+ const words = normalizedItem.split(/\s+/).filter(w => w.length > 3);
4719
+ if (words.length === 0) {
4720
+ addressed.push(item);
4721
+ continue;
4722
+ }
4723
+ const matchedWords = words.filter(w => corpus.includes(w));
4724
+ const coverage = matchedWords.length / words.length;
4725
+ if (coverage >= 0.5) {
4726
+ addressed.push(item);
4727
+ } else {
4728
+ unaddressed.push(item);
4729
+ }
4730
+ }
4731
+
4732
+ return { addressed, unaddressed };
4733
+ }
4734
+
4271
4735
  export {
4272
4736
  STATE_PATH,
4273
4737
  HISTORY_PATH,
@@ -12,6 +12,7 @@ export const VALID_GOVERNED_TEMPLATE_IDS = Object.freeze([
12
12
  'cli-tool',
13
13
  'library',
14
14
  'web-app',
15
+ 'full-local-cli',
15
16
  'enterprise-app',
16
17
  ]);
17
18