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.
@@ -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": {
@@ -16,6 +16,7 @@ import { existsSync, readFileSync } from 'fs';
16
16
  import { join } from 'path';
17
17
  import { getActiveTurn } from './governed-state.js';
18
18
  import { getInvalidPhaseTransitionReason } from './gate-evaluator.js';
19
+ import { validateIdleExpansionTurnResult } from './idle-expansion-result-validator.js';
19
20
 
20
21
  // ── Constants ────────────────────────────────────────────────────────────────
21
22
 
@@ -99,6 +100,12 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
99
100
  return result('schema', 'schema_error', schemaErrors);
100
101
  }
101
102
 
103
+ const activeTurn = getActiveTurn(state) || state?.current_turn || null;
104
+ const idleExpansionResult = validateIdleExpansionTurnResult(turnResult, buildIdleExpansionValidationContext(state, opts, activeTurn));
105
+ if (idleExpansionResult.errors.length > 0) {
106
+ return result('schema', 'schema_error', idleExpansionResult.errors, idleExpansionResult.warnings);
107
+ }
108
+
102
109
  // ── Stage B: Assignment Validation ─────────────────────────────────────
103
110
  const assignmentErrors = validateAssignment(turnResult, state);
104
111
  if (assignmentErrors.length > 0) {
@@ -443,6 +450,24 @@ function validateDelegation(del, index) {
443
450
  return errors;
444
451
  }
445
452
 
453
+ function buildIdleExpansionValidationContext(state, opts, activeTurn) {
454
+ const source = activeTurn?.intake_context?.source || null;
455
+ const context = activeTurn?.idle_expansion_context || activeTurn?.intake_context?.idle_expansion || {};
456
+ return {
457
+ required: source === 'vision_idle_expansion',
458
+ expansionIteration: opts.idleExpansionIteration
459
+ ?? context.expansion_iteration
460
+ ?? state?.idle_expansion?.current_iteration
461
+ ?? state?.continuous?.expansion_iteration
462
+ ?? null,
463
+ visionHeadingsSnapshot: opts.visionHeadingsSnapshot
464
+ ?? context.vision_headings_snapshot
465
+ ?? state?.vision_headings_snapshot
466
+ ?? state?.continuous?.vision_headings_snapshot
467
+ ?? [],
468
+ };
469
+ }
470
+
446
471
  // ── Stage B: Assignment Validation ───────────────────────────────────────────
447
472
 
448
473
  function validateAssignment(tr, state) {
@@ -11,8 +11,9 @@
11
11
  * Spec: .planning/VISION_DRIVEN_CONTINUOUS_SPEC.md
12
12
  */
13
13
 
14
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
15
  import { join, resolve as pathResolve, isAbsolute } from 'node:path';
16
+ import { createHash } from 'node:crypto';
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Parsing
@@ -216,6 +217,169 @@ export function deriveVisionCandidates(root, visionPath) {
216
217
  return { ok: true, candidates };
217
218
  }
218
219
 
220
+ // ---------------------------------------------------------------------------
221
+ // Vision snapshot capture (BUG-60 Slice 3)
222
+ // ---------------------------------------------------------------------------
223
+
224
+ const MAX_PREVIEW_PER_SOURCE_BYTES = 16 * 1024;
225
+ const MAX_PREVIEW_TOTAL_BYTES = 48 * 1024;
226
+ const MAX_SOURCE_FILE_BYTES = 64 * 1024;
227
+
228
+ /**
229
+ * Capture a heading snapshot from parsed VISION.md content.
230
+ * Returns an array of unique heading strings (H1/H2/H3).
231
+ *
232
+ * @param {string} content - Raw VISION.md markdown
233
+ * @returns {string[]}
234
+ */
235
+ export function captureVisionHeadingsSnapshot(content) {
236
+ if (!content || typeof content !== 'string') return [];
237
+ const headings = [];
238
+ for (const line of content.split('\n')) {
239
+ const match = line.match(/^(#{1,3})\s+(.+)$/);
240
+ if (match) {
241
+ const heading = match[2].trim();
242
+ if (heading && !headings.includes(heading)) {
243
+ headings.push(heading);
244
+ }
245
+ }
246
+ }
247
+ return headings;
248
+ }
249
+
250
+ /**
251
+ * Compute a SHA-256 content hash for VISION.md content.
252
+ *
253
+ * @param {string} content - Raw file content
254
+ * @returns {string} Hex-encoded SHA-256
255
+ */
256
+ export function computeVisionContentSha(content) {
257
+ if (!content || typeof content !== 'string') return '';
258
+ return createHash('sha256').update(content, 'utf8').digest('hex');
259
+ }
260
+
261
+ /**
262
+ * Build a bounded source manifest for idle-expansion PM charter context.
263
+ *
264
+ * Per BUG-60 Plan §2: manifest includes path, presence, byte_count, warning,
265
+ * extracted H1/H2 headings, and a bounded preview. Preview truncation is
266
+ * deterministic: at most 16KB per source and 48KB total, using head+tail
267
+ * with `[...truncated middle...]` inserted between halves.
268
+ *
269
+ * VISION.md missing/malformed is a hard error. ROADMAP.md and SYSTEM_SPEC.md
270
+ * missing are warnings. ROADMAP/SYSTEM_SPEC malformed if they cannot be
271
+ * decoded as UTF-8, exceed 64KB, or parse into fewer than one H1/H2 heading.
272
+ *
273
+ * @param {string} root - Project root
274
+ * @param {string[]} sources - Array of project-relative source paths
275
+ * @returns {{ ok: boolean, entries: Array<object>, error?: string }}
276
+ */
277
+ export function buildSourceManifest(root, sources) {
278
+ if (!Array.isArray(sources) || sources.length === 0) {
279
+ return { ok: false, entries: [], error: 'No sources configured for idle expansion.' };
280
+ }
281
+
282
+ const entries = [];
283
+ let totalPreviewBytes = 0;
284
+
285
+ for (const sourcePath of sources) {
286
+ const absPath = isAbsolute(sourcePath) ? sourcePath : pathResolve(root, sourcePath);
287
+ const isVision = sourcePath.toLowerCase().includes('vision');
288
+ const entry = { path: sourcePath, present: false, byte_count: 0, warning: null, headings: [], preview: null };
289
+
290
+ if (!existsSync(absPath)) {
291
+ entry.warning = 'file_not_found';
292
+ if (isVision) {
293
+ return { ok: false, entries, error: `VISION.md not found at ${absPath}. Cannot run idle expansion without VISION.md.` };
294
+ }
295
+ entries.push(entry);
296
+ continue;
297
+ }
298
+
299
+ let content;
300
+ let byteCount;
301
+ try {
302
+ const stat = statSync(absPath);
303
+ byteCount = stat.size;
304
+ entry.byte_count = byteCount;
305
+ entry.present = true;
306
+
307
+ if (byteCount > MAX_SOURCE_FILE_BYTES && !isVision) {
308
+ entry.warning = 'exceeds_64kb';
309
+ // Still read what we can for preview, but flag it
310
+ content = readFileSync(absPath, 'utf8');
311
+ } else {
312
+ content = readFileSync(absPath, 'utf8');
313
+ }
314
+ } catch (err) {
315
+ entry.warning = 'read_error';
316
+ if (isVision) {
317
+ return { ok: false, entries, error: `Cannot read VISION.md at ${absPath}: ${err.message}` };
318
+ }
319
+ entries.push(entry);
320
+ continue;
321
+ }
322
+
323
+ // Extract H1/H2 headings
324
+ const headings = [];
325
+ for (const line of content.split('\n')) {
326
+ const match = line.match(/^(#{1,2})\s+(.+)$/);
327
+ if (match) {
328
+ const heading = match[2].trim();
329
+ if (heading && !headings.includes(heading)) {
330
+ headings.push(heading);
331
+ }
332
+ }
333
+ }
334
+ entry.headings = headings;
335
+
336
+ // Malformed check for non-VISION sources
337
+ if (!isVision) {
338
+ if (byteCount > MAX_SOURCE_FILE_BYTES) {
339
+ entry.warning = 'exceeds_64kb';
340
+ } else if (headings.length === 0) {
341
+ entry.warning = 'no_headings';
342
+ }
343
+ }
344
+
345
+ // Bounded preview
346
+ const remainingBudget = MAX_PREVIEW_TOTAL_BYTES - totalPreviewBytes;
347
+ const perSourceCap = Math.min(MAX_PREVIEW_PER_SOURCE_BYTES, remainingBudget);
348
+ if (perSourceCap > 0 && content.length > 0) {
349
+ entry.preview = truncatePreview(content, perSourceCap);
350
+ totalPreviewBytes += Buffer.byteLength(entry.preview, 'utf8');
351
+ }
352
+
353
+ entries.push(entry);
354
+ }
355
+
356
+ return { ok: true, entries };
357
+ }
358
+
359
+ /**
360
+ * Deterministic head+tail preview truncation.
361
+ * If content fits within cap, return as-is. Otherwise split into
362
+ * head half + `[...truncated middle...]` + tail half.
363
+ *
364
+ * @param {string} content
365
+ * @param {number} capBytes
366
+ * @returns {string}
367
+ */
368
+ function truncatePreview(content, capBytes) {
369
+ const contentBytes = Buffer.byteLength(content, 'utf8');
370
+ if (contentBytes <= capBytes) return content;
371
+
372
+ const marker = '\n[...truncated middle...]\n';
373
+ const markerBytes = Buffer.byteLength(marker, 'utf8');
374
+ const usable = capBytes - markerBytes;
375
+ if (usable <= 0) return content.slice(0, 100) + marker;
376
+
377
+ const halfChars = Math.floor(usable / 2);
378
+ const head = content.slice(0, halfChars);
379
+ const tail = content.slice(-halfChars);
380
+ return head + marker + tail;
381
+ }
382
+
219
383
  /**
220
384
  * Resolve a vision path relative to the project root.
221
385
  *