@tachikomagundam/abathur 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/genome.js +2 -2
  4. package/dist/commands/graft.js +1 -1
  5. package/dist/commands/readjudicate.js +28 -0
  6. package/dist/commands/retract.js +53 -0
  7. package/dist/commands/run.js +1 -1
  8. package/dist/commands/self-eval.js +1 -1
  9. package/dist/commands/tombstone.js +30 -16
  10. package/dist/core/evolve/run-loop.js +14 -0
  11. package/dist/core/evolve/run-rows.js +14 -0
  12. package/dist/core/evolve/score-bank.js +214 -0
  13. package/dist/core/graft-rebench.js +3 -0
  14. package/dist/core/ledger.js +8 -1
  15. package/dist/core/promote.js +63 -47
  16. package/dist/core/readjudicate.js +247 -0
  17. package/dist/core/retract.js +166 -0
  18. package/dist/core/stats-math.js +39 -0
  19. package/dist/core/stats.js +93 -19
  20. package/dist/core/worktree.js +1 -1
  21. package/dist/genomes/toy-smoke/init.mjs +1 -1
  22. package/dist/test/bundle.test.js +1 -1
  23. package/dist/test/fixture-loop.test.js +1 -1
  24. package/dist/test/fixtures-self.js +1 -1
  25. package/dist/test/fixtures-wt.js +1 -1
  26. package/dist/test/friction.test.js +3 -3
  27. package/dist/test/graft.test.js +1 -1
  28. package/dist/test/historian-grader-integrity.test.js +509 -2
  29. package/dist/test/promote.test.js +222 -5
  30. package/dist/test/readjudicate.test.js +212 -0
  31. package/dist/test/reflect.test.js +1 -1
  32. package/dist/test/repopath-seams.test.js +55 -0
  33. package/dist/test/score-bank.test.js +235 -0
  34. package/dist/test/seam-gates.test.js +84 -0
  35. package/dist/test/self-snapshot.test.js +2 -2
  36. package/dist/test/stats-acceptance.test.js +329 -0
  37. package/graders/historian/grader-core.d.mts +27 -0
  38. package/graders/historian/grader-core.mjs +311 -19
  39. package/graders/historian/grader.mjs +5 -5
  40. package/graders/historian/judge-poststage-19.mjs +212 -0
  41. package/graders/historian/run-scenario-19.sh +43 -0
  42. package/graders/historian/run-scenario.sh +16 -0
  43. package/package.json +1 -1
  44. package/plugin/abathur-command.md +3 -3
  45. package/plugin/abathur.ts +6 -6
@@ -9,13 +9,14 @@ import path from "node:path";
9
9
  import { ExitSignal, blocked, cannotAnswer } from "../exit.js";
10
10
  import { compileGlob } from "./glob.js";
11
11
  import { fastForwardIncumbent } from "./incumbent.js";
12
+ import { effectiveRepoPath } from "./spec.js";
12
13
  import { buildManifest, kernelsDirOf, manifestPathFor, readManifestFile, serializeManifest, } from "./kernel.js";
13
- import { Ledger, ledgerPath } from "./ledger.js";
14
+ import { acquireGenomeLock, acquireLock, LEDGER_KIND_PROMOTE, Ledger, ledgerPath } from "./ledger.js";
14
15
  import { gitOpts, requireSha } from "./genome-paths.js";
15
16
  import { snapshotCommit } from "./snapshot.js";
16
17
  import { tryGit } from "../util/git.js";
17
18
  import { decodeGenerationRecord } from "./evolve/run-bench.js";
18
- export const LEDGER_KIND_PROMOTE = "promote";
19
+ export { LEDGER_KIND_PROMOTE };
19
20
  /** Ledger/display-safe flattening of untrusted text (todo-8 sealMessage discipline). */
