agentxchain 2.134.1 → 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.1",
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({
@@ -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) {
@@ -147,7 +147,7 @@ function renderGovernedStatus(context, opts) {
147
147
  const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
148
148
  const humanEscalation = findCurrentHumanEscalation(root, state);
149
149
  const preemptionMarker = readPreemptionMarker(root);
150
- const pendingIntents = findPendingApprovedIntents(root);
150
+ const pendingIntents = findPendingApprovedIntents(root, { run_id: stateRunId || null });
151
151
  const continuousSession = readContinuousSession(root);
152
152
  const gateActionAttempt = state?.pending_phase_transition
153
153
  ? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
@@ -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
 
@@ -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;