agentxchain 2.154.10 → 2.155.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.
@@ -12,7 +12,13 @@
12
12
  import { existsSync, readFileSync, mkdirSync, unlinkSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { randomUUID } from 'node:crypto';
15
- import { resolveVisionPath, deriveVisionCandidates } from './vision-reader.js';
15
+ import {
16
+ resolveVisionPath,
17
+ deriveVisionCandidates,
18
+ captureVisionHeadingsSnapshot,
19
+ computeVisionContentSha,
20
+ buildSourceManifest,
21
+ } from './vision-reader.js';
16
22
  import {
17
23
  recordEvent,
18
24
  triageIntent,
@@ -21,6 +27,7 @@ import {
21
27
  prepareIntentForDispatch,
22
28
  consumeNextApprovedIntent,
23
29
  resolveIntent,
30
+ buildVisionIdleExpansionSignal,
24
31
  } from './intake.js';
25
32
  import { loadProjectState } from './config.js';
26
33
  import { safeWriteJson } from './safe-write.js';
@@ -74,7 +81,7 @@ export function removeContinuousSession(root) {
74
81
  }
75
82
  }
76
83
 
77
- function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd, currentRunId = null) {
84
+ function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd, currentRunId = null, snapshotOpts = {}) {
78
85
  return {
79
86
  session_id: `cont-${randomUUID().slice(0, 8)}`,
80
87
  started_at: new Date().toISOString(),
@@ -90,6 +97,12 @@ function createSession(visionPath, maxRuns, maxIdleCycles, perSessionMaxUsd, cur
90
97
  cumulative_spent_usd: 0,
91
98
  budget_exhausted: false,
92
99
  startup_reconciled_run_id: null,
100
+ // BUG-60 Slice 3: vision snapshot for idle-expansion traceability
101
+ vision_headings_snapshot: snapshotOpts.visionHeadingsSnapshot || null,
102
+ vision_sha_at_snapshot: snapshotOpts.visionShaAtSnapshot || null,
103
+ expansion_iteration: snapshotOpts.expansionIteration ?? 0,
104
+ // Track which vision SHA values have already emitted a stale warning
105
+ _vision_stale_warned_shas: [],
93
106
  };
94
107
  }
95
108
 
@@ -106,6 +119,12 @@ function describeContinuousTerminalStep(step, contOpts) {
106
119
  if (step.status === 'idle_exit') {
107
120
  return `All vision goals appear addressed (${contOpts.maxIdleCycles} consecutive idle cycles). Stopping.`;
108
121
  }
122
+ if (step.status === 'vision_exhausted') {
123
+ return 'PM idle-expansion declared vision exhausted. Stopping.';
124
+ }
125
+ if (step.status === 'vision_expansion_exhausted') {
126
+ return `Idle-expansion cap reached (${contOpts.idleExpansion?.maxExpansions ?? '?'} expansions without productive run). Stopping.`;
127
+ }
109
128
  if (step.status === 'failed') {
110
129
  const reason = step.stop_reason || step.action || 'unknown';
111
130
  return `Continuous loop failed: ${reason}. Check "agentxchain status" for details.`;
@@ -131,6 +150,73 @@ function isBlockedContinuousExecution(execution) {
131
150
  || stopReason === 'reject_exhausted';
132
151
  }
133
152
 
153
+ function getAcceptedIdleExpansionEntries(execution) {
154
+ const entries = Array.isArray(execution?.result?.accepted_turn_results)
155
+ ? execution.result.accepted_turn_results
156
+ : [];
157
+ return entries.filter((entry) => entry?.turn_result?.idle_expansion_result);
158
+ }
159
+
160
+ function ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log = console.log) {
161
+ const entries = getAcceptedIdleExpansionEntries(execution);
162
+ if (entries.length === 0) {
163
+ return null;
164
+ }
165
+
166
+ let lastIngested = null;
167
+ for (const entry of entries) {
168
+ const ingested = ingestAcceptedIdleExpansion(context, session, {
169
+ turnResult: entry.turn_result,
170
+ historyEntry: entry.accepted || null,
171
+ state: entry.state || execution?.result?.state || null,
172
+ });
173
+
174
+ if (!ingested.ingested) {
175
+ session.status = 'failed';
176
+ writeContinuousSession(context.root, session);
177
+ emitRunEvent(context.root, 'idle_expansion_ingestion_failed', {
178
+ run_id: session.current_run_id || null,
179
+ phase: null,
180
+ status: 'failed',
181
+ payload: {
182
+ session_id: session.session_id,
183
+ expansion_iteration: session.expansion_iteration,
184
+ turn_id: entry.turn_id || null,
185
+ error: ingested.error || 'unknown idle-expansion ingestion failure',
186
+ },
187
+ });
188
+ log(`Idle-expansion ingestion failed: ${ingested.error || 'unknown error'}`);
189
+ return {
190
+ ok: false,
191
+ status: 'failed',
192
+ action: 'idle_expansion_ingestion_failed',
193
+ stop_reason: ingested.error || 'idle_expansion_ingestion_failed',
194
+ run_id: session.current_run_id || null,
195
+ };
196
+ }
197
+
198
+ lastIngested = ingested;
199
+ }
200
+
201
+ if (lastIngested?.kind === 'vision_exhausted') {
202
+ return {
203
+ ok: true,
204
+ status: 'vision_exhausted',
205
+ action: 'idle_expansion_ingested',
206
+ stop_reason: 'vision_exhausted',
207
+ run_id: session.current_run_id || null,
208
+ };
209
+ }
210
+
211
+ return {
212
+ ok: true,
213
+ status: 'running',
214
+ action: 'idle_expansion_ingested',
215
+ intent_id: lastIngested?.intentId || null,
216
+ run_id: session.current_run_id || null,
217
+ };
218
+ }
219
+
134
220
  function getBlockedRecoveryAction(state) {
135
221
  return state?.blocked_reason?.recovery?.recovery_action || null;
136
222
  }
@@ -605,6 +691,325 @@ export function seedFromVision(root, visionPath, options = {}) {
605
691
  };
606
692
  }
607
693
 
694
+ // ---------------------------------------------------------------------------
695
+ // BUG-60: Idle-expansion dispatch + ingestion for perpetual continuous mode
696
+ // ---------------------------------------------------------------------------
697
+
698
+ /**
699
+ * Dispatch a PM idle-expansion turn via the intake pipeline.
700
+ *
701
+ * Called when on_idle === "perpetual" and idle_cycles >= maxIdleCycles.
702
+ * Records a `vision_idle_expansion` intake event with deterministic signal,
703
+ * triages and auto-approves the synthesized PM intent, then returns a
704
+ * non-terminal step so the main loop re-enters on the next cycle.
705
+ *
706
+ * Returns null if the expansion cannot be dispatched (cap reached, source
707
+ * manifest fails, etc.) — caller falls through to idle_exit.
708
+ *
709
+ * @returns {{ ok, status, action, ... } | null}
710
+ */
711
+ async function dispatchIdleExpansion(context, session, contOpts, absVisionPath, log = console.log) {
712
+ const { root } = context;
713
+ const expansion = contOpts.idleExpansion;
714
+ if (!expansion) return null;
715
+
716
+ // Check expansion iteration cap
717
+ const currentIteration = (session.expansion_iteration || 0) + 1;
718
+ if (currentIteration > expansion.maxExpansions) {
719
+ session.status = 'vision_expansion_exhausted';
720
+ writeContinuousSession(root, session);
721
+ log(`Idle-expansion cap reached (${expansion.maxExpansions} expansions). Stopping.`);
722
+ emitRunEvent(root, 'idle_expansion_cap_reached', {
723
+ run_id: session.current_run_id || null,
724
+ phase: null,
725
+ status: 'completed',
726
+ payload: {
727
+ session_id: session.session_id,
728
+ expansion_iteration: currentIteration - 1,
729
+ max_expansions: expansion.maxExpansions,
730
+ },
731
+ });
732
+ return {
733
+ ok: true,
734
+ status: 'vision_expansion_exhausted',
735
+ action: 'idle_expansion_cap_reached',
736
+ stop_reason: 'vision_expansion_exhausted',
737
+ };
738
+ }
739
+
740
+ // Build bounded source manifest
741
+ const manifest = buildSourceManifest(root, expansion.sources);
742
+ if (!manifest.ok) {
743
+ log(`Idle-expansion source manifest failed: ${manifest.error}`);
744
+ return null; // Fall through to idle_exit
745
+ }
746
+
747
+ // Build the PM charter for idle-expansion
748
+ const sourceList = manifest.entries
749
+ .filter(e => e.present)
750
+ .map(e => ` - ${e.path} (${e.headings.length} headings, ${e.byte_count} bytes${e.warning ? `, warning: ${e.warning}` : ''})`)
751
+ .join('\n');
752
+ const visionHeadings = (session.vision_headings_snapshot || []).map(h => ` - ${h}`).join('\n');
753
+
754
+ const charter = [
755
+ `[idle-expansion #${currentIteration}] Inspect VISION.md, ROADMAP.md, SYSTEM_SPEC.md, and current project state.`,
756
+ `Derive the next concrete increment as a new intake intent with charter + acceptance_contract + priority.`,
757
+ `If ALL vision goals are genuinely exhausted, declare vision_exhausted with per-heading classification.`,
758
+ ``,
759
+ `CONSTRAINTS:`,
760
+ `- Do NOT modify .planning/VISION.md (human-owned, read-only).`,
761
+ `- ROADMAP.md and SYSTEM_SPEC.md may be updated as supporting evidence.`,
762
+ `- Every proposed intent MUST cite at least one VISION.md heading from the snapshot below.`,
763
+ `- Output MUST be a structured idle_expansion_result (new_intake_intent or vision_exhausted).`,
764
+ ``,
765
+ `VISION headings snapshot:`,
766
+ visionHeadings || ' (none captured)',
767
+ ``,
768
+ `Source manifest:`,
769
+ sourceList || ' (no sources available)',
770
+ ].join('\n');
771
+
772
+ // Use a placeholder accepted_turn_id for the signal — it will be the turn assigned by intake
773
+ // We use session_id + iteration as a pre-dispatch key; the real signal with accepted_turn_id
774
+ // is built after the PM turn completes and is accepted via ingestAcceptedIdleExpansion.
775
+ const preDispatchSignal = buildVisionIdleExpansionSignal(
776
+ session.session_id,
777
+ currentIteration,
778
+ `pre_dispatch_${session.session_id}_${currentIteration}`,
779
+ );
780
+
781
+ const idleExpansionContext = {
782
+ expansion_iteration: currentIteration,
783
+ vision_headings_snapshot: session.vision_headings_snapshot || [],
784
+ };
785
+
786
+ // Record through intake pipeline
787
+ const eventResult = recordEvent(root, {
788
+ source: 'vision_idle_expansion',
789
+ category: 'idle_expansion',
790
+ signal: preDispatchSignal,
791
+ idle_expansion_context: idleExpansionContext,
792
+ evidence: [
793
+ { type: 'text', value: `Idle-expansion iteration ${currentIteration}/${expansion.maxExpansions} — PM deriving next increment from vision/roadmap/spec.` },
794
+ ],
795
+ });
796
+
797
+ if (!eventResult.ok) {
798
+ if (eventResult.deduplicated) {
799
+ log(`Idle-expansion iteration ${currentIteration} already recorded (deduplicated). Skipping.`);
800
+ return null;
801
+ }
802
+ log(`Idle-expansion intake record failed: ${eventResult.error}`);
803
+ return null;
804
+ }
805
+
806
+ const intentId = eventResult.intent.intent_id;
807
+
808
+ // Triage with idle-expansion charter
809
+ const triageResult = triageIntent(root, intentId, {
810
+ priority: 'p1',
811
+ template: 'generic',
812
+ charter,
813
+ acceptance_contract: [
814
+ 'Produces a structured idle_expansion_result with kind "new_intake_intent" or "vision_exhausted".',
815
+ 'If new_intake_intent: contains charter, acceptance_contract (array), priority, and vision_traceability citing snapshot headings.',
816
+ 'If vision_exhausted: contains per-heading classification covering all snapshot headings.',
817
+ ],
818
+ });
819
+
820
+ if (!triageResult.ok) {
821
+ log(`Idle-expansion triage failed: ${triageResult.error}`);
822
+ return null;
823
+ }
824
+
825
+ // Auto-approve (idle-expansion intents are always auto-approved in perpetual mode)
826
+ const approveResult = approveIntent(root, intentId, {
827
+ approver: 'continuous_loop_idle_expansion',
828
+ reason: `idle-expansion iteration ${currentIteration}`,
829
+ });
830
+
831
+ if (!approveResult.ok) {
832
+ log(`Idle-expansion approve failed: ${approveResult.error}`);
833
+ return null;
834
+ }
835
+
836
+ // Update session state
837
+ session.expansion_iteration = currentIteration;
838
+ session.idle_cycles = 0; // Reset idle cycles after dispatching expansion
839
+ writeContinuousSession(root, session);
840
+
841
+ emitRunEvent(root, 'idle_expansion_dispatched', {
842
+ run_id: session.current_run_id || null,
843
+ phase: null,
844
+ status: 'running',
845
+ payload: {
846
+ session_id: session.session_id,
847
+ expansion_iteration: currentIteration,
848
+ max_expansions: expansion.maxExpansions,
849
+ intent_id: intentId,
850
+ role: expansion.role,
851
+ source_count: manifest.entries.length,
852
+ sources_present: manifest.entries.filter(e => e.present).length,
853
+ },
854
+ });
855
+
856
+ log(`Idle-expansion ${currentIteration}/${expansion.maxExpansions} dispatched — PM intent ${intentId} queued.`);
857
+ return {
858
+ ok: true,
859
+ status: 'running',
860
+ action: 'idle_expansion_dispatched',
861
+ intent_id: intentId,
862
+ expansion_iteration: currentIteration,
863
+ };
864
+ }
865
+
866
+ /**
867
+ * Ingest the accepted result of a PM idle-expansion turn.
868
+ *
869
+ * Called after a PM turn with `intake_context.source === 'vision_idle_expansion'`
870
+ * has been accepted. Reads the `idle_expansion_result` from the accepted turn
871
+ * result and either:
872
+ * (a) records a new intake intent from `new_intake_intent` → returns { ingested: true, kind: 'new_intake_intent', intentId }
873
+ * (b) sets session status to `vision_exhausted` → returns { ingested: true, kind: 'vision_exhausted' }
874
+ * (c) returns { ingested: false, error } on malformed output
875
+ *
876
+ * @param {object} context - { root, config }
877
+ * @param {object} session - mutable session
878
+ * @param {{ turnResult: object, historyEntry: object, state: object }} accepted
879
+ * @returns {{ ingested: boolean, kind?: string, intentId?: string, error?: string }}
880
+ */
881
+ export function ingestAcceptedIdleExpansion(context, session, accepted) {
882
+ const { root } = context;
883
+ const { turnResult } = accepted;
884
+ const result = turnResult?.idle_expansion_result;
885
+
886
+ if (!result || typeof result !== 'object') {
887
+ emitRunEvent(root, 'idle_expansion_malformed', {
888
+ run_id: session.current_run_id || null,
889
+ phase: null,
890
+ status: 'running',
891
+ payload: {
892
+ session_id: session.session_id,
893
+ expansion_iteration: session.expansion_iteration,
894
+ error: 'Missing or invalid idle_expansion_result in accepted turn result.',
895
+ },
896
+ });
897
+ return { ingested: false, error: 'Missing or invalid idle_expansion_result in accepted turn result.' };
898
+ }
899
+
900
+ if (result.kind === 'new_intake_intent') {
901
+ const intent = result.new_intake_intent;
902
+ if (!intent || !intent.charter || !Array.isArray(intent.acceptance_contract) || intent.acceptance_contract.length === 0) {
903
+ emitRunEvent(root, 'idle_expansion_malformed', {
904
+ run_id: session.current_run_id || null,
905
+ phase: null,
906
+ status: 'running',
907
+ payload: {
908
+ session_id: session.session_id,
909
+ expansion_iteration: session.expansion_iteration,
910
+ error: 'new_intake_intent missing required fields (charter, acceptance_contract).',
911
+ },
912
+ });
913
+ return { ingested: false, error: 'new_intake_intent missing required fields (charter, acceptance_contract).' };
914
+ }
915
+
916
+ // Record the PM-derived intent through the normal intake pipeline
917
+ const eventResult = recordEvent(root, {
918
+ source: 'vision_scan',
919
+ category: 'pm_idle_expansion_derived',
920
+ signal: {
921
+ description: intent.charter,
922
+ derived: true,
923
+ expansion_iteration: session.expansion_iteration,
924
+ vision_traceability: result.vision_traceability || null,
925
+ },
926
+ evidence: [
927
+ { type: 'text', value: `PM idle-expansion #${session.expansion_iteration} derived: ${intent.charter}` },
928
+ ],
929
+ });
930
+
931
+ if (!eventResult.ok) {
932
+ if (eventResult.deduplicated) {
933
+ return { ingested: true, kind: 'new_intake_intent', intentId: null, deduplicated: true };
934
+ }
935
+ return { ingested: false, error: `Intake record for PM-derived intent failed: ${eventResult.error}` };
936
+ }
937
+
938
+ const newIntentId = eventResult.intent.intent_id;
939
+
940
+ // Triage with PM-derived charter and acceptance contract
941
+ const triageResult = triageIntent(root, newIntentId, {
942
+ priority: intent.priority || 'p2',
943
+ template: intent.template || 'generic',
944
+ charter: `[pm-derived] ${intent.charter}`,
945
+ acceptance_contract: intent.acceptance_contract,
946
+ });
947
+
948
+ if (!triageResult.ok) {
949
+ return { ingested: false, error: `Triage for PM-derived intent failed: ${triageResult.error}` };
950
+ }
951
+
952
+ // Auto-approve the PM-derived intent
953
+ const approveResult = approveIntent(root, newIntentId, {
954
+ approver: 'idle_expansion_ingestion',
955
+ reason: `PM idle-expansion #${session.expansion_iteration} derived intent`,
956
+ });
957
+
958
+ if (!approveResult.ok) {
959
+ return { ingested: false, error: `Approve for PM-derived intent failed: ${approveResult.error}` };
960
+ }
961
+
962
+ emitRunEvent(root, 'idle_expansion_ingested', {
963
+ run_id: session.current_run_id || null,
964
+ phase: null,
965
+ status: 'running',
966
+ payload: {
967
+ session_id: session.session_id,
968
+ expansion_iteration: session.expansion_iteration,
969
+ kind: 'new_intake_intent',
970
+ intent_id: newIntentId,
971
+ charter: intent.charter,
972
+ priority: intent.priority || 'p2',
973
+ },
974
+ });
975
+
976
+ return { ingested: true, kind: 'new_intake_intent', intentId: newIntentId };
977
+ }
978
+
979
+ if (result.kind === 'vision_exhausted') {
980
+ session.status = 'vision_exhausted';
981
+ writeContinuousSession(root, session);
982
+
983
+ emitRunEvent(root, 'idle_expansion_ingested', {
984
+ run_id: session.current_run_id || null,
985
+ phase: null,
986
+ status: 'completed',
987
+ payload: {
988
+ session_id: session.session_id,
989
+ expansion_iteration: session.expansion_iteration,
990
+ kind: 'vision_exhausted',
991
+ reason_excerpt: result.vision_exhausted?.classification?.[0]?.reason || null,
992
+ classification: result.vision_exhausted?.classification || null,
993
+ },
994
+ });
995
+
996
+ return { ingested: true, kind: 'vision_exhausted' };
997
+ }
998
+
999
+ // Unknown kind
1000
+ emitRunEvent(root, 'idle_expansion_malformed', {
1001
+ run_id: session.current_run_id || null,
1002
+ phase: null,
1003
+ status: 'running',
1004
+ payload: {
1005
+ session_id: session.session_id,
1006
+ expansion_iteration: session.expansion_iteration,
1007
+ error: `Unknown idle_expansion_result.kind: "${result.kind}". Expected "new_intake_intent" or "vision_exhausted".`,
1008
+ },
1009
+ });
1010
+ return { ingested: false, error: `Unknown idle_expansion_result.kind: "${result.kind}".` };
1011
+ }
1012
+
608
1013
  // ---------------------------------------------------------------------------
609
1014
  // Resolve continuous options from CLI flags + config
610
1015
  // ---------------------------------------------------------------------------
@@ -631,6 +1036,25 @@ export function resolveContinuousOptions(opts, config) {
631
1036
  ?? configuredReconcile
632
1037
  ?? (fullAuto ? 'auto_safe_only' : 'manual');
633
1038
 
1039
+ // Resolve on_idle policy — CLI flag overrides config
1040
+ const validOnIdle = new Set(['exit', 'perpetual', 'human_review']);
1041
+ const configOnIdle = typeof configCont.on_idle === 'string' && validOnIdle.has(configCont.on_idle)
1042
+ ? configCont.on_idle : null;
1043
+ const cliOnIdle = typeof opts.onIdle === 'string' && validOnIdle.has(opts.onIdle)
1044
+ ? opts.onIdle : null;
1045
+ const onIdle = cliOnIdle ?? configOnIdle ?? 'exit';
1046
+
1047
+ // Resolve idle_expansion block when perpetual mode is active
1048
+ const configIdleExpansion = configCont.idle_expansion || {};
1049
+ const idleExpansion = onIdle === 'perpetual' ? {
1050
+ sources: Array.isArray(configIdleExpansion.sources) && configIdleExpansion.sources.length > 0
1051
+ ? configIdleExpansion.sources
1052
+ : ['.planning/VISION.md', '.planning/ROADMAP.md', '.planning/SYSTEM_SPEC.md'],
1053
+ maxExpansions: configIdleExpansion.max_expansions ?? 5,
1054
+ role: configIdleExpansion.role ?? 'pm',
1055
+ malformedRetryLimit: configIdleExpansion.malformed_retry_limit ?? 1,
1056
+ } : null;
1057
+
634
1058
  return {
635
1059
  enabled: opts.continuous ?? configCont.enabled ?? false,
636
1060
  continueFrom: opts.continueFrom ?? null,
@@ -652,6 +1076,8 @@ export function resolveContinuousOptions(opts, config) {
652
1076
  ?? 5,
653
1077
  },
654
1078
  reconcileOperatorCommits,
1079
+ onIdle,
1080
+ idleExpansion,
655
1081
  };
656
1082
  }
657
1083
 
@@ -684,20 +1110,42 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
684
1110
  const { root } = context;
685
1111
  const absVisionPath = resolveVisionPath(root, contOpts.visionPath);
686
1112
 
687
- // Terminal checks
688
- if (session.runs_completed >= contOpts.maxRuns) {
689
- session.status = 'completed';
690
- writeContinuousSession(root, session);
691
- return { ok: true, status: 'completed', action: 'max_runs_reached', stop_reason: 'max_runs' };
1113
+ // BUG-60 Slice 3: detect VISION.md content drift since session snapshot
1114
+ if (session.vision_sha_at_snapshot && existsSync(absVisionPath)) {
1115
+ try {
1116
+ const currentContent = readFileSync(absVisionPath, 'utf8');
1117
+ const currentSha = computeVisionContentSha(currentContent);
1118
+ const warnedShas = session._vision_stale_warned_shas || [];
1119
+ if (currentSha !== session.vision_sha_at_snapshot && !warnedShas.includes(currentSha)) {
1120
+ emitRunEvent(root, 'vision_snapshot_stale', {
1121
+ run_id: session.current_run_id || null,
1122
+ phase: null,
1123
+ status: session.status || 'running',
1124
+ payload: {
1125
+ session_id: session.session_id,
1126
+ snapshot_sha: session.vision_sha_at_snapshot,
1127
+ current_sha: currentSha,
1128
+ vision_path: contOpts.visionPath,
1129
+ },
1130
+ });
1131
+ session._vision_stale_warned_shas = [...warnedShas, currentSha];
1132
+ writeContinuousSession(root, session);
1133
+ log(`Warning: VISION.md has changed since session started (snapshot: ${session.vision_sha_at_snapshot.slice(0, 8)}, current: ${currentSha.slice(0, 8)}). Active session keeps its heading snapshot.`);
1134
+ }
1135
+ } catch {
1136
+ // VISION.md read failed — will be caught by the vision_missing guard below
1137
+ }
692
1138
  }
693
1139
 
694
- if (session.idle_cycles >= contOpts.maxIdleCycles) {
1140
+ // Terminal checks — order matters: max_runs, then budget, then idle policy.
1141
+ // Budget MUST fire before idle-expansion dispatch (BUG-60 Plan §5).
1142
+ if (session.runs_completed >= contOpts.maxRuns) {
695
1143
  session.status = 'completed';
696
1144
  writeContinuousSession(root, session);
697
- return { ok: true, status: 'idle_exit', action: 'max_idle_reached', stop_reason: 'idle_exit' };
1145
+ return { ok: true, status: 'completed', action: 'max_runs_reached', stop_reason: 'max_runs' };
698
1146
  }
699
1147
 
700
- // Session budget check (cumulative spend across all runs)
1148
+ // Session budget check (cumulative spend across all runs) — before idle policy
701
1149
  const sessionBudget = session.per_session_max_usd ?? contOpts.perSessionMaxUsd ?? null;
702
1150
  if (sessionBudget != null && (session.cumulative_spent_usd || 0) >= sessionBudget) {
703
1151
  session.status = 'completed';
@@ -707,6 +1155,44 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
707
1155
  return { ok: true, status: 'completed', action: 'session_budget_exhausted', stop_reason: 'session_budget' };
708
1156
  }
709
1157
 
1158
+ // Idle-cycle check: on_idle policy determines behavior
1159
+ if (session.idle_cycles >= contOpts.maxIdleCycles) {
1160
+ if (contOpts.onIdle === 'perpetual' && contOpts.idleExpansion) {
1161
+ // BUG-60: perpetual mode — dispatch PM idle-expansion instead of exiting
1162
+ const expansionResult = await dispatchIdleExpansion(context, session, contOpts, absVisionPath, log);
1163
+ if (expansionResult) return expansionResult;
1164
+ // If dispatchIdleExpansion returned null, fall through to idle_exit
1165
+ }
1166
+ if (contOpts.onIdle === 'human_review') {
1167
+ session.status = 'paused';
1168
+ writeContinuousSession(root, session);
1169
+ emitRunEvent(root, 'idle_human_review_required', {
1170
+ run_id: session.current_run_id || null,
1171
+ phase: null,
1172
+ status: 'blocked',
1173
+ payload: {
1174
+ session_id: session.session_id,
1175
+ idle_cycles: session.idle_cycles,
1176
+ max_idle_cycles: contOpts.maxIdleCycles,
1177
+ vision_path: contOpts.visionPath,
1178
+ },
1179
+ });
1180
+ log(`Idle threshold reached (${session.idle_cycles}/${contOpts.maxIdleCycles}) — pausing for human review.`);
1181
+ return {
1182
+ ok: true,
1183
+ status: 'blocked',
1184
+ action: 'idle_human_review_required',
1185
+ stop_reason: 'human_review',
1186
+ run_id: session.current_run_id || null,
1187
+ recovery_action: 'Review .agentxchain/continuous-session.json and either inject/approve new work or rerun with --on-idle exit/perpetual.',
1188
+ blocked_category: 'idle_human_review',
1189
+ };
1190
+ }
1191
+ session.status = 'completed';
1192
+ writeContinuousSession(root, session);
1193
+ return { ok: true, status: 'idle_exit', action: 'max_idle_reached', stop_reason: 'idle_exit' };
1194
+ }
1195
+
710
1196
  reconcileContinuousStartupState(context, session, contOpts, log);
711
1197
 
712
1198
  const reconcileBlock = maybeAutoReconcileOperatorCommits(context, session, contOpts, log);
@@ -782,6 +1268,12 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
782
1268
  return { ok: false, status: 'failed', action: 'run_failed', stop_reason: resumeStopReason || `exit_code_${execution.exitCode}`, run_id: session.current_run_id };
783
1269
  }
784
1270
 
1271
+ const idleExpansionStep = ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log);
1272
+ if (idleExpansionStep) {
1273
+ writeContinuousSession(root, session);
1274
+ return idleExpansionStep;
1275
+ }
1276
+
785
1277
  session.runs_completed += 1;
786
1278
  session.current_run_id = execution.result?.state?.run_id || session.current_run_id;
787
1279
  log(`Resumed run completed (${session.runs_completed}/${contOpts.maxRuns}): ${resumeStopReason || 'completed'}`);
@@ -844,6 +1336,12 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
844
1336
  return { ok: false, status: 'failed', action: 'run_failed', stop_reason: resumeStopReason || `exit_code_${execution.exitCode}`, run_id: session.current_run_id };
845
1337
  }
