Round 8 (0f400d3..29b4ac9, BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR). Each fix
began with a test that failed on 29b4ac9 (13 tests, all red there); suite
2221 (2219/0/2).
- BLOCKER: a backticked check whose trailing comment holds a quote, $,
backtick or backslash is refused, not dropped. The comment was never
screened, while the executor hands the Verify span as written to red
proof, the test lock and the done line inside --cmd "...", where a $(...)
runs and a quote ends the argument. trekplan's text and the reviewer's
NOT RUN table say so.
- A span that is only a comment is not-a-command, not a placeholder. The
dropComment text says the allowlist still refuses a word with a # in it.
- The criteria runner, comparison-gate and end-state-gate use isMain: they
did nothing and exited 0 under a symlinked path. The test now refuses any
process.argv[1] comparison outside lib/util/is-main.mjs.
- Files: in a field with backticked paths, a plain word counts only before
the entry's description (a dash, or a colon after the path); a name with
no slash only first in its entry or after and/or/+/&. "mocks
fs.readFileSync", "runs on Node.js 22.x" or "no change to package.json"
no longer make a tests-only step look like it builds something.
Lowercase makefile, rakefile and justfile are build files. Measured on the
520 Files: fields in 48 plans of the marketplace and the test repo:
3 fields read differently (two notes no longer read as files, one
duplicate name dropped), tests-only changed in 0.
- The test lock compares --cmd without its trailing comment, and the test
lock and the done line read a stop with stoppedBy, the one reading.
red-proof drops its second reading, spawnError.
- release-check reads a relative --review in --repo or, when only that one
exists, from the current directory as 6.3.0 did; two different files are
exit 2, never guessed. The runbook says so.
- One trailing-json reader: a closing fence may be longer than three
backticks, in the block reader and the verdict gate alike, and the review
validator and the synthesis digest use it, so three backticks inside a
JSON string no longer hide a BLOCKER from the remediation-step check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
6.7 KiB
JavaScript
177 lines
6.7 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';
|
|
import { extractFindingsBlock } from '../review/findings-schema.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'],
|
|
]);
|
|
|
|
/**
|
|
* Extract the inner body of the LAST fenced `json` block in `text`, with the
|
|
* one block reader (lib/review/findings-schema.mjs): three backticks inside a
|
|
* JSON string do not end the block (release review of 6.3.0, round 8).
|
|
* @param {string} text
|
|
* @returns {string|null} the JSON source, or null if no json fence is present.
|
|
*/
|
|
export function extractDigestBlock(text) {
|
|
return extractFindingsBlock(text);
|
|
}
|
|
|
|
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);
|
|
}
|