@tachikomagundam/abathur 0.1.0

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 (119) hide show
  1. package/.github/workflows/ci.yml +29 -0
  2. package/.github/workflows/publish.yml +74 -0
  3. package/LICENSE +21 -0
  4. package/README.md +461 -0
  5. package/config/abathur.jsonc +17 -0
  6. package/config/genomes/historian.example.jsonc +124 -0
  7. package/dist/bench/adapter.js +201 -0
  8. package/dist/bench/fixture-probe.js +92 -0
  9. package/dist/bench/fixture-support.js +173 -0
  10. package/dist/bench/fixture.js +236 -0
  11. package/dist/bench/toy.js +152 -0
  12. package/dist/cli.js +110 -0
  13. package/dist/commands/bundle.js +79 -0
  14. package/dist/commands/genome.js +94 -0
  15. package/dist/commands/graft.js +71 -0
  16. package/dist/commands/kernel.js +47 -0
  17. package/dist/commands/promote.js +25 -0
  18. package/dist/commands/run.js +145 -0
  19. package/dist/commands/self-eval.js +240 -0
  20. package/dist/commands/status.js +186 -0
  21. package/dist/commands/tombstone.js +72 -0
  22. package/dist/config.js +161 -0
  23. package/dist/core/bundle-common.js +119 -0
  24. package/dist/core/bundle-export.js +212 -0
  25. package/dist/core/bundle-inspect.js +143 -0
  26. package/dist/core/bundle-manifest.js +105 -0
  27. package/dist/core/bundle-mask.js +75 -0
  28. package/dist/core/bundle-tar.js +240 -0
  29. package/dist/core/bundle.js +9 -0
  30. package/dist/core/evolve/brief.js +45 -0
  31. package/dist/core/evolve/candidate.js +140 -0
  32. package/dist/core/evolve/child-track.js +197 -0
  33. package/dist/core/evolve/friction.js +150 -0
  34. package/dist/core/evolve/reflect.js +191 -0
  35. package/dist/core/evolve/run-bench.js +170 -0
  36. package/dist/core/evolve/run-friction.js +63 -0
  37. package/dist/core/evolve/run-loop.js +282 -0
  38. package/dist/core/evolve/run-plan.js +39 -0
  39. package/dist/core/evolve/run-rows.js +145 -0
  40. package/dist/core/evolve/self-overlay.js +213 -0
  41. package/dist/core/evolve/self-snapshot.js +170 -0
  42. package/dist/core/evolve/stub-mutators.mjs +105 -0
  43. package/dist/core/evolve/udiff.js +189 -0
  44. package/dist/core/genome-paths.js +76 -0
  45. package/dist/core/genome.js +176 -0
  46. package/dist/core/glob.js +106 -0
  47. package/dist/core/graft-gates.js +184 -0
  48. package/dist/core/graft-rebench.js +187 -0
  49. package/dist/core/graft-support.js +181 -0
  50. package/dist/core/graft.js +218 -0
  51. package/dist/core/ids.js +154 -0
  52. package/dist/core/incumbent.js +46 -0
  53. package/dist/core/kernel.js +112 -0
  54. package/dist/core/ledger.js +198 -0
  55. package/dist/core/locks.js +172 -0
  56. package/dist/core/promote.js +119 -0
  57. package/dist/core/snapshot.js +61 -0
  58. package/dist/core/spec.js +178 -0
  59. package/dist/core/stats-math.js +102 -0
  60. package/dist/core/stats-pareto.js +57 -0
  61. package/dist/core/stats.js +184 -0
  62. package/dist/core/worktree.js +190 -0
  63. package/dist/exit.js +32 -0
  64. package/dist/genomes/toy-smoke/genome.jsonc +30 -0
  65. package/dist/genomes/toy-smoke/grader.mjs +61 -0
  66. package/dist/genomes/toy-smoke/init.mjs +63 -0
  67. package/dist/genomes/toy-smoke/units/add.mjs +17 -0
  68. package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
  69. package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
  70. package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
  71. package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
  72. package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
  73. package/dist/jsonc.js +77 -0
  74. package/dist/out.js +5 -0
  75. package/dist/test/bench-adapter.test.js +33 -0
  76. package/dist/test/bench-fixture.test.js +407 -0
  77. package/dist/test/bench-toy.test.js +251 -0
  78. package/dist/test/bundle.test.js +659 -0
  79. package/dist/test/config.test.js +185 -0
  80. package/dist/test/d7-gate.test.js +56 -0
  81. package/dist/test/fixture-loop.test.js +267 -0
  82. package/dist/test/fixtures/friction-writer.js +16 -0
  83. package/dist/test/fixtures-historian.js +82 -0
  84. package/dist/test/fixtures-self.js +143 -0
  85. package/dist/test/fixtures-wt.js +64 -0
  86. package/dist/test/friction.test.js +398 -0
  87. package/dist/test/genome.test.js +453 -0
  88. package/dist/test/git.test.js +69 -0
  89. package/dist/test/graft.test.js +567 -0
  90. package/dist/test/historian-genome.test.js +134 -0
  91. package/dist/test/historian-grader-io.test.js +148 -0
  92. package/dist/test/historian-grader.test.js +209 -0
  93. package/dist/test/ids.test.js +116 -0
  94. package/dist/test/include-val.test.js +120 -0
  95. package/dist/test/ledger-lock.test.js +99 -0
  96. package/dist/test/ledger.test.js +102 -0
  97. package/dist/test/promote.test.js +394 -0
  98. package/dist/test/reflect.test.js +410 -0
  99. package/dist/test/run-loop.test.js +433 -0
  100. package/dist/test/self-snapshot.test.js +328 -0
  101. package/dist/test/snapshot.test.js +86 -0
  102. package/dist/test/stats.test.js +423 -0
  103. package/dist/test/stub-mutators.test.js +17 -0
  104. package/dist/test/testutil.js +30 -0
  105. package/dist/test/worktree.test.js +198 -0
  106. package/dist/util/freeze.js +30 -0
  107. package/dist/util/git.js +85 -0
  108. package/docs/federation.md +184 -0
  109. package/docs/immutable-kernel.md +87 -0
  110. package/graders/historian/grader-core.d.mts +53 -0
  111. package/graders/historian/grader-core.mjs +276 -0
  112. package/graders/historian/grader-support.d.mts +57 -0
  113. package/graders/historian/grader-support.mjs +137 -0
  114. package/graders/historian/grader.mjs +113 -0
  115. package/graders/historian/mutate.sh +114 -0
  116. package/graders/historian/reset-sandbox.sh +60 -0
  117. package/graders/historian/run-scenario.sh +49 -0
  118. package/graders/historian/seed-wrapped.sh +32 -0
  119. package/package.json +42 -0
