agentxchain 2.135.0 → 2.136.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.
@@ -46,6 +46,10 @@ import { emitRunEvent } from './run-events.js';
46
46
  import { writeSessionCheckpoint } from './session-checkpoint.js';
47
47
  import { recordRunHistory } from './run-history.js';
48
48
  import { buildDefaultRunProvenance } from './run-provenance.js';
49
+ import {
50
+ archiveStaleIntentsForRun,
51
+ formatLegacyIntentMigrationNotice,
52
+ } from './intent-startup-migration.js';
49
53
  import {
50
54
  ensureHumanEscalation,
51
55
  findCurrentHumanEscalation,
@@ -1243,6 +1247,13 @@ function buildConflictContext(turn) {
1243
1247
  files_changed: Array.isArray(entry.files_changed) ? entry.files_changed : [],
1244
1248
  }))
1245
1249
  : [];
1250
+ const forwardRevisionTurnsSince = Array.isArray(conflictError.forward_revision_turns)
1251
+ ? conflictError.forward_revision_turns.map((entry) => ({
1252
+ turn_id: entry.turn_id,
1253
+ role: entry.role,
1254
+ files_changed: Array.isArray(entry.files_changed) ? entry.files_changed : [],
1255
+ }))
1256
+ : [];
1246
1257
 
