@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,124 @@
1
+ // Historian genome instance (plan todo 14) — the first REAL genome.
2
+ //
3
+ // This file is a TEMPLATE: every machine-specific value is a ${ENV}-style
4
+ // placeholder. Repo-tracked config must contain ZERO machine literals
5
+ // (todo-15 grep gate). Materialize it for a live run by substituting the
6
+ // placeholders from your environment into a PRIVATE copy (never committed),
7
+ // then `abathur genome add <private-copy>`:
8
+ //
9
+ // ${ABATHUR_HISTORIAN_REPO} absolute path of the historian bench repo
10
+ // (contains scenarios/, rubric.md, seed_sandbox.sh)
11
+ // ${ABATHUR_REPO} absolute path of THIS Abathur checkout
12
+ // (hosts graders/historian/*)
13
+ // ${ABATHUR_WIKI_BASE} canonical wiki root, e.g. http://localhost:3000
14
+ // (the /graphql endpoint is derived from it)
15
+ // ${ABATHUR_WIKI_OPS} absolute path of the wiki-ops CLI script
16
+ //
17
+ // Live-run env the wrapper scripts require (exported by the operator shell,
18
+ // never stored in config): ABATHUR_WIKI_KEY_FILE (wikijs admin token file;
19
+ // defaults to $HOME/.wikijs-api-key inside the sandbox home).
20
+ //
21
+ // Bench protocol (src/bench/fixture.ts): per unit the adapter runs
22
+ // reset -> seed -> run -> score in <configDir>/bench-sandboxes/<invId>/…
23
+ // with HOME = the mirrored sandbox home; the scenario agent is spawned by
24
+ // runCommand (opencode headless), the transcript is recorded at
25
+ // $ABATHUR_TRANSCRIPT, and graderCommand emits the ONE-JSON-line score.
26
+ //
27
+ // Scoring (plan line 185, implemented in graders/historian/grader-core.mjs):
28
+ // full scenarios Σ(w·d)/12 with pass = Σ ≥ 10 ∧ G = 1 (G hard gate);
29
+ // scenario-05 renormalizes over the applicable set: (G+H+judgment)/3.
30
+ // judgeCommand is deliberately UNWIRED: the fixture adapter never executes
31
+ // it, so subjective dims A-D fall back to script-first mechanical proxies
32
+ // (see the header comments in graders/historian/grader-core.mjs + learnings
33
+ // §Todo 14).
34
+ {
35
+ "label": "historian",
36
+ "repoPath": "${ABATHUR_HISTORIAN_REPO}",
37
+ "bench": {
38
+ "type": "opencode-fixture-scenarios",
39
+ "units": [
40
+ { "id": "scenario-01", "path": "scenarios/01-new-finding.md", "split": "train" },
41
+ { "id": "scenario-02", "path": "scenarios/02-incident.md", "split": "train" },
42
+ { "id": "scenario-03", "path": "scenarios/03-overlap-integration.md", "split": "train" },
43
+ { "id": "scenario-04", "path": "scenarios/04-organize-mess.md", "split": "val" },
44
+ { "id": "scenario-05", "path": "scenarios/05-worthiness-gate.md", "split": "train" },
45
+ { "id": "scenario-06", "path": "scenarios/06-incident-g1-format.md", "split": "train" },
46
+ { "id": "scenario-07", "path": "scenarios/07-zh-url-reporting.md", "split": "train" },
47
+ { "id": "scenario-08", "path": "scenarios/08-current-state-g5.md", "split": "train" },
48
+ { "id": "scenario-09", "path": "scenarios/09-timeline-week-groups.md", "split": "val" }
49
+ ],
50
+ "seedCommand": "bash ${ABATHUR_REPO}/graders/historian/seed-wrapped.sh ${ABATHUR_HISTORIAN_REPO}/seed_sandbox.sh",
51
+ "resetCommand": "bash ${ABATHUR_REPO}/graders/historian/reset-sandbox.sh",
52
+ "runCommand": "bash ${ABATHUR_REPO}/graders/historian/run-scenario.sh {unit.id} ${ABATHUR_HISTORIAN_REPO}/{unit.path}",
53
+ "graderCommand": "node ${ABATHUR_REPO}/graders/historian/grader.mjs {unit.id} ${ABATHUR_HISTORIAN_REPO}/{unit.path} ${ABATHUR_WIKI_BASE}",
54
+ "agentModel": "bailian-token-plan/qwen3.8-flash",
55
+ // Real agent runs, not unit tests: 1200s caps one stuck scenario without
56
+ // poisoning the generation (timeout => unscored/inconclusive, excluded by
57
+ // the stats layer). seed+reset hooks share a FIXED 60s adapter budget
58
+ // (fixture.ts HOOK_TIMEOUT_S) — measured full seed is 15.4s and reset
59
+ // (wiki-ops deletes + cache-refresh) ~15s, so both fit with margin.
60
+ "timeoutS": 1200,
61
+ "stats": {
62
+ "halfWidth": 0.15,
63
+ "minEffect": 0.1,
64
+ "nReps": { "initial": 1, "max": 3 }
65
+ }
66
+ },
67
+ // maxCandidates=3: the live campaign spends two generations on the
68
+ // back-to-back reset-equivalence proof (plan 182) and one on the bundle
69
+ // export AC (the first candidate trees sealed a too-short maskLiterals set,
70
+ // so their export correctly refuses — masking gate proven by refusal); a
71
+ // curator can lower this back to 1/2 once the campaign is archived. Wall/
72
+ // token caps are the hard stop for the live episode: 10 units x 2 benches x
73
+ // <=1200s worst case = 6.7h, so 8h bounds the run with slack (budget trip
74
+ // => inconclusive).
75
+ "budget": {
76
+ "maxCandidates": 3,
77
+ "maxModelCalls": 96,
78
+ "maxTokens": 80000000,
79
+ "maxWallS": 28800
80
+ },
81
+ // The bench surface is sealed: the mutator can never move scenario content,
82
+ // the rubric, the seeder, or the grading baseline (P5 / kernel audit).
83
+ "kernel": {
84
+ "immutableGlobs": [
85
+ "scenarios/**",
86
+ "rubric.md",
87
+ "seed_sandbox.sh",
88
+ "baseline/**",
89
+ "README.md"
90
+ ]
91
+ },
92
+ "requires": [
93
+ { "cmd": "python3", "args": ["${ABATHUR_WIKI_OPS}", "list"], "probeExit": 0 },
94
+ { "cmd": "curl", "args": ["-sf", "${ABATHUR_WIKI_BASE}"], "probeExit": 0 }
95
+ ],
96
+ "opencodeBinVersion": { "minVersion": "1.18.0" },
97
+ // Evidence masking for `bundle export` (todo 12): HOME + repoPath are always
98
+ // masked; the wiki origin plus the generic Unix roots below cover what live
99
+ // agents actually echo into transcripts (sandbox /tmp/... paths, /opt/wiki-ops
100
+ // CLI output, /mnt//var//etc host facts gathered during a scenario). The
101
+ // export gate fail-closes on ANY surviving /<machine-root>/, so without these
102
+ // declarations a real-run bundle refuses to write (observed live, task-14).
103
+ // The operator home root is intentionally absent here: HOME is masked
104
+ // automatically, and repo-tracked config must stay free of home-path literals
105
+ // (todo-15 grep gate).
106
+ "bundle": {
107
+ "maskLiterals": [
108
+ "${ABATHUR_WIKI_BASE}",
109
+ "/opt/",
110
+ "/tmp/",
111
+ "/var/",
112
+ "/etc/",
113
+ "/usr/",
114
+ "/mnt/",
115
+ "/srv/",
116
+ "/proc/",
117
+ "/sys/",
118
+ "/media/",
119
+ "/private/",
120
+ "/Users/",
121
+ "/root/"
122
+ ]
123
+ }
124
+ }
@@ -0,0 +1,201 @@
1
+ // Bench adapter contract (plan todo 5). The iface (incl. reset) is DEFINED HERE and
2
+ // reused verbatim by the opencode-fixture adapter (todo 6) and the evolution loop
3
+ // (todo 9). Shared plumbing also lives here so both adapters get identical argv
4
+ // discipline — mirrors src/util/git.ts: argv arrays only, no shell, process-group
5
+ // SIGKILL on timeout — and identical status semantics:
6
+ // ok run completed under bench control (exitCode may still be != 0:
7
+ // a failing UNIT is a measurement result, not an infra failure)
8
+ // timeout killed after timeoutS via kill(-pid) — recorded, never rethrown
9
+ // infra_failed the bench itself could not run (binary missing, spawn refused);
10
+ // excluded from score distributions (hr discipline), not zeroed.
11
+ import { spawn } from "node:child_process";
12
+ import { cannotAnswer } from "../exit.js";
13
+ export function unitVars(unit, sandboxDir) {
14
+ return { "unit.path": unit.path, "unit.id": unit.id, sandbox: sandboxDir, workdir: sandboxDir };
15
+ }
16
+ export function sandboxVars(sandboxDir) {
17
+ return { sandbox: sandboxDir, workdir: sandboxDir };
18
+ }
19
+ const PLACEHOLDER = /\{([A-Za-z0-9_.]+)\}/g;
20
+ /**
21
+ * Render a bench command template into argv: quote-aware tokenization (single and
22
+ * double quotes group, are stripped), then {placeholder} substitution against vars.
23
+ * Unknown placeholders and unbalanced quotes are config errors -> exit 2 (fail-closed,
24
+ * never a silently half-substituted command).
25
+ */
26
+ export function renderCommand(template, vars) {
27
+ const argv = tokenize(template);
28
+ if (argv.length === 0)
29
+ cannotAnswer(`bench command template is empty: '${template}'`);
30
+ return argv.map((token) => token.replace(PLACEHOLDER, (whole, key) => {
31
+ const value = vars[key];
32
+ if (value === undefined) {
33
+ cannotAnswer(`bench command '${template}': unknown placeholder '${whole}'`);
34
+ }
35
+ return value;
36
+ }));
37
+ }
38
+ function tokenize(source) {
39
+ const out = [];
40
+ let current = "";
41
+ let started = false;
42
+ let quote = null;
43
+ for (const ch of source) {
44
+ if (quote !== null) {
45
+ if (ch === quote)
46
+ quote = null;
47
+ else
48
+ current += ch;
49
+ continue;
50
+ }
51
+ if (ch === '"' || ch === "'") {
52
+ quote = ch;
53
+ started = true;
54
+ continue;
55
+ }
56
+ if (ch === " " || ch === "\t") {
57
+ if (started) {
58
+ out.push(current);
59
+ current = "";
60
+ started = false;
61
+ }
62
+ continue;
63
+ }
64
+ current += ch;
65
+ started = true;
66
+ }
67
+ if (quote !== null)
68
+ cannotAnswer(`unbalanced quote in bench command: '${source}'`);
69
+ if (started)
70
+ out.push(current);
71
+ return out;
72
+ }
73
+ const STREAM_CAP_BYTES = 1024 * 1024;
74
+ /** SIGKILL the whole process group; ESRCH means everyone already exited. */
75
+ export function killGroup(pid) {
76
+ if (pid === undefined)
77
+ return;
78
+ try {
79
+ process.kill(-pid, "SIGKILL");
80
+ }
81
+ catch {
82
+ // group already gone — the close handler still resolves the outcome.
83
+ }
84
+ }
85
+ /**
86
+ * Run argv (no shell) in a detached process group and hard-kill the GROUP after
87
+ * timeoutS, so children of the unit (sh -c sleep, opencode spawns) cannot outlive
88
+ * the bench. Resolves exactly once; never rejects — timeouts and spawn failures
89
+ * are recorded outcomes, matching the RunStatus trichotomy above.
90
+ */
91
+ export function runChild(opts) {
92
+ if (!Number.isFinite(opts.timeoutS) || opts.timeoutS <= 0) {
93
+ throw new TypeError(`runChild: timeoutS must be a positive number, got ${String(opts.timeoutS)}`);
94
+ }
95
+ const [bin, ...rest] = opts.argv;
96
+ if (bin === undefined || bin.length === 0) {
97
+ throw new TypeError("runChild: argv must start with a binary path/name");
98
+ }
99
+ return new Promise((resolve) => {
100
+ const child = spawn(bin, rest, {
101
+ cwd: opts.cwd,
102
+ detached: true,
103
+ stdio: ["ignore", "pipe", "pipe"],
104
+ env: opts.env === undefined
105
+ ? { ...process.env, LC_ALL: "C" }
106
+ : { ...process.env, ...opts.env, LC_ALL: "C" },
107
+ });
108
+ let stdout = "";
109
+ let stderr = "";
110
+ let timedOut = false;
111
+ let spawnError = null;
112
+ let notifyExit = () => { };
113
+ const exited = new Promise((resolve) => {
114
+ notifyExit = resolve;
115
+ });
116
+ if (opts.onChild !== undefined && child.pid !== undefined) {
117
+ opts.onChild({ pid: child.pid, exited }); // record BEFORE anything can wait on it
118
+ }
119
+ const cap = (buffer, chunk) => buffer.length >= STREAM_CAP_BYTES ? buffer : buffer + String(chunk);
120
+ child.stdout?.on("data", (chunk) => {
121
+ stdout = cap(stdout, chunk);
122
+ });
123
+ child.stderr?.on("data", (chunk) => {
124
+ stderr = cap(stderr, chunk);
125
+ });
126
+ const timer = setTimeout(() => {
127
+ timedOut = true;
128
+ killGroup(child.pid);
129
+ }, Math.round(opts.timeoutS * 1000));
130
+ // Node emits 'close' after a failed spawn too (code null), so one resolve site.
131
+ child.on("error", (cause) => {
132
+ spawnError = cause.message;
133
+ });
134
+ child.on("close", (code) => {
135
+ clearTimeout(timer);
136
+ notifyExit();
137
+ const kind = spawnError !== null ? "spawn_failed" : timedOut ? "timeout" : "exited";
138
+ const reason = spawnError !== null
139
+ ? `spawn failed: ${spawnError}`
140
+ : timedOut
141
+ ? `killed after ${String(opts.timeoutS)}s: process group SIGKILL`
142
+ : `exit code ${String(code)}`;
143
+ resolve({ kind, exitCode: kind === "exited" ? code : null, stdout, stderr, reason });
144
+ });
145
+ });
146
+ }
147
+ // ------------------------------------------------- shared adapter plumbing
148
+ /** Metrics for a run that produced no measurement (timeout / infra_failed). */
149
+ export const ZERO_METRICS = { tokensEst: 0, turns: 0 };
150
+ export function childStatus(kind) {
151
+ switch (kind) {
152
+ case "exited":
153
+ return "ok";
154
+ case "timeout":
155
+ return "timeout";
156
+ case "spawn_failed":
157
+ return "infra_failed";
158
+ }
159
+ }
160
+ export function inconclusive(unitId, reason) {
161
+ return { kind: "inconclusive", unitId, reason };
162
+ }
163
+ export function firstLine(text) {
164
+ return (text.split("\n", 1)[0] ?? "").trim();
165
+ }
166
+ /** Grader contract (toy + fixture): the LAST stdout line parses to {unit, score 0..1, pass, metrics}. */
167
+ export function parseGraderLine(stdoutText) {
168
+ const last = stdoutText
169
+ .split("\n")
170
+ .map((line) => line.trim())
171
+ .filter((line) => line.length > 0)
172
+ .at(-1);
173
+ if (last === undefined)
174
+ return null;
175
+ let doc;
176
+ try {
177
+ doc = JSON.parse(last);
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ if (!isRecord(doc) || typeof doc.unit !== "string")
183
+ return null;
184
+ if (!isFiniteNumber(doc.score) || doc.score < 0 || doc.score > 1)
185
+ return null;
186
+ if (typeof doc.pass !== "boolean")
187
+ return null;
188
+ const metrics = doc.metrics;
189
+ if (!isRecord(metrics) || !isCount(metrics.tokensEst) || !isCount(metrics.turns))
190
+ return null;
191
+ return { score: doc.score, pass: doc.pass, metrics: { tokensEst: metrics.tokensEst, turns: metrics.turns } };
192
+ }
193
+ function isRecord(value) {
194
+ return typeof value === "object" && value !== null && !Array.isArray(value);
195
+ }
196
+ function isFiniteNumber(value) {
197
+ return typeof value === "number" && Number.isFinite(value);
198
+ }
199
+ function isCount(value) {
200
+ return isFiniteNumber(value) && value >= 0;
201
+ }
@@ -0,0 +1,92 @@
1
+ // Startup engine probes for the fixture-scenarios adapter (todo 6, plan 117-124):
2
+ // `<opencodeBin> --version` parsed as semver and enforced against
3
+ // spec.opencodeBinVersion.minVersion, plus every spec.requires[] binary probed by
4
+ // argv spawn (PATH lookup, never a shell). Any failure is exit 2 BEFORE a unit
5
+ // runs; every observed version string lands in the returned provenance list.
6
+ import { cannotAnswer } from "../exit.js";
7
+ import { loadConfig } from "../config.js";
8
+ import { firstLine, runChild } from "./adapter.js";
9
+ import { compareSemver, parseSemver, semverFromText } from "./fixture-support.js";
10
+ const PROBE_TIMEOUT_S = 30;
11
+ export function resolveOpencodeBin(opts) {
12
+ if (opts.opencodeBin !== undefined)
13
+ return opts.opencodeBin;
14
+ return loadConfig(opts.env ?? process.env).config.opencodeBin ?? "opencode";
15
+ }
16
+ export async function probeEngines(spec, bin, repoRoot, opts = {}) {
17
+ const observed = await probeOpencodeVersion(bin, repoRoot, opts);
18
+ const min = spec.opencodeBinVersion?.minVersion;
19
+ if (min !== undefined) {
20
+ const minVersion = parseSemver(min);
21
+ if (minVersion === null) {
22
+ cannotAnswer(`fixture: opencodeBinVersion.minVersion '${min}' is not a semver`);
23
+ }
24
+ if (compareSemver(observed.version, minVersion) < 0) {
25
+ cannotAnswer(`fixture: opencode ${observed.raw} is older than required minVersion ${min}`, "upgrade the opencode binary (config opencodeBin)");
26
+ }
27
+ }
28
+ const versions = [
29
+ { bin: "node", version: process.version },
30
+ { bin, version: observed.raw },
31
+ ];
32
+ for (const req of spec.requires ?? []) {
33
+ versions.push(await probeRequires(req, repoRoot, opts));
34
+ }
35
+ return versions;
36
+ }
37
+ async function probeOpencodeVersion(bin, cwd, opts) {
38
+ const probe = await runChild({
39
+ argv: [bin, "--version"],
40
+ cwd,
41
+ timeoutS: PROBE_TIMEOUT_S,
42
+ ...(opts.onChild === undefined ? {} : { onChild: opts.onChild }),
43
+ });
44
+ if (probe.kind !== "exited" || probe.exitCode !== 0) {
45
+ cannotAnswer(`fixture: '${bin} --version' failed (${probe.reason}) — no unit will run`, "set opencodeBin in config or install a supported opencode");
46
+ }
47
+ const observed = semverFromText(probe.stdout);
48
+ if (observed === null) {
49
+ cannotAnswer(`fixture: cannot parse a semver from '${bin} --version' output: ${firstLine(probe.stdout) || "no output"}`);
50
+ }
51
+ return observed;
52
+ }
53
+ /**
54
+ * Dry-run-only requires probe (task 14): verifies every spec.requires[] argv
55
+ * WITHOUT the opencode `--version` engine probe, so `run --dry-run` can prove
56
+ * machine prerequisites without spawning the engine or the mutator. Failure
57
+ * messages name the FULL argv (not just the binary) so a bad arg path is obvious.
58
+ * Returns the number of probes that passed.
59
+ */
60
+ export async function probeRequiresOnly(spec, repoRoot) {
61
+ const reqs = spec.requires ?? [];
62
+ for (const req of reqs) {
63
+ const argv = [req.cmd, ...(req.args ?? ["--version"])];
64
+ const shown = argv.join(" ");
65
+ const outcome = await runChild({ argv, cwd: repoRoot, timeoutS: PROBE_TIMEOUT_S });
66
+ if (outcome.kind === "spawn_failed") {
67
+ cannotAnswer(`dry-run: prerequisite '${shown}' cannot spawn: ${outcome.reason}`, "install it or fix spec.requires before the real run");
68
+ }
69
+ if (outcome.kind !== "exited" || outcome.exitCode !== req.probeExit) {
70
+ cannotAnswer(`dry-run: prerequisite '${shown}' probe expected exit ${String(req.probeExit)}, got ${outcome.reason}`, "fix the prerequisite or spec.requires before the real run");
71
+ }
72
+ }
73
+ return reqs.length;
74
+ }
75
+ async function probeRequires(req, cwd, opts) {
76
+ const outcome = await runChild({
77
+ argv: [req.cmd, ...(req.args ?? ["--version"])],
78
+ cwd,
79
+ timeoutS: PROBE_TIMEOUT_S,
80
+ ...(opts.onChild === undefined ? {} : { onChild: opts.onChild }),
81
+ });
82
+ if (outcome.kind === "spawn_failed") {
83
+ cannotAnswer(`fixture: required prerequisite '${req.cmd}' is missing: ${outcome.reason}`, "install it or fix spec.requires before benching");
84
+ }
85
+ if (outcome.kind !== "exited" || outcome.exitCode !== req.probeExit) {
86
+ cannotAnswer(`fixture: prerequisite '${req.cmd}' probe expected exit ${String(req.probeExit)}, got ${outcome.reason}`);
87
+ }
88
+ return {
89
+ bin: req.cmd,
90
+ version: firstLine(outcome.stdout) || firstLine(outcome.stderr) || "no version output",
91
+ };
92
+ }
@@ -0,0 +1,173 @@
1
+ // Pure helpers behind the fixture-scenarios adapter (todo 6): semver probing,
2
+ // the sandbox HOME mirror (copy-only, never symlink), the val-hiding scenario
3
+ // manifest, and run-metadata parsing. No process state, no zod — everything is
4
+ // deterministic given its inputs so reset→seed digests replay exactly.
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { cannotAnswer } from "../exit.js";
8
+ import { canonicalJson } from "../core/ids.js";
9
+ import { parseJsonc } from "../jsonc.js";
10
+ const SEMVER = /v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/;
11
+ export function parseSemver(text) {
12
+ const m = SEMVER.exec(text);
13
+ if (m === null)
14
+ return null;
15
+ const pre = m[4];
16
+ return {
17
+ major: Number(m[1]),
18
+ minor: Number(m[2]),
19
+ patch: Number(m[3]),
20
+ pre: pre === undefined || pre.length === 0 ? null : pre,
21
+ };
22
+ }
23
+ /** First line carrying a semver, as {raw, version}; raw is the whole trimmed line. */
24
+ export function semverFromText(text) {
25
+ for (const line of text.split("\n")) {
26
+ const trimmed = line.trim();
27
+ if (trimmed.length === 0)
28
+ continue;
29
+ const version = parseSemver(trimmed);
30
+ if (version !== null)
31
+ return { raw: trimmed, version };
32
+ }
33
+ return null;
34
+ }
35
+ export function compareSemver(a, b) {
36
+ for (const key of ["major", "minor", "patch"]) {
37
+ if (a[key] !== b[key])
38
+ return a[key] < b[key] ? -1 : 1;
39
+ }
40
+ if (a.pre === b.pre)
41
+ return 0;
42
+ if (a.pre === null)
43
+ return 1;
44
+ if (b.pre === null)
45
+ return -1;
46
+ return a.pre < b.pre ? -1 : 1;
47
+ }
48
+ // ------------------------------------------------------------- sandbox HOME
49
+ export const SANDBOX_HOME_DIRNAME = ".sandbox-home";
50
+ export const BENCH_DIRNAME = ".bench";
51
+ /** Only this .opencode subset is mirrored from the real HOME (plan line 120). */
52
+ const OPENCODE_SUBDIRS = ["plugin", "skills", "node_modules"];
53
+ export function sandboxHomeDir(sandboxDir) {
54
+ return path.join(sandboxDir, SANDBOX_HOME_DIRNAME);
55
+ }
56
+ export function benchDir(sandboxDir) {
57
+ return path.join(sandboxDir, BENCH_DIRNAME);
58
+ }
59
+ export function transcriptPathFor(sandboxDir, unitId) {
60
+ return path.join(benchDir(sandboxDir), "transcripts", `${unitId}.jsonl`);
61
+ }
62
+ function copyDirIfExists(src, dst) {
63
+ if (!statSync(src, { throwIfNoEntry: false })?.isDirectory())
64
+ return;
65
+ try {
66
+ mkdirSync(path.dirname(dst), { recursive: true });
67
+ cpSync(src, dst, { recursive: true, dereference: true });
68
+ }
69
+ catch (cause) {
70
+ cannotAnswer(`fixture: cannot mirror sandbox home entry '${src}': ${cause instanceof Error ? cause.message : String(cause)}`);
71
+ }
72
+ }
73
+ function readConfigBase(file) {
74
+ let text;
75
+ try {
76
+ text = readFileSync(file, "utf8");
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ try {
82
+ const doc = file.endsWith(".jsonc") ? parseJsonc(text) : JSON.parse(text);
83
+ if (typeof doc === "object" && doc !== null && !Array.isArray(doc)) {
84
+ return doc;
85
+ }
86
+ }
87
+ catch {
88
+ // the copied config is the user's own malformed file: reset it to the mutation
89
+ }
90
+ return {};
91
+ }
92
+ /**
93
+ * Rebuild <sandbox>/.sandbox-home: .opencode/{plugin,skills,node_modules} copied from
94
+ * the real HOME, plus a COPY of the user's opencode config mutated with the scenario's
95
+ * agent model (never a symlink; real HOME is read-only for this adapter).
96
+ */
97
+ export function mirrorSandboxHome(home, sandboxDir, agentModel) {
98
+ const sbHome = sandboxHomeDir(sandboxDir);
99
+ rmSync(sbHome, { recursive: true, force: true });
100
+ mkdirSync(sbHome, { recursive: true });
101
+ const srcOpen = path.join(home, ".opencode");
102
+ for (const sub of OPENCODE_SUBDIRS) {
103
+ copyDirIfExists(path.join(srcOpen, sub), path.join(sbHome, ".opencode", sub));
104
+ }
105
+ const srcCfg = path.join(home, ".config", "opencode");
106
+ const dstCfg = path.join(sbHome, ".config", "opencode");
107
+ copyDirIfExists(srcCfg, dstCfg);
108
+ mkdirSync(dstCfg, { recursive: true });
109
+ const jsonFile = path.join(dstCfg, "opencode.json");
110
+ const jsoncFile = path.join(dstCfg, "opencode.jsonc");
111
+ const base = existsSync(jsonFile)
112
+ ? readConfigBase(jsonFile)
113
+ : existsSync(jsoncFile)
114
+ ? readConfigBase(jsoncFile)
115
+ : {};
116
+ base["model"] = agentModel;
117
+ writeFileSync(jsonFile, `${canonicalJson(base)}\n`, "utf8");
118
+ }
119
+ /**
120
+ * Mutator-facing view: val scenarios appear as opaque aliases only — their id and
121
+ * path never enter the output unless the operator asked with includeVal.
122
+ */
123
+ export function buildManifest(units, includeVal) {
124
+ return units.map((unit, index) => {
125
+ const alias = `scenario-${String(index + 1).padStart(2, "0")}`;
126
+ if (unit.split === "val" && !includeVal)
127
+ return { alias, split: "val" };
128
+ return { alias, split: unit.split, id: unit.id, path: unit.path };
129
+ });
130
+ }
131
+ export function manifestPathFor(sandboxDir) {
132
+ return path.join(benchDir(sandboxDir), "manifest.json");
133
+ }
134
+ export function writeManifest(sandboxDir, entries) {
135
+ mkdirSync(benchDir(sandboxDir), { recursive: true });
136
+ writeFileSync(manifestPathFor(sandboxDir), `${canonicalJson({ benchType: "opencode-fixture-scenarios", scenarios: entries })}\n`, "utf8");
137
+ }
138
+ /**
139
+ * Train/val discrimination is by SPLIT FIELD ONLY. allowVal = the caller's
140
+ * benching permission: operator includeVal OR the loop's internal
141
+ * loopValAuthority (see FixtureAdapterOptions; F1-fix2).
142
+ */
143
+ export function gateVal(unit, allowVal) {
144
+ if (unit.split === "val" && !allowVal) {
145
+ cannotAnswer(`fixture: unit '${unit.id}' is a val-split scenario and requires the operator flag --include-val`, "the evolution loop benches val replicates under its internal loopValAuthority; this refusal is for a direct adapter consumer that never opted in — pass includeVal (CLI: 'abathur run --include-val', which additionally EXPOSES val ids/paths in the manifest)");
146
+ }
147
+ }
148
+ // ---------------------------------------------------------------- run meta
149
+ /**
150
+ * Run-metadata contract: the fake/real runner may append a final JSON line
151
+ * {tokensEst?, turns?}; garbage or absence degrades to zeroed metrics, never a crash.
152
+ */
153
+ export function parseRunMeta(stdoutText) {
154
+ const last = stdoutText
155
+ .split("\n")
156
+ .map((line) => line.trim())
157
+ .filter((line) => line.length > 0)
158
+ .at(-1);
159
+ if (last === undefined)
160
+ return { tokensEst: 0, turns: 0 };
161
+ let doc;
162
+ try {
163
+ doc = JSON.parse(last);
164
+ }
165
+ catch {
166
+ return { tokensEst: 0, turns: 0 };
167
+ }
168
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc))
169
+ return { tokensEst: 0, turns: 0 };
170
+ const record = doc;
171
+ const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
172
+ return { tokensEst: count(record["tokensEst"]), turns: count(record["turns"]) };
173
+ }