20
21
  function display(value, max = 96) {
21
22
  const flat = value.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
@@ -65,55 +66,70 @@ async function sealedPathCheck(genId, nom, matchers, sealedPaths, cwd, opts) {
65
66
  }
66
67
  export async function promoteGeneration(req) {
67
68
  const { entry, configDir } = req;
68
- const repo = entry.spec.repoPath;
69
+ const repo = effectiveRepoPath(entry.spec.repoPath);
69
70
  const shown = display(req.genId);
70
71
  const opts = req.env === undefined ? {} : { env: req.env };
71
72
  const ledger = Ledger.open(repo);
72
- const records = ledger.readAll();
73
- const genRows = records.filter((r) => r.kind === "generation_complete" && r.genId === req.genId);
74
- const lastRow = genRows.at(-1);
75
- if (lastRow === undefined) {
76
- blocked(`promote: no generation_complete ledger row for gen '${shown}' of '${entry.label}'`, `state is read only from ${ledgerPath(repo)} — check candidates with \`abathur status ${entry.label}\``);
77
- }
78
- if (records.some((r) => r.kind === LEDGER_KIND_PROMOTE && r.genId === req.genId)) {
79
- blocked(`promote: gen '${shown}' is already promoted (promote row in ledger) — nothing to do`);
80
- }
81
- const nom = nominatedGeneration(decodeGenerationRecord(lastRow), shown);
82
- const manifestFile = manifestPathFor(configDir, entry.fingerprint);
83
- let manifestEntries;
73
+ // the human gate is a state mutation: single-flight it against the run loop
74
+ // and any concurrent gate, same class as graft (postmortem 2026-09-28 retract
75
+ // review — the gate read→check→mutate→append sequence must not interleave).
76
+ const lease = acquireGenomeLock({ ledger, configDir, genomeFp: entry.fingerprint });
84
77
  try {
85
- manifestEntries = readManifestFile(manifestFile);
78
+ const repoLease = acquireLock({ configDir: path.join(repo, ".state", "abathur"), key: "gate", label: "gate lock" });
79
+ try {
80
+ const records = ledger.readAll();
81
+ const genRows = records.filter((r) => r.kind === "generation_complete" && r.genId === req.genId);
82
+ const lastRow = genRows.at(-1);
83
+ if (lastRow === undefined) {
84
+ blocked(`promote: no generation_complete ledger row for gen '${shown}' of '${entry.label}'`, `state is read only from ${ledgerPath(repo)} — check candidates with \`abathur status ${entry.label}\``);
85
+ }
86
+ if (records.some((r) => r.kind === LEDGER_KIND_PROMOTE && r.genId === req.genId)) {
87
+ blocked(`promote: gen '${shown}' is already promoted (promote row in ledger) — nothing to do`);
88
+ }
89
+ const nom = nominatedGeneration(decodeGenerationRecord(lastRow), shown);
90
+ const manifestFile = manifestPathFor(configDir, entry.fingerprint);
91
+ let manifestEntries;
92
+ try {
93
+ manifestEntries = readManifestFile(manifestFile);
94
+ }
95
+ catch (cause) {
96
+ if (cause instanceof ExitSignal)
97
+ throw cause;
98
+ const message = cause instanceof Error ? cause.message : String(cause);
99
+ cannotAnswer(`promote: kernel manifest unreadable, refusing to gate: ${message}`);
100
+ }
101
+ const sealedPaths = new Set(manifestEntries.map((e) => e.path));
102
+ const matchers = entry.spec.kernel.immutableGlobs.map((glob) => compileGlob(glob));
103
+ await sealedPathCheck(req.genId, nom, matchers, sealedPaths, repo, opts);
104
+ const genome = { repoPath: repo, genomeFp: entry.fingerprint };
105
+ const ff = await fastForwardIncumbent(genome, nom.commitSha, opts);
106
+ // regeneration happens ONLY here, after the human-invoked diff passed:
107
+ // manifest reborn from the new incumbent's committed tree, not the worktree.
108
+ const snap = await snapshotCommit(genome, nom.commitSha, opts);
109
+ const fresh = buildManifest(snap.snapshotPath, entry.spec.kernel.immutableGlobs);
110
+ mkdirSync(kernelsDirOf(configDir), { recursive: true });
111
+ writeFileSync(manifestFile, serializeManifest(fresh), "utf8");
112
+ ledger.append({
113
+ kind: LEDGER_KIND_PROMOTE,
114
+ genId: req.genId,
115
+ data: { actor: "cli", genId: req.genId, from: ff.fromSha, to: ff.toSha },
116
+ });
117
+ return {
118
+ genId: req.genId,
119
+ fromSha: ff.fromSha,
120
+ toSha: ff.toSha,
121
+ lines: [
122
+ `promoted '${entry.label}' (${entry.fingerprint}): ${ff.fromSha === null ? "incumbent branch created" : short(ff.fromSha)} -> ${short(ff.toSha)} (gen ${shown})`,
123
+ `manifest: rewrote ${path.relative(process.cwd(), manifestFile) || manifestFile} from tree ${short(nom.commitSha)} (${String(fresh.length)} sealed ${fresh.length === 1 ? "entry" : "entries"})`,
124
+ `ledger: appended '${LEDGER_KIND_PROMOTE}' row (actor: cli) to ${ledgerPath(repo)}`,
125
+ ],
126
+ };
127
+ }
128
+ finally {
129
+ repoLease.release();
130
+ }
86
131
  }
87
- catch (cause) {
88
- if (cause instanceof ExitSignal)
89
- throw cause;
90
- const message = cause instanceof Error ? cause.message : String(cause);
91
- cannotAnswer(`promote: kernel manifest unreadable, refusing to gate: ${message}`);
132
+ finally {
133
+ lease.release();
92
134
  }
93
- const sealedPaths = new Set(manifestEntries.map((e) => e.path));
94
- const matchers = entry.spec.kernel.immutableGlobs.map((glob) => compileGlob(glob));
95
- await sealedPathCheck(req.genId, nom, matchers, sealedPaths, repo, opts);
96
- const genome = { repoPath: repo, genomeFp: entry.fingerprint };
97
- const ff = await fastForwardIncumbent(genome, nom.commitSha, opts);
98
- // regeneration happens ONLY here, after the human-invoked diff passed:
99
- // manifest reborn from the new incumbent's committed tree, not the worktree.
100
- const snap = await snapshotCommit(genome, nom.commitSha, opts);
101
- const fresh = buildManifest(snap.snapshotPath, entry.spec.kernel.immutableGlobs);
102
- mkdirSync(kernelsDirOf(configDir), { recursive: true });
103
- writeFileSync(manifestFile, serializeManifest(fresh), "utf8");
104
- ledger.append({
105
- kind: LEDGER_KIND_PROMOTE,
106
- genId: req.genId,
107
- data: { actor: "cli", genId: req.genId, from: ff.fromSha, to: ff.toSha },
108
- });
109
- return {
110
- genId: req.genId,
111
- fromSha: ff.fromSha,
112
- toSha: ff.toSha,
113
- lines: [
114
- `promoted '${entry.label}' (${entry.fingerprint}): ${ff.fromSha === null ? "incumbent branch created" : short(ff.fromSha)} -> ${short(ff.toSha)} (gen ${shown})`,
115
- `manifest: rewrote ${path.relative(process.cwd(), manifestFile) || manifestFile} from tree ${short(nom.commitSha)} (${String(fresh.length)} sealed ${fresh.length === 1 ? "entry" : "entries"})`,
116
- `ledger: appended '${LEDGER_KIND_PROMOTE}' row (actor: cli) to ${ledgerPath(repo)}`,
117
- ],
118
- };
119
135
  }
@@ -0,0 +1,247 @@
1
+ // re-adjudicate (institutional addition 2026-09-28, born of the c17 silent-downgrade
2
+ // incident): when the GATE code that judged a generation is later found to have been
3
+ // the wrong binary (or any honest cause), the corrected verdict must be able to enter
4
+ // machine state WITHOUT touching the immutable ledger files: this command replays the
5
+ // CURRENT evaluate() over the archived rows and APPENDS a fresh generation_complete
6
+ // row carrying the corrected verdict plus full provenance. Promote already reads the
7
+ // LAST row for a gen, so a corrected nomination unlocks the human gate; a corrected
8
+ // cull stays honest too — this tool re-judges, it never flips.
9
+ //
10
+ // Replay contract (mirrors scripts/backtest-acceptance.mjs, the established approach):
11
+ // - gate-time counters = lifetime sum over the epoch (all rows sharing the runId)
12
+ // MINUS the candidate row's own spend (its slot was already committed by the
13
+ // session clamp — feeding it back would self-trip the cap, run-loop.ts:316-318);
14
+ // - the score bank excludes THIS candidate's own tree groups (its verdict-time
15
+ // draws must not price its own noise — excludeCandidateTrees is the built hook);
16
+ // - nPairs = 1 (one finalist pair per re-judged epoch).
17
+ // Fail-closed: no candidate row / no incumbent row for the same runId / an existing
18
+ // promote row for the gen ⇒ refusal before any state read becomes a mutation.
19
+ import { readFileSync, readdirSync } from "node:fs";
20
+ import path from "node:path";
21
+ import { blocked, cannotAnswer } from "../exit.js";
22
+ import { effectiveRepoPath } from "./spec.js";
23
+ import { evaluate, needsExit } from "./stats.js";
24
+ import { acquireGenomeLock, acquireLock, Ledger, ledgerPath, ledgerRecordSchema, LEDGER_KIND_GENERATION_COMPLETE, LEDGER_KIND_PROMOTE } from "./ledger.js";
25
+ import { asReplicates, decodeGenerationRecord } from "./evolve/run-bench.js";
26
+ import { effectiveCaps } from "./evolve/run-plan.js";
27
+ import { loadScoreBank } from "./evolve/score-bank.js";
28
+ const LIVE_LEDGER = "ledger.jsonl";
29
+ function isRotatedArchive(name) {
30
+ return name.startsWith("ledger.") && name.endsWith(".jsonl") && name !== LIVE_LEDGER && !name.startsWith("ledger.corrupt-");
31
+ }
32
+ /** Same read discipline as the score bank: schema-invalid lines are skipped with a notice, never a crash. */
33
+ function collectRows(dir) {
34
+ let names;
35
+ try {
36
+ names = readdirSync(dir);
37
+ }
38
+ catch {
39
+ return { rows: [], notices: [] };
40
+ }
41
+ const files = names.filter((n) => n === LIVE_LEDGER || isRotatedArchive(n)).sort();
42
+ const rows = [];
43
+ const notices = [];
44
+ for (const file of files) {
45
+ let text;
46
+ try {
47
+ text = readFileSync(path.join(dir, file), "utf8");
48
+ }
49
+ catch {
50
+ notices.push(`${file}: unreadable — skipped`);
51
+ continue;
52
+ }
53
+ for (const [index, raw] of text.split("\n").entries()) {
54
+ const line = raw.trim();
55
+ if (line.length === 0)
56
+ continue;
57
+ let candidate;
58
+ try {
59
+ candidate = JSON.parse(line);
60
+ }
61
+ catch {
62
+ notices.push(`${file}:${String(index + 1)}: not JSON — row skipped`);
63
+ continue;
64
+ }
65
+ const parsed = ledgerRecordSchema.safeParse(candidate);
66
+ if (!parsed.success) {
67
+ notices.push(`${file}:${String(index + 1)}: schema-invalid — row skipped`);
68
+ continue;
69
+ }
70
+ rows.push({ file, ts: parsed.data.ts, record: parsed.data });
71
+ }
72
+ }
73
+ return { rows, notices };
74
+ }
75
+ function display(value, max = 96) {
76
+ const flat = value.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
77
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
78
+ }
79
+ export function readjudicateGeneration(req) {
80
+ const { entry, genId } = req;
81
+ const shown = display(genId);
82
+ const repo = effectiveRepoPath(entry.spec.repoPath);
83
+ const dir = path.dirname(ledgerPath(repo));
84
+ const { rows, notices } = collectRows(dir);
85
+ // locate the candidate row: the LAST generation_complete row for this gen that is a
86
+ // candidate arm; if it already carries readjudication provenance, this is the second pass.
87
+ const genRows = rows.filter((r) => r.record.kind === LEDGER_KIND_GENERATION_COMPLETE && r.record.genId === genId);
88
+ let incumbent = null;
89
+ let candidate = null;
90
+ for (const r of genRows) {
91
+ let data;
92
+ try {
93
+ data = decodeGenerationRecord(r.record);
94
+ }
95
+ catch {
96
+ continue; // not a decodable generation row — skip like the bank does
97
+ }
98
+ if (data.source === "candidate")
99
+ candidate = { row: r, data };
100
+ }
101
+ if (candidate === null) {
102
+ blocked(`re-adjudicate: no candidate generation_complete row for gen '${shown}' (searched live ledger + rotated archives in ${dir})`, `state is read only from the ledger files — check candidates with \`abathur status ${entry.label}\``);
103
+ }
104
+ if (candidate.data.verdict === undefined) {
105
+ blocked(`re-adjudicate: candidate row for gen '${shown}' has no verdict — nothing to re-judge`);
106
+ }
107
+ // non-null alias: TS narrowing does not survive let-assignment inside loops
108
+ const cand = candidate;
109
+ if (rows.some((r) => r.record.kind === LEDGER_KIND_PROMOTE && r.record.genId === genId)) {
110
+ blocked(`re-adjudicate: gen '${shown}' is already promoted — a corrected verdict cannot and need not move it`);
111
+ }
112
+ // incumbent row: same runId, candidate's epoch — search ALL rows, archives included.
113
+ const runId = cand.row.record.runId;
114
+ if (runId === undefined)
115
+ cannotAnswer(`re-adjudicate: candidate row for gen '${shown}' lacks runId — cannot pair it with its incumbent baseline`);
116
+ for (const r of rows) {
117
+ if (r.record.kind !== LEDGER_KIND_GENERATION_COMPLETE || r.record.runId !== runId)
118
+ continue;
119
+ if (r.record.genId === genId)
120
+ continue; // same gen never supplies the incumbent arm
121
+ let data;
122
+ try {
123
+ data = decodeGenerationRecord(r.record);
124
+ }
125
+ catch {
126
+ continue;
127
+ }
128
+ if (data.source === "incumbent") {
129
+ if (incumbent !== null && incumbent.row.record.genId !== r.record.genId) {
130
+ cannotAnswer(`re-adjudicate: multiple incumbent rows for runId '${display(runId)}' with differing gens — ambiguous epoch, refusing`);
131
+ }
132
+ incumbent = { row: r, data };
133
+ }
134
+ }
135
+ if (incumbent === null) {
136
+ blocked(`re-adjudicate: no incumbent baseline row for runId '${display(runId)}' (candidate gen '${shown}') — cannot re-judge an unpaired epoch`);
137
+ }
138
+ const inc = incumbent; // non-null alias (incumbent guarded above; narrowing dies in the loop)
139
+ // gate-time counters mirror the backtest: lifetime epoch sum minus the candidate's own slot.
140
+ let tokens = 0;
141
+ let modelCalls = 0;
142
+ let wallS = 0;
143
+ for (const r of rows) {
144
+ if (r.record.runId !== runId)
145
+ continue;
146
+ if (r === cand.row)
147
+ continue;
148
+ let data;
149
+ try {
150
+ data = decodeGenerationRecord(r.record);
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ tokens += data.counters.tokens;
156
+ modelCalls += data.counters.modelCalls;
157
+ wallS += data.counters.wallS;
158
+ }
159
+ const counters = { candidates: 0, modelCalls, tokens, wallS: Number(wallS.toFixed(3)) };
160
+ const bank = loadScoreBank(repo, { excludeCandidateTrees: cand.data.treeSha === undefined ? [] : [cand.data.treeSha] });
161
+ const gate = evaluateWith(entry, cand.data, inc.data, counters, runId, bank);
162
+ const lines = [];
163
+ for (const n of notices)
164
+ lines.push(`notice: ${n}`);
165
+ lines.push(`replay: gen ${shown} verdict-time ${gate.candidateVerdictBefore} -> current gate '${gate.verdict}' (gain ${gate.gain === null ? "null" : gate.gain.toFixed(4)}, bank ${bank === null ? "absent → legacy gate" : `${String(bank.units.size)} units, prior sigma ${bank.priorSigma.toFixed(4)}, pooled df ${String(bank.totalDf)}`})`);
166
+ if (gate.verdict === cand.data.verdict && cand.data.readjudication === undefined) {
167
+ lines.push(`no-op: current gate already agrees with the row verdict '${gate.verdict}' — nothing appended`);
168
+ return { appended: false, verdict: gate.verdict, lines };
169
+ }
170
+ if (cand.data.readjudication !== undefined && gate.verdict === cand.data.verdict) {
171
+ lines.push(`no-op: gen already carries a readjudication row with verdict '${gate.verdict}' — nothing appended`);
172
+ return { appended: false, verdict: gate.verdict, lines };
173
+ }
174
+ const ledger = Ledger.open(repo);
175
+ const lease = acquireGenomeLock({ ledger, configDir: req.configDir, genomeFp: entry.fingerprint });
176
+ try {
177
+ const repoLease = acquireLock({ configDir: path.join(repo, ".state", "abathur"), key: "gate", label: "re-adjudicate lock" });
178
+ try {
179
+ // re-read under lock: a promote or younger row may have landed while we computed.
180
+ const fresh = collectRows(dir).rows.filter((r) => r.record.kind === LEDGER_KIND_GENERATION_COMPLETE && r.record.genId === genId);
181
+ const lastData = [...fresh].reverse().map((r) => {
182
+ try {
183
+ return decodeGenerationRecord(r.record);
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }).find((d) => d !== null);
189
+ if (lastData === undefined) {
190
+ blocked(`re-adjudicate: gen '${shown}' rows vanished under lock — state moved, nothing appended`);
191
+ }
192
+ if (lastData.verdict === gate.verdict) {
193
+ lines.push(`no-op (under lock): last row already reads '${gate.verdict}'`);
194
+ return { appended: false, verdict: gate.verdict, lines };
195
+ }
196
+ if (lastData.readjudication !== undefined) {
197
+ blocked(`re-adjudicate: gen '${shown}' already has a readjudication row verdict '${lastData.verdict}' disagreeing with replay '${gate.verdict}' — a second correction would erase the first; hand the ledger a ruling instead`);
198
+ }
199
+ if (fresh.some((r) => r.record.kind === LEDGER_KIND_PROMOTE)) {
200
+ blocked(`re-adjudicate: gen '${shown}' was promoted while this command computed — refusing to amend a promoted epoch`);
201
+ }
202
+ const replayExit = needsExit(gate.verdict);
203
+ const data = {
204
+ ...cand.data,
205
+ verdict: gate.verdict,
206
+ exitCode: replayExit === 0 ? 0 : replayExit === 1 ? 1 : 2,
207
+ gain: gate.gain,
208
+ gateFailures: [...gate.failures],
209
+ readjudication: {
210
+ at: req.now().toISOString(),
211
+ specFingerprint: entry.fingerprint,
212
+ gate: bank === null ? "legacy" : "acceptance",
213
+ bank: bank === null ? null : { units: bank.units.size, priorSigma: bank.priorSigma, pooledDf: bank.totalDf },
214
+ sourceRows: {
215
+ candidate: { file: cand.row.file, ts: cand.row.ts },
216
+ incumbent: { file: inc.row.file, ts: inc.row.ts },
217
+ },
218
+ replacedVerdict: cand.data.verdict ?? "none",
219
+ },
220
+ };
221
+ ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId, ...(runId === undefined ? {} : { runId }), data });
222
+ lines.push(`ledger: appended corrected generation_complete row (verdict '${gate.verdict}', actor: re-adjudicate) to ${ledgerPath(repo)}`);
223
+ lines.push(`files are immutable — the original '${cand.data.verdict}' row remains in ${cand.row.file} as history`);
224
+ for (const f of gate.failures)
225
+ lines.push(` gate: ${f}`);
226
+ }
227
+ finally {
228
+ repoLease.release();
229
+ }
230
+ }
231
+ finally {
232
+ lease.release();
233
+ }
234
+ return { appended: true, verdict: gate.verdict, lines };
235
+ }
236
+ // the replay itself — kept tiny and explicit so the counters/bank/nPairs contract above is auditable.
237
+ function evaluateWith(entry, candidate, incumbent, counters, runId, bank) {
238
+ const verdict = evaluate({
239
+ candidate: { runId: runId ?? "re-adjudicate", units: asReplicates(candidate.units), counters },
240
+ incumbent: { units: asReplicates(incumbent.units) },
241
+ stats: entry.spec.bench.stats,
242
+ budgetCaps: effectiveCaps(entry.spec, undefined),
243
+ nPairs: 1,
244
+ ...(bank === null ? {} : { bank: bank.units }),
245
+ });
246
+ return { verdict: verdict.verdict, gain: verdict.gain, failures: verdict.failures, candidateVerdictBefore: String(candidate.verdict) };
247
+ }
@@ -0,0 +1,166 @@
1
+ // retractGeneration — the human gate that undoes a promote (design gap found
2
+ // 2026-09-28 when the human ordered a rollback of self-cli and the engine had
3
+ // no path: tombstone refuses promoted gens by design, incumbent.ts moves the
4
+ // ref forward-only). Doctrine:
5
+ // - History is append-only: the promote row STAYS; retract appends its own
6
+ // row. No commit moves, no lineage deletes (retract ≠ delete).
7
+ // - LIFO only: a later LIVE promote must be retracted first.
8
+ // - Descendant guard: a generation seeded FROM this incumbent (a
9
+ // generation_complete row appended after the promote row) blocks the
10
+ // retract unless its lineage is dead (tombstoned or retracted).
11
+ // - ref-absent is NOT fail-open: when the incumbent ref is already gone (a
12
+ // documented manual rollback), the retract is recordable only when the gen
13
+ // being retracted is the ONLY live promote — anything else is a divergence
14
+ // the human must resolve first. The row then carries refState:"absent".
15
+ // - The incumbent branch must be idle in EVERY worktree (worktree list
16
+ // --porcelain), not just the primary checkout.
17
+ // - The whole read→guard→mutate→append sequence runs under the genome lock
18
+ // plus a repo-local gate lock (cross-config-home safety). The descendant
19
+ // guard is point-in-time BY DESIGN: a run seeded from this incumbent before
20
+ // the retract may still append its generation_complete afterwards — that row
21
+ // is honest and its commits are preserved (retract ≠ delete); what the lock
22
+ // guarantees is that no decision RACES the retract, not that no lineage
23
+ // outlives it.
24
+ // Import policy mirrors core/promote.ts: this module is imported ONLY by
25
+ // src/commands/retract.ts (pinned by the structural test in promote.test.ts).
26
+ // No force flag: every refusal is a bug report, not a flag.
27
+ import { existsSync, rmSync } from "node:fs";
28
+ import path from "node:path";
29
+ import { blocked, cannotAnswer } from "../exit.js";
30
+ import { tryGit } from "../util/git.js";
31
+ import { gitOpts } from "./genome-paths.js";
32
+ import { INCUMBENT_BRANCH } from "./incumbent.js";
33
+ import { manifestPathFor } from "./kernel.js";
34
+ import { acquireGenomeLock, acquireLock, LEDGER_KIND_PROMOTE, LEDGER_KIND_TOMBSTONE, Ledger } from "./ledger.js";
35
+ import { effectiveRepoPath } from "./spec.js";
36
+ export const LEDGER_KIND_RETRACT = "retract";
37
+ /** Every worktree (primary + linked) that has the incumbent branch checked out. */
38
+ async function incumbentCheckouts(repo, opts) {
39
+ const run = await tryGit(["worktree", "list", "--porcelain"], gitOpts(opts, repo));
40
+ if (!run.ok)
41
+ cannotAnswer(`retract: cannot enumerate worktrees in ${repo}: ${run.error.kind}`);
42
+ // entries are blank-line-separated blocks; attributes (branch, prunable …)
43
+ // come AFTER the worktree line, so evaluate whole blocks, not a line stream
44
+ const busy = [];
45
+ for (const block of run.stdout.split("\n\n")) {
46
+ const lines = block.split("\n");
47
+ const wt = lines.find((l) => l.startsWith("worktree "));
48
+ if (wt === undefined)
49
+ continue;
50
+ // "prunable <reason>" = stale metadata for a deleted directory — it must
51
+ // not permanently block the rollback valve
52
+ if (lines.some((l) => l.startsWith("prunable")))
53
+ continue;
54
+ if (lines.some((l) => l === `branch refs/heads/${INCUMBENT_BRANCH}`))
55
+ busy.push(wt.slice("worktree ".length));
56
+ }
57
+ return busy;
58
+ }
59
+ export async function retractGeneration(req) {
60
+ const repo = effectiveRepoPath(req.entry.spec.repoPath);
61
+ const opts = req.env === undefined ? {} : { env: req.env };
62
+ const gopts = gitOpts(opts, repo);
63
+ const ledger = Ledger.open(repo);
64
+ const lease = acquireGenomeLock({ ledger, configDir: req.configDir, genomeFp: req.entry.fingerprint });
65
+ try {
66
+ // the genome lock is keyed (configDir, fingerprint): two registry entries or two
67
+ // config homes pointing at the SAME repo hold different locks. The ledger and the
68
+ // incumbent ref are repo-global, so the gate sequence also takes a REPO-local
69
+ // lock — same lock regardless of which config home the caller came through.
70
+ // Nested inside the genome-lock try so a contended repo lock never strands it.
71
+ const repoLease = acquireLock({ configDir: path.join(repo, ".state", "abathur"), key: "gate", label: "gate lock" });
72
+ try {
73
+ const records = ledger.readAll();
74
+ const promoteIndex = records.findIndex((r) => r.kind === LEDGER_KIND_PROMOTE && r.genId === req.genId);
75
+ if (promoteIndex < 0) {
76
+ blocked(`retract: no promote ledger row for gen '${req.genId}' of '${req.entry.label}' — nothing to retract`, "retract undoes a promote; for a never-promoted candidate use `abathur tombstone`");
77
+ }
78
+ const promoteRow = records[promoteIndex];
79
+ if (records.some((r) => r.kind === LEDGER_KIND_RETRACT && r.genId === req.genId)) {
80
+ blocked(`retract: gen '${req.genId}' is already retracted — nothing to do`);
81
+ }
82
+ // a lineage is dead once tombstoned (never promoted) or retracted (promoted,
83
+ // then undone): it no longer sits on the incumbent chain.
84
+ const released = new Set(records.filter((r) => r.kind === LEDGER_KIND_TOMBSTONE || r.kind === LEDGER_KIND_RETRACT).map((r) => r.genId));
85
+ const livePromotes = records.filter((r) => r.kind === LEDGER_KIND_PROMOTE && !released.has(r.genId));
86
+ const later = records.slice(promoteIndex + 1);
87
+ if (later.some((r) => r.kind === LEDGER_KIND_PROMOTE && !released.has(r.genId))) {
88
+ blocked(`retract: a later promote exists for '${req.entry.label}' — retract is LIFO`, "retract the later generation first, then re-run this retract");
89
+ }
90
+ const orphans = later.filter((r) => r.kind === "generation_complete" && !released.has(r.genId));
91
+ if (orphans.length > 0) {
92
+ blocked(`retract: ${String(orphans.length)} generation(s) were seeded from this incumbent after the promote — retracting would orphan them`, "let them conclude and tombstone the culled ones first, then re-run this retract");
93
+ }
94
+ const to = promoteRow.data.to;
95
+ if (typeof to !== "string" || !/^[0-9a-f]{40}$/.test(to)) {
96
+ cannotAnswer(`retract: promote row for '${req.genId}' lacks a full commit sha — ledger inconsistent, refusing`);
97
+ }
98
+ const busy = await incumbentCheckouts(repo, opts);
99
+ if (busy.length > 0) {
100
+ blocked(`retract: ${INCUMBENT_BRANCH} is checked out in ${busy.join(", ")} — retract needs it idle`, "switch that worktree to another branch first; if the listing is stale, run `git worktree prune` in the genome repo");
101
+ }
102
+ // for-each-ref: ok+empty = genuinely absent; ok+sha = present; !ok = git
103
+ // itself failed (corruption/lock) — never launder a git failure into "absent"
104
+ const ref = await tryGit(["for-each-ref", "--format=%(objectname)", `refs/heads/${INCUMBENT_BRANCH}`], gopts);
105
+ if (!ref.ok) {
106
+ cannotAnswer(`retract: cannot read ${INCUMBENT_BRANCH} in ${repo}: ${ref.error.kind} — nothing was touched`);
107
+ }
108
+ let refState;
109
+ let removedSha = null;
110
+ const current = ref.stdout.trim();
111
+ if (current !== "") {
112
+ if (current !== to) {
113
+ blocked(`retract: ${INCUMBENT_BRANCH} now points at ${current.slice(0, 12)} but the promote row recorded ${to.slice(0, 12)} — state diverged, refusing`, "investigate with `git -C <repo> log abathur/incumbent` and the ledger before any manual surgery");
114
+ }
115
+ // compare-and-delete: the old value pins what we verified, so a ref moved
116
+ // between the check and the delete makes git refuse instead of clobbering
117
+ const del = await tryGit(["update-ref", "-d", `refs/heads/${INCUMBENT_BRANCH}`, current], gopts);
118
+ if (!del.ok) {
119
+ blocked(`retract: ${INCUMBENT_BRANCH} moved between verification and deletion — refusing rather than deleting an unverified ref`, "re-run the retract; if it keeps failing, inspect the ref with git directly");
120
+ }
121
+ removedSha = current;
122
+ refState = "deleted";
123
+ }
124
+ else {
125
+ // Manual-rollback path (documented 2026-09-28): recordable ONLY when this
126
+ // gen is the sole live promote — otherwise the absent ref means a
127
+ // divergence retract must not launder into an official-looking row.
128
+ if (livePromotes.length !== 1) {
129
+ blocked(`retract: ${INCUMBENT_BRANCH} is absent but ${String(livePromotes.length)} live promotes exist — cannot verify what the retract undoes`, "restore the ref to the promoted commit (or resolve the divergence) before retracting");
130
+ }
131
+ refState = "absent";
132
+ }
133
+ const manifestFile = manifestPathFor(req.configDir, req.entry.fingerprint);
134
+ const manifestBefore = existsSync(manifestFile);
135
+ rmSync(manifestFile, { force: true });
136
+ const manifestGone = !existsSync(manifestFile);
137
+ // verbatim in the ledger (JSON-escaped on disk); printable-ASCII echo only
138
+ ledger.append({
139
+ kind: LEDGER_KIND_RETRACT,
140
+ genId: req.genId,
141
+ data: { actor: "cli", genId: req.genId, reason: req.reason, retractedTo: to, refState },
142
+ });
143
+ const flat = req.reason.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
144
+ const echoReason = flat.length > 120 ? `${flat.slice(0, 120)}…` : flat;
145
+ const lines = [
146
+ `retract recorded: '${req.genId}' (reason: ${echoReason})`,
147
+ removedSha === null
148
+ ? `${INCUMBENT_BRANCH} was already absent (manual rollback) — recorded with refState=absent`
149
+ : `${INCUMBENT_BRANCH} removed (was ${removedSha.slice(0, 12)}) — genome returns to incumbent: none`,
150
+ manifestBefore && manifestGone
151
+ ? `kernel manifest cleared: ${manifestFile}`
152
+ : !manifestBefore && manifestGone
153
+ ? `kernel manifest already absent: ${manifestFile}`
154
+ : `kernel manifest REMOVAL UNVERIFIED: ${manifestFile} still exists — investigate`,
155
+ `the promote row remains in the append-only ledger; candidate commit ${to.slice(0, 12)} preserved (retract ≠ delete)`,
156
+ ];
157
+ return { genId: req.genId, lines };
158
+ }
159
+ finally {
160
+ repoLease.release();
161
+ }
162
+ }
163
+ finally {
164
+ lease.release();
165
+ }
166
+ }
@@ -74,6 +74,45 @@ function betai(a, b, x) {
74
74
  return (front * betacf(a, b, x)) / a;
75
75
  return 1 - (front * betacf(b, a, 1 - x)) / b;
76
76
  }
77
+ /**
78
+ * Standard normal CDF Φ(x): Abramowitz-Stegun 7.1.26 erf approximation,
79
+ * |error| < 1.5e-7 — far tighter than the 4-decimal P values the acceptance
80
+ * gate reports. ±∞ map to 1/0; NaN maps to NaN (callers branch on se===0
81
+ * before dividing, so 0/0 NaN never reaches here by design).
82
+ */
83
+ export function normalCdf(x) {
84
+ if (Number.isNaN(x))
85
+ return Number.NaN;
86
+ if (x === Number.POSITIVE_INFINITY)
87
+ return 1;
88
+ if (x === Number.NEGATIVE_INFINITY)
89
+ return 0;
90
+ const z = Math.abs(x) / Math.SQRT2;
91
+ const t = 1 / (1 + 0.3275911 * z);
92
+ const poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
93
+ const erf = 1 - poly * Math.exp(-z * z);
94
+ return 0.5 * (1 + (x < 0 ? -erf : erf));
95
+ }
96
+ /**
97
+ * Inverse standard normal Φ⁻¹(p), bisection against normalCdf above — the two
98
+ * are mutually consistent by construction (a band built from normalQuantile(q)
99
+ * and tested through normalCdf lands exactly at probability q). Same shape as
100
+ * studentTQuantile's bisection; |error| < 1e-13.
101
+ */
102
+ export function normalQuantile(p) {
103
+ if (!(p > 0 && p < 1))
104
+ throw new RangeError(`normalQuantile: p=${String(p)} must be in (0, 1)`);
105
+ let lo = -40;
106
+ let hi = 40;
107
+ for (let i = 0; i < 80; i += 1) {
108
+ const mid = (lo + hi) / 2;
109
+ if (normalCdf(mid) < p)
110
+ lo = mid;
111
+ else
112
+ hi = mid;
113
+ }
114
+ return (lo + hi) / 2;
115
+ }
77
116
  /**
78
117
  * Two-sided Student-t critical value: the t such that P(|T| ≤ t) = 1 − alpha
79
118
  * with T ~ t(df). Planned accuracy: matches t-table values to < 1e-3 for the