@tachikomagundam/abathur 0.2.6 → 0.2.8

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 (56) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/readjudicate.js +28 -0
  4. package/dist/commands/retract.js +53 -0
  5. package/dist/commands/tombstone.js +28 -15
  6. package/dist/core/evolve/run-bench.js +7 -1
  7. package/dist/core/evolve/run-rows.js +14 -0
  8. package/dist/core/ledger.js +2 -0
  9. package/dist/core/promote.js +61 -46
  10. package/dist/core/readjudicate.js +247 -0
  11. package/dist/core/retract.js +166 -0
  12. package/dist/core/stats.js +7 -0
  13. package/dist/core/worktree.js +1 -1
  14. package/dist/genomes/toy-smoke/init.mjs +1 -1
  15. package/dist/test/bundle.test.js +1 -1
  16. package/dist/test/fixture-loop.test.js +1 -1
  17. package/dist/test/fixtures-self.js +1 -1
  18. package/dist/test/fixtures-wt.js +1 -1
  19. package/dist/test/friction.test.js +3 -3
  20. package/dist/test/graft.test.js +1 -1
  21. package/dist/test/historian-run-scenario.test.js +2 -2
  22. package/dist/test/promote.test.js +222 -5
  23. package/dist/test/readjudicate.test.js +212 -0
  24. package/dist/test/reflect.test.js +1 -1
  25. package/dist/test/repopath-seams.test.js +1 -0
  26. package/dist/test/seam-gates.test.js +6 -0
  27. package/dist/test/self-snapshot.test.js +2 -2
  28. package/dist/test/stats.test.js +17 -0
  29. package/docs/AGENTISM.md +58 -0
  30. package/docs/release/README.md +9 -0
  31. package/docs/release/TEMPLATE.md +10 -0
  32. package/docs/release/v0.2.7.md +7 -0
  33. package/docs/release/v0.2.8.md +8 -0
  34. package/graders/historian/judge-poststage.mjs +8 -3
  35. package/graders/pcb-agent/bench-approver.py +77 -0
  36. package/graders/pcb-agent/grader.mjs +130 -0
  37. package/graders/pcb-agent/mutate.sh +165 -0
  38. package/graders/pcb-agent/r20-bench.sh +27 -0
  39. package/graders/pcb-agent/reset-sandbox.sh +7 -0
  40. package/graders/pcb-agent/run-scenario-r20.sh +77 -0
  41. package/graders/pcb-agent/run-scenario.sh +94 -0
  42. package/graders/pcb-agent/seed/esp32s3-r12-BRIEF.txt +51 -0
  43. package/graders/pcb-agent/seed/spec.json +129 -0
  44. package/graders/pcb-agent/seed/tools/board_author_pcb.py +237 -0
  45. package/graders/pcb-agent/seed/tools/board_author_sch.py +118 -0
  46. package/graders/pcb-agent/seed/tools/board_finalize.sh +23 -0
  47. package/graders/pcb-agent/seed/tools/board_gate.py +69 -0
  48. package/graders/pcb-agent/seed/tools/board_gate_pcb.py +23 -0
  49. package/graders/pcb-agent/seed/tools/board_route.py +324 -0
  50. package/graders/pcb-agent/seed/tools/board_zone_pour.py +312 -0
  51. package/graders/pcb-agent/seed/tools/claims_lint.py +417 -0
  52. package/graders/pcb-agent/seed-r20-arena.sh +54 -0
  53. package/graders/pcb-agent/seed-sandbox.sh +13 -0
  54. package/package.json +1 -1
  55. package/plugin/abathur-command.md +3 -3
  56. package/plugin/abathur.ts +6 -6
package/README.md CHANGED
@@ -30,7 +30,7 @@ instance learn from another's evidence; nothing in v1 talks to a network.
30
30
  `peerClaim` metadata and never feed nomination math.
31
31
  - **run loop** — incumbent → brief (failure observations) → mutator session →
32
32
  candidate diffs → seal → re-bench → verdicts. Nominated generations sit in
