// lib/plan/synthesis-digest-schema.mjs // Digest-output JSON schema contract for the synthesis-agent (NW3 / S12). // // The synthesis-agent (agents/synthesis-agent.md) ingests the trekplan Phase-5/7 // exploration outputs and emits a trailing fenced ```json block carrying the // findings DIGEST that main currently writes inline in Phase 7. Shape: // // { "agent": "synthesis-agent", // "task": "", // "architecture_model": "", // "reusable_code": [ { ref, note? }, ... ], // "contradictions": [ "", ... ], // "risks": [ { risk, severity? }, ... ], // "gaps": [ "", ... ], // "sources": [ { finding, origin: "codebase" | "research" }, ... ] } // // This codifies the contract so a delegated synthesis path could VALIDATE the // digest (not merely JSON.parse it) and re-ask on schema failure, and so the // measurement harness has a fixed quality contract to compare inline-vs-delegated // digests against. // // Load-bearing fields (what Phase 8 deep-planning consumes): task, // architecture_model, and the five synthesis arrays. Each `sources` entry must // be origin-tagged codebase|research (Phase 7 rule 7). Descriptive fields and // unknown top-level keys are tolerated (forward-compat, mirroring // review-validator.mjs / findings-schema.mjs). // // 3-layer pattern (Content → Raw-text → CLI shim) mirroring the other validators. import { readFileSync, existsSync } from 'node:fs'; import { issue, fail } from '../util/result.mjs'; // Origin tag for every synthesised finding (Phase 7 rule 7: codebase vs research). export const ORIGIN_VALUES = Object.freeze(['codebase', 'research']); // The fields Phase 8 depends on. Descriptive fields (note/severity) are not here // on purpose: their absence should not trigger a re-ask. export const DIGEST_REQUIRED_FIELDS = Object.freeze([ 'task', 'architecture_model', 'reusable_code', 'contradictions', 'risks', 'gaps', 'sources', ]); // The five synthesis arrays + their stable not-an-array error codes. const ARRAY_FIELDS = Object.freeze([ ['reusable_code', 'DIGEST_REUSABLE_NOT_ARRAY'], ['contradictions', 'DIGEST_CONTRADICTIONS_NOT_ARRAY'], ['risks', 'DIGEST_RISKS_NOT_ARRAY'], ['gaps', 'DIGEST_GAPS_NOT_ARRAY'], ['sources', 'DIGEST_SOURCES_NOT_ARRAY'], ]); // Last fenced ```json … ``` block, so prose above it never confuses the parser. const JSON_FENCE_GLOBAL = /```json[ \t]*\r?\n([\s\S]*?)```/gi; /** * Extract the inner body of the LAST fenced `json` block in `text`. * @param {string} text * @returns {string|null} the JSON source, or null if no json fence is present. */ export function extractDigestBlock(text) { if (typeof text !== 'string') return null; JSON_FENCE_GLOBAL.lastIndex = 0; let last = null; let m; while ((m = JSON_FENCE_GLOBAL.exec(text)) !== null) { last = m[1]; } return last; } function isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; } /** * Validate an already-parsed digest payload against the schema. * Accumulates every error (so a re-ask can name all problems at once). * @param {unknown} payload * @returns {import('../util/result.mjs').Result} */ export function validateDigest(payload) { if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { return fail(issue( 'DIGEST_NOT_OBJECT', `Digest must be a JSON object, got ${Array.isArray(payload) ? 'array' : typeof payload}`, )); } const errors = []; const warnings = []; if (!isNonEmptyString(payload.agent)) { warnings.push(issue('DIGEST_MISSING_AGENT', 'Digest should carry a non-empty "agent" name')); } if (!isNonEmptyString(payload.task)) { errors.push(issue('DIGEST_MISSING_TASK', 'Digest "task" must be a non-empty string')); } if (!isNonEmptyString(payload.architecture_model)) { errors.push(issue( 'DIGEST_MISSING_ARCHITECTURE', 'Digest "architecture_model" must be a non-empty string (the synthesised mental model)', )); } for (const [field, code] of ARRAY_FIELDS) { if (!Array.isArray(payload[field])) { errors.push(issue(code, `Digest "${field}" must be an array, got ${typeof payload[field]}`)); } } // Origin-tag check — only when sources actually is an array. if (Array.isArray(payload.sources)) { payload.sources.forEach((s, i) => { const origin = s && typeof s === 'object' ? s.origin : undefined; if (!ORIGIN_VALUES.includes(origin)) { errors.push(issue( 'DIGEST_SOURCE_BAD_ORIGIN', `sources[${i}].origin must be one of ${ORIGIN_VALUES.join('|')}, got ${JSON.stringify(origin)}`, 'Tag every synthesised finding as codebase or research (Phase 7 rule 7).', `sources[${i}]`, )); } }); } return { valid: errors.length === 0, errors, warnings, parsed: payload }; } /** * Validate a synthesis-agent's raw output: extract the last json fence, parse it, * then schema-validate. Parse-stage failures get stable codes so they flow * through the same bounded re-ask path as schema failures. * @param {string} rawText * @returns {import('../util/result.mjs').Result} */ export function validateAgentOutput(rawText) { const block = extractDigestBlock(rawText); if (block === null) { return fail(issue( 'DIGEST_NO_JSON_BLOCK', 'No trailing fenced ```json block found in synthesis-agent output', 'The synthesis-agent must end its output with a single ```json digest block.', )); } let parsed; try { parsed = JSON.parse(block); } catch (e) { return fail(issue('DIGEST_PARSE_ERROR', `Digest JSON block did not parse: ${e.message}`)); } return validateDigest(parsed); } // ---- CLI shim ---------------------------------------------------------------- if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); const filePath = args.find((a) => !a.startsWith('--')); if (!filePath) { process.stderr.write('Usage: synthesis-digest-schema.mjs [--json] \n'); process.exit(2); } if (!existsSync(filePath)) { process.stderr.write(`synthesis-digest-schema: file not found: ${filePath}\n`); process.exit(2); } const r = validateAgentOutput(readFileSync(filePath, 'utf-8')); if (args.includes('--json')) { process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n'); } else { process.stdout.write(`synthesis-digest-schema: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`); for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`); for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`); } process.exit(r.valid ? 0 : 1); }