846
1338
 
1339
+ const idleExpansionStep = ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log);
1340
+ if (idleExpansionStep) {
1341
+ writeContinuousSession(root, session);
1342
+ return idleExpansionStep;
1343
+ }
1344
+
847
1345
  session.runs_completed += 1;
848
1346
  session.current_run_id = execution.result?.state?.run_id || session.current_run_id;
849
1347
  log(`Active run completed (${session.runs_completed}/${contOpts.maxRuns}): ${resumeStopReason || 'completed'}`);
@@ -1044,9 +1542,6 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1044
1542
  };
1045
1543
  }
1046
1544
 
1047
- session.runs_completed += 1;
1048
- log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
1049
-
1050
1545
  // Resolve the consumed intent
1051
1546
  const resolved = resolveIntent(root, targetIntentId);
1052
1547
  if (!resolved.ok) {
@@ -1056,6 +1551,18 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1056
1551
  return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
1057
1552
  }
1058
1553
 
1554
+ const idleExpansionStep = ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log);
1555
+ if (idleExpansionStep) {
1556
+ writeContinuousSession(root, session);
1557
+ return {
1558
+ ...idleExpansionStep,
1559
+ intent_id: idleExpansionStep.intent_id || targetIntentId,
1560
+ };
1561
+ }
1562
+
1563
+ session.runs_completed += 1;
1564
+ log(`Run ${session.runs_completed}/${contOpts.maxRuns} completed: ${stopReason || 'unknown'}`);
1565
+
1059
1566
  writeContinuousSession(root, session);
