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
183 lines
6.8 KiB
JavaScript
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);
|
|
}
|