voyage/lib/plan/synthesis-digest-schema.mjs
Kjell Tore Guttormsen 6b30483304 feat(voyage): S12 — NW3 synthesis-agent built + measured → declined per measurement [skip-docs]
NW3 (CC-26 §6 PoC): delegate trekplan Phase 7 synthesis to a synthesis-agent,
adopt only if Δ main-context ≥30% with no quality loss. Operator chose the
deterministic-proof path (live ≥3-run bake-off was env-blocked: no API key;
installed plugin is a cache copy so a new agent is invisible to `claude -p`).

Decisive structural finding: trekplan Phase 5 runs the swarm FOREGROUND, so its
outputs are already resident in main before Phase 7. Delegating only Phase 7
evicts nothing → Δ_faithful = 0% (BASE-independent). The ≥30% saving needs an
out-of-scope Phase-5 redesign (swarm-writes-to-disk / nested orchestrator).
VERDICT: DECLINED per measurement.

- agents/synthesis-agent.md — dormant, schema-conformant deliverable (NOT wired)
- lib/plan/synthesis-digest-schema.mjs — digest output contract (+ tests)
- scripts/synthesis-measure.mjs — deterministic Δ-accounting core (+ tests)
- tests/fixtures/synthesis/ — 7 exploration outputs + representative digest
- docs/T1-synthesis-poc-results.md — measurement + verdict (reproducible)
- CLAUDE.md — agent table row (doc-consistency: 24 agents)

Tests 670 → 695 (693 pass / 2 skip / 0 fail). `claude plugin validate` clean
(only the pre-existing root-CLAUDE.md warning). commands/trekplan.md untouched.

[skip-docs] rationale: no user-facing feature ships (NW3 declined; agent dormant
and unwired). The substantive doc is docs/T1-synthesis-poc-results.md; the
README/CHANGELOG roll-up for NW1–NW3 is the S13 coordinated release per
docs/W1-narrow-wins-plan.md §S13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
2026-06-18 17:58:39 +02:00

183 lines
6.8 KiB
JavaScript

// 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": "<task being planned>",
// "architecture_model": "<prose mental model of the codebase>",
// "reusable_code": [ { ref, note? }, ... ],
// "contradictions": [ "<overlap/contradiction between agents>", ... ],
// "risks": [ { risk, severity? }, ... ],
// "gaps": [ "<unknown → becomes a plan assumption>", ... ],
// "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] <agent-output.txt|.md>\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);
}