agentxchain 2.154.11 → 2.155.1

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.
@@ -20,6 +20,7 @@ import { join, dirname } from 'path';
20
20
  import { randomBytes, createHash } from 'crypto';
21
21
  import { safeWriteJson } from './safe-write.js';
22
22
  import { validateStagedTurnResult } from './turn-result-validator.js';
23
+ import { summarizeIdleExpansionResult } from './idle-expansion-result-validator.js';
23
24
  import { evaluatePhaseExit, evaluateRunCompletion, getNextPhase } from './gate-evaluator.js';
24
25
  import { evaluateApprovalPolicy } from './approval-policy.js';
25
26
  import { evaluatePolicies } from './policy-evaluator.js';
@@ -3565,6 +3566,9 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
3565
3566
  if (options.intakeContext) {
3566
3567
  newTurn.intake_context = options.intakeContext;
3567
3568
  }
3569
+ if (options.idleExpansionContext) {
3570
+ newTurn.idle_expansion_context = options.idleExpansionContext;
3571
+ }
3568
3572
 
3569
3573
  // Attach delegation context if this turn fulfills a pending delegation
3570
3574
  const delegationQueue = state.delegation_queue || [];
@@ -4870,6 +4874,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4870
4874
  }
4871
4875
 
4872
4876
  const acceptedSequence = (state.turn_sequence || 0) + 1;
