agentxchain 2.134.0 → 2.135.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.134.0",
3
+ "version": "2.135.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,6 +61,7 @@ export async function doctorCommand(opts = {}) {
61
61
  function governedDoctor(root, rawConfig, opts) {
62
62
  const checks = [];
63
63
  const cliVersionHealth = getCliVersionHealth();
64
+ let stateRunId = null;
64
65
 
65
66
  checks.push(buildCliVersionCheck(cliVersionHealth));
66
67
 
@@ -109,6 +110,7 @@ function governedDoctor(root, rawConfig, opts) {
109
110
  if (existsSync(statePath)) {
110
111
  try {
111
112
  const stateData = JSON.parse(readFileSync(statePath, 'utf8'));
113
+ stateRunId = stateData.run_id || null;
112
114
  if (stateData.schema_version) {
113
115
  checks.push({ id: 'state_health', name: 'State health', level: 'pass', detail: `schema_version: ${stateData.schema_version}, status: ${stateData.status || 'unknown'}` });
114
116
  } else {
@@ -354,7 +356,7 @@ function governedDoctor(root, rawConfig, opts) {
354
356
 
355
357
  // 11. Pending intake intents (BUG-15 — informational)
356
358
  {
357
- const pendingIntents = findPendingApprovedIntents(root);
359
+ const pendingIntents = findPendingApprovedIntents(root, { run_id: stateRunId });
358
360
  if (pendingIntents.length > 0) {
359
361
  const summary = pendingIntents.map(pi => `[${pi.priority}] ${pi.intent_id}`).join(', ');
360
362
  checks.push({
@@ -88,7 +88,10 @@ function printEvent(evt) {
88
88
  const coordinatorRetryDetail = evt.event_type === 'coordinator_retry' && evt.payload
89
89
  ? ` — ws ${evt.payload.workstream_id || '?'} repo ${evt.payload.repo_id || '?'} (retry of ${evt.payload.failed_turn_id || '?'})`
90
90
  : '';
91
- console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${intentInfo}${conflictDetail}${conflictResolvedDetail}${rejectionDetail}${acceptanceFailedDetail}${phaseTransitionDetail}${gateFailedDetail}${humanEscalationDetail}${coordinatorRetryDetail}`);
91
+ const projectionWarningDetail = evt.event_type === 'coordinator_retry_projection_warning' && evt.payload
92
+ ? ` — ws ${evt.payload.workstream_id || '?'} repo ${evt.payload.repo_id || '?'} (reconciliation required)`
93
+ : '';
94
+ console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}${intentInfo}${conflictDetail}${conflictResolvedDetail}${rejectionDetail}${acceptanceFailedDetail}${phaseTransitionDetail}${gateFailedDetail}${humanEscalationDetail}${coordinatorRetryDetail}${projectionWarningDetail}`);
92
95
  }
93
96
 
94
97
  function formatConflictDetail(evt) {
@@ -150,6 +153,7 @@ function colorEventType(type) {
150
153
  gate_failed: chalk.red,
151
154
  budget_exceeded_warn: chalk.yellowBright,
152
155
  coordinator_retry: chalk.cyan.bold,
156
+ coordinator_retry_projection_warning: chalk.yellow.bold,
153
157
  turn_checkpointed: chalk.green,
154
158
  dispatch_progress: chalk.blue.dim,
155
159
  };
@@ -38,6 +38,7 @@ import { executeGovernedRun } from './run.js';
38
38
  import { dispatchCoordinatorTurn, selectAssignmentForWorkstream } from '../lib/coordinator-dispatch.js';
39
39
  import { loadCoordinatorState } from '../lib/coordinator-state.js';
40
40
  import { projectRepoAcceptance } from '../lib/coordinator-acceptance.js';
41
+ import { emitRunEvent } from '../lib/run-events.js';
41
42
 
42
43
  export async function missionStartCommand(opts) {
43
44
  const root = findProjectRoot(opts.dir || process.cwd());
@@ -182,6 +183,13 @@ function projectAcceptedCoordinatorTurn(workspacePath, coordinatorConfig, repoId
182
183
  );
183
184
  }
184
185
 
186
+ function buildCoordinatorProjectionWarning(message) {
187
+ return {
188
+ code: 'coordinator_acceptance_projection_incomplete',
189
+ message,
190
+ };
191
+ }
192
+
185
193
  export async function missionListCommand(opts) {
186
194
  const root = findProjectRoot(opts.dir || process.cwd());
187
195
  if (!root) {
@@ -634,6 +642,7 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
634
642
  };
635
643
 
636
644
  let execution;
645
+ const retryWarnings = [];
637
646
  try {
638
647
  execution = await executor(repoContext, runOpts);
639
648
  } catch (error) {
@@ -669,7 +678,21 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
669
678
  loadCoordinatorState(mission.coordinator.workspace_path),
670
679
  );
671
680
  if (!projection.ok) {
672
- console.error(chalk.yellow(`Coordinator retry projection warning: ${projection.error}`));
681
+ const warning = buildCoordinatorProjectionWarning(projection.error);
682
+ retryWarnings.push(warning);
683
+ console.error(chalk.yellow(`Coordinator retry projection warning: ${warning.message}`));
684
+ emitRunEvent(mission.coordinator.workspace_path, 'coordinator_retry_projection_warning', {
685
+ run_id: mission.coordinator.super_run_id,
686
+ phase: coordinatorConfigResult.config?.workstreams?.[opts.workstream]?.phase || null,
687
+ status: 'active',
688
+ payload: {
689
+ workstream_id: opts.workstream,
690
+ repo_id: retry.retryResult.repo_id,
691
+ reissued_turn_id: retry.retryResult.reissued_turn_id,
692
+ warning_code: warning.code,
693
+ warning_message: warning.message,
694
+ },
695
+ });
673
696
  }
674
697
  }
675
698
 
@@ -697,6 +720,8 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
697
720
  workstream_status: retriedWorkstream?.launch_status || 'launched',
698
721
  launch_record: retriedLaunchRecord,
699
722
  exit_code: execution?.exitCode ?? 0,
723
+ warnings: retryWarnings,
724
+ reconciliation_required: retryWarnings.length > 0,
700
725
  }, null, 2));
701
726
  if ((execution?.exitCode ?? 0) !== 0) {
702
727
  process.exit(execution.exitCode);
@@ -713,6 +738,9 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
713
738
  console.log(chalk.dim(` Old Turn: ${retry.retryResult.failed_turn_id}`));
714
739
  console.log(chalk.dim(` New Turn: ${retry.retryResult.reissued_turn_id}`));
715
740
  console.log(chalk.dim(` Workstream: ${retriedWorkstream?.launch_status || 'launched'}`));
741
+ if (retryWarnings.length > 0) {
742
+ console.log(chalk.yellow(` Warning: ${retryWarnings[0].message}`));
743
+ }
716
744
  console.log('');
717
745
  renderPlan(retriedPlan);
718
746
  if ((execution?.exitCode ?? 0) !== 0) {
@@ -342,7 +342,7 @@ export async function restartCommand(opts) {
342
342
  if (activeTurnCount === 0) {
343
343
  // BUG-21 fix: consume approved intents (same as resume path) so intent_id
344
344
  // propagates into turn metadata and all lifecycle events.
345
- const consumed = consumeNextApprovedIntent(root, { role: roleId });
345
+ const consumed = consumeNextApprovedIntent(root, { role: roleId, run_id: state?.run_id || null });
346
346
  let assignedState;
347
347
  let turnId;
348
348
  let assignedRole = roleId;
@@ -267,7 +267,9 @@ export async function resumeCommand(opts) {
267
267
  }
268
268
 
269
269
  const shouldBindIntent = opts.intent !== false;
270
- const consumed = shouldBindIntent ? consumeNextApprovedIntent(root, { role: roleId }) : { ok: false };
270
+ const consumed = shouldBindIntent
271
+ ? consumeNextApprovedIntent(root, { role: roleId, run_id: state?.run_id || null })
272
+ : { ok: false };
271
273
  if (consumed.ok) {
272
274
  state = loadProjectState(root, config);
273
275
  if (!state) {
@@ -24,6 +24,7 @@ import { getDashboardPid, getDashboardSession } from './dashboard.js';
24
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
+ import { readCoordinatorWarnings } from '../lib/coordinator-warnings.js';
27
28
 
28
29
  export async function statusCommand(opts) {
29
30
  const context = loadStatusContext();
@@ -111,10 +112,22 @@ function loadStatusContext(dir = process.cwd()) {
111
112
  return null;
112
113
  }
113
114
 
115
+ const fallbackConfig = {
116
+ ...rawConfig,
117
+ files: {
118
+ talk: rawConfig.files?.talk || 'TALK.md',
119
+ history: rawConfig.files?.history || '.agentxchain/history.jsonl',
120
+ state: rawConfig.files?.state || '.agentxchain/state.json',
121
+ log: Object.prototype.hasOwnProperty.call(rawConfig.files || {}, 'log')
122
+ ? rawConfig.files.log
123
+ : null,
124
+ },
125
+ };
126
+
114
127
  return {
115
128
  root,
116
129
  rawConfig,
117
- config: rawConfig,
130
+ config: fallbackConfig,
118
131
  version: 4,
119
132
  };
120
133
  }
@@ -122,6 +135,7 @@ function loadStatusContext(dir = process.cwd()) {
122
135
  function renderGovernedStatus(context, opts) {
123
136
  const { root, config, version } = context;
124
137
  const state = loadProjectState(root, config);
138
+ const stateRunId = state?.run_id || readRawStateRunId(root, config);
125
139
  const continuity = getContinuityStatus(root, state);
126
140
  const connectorHealth = getConnectorHealth(root, config, state);
127
141
  const recovery = deriveRecoveryDescriptor(state, config);
@@ -133,7 +147,7 @@ function renderGovernedStatus(context, opts) {
133
147
  const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
134
148
  const humanEscalation = findCurrentHumanEscalation(root, state);
135
149
  const preemptionMarker = readPreemptionMarker(root);
136
- const pendingIntents = findPendingApprovedIntents(root);
150
+ const pendingIntents = findPendingApprovedIntents(root, { run_id: stateRunId || null });
137
151
  const continuousSession = readContinuousSession(root);
138
152
  const gateActionAttempt = state?.pending_phase_transition
139
153
  ? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
@@ -147,6 +161,9 @@ function renderGovernedStatus(context, opts) {
147
161
  const activeTurns = getActiveTurns(state);
148
162
  const dispatchProgress = filterDispatchProgressForActiveTurns(readAllDispatchProgress(root), activeTurns);
149
163
 
164
+ // Coordinator warning surfacing — DEC-COORD-RETRY-PROJECTION-EVENT-001
165
+ const coordinatorWarnings = readCoordinatorWarnings(root, { runId: stateRunId || null });
166
+
150
167
  if (opts.json) {
151
168
  const dashPid = getDashboardPid(root);
152
169
  const dashSession = getDashboardSession(root);
@@ -183,6 +200,7 @@ function renderGovernedStatus(context, opts) {
183
200
  dashboard_session: dashboardSessionObj,
184
201
  binding_drift: detectActiveTurnBindingDrift(state, config),
185
202
  bundle_integrity: detectStateBundleDesync(root, state),
203
+ coordinator_warnings: coordinatorWarnings,
186
204
  }, null, 2));
187
205
  return;
188
206
  }
@@ -284,6 +302,7 @@ function renderGovernedStatus(context, opts) {
284
302
  renderContinuityStatus(continuity, state);
285
303
  renderConnectorHealthStatus(connectorHealth);
286
304
  renderRecentEventSummary(recentEventSummary);
305
+ renderCoordinatorWarnings(coordinatorWarnings);
287
306
 
288
307
  // BUG-18: State/bundle integrity check
289
308
  const desync = detectStateBundleDesync(root, state);
@@ -908,3 +927,37 @@ function formatUsd(value) {
908
927
  if (typeof value !== 'number' || Number.isNaN(value)) return '0.00';
909
928
  return value.toFixed(2);
910
929
  }
930
+
931
+ /**
932
+ * Read coordinator retry projection warnings from the event log.
933
+ * Returns a structured summary for both JSON and CLI output.
934
+ */
935
+ function renderCoordinatorWarnings(warnings) {
936
+ if (!warnings || warnings.count === 0) return;
937
+ console.log(chalk.yellow.bold(` ⚠ Coordinator reconciliation required (${warnings.count} projection warning${warnings.count !== 1 ? 's' : ''})`));
938
+ for (const w of warnings.warnings) {
939
+ const wsLabel = w.workstream_id ? `ws:${w.workstream_id}` : '';
940
+ const repoLabel = w.repo_id ? `repo:${w.repo_id}` : '';
941
+ const parts = [wsLabel, repoLabel].filter(Boolean).join(' ');
942
+ console.log(` ${chalk.yellow('●')} ${parts} — ${w.warning_code}`);
943
+ }
944
+ console.log(chalk.dim(' Run `agentxchain mission plan show latest --json` to force plan sync and verify.'));
945
+ console.log('');
946
+ }
947
+
948
+ function readRawStateRunId(root, config) {
949
+ const relPath = config?.files?.state || '.agentxchain/state.json';
950
+ const filePath = join(root, relPath);
951
+ if (!existsSync(filePath)) {
952
+ return null;
953
+ }
954
+
955
+ try {
956
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
957
+ return typeof parsed?.run_id === 'string' && parsed.run_id.trim().length > 0
958
+ ? parsed.run_id.trim()
959
+ : null;
960
+ } catch {
961
+ return null;
962
+ }
963
+ }
@@ -316,7 +316,9 @@ export async function stepCommand(opts) {
316
316
  }
317
317
 
318
318
  const shouldBindIntent = opts.intent !== false;
319
- const consumed = shouldBindIntent ? consumeNextApprovedIntent(root, { role: roleId }) : { ok: false };
319
+ const consumed = shouldBindIntent
320
+ ? consumeNextApprovedIntent(root, { role: roleId, run_id: state?.run_id || null })
321
+ : { ok: false };
320
322
  if (consumed.ok) {
321
323
  state = loadProjectState(root, config);
322
324
  if (!state) {
@@ -350,8 +350,8 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
350
350
  return { ok: false, status: 'failed', action: 'vision_missing', stop_reason: `VISION.md not found at ${absVisionPath}` };
351
351
  }
352
352
 
353
- // Step 1: Check intake queue for pending work
354
- const queued = findNextDispatchableIntent(root);
353
+ // Step 1: Check intake queue for pending work (BUG-34: scope to current run)
354
+ const queued = findNextDispatchableIntent(root, { run_id: session.current_run_id });
355
355
  let targetIntentId = null;
356
356
  let visionObjective = null;
357
357
 
@@ -0,0 +1,31 @@
1
+ import { readRunEvents } from './run-events.js';
2
+
3
+ function normalizeCoordinatorWarning(event) {
4
+ return {
5
+ event_id: event.event_id,
6
+ timestamp: event.timestamp,
7
+ run_id: event.run_id || null,
8
+ workstream_id: event.payload?.workstream_id || null,
9
+ repo_id: event.payload?.repo_id || null,
10
+ reissued_turn_id: event.payload?.reissued_turn_id || null,
11
+ warning_code: event.payload?.warning_code || 'coordinator_acceptance_projection_incomplete',
12
+ warning_message: event.payload?.warning_message || null,
13
+ };
14
+ }
15
+
16
+ export function readCoordinatorWarnings(root, { runId = null } = {}) {
17
+ const events = readRunEvents(root, { type: 'coordinator_retry_projection_warning' });
18
+ const filtered = runId
19
+ ? events.filter((event) => event.run_id === runId)
20
+ : events;
21
+
22
+ if (filtered.length === 0) {
23
+ return { count: 0, reconciliation_required: false, warnings: [] };
24
+ }
25
+
26
+ return {
27
+ count: filtered.length,
28
+ reconciliation_required: true,
29
+ warnings: filtered.map(normalizeCoordinatorWarning),
30
+ };
31
+ }
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { buildPlanProgressSummary, loadAllPlans } from '../mission-plans.js';
9
9
  import { loadAllMissionArtifacts } from '../missions.js';
10
+ import { loadCoordinatorState } from '../coordinator-state.js';
11
+ import { readCoordinatorWarnings } from '../coordinator-warnings.js';
10
12
 
11
13
  /**
12
14
  * Build a dashboard-ready plan snapshot across all missions.
@@ -49,12 +51,19 @@ export function readPlanSnapshot(workspacePath, { limit, missionId } = {}) {
49
51
  latestSummary = buildPlanSummary(latest);
50
52
  }
51
53
 
54
+ // Surface coordinator projection warnings for dashboard consumers
55
+ const coordinatorState = loadCoordinatorState(workspacePath);
56
+ const coordinatorWarnings = readCoordinatorWarnings(workspacePath, {
57
+ runId: coordinatorState?.super_run_id || null,
58
+ });
59
+
52
60
  return {
53
61
  ok: true,
54
62
  status: 200,
55
63
  body: {
56
64
  latest: latestSummary,
57
65
  plans: plans.map(buildPlanSummary),
66
+ coordinator_warnings: coordinatorWarnings,
58
67
  },
59
68
  };
60
69
  }
@@ -332,25 +332,8 @@ function renderPrompt(role, roleId, turn, state, config, root) {
332
332
  lines.push('');
333
333
  }
334
334
 
335
- if (turn.intake_context) {
336
- lines.push('### Active Injected Intent respond to this as your primary charter');
337
- lines.push('');
338
- if (turn.intake_context.charter) {
339
- lines.push(turn.intake_context.charter);
340
- lines.push('');
341
- }
342
- if (Array.isArray(turn.intake_context.acceptance_contract) && turn.intake_context.acceptance_contract.length > 0) {
343
- lines.push('Acceptance contract:');
344
- turn.intake_context.acceptance_contract.forEach((requirement, index) => {
345
- lines.push(`${index + 1}. ${requirement}`);
346
- });
347
- lines.push('');
348
- }
349
- lines.push('You must explicitly address every acceptance item in your turn summary, artifacts, or verification evidence. Do not treat this as background context.');
350
- lines.push('');
351
- }
352
-
353
- // Retry context
335
+ // BUG-35: retry context must appear BEFORE the injected intent so the agent
336
+ // sees the blocker (gate failure) first and the repair guidance (intent) second.
354
337
  if (turn.attempt > 1 && turn.last_rejection) {
355
338
  lines.push('## Previous Attempt Failed');
356
339
  lines.push('');
@@ -369,6 +352,24 @@ function renderPrompt(role, roleId, turn, state, config, root) {
369
352
  lines.push('');
370
353
  }
371
354
 
355
+ if (turn.intake_context) {
356
+ lines.push('### Active Injected Intent — respond to this as your primary charter');
357
+ lines.push('');
358
+ if (turn.intake_context.charter) {
359
+ lines.push(turn.intake_context.charter);
360
+ lines.push('');
361
+ }
362
+ if (Array.isArray(turn.intake_context.acceptance_contract) && turn.intake_context.acceptance_contract.length > 0) {
363
+ lines.push('Acceptance contract:');
364
+ turn.intake_context.acceptance_contract.forEach((requirement, index) => {
365
+ lines.push(`${index + 1}. ${requirement}`);
366
+ });
367
+ lines.push('');
368
+ }
369
+ lines.push('You must explicitly address every acceptance item in your turn summary, artifacts, or verification evidence. Do not treat this as background context.');
370
+ lines.push('');
371
+ }
372
+
372
373
  if (turn.conflict_context) {
373
374
  lines.push('## File Conflict - Retry Required');
374
375
  lines.push('');
@@ -2154,6 +2154,42 @@ export function initializeGovernedRun(root, config, options = {}) {
2154
2154
  };
2155
2155
 
2156
2156
  writeState(root, updatedState);
2157
+
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 */ }
2192
+
2157
2193
  emitRunEvent(root, 'run_started', {
2158
2194
  run_id: runId,
2159
2195
  phase: updatedState.phase,
@@ -3107,6 +3143,70 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3107
3143
  }
3108
3144
  }
3109
3145
 
3146
+ // ── Gate semantic coverage validation (BUG-36) ────────────────────────────
3147
+ // When a turn proposes a phase transition, pre-evaluate the gate. If the gate
3148
+ // would fail AND the failing files are not in files_changed, reject the turn
3149
+ // early — the agent didn't do the work required for the transition.
3150
+ if (turnResult.phase_transition_request) {
3151
+ const preGateResult = evaluatePhaseExit({
3152
+ state,
3153
+ config,
3154
+ acceptedTurn: turnResult,
3155
+ root,
3156
+ });
3157
+
3158
+ if (preGateResult.action === 'gate_failed') {
3159
+ // Gate is failing. Check if any of the failing reasons reference files
3160
+ // that this turn didn't modify.
3161
+ const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
3162
+ 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(/^\.\//, '')))];
3179
+ const uncoveredFiles = uniqueFailingFiles.filter(f => !declaredFiles.has(f));
3180
+
3181
+ const gateSemanticMode = config.gate_semantic_coverage_mode || 'strict';
3182
+ if (uncoveredFiles.length > 0 && gateSemanticMode === 'strict') {
3183
+ const coverageError = `Gate "${exitGateId}" is failing on ${uncoveredFiles.join(', ')}. Your turn did not modify ${uncoveredFiles.length === 1 ? 'that file' : 'those files'}. Either edit the file(s) to satisfy the gate, or remove the phase transition request.`;
3184
+ transitionToFailedAcceptance(root, state, currentTurn, coverageError, {
3185
+ error_code: 'gate_semantic_coverage',
3186
+ stage: 'gate_semantic_coverage',
3187
+ extra: {
3188
+ gate_id: exitGateId,
3189
+ uncovered_files: uncoveredFiles,
3190
+ declared_files: [...declaredFiles],
3191
+ gate_reasons: preGateResult.reasons,
3192
+ },
3193
+ });
3194
+ return {
3195
+ ok: false,
3196
+ error: coverageError,
3197
+ validation: {
3198
+ ...validation,
3199
+ ok: false,
3200
+ stage: 'gate_semantic_coverage',
3201
+ error_class: 'gate_coverage_error',
3202
+ errors: uncoveredFiles.map(f => `Gate "${exitGateId}" is failing on "${f}". Your turn did not modify that file.`),
3203
+ warnings: [],
3204
+ },
3205
+ };
3206
+ }
3207
+ }
3208
+ }
3209
+
3110
3210
  const observedArtifact = buildObservedArtifact(observation, baseline);
3111
3211
  const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
3112
3212
  const artifactType = turnResult.artifact?.type || 'review';
package/src/lib/intake.js CHANGED
@@ -504,15 +504,35 @@ export function intakeStatus(root, intentId) {
504
504
  return { ok: true, summary, exitCode: 0 };
505
505
  }
506
506
 
507
- export function findNextDispatchableIntent(root) {
507
+ export function findNextDispatchableIntent(root, options = {}) {
508
508
  const dirs = intakeDirs(root);
509
509
  if (!existsSync(dirs.intents)) {
510
510
  return { ok: false, error: 'no intents directory' };
511
511
  }
512
512
 
513
- const intents = readJsonDir(dirs.intents)
513
+ const scopeRunId = options.run_id || null;
514
+
515
+ let intents = readJsonDir(dirs.intents)
514
516
  .filter((intent) => intent && DISPATCHABLE_STATUSES.has(intent.status));
515
517
 
518
+ // 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.
525
+ if (scopeRunId) {
526
+ intents = intents.filter((intent) => {
527
+ if (intent.approved_run_id === scopeRunId) return true;
528
+ if (intent.cross_run_durable === true) return true;
529
+ // Legacy intent with no run binding — stale, skip it
530
+ if (!intent.approved_run_id) return false;
531
+ // Intent bound to a different run — stale, skip it
532
+ return false;
533
+ });
534
+ }
535
+
516
536
  if (intents.length === 0) {
517
537
  return { ok: false, error: 'no dispatchable intents' };
518
538
  }
@@ -550,12 +570,23 @@ export function findNextDispatchableIntent(root) {
550
570
  * Return all approved-but-unconsumed intents sorted by priority (BUG-15).
551
571
  * Used by `status` to surface the pending intent queue.
552
572
  */
553
- export function findPendingApprovedIntents(root) {
573
+ export function findPendingApprovedIntents(root, options = {}) {
554
574
  const dirs = intakeDirs(root);
555
575
  if (!existsSync(dirs.intents)) return [];
556
576
 
577
+ const scopeRunId = options.run_id || null;
578
+
557
579
  return readJsonDir(dirs.intents)
558
- .filter((intent) => intent && intent.status === 'approved')
580
+ .filter((intent) => {
581
+ if (!intent || intent.status !== 'approved') return false;
582
+ // BUG-34: run_id scoping — same logic as findNextDispatchableIntent
583
+ if (scopeRunId) {
584
+ if (intent.approved_run_id === scopeRunId) return true;
585
+ if (intent.cross_run_durable === true) return true;
586
+ return false;
587
+ }
588
+ return true;
589
+ })
559
590
  .sort((a, b) => {
560
591
  const aPriority = PRIORITY_RANK[a.priority] ?? Number.MAX_SAFE_INTEGER;
561
592
  const bPriority = PRIORITY_RANK[b.priority] ?? Number.MAX_SAFE_INTEGER;
@@ -574,6 +605,59 @@ export function findPendingApprovedIntents(root) {
574
605
  }));
575
606
  }
576
607
 
608
+ /**
609
+ * BUG-34: Archive stale intents from prior runs.
610
+ * Called during run initialization to prevent cross-run intent leakage.
611
+ * Transitions approved/planned intents that don't belong to the new run into
612
+ * 'suppressed' status with an archival reason.
613
+ *
614
+ * @param {string} root
615
+ * @param {string} newRunId - the run_id of the newly initialized run
616
+ * @returns {{ archived: number }}
617
+ */
618
+ 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 };
659
+ }
660
+
577
661
  /**
578
662
  * Unified intent consumption entry point (BUG-16).
579
663
  * Both manual (resume/step --resume) and continuous/scheduler paths should call
@@ -584,7 +668,22 @@ export function findPendingApprovedIntents(root) {
584
668
  * @returns {{ ok: boolean, intentId?: string, intent?: object, error?: string }}
585
669
  */
586
670
  export function consumeNextApprovedIntent(root, options = {}) {
587
- const queued = findNextDispatchableIntent(root);
671
+ let runId = options.run_id || null;
672
+ if (!runId) {
673
+ try {
674
+ const context = loadProjectContext(root);
675
+ const state = context ? loadProjectState(root, context.config) : null;
676
+ runId = state?.run_id || null;
677
+ } catch {
678
+ runId = null;
679
+ }
680
+ }
681
+
682
+ if (runId && options.auto_archive_stale !== false) {
683
+ archiveStaleIntents(root, runId);
684
+ }
685
+
686
+ const queued = findNextDispatchableIntent(root, { run_id: runId });
588
687
  if (!queued.ok) {
589
688
  return { ok: false, error: queued.error || 'no dispatchable intents' };
590
689
  }
@@ -717,6 +816,23 @@ export function approveIntent(root, intentId, options = {}) {
717
816
  const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
718
817
  const now = nowISO();
719
818
 
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.
822
+ if (!intent.approved_run_id) {
823
+ const statePath = join(root, '.agentxchain', 'state.json');
824
+ if (existsSync(statePath)) {
825
+ try {
826
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
827
+ if (state.run_id) {
828
+ intent.approved_run_id = state.run_id;
829
+ }
830
+ } catch {
831
+ // non-fatal — stamp is best-effort during approval
832
+ }
833
+ }
834
+ }
835
+
720
836
  intent.status = 'approved';
721
837
  intent.approved_by = approver;
722
838
  intent.updated_at = now;
@@ -47,6 +47,11 @@ function describeEvent(eventType, entry) {
47
47
  const retryRepo = trimToNull(entry.payload?.repo_id);
48
48
  return `${prefix}${eventType}${wsId ? ` ${wsId}` : ''}${retryRepo ? ` (${retryRepo})` : ''}`;
49
49
  }
50
+ case 'coordinator_retry_projection_warning': {
51
+ const wsIdWarn = trimToNull(entry.payload?.workstream_id);
52
+ const warnRepo = trimToNull(entry.payload?.repo_id);
53
+ return `${prefix}${eventType}${wsIdWarn ? ` ${wsIdWarn}` : ''}${warnRepo ? ` (${warnRepo})` : ''} — reconciliation required`;
54
+ }
50
55
  case 'turn_checkpointed':
51
56
  return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
52
57
  case 'dispatch_progress':
@@ -23,6 +23,7 @@ export const VALID_RUN_EVENTS = [
23
23
  'turn_reissued',
24
24
  'turn_checkpointed',
25
25
  'coordinator_retry',
26
+ 'coordinator_retry_projection_warning',
26
27
  'run_blocked',
27
28
  'run_completed',
28
29
  'escalation_raised',