@@ -0,0 +1,198 @@
1
+ // src/core/worktree.ts acceptance pins (todo 3) — plan AC line 98 verbatim:
2
+ // init → generation commit preserves the parent sha; two concurrent generations
3
+ // coexist; after cleanup `git -C <repo> status --porcelain` shows no abathur
4
+ // paths. Plus refusal/stale-state adversarial classes.
5
+ import assert from "node:assert/strict";
6
+ import { mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
7
+ import * as os from "node:os";
8
+ import path from "node:path";
9
+ import test from "node:test";
10
+ import { ExitSignal } from "../exit.js";
11
+ import { cacheRoot, cleanupStale, fastForwardIncumbent, INCUMBENT_BRANCH, newGeneration, openGenome, sealGeneration, } from "../core/worktree.js";
12
+ import { fixtureRepo, gitIn } from "./fixtures-wt.js";
13
+ async function expectExit(code, pattern, action) {
14
+ try {
15
+ await action;
16
+ }
17
+ catch (e) {
18
+ assert.ok(e instanceof ExitSignal, `expected ExitSignal, got ${String(e)}`);
19
+ assert.equal(e.code, code);
20
+ assert.match(e.message, pattern);
21
+ return;
22
+ }
23
+ assert.fail(`expected rejection with exit ${code}`);
24
+ }
25
+ // ------------------------------------------------------------------ cacheRoot
26
+ test("cacheRoot: XDG scheme, ~/.cache fallback, junk XDG ignored", () => {
27
+ assert.equal(cacheRoot({ XDG_CACHE_HOME: "/xdg" }), path.join("/xdg", "abathur", "worktrees"));
28
+ assert.equal(cacheRoot({ HOME: "/home/u" }), path.join("/home/u", ".cache", "abathur", "worktrees"));
29
+ assert.equal(cacheRoot({ XDG_CACHE_HOME: "relative/nope", HOME: "/home/u" }), path.join("/home/u", ".cache", "abathur", "worktrees"));
30
+ assert.equal(cacheRoot({}), path.join(os.homedir(), ".cache", "abathur", "worktrees"));
31
+ });
32
+ // ------------------------------------------------------------------ openGenome
33
+ test("openGenome: clean base reports head + branch, no dirtiness notices", async (t) => {
34
+ const fx = await fixtureRepo(t);
35
+ const opened = await openGenome(fx.repo, [], { env: fx.env });
36
+ assert.equal(await realpath(opened.repoPath), await realpath(fx.repo));
37
+ assert.equal(opened.headCommit, fx.head);
38
+ assert.equal(opened.branch, "main");
39
+ assert.deepEqual(opened.dirtyWorktree, []);
40
+ });
41
+ test("openGenome: dirty TARGET path refuses with exit 1; unrelated dirt becomes notices", async (t) => {
42
+ const fx = await fixtureRepo(t);
43
+ await writeFile(path.join(fx.repo, "bench", "run.sh"), "# drifted\n");
44
+ await expectExit(1, /bench\/run\.sh/, openGenome(fx.repo, ["bench"], { env: fx.env }));
45
+ await expectExit(1, /bench\/run\.sh/, openGenome(fx.repo, [path.join(fx.repo, "bench")], { env: fx.env }));
46
+ // untracked file inside the target scope is dirty too
47
+ await rm(path.join(fx.repo, "bench", "run.sh"));
48
+ await gitIn(fx.repo, "checkout", "-q", "--", "bench/run.sh");
49
+ await writeFile(path.join(fx.repo, "bench", "scratch.tmp"), "u");
50
+ await expectExit(1, /bench\/scratch\.tmp/, openGenome(fx.repo, ["bench"], { env: fx.env }));
51
+ await rm(path.join(fx.repo, "bench", "scratch.tmp"));
52
+ // same dirtiness OUTSIDE the target scope: not a refusal, a ledger notice
53
+ await writeFile(path.join(fx.repo, "a.txt"), "dirty elsewhere\n");
54
+ const opened = await openGenome(fx.repo, ["bench"], { env: fx.env });
55
+ assert.equal(opened.dirtyWorktree.length, 1);
56
+ const notice = opened.dirtyWorktree[0];
57
+ assert.ok(notice !== undefined && notice.file === "a.txt" && notice.xy.length === 2);
58
+ });
59
+ test("openGenome: absent repo, non-git dir, empty repo and bare repo all block exit 1", async (t) => {
60
+ const fx = await fixtureRepo(t);
61
+ await expectExit(1, /not found/, openGenome(path.join(fx.root, "nope"), [], { env: fx.env }));
62
+ const plain = path.join(fx.root, "plain");
63
+ await mkdir(plain);
64
+ await expectExit(1, /not a git repository/, openGenome(plain, [], { env: fx.env }));
65
+ const empty = path.join(fx.root, "empty");
66
+ await mkdir(empty);
67
+ await gitIn(empty, "init", "-q", "-b", "main");
68
+ await expectExit(1, /no commits/, openGenome(empty, [], { env: fx.env }));
69
+ const bare = path.join(fx.root, "bare");
70
+ await gitIn(fx.root, "clone", "-q", "--bare", fx.repo, bare);
71
+ await expectExit(1, /bare/, openGenome(bare, [], { env: fx.env }));
72
+ });
73
+ // ------------------------------------------------- newGeneration + seal lifecycle
74
+ test("lifecycle AC: generation commit preserves parent sha; genome branch untouched", async (t) => {
75
+ const fx = await fixtureRepo(t);
76
+ const genome = { repoPath: fx.repo, genomeFp: fx.fp };
77
+ const gen = await newGeneration(genome, fx.head, "g-100-alpha", { env: fx.env });
78
+ assert.ok(gen.worktreePath.startsWith(path.join(fx.env.XDG_CACHE_HOME, "abathur", "worktrees", fx.fp)));
79
+ assert.equal(gen.parentCommit, fx.head);
80
+ assert.equal(await gitIn(gen.worktreePath, "rev-parse", "HEAD"), fx.head);
81
+ assert.equal(await readFileStr(path.join(gen.worktreePath, "bench", "run.sh")), "echo bench\n");
82
+ await writeFile(path.join(gen.worktreePath, "mutant.txt"), "improved\n");
83
+ const sealed = await sealGeneration(genome, "g-100-alpha", "gen: tune bench mutant", {
84
+ env: fx.env,
85
+ });
86
+ assert.match(sealed.commitSha, /^[0-9a-f]{40}$/);
87
+ assert.match(sealed.treeSha, /^[0-9a-f]{40}$/);
88
+ // parent sha preserved through the seal
89
+ assert.equal(await gitIn(fx.repo, "rev-parse", `${sealed.commitSha}^`), fx.head);
90
+ assert.equal(await gitIn(fx.repo, "rev-parse", `${sealed.commitSha}^{tree}`), sealed.treeSha);
91
+ // the genome repo's checked-out branch was NEVER touched
92
+ assert.equal(await gitIn(fx.repo, "rev-parse", "HEAD"), fx.head);
93
+ assert.equal(await gitIn(fx.repo, "rev-parse", "--abbrev-ref", "HEAD"), "main");
94
+ assert.equal(await gitIn(fx.repo, "status", "--porcelain"), "");
95
+ });
96
+ test("AC: two concurrent generations' worktrees coexist; chain from sealed sha", async (t) => {
97
+ const fx = await fixtureRepo(t);
98
+ const genome = { repoPath: fx.repo, genomeFp: fx.fp };
99
+ const a = await newGeneration(genome, fx.head, "g-100-a", { env: fx.env });
100
+ const b = await newGeneration(genome, fx.head, "g-100-b", { env: fx.env });
101
+ assert.notEqual(a.worktreePath, b.worktreePath);
102
+ await writeFile(path.join(a.worktreePath, "x.txt"), "a");
103
+ const sealedA = await sealGeneration(genome, "g-100-a", "gen a", { env: fx.env });
104
+ // sealedA lives only in the shared object db via a detached worktree
105
+ assert.equal(await gitIn(b.worktreePath, "rev-parse", "HEAD"), fx.head); // untouched
106
+ const c = await newGeneration(genome, sealedA.commitSha, "g-101-c", { env: fx.env });
107
+ assert.equal(c.parentCommit, sealedA.commitSha);
108
+ assert.equal((await stat(path.join(c.worktreePath, "x.txt"))).isFile(), true);
109
+ const listed = await gitIn(fx.repo, "worktree", "list", "--porcelain");
110
+ // main + a + b + c — all four registry entries coexist
111
+ assert.equal(listed.split("\n").filter((l) => l.startsWith("worktree ")).length, 4);
112
+ });
113
+ test("sealGeneration: unknown generation and no-change seal both block exit 1", async (t) => {
114
+ const fx = await fixtureRepo(t);
115
+ const genome = { repoPath: fx.repo, genomeFp: fx.fp };
116
+ await expectExit(1, /not found/, sealGeneration(genome, "g-nope", "ghost", { env: fx.env }));
117
+ await newGeneration(genome, fx.head, "g-100-quiet", { env: fx.env });
118
+ await expectExit(1, /nothing/, sealGeneration(genome, "g-100-quiet", "no changes", { env: fx.env }));
119
+ });
120
+ // ------------------------------------------------------------------ cleanupStale
121
+ test("cleanupStale AC: prunes generations+snapshots, survives double-run and dead registry entries", async (t) => {
122
+ const fx = await fixtureRepo(t);
123
+ const genome = { repoPath: fx.repo, genomeFp: fx.fp };
124
+ const gen = await newGeneration(genome, fx.head, "g-100-x", { env: fx.env });
125
+ await writeFile(path.join(gen.worktreePath, "x.txt"), "x");
126
+ await sealGeneration(genome, "g-100-x", "gen x", { env: fx.env });
127
+ const dead = await newGeneration(genome, fx.head, "g-100-dead", { env: fx.env });
128
+ await rm(dead.worktreePath, { recursive: true, force: true }); // crash simulation: dir gone, registry alive
129
+ const first = await cleanupStale(genome, 0, { env: fx.env });
130
+ assert.ok(first.removed.includes(gen.worktreePath));
131
+ assert.equal(first.kept.length, 0);
132
+ // idempotent second run (stale-state adversarial class)
133
+ const second = await cleanupStale(genome, 0, { env: fx.env });
134
+ assert.deepEqual(second.removed, []);
135
+ // dead registry entry pruned: only the main worktree remains, re-add with same id works
136
+ const listed = await gitIn(fx.repo, "worktree", "list", "--porcelain");
137
+ assert.equal(listed.split("\n").filter((l) => l.startsWith("worktree ")).length, 1);
138
+ const readd = await newGeneration(genome, fx.head, "g-100-dead", { env: fx.env });
139
+ assert.equal(await gitIn(readd.worktreePath, "rev-parse", "HEAD"), fx.head);
140
+ const report = await cleanupStale(genome, 0, { env: fx.env });
141
+ assert.ok(report.removed.length >= 1);
142
+ // AC verbatim: no abathur paths in the genome repo status, ever
143
+ assert.equal(await gitIn(fx.repo, "status", "--porcelain"), "");
144
+ await assert.rejects(stat(path.join(fx.env.XDG_CACHE_HOME, "abathur", "worktrees", fx.fp, "g-100-x")));
145
+ });
146
+ test("cleanupStale: fresh generations are KEPT when olderThanDays does not cover them", async (t) => {
147
+ const fx = await fixtureRepo(t);
148
+ const genome = { repoPath: fx.repo, genomeFp: fx.fp };
149
+ const gen = await newGeneration(genome, fx.head, "g-100-keep", { env: fx.env });
150
+ const r = await cleanupStale(genome, 7, { env: fx.env });
151
+ assert.deepEqual(r.removed, []);
152
+ assert.deepEqual(r.kept, [gen.worktreePath]);
153
+ });
154
+ test("cleanupStale: negative/NaN olderThanDays is a tool error (exit 2)", async (t) => {
155
+ const fx = await fixtureRepo(t);
156
+ const genome = { repoPath: fx.repo, genomeFp: fx.fp };
157
+ await expectExit(2, /olderThanDays/, cleanupStale(genome, Number.NaN, { env: fx.env }));
158
+ await expectExit(2, /olderThanDays/, cleanupStale(genome, -1, { env: fx.env }));
159
+ });
160
+ // ---------------------------------------------------------- incumbent discipline
161
+ test("fastForwardIncumbent: creates, ff-forwards, refuses rewind and checked-out branch (never force)", async (t) => {
162
+ const fx = await fixtureRepo(t);
163
+ const genome = { repoPath: fx.repo, genomeFp: fx.fp };
164
+ const gen = await newGeneration(genome, fx.head, "g-100-p", { env: fx.env });
165
+ await writeFile(path.join(gen.worktreePath, "promote.txt"), "p");
166
+ const sealed = await sealGeneration(genome, "g-100-p", "gen p", { env: fx.env });
167
+ const made = await fastForwardIncumbent(genome, sealed.commitSha, { env: fx.env });
168
+ assert.equal(made.fromSha, null);
169
+ assert.equal(made.branch, INCUMBENT_BRANCH);
170
+ assert.equal(await gitIn(fx.repo, "rev-parse", INCUMBENT_BRANCH), sealed.commitSha);
171
+ // no-op when already there
172
+ const noop = await fastForwardIncumbent(genome, sealed.commitSha, { env: fx.env });
173
+ assert.equal(noop.fromSha, sealed.commitSha);
174
+ // rewind is a refusal, and the ref does not move
175
+ await expectExit(1, /ancestor/, fastForwardIncumbent(genome, fx.head, { env: fx.env }));
176
+ assert.equal(await gitIn(fx.repo, "rev-parse", INCUMBENT_BRANCH), sealed.commitSha);
177
+ // forward again works
178
+ const gen2 = await newGeneration(genome, sealed.commitSha, "g-101-q", { env: fx.env });
179
+ await writeFile(path.join(gen2.worktreePath, "more.txt"), "q");
180
+ const sealed2 = await sealGeneration(genome, "g-101-q", "gen q", { env: fx.env });
181
+ const moved = await fastForwardIncumbent(genome, sealed2.commitSha, { env: fx.env });
182
+ assert.equal(moved.fromSha, sealed.commitSha);
183
+ assert.equal(moved.toSha, sealed2.commitSha);
184
+ // checked-out incumbent must not be moved while in use
185
+ await gitIn(fx.repo, "checkout", "-q", INCUMBENT_BRANCH);
186
+ await expectExit(1, /checked out/, fastForwardIncumbent(genome, fx.head, { env: fx.env }));
187
+ });
188
+ // ---------------------------------------------------------------- unsafe inputs
189
+ test("unsafe genomeFp/genId/parentCommit are tool errors (exit 2), never spawned to git", async (t) => {
190
+ const fx = await fixtureRepo(t);
191
+ await expectExit(2, /fingerprint/, newGeneration({ repoPath: fx.repo, genomeFp: "../evil" }, fx.head, "g-1", { env: fx.env }));
192
+ await expectExit(2, /generation id/, newGeneration({ repoPath: fx.repo, genomeFp: "ok" }, fx.head, "g 1;rm", { env: fx.env }));
193
+ await expectExit(2, /generation id/, newGeneration({ repoPath: fx.repo, genomeFp: "ok" }, fx.head, "-x", { env: fx.env }));
194
+ await expectExit(2, /revision/, newGeneration({ repoPath: fx.repo, genomeFp: "ok" }, "--help", "g-1", { env: fx.env }));
195
+ });
196
+ async function readFileStr(p) {
197
+ return readFile(p, "utf8");
198
+ }
@@ -0,0 +1,30 @@
1
+ // Mode-bit walkers for read-only commit snapshots (todo 3). freezeTree strips
2
+ // write bits recursively (files 0o444/0o555, dirs 0o555); thawTree restores
3
+ // writability so rm -rf can collect a frozen tree. Linux cannot lchmod, so
4
+ // symlinks are skipped — the enclosing dir's 0o555 already forbids unlink/create.
5
+ import { chmod, lstat, readdir } from "node:fs/promises";
6
+ import path from "node:path";
7
+ async function walk(dir, dirMode, fileMode) {
8
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
9
+ if (entry.isSymbolicLink())
10
+ continue;
11
+ const p = path.join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ await walk(p, dirMode, fileMode);
14
+ await chmod(p, dirMode);
15
+ }
16
+ else {
17
+ const st = await lstat(p);
18
+ await chmod(p, fileMode(st.mode));
19
+ }
20
+ }
21
+ await chmod(dir, dirMode);
22
+ }
23
+ /** Strip all write bits under `root` (root itself included). */
24
+ export async function freezeTree(root) {
25
+ await walk(root, 0o555, (mode) => 0o444 | (mode & 0o111));
26
+ }
27
+ /** Re-add owner write bits under `root` (root itself included). */
28
+ export async function thawTree(root) {
29
+ await walk(root, 0o755, (mode) => 0o644 | (mode & 0o111));
30
+ }
@@ -0,0 +1,85 @@
1
+ // Thin git wrapper (todo 3 contract): execFile('git', arrayArgs) ONLY — paths are
2
+ // passed as argv elements and never reach a shell, so metacharacters in repo or
3
+ // worktree paths cannot inject commands. No push, no ref-mutating shortcuts
4
+ // live here; callers pass the exact argv they need.
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ const execFileAsync = promisify(execFile);
8
+ const DEFAULT_TIMEOUT_MS = 30_000;
9
+ const MAX_BUFFER_BYTES = 32 * 1024 * 1024;
10
+ export class GitError extends Error {
11
+ kind;
12
+ args;
13
+ exitCode;
14
+ stderr;
15
+ constructor(kind, args, exitCode, stderr, message) {
16
+ super(message);
17
+ this.kind = kind;
18
+ this.args = args;
19
+ this.exitCode = exitCode;
20
+ this.stderr = stderr;
21
+ this.name = "GitError";
22
+ }
23
+ }
24
+ function isExecError(err) {
25
+ return err instanceof Error;
26
+ }
27
+ function firstLine(text) {
28
+ const line = text.split("\n", 1)[0] ?? "";
29
+ return line.trim();
30
+ }
31
+ function describe(args) {
32
+ return `git ${args.join(" ")}`;
33
+ }
34
+ function classify(args, cause) {
35
+ if (isExecError(cause)) {
36
+ const stderr = cause.stderr ?? "";
37
+ if (cause.killed === true) {
38
+ return new GitError("timeout", args, null, stderr, `${describe(args)} timed out and was killed (signal ${cause.signal ?? "SIGKILL"})`);
39
+ }
40
+ if (cause.code === "ENOENT") {
41
+ return new GitError("spawn", args, null, stderr, `${describe(args)}: cannot spawn git binary: ${firstLine(cause.message)}`);
42
+ }
43
+ if (typeof cause.code === "number") {
44
+ return new GitError("failed", args, cause.code, stderr, `${describe(args)} exited ${cause.code}: ${firstLine(stderr) || firstLine(cause.message)}`);
45
+ }
46
+ }
47
+ const why = cause instanceof Error ? firstLine(cause.message) : String(cause);
48
+ return new GitError("spawn", args, null, "", `${describe(args)} failed to run: ${why}`);
49
+ }
50
+ async function spawnGit(args, opts) {
51
+ try {
52
+ const { stdout, stderr } = await execFileAsync(opts.bin ?? "git", [...args], {
53
+ ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
54
+ timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
55
+ killSignal: "SIGKILL",
56
+ maxBuffer: MAX_BUFFER_BYTES,
57
+ env: { ...process.env, LC_ALL: "C" },
58
+ });
59
+ return { stdout, stderr };
60
+ }
61
+ catch (cause) {
62
+ throw classify(args, cause);
63
+ }
64
+ }
65
+ /** Run git; reject with GitError on non-zero exit, timeout, or spawn failure. */
66
+ export async function git(args, opts = {}) {
67
+ return spawnGit(args, opts);
68
+ }
69
+ /**
70
+ * Like git(), but a plain non-zero *exit* is a result (ok: false) instead of a
71
+ * rejection — for predicates like `merge-base --is-ancestor`. Timeouts and
72
+ * spawn failures still reject (they are not answers, only missing ones).
73
+ */
74
+ export async function tryGit(args, opts = {}) {
75
+ try {
76
+ const result = await spawnGit(args, opts);
77
+ return { ok: true, ...result };
78
+ }
79
+ catch (cause) {
80
+ if (cause instanceof GitError && cause.kind === "failed") {
81
+ return { ok: false, error: cause };
82
+ }
83
+ throw cause;
84
+ }
85
+ }
@@ -0,0 +1,184 @@
1
+ # Federation — the bundle format and the graft contract
2
+
3
+ v1 federation is a **file format plus local tools**. A bundle is a `.tgz` you
4
+ carry by any means you like; `bundle export`, `bundle inspect`, and `graft`
5
+ run entirely offline. There are no signatures, no trust, no discovery, no
6
+ transport, no auto-merge, and no auto-promotion — the verification model
7
+ replaces all of those with byte-equality. Schemas and gate behavior below are
8
+ field-for-field from `src/core/bundle-manifest.ts`, `src/core/bundle-inspect.ts`,
9
+ `src/core/graft.ts`, `src/core/graft-gates.ts`, `src/core/graft-rebench.ts`,
10
+ and `src/core/graft-support.ts`.
11
+
12
+ ## Bundle v1 — container layout
13
+
14
+ Tarball name: `abathur-<fp16>-<genId>.bundle.tgz` (`<fp16>` = first 16 hex of
15
+ the genome fingerprint). Generation content is read from **git objects**
16
+ (`git archive`) at export time, never from a working tree. Members:
17
+
18
+ | Member | Content |
19
+ |---|---|
20
+ | `manifest.json` | the v1 manifest below — the only file that pins everything else |
21
+ | `README.md` | human summary: fingerprint, gen list, primary commit/tree, verdict, rationale |
22
+ | `lineage.json` | per-generation ledger-row summaries (`LineageEntry`: genId, parent, commitSha, treeSha, verdict, rationale) |
23
+ | `patch.diff` | unified diff primary parent → primary commit |
24
+ | `trees/<genId>/**` | full contained tree per generation — exempt from masking (byte truth) |
25
+ | `evidence/<genId>/<runId>.jsonl` | bench transcripts — **train units only, structurally never val**, and mask-scanned |
26
+
27
+ The export is **deterministic**: re-exporting an unchanged ledger produces a
28
+ byte-identical tarball (proven in packaging evidence: identical sha256 across
29
+ two exports). Nothing about a bundle is authoritative until re-verified —
30
+ `bundle inspect` re-hashes every pinned member from the contained bytes.
31
+
32
+ ### Masking (leak gate)
33
+
34
+ Evidence and manifest strings pass a fail-closed secret-scan before the
35
+ tarball is written, and `inspect` re-runs it over the final bytes:
36
+
37
+ - Always masked → placeholder: this machine's `HOME` → `<HOME>`, the genome's
38
+ `repoPath` → `<GENOME>`.
39
+ - Extended per genome by `spec.bundle.maskLiterals` → `<MASKED-n>` (indexed by
40
+ list order).
41
+ - `trees/**` members are exempt: they are the byte-truth content, and sealing
42
+ plus review keep machine literals out of genomes by construction.
43
+ - A leak on export refuses the write (exit 1, nothing produced); a leak found
44
+ by `inspect` is a blocked verdict with `member:line` and snippet.
45
+
46
+ ## Bundle v1 — `manifest.json` schema (strict object; unknown keys are rejected)
47
+
48
+ Key insertion order IS the serialized byte order (pins determinism).
49
+
50
+ | Field | Type / shape | Meaning |
51
+ |---|---|---|
52
+ | `schema_version` | literal `1` | format version |
53
+ | `digest_algo` | string | must be `sha256-canonical-v1`; any other value → inspect exit 2 (cannot answer — wrong tool for the format) |
54
+ | `genome` | `{ label: string≥1, fingerprint: sha256-hex(64) }` | which genome this lineage belongs to; identity is the fingerprint, the label rides along for humans |
55
+ | `parent` | git commit id (hex 7–64) | primary parent commit |
56
+ | `benchDigest` | sha256-hex(64) | digest of the bench SURFACE for the primary tree under this spec (`benchDigestFor`) — what was measured, not the score |
57
+ | `benchProvenance` | object, all fields nullable | scoring conditions, see below |
58
+ | `budgetCounters` | `{ candidates, modelCalls, tokens, wallS }` | budget the peer spent producing this lineage |
59
+ | `stats` | `{ matrix: unitMatrixRow[], verdict: "nominated"\|"culled"\|"indeterminate"\|"inconclusive"\|null }` | the peer's **claim** — per-unit rows + final verdict; never trusted, always re-benched |
60
+ | `sealedGlobs` | string[] | copy of `kernel.immutableGlobs` at export |
61
+ | `files` | `[{ path, sha256, len }]`, ≥1 | every other member, pinned by hash and length; `manifest.json` never pins itself |
62
+ | `rationale` | string | primary generation's rationale |
63
+ | `frictionDigests` | string[] | friction-digest ids that motivated this lineage, for cross-instance learning |
64
+
65
+ `benchProvenance` fields (the scoring-provenance subset):
66
+
67
+ | Field | Derivation (`deriveBenchProvenance`) | Toy genome value |
68
+ |---|---|---|
69
+ | `opencodeVersion` | from the bench row's version probes (`bin == "opencode"`) | `null` |
70
+ | `agentModel` | `spec.bench.agentModel` | engine-driven only |
71
+ | `adapterConfigDigest` | fingerprint of `{adapterIface: abathur-bench-adapter-v1, benchType, graderCommand, judgeCommand, resetCommand, runCommand, seedCommand, timeoutS}` | digest over the inert descriptor commands |
72
+ | `fixtureSeedId` | `fingerprint({seedCommand})` | `null` when no seedCommand |
73
+ | `judgeModel` | `spec.bench.judgeModel` | engine-driven only |
74
+ | `mutatorModel` | constant `null` in v1 | `null` — honest absence, never a guess |
75
+ | `nRepeats` | the peer row's reps | e.g. 2 |
76
+ | `statsConfigDigest` | `fingerprint(bench.stats)` | digest of the stats gates |
77
+
78
+ Fields a bench kind genuinely does not have are explicit `null`s — the manifest
79
+ never fabricates provenance to look comparable.
80
+
81
+ ## `bundle inspect` — gate order and exit codes
82
+
83
+ `inspect` answers "is this bundle internally honest", from contained bytes only:
84
+
85
+ 1. read/gunzip container — garbage → **exit 2**
86
+ 2. parse + schema-validate `manifest.json` — invalid → **exit 2**; unknown
87
+ `digest_algo` → **exit 2**
88
+ 3. re-hash every pinned member — missing member, sha or length mismatch, or
89
+ unexpected member → **exit 1**; a `path` escaping the bundle (absolute,
90
+ `..`, backslash, NUL) is rejected at tar-read → **exit 2**
91
+ 4. `patch.diff` present (it is schema-pinned content) — missing → **exit 1**
92
+ 5. genome check — no `trees/<genId>` members at all → **exit 2**; contained
93
+ spec's fingerprint or label disagreeing with `manifest.genome` → **exit 1**
94
+ 6. recompute `benchDigest` from the contained primary tree + contained spec —
95
+ mismatch → **exit 1**
96
+ 7. val exclusion — any `evidence/` row for a val-split unit → **exit 1**
97
+ 8. mask re-scan over manifest + evidence — leak → **exit 1**
98
+
99
+ On success: exit 0 with a member count, re-hash count, and the primary verdict.
100
+
101
+ ## `graft` — four steps, six decisions
102
+
103
+ The graft target is a bundle plus a locally registered `--genome label`. Every
104
+ step happens **before** the next; the first three never create a worktree.
105
+
106
+ 1. **Full inspect** (above). Integrity failure is a decision, not a
107
+ negotiation: `quarantined` booked to the local ledger, exit 1.
108
+ Container garbage (exit-2 class) leaves no state — a refusal line only.
109
+ 2. **Registration + duplicate check**: genome not registered locally →
110
+ `pending-bench`: an entry under `<configDir>/graft-queue/<bundle-sha256>.json`,
111
+ exit 1, zero bench runs. Repair (register the genome), then re-run `graft` —
112
+ pending rows are explicitly non-terminal. On a registered genome the ledger
113
+ is then checked for a prior terminal decision on this exact bundle + genome:
114
+ any terminal row (`quarantined`/`nominated`/`culled`/`indeterminate`/
115
+ `inconclusive`) → refusal, exit 1 ("export a newer bundle; re-running never
116
+ re-benches").
117
+ 3. **Byte-exact gates**, in order; any miss → `quarantined` (exit 1, one
118
+ `graft_import` ledger row naming the failing gate, no worktree, no bench):
119
+ - gate 1 genome fingerprint: local spec must fingerprint **byte-equal** to
120
+ `manifest.genome.fingerprint`;
121
+ - gate 2 benchDigest: recomputed from the contained primary tree under the
122
+ LOCAL spec vs `manifest.benchDigest`;
123
+ - gate 3 scoring provenance: the five recomputable fields
124
+ (`agentModel, judgeModel, statsConfigDigest, adapterConfigDigest,
125
+ fixtureSeedId`) must agree **three ways** — manifest claim vs what the
126
+ bundle's own contained spec derives vs local — and `opencodeVersion` must
127
+ be semver-equal to the local incumbent bench row's version (both-null is
128
+ the honest equality for toy; unparsable is incomparable);
129
+ - gate 4 `requires[]` probes via the todo-6 machinery (argv spawn, 30 s cap,
130
+ fixture genomes additionally through the engine/minVersion probe).
131
+ Probe failure → `pending-bench` (exit 2 this time — repair setup, re-run).
132
+ 4. **Local re-bench** — only if all gates passed. A fresh worktree is created
133
+ from the LOCAL incumbent HEAD, the bundle's tree bytes are applied as
134
+ byte-truth (`applyBundleTree`: overwrite every member, remove tracked files
135
+ the bundle lacks), and the candidate is benched at **LOCAL** reps
136
+ (`clampReps` under the local stats config) and **LOCAL** thresholds, under
137
+ the machine-local genome lock, inside the normal budget machinery. The todo-7
138
+ statistics gate decides. The bundle's scores never enter the math — they are
139
+ booked as `peerClaim` metadata in the single `graft_import` row.
140
+
141
+ Decision table — the complete outcome surface:
142
+
143
+ | Outcome | Trigger | Exit | Ledger / state |
144
+ |---|---|---|---|
145
+ | `nominated` | local re-bench passes all gates' stats | 0 | `graft_import` + generation row; waits for human `promote` — graft itself never promotes (`promote.ts` is structurally not imported) |
146
+ | `culled` | local stats say no (peer claim irrelevant either way) | 1 | `graft_import` + generation row |
147
+ | `indeterminate` | local stats degenerate (e.g. zero-variance) | 1 | `graft_import` + generation row |
148
+ | `inconclusive` | local bench truncated by budget/timeout | 2 | `graft_import` + generation row — cannot-answer, never a fake pass |
149
+ | `quarantined` | any gate 0–3 integrity/fingerprint/digest/provenance mismatch | 1 | exactly one `graft_import` row; **no worktree, no bench** |
150
+ | `pending-bench` | genome unregistered (exit 1) or probes unmet (exit 2) | 1 / 2 | queue file under `<configDir>/graft-queue/` (+ row if a ledger exists); non-terminal by design |
151
+
152
+ `graftGenId = fingerprint({ graft: bundleSha256, sourceGenome, parent })` — a
153
+ grafted generation has a deterministic id derived from the bundle content, and
154
+ the sealed row carries `commitSha`/`treeSha` of what was actually benched.
155
+
156
+ ## Honest limitation: env-bound genomes re-derive identity per machine
157
+
158
+ Byte-equality is literal. A genome whose spec contains **resolved absolute
159
+ paths** — the historian case, where the operator materializes
160
+ `config/genomes/historian.example.jsonc` into a machine-specific
161
+ `*.local.jsonc` — fingerprints over those bytes. Its fingerprint
162
+ (on the orchestrator's paths: `2b2456be6cddcb91…`) and every digest derived
163
+ from its spec (`benchDigest`, `adapterConfigDigest`) are therefore stable **per
164
+ resolved path set**, not across machines. Grafting a historian bundle onto a
165
+ peer whose local spec resolved to different paths fails gate 1 — quarantined,
166
+ correctly: "same genome" for v1 means "byte-identical spec", and different
167
+ paths can genuinely mean different fixture services.
168
+
169
+ The escape hatch is the one abathur-self uses: keep the spec bytes
170
+ machine-independent (e.g. the `${ABATHUR_SELF_REPO}` unresolved-literal
171
+ `repoPath` pattern, resolved only at filesystem seams, `effectiveRepoPath` in
172
+ `src/core/spec.ts`) and machine-neutral commands. Then the same spec
173
+ fingerprints identically everywhere (`1913fcec…`) and graft works across
174
+ machines as designed. What v1 will not do is pretend two differently-resolved
175
+ genomes are comparable.
176
+
177
+ ## Out of scope (v1, explicitly)
178
+
179
+ Bundle signing or any trust chain; discovery, transport, or sync protocols;
180
+ merge of two incumbent branches; auto-promotion or any bypass flag
181
+ (`--force` does not exist in the binary); noise-tolerance bands on digest
182
+ equality. Every one of these, if ever built, must fit the invariant the whole
183
+ design rests on: **a verdict exists only if it was recomputed locally from
184
+ bytes that hash the same on both sides.**
@@ -0,0 +1,87 @@
1
+ # The Immutable Kernel — what self-evolution may and may not touch
2
+
3
+ This is the human-facing contract of the shipped seed `genomes/abathur-self.jsonc`
4
+ and of `src/core/evolve/self-overlay.ts` / `self-snapshot.ts`. The seed's header
5
+ comments restate the same contract in spec-adjacent form; where they are terse,
6
+ this document is the expansion. Registration is fail-closed by design: every
7
+ seal glob must cover at least one real file (the `graders/**` contract directory
8
+ satisfies that since todo 14), and the seed stays byte-stable — its `repoPath` is
9
+ the unresolved literal `${ABATHUR_SELF_REPO}`, so the spec fingerprint
10
+ (`1913fcec…`) is identical on every machine and the checkout only resolves at
11
+ filesystem seams. Point that variable at a git checkout with `npm ci` done; unset
12
+ exits 2, naming the variable.
13
+
14
+ ## The sealed set
15
+
16
+ `kernel.immutableGlobs` on the abathur-self genome seals:
17
+
18
+ | Pattern | Why it is sealed |
19
+ |---|---|
20
+ | `src/core/stats.ts` | the selection gate itself — nomination math, Bonferroni, budget verdicts |
21
+ | `src/core/ledger.ts` | append-only evidence: friction queue + generation rows |
22
+ | `src/core/ids.ts` | canonical JSON + fingerprints — the identity of every genome/row/tree |
23
+ | `src/core/genome.ts` | registry semantics: registration, superset rule, fail-closed coverage |
24
+ | `src/core/promote.ts` | the human-only gate: second sealed-path pass, CAS ref move, manifest regen |
25
+ | `src/bench/toy.*`, `src/bench/fixture.*` | the bench adapters — how every score is produced |
26
+ | `graders/**` | grader contract schemas — what "correct" means per bench |
27
+ | `genomes/**` | every genome spec, including this seed and the toy-smoke fixtures |
28
+ | `scripts/copy-assets.mjs`, `package.json`, `tsconfig.json` | the toolchain itself |
29
+
30
+ Self cannot unseal: a candidate diff touching any sealed file is rejected at the
31
+ path stage (`validateCandidate`), the kernel audit refuses runs against a drifted
32
+ working tree (exit 1, before any state exists), and only the human `promote`
33
+ command ever reseals the manifest — from the promoted tree, never the worktree.
34
+ The sealed hashes live in a per-genome manifest under `<configDir>/kernels/`
35
+ (written at registration, re-checked at every bench and `self-eval` entry);
36
+ `abathur kernel audit <label>` compares that manifest against the working tree.
37
+
38
+ **Consequence (accepted):** v1 self-evolution cannot change dependencies or
39
+ build configuration. `package.json` and `tsconfig.json` ride outside the
40
+ overlaid `src/**` and would otherwise slip into a promoted generation
41
+ score-neutrally and silently reseal on installer re-add (plan round-2 #2).
42
+ Toolchain changes are a human PR, not an evolution candidate.
43
+
44
+ ## Why tests run from a snapshot, not from the candidate
45
+
46
+ Fitness-hack class (plan 11c, oracle Critical #1): if the candidate's own tree
47
+ supplies the test suite, a candidate can delete or weaken the tests it is graded
48
+ by and score better while getting worse. The self-bench therefore composes:
49
+
50
+ 1. **Trusted tree** = `snapshotCommit(incumbent)` — sealed kernel files, the
51
+ test suite (`src/test/**`), the golden-replay fixtures + expected digests,
52
+ and toolchain config, all read-only from the incumbent commit.
53
+ 2. **Overlay** = candidate `src/**` files matching no seal and not under
54
+ `src/test/**` (candidate test edits are dropped by the allowlist; deletions
55
+ are unrepresentable in a copy-only overlay, matching udiff v1's
56
+ modify/create-only contract).
57
+ 3. **Build** with the harness-pinned toolchain: the repo's own `tsc` against the
58
+ harness install's `node_modules` — never candidate-supplied build scripts,
59
+ never `npm ci`. Caps: build 180 s, suite 900 s, replay 120 s; a build timeout
60
+ is inconclusive, a suite/replay timeout scores nothing.
61
+ 4. **Score** = `node --test dist/test/**/*.test.js` over the overlaid build plus
62
+ the golden toy-replay digest (`selfbench/replay.mjs`, expected from the
63
+ TRUSTED copy). Pure incumbent-vs-incumbent is degenerate and forbidden; the
64
+ incumbent build is the baseline, candidates are the overlaid builds.
65
+
66
+ The seed's two bench units make the goalposts concrete: `suite` (train) is
67
+ `node --test dist/test/**/*.test.js` built from the overlaid candidate sources;
68
+ `golden-replay` (val) replays `selfbench/replay.mjs` and compares the digest
69
+ against `selfbench/expected.json` — taken from the TRUSTED copy, so a candidate
70
+ cannot move the val goalpost it is measured against. Both units score binary per
71
+ rep, which is why the seed pins `stats.halfWidth 0.05, minEffect 0, nReps 2..4`:
72
+ a nomination means "candidate src passes the incumbent-pinned suite AND the
73
+ golden digest holds, non-degenerately, across reps". A pure incumbent-vs-incumbent
74
+ comparison is degenerate and forbidden; an overlay with no effective change is
75
+ downgraded to `indeterminate` by the overlay-empty guard, never nominated.
76
+
77
+ The candidate worktree's own tests are never executed, and the scorer is invoked
78
+ with snapshot paths only — structurally, nothing in `self-eval`/`self-snapshot`
79
+ imports `promote.ts`; `self-eval` reports fitness, the operator promotes.
80
+
81
+ ## Friction digest
82
+
83
+ Every `run` appends structured `friction_digest` records to the global queue
84
+ `<configDir>/friction.jsonl` (stalls, repeated rejections by stage, inconclusive
85
+ causes, CLI errors). Records are schema-validated, strings are echo-sanitized
86
+ and bounded, and digests covering val-unit runs carry counts/timings/status
87
+ codes only — scenario text and assertion diffs can never enter the queue.
@@ -0,0 +1,53 @@
1
+ // Type declarations for grader-core.mjs — the plan-185 scoring engine.
2
+ // Runtime lives in the .mjs; this file is the compile-time contract.
3
+
4
+ export type DimKey = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H";
5
+ export type Bit = 0 | 1;
6
+ export type FullDims = Readonly<Record<DimKey, Bit>>;
7
+ export type WorthDims = Readonly<{ G: Bit; H: Bit; J: Bit }>;
8
+
9
+ export interface CreatedPage {
10
+ readonly path: string;
11
+ readonly locale: string;
12
+ readonly title: string;
13
+ readonly content: string;
14
+ }
15
+
16
+ export interface Observation {
17
+ readonly scenarioNo: number;
18
+ readonly created: readonly CreatedPage[];
19
+ readonly updated: readonly CreatedPage[];
20
+ readonly moved: readonly { readonly from: string; readonly to: string }[];
21
+ readonly deletedFixturePaths: readonly string[];
22
+ readonly outside: {
23
+ readonly created: readonly string[];
24
+ readonly updated: readonly string[];
25
+ readonly deleted: readonly string[];
26
+ };
27
+ readonly indexUpdated: boolean;
28
+ readonly indexContent: string;
29
+ readonly livePaths: readonly string[];
30
+ readonly allPaths: readonly string[];
31
+ readonly backlinkBodies: ReadonlyArray<{ readonly path: string; readonly locale: string; readonly content: string }>;
32
+ readonly finalMessage: string;
33
+ readonly urlChecks: readonly { readonly url: string; readonly status: number }[];
34
+ }
35
+
36
+ export interface UnitScore {
37
+ readonly score: number;
38
+ readonly pass: boolean;
39
+ readonly total: number;
40
+ readonly applicableWeight: number;
41
+ }
42
+
43
+ export type ScoredUnit = UnitScore & {
44
+ readonly dims: Readonly<Record<string, Bit>>;
45
+ readonly notes: readonly string[];
46
+ };
47
+
48
+ export const WEIGHTS: Readonly<Record<DimKey, number>>;
49
+
50
+ export function computeDims(obs: Observation): FullDims;
51
+ export function judgment(scenarioNo: number, created: readonly CreatedPage[], finalMessage: string): Bit;
52
+ export function scoreFromDims(scenarioNo: number, dims: FullDims | WorthDims): UnitScore;
53
+ export function scoreUnit(obs: Observation): ScoredUnit;