33
- the quarantine queue until a human runs `promote`; `tombstone` buries them.
33
+ the quarantine queue until a human runs `promote`; `tombstone` buries them; `retract` undoes a promote (LIFO, descendant-guarded).
34
34
  Both are append-only ledger decisions; nothing is ever deleted.
35
35
 
36
36
  ### Install
@@ -289,7 +289,7 @@ table are [docs/federation.md](docs/federation.md).
289
289
  same historian genome against one wiki will clobber each other mid-run.
290
290
  Cross-machine serialization of historian benches is the operator's
291
291
  responsibility (schedule, lockbox, one-wiki-one-bench rule — your call).
292
- - Promotion and burial are yours alone: `promote`/`tombstone` carry no
292
+ - Promotion, burial and retraction are yours alone: `promote`/`tombstone`/`retract` carry no
293
293
  `--confirm` because the CLI invocation **is** the gate; there is no
294
294
  `--force` anywhere in the binary. Review `abathur status` quarantine depth
295
295
  and the `graft decisions` rows before promoting.
@@ -476,7 +476,7 @@ opencode 会在启动时自行从 npm 把包下载到它自己的缓存
476
476
  `self-eval`、`kernel`、`--help`),120 秒超时、64 KB 输出封顶。二进制从
477
477
  `PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。诚实的权限说明:该工具具备与
478
478
  bash 等同的权限——`run` 与 `genome` 按设计就会 spawn 变异器/引擎二进制——
479
- 所以允许清单限制的是笔误与体验,而非能力。`promote` 与 `tombstone` 刻意
479
+ 所以允许清单限制的是笔误与体验,而非能力。`promote`、`tombstone` 与 `retract` 刻意
480
480
  不可经由工具触达:它们是人类闸门,必须在终端里运行。
481
481
  - 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数
482
482
  翻译成工具调用并回报退出码。路线 A 由复制的 `commands/abathur.md`
package/dist/cli.js CHANGED
@@ -12,10 +12,12 @@ import { graftCommand } from "./commands/graft.js";
12
12
  import { kernelCommand } from "./commands/kernel.js";
13
13
  import { opencodeCommand } from "./commands/opencode.js";
14
14
  import { promoteCommand } from "./commands/promote.js";
15
+ import { readjudicateCommand } from "./commands/readjudicate.js";
15
16
  import { runCommand } from "./commands/run.js";
16
17
  import { selfEvalCommand } from "./commands/self-eval.js";
17
18
  import { statusCommand } from "./commands/status.js";
18
19
  import { tombstoneCommand } from "./commands/tombstone.js";
20
+ import { retractCommand } from "./commands/retract.js";
19
21
  import { ConfigError, loadConfig } from "./config.js";
20
22
  import { EXIT_CANNOT_ANSWER, EXIT_OK, ExitSignal, cannotAnswer, renderExitSignal, } from "./exit.js";
21
23
  // Router order mirrors the plan; summaries appear verbatim in --help.
@@ -24,7 +26,9 @@ export const COMMANDS = [
24
26
  runCommand,
25
27
  statusCommand,
26
28
  promoteCommand,
29
+ readjudicateCommand,
27
30
  tombstoneCommand,
31
+ retractCommand,
28
32
  bundleCommand,
29
33
  graftCommand,
30
34
  selfEvalCommand,
@@ -0,0 +1,28 @@
1
+ // `abathur re-adjudicate <label> <genId>` — replay the CURRENT gate over an epoch's
2
+ // archived rows and append a corrected verdict row (with provenance). Re-judges, never
3
+ // flips by hand; promote continues to read the LAST row. See core/readjudicate.ts for
4
+ // the replay contract (counters minus own slot, bank excludes this candidate's trees).
5
+ import { resolveConfigDir } from "../config.js";
6
+ import { EXIT_OK, EXIT_CANNOT_ANSWER, cannotAnswer } from "../exit.js";
7
+ import { readjudicateGeneration } from "../core/readjudicate.js";
8
+ import { writeStdout } from "../out.js";
9
+ import { resolveUniqueEntry } from "./run.js";
10
+ const USAGE = "usage: abathur re-adjudicate <label> <genId>";
11
+ function runReadjudicate(args) {
12
+ const [label, genId] = args;
13
+ if (label === undefined || genId === undefined || args.length !== 2)
14
+ cannotAnswer(`re-adjudicate: expected exactly <label> <genId>`, USAGE);
15
+ const configDir = resolveConfigDir();
16
+ const entry = resolveUniqueEntry(configDir, label, "re-adjudicate");
17
+ const outcome = readjudicateGeneration({ entry, configDir, genId, now: () => new Date() });
18
+ for (const line of outcome.lines)
19
+ writeStdout(line);
20
+ // an inconclusive replay means the gate cannot answer — surface it in the exit code,
21
+ // matching the VERDICT_EXIT semantics of run/promote consumers.
22
+ return outcome.verdict === "inconclusive" ? EXIT_CANNOT_ANSWER : EXIT_OK;
23
+ }
24
+ export const readjudicateCommand = {
25
+ name: "re-adjudicate",
26
+ summary: "replay the current gate over a gen's archived rows; append corrected verdict + provenance (files stay immutable)",
27
+ run: ({ args }) => Promise.resolve(runReadjudicate(args)),
28
+ };
@@ -0,0 +1,53 @@
1
+ // `abathur retract <label> <genId> --reason <text>` — the human gate that
2
+ // undoes a promote (core in core/retract.ts, imported ONLY here — the
3
+ // import-policy pin lives in src/test/promote.test.ts). Terminal-only like
4
+ // promote/tombstone: the opencode plugin tool must never reach it. No --force:
5
+ // every refusal is a bug report, not a flag.
6
+ import { resolveConfigDir } from "../config.js";
7
+ import { EXIT_OK, cannotAnswer } from "../exit.js";
8
+ import { retractGeneration } from "../core/retract.js";
9
+ import { writeStdout } from "../out.js";
10
+ import { resolveUniqueEntry } from "./run.js";
11
+ const USAGE = "usage: abathur retract <label> <genId> --reason <text>";
12
+ const MAX_REASON_CHARS = 500;
13
+ async function runRetract(args) {
14
+ const positional = [];
15
+ let reason = null;
16
+ for (let i = 0; i < args.length; i += 1) {
17
+ const arg = args[i];
18
+ if (arg === "--reason") {
19
+ const value = args[(i += 1)];
20
+ if (value === undefined || value.startsWith("--"))
21
+ cannotAnswer("retract: --reason requires a value", USAGE);
22
+ if (reason !== null)
23
+ cannotAnswer("retract: --reason given twice", USAGE);
24
+ reason = value;
25
+ }
26
+ else if (arg.startsWith("--")) {
27
+ cannotAnswer(`retract: unknown flag ${arg}`, USAGE);
28
+ }
29
+ else {
30
+ positional.push(arg);
31
+ }
32
+ }
33
+ const [label, genId] = positional;
34
+ if (label === undefined || genId === undefined || positional.length !== 2) {
35
+ cannotAnswer("retract: expected exactly <label> <genId> --reason <text>", USAGE);
36
+ }
37
+ if (reason === null || reason.trim().length === 0) {
38
+ cannotAnswer("retract: --reason <text> is required (why is this promote being undone?)", USAGE);
39
+ }
40
+ if (reason.length > MAX_REASON_CHARS)
41
+ cannotAnswer(`retract: --reason exceeds ${String(MAX_REASON_CHARS)} chars`, USAGE);
42
+ const configDir = resolveConfigDir();
43
+ const entry = resolveUniqueEntry(configDir, label, "retract");
44
+ const outcome = await retractGeneration({ entry, configDir, genId, reason });
45
+ for (const line of outcome.lines)
46
+ writeStdout(line);
47
+ return EXIT_OK;
48
+ }
49
+ export const retractCommand = {
50
+ name: "retract",
51
+ summary: "human gate: undo a promote (LIFO, descendant-guarded; append-only row, ref deleted, manifest cleared)",
52
+ run: ({ args }) => runRetract(args),
53
+ };
@@ -3,13 +3,13 @@
3
3
  // row survive; the reason is stored verbatim (canonicalJson escapes control
4
4
  // bytes) but NEVER echoed raw — the confirmation line is sanitized like todo-8's
5
5
  // sealMessage so untrusted text cannot forge terminal output.
6
- import { Ledger } from "../core/ledger.js";
6
+ import path from "node:path";
7
+ import { acquireGenomeLock, acquireLock, LEDGER_KIND_TOMBSTONE, Ledger } from "../core/ledger.js";
7
8
  import { effectiveRepoPath } from "../core/spec.js";
8
9
  import { resolveConfigDir } from "../config.js";
9
10
  import { EXIT_OK, blocked, cannotAnswer } from "../exit.js";
10
11
  import { writeStdout } from "../out.js";
11
12
  import { resolveUniqueEntry } from "./run.js";
12
- const LEDGER_KIND_TOMBSTONE = "tombstone";
13
13
  const MAX_REASON_CHARS = 4000;
14
14
  const USAGE = "usage: abathur tombstone <label> <genId> --reason <text>";
15
15
  function echoed(reason) {
@@ -50,21 +50,34 @@ function runTombstone(args) {
50
50
  const configDir = resolveConfigDir();
51
51
  const entry = resolveUniqueEntry(configDir, label, "tombstone");
52
52
  const ledger = Ledger.open(effectiveRepoPath(entry.spec.repoPath));
53
- const records = ledger.readAll();
54
- if (!records.some((r) => r.kind === "generation_complete" && r.genId === genId)) {
55
- blocked(`tombstone: no generation_complete ledger row for gen '${genId}' of '${label}' — nothing to bury`);
56
- }
57
- const promoted = records.find((r) => r.kind === "promote" && r.genId === genId);
58
- if (promoted !== undefined) {
59
- blocked(`tombstone: gen '${genId}' was already promoted at ${promoted.ts} — cull is for candidates the human never accepted`);
53
+ // single-flight with the run loop and sibling gates (same postmortem as promote)
54
+ const lease = acquireGenomeLock({ ledger, configDir, genomeFp: entry.fingerprint });
55
+ try {
56
+ const repoLease = acquireLock({ configDir: path.join(effectiveRepoPath(entry.spec.repoPath), ".state", "abathur"), key: "gate", label: "gate lock" });
57
+ try {
58
+ const records = ledger.readAll();
59
+ if (!records.some((r) => r.kind === "generation_complete" && r.genId === genId)) {
60
+ blocked(`tombstone: no generation_complete ledger row for gen '${genId}' of '${label}' — nothing to bury`);
61
+ }
62
+ const promoted = records.find((r) => r.kind === "promote" && r.genId === genId);
63
+ if (promoted !== undefined) {
64
+ blocked(`tombstone: gen '${genId}' was already promoted at ${promoted.ts} — cull is for candidates the human never accepted`);
65
+ }
66
+ const buried = records.find((r) => r.kind === LEDGER_KIND_TOMBSTONE && r.genId === genId);
67
+ if (buried !== undefined) {
68
+ blocked(`tombstone: gen '${genId}' is already tombstoned at ${buried.ts}`);
69
+ }
70
+ ledger.append({ kind: LEDGER_KIND_TOMBSTONE, genId, data: { genId, reason, actor: "cli" } });
71
+ writeStdout(`tombstone recorded: '${genId}' (reason: ${echoed(reason)}) — commits and lineage preserved (cull ≠ delete)`);
72
+ return EXIT_OK;
73
+ }
74
+ finally {
75
+ repoLease.release();
76
+ }
60
77
  }
61
- const buried = records.find((r) => r.kind === LEDGER_KIND_TOMBSTONE && r.genId === genId);
62
- if (buried !== undefined) {
63
- blocked(`tombstone: gen '${genId}' is already tombstoned at ${buried.ts}`);
78
+ finally {
79
+ lease.release();
64
80
  }
65
- ledger.append({ kind: LEDGER_KIND_TOMBSTONE, genId, data: { genId, reason, actor: "cli" } });
66
- writeStdout(`tombstone recorded: '${genId}' (reason: ${echoed(reason)}) — commits and lineage preserved (cull ≠ delete)`);
67
- return EXIT_OK;
68
81
  }
69
82
  export const tombstoneCommand = {
70
83
  name: "tombstone",
@@ -138,7 +138,13 @@ export async function benchTarget(o) {
138
138
  continue;
139
139
  }
140
140
  const score = await bundle.adapter.score(unit);
141
- if (score.kind === "scored") {
141
+ if (score.kind === "scored" && !Number.isFinite(score.result.score)) {
142
+ // r21 injury ticket: a NaN/Inf score is a BROKEN RULER, not a number —
143
+ // it must never become a replicate (vacuous aggregates minted bogus
144
+ // nominations: stats saw finite-looking rows and judged on nothing).
145
+ row.failures.push(`${unit.id} rep ${String(rep)}: grader score not finite (${String(score.result.score)}) — ruler failure, never counts as evidence`);
146
+ }
147
+ else if (score.kind === "scored") {
142
148
  row.scores.push(score.result.score);
143
149
  row.runIds.push(runId(o.genId, unit.id, rep));
144
150
  if (!score.result.pass) {
@@ -49,6 +49,20 @@ export const generationRowDataSchema = z
49
49
  gateFailures: z.array(z.string()).optional(),
50
50
  /** Unrelated worktree dirtiness notices (todo 3 contract: todo 9 books them). */
51
51
  dirtyWorktree: z.array(z.strictObject({ xy: z.string(), file: z.string() })).optional(),
52
+ /** Present only on rows appended by `abathur re-adjudicate`: the corrected verdict with
53
+ * full replay provenance (source rows, bank state, replaced verdict). The original row
54
+ * stays in the files untouched — this field documents the amendment, never hides it. */
55
+ readjudication: z.strictObject({
56
+ at: z.string().min(1),
57
+ specFingerprint: z.string().min(1),
58
+ gate: z.enum(["legacy", "acceptance"]),
59
+ bank: z.strictObject({ units: z.number().int().nonnegative(), priorSigma: z.number(), pooledDf: z.number() }).nullable(),
60
+ sourceRows: z.strictObject({
61
+ candidate: z.strictObject({ file: z.string().min(1), ts: z.string().min(1) }),
62
+ incumbent: z.strictObject({ file: z.string().min(1), ts: z.string().min(1) }),
63
+ }),
64
+ replacedVerdict: z.string().min(1),
65
+ }).optional(),
52
66
  benchProvenance: z.strictObject({
53
67
  benchType: z.enum(["toy", "opencode-fixture-scenarios"]),
54
68
  versions: z.array(z.strictObject({ bin: z.string().min(1), version: z.string() })),
@@ -14,6 +14,8 @@ import { acquireLock } from "./locks.js";
14
14
  export { acquireLock, lockDirFor } from "./locks.js";
15
15
  export const LEDGER_KIND_LOCK_TAKEOVER = "lock_takeover";
16
16
  export const LEDGER_KIND_GENERATION_COMPLETE = "generation_complete";
17
+ export const LEDGER_KIND_PROMOTE = "promote";
18
+ export const LEDGER_KIND_TOMBSTONE = "tombstone";
17
19
  export const ledgerRecordSchema = z.strictObject({
18
20
  v: z.literal(1),
19
21
  ts: z.string().min(1),
@@ -11,12 +11,12 @@ import { compileGlob } from "./glob.js";
11
11
  import { fastForwardIncumbent } from "./incumbent.js";
12
12
  import { effectiveRepoPath } from "./spec.js";
13
13
  import { buildManifest, kernelsDirOf, manifestPathFor, readManifestFile, serializeManifest, } from "./kernel.js";
14
- import { Ledger, ledgerPath } from "./ledger.js";
14
+ import { acquireGenomeLock, acquireLock, LEDGER_KIND_PROMOTE, Ledger, ledgerPath } from "./ledger.js";
15
15
  import { gitOpts, requireSha } from "./genome-paths.js";
16
16
  import { snapshotCommit } from "./snapshot.js";
17
17
  import { tryGit } from "../util/git.js";
18
18
  import { decodeGenerationRecord } from "./evolve/run-bench.js";
19
- export const LEDGER_KIND_PROMOTE = "promote";
19
+ export { LEDGER_KIND_PROMOTE };
20
20
  /** Ledger/display-safe flattening of untrusted text (todo-8 sealMessage discipline). */
21
21
  function display(value, max = 96) {
22
22
  const flat = value.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
@@ -70,51 +70,66 @@ export async function promoteGeneration(req) {
70
70
  const shown = display(req.genId);
71
71
  const opts = req.env === undefined ? {} : { env: req.env };
72
72
  const ledger = Ledger.open(repo);
73
- const records = ledger.readAll();
74
- const genRows = records.filter((r) => r.kind === "generation_complete" && r.genId === req.genId);
75
- const lastRow = genRows.at(-1);
76
- if (lastRow === undefined) {
77
- 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}\``);
78
- }
79
- if (records.some((r) => r.kind === LEDGER_KIND_PROMOTE && r.genId === req.genId)) {
80
- blocked(`promote: gen '${shown}' is already promoted (promote row in ledger) — nothing to do`);
81
- }
82
- const nom = nominatedGeneration(decodeGenerationRecord(lastRow), shown);
83
- const manifestFile = manifestPathFor(configDir, entry.fingerprint);
84
- 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 });
85
77
  try {
86
- 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
+ }
87
131
  }