4877
+ const idleExpansionResultSummary = summarizeIdleExpansionResult(turnResult);
4873
4878
  const historyEntry = {
4874
4879
  turn_id: turnResult.turn_id,
4875
4880
  run_id: turnResult.run_id,
@@ -4891,6 +4896,9 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4891
4896
  proposed_next_role: turnResult.proposed_next_role,
4892
4897
  phase_transition_request: turnResult.phase_transition_request,
4893
4898
  run_completion_request: Boolean(turnResult.run_completion_request),
4899
+ ...(idleExpansionResultSummary
4900
+ ? { idle_expansion_result_summary: idleExpansionResultSummary }
4901
+ : {}),
4894
4902
  assigned_sequence: Number.isInteger(currentTurn.assigned_sequence) ? currentTurn.assigned_sequence : acceptedSequence,
4895
4903
  accepted_sequence: acceptedSequence,
4896
4904
  concurrent_with: Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
@@ -0,0 +1,251 @@
1
+ import { VALID_GOVERNED_TEMPLATE_IDS } from './governed-templates.js';
2
+
3
+ const VALID_KINDS = ['new_intake_intent', 'vision_exhausted'];
4
+ const VALID_TRACE_KINDS = ['advances', 'supports', 'unblocks'];
5
+ const VALID_EXHAUSTION_STATUSES = ['complete', 'deferred', 'out_of_scope'];
6
+ const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
7
+
8
+ export function validateIdleExpansionTurnResult(turnResult, context = {}) {
9
+ const required = context.required === true;
10
+ const errors = [];
11
+ const warnings = [];
12
+ const result = turnResult?.idle_expansion_result;
13
+
14
+ if (result === undefined || result === null) {
15
+ if (required) {
16
+ errors.push('idle_expansion_result is required for vision_idle_expansion turns.');
17
+ }
18
+ return { ok: errors.length === 0, errors, warnings };
19
+ }
20
+
21
+ if (typeof result !== 'object' || Array.isArray(result)) {
22
+ return {
23
+ ok: false,
24
+ errors: ['idle_expansion_result must be an object.'],
25
+ warnings,
26
+ };
27
+ }
28
+
29
+ if (!VALID_KINDS.includes(result.kind)) {
30
+ errors.push(`idle_expansion_result.kind must be one of: ${VALID_KINDS.join(', ')}.`);
31
+ }
32
+
33
+ if (!Number.isInteger(result.expansion_iteration) || result.expansion_iteration < 1) {
34
+ errors.push('idle_expansion_result.expansion_iteration must be a positive integer.');
35
+ } else if (
36
+ Number.isInteger(context.expansionIteration)
37
+ && context.expansionIteration >= 1
38
+ && result.expansion_iteration !== context.expansionIteration
39
+ ) {
40
+ errors.push(
41
+ `idle_expansion_result.expansion_iteration mismatch: got ${result.expansion_iteration}, expected ${context.expansionIteration}.`
42
+ );
43
+ }
44
+
45
+ const visionHeadings = normalizeVisionHeadingSnapshot(context.visionHeadingsSnapshot);
46
+ const traceErrors = validateVisionTraceability(result.vision_traceability, {
47
+ required: result.kind === 'new_intake_intent',
48
+ visionHeadings,
49
+ });
50
+ errors.push(...traceErrors);
51
+
52
+ if (result.kind === 'new_intake_intent') {
53
+ errors.push(...validateNewIntakeIntent(result.new_intake_intent));
54
+ if ('vision_exhausted' in result) {
55
+ errors.push('idle_expansion_result.vision_exhausted is only allowed when kind is "vision_exhausted".');
56
+ }
57
+ }
58
+
59
+ if (result.kind === 'vision_exhausted') {
60
+ errors.push(...validateVisionExhausted(result.vision_exhausted, visionHeadings));
61
+ if ('new_intake_intent' in result) {
62
+ errors.push('idle_expansion_result.new_intake_intent is only allowed when kind is "new_intake_intent".');
63
+ }
64
+ }
65
+
66
+ return { ok: errors.length === 0, errors, warnings };
67
+ }
68
+
69
+ export function summarizeIdleExpansionResult(turnResult) {
70
+ const result = turnResult?.idle_expansion_result;
71
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
72
+ return null;
73
+ }
74
+
75
+ if (result.kind === 'new_intake_intent') {
76
+ return {
77
+ kind: result.kind,
78
+ expansion_iteration: result.expansion_iteration,
79
+ new_intent_title: truncate(result.new_intake_intent?.title || '', 120),
80
+ priority: result.new_intake_intent?.priority || null,
81
+ template: result.new_intake_intent?.template || null,
82
+ };
83
+ }
84
+
85
+ if (result.kind === 'vision_exhausted') {
86
+ const firstReason = Array.isArray(result.vision_exhausted?.classification)
87
+ ? result.vision_exhausted.classification.find((entry) => typeof entry?.reason === 'string')?.reason || ''
88
+ : '';
89
+ return {
90
+ kind: result.kind,
91
+ expansion_iteration: result.expansion_iteration,
92
+ reason_excerpt: truncate(firstReason, 160),
93
+ };
94
+ }
95
+
96
+ return {
97
+ kind: result.kind || null,
98
+ expansion_iteration: result.expansion_iteration || null,
99
+ };
100
+ }
101
+
102
+ function validateVisionTraceability(traceability, { required, visionHeadings }) {
103
+ const errors = [];
104
+
105
+ if (!Array.isArray(traceability)) {
106
+ errors.push('idle_expansion_result.vision_traceability must be an array.');
107
+ return errors;
108
+ }
109
+ if (required && traceability.length === 0) {
110
+ errors.push('idle_expansion_result.vision_traceability must cite at least one VISION.md heading for new_intake_intent.');
111
+ }
112
+
113
+ for (let i = 0; i < traceability.length; i++) {
114
+ const entry = traceability[i];
115
+ const prefix = `idle_expansion_result.vision_traceability[${i}]`;
116
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
117
+ errors.push(`${prefix} must be an object.`);
118
+ continue;
119
+ }
120
+
121
+ const heading = typeof entry.vision_heading === 'string' ? entry.vision_heading.trim() : '';
122
+ if (!heading) {
123
+ errors.push(`${prefix}.vision_heading must be a non-empty string.`);
124
+ } else if (visionHeadings.length > 0 && !visionHeadings.includes(heading)) {
125
+ errors.push(`${prefix}.vision_heading "${heading}" is not present in the session VISION.md heading snapshot.`);
126
+ }
127
+
128
+ if (entry.goal !== undefined && (typeof entry.goal !== 'string' || !entry.goal.trim())) {
129
+ errors.push(`${prefix}.goal must be a non-empty string when provided.`);
130
+ }
131
+ if (entry.kind !== undefined && !VALID_TRACE_KINDS.includes(entry.kind)) {
132
+ errors.push(`${prefix}.kind must be one of: ${VALID_TRACE_KINDS.join(', ')}.`);
133
+ }
134
+ }
135
+
136
+ return errors;
137
+ }
138
+
139
+ function validateNewIntakeIntent(intent) {
140
+ const errors = [];
141
+ const prefix = 'idle_expansion_result.new_intake_intent';
142
+
143
+ if (!intent || typeof intent !== 'object' || Array.isArray(intent)) {
144
+ return [`${prefix} must be an object.`];
145
+ }
146
+
147
+ for (const field of ['title', 'charter']) {
148
+ if (typeof intent[field] !== 'string' || !intent[field].trim()) {
149
+ errors.push(`${prefix}.${field} must be a non-empty string.`);
150
+ }
151
+ }
152
+
153
+ if (!VALID_PRIORITIES.includes(intent.priority)) {
154
+ errors.push(`${prefix}.priority must be one of: ${VALID_PRIORITIES.join(', ')}.`);
155
+ }
156
+ if (!VALID_GOVERNED_TEMPLATE_IDS.includes(intent.template)) {
157
+ errors.push(`${prefix}.template must be one of: ${VALID_GOVERNED_TEMPLATE_IDS.join(', ')}.`);
158
+ }
159
+ if (!Array.isArray(intent.acceptance_contract) || intent.acceptance_contract.length === 0) {
160
+ errors.push(`${prefix}.acceptance_contract must be a non-empty array.`);
161
+ } else {
162
+ for (let i = 0; i < intent.acceptance_contract.length; i++) {
163
+ if (typeof intent.acceptance_contract[i] !== 'string' || !intent.acceptance_contract[i].trim()) {
164
+ errors.push(`${prefix}.acceptance_contract[${i}] must be a non-empty string.`);
165
+ }
166
+ }
167
+ }
168
+
169
+ return errors;
170
+ }
171
+
172
+ function validateVisionExhausted(visionExhausted, visionHeadings) {
173
+ const errors = [];
174
+ const prefix = 'idle_expansion_result.vision_exhausted';
175
+
176
+ if (!visionExhausted || typeof visionExhausted !== 'object' || Array.isArray(visionExhausted)) {
177
+ return [`${prefix} must be an object.`];
178
+ }
179
+
180
+ const classification = visionExhausted.classification;
181
+ if (!Array.isArray(classification) || classification.length === 0) {
182
+ errors.push(`${prefix}.classification must be a non-empty array.`);
183
+ return errors;
184
+ }
185
+
186
+ const classifiedHeadings = new Set();
187
+ for (let i = 0; i < classification.length; i++) {
188
+ const entry = classification[i];
189
+ const entryPrefix = `${prefix}.classification[${i}]`;
190
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
191
+ errors.push(`${entryPrefix} must be an object.`);
192
+ continue;
193
+ }
194
+
195
+ const heading = typeof entry.vision_heading === 'string' ? entry.vision_heading.trim() : '';
196
+ if (!heading) {
197
+ errors.push(`${entryPrefix}.vision_heading must be a non-empty string.`);
198
+ } else {
199
+ classifiedHeadings.add(heading);
200
+ if (visionHeadings.length > 0 && !visionHeadings.includes(heading)) {
201
+ errors.push(`${entryPrefix}.vision_heading "${heading}" is not present in the session VISION.md heading snapshot.`);
202
+ }
203
+ }
204
+ if (!VALID_EXHAUSTION_STATUSES.includes(entry.status)) {
205
+ errors.push(`${entryPrefix}.status must be one of: ${VALID_EXHAUSTION_STATUSES.join(', ')}.`);
206
+ }
207
+ if (typeof entry.reason !== 'string' || !entry.reason.trim()) {
208
+ errors.push(`${entryPrefix}.reason must be a non-empty string.`);
209
+ }
210
+ }
211
+
212
+ for (const heading of visionHeadings) {
213
+ if (!classifiedHeadings.has(heading)) {
214
+ errors.push(`${prefix}.classification must classify VISION.md heading "${heading}".`);
215
+ }
216
+ }
217
+
218
+ return errors;
219
+ }
220
+
221
+ function normalizeVisionHeadingSnapshot(snapshot) {
222
+ if (!Array.isArray(snapshot)) {
223
+ return [];
224
+ }
225
+
226
+ const headings = [];
227
+ for (const item of snapshot) {
228
+ const heading = typeof item === 'string'
229
+ ? item.trim()
230
+ : typeof item?.heading === 'string'
231
+ ? item.heading.trim()
232
+ : typeof item?.title === 'string'
233
+ ? item.title.trim()
234
+ : '';
235
+ if (heading && !headings.includes(heading)) {
236
+ headings.push(heading);
237
+ }
238
+ }
239
+ return headings;
240
+ }
241
+
242
+ function truncate(value, maxLength) {
243
+ if (typeof value !== 'string') {
244
+ return '';
245
+ }
246
+ const trimmed = value.trim();
247
+ if (trimmed.length <= maxLength) {
248
+ return trimmed;
249
+ }
250
+ return `${trimmed.slice(0, maxLength - 3)}...`;
251
+ }
package/src/lib/intake.js CHANGED
@@ -29,10 +29,11 @@ import {
29
29
  getPhaseOrder,
30
30
  } from './intent-phase-scope.js';
