chore(cleanup): remove the June trials, orchestrator docs, dormant agent, caller-less code and the fix-round chain
R4-R7 of the cleanup list (decision 188): the June trial docs and fixtures, the three orchestrator reference docs, the dormant synthesis-agent, code with no caller (project-discovery, bash-normalize, fidelity-diff, test-census, the next-session prompt validator, arm B and the --workflow flag) and the fix-round chain. Living docs repointed; the reasons for in-session orchestration and the all-opus pin are now written in docs/architecture.md and docs/profiles.md. New tests/lib/living-paths.test.mjs: living tracked text names only plugin paths that exist (seen red with 22 dead pointers before this commit). docs/T1-cc26-delegated-orchestration.md and lib/review/gold-scorer.mjs stay until R-b: the frozen end-state registry names them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
308b149e70
commit
8295601965
158 changed files with 308 additions and 12856 deletions
|
|
@ -36,7 +36,7 @@ const FLAG_SCHEMA = {
|
|||
aliases: {},
|
||||
},
|
||||
trekreview: {
|
||||
boolean: ['--quick', '--fg', '--dry-run', '--validate', '--workflow', '--answers'],
|
||||
boolean: ['--quick', '--fg', '--dry-run', '--validate', '--answers'],
|
||||
valued: ['--project', '--since', '--profile'],
|
||||
aliases: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
// lib/parsers/bash-normalize.mjs
|
||||
// Bash-evasion normalization, lifted from hooks/scripts/pre-bash-executor.mjs.
|
||||
//
|
||||
// Source: ../../hooks/scripts/pre-bash-executor.mjs (lines 22-45) — verbatim
|
||||
// extraction so the runtime hook and the test suite share one implementation.
|
||||
// The hook still inlines a copy because it cannot import from outside the
|
||||
// plugin distribution at this time; both copies must stay in sync.
|
||||
|
||||
/**
|
||||
* Strip bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
|
||||
* Used to canonicalize a command before running denylist regex over it.
|
||||
*/
|
||||
export function normalizeBashExpansion(cmd) {
|
||||
if (typeof cmd !== 'string' || cmd === '') return '';
|
||||
|
||||
let result = cmd
|
||||
.replace(/''/g, '')
|
||||
.replace(/""/g, '')
|
||||
.replace(/\$\{(\w)\}/g, '$1')
|
||||
.replace(/\$\{[^}]*\}/g, '')
|
||||
.replace(/`\s*`/g, '');
|
||||
|
||||
let prev;
|
||||
do {
|
||||
prev = result;
|
||||
result = result.replace(/(\w)\\(\w)/g, '$1$2');
|
||||
} while (result !== prev);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes and collapse whitespace.
|
||||
*/
|
||||
export function normalizeCommand(cmd) {
|
||||
if (typeof cmd !== 'string') return '';
|
||||
return cmd
|
||||
.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full canonicalization pipeline used by hooks before pattern matching.
|
||||
*/
|
||||
export function canonicalize(cmd) {
|
||||
return normalizeCommand(normalizeBashExpansion(cmd));
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
// lib/parsers/project-discovery.mjs
|
||||
// Discover ultra-suite artifacts inside a project directory.
|
||||
//
|
||||
// Layout (post-v3.0.0 project-directory contract):
|
||||
// .claude/projects/<YYYY-MM-DD>-<slug>/
|
||||
// brief.md
|
||||
// research/<NN>-<slug>.md (sorted by filename)
|
||||
// architecture/overview.md (opt-in, produced by an upstream architect plugin)
|
||||
// plan.md
|
||||
// progress.json
|
||||
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* projectDir: string,
|
||||
* brief: string|null,
|
||||
* research: string[],
|
||||
* architecture: { overview: string|null, gaps: string|null, looseFiles: string[] },
|
||||
* plan: string|null,
|
||||
* progress: string|null,
|
||||
* review: string|null,
|
||||
* }} ProjectArtifacts
|
||||
*/
|
||||
|
||||
/** @returns {ProjectArtifacts} */
|
||||
export function discoverProject(projectDir) {
|
||||
const out = {
|
||||
projectDir,
|
||||
brief: null,
|
||||
research: [],
|
||||
architecture: { overview: null, gaps: null, looseFiles: [] },
|
||||
plan: null,
|
||||
progress: null,
|
||||
review: null,
|
||||
};
|
||||
|
||||
if (!projectDir || !existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const briefPath = join(projectDir, 'brief.md');
|
||||
if (existsSync(briefPath) && statSync(briefPath).isFile()) out.brief = briefPath;
|
||||
|
||||
const planPath = join(projectDir, 'plan.md');
|
||||
if (existsSync(planPath) && statSync(planPath).isFile()) out.plan = planPath;
|
||||
|
||||
const progressPath = join(projectDir, 'progress.json');
|
||||
if (existsSync(progressPath) && statSync(progressPath).isFile()) out.progress = progressPath;
|
||||
|
||||
const reviewPath = join(projectDir, 'review.md');
|
||||
if (existsSync(reviewPath) && statSync(reviewPath).isFile()) out.review = reviewPath;
|
||||
|
||||
const researchDir = join(projectDir, 'research');
|
||||
if (existsSync(researchDir) && statSync(researchDir).isDirectory()) {
|
||||
out.research = readdirSync(researchDir)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.sort()
|
||||
.map(f => join(researchDir, f));
|
||||
}
|
||||
|
||||
const archDir = join(projectDir, 'architecture');
|
||||
if (existsSync(archDir) && statSync(archDir).isDirectory()) {
|
||||
const overviewPath = join(archDir, 'overview.md');
|
||||
const gapsPath = join(archDir, 'gaps.md');
|
||||
if (existsSync(overviewPath)) out.architecture.overview = overviewPath;
|
||||
if (existsSync(gapsPath)) out.architecture.gaps = gapsPath;
|
||||
const all = readdirSync(archDir).filter(f => f.endsWith('.md'));
|
||||
out.architecture.looseFiles = all
|
||||
.filter(f => f !== 'overview.md' && f !== 'gaps.md')
|
||||
.map(f => join(archDir, f));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that artifact set is consistent for a given pipeline phase.
|
||||
* Phase = 'brief' | 'research' | 'plan' | 'execute' | 'review'.
|
||||
*/
|
||||
export function checkPhaseRequirements(artifacts, phase) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
if (phase === 'research' && !artifacts.brief) {
|
||||
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'research phase requires brief.md' });
|
||||
}
|
||||
if (phase === 'plan' && !artifacts.brief) {
|
||||
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'plan phase requires brief.md' });
|
||||
}
|
||||
if (phase === 'execute' && !artifacts.plan) {
|
||||
errors.push({ code: 'PROJECT_NO_PLAN', message: 'execute phase requires plan.md' });
|
||||
}
|
||||
if (phase === 'review') {
|
||||
if (!artifacts.brief) {
|
||||
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'review phase requires brief.md' });
|
||||
}
|
||||
if (!artifacts.progress) {
|
||||
warnings.push({
|
||||
code: 'PROJECT_NO_PROGRESS',
|
||||
message: 'review phase: progress.json absent — scope detection will fall back to brief.md mtime',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings, parsed: artifacts };
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
|
|
@ -34,8 +34,7 @@
|
|||
//
|
||||
// Reuses: SEVERITY_VALUES / RULE_KEYS / getRule (rule-catalogue.mjs),
|
||||
// computeFindingIds (finding-id.mjs, triplet), validateFindings
|
||||
// (findings-schema.mjs). Triplet key format mirrors
|
||||
// scripts/bakeoff-armA-merge.mjs `dedupByTriplet`; raised_by provenance mirrors
|
||||
// (findings-schema.mjs). raised_by provenance mirrors
|
||||
// lib/review/plan-review-dedup.mjs.
|
||||
|
||||
import { SEVERITY_VALUES, RULE_KEYS, getRule } from './rule-catalogue.mjs';
|
||||
|
|
@ -122,8 +121,8 @@ export const UNVERIFIED_REASONS = Object.freeze([
|
|||
// is ever marked; only a `/code-review` finding at the MAJOR tier ever drops,
|
||||
// to MINOR, writing `brief_lowered_from: 'MAJOR'`. A BLOCKER and any
|
||||
// `/security-review` finding keep their severity and only carry the mark.
|
||||
// The verdict gate (verdict-gate.mjs) imports `illegalBriefLowering`; the
|
||||
// opt-in workflow review mirrors it. `original_severity` (what the kept finding
|
||||
// The verdict gate (verdict-gate.mjs) imports `illegalBriefLowering`.
|
||||
// `original_severity` (what the kept finding
|
||||
// itself asserted before Pass 1 raised it, review 08deae68) is never read here.
|
||||
|
||||
// The payload `reviewer` names lib/review/engine-findings.mjs writes, and the
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// lib/review/engine-compare.mjs
|
||||
// The standard-path put-back (fix round 4.5b2 part B, Step 9): on the prose
|
||||
// path, an engine finding at MAJOR or BLOCKER that the coordinator dropped is
|
||||
// put back at its own severity, the way the workflow path does
|
||||
// (scripts/trekreview-armB.workflow.mjs, operator decision 23).
|
||||
// put back at its own severity (operator decision 23).
|
||||
//
|
||||
// A finding is matched on its row: its (file, line, rule_key) triplet plus
|
||||
// its title, trimmed and in lower case. An engine finding's row is returned
|
||||
|
|
@ -17,13 +16,9 @@
|
|||
// Only a payload's `findings` are compared. An engine finding in
|
||||
// `out_of_scope` stays declined by the existing rule
|
||||
// (lib/review/coordinator-contract.mjs runContract, `engine:out-of-scope`).
|
||||
//
|
||||
// The row rule below is a lib copy of the armB workflow's (scripts cannot be
|
||||
// imported); tests/lib/engine-findings-compare.test.mjs pins both equal.
|
||||
|
||||
import { ENGINES } from './engine-findings.mjs';
|
||||
|
||||
// Copied from scripts/trekreview-armB.workflow.mjs:60-73 (rowOf, trimmedTitle).
|
||||
// The (file,line,rule_key) triplet a finding is deduped and matched on.
|
||||
const tripletOf = (f) => `${f.file}\u0000${f.line}\u0000${f.rule_key}`;
|
||||
// A finding's title as the grouping compares it: trimmed, lower case.
|
||||
|
|
@ -33,7 +28,7 @@ const normTitle = (f) => String(f.title).trim().toLowerCase();
|
|||
const rowOf = (f) => `${tripletOf(f)}\u0000${f.title === undefined ? '\u0001' : normTitle(f)}`;
|
||||
// The coordinator trims an over-long MINOR/SUGGESTION title as
|
||||
// lib/review/coordinator-contract.mjs judgeFilter does; its marker and limit
|
||||
// are inlined (scripts cannot import) and pinned equal by the workflow test.
|
||||
// are inlined.
|
||||
const TRIM_MARKER = ' […trimmed]';
|
||||
const JUDGE_TITLE_MAX = 100;
|
||||
// A title as that trim writes it: over JUDGE_TITLE_MAX characters, cut to
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
// lib/review/fidelity-diff.mjs
|
||||
// Fidelity comparison of two review.md artifacts — the PRIMARY metric of the
|
||||
// NW2 (S10) prose-vs-Workflow bake-off (T2 §5).
|
||||
//
|
||||
// A substrate swap (prose Arm A → Workflow Arm B) passes the gate only if it
|
||||
// produces a fidelity-equivalent review.md: SAME verdict and an equivalent
|
||||
// finding set (IDs / severities / rule_keys). This module computes that diff
|
||||
// from two rendered review.md texts, reusing the determinism-pipeline
|
||||
// primitives (jaccard over finding-IDs + frontmatter parse + the NW1 trailing-
|
||||
// block extractor).
|
||||
//
|
||||
// Pure JS, zero deps beyond existing lib modules — unit-testable without any
|
||||
// live LLM run.
|
||||
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { jaccardSimilarity } from '../parsers/jaccard.mjs';
|
||||
import { computeFindingIds } from '../parsers/finding-id.mjs';
|
||||
import { extractFindingsBlock } from './findings-schema.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const DEFAULT_JACCARD_TOLERANCE = 0.7;
|
||||
|
||||
/**
|
||||
* Parse a rendered review.md into its comparable shape.
|
||||
* - verdict + finding-ID list come from frontmatter (the validated contract).
|
||||
* - per-finding severity / rule_key / file / line come from the trailing JSON
|
||||
* block. Both the `rule_key` (real reviewer output) and `rule` (fixtures)
|
||||
* keys are accepted.
|
||||
* @param {string} text
|
||||
* @returns {{ verdict: string|null, findingIds: string[],
|
||||
* details: Array<{id, severity, rule_key, file, line}> }}
|
||||
*/
|
||||
export function parseReviewArtifact(text) {
|
||||
const doc = parseDocument(text);
|
||||
const fm = (doc.valid && doc.parsed && doc.parsed.frontmatter) || {};
|
||||
const verdict = typeof fm.verdict === 'string' ? fm.verdict : null;
|
||||
const findingIds = Array.isArray(fm.findings) ? fm.findings.filter((x) => typeof x === 'string') : [];
|
||||
|
||||
let details = [];
|
||||
const block = extractFindingsBlock(text);
|
||||
if (block !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(block);
|
||||
const arr = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.findings) ? parsed.findings : []);
|
||||
details = arr.map((f) => ({
|
||||
id: f.id ?? null,
|
||||
severity: f.severity ?? null,
|
||||
rule_key: f.rule_key ?? f.rule ?? null,
|
||||
file: f.file ?? null,
|
||||
line: f.line ?? null,
|
||||
}));
|
||||
} catch {
|
||||
details = [];
|
||||
}
|
||||
}
|
||||
|
||||
return { verdict, findingIds, details };
|
||||
}
|
||||
|
||||
function detailMap(details) {
|
||||
const m = new Map();
|
||||
for (const d of details) {
|
||||
if (d.id) m.set(d.id, d);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core comparison over two normalized artifacts: {verdict, findingIds, details}.
|
||||
* Shared by fidelityDiff (review.md text) and fidelityDiffStructured (arm output).
|
||||
*/
|
||||
function compareArtifacts(a, b, opts = {}) {
|
||||
const tol = typeof opts.jaccardTolerance === 'number' ? opts.jaccardTolerance : DEFAULT_JACCARD_TOLERANCE;
|
||||
const verdictMatch = a.verdict === b.verdict;
|
||||
const jaccard = jaccardSimilarity(a.findingIds, b.findingIds);
|
||||
|
||||
// Cross-check severity + rule_key on findings present in BOTH arms.
|
||||
const mapA = detailMap(a.details);
|
||||
const mapB = detailMap(b.details);
|
||||
const severityMismatches = [];
|
||||
const ruleKeyMismatches = [];
|
||||
for (const [id, da] of mapA) {
|
||||
const db = mapB.get(id);
|
||||
if (!db) continue;
|
||||
if (da.severity !== db.severity) severityMismatches.push({ id, a: da.severity, b: db.severity });
|
||||
if (da.rule_key !== db.rule_key) ruleKeyMismatches.push({ id, a: da.rule_key, b: db.rule_key });
|
||||
}
|
||||
|
||||
const equivalent =
|
||||
verdictMatch &&
|
||||
jaccard >= tol &&
|
||||
severityMismatches.length === 0 &&
|
||||
ruleKeyMismatches.length === 0;
|
||||
|
||||
return {
|
||||
verdictA: a.verdict,
|
||||
verdictB: b.verdict,
|
||||
verdictMatch,
|
||||
jaccard,
|
||||
countA: a.findingIds.length,
|
||||
countB: b.findingIds.length,
|
||||
severityMismatches,
|
||||
ruleKeyMismatches,
|
||||
equivalent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the fidelity diff between two rendered review.md artifacts.
|
||||
* @param {string} textA — baseline (Arm A — prose)
|
||||
* @param {string} textB — candidate (Arm B — Workflow)
|
||||
* @param {{ jaccardTolerance?: number }} [opts]
|
||||
*/
|
||||
export function fidelityDiff(textA, textB, opts = {}) {
|
||||
return compareArtifacts(parseReviewArtifact(textA), parseReviewArtifact(textB), opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a structured arm output ({verdict, findings:[{severity,rule_key,
|
||||
* file,line}]}) into the comparable shape, recomputing canonical finding-IDs
|
||||
* from the (file, line, rule_key) triplet. Findings missing file/rule_key are
|
||||
* dropped from the ID set (they cannot dedupe), but counted is by valid IDs.
|
||||
*/
|
||||
export function normalizeArmOutput(arm) {
|
||||
const verdict = arm && typeof arm.verdict === 'string' ? arm.verdict : null;
|
||||
const findings = (arm && Array.isArray(arm.findings)) ? arm.findings : [];
|
||||
const findingIds = [];
|
||||
const details = [];
|
||||
// The k-th row on one triplet gets the id over `rule_key#k`, as review.md does.
|
||||
const ids = computeFindingIds(findings.map((f) => ({ file: f.file, line: f.line, rule_key: f.rule_key ?? f.rule ?? null })), () => {});
|
||||
for (const [i, f] of findings.entries()) {
|
||||
const file = f.file;
|
||||
const rule_key = f.rule_key ?? f.rule ?? null;
|
||||
const line = f.line;
|
||||
const id = ids[i];
|
||||
if (id) findingIds.push(id);
|
||||
details.push({ id, severity: f.severity ?? null, rule_key, file: file ?? null, line: line ?? null });
|
||||
}
|
||||
return { verdict, findingIds, details };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fidelity diff between two structured arm outputs (the bake-off comparison —
|
||||
* avoids rendering review.md for each run).
|
||||
* @param {{verdict, findings}} armA
|
||||
* @param {{verdict, findings}} armB
|
||||
* @param {{ jaccardTolerance?: number }} [opts]
|
||||
*/
|
||||
export function fidelityDiffStructured(armA, armB, opts = {}) {
|
||||
return compareArtifacts(normalizeArmOutput(armA), normalizeArmOutput(armB), opts);
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
const files = args.filter((x) => !x.startsWith('--'));
|
||||
if (files.length !== 2) {
|
||||
process.stderr.write('Usage: fidelity-diff.mjs [--json] <review-A.md> <review-B.md>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const d = fidelityDiff(readFileSync(files[0], 'utf-8'), readFileSync(files[1], 'utf-8'));
|
||||
if (args.includes('--json')) {
|
||||
process.stdout.write(JSON.stringify(d, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`fidelity-diff: ${d.equivalent ? 'EQUIVALENT' : 'DIVERGENT'}\n`);
|
||||
process.stdout.write(` verdict: ${d.verdictA} vs ${d.verdictB} (match=${d.verdictMatch})\n`);
|
||||
process.stdout.write(` jaccard: ${d.jaccard.toFixed(4)} (findings ${d.countA} vs ${d.countB})\n`);
|
||||
process.stdout.write(` severity mismatches: ${d.severityMismatches.length}; rule_key mismatches: ${d.ruleKeyMismatches.length}\n`);
|
||||
}
|
||||
process.exit(d.equivalent ? 0 : 1);
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ export const FINDING_REQUIRED_FIELDS = Object.freeze([
|
|||
// `info` is the info string as written, trailing blanks dropped; `body` is the
|
||||
// text between the fence lines, as written.
|
||||
// One reader for the verdict gate and every trailing-json reader: the review
|
||||
// validator, the synthesis digest, fill-finding-ids, recurrence, fidelity-diff.
|
||||
// validator, fill-finding-ids, recurrence.
|
||||
const FENCE_INFO = /^[ \t]*(?:`{3,}|~{3,})(.*)$/;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
// `MAX_REVIEW_ROUNDS` is the one cap: /trekreview refuses a fourth round, and
|
||||
// /trekexecute's review loop reads the same constant (`--cap`).
|
||||
//
|
||||
// prepare({projectDir, since, repo, workflow, dryRun}): the round of a run.
|
||||
// prepare({projectDir, since, repo, dryRun}): the round of a run.
|
||||
// `<run>` is the first 12 hex of `since` (the run's `scope_sha_start`).
|
||||
// - Same run (review.md exists and its `scope_sha_start` equals `since`): a
|
||||
// later round. An archive `review-round-<run>-<n>.md` is review.md's own
|
||||
|
|
@ -20,9 +20,7 @@
|
|||
// derived, never read from the `round` field alone: a `round` field that
|
||||
// disagrees is exit 2. The new round is P + 1. An archive that is a
|
||||
// regular file but cannot be read is exit 2 (not measured).
|
||||
// - Round MAX_REVIEW_ROUNDS + 1 is refused (exit 1); so is round 2 or later
|
||||
// with `workflow` (exit 1): the --workflow path neither hands the reviewers
|
||||
// the round input nor writes their outputs.
|
||||
// - Round MAX_REVIEW_ROUNDS + 1 is refused (exit 1).
|
||||
// - The round base is the previous `scope_sha_end`: a full sha that is an
|
||||
// ancestor of HEAD, else exit 2 (not measured).
|
||||
// - review.md is copied to `review-round-<run>-<P>.md`; an existing file
|
||||
|
|
@ -59,9 +57,9 @@
|
|||
//
|
||||
// CLI:
|
||||
// node lib/review/review-rounds.mjs prepare --project <d> --since <sha>
|
||||
// [--repo <d>] [--workflow] [--dry-run] --json
|
||||
// [--repo <d>] [--dry-run] --json
|
||||
// prints one JSON line {round, round_base, previous, run} or {reason};
|
||||
// exit 0 = the round, 1 = refused (the cap, or round 2+ on --workflow),
|
||||
// exit 0 = the round, 1 = refused (the cap),
|
||||
// 2 = not measured (2 is never green)
|
||||
// node lib/review/review-rounds.mjs --cap prints MAX_REVIEW_ROUNDS
|
||||
|
||||
|
|
@ -128,7 +126,7 @@ const notMeasured = (reason, detail = '') => ({ exit: 2, reason: `${reason} —
|
|||
* The round of a run, with its archive and clean slate (see the header).
|
||||
* @returns {{exit: 0, round: number, round_base: string|null, previous: string|null, run: string, archived?: boolean, deleted: string[]} | {exit: 1|2, reason: string}}
|
||||
*/
|
||||
export function prepare({ projectDir, since, repo, workflow = false, dryRun = false } = {}) {
|
||||
export function prepare({ projectDir, since, repo, dryRun = false } = {}) {
|
||||
if (!projectDir) return { exit: 2, reason: '--project is required' };
|
||||
if (!FULL_SHA_RE.test(String(since ?? ''))) return notMeasured('--since must be a full commit id');
|
||||
try {
|
||||
|
|
@ -181,9 +179,6 @@ export function prepare({ projectDir, since, repo, workflow = false, dryRun = fa
|
|||
if (round > MAX_REVIEW_ROUNDS) {
|
||||
return { exit: 1, reason: `review round cap reached (${MAX_REVIEW_ROUNDS}) — the operator re-plans` };
|
||||
}
|
||||
if (workflow) {
|
||||
return { exit: 1, reason: 'from round 2 /trekreview runs on the standard path — re-run without --workflow' };
|
||||
}
|
||||
const base = ownEnd;
|
||||
if (!base || !isAncestorOfHead(repo ?? projectDir, base)) {
|
||||
return notMeasured('round base is not an ancestor of HEAD');
|
||||
|
|
@ -345,13 +340,13 @@ if (isMain(import.meta.url)) {
|
|||
}
|
||||
let result;
|
||||
if (args[0] !== 'prepare') {
|
||||
process.stderr.write('Usage: review-rounds.mjs prepare --project <d> --since <sha> [--repo <d>] [--workflow] [--dry-run] --json | --cap\n');
|
||||
process.stderr.write('Usage: review-rounds.mjs prepare --project <d> --since <sha> [--repo <d>] [--dry-run] --json | --cap\n');
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
result = prepare({
|
||||
projectDir: opt(args, '--project'), since: opt(args, '--since'), repo: opt(args, '--repo'),
|
||||
workflow: args.includes('--workflow'), dryRun: args.includes('--dry-run'),
|
||||
dryRun: args.includes('--dry-run'),
|
||||
});
|
||||
} catch (e) {
|
||||
result = { exit: 2, reason: `review-rounds crashed: ${e.message} — not measured` };
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
// when several callers decide at once instead of only when they queue.
|
||||
// (c) Correct size bound — worst case is max_conv_turns × max_total_dimensions,
|
||||
// where max_total_dimensions is the WHOLE list (interview + discovered)
|
||||
// under settings.json:16's cap of 8 — not × discovered-only.
|
||||
// under the maxDimensions cap of 8 — not × discovered-only.
|
||||
//
|
||||
// CLAUDE_PLUGIN_DATA absent => fall back to ~/.claude/voyage. The variable is
|
||||
// EMPTY in the Bash tool's process env (measured in a live plugin-enabled
|
||||
|
|
@ -45,7 +45,7 @@ import { homedir } from 'node:os';
|
|||
import { isMain } from './is-main.mjs';
|
||||
|
||||
export const MAX_CONV_TURNS = 3;
|
||||
export const MAX_TOTAL_DIMENSIONS = 8; // settings.json:16 maxDimensions — whole list, not discovered-only
|
||||
export const MAX_TOTAL_DIMENSIONS = 8; // maxDimensions — whole list, not discovered-only
|
||||
|
||||
const LEDGER_FILENAME = 'trekresearch-loop-ledger.jsonl';
|
||||
const CLAIM_DIRNAME = 'trekresearch-loop-claims';
|
||||
|
|
@ -65,7 +65,7 @@ export function isStormEnabled(env = process.env) {
|
|||
* warned about. This is the reader.
|
||||
*
|
||||
* The ceiling is MAX_TOTAL_DIMENSIONS on purpose: the value that sizes the turn
|
||||
* budget IS settings.json:16's maxDimensions, and a second constant for the same
|
||||
* budget IS maxDimensions, and a second constant for the same
|
||||
* number is how two readers end up enforcing different bounds.
|
||||
*
|
||||
* Accepts the dimension list or its count, because Phase 4.5 has the list and
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
// lib/util/test-census.mjs
|
||||
// Census of the test suite: split top-level test() declarations into three
|
||||
// honest categories:
|
||||
// - "behavior" — ordinary behavior coverage (the default bucket).
|
||||
// - "docPins" — doc-consistency pins (string/existence assertions that pin
|
||||
// documentation against a source-of-truth).
|
||||
// - "goldEval" — the offline gold-scored output eval scoring RUN (SKAL-1·4b):
|
||||
// neither behavior nor prose-pin, but a run that scores a
|
||||
// committed agent-run fixture against the golden corpus.
|
||||
// Makes the cited test count honest — a prose-pin is not the same coverage as a
|
||||
// behavior test, and a scoring run is a third thing again, so a single
|
||||
// conflated total oversells behavior coverage.
|
||||
// Devil's-advocate audit §Top changes #8 (S19); third bucket added in SKAL-1·4b.
|
||||
//
|
||||
// Metric: top-level `test(` declarations (static, deterministic, in-process).
|
||||
// This is distinct from node:test's runtime total, which additionally counts
|
||||
// subtests; the runtime total is therefore ≥ this declaration count.
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Files whose tests are documentation pins, not behavior coverage. Kept as a
|
||||
// regex (not a single filename) so a future split-out (e.g. prose-pins.test.mjs)
|
||||
// is bucketed correctly without editing this module.
|
||||
export const PIN_FILE_RE = /(doc-consistency|prose-pins)\.test\.mjs$/;
|
||||
|
||||
// Files whose tests are the offline gold-scored output eval scoring run
|
||||
// (SKAL-1·4b). Kept as a regex (not a single filename) so a future split-out
|
||||
// is bucketed correctly without editing this module.
|
||||
export const GOLD_EVAL_FILE_RE = /gold-eval\.test\.mjs$/;
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...walk(p));
|
||||
else if (e.isFile() && e.name.endsWith('.test.mjs')) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function countTests(file) {
|
||||
return (readFileSync(file, 'utf-8').match(/^\s*test\(/gm) || []).length;
|
||||
}
|
||||
|
||||
// Returns { behavior, docPins, goldEval, total, byFile } for all *.test.mjs
|
||||
// under testsRoot. behavior + docPins + goldEval === total by construction.
|
||||
export function censusTests(testsRoot) {
|
||||
const files = walk(testsRoot).sort();
|
||||
const byFile = {};
|
||||
let behavior = 0;
|
||||
let docPins = 0;
|
||||
let goldEval = 0;
|
||||
for (const f of files) {
|
||||
const n = countTests(f);
|
||||
byFile[f] = n;
|
||||
if (PIN_FILE_RE.test(f)) docPins += n;
|
||||
else if (GOLD_EVAL_FILE_RE.test(f)) goldEval += n;
|
||||
else behavior += n;
|
||||
}
|
||||
return { behavior, docPins, goldEval, total: behavior + docPins + goldEval, byFile };
|
||||
}
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
// lib/validators/next-session-prompt-validator.mjs
|
||||
// Validate NEXT-SESSION-PROMPT.local.md frontmatter (Bug 3 contract).
|
||||
//
|
||||
// Producers (trekexecute Phase 8/2.55/4, trekendsession Phase 3) MUST write
|
||||
// `produced_by:` and `produced_at:` (ISO-8601) frontmatter.
|
||||
// Consumers (/trekcontinue Phase 1.5) compare two candidate files and refuse
|
||||
// when producers disagree on a non-stale pair.
|
||||
//
|
||||
// Schema is forward-compatible: unknown frontmatter keys are tolerated.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { issue, fail } from '../util/result.mjs';
|
||||
import { splitFrontmatter, parseFrontmatter } from '../util/frontmatter.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const NEXT_SESSION_PROMPT_REQUIRED_FIELDS = Object.freeze(['produced_by', 'produced_at']);
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function validateNextSessionPromptContent(text) {
|
||||
const split = splitFrontmatter(text);
|
||||
if (!split.hasFrontmatter) {
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [issue(
|
||||
'NEXT_SESSION_PROMPT_NO_FRONTMATTER',
|
||||
'NEXT-SESSION-PROMPT.local.md has no YAML frontmatter',
|
||||
'Producers should write produced_by and produced_at; legacy files are tolerated.',
|
||||
)],
|
||||
parsed: null,
|
||||
};
|
||||
}
|
||||
const fm = parseFrontmatter(split.frontmatter);
|
||||
if (!fm.valid) {
|
||||
return { valid: false, errors: fm.errors, warnings: [], parsed: fm.parsed || null };
|
||||
}
|
||||
return validateNextSessionPromptObject(fm.parsed);
|
||||
}
|
||||
|
||||
export function validateNextSessionPromptObject(parsed) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return fail(issue('NEXT_SESSION_PROMPT_NOT_OBJECT', 'Frontmatter is not an object'));
|
||||
}
|
||||
|
||||
for (const k of NEXT_SESSION_PROMPT_REQUIRED_FIELDS) {
|
||||
if (!(k in parsed)) {
|
||||
errors.push(issue(
|
||||
'NEXT_SESSION_PROMPT_MISSING_FIELD',
|
||||
`Required frontmatter field missing: ${k}`,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.produced_at !== undefined) {
|
||||
if (typeof parsed.produced_at !== 'string' || Number.isNaN(Date.parse(parsed.produced_at))) {
|
||||
errors.push(issue(
|
||||
'NEXT_SESSION_PROMPT_INVALID_TIMESTAMP',
|
||||
`produced_at "${parsed.produced_at}" is not a valid ISO-8601 timestamp`,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.produced_by !== undefined) {
|
||||
if (typeof parsed.produced_by !== 'string' || parsed.produced_by.length === 0) {
|
||||
errors.push(issue(
|
||||
'NEXT_SESSION_PROMPT_INVALID_PRODUCER',
|
||||
'produced_by must be a non-empty string',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed };
|
||||
}
|
||||
|
||||
export function validateNextSessionPrompt(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return fail(issue('NEXT_SESSION_PROMPT_NOT_FOUND', `File not found: ${filePath}`));
|
||||
}
|
||||
let text;
|
||||
try { text = readFileSync(filePath, 'utf-8'); }
|
||||
catch (e) {
|
||||
return fail(issue('NEXT_SESSION_PROMPT_READ_ERROR', `Cannot read ${filePath}: ${e.message}`));
|
||||
}
|
||||
return validateNextSessionPromptContent(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two NEXT-SESSION-PROMPT files for consistency.
|
||||
* Optional state object enables state-anchored staleness check.
|
||||
*
|
||||
* @param {{path:string, parsed:object|null}} a
|
||||
* @param {{path:string, parsed:object|null}} b
|
||||
* @param {{state?: {updated_at?: string}, now?: number}} opts
|
||||
*/
|
||||
export function validateNextSessionPromptConsistency(a, b, opts = {}) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
||||
const stateUpdatedAt = opts.state && opts.state.updated_at
|
||||
? Date.parse(opts.state.updated_at)
|
||||
: NaN;
|
||||
|
||||
const stale = (cand) => {
|
||||
if (!cand || !cand.parsed || !cand.parsed.produced_at) return false;
|
||||
if (Number.isNaN(stateUpdatedAt)) return false;
|
||||
const t = Date.parse(cand.parsed.produced_at);
|
||||
if (Number.isNaN(t)) return false;
|
||||
return t < stateUpdatedAt;
|
||||
};
|
||||
|
||||
const aStale = stale(a);
|
||||
const bStale = stale(b);
|
||||
const aFm = a && a.parsed;
|
||||
const bFm = b && b.parsed;
|
||||
|
||||
if (aFm && bFm) {
|
||||
const producerMismatch = aFm.produced_by !== bFm.produced_by;
|
||||
const bothFresh = !aStale && !bStale;
|
||||
if (producerMismatch && bothFresh) {
|
||||
errors.push(issue(
|
||||
'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH',
|
||||
`Frontmatter "produced_by" disagrees: "${aFm.produced_by}" (${a.path}) vs "${bFm.produced_by}" (${b.path})`,
|
||||
'One file is stale or producers wrote conflicting frontmatter. Resolve manually.',
|
||||
));
|
||||
} else if (producerMismatch && (aStale || bStale)) {
|
||||
const fresh = aStale ? b : a;
|
||||
warnings.push(issue(
|
||||
'NEXT_SESSION_PROMPT_STALE_IGNORED',
|
||||
`Stale candidate ignored; using fresher prompt from ${fresh.path}`,
|
||||
));
|
||||
}
|
||||
|
||||
for (const cand of [a, b]) {
|
||||
if (!cand || !cand.parsed || !cand.parsed.produced_at) continue;
|
||||
const t = Date.parse(cand.parsed.produced_at);
|
||||
if (Number.isNaN(t)) continue;
|
||||
if (now - t > ONE_DAY_MS) {
|
||||
warnings.push(issue(
|
||||
'NEXT_SESSION_PROMPT_WALL_CLOCK_DRIFT',
|
||||
`${cand.path} produced_at is more than 24h old (${cand.parsed.produced_at})`,
|
||||
'Soft warning only. Resuming after a long pause is fine; verify state is still relevant.',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed: { a: aFm || null, b: bFm || null } };
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
// The valued flag (--state-file) consumes its value before positionals are
|
||||
// collected, so the state file is never counted as a third path.
|
||||
const positionals = [];
|
||||
let wantJson = false;
|
||||
let consistency = false;
|
||||
let stateFile = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--state-file') {
|
||||
const v = args[++i];
|
||||
if (v === undefined || v.startsWith('--')) {
|
||||
process.stderr.write('next-session-prompt-validator: --state-file needs a value\n');
|
||||
process.exit(2);
|
||||
}
|
||||
stateFile = v;
|
||||
} else if (a === '--json') wantJson = true;
|
||||
else if (a === '--consistency') consistency = true;
|
||||
else if (!a.startsWith('--')) positionals.push(a);
|
||||
}
|
||||
|
||||
function emit(r) {
|
||||
if (wantJson) {
|
||||
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`next-session-prompt-validator: ${r.valid ? 'PASS' : 'FAIL'}\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);
|
||||
}
|
||||
|
||||
if (consistency) {
|
||||
const fileArgs = positionals;
|
||||
if (fileArgs.length !== 2) {
|
||||
process.stderr.write('Usage: next-session-prompt-validator.mjs --json --consistency <path-a> <path-b> [--state-file <state.json>]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const [pathA, pathB] = fileArgs;
|
||||
const ra = validateNextSessionPrompt(pathA);
|
||||
const rb = validateNextSessionPrompt(pathB);
|
||||
let stateObj = null;
|
||||
if (stateFile) {
|
||||
try {
|
||||
const txt = readFileSync(stateFile, 'utf-8');
|
||||
stateObj = JSON.parse(txt);
|
||||
} catch (_e) {
|
||||
stateObj = null;
|
||||
}
|
||||
}
|
||||
const r = validateNextSessionPromptConsistency(
|
||||
{ path: pathA, parsed: ra.parsed },
|
||||
{ path: pathB, parsed: rb.parsed },
|
||||
{ state: stateObj },
|
||||
);
|
||||
emit({
|
||||
// Valid only when the pair is consistent AND A and B are each valid.
|
||||
valid: r.valid && ra.valid !== false && rb.valid !== false,
|
||||
errors: [...(ra.errors || []), ...(rb.errors || []), ...r.errors],
|
||||
warnings: [...(ra.warnings || []), ...(rb.warnings || []), ...r.warnings],
|
||||
});
|
||||
} else {
|
||||
if (positionals.length !== 1) {
|
||||
process.stderr.write('Usage: next-session-prompt-validator.mjs [--json] <NEXT-SESSION-PROMPT.local.md>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const r = validateNextSessionPrompt(positionals[0]);
|
||||
emit(r);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// lib/validators/plan-validator.mjs
|
||||
// Wraps plan-schema (heading shape) + manifest-yaml (per-step Manifest blocks).
|
||||
// This is the JS equivalent of Phase 5.5 grep checks in planning-orchestrator.
|
||||
// This is the JS equivalent of the plan's Phase 5.5 grep checks.
|
||||
//
|
||||
// Schema is forward-compatible: unknown top-level frontmatter keys are
|
||||
// tolerated silently. Strict-key checks are intentionally avoided so new
|
||||
|
|
|
|||
|
|
@ -69,9 +69,8 @@ const MAX_BUFFER = 64 * 1024 * 1024;
|
|||
// Fenced blocks are read TWICE over, for two opposite reasons: a `## ` heading
|
||||
// inside a fence is quoted text and must not open a section (the repo's own
|
||||
// examples/02-real-cli/REGENERATED.md quotes a whole plan outline inside one),
|
||||
// while a shell-tagged fence INSIDE the section holds the commands themselves
|
||||
// (examples/01-add-verbose-flag/plan.md writes its whole acceptance run that
|
||||
// way). Reading only bullets made a correct plan parse to zero criteria, exit
|
||||
// while a shell-tagged fence INSIDE the section holds the commands themselves.
|
||||
// Reading only bullets made a correct plan parse to zero criteria, exit
|
||||
// 1, and fell every single-session run — measured 2026-09-18.
|
||||
const FENCE = /^\s*(?:```|~~~)(.*)$/;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue