The cosmetic close came back one level up. The probe reads the test file from the same tree it measures, so a four-line file holding two EMPTY tests with the two named names closed D-03 and D-04 on a tree where `lib/verification/` did not exist at all - "defects 0 of 7, registry intact" (measured 2026-09-18). First a two-line stub exporting the right symbols; then an empty test with the right name. A name is always forgeable. So the named test is now run twice. Once on the tree, as before - and once in a sandbox where the module the condition declares in `stubs` is replaced by a stub exporting the same names, all inert. If the test still passes there, it binds the name and not the behaviour, and the condition THROWS: NOT FELLABLE, counted open. A condition that declares no `stubs`, or names a module that is not there, cannot fire either. The sandbox is a symlink overlay: every entry of the tree is symlinked, and only the test file and the stubbed module are materialised for real - Node resolves an ESM import through the realpath, so a symlinked test file would import the original module and never see the mutant. Nothing is ever written inside the measured tree, and the only directory removed is the one this code made under the system temp dir (pinned by a test). M7 is now a permanent mutant beside M6, in two forms: the checkpoint's own reproduction (unfixed tree + empty named tests) and the harder one (the real module present, so the stub can be built and the empty test passes against it). Both report `defects 2 of 7`. A positive control pins that D-03/D-04 still CLOSE on the real tree, so "not closed" everywhere cannot read as a working probe. The frozen denominator moves a third time, deliberately, and its `why` no longer claims authority it does not have: the second and third amendments were maintenance decisions by the maintainer, not operator decisions, and the tracked file now says exactly that. Measured after: real tree node scripts/end-state-gate.mjs -> defects 0 of 7, intact, exit 1 M6 (stub)8d1669e+ current gate/registry/frozen + lib/cosmetic/stub.mjs -> 2 of 7 M7 (empty)8d1669e+ current gate/registry/frozen + 3-line tests/lib/criteria-runner.test.mjs with the two named tests -> 2 of 7 Red first: 4 of the new tests failed before the change. Suite 1154 (1152/0/2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
584 lines
26 KiB
JavaScript
584 lines
26 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, writeFileSync, existsSync, readdirSync, statSync, realpathSync,
|
|
mkdirSync, mkdtempSync, symlinkSync, rmSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
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, '\\$&');
|
|
|
|
// Every `export` name a module declares, so a stub can offer the same surface
|
|
// and none of the behaviour.
|
|
const EXPORT_NAME = /^\s*export\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
|
|
/**
|
|
* A stub of a module: the same export names, every one of them inert.
|
|
*
|
|
* This is the mutant a behaviour probe must FELL. A test that still passes
|
|
* when the module it names does nothing is not measuring that module — the
|
|
* empty test that closed D-03 and D-04 on a tree without `lib/verification/`
|
|
* is the limiting case (measured 2026-09-18).
|
|
*/
|
|
export function stubModule(source) {
|
|
const names = new Set();
|
|
for (const m of String(source).matchAll(EXPORT_NAME)) names.add(m[1]);
|
|
const lines = [...names].map((n) => `export const ${n} = undefined;`);
|
|
if (/^\s*export\s+default\b/m.test(source)) lines.push('export default undefined;');
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
// Build `dest` as a shallow copy of `root`: every entry is a SYMLINK to the
|
|
// original, except the files in `files`, which are written for real (and whose
|
|
// parent directories are therefore real directories too). Node resolves an
|
|
// ESM import through the realpath, so a symlinked test file would import the
|
|
// ORIGINAL module and never see the stub — only the files that must differ are
|
|
// materialised. Nothing is ever written inside the measured tree.
|
|
function overlay(root, dest, files) {
|
|
const realDirs = new Set();
|
|
for (const rel of files.keys()) {
|
|
const parts = rel.split('/');
|
|
for (let i = 0; i < parts.length - 1; i++) realDirs.add(parts.slice(0, i + 1).join('/'));
|
|
}
|
|
const build = (relDir) => {
|
|
const absSrc = relDir === '' ? root : join(root, relDir);
|
|
const absDst = relDir === '' ? dest : join(dest, relDir);
|
|
mkdirSync(absDst, { recursive: true });
|
|
if (!existsSync(absSrc)) return;
|
|
for (const name of readdirSync(absSrc)) {
|
|
const childRel = relDir === '' ? name : `${relDir}/${name}`;
|
|
if (realDirs.has(childRel)) { build(childRel); continue; }
|
|
if (files.has(childRel)) continue;
|
|
symlinkSync(join(absSrc, name), join(absDst, name));
|
|
}
|
|
};
|
|
build('');
|
|
for (const [rel, body] of files) writeFileSync(join(dest, rel), body);
|
|
}
|
|
|
|
// Run ONE named test out of one test file and report whether it passed.
|
|
// `null` = the runner produced no TAP line for that name (it did not run at
|
|
// all), which is never a pass.
|
|
function runNamedTest(root, testRel, name) {
|
|
// 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 felling 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(name)}$`, testRel],
|
|
{ cwd: root, encoding: 'utf8', timeout: TEST_TIMEOUT_MS, env },
|
|
);
|
|
if (r.error) throw new Error(`test runner did not run ${testRel}: ${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.
|
|
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() !== name) continue;
|
|
return m[1] === undefined;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* A condition that RUNS a named test, reads its result, and requires that the
|
|
* same test FELLS a stub of the module it claims to bind.
|
|
*
|
|
* 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. Running a NAMED test closed that hole and opened a smaller one one
|
|
* level up: two EMPTY tests with the right names closed them again. A name is
|
|
* always forgeable; felling a mutant is not. So the named test is run twice —
|
|
* once on the tree, once against a stub of each module in `stubs` — and it
|
|
* must not pass the second time.
|
|
*
|
|
* `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, a missing or unfelled stub target: 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');
|
|
}
|
|
if (!Array.isArray(cond.stubs) || cond.stubs.length === 0) {
|
|
throw new Error('a test condition must declare `stubs`: the module(s) whose stub the named test must fell');
|
|
}
|
|
const file = join(root, cond.test);
|
|
if (!existsSync(file)) throw new Error(`test file not found: ${cond.test}`);
|
|
|
|
for (const target of cond.stubs) {
|
|
const abs = join(root, target);
|
|
if (!existsSync(abs)) throw new Error(`stub target not found: ${target}`);
|
|
const sandbox = mkdtempSync(join(tmpdir(), 'end-state-mutant-'));
|
|
try {
|
|
overlay(root, sandbox, new Map([
|
|
[cond.test, readFileSync(file, 'utf8')],
|
|
[target, stubModule(readFileSync(abs, 'utf8'))],
|
|
]));
|
|
if (runNamedTest(sandbox, cond.test, cond.name) === true) {
|
|
throw new Error(
|
|
`"${cond.name}" still passes against a stub of ${target} — it binds the name, not the behaviour`,
|
|
);
|
|
}
|
|
} finally {
|
|
// Only ever a directory this function made, under the system temp dir.
|
|
if (sandbox.startsWith(realpathSync(tmpdir()))) rmSync(sandbox, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const passed = runNamedTest(root, cond.test, cond.name);
|
|
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 re-runs it against a stub of the module it binds, which it must fell: neither a stub that exports the right symbols nor an empty test with the right name closes 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));
|
|
}
|