Measured 2026-09-18 (PM checkpoint on6cafb4c, mutant M6): a two-line file export function runPlanVerification() { return { ok: true }; } export function formatCriteriaEvidence() { return ''; } dropped into an otherwise UNFIXED tree closed both D-03 and D-04, and the gate reported "defects 0 of 7, registry integrity: intact". The same checkpoint felled 5 of 5 mutants with the SUITE and 0 of 5 with the GATE. Both probes called themselves `behaviour` while asking a grep whether an export name was present - a phrase probe pointed at code. A check condition may now name a test instead of a pattern: { "test": "tests/lib/criteria-runner.test.mjs", "name": "<exact test name>", "expect": "fails" } The gate spawns the test runner on that one test and reads its TAP line. `expect: "fails"` holds - the defect stays open - while the test does not pass. A missing file, a name that matches nothing, a runner that will not start: all throw, which counts as NOT FELLABLE and therefore open. A check that cannot fire is never "fixed". The child's `NODE_TEST_*` env is stripped. The gate usually runs UNDER the test runner, and an inherited `NODE_TEST_CONTEXT` makes the grandchild report over the parent's IPC channel instead of stdout - the TAP line would never arrive and the probe would silently stop felling anything. M6 is now a permanent mutant in the gate's own test, so the cosmetic close cannot come back. Verified end to end against a real stub tree: git archive8d1669e| tar -x -C $TMP cp scripts/end-state-gate.mjs scripts/end-state-registry.json $TMP/scripts/ cp tests/fixtures/end-state-frozen.json $TMP/tests/fixtures/; cp STATE.md $TMP/ printf 'export function runPlanVerification…' > $TMP/lib/cosmetic/stub.mjs node scripts/end-state-gate.mjs --root $TMP -> defects 2 of 7, both NOT FELLABLE, exit 1 The gate also now states the limit out loud, as its own line above the table: a behaviour probe proves the capability WORKS; that Phase 7 and Phase 4.5 CALL it is pinned by TEXT in doc-consistency, not proven deterministically - real proof is a headless plugin-eval run against a fixture plan (week 40). FROZEN DENOMINATOR AMENDED, second time in one day, by the work order that carries this change: D-03 and D-04 signatures moved because their checks changed on purpose. tests/fixtures/end-state-frozen.json and the FROZEN literal in tests/scripts/end-state-gate.test.mjs are updated together, both dated, both saying why. No entry was removed. Gate after this change: defects 0 of 7, registry integrity intact, exit 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
496 lines
22 KiB
JavaScript
496 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/end-state-gate.mjs
|
|
// End-state gate — counts the distance to "Voyage is finished" as five tallies:
|
|
//
|
|
// defects open pipeline defects (listed in the registry, each with a runnable check)
|
|
// experiments unresolved experiments/opt-ins (listed in the registry, each with a runnable check)
|
|
// dormant-agents spawnable agents no command spawns (measured structurally, see spawnedNames)
|
|
// decisions open operator decisions (counted in STATE.md by a fixed marker)
|
|
// feat-after-freeze feat commits after the freeze tag (measured from git)
|
|
//
|
|
// "Finished" = all five at 0 AND the registry is intact against its frozen denominator
|
|
// (tests/fixtures/end-state-frozen.json: an entry may close, never disappear or change its
|
|
// check). Exit 0 = green, 1 = red (a tally > 0, a tally not measurable, or the registry
|
|
// tampered with), 2 = usage or registry error.
|
|
//
|
|
// A registry entry is OPEN while ALL of its conditions hold. An entry whose check is null,
|
|
// or whose check cannot run (missing file, empty glob, missing section), is NOT FELLABLE and
|
|
// is counted as open — a check that cannot fire must never read as "fixed".
|
|
//
|
|
// Usage: node scripts/end-state-gate.mjs [--json] [--root <dir>]
|
|
|
|
import { readFileSync, existsSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
|
import { join, resolve, dirname, relative, sep, basename } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createHash } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
export const REGISTRY_PATH = 'scripts/end-state-registry.json';
|
|
export const FROZEN_PATH = 'tests/fixtures/end-state-frozen.json';
|
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
const toPosix = (p) => p.split(sep).join('/');
|
|
|
|
function walk(dir) {
|
|
const out = [];
|
|
for (const name of readdirSync(dir)) {
|
|
const p = join(dir, name);
|
|
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
else out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Supported path forms: an exact file, `dir/*.ext`, `dir/**/*.ext`.
|
|
export function expandPath(root, pattern) {
|
|
if (!pattern.includes('*')) {
|
|
const abs = join(root, pattern);
|
|
if (!existsSync(abs)) throw new Error(`path not found: ${pattern}`);
|
|
return [abs];
|
|
}
|
|
const m = pattern.match(/^(.+?)\/(\*\*\/)?\*(\.[A-Za-z0-9]+)$/);
|
|
if (!m) throw new Error(`unsupported glob: ${pattern}`);
|
|
const [, base, deep, ext] = m;
|
|
const absBase = join(root, base);
|
|
if (!existsSync(absBase) || !statSync(absBase).isDirectory()) {
|
|
throw new Error(`glob base not found: ${pattern}`);
|
|
}
|
|
const files = deep
|
|
? walk(absBase)
|
|
: readdirSync(absBase).map((f) => join(absBase, f)).filter((f) => statSync(f).isFile());
|
|
const hits = files.filter((f) => f.endsWith(ext)).sort();
|
|
if (hits.length === 0) throw new Error(`glob matched no files: ${pattern}`);
|
|
return hits;
|
|
}
|
|
|
|
// The text from the first line starting with `heading` up to the next `## ` heading.
|
|
function sectionOf(text, heading, label) {
|
|
const lines = text.split('\n');
|
|
const start = lines.findIndex((l) => l.startsWith(heading));
|
|
if (start === -1) throw new Error(`section "${heading}" not found in ${label}`);
|
|
let end = lines.length;
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
if (lines[i].startsWith('## ')) { end = i; break; }
|
|
}
|
|
return lines.slice(start, end).join('\n');
|
|
}
|
|
|
|
export const TEST_TIMEOUT_MS = 120_000;
|
|
|
|
const escapeRegExp = (t) => String(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
/**
|
|
* A condition that RUNS a named test and reads its result.
|
|
*
|
|
* A grep condition can only ask whether a string is present, so a "behaviour"
|
|
* probe built from one is a phrase probe pointed at code: measured 2026-09-18,
|
|
* a two-line file exporting `runPlanVerification` and `formatCriteriaEvidence`
|
|
* closed D-03 and D-04 on a tree where nothing was fixed. This spawns the test
|
|
* runner on one named test and reads the TAP line for it.
|
|
*
|
|
* `expect: "passes"` holds when the named test passes; `"fails"` holds when it
|
|
* does not. A test file that is missing, a name that matches nothing, a runner
|
|
* that cannot start: all THROW, which the caller counts as NOT FELLABLE and
|
|
* therefore open. A check that cannot fire is never "fixed".
|
|
*/
|
|
export function evaluateTestCondition(root, cond) {
|
|
if (cond.expect !== 'passes' && cond.expect !== 'fails') {
|
|
throw new Error(`a test condition's expect must be "passes" or "fails", got ${JSON.stringify(cond.expect)}`);
|
|
}
|
|
if (typeof cond.name !== 'string' || cond.name.trim() === '') {
|
|
throw new Error('a test condition must name exactly one test');
|
|
}
|
|
const file = join(root, cond.test);
|
|
if (!existsSync(file)) throw new Error(`test file not found: ${cond.test}`);
|
|
// The gate itself often runs UNDER the test runner (the gate's own test
|
|
// spawns it). `NODE_TEST_CONTEXT` inherited into this child makes it report
|
|
// over the parent's IPC channel instead of stdout, and the TAP line would
|
|
// never arrive — a probe that silently stops fellling anything.
|
|
const env = { ...process.env };
|
|
for (const key of Object.keys(env)) {
|
|
if (key.startsWith('NODE_TEST_')) delete env[key];
|
|
}
|
|
const r = spawnSync(
|
|
process.execPath,
|
|
['--test', '--test-reporter=tap', `--test-name-pattern=^${escapeRegExp(cond.name)}$`, file],
|
|
{ cwd: root, encoding: 'utf8', timeout: TEST_TIMEOUT_MS, env },
|
|
);
|
|
if (r.error) throw new Error(`test runner did not run ${cond.test}: ${r.error.message}`);
|
|
// The file itself reports as a subtest too, so the summary counters are
|
|
// ambiguous; the TAP line carrying the exact name is not. TAP escapes `#`
|
|
// and `\`, so unescape before comparing.
|
|
let passed = null;
|
|
for (const line of String(r.stdout ?? '').split('\n')) {
|
|
const m = line.match(/^\s*(not )?ok \d+ - (.*)$/);
|
|
if (!m) continue;
|
|
if (m[2].replace(/\\(.)/g, '$1').trim() !== cond.name) continue;
|
|
passed = m[1] === undefined;
|
|
break;
|
|
}
|
|
if (passed === null) throw new Error(`no test named "${cond.name}" ran in ${cond.test}`);
|
|
return cond.expect === 'passes' ? passed : !passed;
|
|
}
|
|
|
|
export function evaluateCondition(root, cond) {
|
|
if (cond.test !== undefined) return evaluateTestCondition(root, cond);
|
|
if (cond.expect !== 'match' && cond.expect !== 'no-match') {
|
|
throw new Error(`expect must be "match" or "no-match", got ${JSON.stringify(cond.expect)}`);
|
|
}
|
|
if (String(cond.flags ?? '').includes('g')) throw new Error('the g flag is not allowed');
|
|
const paths = Array.isArray(cond.path) ? cond.path : [cond.path];
|
|
const files = paths.flatMap((p) => expandPath(root, p));
|
|
const anyMatch = files.some((f) => {
|
|
let text = readFileSync(f, 'utf8');
|
|
if (cond.section) text = sectionOf(text, cond.section, toPosix(relative(root, f)));
|
|
return new RegExp(cond.pattern, cond.flags ?? '').test(text);
|
|
});
|
|
return cond.expect === 'match' ? anyMatch : !anyMatch;
|
|
}
|
|
|
|
export function evaluateCheck(root, check) {
|
|
if (check === null || check === undefined) {
|
|
return { status: 'not-fellable', detail: 'no runnable check' };
|
|
}
|
|
if (!Array.isArray(check) || check.length === 0) {
|
|
return { status: 'not-fellable', detail: 'check must be a non-empty list of conditions' };
|
|
}
|
|
try {
|
|
// Evaluate every condition (no short-circuit) so a broken one is always reported.
|
|
const results = check.map((c) => evaluateCondition(root, c));
|
|
return { status: results.every(Boolean) ? 'open' : 'closed', detail: '' };
|
|
} catch (err) {
|
|
return { status: 'not-fellable', detail: err.message };
|
|
}
|
|
}
|
|
|
|
// --- the frozen denominator -------------------------------------------------
|
|
|
|
// JSON with object keys sorted, so a signature does not depend on key order.
|
|
function canonical(value) {
|
|
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
if (value && typeof value === 'object') {
|
|
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
}
|
|
return JSON.stringify(value ?? null);
|
|
}
|
|
|
|
export function checkSignature(check) {
|
|
return createHash('sha256').update(canonical(check)).digest('hex');
|
|
}
|
|
|
|
export function loadFrozen(root) {
|
|
return JSON.parse(readFileSync(join(root, FROZEN_PATH), 'utf8'));
|
|
}
|
|
|
|
// The judged party must not own the ledger: every frozen entry must still be present with
|
|
// the same check, and the counting configuration must not move. New entries are allowed.
|
|
export function verifyIntegrity(registry, frozen) {
|
|
const violations = [];
|
|
for (const kind of ['defects', 'experiments']) {
|
|
const byId = new Map();
|
|
for (const e of registry[kind] || []) {
|
|
if (byId.has(e.id)) violations.push(`${kind}: duplicate id ${e.id}`);
|
|
byId.set(e.id, e);
|
|
}
|
|
for (const [id, sig] of Object.entries(frozen[kind] || {})) {
|
|
const e = byId.get(id);
|
|
if (!e) {
|
|
violations.push(`${kind}: ${id} is frozen but missing from the registry (an entry may close, never disappear)`);
|
|
} else if (checkSignature(e.check) !== sig) {
|
|
violations.push(`${kind}: ${id} has a changed check (signature ${checkSignature(e.check).slice(0, 12)}, frozen ${sig.slice(0, 12)})`);
|
|
}
|
|
}
|
|
}
|
|
for (const key of ['agents', 'decisions', 'freeze']) {
|
|
if (canonical(registry[key]) !== canonical(frozen[key])) {
|
|
violations.push(`${key}: configuration differs from the frozen manifest`);
|
|
}
|
|
}
|
|
const sites = [].concat(registry.agents?.spawnSites ?? []);
|
|
if (sites.some((s) => /(^|\/)agents\//.test(String(s)))) {
|
|
violations.push('agents: spawnSites may never include agents/ (an agent file would reference itself)');
|
|
}
|
|
return { ok: violations.length === 0, violations };
|
|
}
|
|
|
|
// --- tallies ----------------------------------------------------------------
|
|
|
|
const notMeasured = (detail) => ({ open: null, total: null, items: [], detail });
|
|
|
|
// What each probe kind can and cannot prove. An entry declares its kind; the
|
|
// gate states the limit out loud, because a closed check is only ever as strong
|
|
// as the thing it reads. Every kind a registry entry may declare belongs here —
|
|
// the entry-shape test rejects a kind the gate cannot explain.
|
|
// What no probe in this file can prove. /trekexecute and /trekreview are
|
|
// markdown a model interprets, so no unit test can observe that Phase 7 or
|
|
// Phase 4.5 was performed. The gate says this out loud rather than letting a
|
|
// green defect row imply it.
|
|
export const WIRING_NOTE =
|
|
'wiring: a behaviour probe proves the capability WORKS. That /trekexecute Phase 7 and ' +
|
|
'/trekreview Phase 4.5 actually CALL it is pinned by TEXT in tests/lib/doc-consistency.test.mjs, ' +
|
|
'not proven deterministically - real proof is a headless plugin-eval run against a fixture plan (week 40).';
|
|
|
|
export const PROBE_NOTES = {
|
|
phrase:
|
|
'a phrase probe closes on rewording: a closed phrase probe is evidence, not proof of behaviour',
|
|
behaviour:
|
|
'a behaviour probe RUNS a named test and reads its result, so a stub that only exports the right symbol does not close it; it proves the capability WORKS, not that a prose phase calls it',
|
|
byte:
|
|
"a byte probe reads the file's bytes, so it closes only on a real change to them",
|
|
};
|
|
|
|
function tallyEntries(root, entries) {
|
|
const items = entries.map((e) => ({ id: e.id, summary: e.summary, probe: e.probe, ...evaluateCheck(root, e.check) }));
|
|
const kinds = Object.keys(PROBE_NOTES).filter((k) => items.some((i) => i.probe === k));
|
|
return {
|
|
open: items.filter((i) => i.status !== 'closed').length,
|
|
total: items.length,
|
|
items,
|
|
detail: kinds.map((k) => PROBE_NOTES[k]).join('; '),
|
|
};
|
|
}
|
|
|
|
// Lines that can carry an instruction: HTML comments and fenced code are blanked (line numbers
|
|
// are kept so "the next line" still means the next line). Block quotes need no stripping: every
|
|
// spawn form below is anchored at the start of the line, and a quoted line starts with `>`.
|
|
function instructionLines(markdown) {
|
|
const uncommented = markdown.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ''));
|
|
const out = [];
|
|
let fenced = false;
|
|
for (const line of uncommented.split('\n')) {
|
|
if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; out.push(''); continue; }
|
|
out.push(fenced ? '' : line);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const NAME = '[a-z0-9][a-z0-9-]*';
|
|
const ROSTER_ROW = new RegExp(`^\\s*\\|\\s*\`(${NAME})\`\\s*\\|`);
|
|
const LAUNCH_LINE = /^\s*(?:[-*]\s+|\d+\.\s+)?(?:\*\*)?(?:launch|spawn)\b(?:\*\*)?(.*)$/i;
|
|
const LAUNCH_NAME = new RegExp(`\`(?:voyage:)?(${NAME})\`|\\*\\*(${NAME})\\*\\*|voyage:(${NAME})`, 'g');
|
|
const AGENT_BLOCK = new RegExp(`^\\s*\\*\\*(${NAME})\\*\\*`);
|
|
|
|
// The agent names a command markdown file actually spawns. A spawn instruction is one of:
|
|
// 1. a row of a table whose header's first cell is `Agent` (a roster the step launches);
|
|
// 2. an imperative line that STARTS with Launch/Spawn and names the agent as `name`,
|
|
// **name** or voyage:name;
|
|
// 3. a line starting with **name** whose next non-empty line starts with `Prompt:`.
|
|
// Prose, negations, HTML comments, fenced code and block quotes never count.
|
|
export function spawnedNames(markdown) {
|
|
const lines = instructionLines(markdown);
|
|
const names = new Set();
|
|
let inTable = false;
|
|
let roster = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (/^\s*\|/.test(line)) {
|
|
if (!inTable) {
|
|
inTable = true;
|
|
roster = /^\s*\|\s*Agent\s*\|/.test(line);
|
|
continue;
|
|
}
|
|
const row = roster && line.match(ROSTER_ROW);
|
|
if (row) names.add(row[1]);
|
|
continue;
|
|
}
|
|
inTable = false;
|
|
const launch = line.match(LAUNCH_LINE);
|
|
if (launch) {
|
|
for (const m of launch[1].matchAll(LAUNCH_NAME)) names.add(m[1] || m[2] || m[3]);
|
|
continue;
|
|
}
|
|
const block = line.match(AGENT_BLOCK);
|
|
if (block) {
|
|
let j = i + 1;
|
|
while (j < lines.length && lines[j].trim() === '') j++;
|
|
if (j < lines.length && /^\s*Prompt:/.test(lines[j])) names.add(block[1]);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function tallyDormantAgents(root, cfg) {
|
|
try {
|
|
const agentFiles = expandPath(root, `${cfg.dir}/*.md`);
|
|
const spawned = new Set();
|
|
for (const f of expandPath(root, cfg.spawnSites)) {
|
|
for (const n of spawnedNames(readFileSync(f, 'utf8'))) spawned.add(n);
|
|
}
|
|
const items = [];
|
|
for (const file of agentFiles) {
|
|
const text = readFileSync(file, 'utf8');
|
|
const fm = text.startsWith('---') ? text.split('\n---')[0] : '';
|
|
if (cfg.referenceMarker && fm.includes(cfg.referenceMarker)) continue;
|
|
const name = (fm.match(/^name:\s*(\S+)\s*$/m) || [])[1] || basename(file, '.md');
|
|
const isSpawned = spawned.has(name);
|
|
items.push({
|
|
id: name,
|
|
summary: toPosix(relative(root, file)),
|
|
status: isSpawned ? 'closed' : 'open',
|
|
detail: isSpawned ? '' : `no spawn instruction in ${cfg.spawnSites}`,
|
|
});
|
|
}
|
|
return {
|
|
open: items.filter((i) => i.status === 'open').length,
|
|
total: items.length,
|
|
items,
|
|
detail: 'spawnable = agent files without the reference-document marker; spawned = named in a spawn instruction (an `Agent` table row, a line starting with Launch/Spawn, or a **name** block followed by Prompt:); prose, comments, fences and quotes do not count',
|
|
};
|
|
} catch (err) {
|
|
return notMeasured(`not measurable: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function tallyDecisions(root, cfg) {
|
|
const abs = join(root, cfg.file);
|
|
if (!existsSync(abs)) {
|
|
return notMeasured(`not measurable: ${cfg.file} not found (it is local-only)`);
|
|
}
|
|
try {
|
|
const section = sectionOf(readFileSync(abs, 'utf8'), cfg.section, cfg.file);
|
|
const lines = section.split('\n');
|
|
const openRe = new RegExp(cfg.open);
|
|
const closedRe = new RegExp(cfg.closed);
|
|
const open = lines.filter((l) => openRe.test(l));
|
|
const closed = lines.filter((l) => closedRe.test(l));
|
|
// Format drift guard: a list item that carries neither marker would silently read as "0 open".
|
|
const unmarked = lines.filter((l) => /^\s*(\d+\.|[-*]) /.test(l) && !openRe.test(l) && !closedRe.test(l));
|
|
if (unmarked.length > 0) {
|
|
return notMeasured(`not measurable: ${unmarked.length} unmarked list item(s) in ${cfg.section} — mark each with the open/decided marker`);
|
|
}
|
|
return {
|
|
open: open.length,
|
|
total: open.length + closed.length,
|
|
items: open.map((l, i) => ({ id: `#${i + 1}`, summary: l.replace(openRe, '').slice(0, 100), status: 'open', detail: '' })),
|
|
detail: `marker: open = /${cfg.open}/, decided = /${cfg.closed}/; this counts ticks — a ticked line is not verified to be an actual decision`,
|
|
};
|
|
} catch (err) {
|
|
return notMeasured(`not measurable: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function tallyFeatAfterFreeze(root, cfg) {
|
|
const git = (...args) => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
const top = git('rev-parse', '--show-toplevel');
|
|
if (top.status !== 0) return notMeasured(`not measured: ${toPosix(root)} is not a git work tree`);
|
|
let atTop = false;
|
|
try { atTop = realpathSync(top.stdout.trim()) === realpathSync(root); } catch { atTop = false; }
|
|
if (!atTop) return notMeasured('not measured: the gate root is not the top of its git work tree');
|
|
const ref = `refs/tags/${cfg.tag}`;
|
|
if (git('rev-parse', '-q', '--verify', ref).status !== 0) {
|
|
return notMeasured(`not measured: no freeze tag "${cfg.tag}" yet — open until the freeze is declared`);
|
|
}
|
|
const log = git('log', '--format=%s', `${ref}..HEAD`);
|
|
if (log.status !== 0) return notMeasured(`not measured: git log failed: ${log.stderr.trim()}`);
|
|
const subjects = log.stdout.split('\n').filter(Boolean);
|
|
const featRe = new RegExp(cfg.featPattern);
|
|
const feats = subjects.filter((s) => featRe.test(s));
|
|
return {
|
|
open: feats.length,
|
|
total: subjects.length,
|
|
items: feats.map((s, i) => ({ id: `#${i + 1}`, summary: s, status: 'open', detail: '' })),
|
|
detail: `commits after ${cfg.tag} whose subject matches /${cfg.featPattern}/`,
|
|
};
|
|
}
|
|
|
|
export function measure(root, registry, frozen) {
|
|
const d = registry.decisions;
|
|
const f = registry.freeze;
|
|
const rows = [
|
|
{ id: 'defects', label: 'open pipeline defects', source: `${REGISTRY_PATH}#defects`, ...tallyEntries(root, registry.defects) },
|
|
{ id: 'experiments', label: 'unresolved experiments/opt-ins', source: `${REGISTRY_PATH}#experiments`, ...tallyEntries(root, registry.experiments) },
|
|
{ id: 'dormant-agents', label: 'dormant agents', source: `${registry.agents.dir}/*.md vs ${registry.agents.spawnSites}`, ...tallyDormantAgents(root, registry.agents) },
|
|
{ id: 'decisions', label: 'open operator decisions', source: `${d.file} ${d.section.replace(/^#+\s*/, '§ ')}`, ...tallyDecisions(root, d) },
|
|
{ id: 'feat-after-freeze', label: 'feat commits after the freeze tag', source: `git log ${f.tag}..HEAD`, ...tallyFeatAfterFreeze(root, f) },
|
|
].map((r) => ({ ...r, target: 0 }));
|
|
let integrity;
|
|
try {
|
|
integrity = { ...verifyIntegrity(registry, frozen ?? loadFrozen(root)), detail: '' };
|
|
} catch (err) {
|
|
integrity = { ok: null, violations: [], detail: `not measurable: cannot read ${FROZEN_PATH}: ${err.message}` };
|
|
}
|
|
return { green: rows.every((r) => r.open === 0) && integrity.ok === true, rows, integrity, wiring: WIRING_NOTE };
|
|
}
|
|
|
|
export function loadRegistry(root) {
|
|
const abs = join(root, REGISTRY_PATH);
|
|
let reg;
|
|
try {
|
|
reg = JSON.parse(readFileSync(abs, 'utf8'));
|
|
} catch (err) {
|
|
throw new Error(`cannot read ${REGISTRY_PATH}: ${err.message}`);
|
|
}
|
|
for (const key of ['defects', 'experiments']) {
|
|
if (!Array.isArray(reg[key])) throw new Error(`${REGISTRY_PATH}: "${key}" must be a list`);
|
|
}
|
|
for (const key of ['agents', 'decisions', 'freeze']) {
|
|
if (!reg[key] || typeof reg[key] !== 'object') throw new Error(`${REGISTRY_PATH}: "${key}" must be an object`);
|
|
}
|
|
return reg;
|
|
}
|
|
|
|
export function render(result) {
|
|
const zero = result.rows.filter((r) => r.open === 0).length;
|
|
const out = [];
|
|
out.push(`Voyage end-state gate: ${result.green ? 'GREEN' : 'RED'} (${zero} of ${result.rows.length} tallies at 0)`);
|
|
const integrity = result.integrity;
|
|
if (integrity?.ok === true) {
|
|
out.push(`registry integrity: intact against ${FROZEN_PATH}`);
|
|
} else if (integrity?.ok === false) {
|
|
out.push('registry integrity: VIOLATED — the gate cannot be green while the ledger differs from its frozen denominator');
|
|
for (const v of integrity.violations) out.push(` - ${v}`);
|
|
} else if (integrity) {
|
|
out.push(`registry integrity: n/a — ${integrity.detail}`);
|
|
}
|
|
out.push(result.wiring ?? WIRING_NOTE);
|
|
out.push('');
|
|
out.push('| tally | open | of | target | source |');
|
|
out.push('|---|---|---|---|---|');
|
|
for (const r of result.rows) {
|
|
const open = r.open === null ? 'n/a' : String(r.open);
|
|
const total = r.total === null ? 'n/a' : String(r.total);
|
|
out.push(`| ${r.id} | ${open} | ${total} | ${r.target} | ${r.source} |`);
|
|
}
|
|
for (const r of result.rows) {
|
|
out.push('');
|
|
out.push(`${r.id} — ${r.label}${r.detail ? ` (${r.detail})` : ''}`);
|
|
for (const i of r.items) {
|
|
if (i.status === 'closed') continue;
|
|
let tag = i.status === 'not-fellable' ? 'NOT FELLABLE, counted open' : 'open';
|
|
if (i.probe) tag += ` · ${i.probe} probe`;
|
|
out.push(` [${tag}] ${i.id}: ${i.summary}${i.detail ? ` — ${i.detail}` : ''}`);
|
|
}
|
|
const closed = r.items.filter((i) => i.status === 'closed' && i.probe);
|
|
if (closed.length > 0) {
|
|
out.push(` closed: ${closed.map((i) => `${i.id} (${i.probe} probe)`).join(', ')}`);
|
|
}
|
|
}
|
|
return out.join('\n');
|
|
}
|
|
|
|
export function main(argv) {
|
|
let json = false;
|
|
let root = REPO_ROOT;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === '--json') json = true;
|
|
else if (argv[i] === '--root' && argv[i + 1]) root = resolve(argv[++i]);
|
|
else {
|
|
process.stderr.write(`end-state-gate: unknown argument ${argv[i]}\nusage: end-state-gate.mjs [--json] [--root <dir>]\n`);
|
|
return 2;
|
|
}
|
|
}
|
|
let registry;
|
|
try {
|
|
registry = loadRegistry(root);
|
|
} catch (err) {
|
|
process.stderr.write(`end-state-gate: ${err.message}\n`);
|
|
return 2;
|
|
}
|
|
const result = measure(root, registry);
|
|
process.stdout.write((json ? JSON.stringify(result, null, 2) : render(result)) + '\n');
|
|
return result.green ? 0 : 1;
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
process.exitCode = main(process.argv.slice(2));
|
|
}
|