@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,143 @@
1
+ // Shared fixture helpers for the todo-11 self-bench tests: a TRIMMED COPY of the
2
+ // harness repo (every real src file except src/test/**, plus the toolchain,
3
+ // genomes, selfbench and a stub graders/ contract) committed to a fresh git
4
+ // repo in tmp. NEVER the real repo — self-bench tests only ever point
5
+ // ABATHUR_SELF_REPO at these copies. The trimmed copy carries two tiny trusted
6
+ // tests so the overlay suite runs in seconds; node_modules is symlinked to the
7
+ // harness install (exactly what self-snapshot does for the toolchain).
8
+ import { execFileSync } from "node:child_process";
9
+ import { appendFileSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
10
+ import * as os from "node:os";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ export const HARNESS_ROOT = path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
14
+ const SANITY_TEST = `import assert from "node:assert/strict";
15
+ import test from "node:test";
16
+ import { EXIT_OK } from "../exit.js";
17
+ import { writeStdout } from "../out.js";
18
+ import { canonicalJson, fingerprint } from "../core/ids.js";
19
+
20
+ test("self-sanity: exit contract", () => {
21
+ assert.equal(EXIT_OK, 0);
22
+ });
23
+
24
+ test("self-sanity: stdout funnel", () => {
25
+ assert.equal(typeof writeStdout, "function");
26
+ });
27
+
28
+ test("self-sanity: canonical json ordering", () => {
29
+ assert.equal(fingerprint({ b: 1, a: [1, { d: 2, c: 3 }] }), fingerprint({ a: [1, { c: 3, d: 2 }], b: 1 }));
30
+ assert.equal(canonicalJson({ x: undefined }), "{}");
31
+ });
32
+ `;
33
+ const MUTATOR_TEST = `import assert from "node:assert/strict";
34
+ import test from "node:test";
35
+ import { scriptedPatches, selectPatches } from "../core/evolve/stub-mutators.mjs";
36
+
37
+ test("self-sanity: stub mutators deterministic", () => {
38
+ const a = selectPatches(7, scriptedPatches().length).map((p) => p.id);
39
+ const b = selectPatches(7, scriptedPatches().length).map((p) => p.id);
40
+ assert.deepEqual(a, b);
41
+ assert.equal(a.length, 4);
42
+ });
43
+ `;
44
+ function git(cwd, ...args) {
45
+ return execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=abathur@harness.local", "-C", cwd, ...args], {
46
+ encoding: "utf8",
47
+ env: { ...process.env, LC_ALL: "C" },
48
+ }).trim();
49
+ }
50
+ /** Fresh git repo: real harness tree minus src/test, plus tiny trusted tests. */
51
+ export function makeSelfHarness(t) {
52
+ const root = mkdtempSync(path.join(os.tmpdir(), "abathur-self-"));
53
+ t.after(() => {
54
+ // frozen snapshot dirs (0444/0555) need u+w before rm -rf (todo-3 rule).
55
+ try {
56
+ execFileSync("chmod", ["-R", "u+w", root]);
57
+ }
58
+ catch {
59
+ // tree already gone — nothing to thaw.
60
+ }
61
+ rmSync(root, { recursive: true, force: true });
62
+ });
63
+ const repo = path.join(root, "harness");
64
+ mkdirSync(repo, { recursive: true });
65
+ cpSync(path.join(HARNESS_ROOT, "src"), path.join(repo, "src"), {
66
+ recursive: true,
67
+ filter: (src) => {
68
+ const rel = path.relative(HARNESS_ROOT, src);
69
+ return rel !== "src/test" && !rel.startsWith(`src/test${path.sep}`);
70
+ },
71
+ });
72
+ for (const rel of ["package.json", "tsconfig.json", ".gitignore"]) {
73
+ cpSync(path.join(HARNESS_ROOT, rel), path.join(repo, rel));
74
+ }
75
+ mkdirSync(path.join(repo, "scripts"), { recursive: true });
76
+ cpSync(path.join(HARNESS_ROOT, "scripts", "copy-assets.mjs"), path.join(repo, "scripts", "copy-assets.mjs"));
77
+ mkdirSync(path.join(repo, "genomes"), { recursive: true });
78
+ cpSync(path.join(HARNESS_ROOT, "genomes"), path.join(repo, "genomes"), { recursive: true });
79
+ mkdirSync(path.join(repo, "selfbench"), { recursive: true });
80
+ cpSync(path.join(HARNESS_ROOT, "selfbench", "replay.mjs"), path.join(repo, "selfbench", "replay.mjs"));
81
+ mkdirSync(path.join(repo, "graders"), { recursive: true });
82
+ writeFileSync(path.join(repo, "graders", "contract.json"), '{"schema":"abathur-grader-contract-v1","line":"{unit, score 0..1, pass, metrics{tokensEst,turns}}"}\n', "utf8");
83
+ rmSync(path.join(repo, "src", "test"), { recursive: true, force: true });
84
+ mkdirSync(path.join(repo, "src", "test"), { recursive: true });
85
+ writeFileSync(path.join(repo, "src", "test", "self-sanity.test.ts"), SANITY_TEST, "utf8");
86
+ writeFileSync(path.join(repo, "src", "test", "self-mutators.test.ts"), MUTATOR_TEST, "utf8");
87
+ symlinkSync(path.join(HARNESS_ROOT, "node_modules"), path.join(repo, "node_modules"), "dir");
88
+ git(repo, "init", "-b", "main");
89
+ // the harness's 'node_modules/' pattern is dir-only and never matches this
90
+ // symlink — without the exact-name exclude the link would be COMMITTED and
91
+ // ride into every snapshot, colliding with the bench's own node_modules link.
92
+ appendFileSync(path.join(repo, ".git", "info", "exclude"), "node_modules\n", "utf8");
93
+ git(repo, "add", "-A");
94
+ git(repo, "commit", "-m", "self harness fixture");
95
+ return {
96
+ root,
97
+ repo,
98
+ configDir: path.join(root, "config"),
99
+ xdg: path.join(root, "xdg"),
100
+ env: { XDG_CACHE_HOME: path.join(root, "xdg"), HOME: path.join(root, "home") },
101
+ specPath: path.join(repo, "genomes", "abathur-self.jsonc"),
102
+ };
103
+ }
104
+ /** Capture the golden replay digest against a fresh build of the CURRENT tree. */
105
+ export function captureReplayDigest(repo) {
106
+ execFileSync(process.execPath, [path.join(repo, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.json"], {
107
+ cwd: repo,
108
+ encoding: "utf8",
109
+ env: { ...process.env, LC_ALL: "C" },
110
+ maxBuffer: 16 * 1024 * 1024,
111
+ });
112
+ const out = execFileSync(process.execPath, ["selfbench/replay.mjs"], {
113
+ cwd: repo,
114
+ encoding: "utf8",
115
+ env: { ...process.env, LC_ALL: "C" },
116
+ });
117
+ const last = out.trim().split("\n").at(-1) ?? "";
118
+ const parsed = JSON.parse(last);
119
+ if (typeof parsed.digest !== "string")
120
+ throw new Error(`replay produced no digest: ${last}`);
121
+ return parsed.digest;
122
+ }
123
+ /** Write + commit selfbench/expected.json (digest of the current tree's replay). */
124
+ export function seedExpectedDigest(h, digest) {
125
+ writeFileSync(path.join(h.repo, "selfbench", "expected.json"), `${JSON.stringify({ digest, capturedAt: new Date().toISOString().slice(0, 10), note: "golden toy-replay at seed — todo 11" }, null, 2)}\n`, "utf8");
126
+ git(h.repo, "add", "selfbench/expected.json");
127
+ git(h.repo, "commit", "-m", "selfbench expected digest");
128
+ return headSha(h.repo);
129
+ }
130
+ export function headSha(repo) {
131
+ return git(repo, "rev-parse", "HEAD");
132
+ }
133
+ /** Apply a working-tree surgery, commit it, return the new HEAD sha. */
134
+ export function handCommit(repo, surgery, message) {
135
+ surgery();
136
+ git(repo, "add", "-A");
137
+ git(repo, "commit", "-m", message);
138
+ return headSha(repo);
139
+ }
140
+ export function editFile(repo, rel, mutate) {
141
+ const abs = path.join(repo, rel);
142
+ writeFileSync(abs, mutate(readFileSync(abs, "utf8")), "utf8");
143
+ }
@@ -0,0 +1,64 @@
1
+ // Shared test fixtures for todo 3 (worktree store). Deliberately independent of
2
+ // src/util/git.ts so the RED phase fails on the module under test, not here.
3
+ // Every fixture lives under a per-run mkdtemp root (no path shared across
4
+ // checkouts/worktrees running the suite concurrently).
5
+ import { execFile } from "node:child_process";
6
+ import { chmod, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
7
+ import * as os from "node:os";
8
+ import path from "node:path";
9
+ import { promisify } from "node:util";
10
+ const exec = promisify(execFile);
11
+ /** Deterministic identity + no signing, immune to the machine's global gitconfig. */
12
+ export const GIT_ID = [
13
+ "-c",
14
+ "user.name=abathur-test",
15
+ "-c",
16
+ "user.email=test@abathur.local",
17
+ "-c",
18
+ "commit.gpgsign=false",
19
+ ];
20
+ export async function gitIn(cwd, ...args) {
21
+ const { stdout } = await exec("git", [...args], {
22
+ cwd,
23
+ env: { ...process.env, LC_ALL: "C" },
24
+ });
25
+ return stdout.trim();
26
+ }
27
+ /** chmod dirs on the way up so rm -rf can collect a frozen snapshot tree. */
28
+ export async function forceRm(root) {
29
+ async function walk(dir) {
30
+ for (const e of await readdir(dir, { withFileTypes: true })) {
31
+ if (e.isDirectory())
32
+ await walk(path.join(dir, e.name));
33
+ }
34
+ await chmod(dir, 0o755);
35
+ }
36
+ try {
37
+ await walk(root);
38
+ }
39
+ catch {
40
+ /* already gone */
41
+ }
42
+ await rm(root, { recursive: true, force: true });
43
+ }
44
+ /** Git working copy with tracked `a.txt` and `bench/run.sh`, one seeded commit on `main`. */
45
+ export async function fixtureRepo(t, prefix = "abathur-wt-") {
46
+ const root = await mkdtemp(path.join(os.tmpdir(), prefix));
47
+ t.after(() => forceRm(root));
48
+ const repo = path.join(root, "src-repo");
49
+ await mkdir(repo, { recursive: true });
50
+ await gitIn(repo, "init", "-q", "-b", "main");
51
+ await writeFile(path.join(repo, "a.txt"), "first\n");
52
+ await mkdir(path.join(repo, "bench"));
53
+ await writeFile(path.join(repo, "bench", "run.sh"), "echo bench\n");
54
+ await gitIn(repo, "add", ".");
55
+ await gitIn(repo, ...GIT_ID, "commit", "-qm", "genome seed");
56
+ const head = await gitIn(repo, "rev-parse", "HEAD");
57
+ return {
58
+ root,
59
+ repo,
60
+ env: { XDG_CACHE_HOME: path.join(root, "xdg-cache"), HOME: path.join(root, "fake-home") },
61
+ fp: "fp-fixture01",
62
+ head,
63
+ };
64
+ }
@@ -0,0 +1,398 @@
1
+ // Todo 11 friction digest + self-genome plumbing pins.
2
+ // AC-1: a forced rejection-storm toy run appends >= 1 STRUCTURED friction
3
+ // record to the queue file (<configDir>/friction.jsonl via the todo-2
4
+ // guarded helpers), with byStage counts matching the observed rejects;
5
+ // AC-2: a unique canary planted in the val unit's id/path NEVER appears in the
6
+ // queue file (val runs contribute counts/aliases only);
7
+ // schema: strings echo-sanitized + bounded; malformed pre-existing queue ⇒
8
+ // fail-closed readable error, never a crash;
9
+ // CLI errors after registry resolution land as cause 'cli-error' records
10
+ // (spawned `run` on a drifted kernel — refuses exit 1, record written);
11
+ // machine-independence: the abathur-self seed registers under different
12
+ // ABATHUR_SELF_REPO values to the IDENTICAL fingerprint/stored bytes, and an
13
+ // unset env exits 2 naming the variable;
14
+ // structural: no todo-11 module may reference promote authority, and none may
15
+ // (dynamically) load candidate code into the harness process.
16
+ import assert from "node:assert/strict";
17
+ import { execFileSync } from "node:child_process";
18
+ import { spawnSync } from "node:child_process";
19
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
20
+ import * as os from "node:os";
21
+ import path from "node:path";
22
+ import test from "node:test";
23
+ import { ExitSignal } from "../exit.js";
24
+ import { registerGenome, requireGenomesByLabel, fingerprint16 } from "../core/genome.js";
25
+ import { appendFriction, frictionQueuePath } from "../core/ledger.js";
26
+ import { loadGenomeSpecFile } from "../core/spec.js";
27
+ import { effectiveRepoPath, isEnvRepoLiteral } from "../core/spec.js";
28
+ import { prepareToyGenome } from "../bench/toy.js";
29
+ import { runEvolution } from "../core/evolve/run-loop.js";
30
+ import { FRICTION_KIND, appendRunFriction, buildRunFriction, readFrictionDigests, frictionDigestSchema, } from "../core/evolve/friction.js";
31
+ const HARNESS_ROOT = path.resolve(new URL("../../", import.meta.url).pathname);
32
+ function baseInput(over = {}) {
33
+ return {
34
+ genomeFp: "0123456789abcdef",
35
+ cause: "run-summary",
36
+ exit: 1,
37
+ complete: true,
38
+ counts: { applied: 1, rejected: 3, benched: 2, inconclusive: 0, nominated: 0, timeouts: 0, reaped: 0 },
39
+ rejected: [
40
+ { stage: "path", reason: "immutable path grader.mjs" },
41
+ { stage: "schema", reason: "missing diffs" },
42
+ { stage: "apply", reason: "hunk context mismatch" },
43
+ ],
44
+ units: [
45
+ { unitId: "add", split: "train", scores: [0, 1], failures: ["add rep 0: scored 0 (not passing)"] },
46
+ { unitId: "sub", split: "val", scores: [1, 1], failures: [] },
47
+ ],
48
+ reasons: ["gate: aggregate gain 0.5000 < minEffect 0.6000"],
49
+ stall: { budgetTruncated: false, orphanGroups: 0 },
50
+ ...over,
51
+ };
52
+ }
53
+ test("friction: buildRunFriction sanitizes and bounds every string, fail-closed schema", () => {
54
+ const nasty = `ESCAPE\u001b[31mRED\nline2 \u0000 tab\t end ${"x".repeat(5000)}`;
55
+ const input = baseInput({
56
+ rejected: [{ stage: "unknown\u001bstage", reason: nasty }],
57
+ reasons: [nasty, nasty, nasty, nasty, nasty, nasty, nasty, nasty, nasty, nasty],
58
+ units: [
59
+ { unitId: "unit".repeat(80), split: "train", scores: [1], failures: [nasty, nasty, nasty, nasty, nasty, nasty] },
60
+ ...baseInput().units,
61
+ ],
62
+ });
63
+ const record = buildRunFriction(input);
64
+ assert.equal(record.kind, FRICTION_KIND);
65
+ const data = frictionDigestSchema.parse(record.data);
66
+ const text = JSON.stringify(data);
67
+ assert.ok(!text.includes("\u001b"));
68
+ assert.ok(!text.includes("\n"));
69
+ for (const reason of data.reasons) {
70
+ assert.ok(reason.length <= 200);
71
+ assert.match(reason, /^[\x20-\x7e]*$/);
72
+ }
73
+ assert.ok(data.reasons.length <= 10, "reasons are hard-capped at 10");
74
+ assert.equal(data.rejections.total, 1);
75
+ assert.equal(data.rejections.byStage.path, 0);
76
+ const unit = data.train[0];
77
+ assert.ok(unit !== undefined && unit.unitId.length <= 120 && unit.failures.length <= 4);
78
+ // val material never textual: the sub unit contributes alias + counts only.
79
+ assert.deepEqual(data.val, { count: 1, aliases: ["val-1"], samples: 2 });
80
+ assert.ok(!JSON.stringify(data.val).includes("sub"));
81
+ // fail-closed on a broken record: negative count never reaches the builder output.
82
+ assert.throws(() => buildRunFriction(baseInput({ counts: { ...baseInput().counts, applied: -1 } })));
83
+ });
84
+ function tmpConfig(t) {
85
+ const root = mkdtempSync(path.join(os.tmpdir(), "abathur-friction-"));
86
+ t.after(() => rmSync(root, { recursive: true, force: true }));
87
+ const configDir = path.join(root, "config");
88
+ mkdirSync(configDir, { recursive: true });
89
+ return configDir;
90
+ }
91
+ test("friction: append/read round-trip through the guarded queue file", (t) => {
92
+ const configDir = tmpConfig(t);
93
+ const first = appendRunFriction(configDir, baseInput());
94
+ appendRunFriction(configDir, baseInput({ cause: "cli-error", exit: 2, reasons: ["abathur: nope"] }));
95
+ assert.equal(first.kind, FRICTION_KIND);
96
+ const { digests, error } = readFrictionDigests(configDir);
97
+ assert.equal(error, null);
98
+ assert.equal(digests.length, 2);
99
+ assert.deepEqual(digests.map((d) => d.cause), ["run-summary", "cli-error"]);
100
+ const raw = readFileSync(frictionQueuePath(configDir), "utf8");
101
+ assert.equal(raw.endsWith("\n"), true);
102
+ });
103
+ test("friction: malformed queue line fails closed with a readable error, never crashes", (t) => {
104
+ const configDir = tmpConfig(t);
105
+ appendRunFriction(configDir, baseInput());
106
+ const file = frictionQueuePath(configDir);
107
+ const good = readFileSync(file, "utf8");
108
+ writeFileSync(file, `${good}{{{ not json at all\n${good}`, "utf8");
109
+ const mid = readFrictionDigests(configDir);
110
+ assert.equal(mid.digests.length, 0);
111
+ assert.ok(mid.error !== null && mid.error.includes("line 2"));
112
+ // a crash-truncated TAIL (no trailing newline) is repaired like the ledger's own:
113
+ const configDir2 = tmpConfig(t);
114
+ appendRunFriction(configDir2, baseInput());
115
+ const f2 = frictionQueuePath(configDir2);
116
+ writeFileSync(f2, `${readFileSync(f2, "utf8")}${good.trimEnd().slice(0, 40)}`, "utf8");
117
+ const repaired = readFrictionDigests(configDir2);
118
+ assert.equal(repaired.error, null);
119
+ assert.equal(repaired.digests.length, 1);
120
+ });
121
+ test("friction: foreign schema-valid record kinds in the queue fail closed (never crash)", (t) => {
122
+ const configDir = tmpConfig(t);
123
+ appendFriction(configDir, { kind: "generation_complete", data: { anything: true } });
124
+ const { digests, error } = readFrictionDigests(configDir);
125
+ assert.equal(digests.length, 0);
126
+ assert.ok(error !== null && error.includes("friction_digest"));
127
+ });
128
+ // ------------------------------------------------------------- toy run storms
129
+ export const STUB_STORM = `#!/usr/bin/env node
130
+ import { readFileSync } from "node:fs";
131
+ const args = process.argv.slice(2);
132
+ const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
133
+ const dir = opt("--dir");
134
+ const read = (f) => readFileSync(dir + "/" + f, "utf8");
135
+ function lineDiff(file, anchor, replacement) {
136
+ const lines = read(file).split("\\n");
137
+ const i = lines.findIndex((l) => l.includes(anchor));
138
+ if (i < 0) { process.stderr.write("stub: no anchor in " + file + "\\n"); process.exit(1); }
139
+ const changed = lines[i].replace(anchor, replacement);
140
+ return "--- a/" + file + "\\n+++ b/" + file + "\\n@@ -" + String(i + 1) + ",1 +" + String(i + 1) + ",1 @@\\n-" + lines[i] + "\\n+" + changed + "\\n";
141
+ }
142
+ const mulFirst = read("units/mul.mjs").split("\\n")[0];
143
+ process.stdout.write(JSON.stringify({ candidates: [
144
+ { id: "touch-kernel", rationale: "subvert grading", diffs: [lineDiff("grader.mjs", "model-free", "model-free-ish")] },
145
+ { id: "no-diffs", rationale: "schema damage" },
146
+ { id: "wrong-context", rationale: "apply damage", diffs: ["--- a/units/add.mjs\\n+++ b/units/add.mjs\\n@@ -1,1 +1,1 @@\\n-" + mulFirst + "\\n+" + mulFirst + " /*x*/\\n"] },
147
+ { id: "annotate-add", rationale: "harmless annotate", diffs: [lineDiff("units/add.mjs", "export function add(", "export function add /* patched */(")] },
148
+ ] }) + "\\n");
149
+ `;
150
+ async function stormFixture(t, canaryVal = false) {
151
+ const root = mkdtempSync(path.join(os.tmpdir(), "abathur-storm-"));
152
+ t.after(() => spawnSyncRm(root));
153
+ const configDir = path.join(root, "config");
154
+ mkdirSync(configDir, { recursive: true });
155
+ writeFileSync(path.join(configDir, "config.jsonc"), '{ "opencodeBin": null }\n', "utf8");
156
+ const repo = await prepareToyGenome(path.join(root, "genome"));
157
+ if (canaryVal) {
158
+ // rename the val unit BEFORE registration (edit-then-register rule); the file
159
+ // is a bench target, so the rename must be committed or openGenome refuses.
160
+ const specPath = path.join(repo, "genome.jsonc");
161
+ const doc = JSON.parse(readFileSync(specPath, "utf8"));
162
+ const val = doc.bench.units.find((u) => u.split === "val");
163
+ if (val === undefined)
164
+ throw new Error("fixture: toy-smoke lost its val unit");
165
+ const subBytes = readFileSync(path.join(repo, val.path), "utf8");
166
+ val.id = "val-CANARY-9f3a";
167
+ val.path = "units/CANARY-9f3a-sub.mjs";
168
+ writeFileSync(specPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
169
+ rmSync(path.join(repo, "units/sub.mjs"));
170
+ writeFileSync(path.join(repo, val.path), subBytes, "utf8");
171
+ gitIn(repo, "add", "-A");
172
+ gitIn(repo, "commit", "-m", "canary val unit");
173
+ }
174
+ registerGenome(configDir, path.join(repo, "genome.jsonc"));
175
+ const entry = requireGenomesByLabel(configDir, "toy-smoke").entries[0];
176
+ if (entry === undefined)
177
+ throw new Error("fixture: toy-smoke vanished from registry");
178
+ const stub = path.join(root, "stub.mjs");
179
+ writeFileSync(stub, STUB_STORM, "utf8");
180
+ chmodSync(stub, 0o755);
181
+ return {
182
+ root,
183
+ configDir,
184
+ repo,
185
+ entry,
186
+ mutatorCommand: `node ${stub} --dir {worktree} --brief {brief}`,
187
+ env: { XDG_CACHE_HOME: path.join(root, "xdg"), HOME: path.join(root, "home") },
188
+ queue: frictionQueuePath(configDir),
189
+ };
190
+ }
191
+ function spawnSyncRm(root) {
192
+ rmSync(root, { recursive: true, force: true });
193
+ }
194
+ test("AC-1: rejection-storm toy run appends one structured friction digest matching observed rejects", async (t) => {
195
+ // stage rename: the canary file write needs the ORIGINAL sub.mjs bytes first.
196
+ const f = await stormFixture(t);
197
+ const seen = [];
198
+ const outcome = await runEvolution({
199
+ entry: f.entry,
200
+ configDir: f.configDir,
201
+ mutatorCommand: f.mutatorCommand,
202
+ env: f.env,
203
+ sandboxRoot: path.join(f.root, "sandboxes"),
204
+ friction: (input) => {
205
+ seen.push(input);
206
+ appendRunFriction(f.configDir, input);
207
+ },
208
+ });
209
+ assert.equal(seen.length, 1, "exactly one run-summary digest per run");
210
+ assert.equal(outcome.exitCode, 1, "no nomination in the storm => blocked exit");
211
+ const { digests, error } = readFrictionDigests(f.configDir);
212
+ assert.equal(error, null);
213
+ assert.equal(digests.length, 1);
214
+ const d = digests[0];
215
+ if (d === undefined)
216
+ throw new Error("unreachable");
217
+ assert.equal(d.cause, "run-summary");
218
+ assert.equal(d.genomeFp, f.entry.fingerprint);
219
+ assert.equal(d.rejections.total, 3);
220
+ assert.deepEqual(d.rejections.byStage, { schema: 1, syntax: 0, path: 1, apply: 1, parse: 0 });
221
+ assert.equal(d.counts.applied, 1);
222
+ assert.equal(d.counts.rejected, 3);
223
+ assert.equal(d.counts.benched, 2);
224
+ assert.equal(d.counts.nominated, 0);
225
+ assert.equal(d.exit, 1);
226
+ assert.equal(d.complete, true);
227
+ assert.ok(d.train.some((u) => u.unitId === "add" && u.n === 4), "train unit ids preserved (incumbent+candidate samples merged per unit)");
228
+ // misleading_success guard: every rejection line the run printed is counted.
229
+ const rejectedLines = outcome.lines.filter((l) => l.includes("rejected ("));
230
+ assert.equal(rejectedLines.length, d.rejections.total);
231
+ });
232
+ test("AC-2: canary planted in the val unit NEVER appears in the friction queue", async (t) => {
233
+ const f = await stormFixture(t, true);
234
+ const outcome = await runEvolution({
235
+ entry: f.entry,
236
+ configDir: f.configDir,
237
+ mutatorCommand: f.mutatorCommand,
238
+ env: f.env,
239
+ sandboxRoot: path.join(f.root, "sandboxes"),
240
+ friction: (input) => appendRunFriction(f.configDir, input),
241
+ });
242
+ assert.equal(outcome.exitCode, 1);
243
+ const raw = readFileSync(f.queue, "utf8");
244
+ assert.ok(raw.length > 0, "the queue file was written");
245
+ assert.ok(!raw.includes("CANARY"), "val canary (id AND path) never reaches the queue");
246
+ assert.ok(!raw.includes("sub"), "even the original val id is absent");
247
+ const { digests, error } = readFrictionDigests(f.configDir);
248
+ assert.equal(error, null);
249
+ const val = digests[0]?.val;
250
+ assert.deepEqual(val, { count: 1, aliases: ["val-1"], samples: 4 }, "scrubbed val material is present as counts/aliases");
251
+ });
252
+ function gitIn(repo, ...args) {
253
+ execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=abathur@harness.local", "-C", repo, ...args], { stdio: "pipe" });
254
+ }
255
+ test("friction: run CLI on drifted kernel records cause cli-error (spawned exit 1)", (t) => {
256
+ const root = mkdtempSync(path.join(os.tmpdir(), "abathur-drift-"));
257
+ t.after(() => rmSync(root, { recursive: true, force: true }));
258
+ const configDir = path.join(root, "config");
259
+ mkdirSync(configDir, { recursive: true });
260
+ const configPath = path.join(configDir, "config.jsonc");
261
+ writeFileSync(configPath, '{ "opencodeBin": null }\n', "utf8");
262
+ const repo = mkdtempSync(path.join(root, "genome"));
263
+ // register a minimal fake toy genome: coverage needs files only.
264
+ touchSeal(repo, ["units/add.mjs", "units/mul.mjs", "units/explode.mjs", "units/sub.mjs", "grader.mjs"]);
265
+ writeFileSync(path.join(repo, "genome.jsonc"), toySpec(repo), "utf8");
266
+ execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" });
267
+ execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=abathur@harness.local", "-C", repo, "add", "-A"], { stdio: "pipe" });
268
+ execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=abathur@harness.local", "-C", repo, "commit", "-m", "genome"], { stdio: "pipe" });
269
+ registerGenome(configDir, path.join(repo, "genome.jsonc"));
270
+ // drift the sealed grader AFTER registration:
271
+ writeFileSync(path.join(repo, "grader.mjs"), "// tampered\n", "utf8");
272
+ const run = spawnSync(process.execPath, [path.join(HARNESS_ROOT, "dist/cli.js"), "run", "--genome", "toy-drift"], {
273
+ encoding: "utf8",
274
+ env: { ...process.env, ABATHUR_CONFIG: configPath, HOME: path.join(root, "home"), XDG_CACHE_HOME: path.join(root, "xdg") },
275
+ });
276
+ assert.equal(run.status, 1, run.stderr);
277
+ assert.match(run.stderr, /kernel drift/);
278
+ const { digests, error } = readFrictionDigests(configDir);
279
+ assert.equal(error, null);
280
+ assert.equal(digests.length, 1);
281
+ const d = digests[0];
282
+ assert.ok(d !== undefined && d.cause === "cli-error" && d.exit === 1);
283
+ assert.ok(d.reasons.some((r) => r.includes("kernel drift")));
284
+ // the refusal wrote no ledger state (audit precedes Ledger.open):
285
+ assert.equal(existsSync(path.join(repo, ".state")), false);
286
+ });
287
+ function touchSeal(root, rels) {
288
+ for (const rel of rels) {
289
+ const abs = path.join(root, rel);
290
+ mkdirSync(path.dirname(abs), { recursive: true });
291
+ writeFileSync(abs, "// unit\nexport function checks() { return [true]; }\n", "utf8");
292
+ }
293
+ }
294
+ function toySpec(repo) {
295
+ return `{
296
+ "label": "toy-drift",
297
+ "repoPath": ${JSON.stringify(repo)},
298
+ "bench": {
299
+ "type": "toy",
300
+ "units": [
301
+ { "id": "add", "path": "units/add.mjs", "split": "train" },
302
+ { "id": "sub", "path": "units/sub.mjs", "split": "val" }
303
+ ],
304
+ "runCommand": "node {unit.path}",
305
+ "graderCommand": "node grader.mjs {unit.path}",
306
+ "timeoutS": 10,
307
+ "stats": { "halfWidth": 0.25, "minEffect": 0.5, "nReps": { "initial": 2, "max": 4 } }
308
+ },
309
+ "budget": { "maxCandidates": 4, "maxModelCalls": 16, "maxTokens": 100000, "maxWallS": 300 },
310
+ "kernel": { "immutableGlobs": ["grader.mjs"] }
311
+ }
312
+ `;
313
+ }
314
+ // ------------------------------------------------- machine-independence (seed)
315
+ function liteSelfRepo(root) {
316
+ touchSeal(root, [
317
+ "src/core/stats.ts",
318
+ "src/core/ledger.ts",
319
+ "src/core/ids.ts",
320
+ "src/core/genome.ts",
321
+ "src/core/promote.ts",
322
+ "src/bench/toy.ts",
323
+ "src/bench/fixture.ts",
324
+ "graders/contract.json",
325
+ "genomes/abathur-self.jsonc",
326
+ "scripts/copy-assets.mjs",
327
+ "package.json",
328
+ "tsconfig.json",
329
+ ]);
330
+ }
331
+ test("seed genome: fingerprint + registry stem machine-independent across ABATHUR_SELF_REPO values", (t) => {
332
+ const spec = loadGenomeSpecFile(path.join(HARNESS_ROOT, "genomes/abathur-self.jsonc"));
333
+ assert.equal(spec.label, "abathur-self");
334
+ assert.equal(spec.repoPath, "${ABATHUR_SELF_REPO}");
335
+ assert.equal(isEnvRepoLiteral(spec.repoPath), true);
336
+ assert.equal(effectiveRepoPath("relative/repo").length > 0, true);
337
+ const fp = fingerprint16(spec);
338
+ assert.match(fp, /^[0-9a-f]{16}$/);
339
+ const homes = [];
340
+ for (const name of ["A", "B"]) {
341
+ const root = mkdtempSync(path.join(os.tmpdir(), `abathur-selfreg-${name}-`));
342
+ t.after(() => rmSync(root, { recursive: true, force: true }));
343
+ const configDir = path.join(root, "config");
344
+ mkdirSync(configDir, { recursive: true });
345
+ const repoDir = path.join(root, "harness");
346
+ liteSelfRepo(repoDir);
347
+ homes.push({ dir: configDir, env: repoDir });
348
+ }
349
+ const [homeA, homeB] = homes;
350
+ const saved = process.env.ABATHUR_SELF_REPO;
351
+ try {
352
+ process.env.ABATHUR_SELF_REPO = homeA.env;
353
+ registerGenome(homeA.dir, path.join(HARNESS_ROOT, "genomes/abathur-self.jsonc"));
354
+ process.env.ABATHUR_SELF_REPO = homeB.env;
355
+ registerGenome(homeB.dir, path.join(HARNESS_ROOT, "genomes/abathur-self.jsonc"));
356
+ }
357
+ finally {
358
+ if (saved === undefined)
359
+ delete process.env.ABATHUR_SELF_REPO;
360
+ else
361
+ process.env.ABATHUR_SELF_REPO = saved;
362
+ }
363
+ const fileA = path.join(homeA.dir, "genomes", `${fp}.jsonc`);
364
+ const fileB = path.join(homeB.dir, "genomes", `${fp}.jsonc`);
365
+ assert.ok(existsSync(fileA) && existsSync(fileB), "same registry stem under both env values");
366
+ const a = readFileSync(fileA, "utf8");
367
+ assert.equal(a, readFileSync(fileB, "utf8"));
368
+ assert.ok(a.includes("${ABATHUR_SELF_REPO}"), "stored spec keeps the LITERAL, never the resolved path");
369
+ // unset env => every consumer exits 2 naming the variable, never a literal dir:
370
+ delete process.env.ABATHUR_SELF_REPO;
371
+ const root3 = mkdtempSync(path.join(os.tmpdir(), "abathur-selfreg-C-"));
372
+ t.after(() => rmSync(root3, { recursive: true, force: true }));
373
+ try {
374
+ registerGenome(root3, path.join(HARNESS_ROOT, "genomes/abathur-self.jsonc"));
375
+ assert.fail("registration must refuse without ABATHUR_SELF_REPO");
376
+ }
377
+ catch (error) {
378
+ assert.ok(error instanceof ExitSignal && error.code === 2);
379
+ assert.match(error.message, /ABATHUR_SELF_REPO/);
380
+ }
381
+ assert.equal(existsSync(path.join(root3, "${ABATHUR_SELF_REPO}")), false);
382
+ });
383
+ // ----------------------------------------------------------- structural guard
384
+ test("structural: todo-11 modules grant no promote authority and load no candidate code", () => {
385
+ const files = [
386
+ "src/core/evolve/friction.ts",
387
+ "src/core/evolve/self-snapshot.ts",
388
+ "src/core/evolve/self-overlay.ts",
389
+ "src/commands/self-eval.ts",
390
+ ];
391
+ for (const rel of files) {
392
+ const text = readFileSync(path.join(HARNESS_ROOT, rel), "utf8");
393
+ for (const banned of ["core/promote.js", "promoteGeneration", "fastForwardIncumbent", "new Function", "eval(", " child_process.exec(", "execSync("]) {
394
+ assert.ok(!text.includes(banned), `${rel} must not reference '${banned}'`);
395
+ }
396
+ assert.ok(!/import\s*\(/.test(text), `${rel} must not dynamically import candidate code`);
397
+ }
398
+ });