31
31
 
32
- const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan'];
32
+ const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan', 'vision_idle_expansion'];
33
33
  const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
34
34
  const EVENT_ID_RE = /^evt_\d+_[0-9a-f]{4}$/;
35
35
  const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
36
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
36
37
 
37
38
  // V3-S1 through S5 states. `failed` remains read-tolerant for historical/manual
38
39
  // intent files, but current first-party intake writers do not transition into it.
@@ -66,6 +67,17 @@ function computeDedupKey(source, signal) {
66
67
  return `${source}:${hash}`;
67
68
  }
68
69
 
70
+ export function buildVisionIdleExpansionSignal(sessionId, expansionIteration, acceptedTurnId) {
71
+ const expansionKey = createHash('sha256')
72
+ .update(`${sessionId}::${expansionIteration}::${acceptedTurnId}`)
73
+ .digest('hex');
74
+ return {
75
+ expansion_key: expansionKey,
76
+ expansion_iteration: expansionIteration,
77
+ accepted_turn_id: acceptedTurnId,
78
+ };
79
+ }
80
+
69
81
  function nowISO() {
70
82
  return new Date().toISOString();
71
83
  }
@@ -268,6 +280,13 @@ export function validateEventPayload(payload) {
268
280
  errors.push('signal must be a non-empty object');
269
281
  }
