@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.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +74 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/config/abathur.jsonc +17 -0
- package/config/genomes/historian.example.jsonc +124 -0
- package/dist/bench/adapter.js +201 -0
- package/dist/bench/fixture-probe.js +92 -0
- package/dist/bench/fixture-support.js +173 -0
- package/dist/bench/fixture.js +236 -0
- package/dist/bench/toy.js +152 -0
- package/dist/cli.js +110 -0
- package/dist/commands/bundle.js +79 -0
- package/dist/commands/genome.js +94 -0
- package/dist/commands/graft.js +71 -0
- package/dist/commands/kernel.js +47 -0
- package/dist/commands/promote.js +25 -0
- package/dist/commands/run.js +145 -0
- package/dist/commands/self-eval.js +240 -0
- package/dist/commands/status.js +186 -0
- package/dist/commands/tombstone.js +72 -0
- package/dist/config.js +161 -0
- package/dist/core/bundle-common.js +119 -0
- package/dist/core/bundle-export.js +212 -0
- package/dist/core/bundle-inspect.js +143 -0
- package/dist/core/bundle-manifest.js +105 -0
- package/dist/core/bundle-mask.js +75 -0
- package/dist/core/bundle-tar.js +240 -0
- package/dist/core/bundle.js +9 -0
- package/dist/core/evolve/brief.js +45 -0
- package/dist/core/evolve/candidate.js +140 -0
- package/dist/core/evolve/child-track.js +197 -0
- package/dist/core/evolve/friction.js +150 -0
- package/dist/core/evolve/reflect.js +191 -0
- package/dist/core/evolve/run-bench.js +170 -0
- package/dist/core/evolve/run-friction.js +63 -0
- package/dist/core/evolve/run-loop.js +282 -0
- package/dist/core/evolve/run-plan.js +39 -0
- package/dist/core/evolve/run-rows.js +145 -0
- package/dist/core/evolve/self-overlay.js +213 -0
- package/dist/core/evolve/self-snapshot.js +170 -0
- package/dist/core/evolve/stub-mutators.mjs +105 -0
- package/dist/core/evolve/udiff.js +189 -0
- package/dist/core/genome-paths.js +76 -0
- package/dist/core/genome.js +176 -0
- package/dist/core/glob.js +106 -0
- package/dist/core/graft-gates.js +184 -0
- package/dist/core/graft-rebench.js +187 -0
- package/dist/core/graft-support.js +181 -0
- package/dist/core/graft.js +218 -0
- package/dist/core/ids.js +154 -0
- package/dist/core/incumbent.js +46 -0
- package/dist/core/kernel.js +112 -0
- package/dist/core/ledger.js +198 -0
- package/dist/core/locks.js +172 -0
- package/dist/core/promote.js +119 -0
- package/dist/core/snapshot.js +61 -0
- package/dist/core/spec.js +178 -0
- package/dist/core/stats-math.js +102 -0
- package/dist/core/stats-pareto.js +57 -0
- package/dist/core/stats.js +184 -0
- package/dist/core/worktree.js +190 -0
- package/dist/exit.js +32 -0
- package/dist/genomes/toy-smoke/genome.jsonc +30 -0
- package/dist/genomes/toy-smoke/grader.mjs +61 -0
- package/dist/genomes/toy-smoke/init.mjs +63 -0
- package/dist/genomes/toy-smoke/units/add.mjs +17 -0
- package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
- package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
- package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
- package/dist/jsonc.js +77 -0
- package/dist/out.js +5 -0
- package/dist/test/bench-adapter.test.js +33 -0
- package/dist/test/bench-fixture.test.js +407 -0
- package/dist/test/bench-toy.test.js +251 -0
- package/dist/test/bundle.test.js +659 -0
- package/dist/test/config.test.js +185 -0
- package/dist/test/d7-gate.test.js +56 -0
- package/dist/test/fixture-loop.test.js +267 -0
- package/dist/test/fixtures/friction-writer.js +16 -0
- package/dist/test/fixtures-historian.js +82 -0
- package/dist/test/fixtures-self.js +143 -0
- package/dist/test/fixtures-wt.js +64 -0
- package/dist/test/friction.test.js +398 -0
- package/dist/test/genome.test.js +453 -0
- package/dist/test/git.test.js +69 -0
- package/dist/test/graft.test.js +567 -0
- package/dist/test/historian-genome.test.js +134 -0
- package/dist/test/historian-grader-io.test.js +148 -0
- package/dist/test/historian-grader.test.js +209 -0
- package/dist/test/ids.test.js +116 -0
- package/dist/test/include-val.test.js +120 -0
- package/dist/test/ledger-lock.test.js +99 -0
- package/dist/test/ledger.test.js +102 -0
- package/dist/test/promote.test.js +394 -0
- package/dist/test/reflect.test.js +410 -0
- package/dist/test/run-loop.test.js +433 -0
- package/dist/test/self-snapshot.test.js +328 -0
- package/dist/test/snapshot.test.js +86 -0
- package/dist/test/stats.test.js +423 -0
- package/dist/test/stub-mutators.test.js +17 -0
- package/dist/test/testutil.js +30 -0
- package/dist/test/worktree.test.js +198 -0
- package/dist/util/freeze.js +30 -0
- package/dist/util/git.js +85 -0
- package/docs/federation.md +184 -0
- package/docs/immutable-kernel.md +87 -0
- package/graders/historian/grader-core.d.mts +53 -0
- package/graders/historian/grader-core.mjs +276 -0
- package/graders/historian/grader-support.d.mts +57 -0
- package/graders/historian/grader-support.mjs +137 -0
- package/graders/historian/grader.mjs +113 -0
- package/graders/historian/mutate.sh +114 -0
- package/graders/historian/reset-sandbox.sh +60 -0
- package/graders/historian/run-scenario.sh +49 -0
- package/graders/historian/seed-wrapped.sh +32 -0
- 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
|
+
}
|
package/dist/util/git.js
ADDED
|
@@ -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;
|