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:
Kjell Tore Guttormsen 2026-11-07 12:51:54 +01:00
commit 8295601965
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
158 changed files with 308 additions and 12856 deletions

View file

@ -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: {},
},

View file

@ -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));
}

View file

@ -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 };
}

View file

@ -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);
}

View file

@ -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

View file

@ -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

View file

@ -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);
}

View file

@ -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,})(.*)$/;
/**

View file

@ -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` };

View file

@ -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

View file

@ -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 };
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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*(?:```|~~~)(.*)$/;