@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,236 @@
1
+ // Opencode-fixture-scenarios bench adapter (plan todo 6, lines 117-124): the
2
+ // real-surface path the evolution loop (todo 9) drives when bench.type is
3
+ // "opencode-fixture-scenarios". Reuses the todo-5 plumbing verbatim (renderCommand
4
+ // templates, runChild process-group discipline, grader JSON contract) and adds:
5
+ // - startup probes: `<opencodeBin> --version` enforced against
6
+ // spec.opencodeBinVersion.minVersion and every spec.requires[] binary probed via
7
+ // PATH (argv spawn, never shell); any failure is exit 2 BEFORE a unit runs, and
8
+ // the observed version strings land in EVERY RunResult.benchProvenance.
9
+ // - per-unit lifecycle reset → seed → run under a fingerprint-keyed
10
+ // single-flight genome lock (src/core/locks via acquireGenomeLock).
11
+ // - sandbox HOME discipline: ONLY .opencode/{plugin,skills,node_modules} is
12
+ // mirrored from the real HOME and the opencode config is copied-then-mutated
13
+ // per scenario (never symlinked, real HOME untouched).
14
+ // - train/val by SPLIT FIELD ONLY, with two INDEPENDENT axes (plan lines
15
+ // 117-124 + 125-132): BENCHING authority — the loop's bench driver runs val
16
+ // replicates under loopValAuthority (the selection gate needs them), while a
17
+ // direct adapter consumer must pass includeVal; and EXPOSURE — val ids/paths
18
+ // enter the sandbox manifest only under includeVal, reachable ONLY via the
19
+ // operator CLI switch `abathur run --include-val`. Without it the manifest
20
+ // keeps val entries as opaque aliases, even while those units are benched.
21
+ // Scenario content stays opaque — no harness-specific parsing anywhere here.
22
+ //
23
+ // Pure-LOC documented exception (F2 review, 2026-09-10): 253 pure LOC, above the
24
+ // 250 ceiling — the LOOP_VAL_AUTHORITY benching seam must live in-fixture beside
25
+ // the exposure axis it is deliberately independent from (F1-fix2 pins both through
26
+ // this module's surface; a minimal seam was mandated over splitting). Accepted
27
+ // exception: do not grow this file; split at the next real feature.
28
+ import { existsSync, mkdirSync, statSync } from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import { cannotAnswer, ExitSignal } from "../exit.js";
32
+ import { resolveConfigDir } from "../config.js";
33
+ import { fingerprint } from "../core/ids.js";
34
+ import { acquireGenomeLock, Ledger } from "../core/ledger.js";
35
+ import { childStatus, firstLine, inconclusive, parseGraderLine, renderCommand, runChild, sandboxVars, unitVars, ZERO_METRICS, } from "./adapter.js";
36
+ import { buildManifest, gateVal, mirrorSandboxHome, parseRunMeta, sandboxHomeDir, transcriptPathFor, writeManifest, } from "./fixture-support.js";
37
+ import { probeEngines, resolveOpencodeBin } from "./fixture-probe.js";
38
+ export { buildManifest, compareSemver, manifestPathFor, mirrorSandboxHome, parseSemver, sandboxHomeDir, transcriptPathFor, writeManifest, } from "./fixture-support.js";
39
+ export { probeEngines, resolveOpencodeBin } from "./fixture-probe.js";
40
+ const HOOK_TIMEOUT_S = 60;
41
+ export class FixtureScenariosAdapter {
42
+ spec;
43
+ opts;
44
+ repoRoot;
45
+ lease = null;
46
+ started = null;
47
+ versions = [];
48
+ activeSandbox = null;
49
+ constructor(spec, opts = {}) {
50
+ this.spec = spec;
51
+ this.opts = opts;
52
+ if (spec.bench.type !== "opencode-fixture-scenarios") {
53
+ cannotAnswer(`fixture adapter: bench.type '${spec.bench.type}' is not "opencode-fixture-scenarios"`, "use ToyBenchAdapter for bench.type 'toy'");
54
+ }
55
+ const root = path.resolve(spec.repoPath);
56
+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
57
+ cannotAnswer(`fixture: repoPath '${spec.repoPath}' is not a readable directory`);
58
+ }
59
+ this.repoRoot = root;
60
+ }
61
+ async reset(sandboxDir) {
62
+ await this.ensureStarted();
63
+ this.activeSandbox = sandboxDir;
64
+ mkdirSync(sandboxDir, { recursive: true });
65
+ await this.hook(this.spec.bench.resetCommand, sandboxDir, "resetCommand");
66
+ // the reset script owns scenario teardown and may replace the dir wholesale
67
+ mkdirSync(sandboxDir, { recursive: true });
68
+ mirrorSandboxHome(this.homeDir(), sandboxDir, this.agentModel());
69
+ }
70
+ async seed(sandboxDir) {
71
+ await this.ensureStarted();
72
+ this.activeSandbox = sandboxDir;
73
+ mkdirSync(sandboxDir, { recursive: true });
74
+ if (!existsSync(sandboxHomeDir(sandboxDir)))
75
+ mirrorSandboxHome(this.homeDir(), sandboxDir, this.agentModel());
76
+ writeManifest(sandboxDir, this.scenarioManifest());
77
+ await this.hook(this.spec.bench.seedCommand, sandboxDir, "seedCommand");
78
+ }
79
+ async run(unit, sandboxDir, timeoutS) {
80
+ await this.ensureStarted();
81
+ gateVal(unit, this.valRunAllowed());
82
+ this.activeSandbox = sandboxDir;
83
+ mkdirSync(sandboxDir, { recursive: true });
84
+ const transcript = transcriptPathFor(sandboxDir, unit.id);
85
+ mkdirSync(path.dirname(transcript), { recursive: true });
86
+ const outcome = await runChild({
87
+ argv: renderCommand(this.spec.bench.runCommand, unitVars(unit, sandboxDir)),
88
+ cwd: sandboxDir,
89
+ timeoutS,
90
+ env: this.sandboxEnv(sandboxDir, unit),
91
+ ...(this.opts.onChild === undefined ? {} : { onChild: this.opts.onChild }),
92
+ });
93
+ const status = childStatus(outcome.kind);
94
+ return {
95
+ unitId: unit.id,
96
+ status,
97
+ metrics: status === "ok" ? parseRunMeta(outcome.stdout) : ZERO_METRICS,
98
+ benchProvenance: { benchType: "opencode-fixture-scenarios", versions: this.versions },
99
+ exitCode: outcome.exitCode,
100
+ note: status === "ok" && outcome.exitCode === 0 ? undefined : outcome.reason,
101
+ transcriptPath: status === "ok" && existsSync(transcript) ? transcript : undefined,
102
+ };
103
+ }
104
+ async score(unit) {
105
+ await this.ensureStarted();
106
+ gateVal(unit, this.valRunAllowed());
107
+ const sandbox = this.activeSandbox;
108
+ if (sandbox === null) {
109
+ cannotAnswer("fixture adapter: score() called before any reset()/seed()/run() — no sandbox yet", "drive the adapter in reset→seed→run→score order");
110
+ }
111
+ const outcome = await runChild({
112
+ argv: renderCommand(this.spec.bench.graderCommand, unitVars(unit, sandbox)),
113
+ cwd: sandbox,
114
+ timeoutS: this.spec.bench.timeoutS,
115
+ env: this.sandboxEnv(sandbox),
116
+ ...(this.opts.onChild === undefined ? {} : { onChild: this.opts.onChild }),
117
+ });
118
+ if (outcome.kind !== "exited") {
119
+ return inconclusive(unit.id, `grader ${outcome.kind}: ${outcome.reason}`);
120
+ }
121
+ if (outcome.exitCode !== 0) {
122
+ return inconclusive(unit.id, `grader exited ${String(outcome.exitCode)}: ${firstLine(outcome.stderr) || firstLine(outcome.stdout) || "no output"}`);
123
+ }
124
+ const parsed = parseGraderLine(outcome.stdout);
125
+ if (parsed === null) {
126
+ return inconclusive(unit.id, `grader stdout is not a score JSON line: ${firstLine(outcome.stdout) || "no output"}`);
127
+ }
128
+ return { kind: "scored", result: { unitId: unit.id, ...parsed } };
129
+ }
130
+ /**
131
+ * Val units may be RUN/SCORED when the operator opened exposure (includeVal)
132
+ * OR the loop holds benching authority (loopValAuthority, F1-fix2); either
133
+ * way this never decides what the manifest EXPOSES.
134
+ */
135
+ valRunAllowed() {
136
+ return this.opts.includeVal === true || this.opts.loopValAuthority === true;
137
+ }
138
+ /**
139
+ * Sandbox view of the scenarios: val ids/paths surface only under operator
140
+ * includeVal. loopValAuthority deliberately does NOT open this — the loop may
141
+ * bench val units while their paths stay opaque aliases (plan line 118).
142
+ */
143
+ scenarioManifest() {
144
+ return buildManifest(this.spec.bench.units, this.opts.includeVal === true);
145
+ }
146
+ /** Free the single-flight lease (todo 9 calls this when the bench run ends). */
147
+ release() {
148
+ this.lease?.release();
149
+ this.lease = null;
150
+ this.started = null;
151
+ this.versions = [];
152
+ }
153
+ // ------------------------------------------------------- startup + locking
154
+ ensureStarted() {
155
+ if (this.started === null) {
156
+ this.started = this.startProbes().catch((cause) => {
157
+ this.started = null;
158
+ throw cause;
159
+ });
160
+ }
161
+ return this.started;
162
+ }
163
+ async startProbes() {
164
+ this.acquireFlight();
165
+ try {
166
+ this.versions = await probeEngines(this.spec, this.opencodeBinName(), this.repoRoot, {
167
+ ...(this.opts.onChild === undefined ? {} : { onChild: this.opts.onChild }),
168
+ });
169
+ }
170
+ catch (cause) {
171
+ this.release();
172
+ throw cause;
173
+ }
174
+ }
175
+ acquireFlight() {
176
+ if (this.lease !== null)
177
+ return;
178
+ const configDir = this.opts.configDir ?? resolveConfigDir(this.opts.env ?? process.env);
179
+ const fp = fingerprint(this.spec);
180
+ const ledger = Ledger.open(this.repoRoot);
181
+ try {
182
+ this.lease = acquireGenomeLock({
183
+ ledger,
184
+ configDir,
185
+ genomeFp: fp,
186
+ waitMs: this.opts.lockWaitMs ?? 0,
187
+ });
188
+ }
189
+ catch (cause) {
190
+ if (cause instanceof ExitSignal) {
191
+ cannotAnswer(`fixture: another bench active for genome '${fp.slice(0, 16)}'`, cause.hint ?? cause.message);
192
+ }
193
+ throw cause;
194
+ }
195
+ }
196
+ // ----------------------------------------------------------------- sandbox
197
+ sandboxEnv(sandboxDir, unit) {
198
+ const env = {
199
+ HOME: sandboxHomeDir(sandboxDir),
200
+ ABATHUR_AGENT_MODEL: this.agentModel(),
201
+ };
202
+ const judge = this.spec.bench.judgeModel;
203
+ if (judge !== undefined)
204
+ env["ABATHUR_JUDGE_MODEL"] = judge;
205
+ if (unit !== undefined)
206
+ env["ABATHUR_TRANSCRIPT"] = transcriptPathFor(sandboxDir, unit.id);
207
+ return env;
208
+ }
209
+ /** seed/reset hooks are infrastructure: a failing hook is a tool error (exit 2). */
210
+ async hook(template, sandboxDir, label) {
211
+ if (template === undefined)
212
+ return;
213
+ const outcome = await runChild({
214
+ argv: renderCommand(template, sandboxVars(sandboxDir)),
215
+ cwd: sandboxDir,
216
+ timeoutS: HOOK_TIMEOUT_S,
217
+ env: this.sandboxEnv(sandboxDir),
218
+ ...(this.opts.onChild === undefined ? {} : { onChild: this.opts.onChild }),
219
+ });
220
+ if (outcome.kind !== "exited" || outcome.exitCode !== 0) {
221
+ cannotAnswer(`fixture ${label} failed (${outcome.reason}): ${firstLine(outcome.stderr) || "no output"}`);
222
+ }
223
+ }
224
+ agentModel() {
225
+ return this.spec.bench.agentModel ?? cannotAnswer("fixture: bench.agentModel is required");
226
+ }
227
+ opencodeBinName() {
228
+ return resolveOpencodeBin(this.opts);
229
+ }
230
+ homeDir() {
231
+ if (this.opts.home !== undefined)
232
+ return this.opts.home;
233
+ const envHome = (this.opts.env ?? process.env).HOME;
234
+ return envHome === undefined || envHome.length === 0 ? os.homedir() : envHome;
235
+ }
236
+ }
@@ -0,0 +1,152 @@
1
+ // Toy bench adapter (plan todo 5): the deterministic, model-free evolution
2
+ // substrate. Units are tiny source files of the genome repo itself; the grader is
3
+ // the repo's own `node grader.mjs {unit.path}`. The toy path never requires
4
+ // opencode, network, or absolute machine paths (D7 generality proof): every binary
5
+ // is a PATH name, every file reference is genome-relative, sandboxes are caller-chosen.
6
+ //
7
+ // Sandbox model: reset = wipe+recreate (determinism substrate), seed = copy the
8
+ // genome working tree (minus .git/.state) into the sandbox. score() has no sandbox
9
+ // parameter by iface contract, so the adapter remembers the last sandbox passed to
10
+ // reset/seed/run and grades the unit copy inside it.
11
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { cannotAnswer } from "../exit.js";
15
+ import { walkTree } from "../core/ids.js";
16
+ import { childStatus, firstLine, inconclusive, parseGraderLine, renderCommand, runChild, sandboxVars, unitVars, ZERO_METRICS, } from "./adapter.js";
17
+ /** Model-free token heuristic pinned by the grader too: 1 token ~ 4 source bytes. */
18
+ const BYTES_PER_TOKEN = 4;
19
+ const HOOK_TIMEOUT_S = 60;
20
+ export class ToyBenchAdapter {
21
+ spec;
22
+ opts;
23
+ repoRoot;
24
+ activeSandbox = null;
25
+ constructor(spec, opts = {}) {
26
+ this.spec = spec;
27
+ this.opts = opts;
28
+ const root = path.resolve(spec.repoPath);
29
+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
30
+ cannotAnswer(`toy: repoPath '${spec.repoPath}' is not a readable directory`);
31
+ }
32
+ this.repoRoot = root;
33
+ }
34
+ async reset(sandboxDir) {
35
+ rmSync(sandboxDir, { recursive: true, force: true });
36
+ mkdirSync(sandboxDir, { recursive: true });
37
+ this.activeSandbox = sandboxDir;
38
+ await this.hook(this.spec.bench.resetCommand, sandboxDir, "resetCommand");
39
+ }
40
+ async seed(sandboxDir) {
41
+ mkdirSync(sandboxDir, { recursive: true });
42
+ for (const file of walkTree(this.repoRoot)) {
43
+ const top = file.path.split("/")[0];
44
+ if (top === ".git" || top === ".state")
45
+ continue;
46
+ const target = path.join(sandboxDir, file.path);
47
+ mkdirSync(path.dirname(target), { recursive: true });
48
+ writeFileSync(target, file.content);
49
+ }
50
+ this.activeSandbox = sandboxDir;
51
+ await this.hook(this.spec.bench.seedCommand, sandboxDir, "seedCommand");
52
+ }
53
+ async run(unit, sandboxDir, timeoutS) {
54
+ this.activeSandbox = sandboxDir;
55
+ const outcome = await runChild({
56
+ argv: renderCommand(this.spec.bench.runCommand, unitVars(unit, sandboxDir)),
57
+ cwd: sandboxDir,
58
+ timeoutS,
59
+ ...(this.opts.onChild === undefined ? {} : { onChild: this.opts.onChild }),
60
+ });
61
+ const status = childStatus(outcome.kind);
62
+ return {
63
+ unitId: unit.id,
64
+ status,
65
+ metrics: status === "ok" ? this.runMetrics(unit, sandboxDir) : ZERO_METRICS,
66
+ benchProvenance: this.provenance(),
67
+ exitCode: outcome.exitCode,
68
+ // clean ok runs stay note-less; non-zero unit exits record the plain reason
69
+ note: status === "ok" && outcome.exitCode === 0 ? undefined : outcome.reason,
70
+ };
71
+ }
72
+ async score(unit) {
73
+ const sandbox = this.activeSandbox;
74
+ if (sandbox === null) {
75
+ cannotAnswer("toy adapter: score() called before any reset()/seed()/run() — no sandbox yet", "drive the adapter in reset→seed→run→score order");
76
+ }
77
+ const outcome = await runChild({
78
+ argv: renderCommand(this.spec.bench.graderCommand, unitVars(unit, sandbox)),
79
+ cwd: sandbox,
80
+ timeoutS: this.spec.bench.timeoutS,
81
+ ...(this.opts.onChild === undefined ? {} : { onChild: this.opts.onChild }),
82
+ });
83
+ if (outcome.kind !== "exited") {
84
+ return inconclusive(unit.id, `grader ${outcome.kind}: ${outcome.reason}`);
85
+ }
86
+ if (outcome.exitCode !== 0) {
87
+ return inconclusive(unit.id, `grader exited ${String(outcome.exitCode)}: ${firstLine(outcome.stderr) || firstLine(outcome.stdout) || "no output"}`);
88
+ }
89
+ const parsed = parseGraderLine(outcome.stdout);
90
+ if (parsed === null) {
91
+ return inconclusive(unit.id, `grader stdout is not a score JSON line: ${firstLine(outcome.stdout) || "no output"}`);
92
+ }
93
+ return { kind: "scored", result: { unitId: unit.id, ...parsed } };
94
+ }
95
+ runMetrics(unit, sandboxDir) {
96
+ try {
97
+ const bytes = readFileSync(path.join(sandboxDir, unit.path)).length;
98
+ return { tokensEst: Math.ceil(bytes / BYTES_PER_TOKEN), turns: 1 };
99
+ }
100
+ catch {
101
+ return { tokensEst: 0, turns: 1 }; // unit vanished mid-run: still one measured turn
102
+ }
103
+ }
104
+ provenance() {
105
+ return { benchType: "toy", versions: [{ bin: "node", version: process.version }] };
106
+ }
107
+ /** seed/reset hooks are infrastructure: a failing hook is a tool error (exit 2). */
108
+ async hook(template, sandboxDir, label) {
109
+ if (template === undefined)
110
+ return;
111
+ const outcome = await runChild({
112
+ argv: renderCommand(template, sandboxVars(sandboxDir)),
113
+ cwd: sandboxDir,
114
+ timeoutS: HOOK_TIMEOUT_S,
115
+ ...(this.opts.onChild === undefined ? {} : { onChild: this.opts.onChild }),
116
+ });
117
+ if (outcome.kind !== "exited" || outcome.exitCode !== 0) {
118
+ cannotAnswer(`toy ${label} failed (${outcome.reason}): ${firstLine(outcome.stderr) || "no output"}`);
119
+ }
120
+ }
121
+ }
122
+ // ------------------------------------------------------- fixture self-init
123
+ /** Locates genomes/toy-smoke beside the compiled module (repo source or dist copy). */
124
+ export function toyTemplateDir() {
125
+ for (const rel of ["../../genomes/toy-smoke/", "../genomes/toy-smoke/"]) {
126
+ const dir = fileURLToPath(new URL(rel, import.meta.url));
127
+ if (existsSync(path.join(dir, "genome.jsonc")))
128
+ return dir;
129
+ }
130
+ cannotAnswer("toy fixture: genomes/toy-smoke not found beside dist/bench/toy.js", "npm run build ships it to dist/genomes/toy-smoke via copy-assets");
131
+ }
132
+ /**
133
+ * Materialize an independent toy genome repo (git-initialized, repoPath rewritten)
134
+ * by running the fixture's own init.mjs — one implementation, shell-usable too.
135
+ */
136
+ export async function prepareToyGenome(destDir, templateDir = toyTemplateDir()) {
137
+ const dest = path.resolve(destDir);
138
+ // init.mjs runs with cwd = parent of dest; a missing cwd surfaces as a
139
+ // misleading `spawn <bin> ENOENT`, so materialize the parent first.
140
+ mkdirSync(path.dirname(dest), { recursive: true });
141
+ const outcome = await runChild({
142
+ argv: [process.execPath, path.join(templateDir, "init.mjs"), dest],
143
+ cwd: path.dirname(dest),
144
+ timeoutS: HOOK_TIMEOUT_S,
145
+ });
146
+ if (outcome.kind !== "exited" || outcome.exitCode !== 0) {
147
+ cannotAnswer(`toy fixture init failed (${outcome.reason}): ${firstLine(outcome.stderr) || "no output"}`);
148
+ }
149
+ return dest;
150
+ }
151
+ // --------------------------------------------------------------- grader JSON
152
+ // parseGraderLine + the score contract live in adapter.ts (shared with todo 6).
package/dist/cli.js ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // CLI spine (todo 1). Every command group is a CommandSpec in COMMANDS (group
3
+ // logic lives in src/commands/<group>.ts; this file stays a thin router).
4
+ // Handlers return EXIT_OK or throw ExitSignal (see src/exit.ts) — never touch
5
+ // process themselves, so they stay unit-testable.
6
+ import { realpathSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { bundleCommand } from "./commands/bundle.js";
10
+ import { genomeCommand } from "./commands/genome.js";
11
+ import { graftCommand } from "./commands/graft.js";
12
+ import { kernelCommand } from "./commands/kernel.js";
13
+ import { promoteCommand } from "./commands/promote.js";
14
+ import { runCommand } from "./commands/run.js";
15
+ import { selfEvalCommand } from "./commands/self-eval.js";
16
+ import { statusCommand } from "./commands/status.js";
17
+ import { tombstoneCommand } from "./commands/tombstone.js";
18
+ import { ConfigError, loadConfig } from "./config.js";
19
+ import { EXIT_CANNOT_ANSWER, EXIT_OK, ExitSignal, cannotAnswer, renderExitSignal, } from "./exit.js";
20
+ // Router order mirrors the plan; summaries appear verbatim in --help.
21
+ export const COMMANDS = [
22
+ genomeCommand,
23
+ runCommand,
24
+ statusCommand,
25
+ promoteCommand,
26
+ tombstoneCommand,
27
+ bundleCommand,
28
+ graftCommand,
29
+ selfEvalCommand,
30
+ kernelCommand,
31
+ ];
32
+ export function usage() {
33
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
34
+ const lines = COMMANDS.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`).join("\n");
35
+ return [
36
+ "abathur — OpenCode genome evolution harness",
37
+ "",
38
+ "usage: abathur <command> [args...]",
39
+ "",
40
+ "commands:",
41
+ lines,
42
+ "",
43
+ "exit codes:",
44
+ " 0 ok / pass",
45
+ " 1 blocked / failed (decision)",
46
+ " 2 cannot-answer (config error, malformed input, missing engine, unimplemented)",
47
+ "",
48
+ "config: $ABATHUR_CONFIG > ~/.config/abathur/config.jsonc > <package>/config/abathur.jsonc,",
49
+ " with gitignored *.local.jsonc deep-merge overlay.",
50
+ "",
51
+ ].join("\n");
52
+ }
53
+ async function dispatch(argv) {
54
+ const [head, ...rest] = argv;
55
+ if (head === undefined) {
56
+ return cannotAnswer("no command given", "run 'abathur --help' for the command list");
57
+ }
58
+ if (head === "--help" || head === "-h" || head === "help") {
59
+ process.stdout.write(usage());
60
+ return EXIT_OK;
61
+ }
62
+ const command = COMMANDS.find((spec) => spec.name === head);
63
+ if (command === undefined) {
64
+ return cannotAnswer(`unknown command '${head}'`, "run 'abathur --help' for the command list");
65
+ }
66
+ let loaded;
67
+ try {
68
+ loaded = loadConfig();
69
+ }
70
+ catch (error) {
71
+ if (error instanceof ConfigError)
72
+ return cannotAnswer(error.message);
73
+ throw error;
74
+ }
75
+ return await command.run({ loaded, args: rest });
76
+ }
77
+ async function main(argv) {
78
+ try {
79
+ process.exitCode = await dispatch(argv);
80
+ }
81
+ catch (error) {
82
+ if (error instanceof ExitSignal) {
83
+ process.stderr.write(`${renderExitSignal(error)}\n`);
84
+ process.exitCode = error.code;
85
+ return;
86
+ }
87
+ // Last-resort funnel: a crash is still a "cannot-answer", never a stack dump.
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ process.stderr.write(`abathur: cannot answer: unexpected error: ${message}\n`);
90
+ if (process.env.ABATHUR_DEBUG !== undefined && error instanceof Error) {
91
+ process.stderr.write(`${error.stack ?? ""}\n`);
92
+ }
93
+ process.exitCode = EXIT_CANNOT_ANSWER;
94
+ }
95
+ }
96
+ // Node passes the symlink path (npm's global bin shim) as argv[1], so compare
97
+ // realpaths — a plain resolve() made `npm i -g` installs silently no-op (todo 15
98
+ // pack→install proof). realpathSync throws for missing files; the catch keeps
99
+ // the guard false there, which is the pre-existing behavior.
100
+ let invokedDirectly = false;
101
+ try {
102
+ invokedDirectly =
103
+ process.argv[1] !== undefined &&
104
+ realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url);
105
+ }
106
+ catch {
107
+ invokedDirectly = false;
108
+ }
109
+ if (invokedDirectly)
110
+ await main(process.argv.slice(2));
@@ -0,0 +1,79 @@
1
+ // `abathur bundle export|inspect` (todo 12) — thin router: flag parsing and the
2
+ // registry lookup live here; all bundle mechanics in src/core/bundle*.ts.
3
+ import { homedir } from "node:os";
4
+ import { resolveConfigDir } from "../config.js";
5
+ import { EXIT_OK, cannotAnswer } from "../exit.js";
6
+ import { exportBundle } from "../core/bundle-export.js";
7
+ import { inspectBundle } from "../core/bundle-inspect.js";
8
+ import { writeStdout } from "../out.js";
9
+ import { resolveUniqueEntry } from "./run.js";
10
+ const USAGE = "usage: abathur bundle export <label> (--gen <genId> [--gen <genId> ...] | --last <N>) --out <dir>\n" +
11
+ " or: abathur bundle inspect <path>";
12
+ function parseExportFlags(args) {
13
+ const [label, ...rest] = args;
14
+ if (label === undefined || label.startsWith("--"))
15
+ cannotAnswer("bundle export: <label> required", USAGE);
16
+ const genIds = [];
17
+ let last = null;
18
+ let outDir = null;
19
+ for (let i = 0; i < rest.length; i += 1) {
20
+ const flag = rest[i];
21
+ const value = rest[i + 1];
22
+ if (flag === "--gen") {
23
+ if (value === undefined || value.startsWith("--"))
24
+ cannotAnswer("bundle export: --gen needs a genId", USAGE);
25
+ genIds.push(value);
26
+ i += 1;
27
+ }
28
+ else if (flag === "--last") {
29
+ if (value === undefined || !/^\d+$/.test(value))
30
+ cannotAnswer("bundle export: --last needs a positive integer", USAGE);
31
+ last = Number.parseInt(value, 10);
32
+ i += 1;
33
+ }
34
+ else if (flag === "--out") {
35
+ if (value === undefined || value.startsWith("--"))
36
+ cannotAnswer("bundle export: --out needs a directory", USAGE);
37
+ outDir = value;
38
+ i += 1;
39
+ }
40
+ else {
41
+ cannotAnswer(`bundle export: unknown argument '${String(flag)}'`, USAGE);
42
+ }
43
+ }
44
+ if (genIds.length > 0 && last !== null)
45
+ cannotAnswer("bundle export: choose --gen or --last, not both", USAGE);
46
+ if (genIds.length === 0 && last === null)
47
+ cannotAnswer("bundle export: pick generations with --gen <id> or --last <N>", USAGE);
48
+ if (outDir === null)
49
+ cannotAnswer("bundle export: --out <dir> is required", USAGE);
50
+ return { label, genIds: genIds.length > 0 ? genIds : null, last, outDir };
51
+ }
52
+ async function runBundle(args) {
53
+ const [sub, ...rest] = args;
54
+ if (sub === "export") {
55
+ const flags = parseExportFlags(rest);
56
+ const configDir = resolveConfigDir();
57
+ const entry = resolveUniqueEntry(configDir, flags.label, "bundle");
58
+ const select = flags.genIds !== null ? { genIds: flags.genIds } : { last: flags.last ?? 0 };
59
+ const outcome = await exportBundle({ entry, configDir, select, outDir: flags.outDir ?? "", home: homedir() });
60
+ for (const line of outcome.lines)
61
+ writeStdout(line);
62
+ return EXIT_OK;
63
+ }
64
+ if (sub === "inspect") {
65
+ const [bundlePath] = rest;
66
+ if (bundlePath === undefined || rest.length !== 1)
67
+ cannotAnswer("bundle inspect: expected exactly one bundle path", USAGE);
68
+ const outcome = inspectBundle({ bundlePath, home: homedir() });
69
+ for (const line of outcome.lines)
70
+ writeStdout(line);
71
+ return outcome.exitCode;
72
+ }
73
+ cannotAnswer(`bundle: unknown subcommand '${String(sub ?? "(none)")}'`, USAGE);
74
+ }
75
+ export const bundleCommand = {
76
+ name: "bundle",
77
+ summary: "export a genome + lineage as an offline bundle (or inspect one: re-hash, self-consistency, leak re-scan)",
78
+ run: ({ args }) => runBundle(args),
79
+ };
@@ -0,0 +1,94 @@
1
+ // `abathur genome` — thin subcommand router (todo 1 seam); all logic lives in
2
+ // src/core/{spec,genome,kernel,glob}.ts. `rm` (todo 10) is registry-only: a
3
+ // genome with ledger history is refused outright — lineage is archive-not-delete.
4
+ import { existsSync, readFileSync, rmSync } from "node:fs";
5
+ import { resolveConfigDir } from "../config.js";
6
+ import { readRegistry, registerGenome, requireGenomesByLabel } from "../core/genome.js";
7
+ import { ledgerPath } from "../core/ledger.js";
8
+ import { EXIT_OK, blocked, cannotAnswer } from "../exit.js";
9
+ import { writeStdout } from "../out.js";
10
+ import { resolveUniqueEntry } from "./run.js";
11
+ function requireLabelArg(args, command) {
12
+ const label = args[0];
13
+ if (label === undefined || label.length === 0) {
14
+ cannotAnswer(`genome ${command}: missing <label> argument`);
15
+ }
16
+ return label;
17
+ }
18
+ function genomeAdd(configDir, args) {
19
+ const specPath = args[0];
20
+ if (specPath === undefined) {
21
+ cannotAnswer("genome add: missing <spec.jsonc> argument", "usage: abathur genome add <file.jsonc>");
22
+ }
23
+ const result = registerGenome(configDir, specPath);
24
+ writeStdout(result.kind === "registered"
25
+ ? `registered genome '${result.label}' (${result.fingerprint})`
26
+ : `genome '${result.label}' (${result.fingerprint}) already registered — kernel manifest byte-identical, nothing rewritten`);
27
+ return EXIT_OK;
28
+ }
29
+ function genomeList(configDir) {
30
+ const scan = readRegistry(configDir);
31
+ for (const warning of scan.warnings)
32
+ writeStdout(`warning: ${warning}`);
33
+ if (scan.entries.length === 0) {
34
+ writeStdout("no genomes registered");
35
+ return EXIT_OK;
36
+ }
37
+ for (const entry of scan.entries)
38
+ writeStdout(`${entry.fingerprint} ${entry.label}`);
39
+ return EXIT_OK;
40
+ }
41
+ function genomeShow(configDir, label) {
42
+ const scan = requireGenomesByLabel(configDir, label);
43
+ for (const warning of scan.warnings)
44
+ writeStdout(`warning: ${warning}`);
45
+ for (const entry of scan.entries) {
46
+ writeStdout(`# ${entry.label} (${entry.fingerprint}) — ${entry.registryFile}`);
47
+ writeStdout(entry.storedText.trimEnd());
48
+ }
49
+ return EXIT_OK;
50
+ }
51
+ function genomeSeals(configDir, label) {
52
+ const scan = requireGenomesByLabel(configDir, label);
53
+ for (const warning of scan.warnings)
54
+ writeStdout(`warning: ${warning}`);
55
+ for (const entry of scan.entries) {
56
+ writeStdout(`# effective seal globs for '${entry.label}' (${entry.fingerprint}) under ${entry.spec.repoPath}`);
57
+ for (const glob of entry.spec.kernel.immutableGlobs)
58
+ writeStdout(glob);
59
+ }
60
+ return EXIT_OK;
61
+ }
62
+ function genomeRm(configDir, label) {
63
+ const entry = resolveUniqueEntry(configDir, label, "genome rm");
64
+ const file = ledgerPath(entry.spec.repoPath);
65
+ if (existsSync(file) && readFileSync(file, "utf8").trim().length > 0) {
66
+ blocked(`genome rm refused: '${entry.label}' (${entry.fingerprint}) has ledger history — archive instead`, `lineage lives in ${file}; unregistering a lived genome would orphan its evidence. Keep it registered or move the repo aside.`);
67
+ }
68
+ rmSync(entry.registryFile);
69
+ writeStdout(`unregistered genome '${entry.label}' (${entry.fingerprint}) — repo and kernel manifest left in place (cull ≠ delete)`);
70
+ return EXIT_OK;
71
+ }
72
+ function runGenome(args) {
73
+ const configDir = resolveConfigDir();
74
+ const [sub, ...rest] = args;
75
+ switch (sub) {
76
+ case "add":
77
+ return genomeAdd(configDir, rest);
78
+ case "list":
79
+ return genomeList(configDir);
80
+ case "show":
81
+ return genomeShow(configDir, requireLabelArg(rest, "show"));
82
+ case "seals":
83
+ return genomeSeals(configDir, requireLabelArg(rest, "seals"));
84
+ case "rm":
85
+ return genomeRm(configDir, requireLabelArg(rest, "rm"));
86
+ default:
87
+ return cannotAnswer(`genome: unknown subcommand '${sub ?? "<none>"}'`, "usage: abathur genome add <spec.jsonc> | list | show <label> | seals <label> | rm <label>");
88
+ }
89
+ }
90
+ export const genomeCommand = {
91
+ name: "genome",
92
+ summary: "add/list/show/seals/rm genome specs and their kernel seals",
93
+ run: ({ args }) => runGenome(args),
94
+ };