270
282
 
283
+ if (payload.source === 'vision_idle_expansion' && payload.signal && typeof payload.signal === 'object' && !Array.isArray(payload.signal)) {
284
+ errors.push(...validateVisionIdleExpansionSignal(payload.signal));
285
+ errors.push(...validateVisionIdleExpansionContext(payload.idle_expansion_context));
286
+ } else if (payload.idle_expansion_context !== undefined) {
287
+ errors.push('idle_expansion_context is only allowed for source "vision_idle_expansion"');
288
+ }
289
+
271
290
  if (!Array.isArray(payload.evidence) || payload.evidence.length === 0) {
272
291
  errors.push('evidence must be a non-empty array');
273
292
  } else {
@@ -288,6 +307,57 @@ export function validateEventPayload(payload) {
288
307
  return { valid: errors.length === 0, errors };
289
308
  }
290
309
 
310
+ function validateVisionIdleExpansionSignal(signal) {
311
+ const errors = [];
312
+ const keys = Object.keys(signal).sort();
313
+ const expected = ['accepted_turn_id', 'expansion_iteration', 'expansion_key'];
314
+ if (JSON.stringify(keys) !== JSON.stringify(expected)) {
315
+ errors.push(`vision_idle_expansion signal must contain exactly: ${expected.join(', ')}`);
316
+ return errors;
317
+ }
318
+ if (typeof signal.expansion_key !== 'string' || !SHA256_HEX_RE.test(signal.expansion_key)) {
319
+ errors.push('vision_idle_expansion signal.expansion_key must be a SHA-256 hex string');
320
+ }
321
+ if (!Number.isInteger(signal.expansion_iteration) || signal.expansion_iteration < 1) {
322
+ errors.push('vision_idle_expansion signal.expansion_iteration must be an integer >= 1');
323
+ }
324
+ if (typeof signal.accepted_turn_id !== 'string' || !signal.accepted_turn_id.trim()) {
325
+ errors.push('vision_idle_expansion signal.accepted_turn_id must be a non-empty string');
326
+ }
327
+ return errors;
328
+ }
329
+
330
+ function validateVisionIdleExpansionContext(context) {
331
+ const errors = [];
332
+ if (!context || typeof context !== 'object' || Array.isArray(context)) {
333
+ return ['idle_expansion_context must be a JSON object for source "vision_idle_expansion"'];
334
+ }
335
+ if (!Number.isInteger(context.expansion_iteration) || context.expansion_iteration < 1) {
336
+ errors.push('idle_expansion_context.expansion_iteration must be an integer >= 1');
337
+ }
338
+ if (!Array.isArray(context.vision_headings_snapshot) || context.vision_headings_snapshot.length === 0) {
339
+ errors.push('idle_expansion_context.vision_headings_snapshot must be a non-empty array');
340
+ } else {
341
+ for (let i = 0; i < context.vision_headings_snapshot.length; i++) {
342
+ const heading = context.vision_headings_snapshot[i];
343
+ if (typeof heading !== 'string' || !heading.trim()) {
344
+ errors.push(`idle_expansion_context.vision_headings_snapshot[${i}] must be a non-empty string`);
345
+ }
346
+ }
347
+ }
348
+ return errors;
349
+ }
350
+
351
+ function normalizeVisionIdleExpansionContext(context) {
352
+ if (!context || typeof context !== 'object' || Array.isArray(context)) return null;
353
+ return {
354
+ expansion_iteration: context.expansion_iteration,
355
+ vision_headings_snapshot: Array.isArray(context.vision_headings_snapshot)
356
+ ? context.vision_headings_snapshot.map((heading) => String(heading).trim()).filter(Boolean)
357
+ : [],
358
+ };
359
+ }
360
+
291
361
  export function validateTriageFields(fields, config = null) {
292
362
  const errors = [];
293
363
 
@@ -357,6 +427,12 @@ export function recordEvent(root, payload) {
357
427
  evidence: payload.evidence,
358
428
  dedup_key: dedupKey,
359
429
  };
430
+ const idleExpansionContext = payload.source === 'vision_idle_expansion'
431
+ ? normalizeVisionIdleExpansionContext(payload.idle_expansion_context)
432
+ : null;
433
+ if (idleExpansionContext) {
434
+ event.idle_expansion_context = idleExpansionContext;
435
+ }
360
436
 
361
437
  safeWriteJson(join(dirs.events, `${eventId}.json`), event);
362
438
 
@@ -380,6 +456,9 @@ export function recordEvent(root, payload) {
380
456
  { from: null, to: 'detected', at: now, reason: 'event ingested' },
381
457
  ],
382
458
  };
459
+ if (idleExpansionContext) {
460
+ intent.idle_expansion_context = idleExpansionContext;
461
+ }
383
462
 
384
463
  safeWriteJson(join(dirs.intents, `${intentId}.json`), intent);
385
464
 
@@ -968,6 +1047,9 @@ export function startIntent(root, intentId, options = {}) {
968
1047
  return loadedEvent;
969
1048
  }
970
1049
  const { event } = loadedEvent;
1050
+ const idleExpansionContext = event.source === 'vision_idle_expansion'
1051
+ ? normalizeVisionIdleExpansionContext(intent.idle_expansion_context || event.idle_expansion_context)
1052
+ : null;
971
1053
  const intakeContext = {
972
1054
  intent_id: intent.intent_id,
973
1055
  event_id: intent.event_id,
@@ -976,6 +1058,7 @@ export function startIntent(root, intentId, options = {}) {
976
1058
  charter: intent.charter || null,
977
1059
  acceptance_contract: Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract : [],
978
1060
  phase_scope: intent.phase_scope || null,
1061
+ ...(idleExpansionContext ? { idle_expansion: idleExpansionContext } : {}),
979
1062
  };
980
1063
 
981
1064
  // Load governed project context
@@ -1072,6 +1155,7 @@ export function startIntent(root, intentId, options = {}) {
1072
1155
  // Assign governed turn
1073
1156
  const assignResult = assignGovernedTurn(root, config, roleId.role, {
1074
1157
  intakeContext,
1158
+ ...(idleExpansionContext ? { idleExpansionContext } : {}),
1075
1159
  });
1076
1160
  if (!assignResult.ok) {
1077
1161
  return { ok: false, error: `turn assignment failed: ${assignResult.error}`, exitCode: 1 };
@@ -647,6 +647,7 @@ export function validateRunLoopConfig(runLoop) {
647
647
  }
648
648
 
649
649
  export const VALID_RECONCILE_OPERATOR_COMMITS = ['manual', 'auto_safe_only', 'disabled'];
650
+ export { VALID_ON_IDLE, RESERVED_ON_IDLE };
650
651
 
651
652
  function validateRunLoopContinuousConfig(path, continuous, errors) {
652
653
  if (typeof continuous !== 'object' || Array.isArray(continuous)) {
@@ -669,6 +670,32 @@ function validateRunLoopContinuousConfig(path, continuous, errors) {
669
670
  );
670
671
  }
671
672
  }
673
+ // BUG-60: validate on_idle policy
674
+ if (continuous.on_idle !== undefined && continuous.on_idle !== null) {
675
+ if (typeof continuous.on_idle !== 'string') {
676
+ errors.push(`${path}.on_idle must be one of: ${VALID_ON_IDLE.join(', ')}`);
677
+ } else if (!VALID_ON_IDLE.includes(continuous.on_idle)) {
678
+ errors.push(`${path}.on_idle must be one of: ${VALID_ON_IDLE.join(', ')}`);
679
+ }
680
+ }
681
+ // BUG-60: validate idle_expansion block when present
682
+ if (continuous.idle_expansion !== undefined && continuous.idle_expansion !== null) {
683
+ if (typeof continuous.idle_expansion !== 'object' || Array.isArray(continuous.idle_expansion)) {
684
+ errors.push(`${path}.idle_expansion must be an object`);
685
+ } else {
686
+ if (continuous.idle_expansion.max_expansions !== undefined) {
687
+ validatePositiveInteger(`${path}.idle_expansion.max_expansions`, continuous.idle_expansion.max_expansions, 'expansion count', errors);
688
+ }
689
+ if (continuous.idle_expansion.malformed_retry_limit !== undefined
690
+ && continuous.idle_expansion.malformed_retry_limit !== null) {
691
+ if (typeof continuous.idle_expansion.malformed_retry_limit !== 'number'
692
+ || !Number.isInteger(continuous.idle_expansion.malformed_retry_limit)
693
+ || continuous.idle_expansion.malformed_retry_limit < 0) {
694
+ errors.push(`${path}.idle_expansion.malformed_retry_limit must be a non-negative integer`);
695
+ }
696
+ }
697
+ }
698
+ }
672
699
  }
673
700
 
674
701
  function validateAutoRetryOnGhostConfig(path, value, errors) {
@@ -1329,9 +1356,43 @@ export function normalizeV4(raw) {
1329
1356
  };
1330
1357
  }
1331
1358
 
1359
+ const VALID_ON_IDLE = ['exit', 'perpetual', 'human_review'];
1360
+ const RESERVED_ON_IDLE = [];
1361
+
1362
+ function normalizeIdleExpansion(raw) {
1363
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
1364
+ return {
1365
+ sources: ['.planning/VISION.md', '.planning/ROADMAP.md', '.planning/SYSTEM_SPEC.md'],
1366
+ max_expansions: 5,
1367
+ role: 'pm',
1368
+ output: 'intake_intent_or_vision_exhausted',
1369
+ malformed_retry_limit: 1,
1370
+ };
1371
+ }
1372
+ const sources = Array.isArray(raw.sources) && raw.sources.length > 0
1373
+ ? raw.sources.filter(s => typeof s === 'string' && s.length > 0)
1374
+ : ['.planning/VISION.md', '.planning/ROADMAP.md', '.planning/SYSTEM_SPEC.md'];
1375
+ return {
1376
+ sources: sources.length > 0 ? sources : ['.planning/VISION.md'],
1377
+ max_expansions: Number.isInteger(raw.max_expansions) && raw.max_expansions >= 1 ? raw.max_expansions : 5,
1378
+ role: typeof raw.role === 'string' && raw.role.length > 0 ? raw.role : 'pm',
1379
+ output: 'intake_intent_or_vision_exhausted',
1380
+ malformed_retry_limit: Number.isInteger(raw.malformed_retry_limit) && raw.malformed_retry_limit >= 0
1381
+ ? raw.malformed_retry_limit : 1,
1382
+ };
1383
+ }
1384
+
1332
1385
  function normalizeContinuousConfig(raw) {
1333
1386
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1334
1387
  if (raw.enabled !== true) return null;
1388
+
1389
+ // Normalize on_idle — reserved and invalid values fall back to 'exit';
1390
+ // validation layer reports actionable errors to the operator.
1391
+ let on_idle = 'exit';
1392
+ if (typeof raw.on_idle === 'string' && VALID_ON_IDLE.includes(raw.on_idle)) {
1393
+ on_idle = raw.on_idle;
1394
+ }
1395
+
1335
1396
  return {
1336
1397
  enabled: true,
1337
1398
  vision_path: raw.vision_path || '.planning/VISION.md',
@@ -1341,6 +1402,9 @@ function normalizeContinuousConfig(raw) {
1341
1402
  per_session_max_usd: Number.isFinite(raw.per_session_max_usd) && raw.per_session_max_usd > 0
1342
1403
  ? raw.per_session_max_usd
1343
1404
  : null,
1405
+ auto_checkpoint: raw.auto_checkpoint === true || raw.auto_checkpoint === false ? raw.auto_checkpoint : undefined,
1406
+ on_idle,
1407
+ idle_expansion: on_idle === 'perpetual' ? normalizeIdleExpansion(raw.idle_expansion) : null,
1344
1408
  };
1345
1409
  }
1346
1410
 
@@ -109,6 +109,37 @@
109
109
  "type": "object",
110
110
  "description": "Continuous-run control knobs.",
111
111
  "properties": {
112
+ "on_idle": {
113
+ "type": "string",
114
+ "enum": ["exit", "perpetual", "human_review"],
115
+ "description": "Policy after max_idle_cycles with no derivable work: exit, dispatch PM idle-expansion, or pause for operator review."
116
+ },
117
+ "idle_expansion": {
118
+ "type": "object",
119
+ "description": "PM idle-expansion policy used when on_idle is perpetual.",
120
+ "properties": {
121
+ "sources": {
122
+ "type": "array",
123
+ "items": { "type": "string" },
124
+ "description": "Project-relative source files the PM expansion turn should inspect. Defaults to VISION, ROADMAP, and SYSTEM_SPEC."
125
+ },
126
+ "max_expansions": {
127
+ "type": "integer",
128
+ "minimum": 1,
129
+ "description": "Maximum consecutive PM idle-expansion attempts before stopping with vision_expansion_exhausted. Default 5."
130
+ },
131
+ "role": {
132
+ "type": "string",
133
+ "description": "Role used for the PM idle-expansion turn. Default pm."
134
+ },
135
+ "malformed_retry_limit": {
136
+ "type": "integer",
137
+ "minimum": 0,
138
+ "description": "Reserved retry budget for malformed idle_expansion_result outputs. Default 1."
139
+ }
140
+ },
141
+ "additionalProperties": true
142
+ },
112
143
  "auto_retry_on_ghost": {
113
144
  "type": "object",
114
145
  "description": "Bounded ghost-turn retry policy for continuous/full-auto sessions.",
@@ -251,6 +251,73 @@
251
251
  }
252
252
  }
253
253
  },
254
+ "idle_expansion_result": {
255
+ "type": "object",
256
+ "description": "Required only for vision_idle_expansion turns. PM output that either proposes the next intake intent or declares the product vision exhausted.",
257
+ "required": ["kind", "expansion_iteration", "vision_traceability"],
258
+ "additionalProperties": false,
259
+ "properties": {
260
+ "kind": {
261
+ "enum": ["new_intake_intent", "vision_exhausted"]
262
+ },
263
+ "expansion_iteration": {
264
+ "type": "integer",
265
+ "minimum": 1
266
+ },
267
+ "vision_traceability": {
268
+ "type": "array",
269
+ "items": {
270
+ "type": "object",
271
+ "required": ["vision_heading"],
272
+ "additionalProperties": false,
273
+ "properties": {
274
+ "vision_heading": { "type": "string", "minLength": 1 },
275
+ "goal": { "type": "string", "minLength": 1 },
276
+ "kind": { "enum": ["advances", "supports", "unblocks"] }
277
+ }
278
+ }
279
+ },
280
+ "new_intake_intent": {
281
+ "type": "object",
282
+ "required": ["title", "charter", "acceptance_contract", "priority", "template"],
283
+ "additionalProperties": false,
284
+ "properties": {
285
+ "title": { "type": "string", "minLength": 1 },
286
+ "charter": { "type": "string", "minLength": 1 },
287
+ "acceptance_contract": {
288
+ "type": "array",
289
+ "minItems": 1,
290
+ "items": { "type": "string", "minLength": 1 }
291
+ },
292
+ "priority": { "enum": ["p0", "p1", "p2", "p3"] },
293
+ "template": {
294
+ "enum": ["generic", "api-service", "cli-tool", "library", "web-app", "full-local-cli", "enterprise-app"]
295
+ }
296
+ }
297
+ },
298
+ "vision_exhausted": {
299
+ "type": "object",
300
+ "required": ["classification"],
301
+ "additionalProperties": false,
302
+ "properties": {
303
+ "classification": {
304
+ "type": "array",
305
+ "minItems": 1,
306
+ "items": {
307
+ "type": "object",
308
+ "required": ["vision_heading", "status", "reason"],
309
+ "additionalProperties": false,
310
+ "properties": {
311
+ "vision_heading": { "type": "string", "minLength": 1 },
312
+ "status": { "enum": ["complete", "deferred", "out_of_scope"] },
313
+ "reason": { "type": "string", "minLength": 1 }
314
+ }
315
+ }
316
+ }
317
+ }
318
+ }
319
+ }
320
+ },
254
321
  "cost": {
255
322
  "type": "object",
256
323
  "properties": {