88
- catch (cause) {
89
- if (cause instanceof ExitSignal)
90
- throw cause;
91
- const message = cause instanceof Error ? cause.message : String(cause);
92
- cannotAnswer(`promote: kernel manifest unreadable, refusing to gate: ${message}`);
132
+ finally {
133
+ lease.release();
93
134
  }
94
- const sealedPaths = new Set(manifestEntries.map((e) => e.path));
95
- const matchers = entry.spec.kernel.immutableGlobs.map((glob) => compileGlob(glob));
96
- await sealedPathCheck(req.genId, nom, matchers, sealedPaths, repo, opts);
97
- const genome = { repoPath: repo, genomeFp: entry.fingerprint };
98
- const ff = await fastForwardIncumbent(genome, nom.commitSha, opts);
99
- // regeneration happens ONLY here, after the human-invoked diff passed:
100
- // manifest reborn from the new incumbent's committed tree, not the worktree.
101
- const snap = await snapshotCommit(genome, nom.commitSha, opts);
102
- const fresh = buildManifest(snap.snapshotPath, entry.spec.kernel.immutableGlobs);
103
- mkdirSync(kernelsDirOf(configDir), { recursive: true });
104
- writeFileSync(manifestFile, serializeManifest(fresh), "utf8");
105
- ledger.append({
106
- kind: LEDGER_KIND_PROMOTE,
107
- genId: req.genId,
108
- data: { actor: "cli", genId: req.genId, from: ff.fromSha, to: ff.toSha },
109
- });
110
- return {
111
- genId: req.genId,
112
- fromSha: ff.fromSha,
113
- toSha: ff.toSha,
114
- lines: [
115
- `promoted '${entry.label}' (${entry.fingerprint}): ${ff.fromSha === null ? "incumbent branch created" : short(ff.fromSha)} -> ${short(ff.toSha)} (gen ${shown})`,
116
- `manifest: rewrote ${path.relative(process.cwd(), manifestFile) || manifestFile} from tree ${short(nom.commitSha)} (${String(fresh.length)} sealed ${fresh.length === 1 ? "entry" : "entries"})`,
117
- `ledger: appended '${LEDGER_KIND_PROMOTE}' row (actor: cli) to ${ledgerPath(repo)}`,
118
- ],
119
- };
120
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
+ }