1247
1258
  return {
1248
1259
  prior_attempt_turn_id: turn.turn_id,
@@ -1250,6 +1261,8 @@ function buildConflictContext(turn) {
1250
1261
  conflict_type: conflictError.type || 'file_conflict',
1251
1262
  conflicting_files: Array.isArray(conflictError.conflicting_files) ? conflictError.conflicting_files : [],
1252
1263
  accepted_turns_since: acceptedTurnsSince,
1264
+ forward_revision_files: Array.isArray(conflictError.forward_revision_files) ? conflictError.forward_revision_files : [],
1265
+ forward_revision_turns_since: forwardRevisionTurnsSince,
1253
1266
  non_conflicting_files_preserved: Array.isArray(conflictError.non_conflicting_files)
1254
1267
  ? conflictError.non_conflicting_files
1255
1268
  : [],
@@ -2049,6 +2062,7 @@ export function reactivateGovernedRun(root, state, details = {}) {
2049
2062
 
2050
2063
  const now = new Date().toISOString();
2051
2064
  const wasEscalation = state.status === 'blocked' && typeof state.blocked_on === 'string' && state.blocked_on.startsWith('escalation:');
2065
+ const wasNonProgress = state.blocked_on === 'non_progress';
2052
2066
  const humanEscalation = findCurrentHumanEscalation(root, state);
2053
2067
  const nextState = {
2054
2068
  ...state,
@@ -2058,8 +2072,33 @@ export function reactivateGovernedRun(root, state, details = {}) {
2058
2072
  escalation: null,
2059
2073
  };
2060
2074
 
2075
+ // BUG-38: reset non-progress tracking when acknowledging non-progress block
2076
+ if (wasNonProgress || details.acknowledge_non_progress) {
2077
+ nextState.non_progress_signature = null;
2078
+ nextState.non_progress_count = 0;
2079
+ }
2080
+
2061
2081
  writeState(root, nextState);
2062
2082
 
2083
+ const startupIntents = nextState.run_id
2084
+ ? archiveStaleIntentsForRun(root, nextState.run_id, {
2085
+ protocolVersion: nextState.protocol_version || state.protocol_version || '2.x',
2086
+ })
2087
+ : { archived_migration_intent_ids: [], migration_notice: null };
2088
+
2089
+ if (startupIntents.archived_migration_intent_ids?.length > 0) {
2090
+ emitRunEvent(root, 'intents_migrated', {
2091
+ run_id: nextState.run_id,
2092
+ phase: nextState.phase,
2093
+ status: nextState.status,
2094
+ payload: {
2095
+ archived_count: startupIntents.archived_migration_intent_ids.length,
2096
+ archived_intent_ids: startupIntents.archived_migration_intent_ids,
2097
+ reason: 'pre-BUG-34 intents with approved_run_id: null archived during run reactivation',
2098
+ },
2099
+ });
2100
+ }
2101
+
2063
2102
  if (humanEscalation) {
2064
2103
  resolveHumanEscalation(root, humanEscalation.escalation_id, {
2065
2104
  resolved_at: now,
@@ -2101,7 +2140,11 @@ export function reactivateGovernedRun(root, state, details = {}) {
2101
2140
  });
2102
2141
  }
2103
2142
 
2104
- return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
2143
+ return {
2144
+ ok: true,
2145
+ state: attachLegacyCurrentTurnAlias(nextState),
2146
+ migration_notice: formatLegacyIntentMigrationNotice(startupIntents.archived_migration_intent_ids),
2147
+ };
2105
2148
  }
2106
2149
 
2107
2150
  // ── Core Operations ──────────────────────────────────────────────────────────
@@ -2155,40 +2198,24 @@ export function initializeGovernedRun(root, config, options = {}) {
2155
2198
 
2156
2199
  writeState(root, updatedState);
2157
2200
 
2158
- // BUG-34: retroactive migration archive stale intents from prior runs.
2159
- // Intents with an approved_run_id from a DIFFERENT run are archived.
2160
- // Intents with no approved_run_id are adopted into the current run
2161
- // (they were created while the project was idle or pre-run).
2162
- try {
2163
- const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
2164
- if (existsSync(intentsDir)) {
2165
- const DISPATCHABLE = new Set(['planned', 'approved']);
2166
- const intNow = new Date().toISOString();
2167
- for (const f of readdirSync(intentsDir).filter(x => x.endsWith('.json') && !x.startsWith('.tmp-'))) {
2168
- const ip = join(intentsDir, f);
2169
- try {
2170
- const intent = JSON.parse(readFileSync(ip, 'utf8'));
2171
- if (!intent || !DISPATCHABLE.has(intent.status)) continue;
2172
- if (intent.cross_run_durable === true) continue;
2173
- if (intent.approved_run_id === runId) continue;
2174
-
2175
- if (intent.approved_run_id && intent.approved_run_id !== runId) {
2176
- // Intent from a different run — archive it
2177
- intent.status = 'suppressed';
2178
- intent.updated_at = intNow;
2179
- intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${runId} initialization`;
2180
- if (!intent.history) intent.history = [];
2181
- intent.history.push({ from: 'approved', to: 'suppressed', at: intNow, reason: intent.archived_reason });
2182
- } else if (!intent.approved_run_id) {
2183
- // Legacy intent with no run binding — adopt into current run
2184
- intent.approved_run_id = runId;
2185
- intent.updated_at = intNow;
2186
- }
2187
- safeWriteJson(ip, intent);
2188
- } catch { /* non-fatal per-intent */ }
2189
- }
2190
- }
2191
- } catch { /* non-fatal — intent migration is best-effort */ }
2201
+ const startupIntents = archiveStaleIntentsForRun(root, runId, {
2202
+ protocolVersion: updatedState.protocol_version || '2.x',
2203
+ });
2204
+ const archivedMigrationIntentIds = startupIntents.archived_migration_intent_ids || [];
2205
+
2206
+ // BUG-39: emit intents_migrated event when pre-BUG-34 intents were archived
2207
+ if (archivedMigrationIntentIds.length > 0) {
2208
+ emitRunEvent(root, 'intents_migrated', {
2209
+ run_id: runId,
2210
+ phase: updatedState.phase,
2211
+ status: 'active',
2212
+ payload: {
2213
+ archived_count: archivedMigrationIntentIds.length,
2214
+ archived_intent_ids: archivedMigrationIntentIds,
2215
+ reason: 'pre-BUG-34 intents with approved_run_id: null archived during run initialization',
2216
+ },
2217
+ });
2218
+ }
2192
2219
 
2193
2220
  emitRunEvent(root, 'run_started', {
2194
2221
  run_id: runId,
@@ -2196,7 +2223,10 @@ export function initializeGovernedRun(root, config, options = {}) {
2196
2223
  status: 'active',
2197
2224
  payload: { provenance: provenance || {} },
2198
2225
  });
2199
- return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
2226
+ // BUG-39: return migration notice so callers can display it
2227
+ const migrationNotice = startupIntents.migration_notice;
2228
+
2229
+ return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), migration_notice: migrationNotice };
2200
2230
  }
2201
2231
 
2202
2232
  /**
@@ -3160,22 +3190,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3160
3190
  // that this turn didn't modify.
3161
3191
  const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
3162
3192
  const exitGateId = preGateResult.gate_id || 'unknown_gate';
3163
-
3164
- // Extract file paths from gate failure reasons and missing_files
3165
- const failingFiles = [
3166
- ...(preGateResult.missing_files || []),
3167
- ];
3168
-
3169
- // Also extract file paths from failure reasons (e.g., ".planning/IMPLEMENTATION_NOTES.md: ...")
3170
- for (const reason of (preGateResult.reasons || [])) {
3171
- const fileMatch = reason.match(/(?:Required file missing|file): ([^\s,]+)/);
3172
- if (fileMatch) failingFiles.push(fileMatch[1]);
3173
- // Also catch paths at the start of semantic failure messages
3174
- const semanticMatch = reason.match(/^([^\s:]+\.md):/);
3175
- if (semanticMatch) failingFiles.push(semanticMatch[1]);
3176
- }
3177
-
3178
- const uniqueFailingFiles = [...new Set(failingFiles.map(f => f.replace(/^\.\//, '')))];
3193
+ const uniqueFailingFiles = [...new Set(
3194
+ (preGateResult.failing_files || preGateResult.missing_files || [])
3195
+ .map(f => f.replace(/^\.\//, ''))
3196
+ )];
3179
3197
  const uncoveredFiles = uniqueFailingFiles.filter(f => !declaredFiles.has(f));
3180
3198
 
3181
3199
  const gateSemanticMode = config.gate_semantic_coverage_mode || 'strict';
@@ -4207,6 +4225,118 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4207
4225
  }
4208
4226
  }
4209
4227
 
4228
+ // ── BUG-38: Non-progress convergence guard ─────────────────────────────
4229
+ // Track whether consecutive accepted turns leave the same gate failure
4230
+ // intact without modifying the gated files. When the count reaches the
4231
+ // configurable threshold, block the run to prevent infinite loops.
4232
+ // Proactively evaluates the current phase exit gate on every accepted turn
4233
+ // — not just when a transition is requested — to detect stalled patterns.
4234
+ if (updatedState.status === 'active') {
4235
+ const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
4236
+ const npThreshold = config.run_loop?.non_progress_threshold ?? 3;
4237
+
4238
+ // Proactively evaluate the exit gate for the current phase.
4239
+ // evaluatePhaseExit requires a phase_transition_request, so we synthesize
4240
+ // one pointing to the next phase from routing for probing purposes.
4241
+ let proactiveGateFailure = null;
4242
+ try {
4243
+ const currentRouting = config.routing?.[updatedState.phase];
4244
+ if (currentRouting?.exit_gate) {
4245
+ // Find the next phase to use as synthetic transition target
4246
+ const phases = config.phases || [];
4247
+ const currentIdx = phases.findIndex(p => p.id === updatedState.phase);
4248
+ const nextPhaseId = currentIdx >= 0 && currentIdx < phases.length - 1
4249
+ ? phases[currentIdx + 1].id
4250
+ : (currentRouting.allowed_next_roles ? Object.keys(config.routing).find(k => k !== updatedState.phase) : null);
4251
+
4252
+ if (nextPhaseId) {
4253
+ const probeTurn = { ...turnResult, phase_transition_request: nextPhaseId };
4254
+ const probeResult = evaluatePhaseExit({
4255
+ state: updatedState,
4256
+ config,
4257
+ acceptedTurn: probeTurn,
4258
+ root,
4259
+ });
4260
+ if (probeResult.action === 'gate_failed') {
4261
+ proactiveGateFailure = probeResult;
4262
+ }
4263
+ }
4264
+ }
4265
+ } catch { /* non-fatal — probe is advisory */ }
4266
+
4267
+ // Fall back to last_gate_failure if proactive evaluation didn't find one
4268
+ const effectiveGateFailure = proactiveGateFailure || updatedState.last_gate_failure;
4269
+
4270
+ if (effectiveGateFailure && (effectiveGateFailure.gate_id || effectiveGateFailure.gate_type)) {
4271
+ const gateId = effectiveGateFailure.gate_id || effectiveGateFailure.gate_type || 'unknown';
4272
+ // Compute gate failure signature: gate_id + sorted failing files + sorted reasons
4273
+ const failingFiles = [...(effectiveGateFailure.missing_files || effectiveGateFailure.failing_files || [])].sort();
4274
+ const reasons = [...(effectiveGateFailure.reasons || [])].sort();
4275
+ const signature = `${gateId}::${failingFiles.join(',')}::${reasons.join(',')}`;
4276
+
4277
+ // Check if any of the gated files were modified by this turn
4278
+ const gatedFilesModified = failingFiles.some(f => declaredFiles.has(f.replace(/^\.\//, '')));
4279
+
4280
+ const prevSignature = state.non_progress_signature || null;
4281
+ const prevCount = state.non_progress_count || 0;
4282
+
4283
+ if (signature === prevSignature && !gatedFilesModified) {
4284
+ // Same gate failure, gated files untouched — increment counter
4285
+ const newCount = prevCount + 1;
4286
+ updatedState.non_progress_signature = signature;
4287
+ updatedState.non_progress_count = newCount;
4288
+
4289
+ if (newCount >= npThreshold) {
4290
+ // Threshold reached — block the run
4291
+ updatedState.status = 'blocked';
4292
+ updatedState.blocked_on = 'non_progress';
4293
+ updatedState.blocked_reason = buildBlockedReason({
4294
+ category: 'non_progress',
4295
+ recovery: {
4296
+ typed_reason: `Non-progress detected: ${newCount} accepted turns have not reduced gate failure "${gateId}".`,
4297
+ recovery_action: 'agentxchain resume --acknowledge-non-progress',
4298
+ detail: `Gate "${gateId}" has been failing on ${failingFiles.join(', ')} for ${newCount} consecutive turns. The gated file(s) were never modified.`,
4299
+ },
4300
+ turnId: currentTurn.turn_id,
4301
+ blockedAt: now,
4302
+ });
4303
+
4304
+ ledgerEntries.push({
4305
+ type: 'non_progress_block',
4306
+ gate_id: gateId,
4307
+ consecutive_turns: newCount,
4308
+ threshold: npThreshold,
4309
+ failing_files: failingFiles,
4310
+ signature,
4311
+ timestamp: now,
4312
+ });
4313
+
4314
+ emitRunEvent(root, 'run_stalled', {
4315
+ run_id: updatedState.run_id,
4316
+ phase: updatedState.phase,
4317
+ status: 'blocked',
4318
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4319
+ payload: {
4320
+ consecutive_non_progress_turns: newCount,
4321
+ threshold: npThreshold,
4322
+ gate_id: gateId,
4323
+ failing_files: failingFiles,
4324
+ signature,
4325
+ },
4326
+ });
4327
+ }
4328
+ } else {
4329
+ // Gate failure changed or gated files were modified — reset counter
4330
+ updatedState.non_progress_signature = signature;
4331
+ updatedState.non_progress_count = 1;
4332
+ }
4333
+ } else {
4334
+ // No gate failure — reset non-progress tracking
4335
+ updatedState.non_progress_signature = null;
4336
+ updatedState.non_progress_count = 0;
4337
+ }
4338
+ }
4339
+
4210
4340
  // ── Transaction journal: prepare before committing writes ──────────────
4211
4341
  const transactionId = generateId('txn');
4212
4342
  const journal = {
package/src/lib/intake.js CHANGED
@@ -17,6 +17,11 @@ import { getDispatchTurnDir } from './turn-paths.js';
17
17
  import { loadCoordinatorConfig } from './coordinator-config.js';
18
18
  import { loadCoordinatorState, readBarriers } from './coordinator-state.js';
19
19
  import { writeCoordinatorHandoff } from './intake-handoff.js';
20
+ import {
21
+ archiveStaleIntentsForRun,
22
+ migratePreBug34Intents,
23
+ formatLegacyIntentMigrationNotice,
24
+ } from './intent-startup-migration.js';
20
25
 
21
26
  const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan'];
22
27
  const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
@@ -516,16 +521,14 @@ export function findNextDispatchableIntent(root, options = {}) {
516
521
  .filter((intent) => intent && DISPATCHABLE_STATUSES.has(intent.status));
517
522
 
518
523
  // BUG-34: when run_id scoping is active, filter out intents that belong to
519
- // a different run. An intent belongs to the current run if:
520
- // (a) it has approved_run_id matching the current run, OR
521
- // (b) it has no approved_run_id AND is marked cross_run_durable, OR
522
- // (c) it was injected in the current run (approved_run_id matches)
523
- // Legacy intents (no approved_run_id, no cross_run_durable) are excluded
524
- // because they are stale leftovers from prior runs.
524
+ // a different run. `cross_run_durable` is only a pre-run holding state for
525
+ // freshly approved idle intents. Once a run starts, those intents must be
526
+ // rebound onto that run and the flag cleared; it must never override an
527
+ // existing run binding.
525
528
  if (scopeRunId) {
526
529
  intents = intents.filter((intent) => {
527
530
  if (intent.approved_run_id === scopeRunId) return true;
528
- if (intent.cross_run_durable === true) return true;
531
+ if (!intent.approved_run_id && intent.cross_run_durable === true) return true;
529
532
  // Legacy intent with no run binding — stale, skip it
530
533
  if (!intent.approved_run_id) return false;
531
534
  // Intent bound to a different run — stale, skip it
@@ -579,10 +582,11 @@ export function findPendingApprovedIntents(root, options = {}) {
579
582
  return readJsonDir(dirs.intents)
580
583
  .filter((intent) => {
581
584
  if (!intent || intent.status !== 'approved') return false;
582
- // BUG-34: run_id scoping — same logic as findNextDispatchableIntent
585
+ // BUG-34: run_id scoping — same logic as findNextDispatchableIntent.
586
+ // `cross_run_durable` only applies before the first run binding exists.
583
587
  if (scopeRunId) {
584
588
  if (intent.approved_run_id === scopeRunId) return true;
585
- if (intent.cross_run_durable === true) return true;
589
+ if (!intent.approved_run_id && intent.cross_run_durable === true) return true;
586
590
  return false;
587
591
  }
588
592
  return true;
@@ -616,46 +620,7 @@ export function findPendingApprovedIntents(root, options = {}) {
616
620
  * @returns {{ archived: number }}
617
621
  */
618
622
  export function archiveStaleIntents(root, newRunId) {
619
- const dirs = intakeDirs(root);
620
- if (!existsSync(dirs.intents)) return { archived: 0, adopted: 0 };
621
-
622
- const now = nowISO();
623
- let archived = 0;
624
- let adopted = 0;
625
-
626
- const files = readdirSync(dirs.intents).filter(f => f.endsWith('.json') && !f.startsWith('.tmp-'));
627
- for (const file of files) {
628
- const intentPath = join(dirs.intents, file);
629
- let intent;
630
- try {
631
- intent = JSON.parse(readFileSync(intentPath, 'utf8'));
632
- } catch {
633
- continue;
634
- }
635
-
636
- if (!intent || !DISPATCHABLE_STATUSES.has(intent.status)) continue;
637
- if (intent.cross_run_durable === true) continue;
638
- if (intent.approved_run_id === newRunId) continue;
639
-
640
- if (intent.approved_run_id && intent.approved_run_id !== newRunId) {
641
- // Intent from a different run — archive it
642
- intent.status = 'suppressed';
643
- intent.updated_at = now;
644
- intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${newRunId} initialization`;
645
- if (!intent.history) intent.history = [];
646
- intent.history.push({ from: 'approved', to: 'suppressed', at: now, reason: intent.archived_reason });
647
- safeWriteJson(intentPath, intent);
648
- archived++;
649
- } else if (!intent.approved_run_id) {
650
- // Legacy intent with no run binding — adopt into current run
651
- intent.approved_run_id = newRunId;
652
- intent.updated_at = now;
653
- safeWriteJson(intentPath, intent);
654
- adopted++;
655
- }
656
- }
657
-
658
- return { archived, adopted };
623
+ return archiveStaleIntentsForRun(root, newRunId);
659
624
  }
660
625
 
661
626
  /**
@@ -816,9 +781,11 @@ export function approveIntent(root, intentId, options = {}) {
816
781
  const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
817
782
  const now = nowISO();
818
783
 
819
- // BUG-34: stamp the current run_id on approval so the intent is scoped to
820
- // the run that approved it. Intents without approved_run_id are treated as
821
- // legacy/unbound and filtered out by run-scoped queries.
784
+ // BUG-34/39: stamp the current run_id on approval so the intent is scoped
785
+ // to the run that approved it. When approval happens before any governed
786
+ // run exists, hold the intent in a pre-run durable state so the *next* run
787
+ // initialization can bind it to that run. The flag is not an evergreen
788
+ // cross-run bypass; it exists only until first run binding happens.
822
789
  if (!intent.approved_run_id) {
823
790
  const statePath = join(root, '.agentxchain', 'state.json');
824
791
  if (existsSync(statePath)) {
@@ -826,10 +793,14 @@ export function approveIntent(root, intentId, options = {}) {
826
793
  const state = JSON.parse(readFileSync(statePath, 'utf8'));
827
794
  if (state.run_id) {
828
795
  intent.approved_run_id = state.run_id;
796
+ } else {
797
+ intent.cross_run_durable = true;
829
798
  }
830
799
  } catch {
831
800
  // non-fatal — stamp is best-effort during approval
832
801
  }
802
+ } else {
803
+ intent.cross_run_durable = true;
833
804
  }
834
805
  }
835
806
 
@@ -0,0 +1,151 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { safeWriteJson } from './safe-write.js';
5
+
6
+ const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
7
+
8
+ function nowISO() {
9
+ return new Date().toISOString();
10
+ }
11
+
12
+ function getIntentsDir(root) {
13
+ return join(root, '.agentxchain', 'intake', 'intents');
14
+ }
15
+
16
+ function listIntentFiles(intentsDir) {
17
+ if (!existsSync(intentsDir)) return [];
18
+ return readdirSync(intentsDir).filter((file) => file.endsWith('.json') && !file.startsWith('.tmp-'));
19
+ }
20
+
21
+ export function formatLegacyIntentMigrationNotice(intentIds) {
22
+ if (!Array.isArray(intentIds) || intentIds.length === 0) return null;
23
+ return `Archived ${intentIds.length} pre-BUG-34 intent(s): ${intentIds.join(', ')}`;
24
+ }
25
+
26
+ export function migratePreBug34Intents(root, runId, options = {}) {
27
+ const intentsDir = getIntentsDir(root);
28
+ if (!existsSync(intentsDir)) {
29
+ return {
30
+ archived_migration_count: 0,
31
+ archived_migration_intent_ids: [],
32
+ migration_notice: null,
33
+ };
34
+ }
35
+
36
+ const now = nowISO();
37
+ const archivedMigrationIntentIds = [];
38
+ const protocolVersion = options.protocolVersion || '2.x';
39
+
40
+ for (const file of listIntentFiles(intentsDir)) {
41
+ const intentPath = join(intentsDir, file);
42
+ let intent;
43
+ try {
44
+ intent = JSON.parse(readFileSync(intentPath, 'utf8'));
45
+ } catch {
46
+ continue;
47
+ }
48
+
49
+ if (!intent || !DISPATCHABLE_STATUSES.has(intent.status)) continue;
50
+ if (intent.cross_run_durable === true) continue;
51
+ if (intent.approved_run_id) continue;
52
+
53
+ const prevStatus = intent.status;
54
+ intent.status = 'archived_migration';
55
+ intent.updated_at = now;
56
+ intent.archived_reason = `pre-BUG-34 intent with no run scope; archived during v${protocolVersion} migration on run ${runId}`;
57
+ if (!intent.history) intent.history = [];
58
+ intent.history.push({
59
+ from: prevStatus,
60
+ to: 'archived_migration',
61
+ at: now,
62
+ reason: intent.archived_reason,
63
+ });
64
+ safeWriteJson(intentPath, intent);
65
+ if (intent.intent_id) archivedMigrationIntentIds.push(intent.intent_id);
66
+ }
67
+
68
+ return {
69
+ archived_migration_count: archivedMigrationIntentIds.length,
70
+ archived_migration_intent_ids: archivedMigrationIntentIds,
71
+ migration_notice: formatLegacyIntentMigrationNotice(archivedMigrationIntentIds),
72
+ };
73
+ }
74
+
75
+ export function archiveStaleIntentsForRun(root, runId, options = {}) {
76
+ const intentsDir = getIntentsDir(root);
77
+ if (!existsSync(intentsDir)) {
78
+ return {
79
+ archived: 0,
80
+ adopted: 0,
81
+ archived_migration_count: 0,
82
+ archived_migration_intent_ids: [],
83
+ migration_notice: null,
84
+ };
85
+ }
86
+
87
+ const now = nowISO();
88
+ let archived = 0;
89
+ let adopted = 0;
90
+
91
+ for (const file of listIntentFiles(intentsDir)) {
92
+ const intentPath = join(intentsDir, file);
93
+ let intent;
94
+ try {
95
+ intent = JSON.parse(readFileSync(intentPath, 'utf8'));
96
+ } catch {
97
+ continue;
98
+ }
99
+
100
+ if (!intent || !DISPATCHABLE_STATUSES.has(intent.status)) continue;
101
+
102
+ if (intent.cross_run_durable === true && !intent.approved_run_id) {
103
+ intent.approved_run_id = runId;
104
+ delete intent.cross_run_durable;
105
+ intent.updated_at = now;
106
+ if (!intent.history) intent.history = [];
107
+ intent.history.push({
108
+ from: intent.status,
109
+ to: intent.status,
110
+ at: now,
111
+ reason: `pre-run durable approval bound to run ${runId}`,
112
+ });
113
+ safeWriteJson(intentPath, intent);
114
+ adopted += 1;
115
+ continue;
116
+ }
117
+
118
+ if (intent.cross_run_durable === true && intent.approved_run_id === runId) {
119
+ delete intent.cross_run_durable;
120
+ intent.updated_at = now;
121
+ safeWriteJson(intentPath, intent);
122
+ continue;
123
+ }
124
+
125
+ if (intent.approved_run_id && intent.approved_run_id !== runId) {
126
+ const prevStatus = intent.status;
127
+ intent.status = 'suppressed';
128
+ intent.updated_at = now;
129
+ intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${runId} initialization`;
130
+ if (!intent.history) intent.history = [];
131
+ intent.history.push({
132
+ from: prevStatus,
133
+ to: 'suppressed',
134
+ at: now,
135
+ reason: intent.archived_reason,
136
+ });
137
+ safeWriteJson(intentPath, intent);
138
+ archived += 1;
139
+ }
140
+ }
141
+
142
+ const migration = migratePreBug34Intents(root, runId, options);
143
+
144
+ return {
145
+ archived,
146
+ adopted,
147
+ archived_migration_count: migration.archived_migration_count,
148
+ archived_migration_intent_ids: migration.archived_migration_intent_ids,
149
+ migration_notice: migration.migration_notice,
150
+ };
151
+ }
@@ -1178,6 +1178,11 @@ export function normalizeV4(raw) {
1178
1178
  state: '.agentxchain/state.json',
1179
1179
  log: null,
1180
1180
  },
1181
+ // Passthrough fields — these are runtime behavior overrides that don't
1182
+ // need normalization but must survive the config pipeline.
1183
+ ...(raw.gate_semantic_coverage_mode ? { gate_semantic_coverage_mode: raw.gate_semantic_coverage_mode } : {}),
1184
+ ...(raw.intent_coverage_mode ? { intent_coverage_mode: raw.intent_coverage_mode } : {}),
1185
+ ...(raw.run_loop ? { run_loop: raw.run_loop } : {}),
1181
1186
  compat: {
1182
1187
  next_owner_source: 'state-json',
1183
1188
  lock_based_coordination: false,