Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.
- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
text says so (red-proof, trekexecute Sub-step B, step-implementer,
HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
already in the tree when the step began no longer counts against it, and a
plan placed outside .claude/ reads the same. The round-6 test that had the
diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
(", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
not "could not start"; the release check tells a timeout from output past
its buffer and from another signal. One reading of a stop
(criteria-runner stoppedBy) for red proof, the criteria runner and the
release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
the realpath guard and the 20 modules still on the file:// guard, which
exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
passed at once; it fails when 127 is read as could-not-run); the prose now
names the git revision `@~1` as the one leading-@ exception.
Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.8 KiB
JavaScript
184 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';
|
|
import { isMain } from '../util/is-main.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 (isMain(import.meta.url)) {
|
|
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);
|
|
}
|