1060
1567
  return {
1061
1568
  ok: true,
@@ -1092,12 +1599,25 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
1092
1599
 
1093
1600
  const startupState = loadProjectState(root, context.config);
1094
1601
  const initialRunId = contOpts.continueFrom || startupState?.run_id || null;
1602
+
1603
+ // BUG-60 Slice 3: capture vision heading snapshot + content hash at session start
1604
+ let visionHeadingsSnapshot = null;
1605
+ let visionShaAtSnapshot = null;
1606
+ try {
1607
+ const visionContent = readFileSync(absVisionPath, 'utf8');
1608
+ visionHeadingsSnapshot = captureVisionHeadingsSnapshot(visionContent);
1609
+ visionShaAtSnapshot = computeVisionContentSha(visionContent);
1610
+ } catch {
1611
+ // VISION.md unreadable — will fail at first advanceContinuousRunOnce anyway
1612
+ }
1613
+
1095
1614
  const session = createSession(
1096
1615
  contOpts.visionPath,
1097
1616
  contOpts.maxRuns,
1098
1617
  contOpts.maxIdleCycles,
1099
1618
  contOpts.perSessionMaxUsd,
1100
1619
  initialRunId,
1620
+ { visionHeadingsSnapshot, visionShaAtSnapshot },
1101
1621
  );
1102
1622
  writeContinuousSession(root, session);
1103
1623
 
@@ -1114,7 +1634,7 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
1114
1634
  const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
1115
1635
 
1116
1636
  // Terminal states
1117
- if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped') {
1637
+ if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped' || step.status === 'vision_exhausted' || step.status === 'vision_expansion_exhausted') {
1118
1638
  const terminalMessage = describeContinuousTerminalStep(step, contOpts);
1119
1639
  if (terminalMessage) {
1120
1640
  log(terminalMessage);