@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,184 @@
1
+ // Selection statistics (plan todo 7): nomination gates, Bonferroni correction,
2
+ // Pareto ranking, budget-inconclusive verdicts. PURE functions only — budget
3
+ // counters arrive as plain inputs, the seed is injectable, every threshold
4
+ // comes from GenomeSpec.bench.stats (spec.ts), no I/O, no Date.now().
5
+ //
6
+ // SEMANTICS (plan-literal):
7
+ // - per-unit mean over nReps; the runner clamps nReps via clampReps (fixed
8
+ // count, no sequential escalation in v1). HARD RULE: n<2 on ANY candidate
9
+ // unit ⇒ variance undefined ⇒ verdict indeterminate, NEVER nominated; a
10
+ // val unit missing from the candidate entirely is the n=0 case of the same
11
+ // rule.
12
+ // - nomination gate (all three must hold, per pair): (1) CI half-width of the
13
+ // candidate's per-unit sample mean (95% Student-t, two-sided) ≤
14
+ // stats.halfWidth on every val unit of the incumbent's suite; (2) gain
15
+ // (aggregate train mean over the unit means, val-aggregate fallback when
16
+ // the bench has no train units) ≥ stats.minEffect; (3) no val regression:
17
+ // per-unit val mean ≥ incumbent mean − that unit's CI half-width (ties
18
+ // allowed ⇒ passes ⇔ delta ≥ −ciHalfWidth, evaluated with the SAME
19
+ // corrected alpha).
20
+ // - Bonferroni: family alpha 0.05 constant across finalist pairs; each pair's
21
+ // CI uses the corrected alpha FAMILY_ALPHA / nPairs (never configurable).
22
+ // - budget exhaustion (any cap at/above, counters vs caps) ⇒ inconclusive
23
+ // (exit 2 via VERDICT_EXIT), excluded from the comparison series, never an
24
+ // implicit pass. The gate itself is RNG-free; the only tie-break in this
25
+ // module is Pareto rank order, which takes an injectable seed.
26
+ // - Pareto over (trainScore, tokens, wallS); budget-truncated generations
27
+ // (complete:false) never enter the set.
28
+ //
29
+ // The inverse Student-t lives in stats-math.ts and the Pareto ranking in
30
+ // stats-pareto.ts; both are re-exported here so the public surface of this
31
+ // module — the one todos 9/10/12 consume — is unchanged. Accuracy: |t − table|
32
+ // < 1e-3 for the t-distribution rows checked in stats.test.ts (df=1..30, α=0.05).
33
+ import { EXIT_BLOCKED, EXIT_CANNOT_ANSWER, EXIT_OK } from "../exit.js";
34
+ /** Per-unit replicate scores (train and val units alike). */
35
+ import { studentTQuantile } from "./stats-math.js";
36
+ import { seededRandom, paretoFrontier } from "./stats-pareto.js";
37
+ export { studentTQuantile, seededRandom, paretoFrontier };
38
+ /** Family-wise alpha across finalist pairs (bonferroniAlpha divides it). */
39
+ export const FAMILY_ALPHA = 0.05;
40
+ /** Verdict → process exit code. inconclusive = 2 (cannot answer), never 0. */
41
+ export const VERDICT_EXIT = {
42
+ nominated: EXIT_OK, // 0 — gate recommends acceptance
43
+ culled: EXIT_BLOCKED, // 1 — decision: rejected by the gates
44
+ indeterminate: EXIT_BLOCKED, // 1 — decision: insufficient evidence to nominate
45
+ inconclusive: EXIT_CANNOT_ANSWER, // 2 — budget truncated the run; cannot answer
46
+ };
47
+ export function needsExit(verdict) {
48
+ return VERDICT_EXIT[verdict];
49
+ }
50
+ export function bonferroniAlpha(nPairs) {
51
+ if (!Number.isInteger(nPairs) || nPairs < 1) {
52
+ throw new RangeError(`bonferroniAlpha: nPairs=${String(nPairs)} must be an integer >= 1`);
53
+ }
54
+ return FAMILY_ALPHA / nPairs;
55
+ }
56
+ // --- Student-t quantile (no deps: bisection on the regularized incomplete beta) ---
57
+ /** Per-unit summary: mean, unbiased variance, 95% two-sided CI half-width. */
58
+ export function summarizeUnit(scores, alpha = FAMILY_ALPHA) {
59
+ const n = scores.length;
60
+ if (n < 2) {
61
+ return { n, mean: n === 1 ? scores[0] : Number.NaN, sampleVariance: Number.NaN, sem: Number.NaN, ciHalfWidth: Number.NaN, indeterminate: true };
62
+ }
63
+ let sum = 0;
64
+ for (const s of scores)
65
+ sum += s;
66
+ const mean = sum / n;
67
+ let ss = 0;
68
+ for (const s of scores) {
69
+ const d = s - mean;
70
+ ss += d * d;
71
+ }
72
+ const sampleVariance = ss / (n - 1);
73
+ const sem = Math.sqrt(sampleVariance / n);
74
+ return { n, mean, sampleVariance, sem, ciHalfWidth: studentTQuantile(alpha, n - 1) * sem, indeterminate: false };
75
+ }
76
+ /** Aggregate bench score: mean over train unit means; val fallback when the bench has no train units. */
77
+ export function aggregateScore(units) {
78
+ const train = units.filter((u) => u.split === "train");
79
+ const pool = train.length > 0 ? train : units;
80
+ let sum = 0;
81
+ for (const u of pool)
82
+ sum += summarizeUnit(u.scores).mean;
83
+ return sum / pool.length;
84
+ }
85
+ /** `--reps N` clamps into [nReps.initial, nReps.max]; absent ⇒ initial (no sequential escalation in v1). */
86
+ export function clampReps(requested, nReps) {
87
+ const target = requested !== undefined && Number.isFinite(requested) ? requested : nReps.initial;
88
+ return Math.min(nReps.max, Math.max(nReps.initial, target));
89
+ }
90
+ /** Any cap at/above ⇒ exhausted. Counters arrive as plain inputs — no I/O. */
91
+ export function budgetExhausted(counters, caps) {
92
+ return (counters.candidates >= caps.maxCandidates ||
93
+ counters.modelCalls >= caps.maxModelCalls ||
94
+ counters.tokens >= caps.maxTokens ||
95
+ counters.wallS >= caps.maxWallS);
96
+ }
97
+ function fmt(x) {
98
+ return Number.isFinite(x) ? x.toFixed(4) : String(x);
99
+ }
100
+ function budgetFailureLines(counters, caps) {
101
+ const rows = [
102
+ ["candidates", counters.candidates, caps.maxCandidates],
103
+ ["modelCalls", counters.modelCalls, caps.maxModelCalls],
104
+ ["tokens", counters.tokens, caps.maxTokens],
105
+ ["wallS", counters.wallS, caps.maxWallS],
106
+ ];
107
+ return rows
108
+ .filter(([, used, cap]) => used >= cap)
109
+ .map(([name, used, cap]) => `budget exhausted: ${name}=${used}/${cap}`);
110
+ }
111
+ /**
112
+ * Nomination gate for ONE finalist pair (candidate vs incumbent). The verdict
113
+ * never inspects the world: thresholds come from stats, budget counters and
114
+ * caps are plain values, and the gate is RNG-free (no tie-break needed).
115
+ */
116
+ export function evaluate(input) {
117
+ const { candidate, incumbent, stats, budgetCaps, nPairs } = input;
118
+ const alpha = bonferroniAlpha(nPairs);
119
+ const base = {
120
+ runId: candidate.runId,
121
+ counters: candidate.counters,
122
+ caps: budgetCaps,
123
+ minEffect: stats.minEffect,
124
+ effectiveAlpha: alpha,
125
+ nPairs,
126
+ };
127
+ // 1) Budget exhaustion short-circuits: inconclusive, excluded from the
128
+ // comparison series, never an implicit pass even when metrics look great.
129
+ if (budgetExhausted(candidate.counters, budgetCaps)) {
130
+ return { ...base, verdict: "inconclusive", exitCode: needsExit("inconclusive"), gain: null, unitComparisons: [], failures: budgetFailureLines(candidate.counters, budgetCaps) };
131
+ }
132
+ const candByUnit = new Map(candidate.units.map((u) => [u.unitId, u]));
133
+ const incByUnit = new Map(incumbent.units.map((u) => [u.unitId, u]));
134
+ const failures = [];
135
+ // 2) HARD RULE nReps ≥ 2: n<2 on ANY candidate unit (or a val unit missing
136
+ // from the candidate ⇒ n=0) leaves the variance undefined.
137
+ for (const unit of candidate.units) {
138
+ if (unit.scores.length < 2) {
139
+ failures.push(`n=${unit.scores.length} on unit '${unit.unitId}': variance undefined — indeterminate, never nominated`);
140
+ }
141
+ }
142
+ for (const [unitId, incUnit] of incByUnit) {
143
+ if (incUnit.split === "val" && !candByUnit.has(unitId)) {
144
+ failures.push(`no replicates for val unit '${unitId}' on candidate '${candidate.runId}' — variance undefined, never nominated`);
145
+ }
146
+ }
147
+ if (failures.length > 0) {
148
+ return { ...base, verdict: "indeterminate", exitCode: needsExit("indeterminate"), gain: aggregateScore(candidate.units) - aggregateScore(incumbent.units), unitComparisons: [], failures };
149
+ }
150
+ // 3) Per-val-unit comparisons + the two precision gates.
151
+ const unitComparisons = [];
152
+ for (const [unitId, incUnit] of incByUnit) {
153
+ if (incUnit.split !== "val")
154
+ continue;
155
+ const candUnit = candByUnit.get(unitId);
156
+ if (candUnit === undefined)
157
+ continue; // already ruled indeterminate above
158
+ const cs = summarizeUnit(candUnit.scores, alpha);
159
+ const incumbentMean = summarizeUnit(incUnit.scores).mean;
160
+ const delta = cs.mean - incumbentMean;
161
+ unitComparisons.push({
162
+ unitId,
163
+ candidateMean: cs.mean,
164
+ incumbentMean,
165
+ ciHalfWidth: cs.ciHalfWidth,
166
+ delta,
167
+ ciPasses: cs.ciHalfWidth <= stats.halfWidth,
168
+ passes: delta >= -cs.ciHalfWidth, // ties allowed
169
+ });
170
+ if (cs.ciHalfWidth > stats.halfWidth) {
171
+ failures.push(`CI half-width ${fmt(cs.ciHalfWidth)} > halfWidth ${fmt(stats.halfWidth)} on unit '${unitId}'`);
172
+ }
173
+ if (delta < -cs.ciHalfWidth) {
174
+ failures.push(`regression on unit '${unitId}': delta ${fmt(delta)} < ${fmt(-cs.ciHalfWidth)} (mean ${fmt(cs.mean)} < ${fmt(incumbentMean)} - ${fmt(cs.ciHalfWidth)})`);
175
+ }
176
+ }
177
+ // 4) Effect-size floor on the aggregate gain.
178
+ const gain = aggregateScore(candidate.units) - aggregateScore(incumbent.units);
179
+ if (gain < stats.minEffect) {
180
+ failures.push(`gain ${fmt(gain)} < minEffect ${fmt(stats.minEffect)}`);
181
+ }
182
+ const verdict = failures.length === 0 ? "nominated" : "culled";
183
+ return { ...base, verdict, exitCode: needsExit(verdict), gain, unitComparisons, failures };
184
+ }
@@ -0,0 +1,190 @@
1
+ // Git worktree generation store (todo 3).
2
+ //
3
+ // Cache-path scheme:
4
+ // $XDG_CACHE_HOME/abathur/worktrees/<genomeFp>/<genId> generation worktrees
5
+ // $XDG_CACHE_HOME/abathur/worktrees/<genomeFp>/snapshots/<sha> read-only commit copies
6
+ // fallback root when XDG_CACHE_HOME is unset/empty/relative: ~/.cache
7
+ //
8
+ // Invariants upheld here:
9
+ // - generations live in detached worktrees under the cache root — abathur NEVER
10
+ // commits into the genome repo's checked-out branch and never mutates the
11
+ // user's main worktree (dirty target paths are a refusal, not a fix);
12
+ // - no push and no ref update without a compare-and-swap old-value here —
13
+ // abathur/incumbent moves fast-forward-only (todo 10 consumes that helper);
14
+ // - every git call goes through src/util/git.ts (execFile array argv, timeout).
15
+ import { mkdir, readdir, stat } from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { blocked, cannotAnswer } from "../exit.js";
18
+ import { git, tryGit } from "../util/git.js";
19
+ import { generationPath, genomeDir, gitOpts, requireRev, SNAPSHOTS_DIR, } from "./genome-paths.js";
20
+ import { removeFrozenTree } from "./snapshot.js";
21
+ export { cacheRoot } from "./genome-paths.js";
22
+ export { snapshotCommit } from "./snapshot.js";
23
+ export { INCUMBENT_BRANCH, fastForwardIncumbent } from "./incumbent.js";
24
+ const DAY_MS = 86_400_000;
25
+ /** Fixed committer identity for sealed generations (repo config is never trusted or touched). */
26
+ const SEAL_IDENTITY = ["-c", "user.name=abathur", "-c", "user.email=abathur@harness.local"];
27
+ // ------------------------------------------------------------------ openGenome
28
+ /** Porcelain v1 with -z: "XY path" records; rename/copy records carry the source as an extra NUL field. */
29
+ function parsePorcelain(raw) {
30
+ const tokens = raw.split("\0");
31
+ const out = [];
32
+ for (let i = 0; i < tokens.length; i += 1) {
33
+ const token = tokens[i];
34
+ if (token === undefined || token.length < 4)
35
+ continue;
36
+ const entry = { xy: token.slice(0, 2), file: token.slice(3) };
37
+ out.push(entry);
38
+ const kind = entry.xy.charAt(0);
39
+ if (kind === "R" || kind === "C")
40
+ i += 1; // consume rename/copy source
41
+ }
42
+ return out;
43
+ }
44
+ function normalizeTarget(repo, target) {
45
+ const rel = path.relative(repo, path.resolve(repo, target));
46
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
47
+ cannotAnswer(`worktree: target path "${target}" is outside the genome repo ${repo}`);
48
+ }
49
+ return rel; // "" === whole repo
50
+ }
51
+ function isUnder(file, target) {
52
+ return target === "" || file === target || file.startsWith(`${target}/`);
53
+ }
54
+ /**
55
+ * Verify the genome repo is a usable worktree base and return dirtiness data.
56
+ * Refusal (blocked, exit 1) happens ONLY when a path under `targetPaths` is
57
+ * dirty (tracked modifications, staged changes, or untracked files); dirtiness
58
+ * elsewhere is returned as `dirtyWorktree` notices for the ledger.
59
+ */
60
+ export async function openGenome(repoPath, targetPaths = [], opts = {}) {
61
+ const repo = path.resolve(repoPath);
62
+ const dirStat = await stat(repo).catch(() => null);
63
+ if (dirStat === null || !dirStat.isDirectory()) {
64
+ blocked(`worktree: genome repo not found at ${repo}`, "clone the genome first");
65
+ }
66
+ const gopts = gitOpts(opts, repo);
67
+ const inside = await tryGit(["rev-parse", "--git-dir"], gopts);
68
+ if (!inside.ok) {
69
+ blocked(`worktree: ${repo} is not a git repository`, inside.error.stderr.trim());
70
+ }
71
+ const bare = await git(["rev-parse", "--is-bare-repository"], gopts);
72
+ if (bare.stdout.trim() === "true") {
73
+ blocked(`worktree: ${repo} is a bare repo — genomes must be full working copies (the ledger lives in-tree)`);
74
+ }
75
+ const head = await tryGit(["rev-parse", "HEAD"], gopts);
76
+ if (!head.ok) {
77
+ blocked(`worktree: ${repo} has no commits — seed the genome before opening it`);
78
+ }
79
+ const branchRun = await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], gopts);
80
+ const status = await git(["status", "--porcelain=v1", "-z", "--untracked-files=all"], gopts);
81
+ const entries = parsePorcelain(status.stdout);
82
+ const targets = targetPaths.map((t) => normalizeTarget(repo, t));
83
+ const underTarget = (e) => targets.some((t) => isUnder(e.file, t));
84
+ const dirtyTargets = entries.filter(underTarget);
85
+ if (dirtyTargets.length > 0) {
86
+ blocked(`worktree: refusing to open ${repo}: dirty target path(s): ${dirtyTargets
87
+ .map((e) => `${e.xy} ${e.file}`)
88
+ .join(", ")}`, "commit or restore them first — abathur never mutates your worktree");
89
+ }
90
+ return {
91
+ repoPath: repo,
92
+ headCommit: head.stdout.trim(),
93
+ branch: branchRun.ok ? branchRun.stdout.trim() : "HEAD",
94
+ dirtyWorktree: entries.filter((e) => !underTarget(e)),
95
+ };
96
+ }
97
+ // -------------------------------------------------------------- generations
98
+ /** `git worktree add --detach <cache>/<genomeFp>/<genId> <parentCommit>`; rev may be a sha or a safe ref name. */
99
+ export async function newGeneration(genome, parentCommit, genId, opts = {}) {
100
+ const env = opts.env ?? process.env;
101
+ const wt = generationPath(env, genome, genId); // validates both segments (exit 2 on junk)
102
+ const rev = requireRev(parentCommit); // validates before any git spawn
103
+ const repo = path.resolve(genome.repoPath);
104
+ if ((await stat(wt).catch(() => null)) !== null) {
105
+ cannotAnswer(`worktree: generation directory already exists: ${wt}`);
106
+ }
107
+ await mkdir(path.dirname(wt), { recursive: true });
108
+ const added = await tryGit(["worktree", "add", "--detach", wt, rev], gitOpts(opts, repo));
109
+ if (!added.ok) {
110
+ const why = added.error.stderr.trim().split("\n")[0] ?? added.error.message;
111
+ blocked(`worktree: cannot create generation ${genId} from ${rev}: ${why}`, "is the parent commit reachable in the genome repo?");
112
+ }
113
+ const head = await git(["rev-parse", "HEAD"], gitOpts(opts, wt));
114
+ return { worktreePath: wt, parentCommit: head.stdout.trim() };
115
+ }
116
+ /** Stage everything inside the generation worktree and commit it; returns commit + tree shas. */
117
+ export async function sealGeneration(genome, genId, message, opts = {}) {
118
+ const env = opts.env ?? process.env;
119
+ const wt = generationPath(env, genome, genId);
120
+ if ((await stat(wt).catch(() => null)) === null) {
121
+ blocked(`worktree: generation ${genId} not found under ${path.dirname(wt)}`, "open a generation with newGeneration first");
122
+ }
123
+ const gopts = gitOpts(opts, wt);
124
+ const attached = await tryGit(["symbolic-ref", "-q", "HEAD"], gopts);
125
+ if (attached.ok) {
126
+ blocked(`worktree: refusing to seal ${genId} — generations must be detached from branches`, "this worktree is on a live branch; abathur never commits there");
127
+ }
128
+ const porcelain = await git(["status", "--porcelain", "--untracked-files=all"], gopts);
129
+ if (porcelain.stdout.length === 0) {
130
+ blocked(`worktree: nothing to seal in generation ${genId} — stage a mutation first`);
131
+ }
132
+ await git(["add", "-A"], gopts);
133
+ // `-m${message}` as ONE argv element: a dash-leading message can never be parsed as an option.
134
+ await git([...SEAL_IDENTITY, "commit", "-q", `-m${message}`], gopts);
135
+ const commitSha = (await git(["rev-parse", "HEAD"], gopts)).stdout.trim();
136
+ const treeSha = (await git(["rev-parse", "HEAD^{tree}"], gopts)).stdout.trim();
137
+ return { commitSha, treeSha };
138
+ }
139
+ // ------------------------------------------------------------------ cleanup
140
+ async function listOrEmpty(dir) {
141
+ try {
142
+ return await readdir(dir);
143
+ }
144
+ catch {
145
+ return [];
146
+ }
147
+ }
148
+ /**
149
+ * Remove generation/snapshot dirs older than `olderThanDays` (0 = everything)
150
+ * and prune the repo's dead worktree registry entries. Idempotent: a second
151
+ * run reports nothing. Frozen snapshot dirs are thawed before removal.
152
+ */
153
+ export async function cleanupStale(genome, olderThanDays, opts = {}) {
154
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
155
+ cannotAnswer("worktree: olderThanDays must be a finite number >= 0");
156
+ }
157
+ const env = opts.env ?? process.env;
158
+ const dir = genomeDir(env, genome); // validates the fingerprint
159
+ const cutoff = Date.now() - olderThanDays * DAY_MS;
160
+ const roots = [];
161
+ for (const name of await listOrEmpty(dir)) {
162
+ if (name !== SNAPSHOTS_DIR)
163
+ roots.push(path.join(dir, name));
164
+ }
165
+ const snapRoot = path.join(dir, SNAPSHOTS_DIR);
166
+ for (const name of await listOrEmpty(snapRoot)) {
167
+ roots.push(path.join(snapRoot, name));
168
+ }
169
+ const removed = [];
170
+ const kept = [];
171
+ for (const p of roots) {
172
+ const st = await stat(p).catch(() => null);
173
+ if (st === null)
174
+ continue;
175
+ if (st.mtimeMs <= cutoff) {
176
+ await removeFrozenTree(p);
177
+ removed.push(p);
178
+ }
179
+ else {
180
+ kept.push(p);
181
+ }
182
+ }
183
+ // Prune AFTER removal: dirs we just deleted leave dead registry entries that
184
+ // would otherwise block re-adding a generation with the same id.
185
+ const pruned = await tryGit(["worktree", "prune"], gitOpts(opts, path.resolve(genome.repoPath)));
186
+ if (!pruned.ok) {
187
+ blocked(`worktree: cannot prune the worktree registry of ${genome.repoPath}: ${pruned.error.stderr.trim()}`);
188
+ }
189
+ return { removed, kept };
190
+ }
package/dist/exit.js ADDED
@@ -0,0 +1,32 @@
1
+ // Exit-code contract (family-wide, border/README.md:289-296):
2
+ // 0 = ok/pass 1 = blocked/failed (decision) 2 = cannot-answer (tool error:
3
+ // config error, malformed input, missing engine, lock holder)
4
+ // Handlers never touch process directly: they return 0 or throw ExitSignal; the CLI
5
+ // boundary (src/cli.ts main) renders one clean line per failure and sets process.exitCode.
6
+ export const EXIT_OK = 0;
7
+ export const EXIT_BLOCKED = 1;
8
+ export const EXIT_CANNOT_ANSWER = 2;
9
+ /** Control-flow exception carrying the process exit code for the current run. */
10
+ export class ExitSignal extends Error {
11
+ code;
12
+ hint;
13
+ constructor(code, message, hint) {
14
+ super(message);
15
+ this.code = code;
16
+ this.hint = hint;
17
+ this.name = "ExitSignal";
18
+ }
19
+ }
20
+ /** Gate blocked the action (HIGH/CRITICAL verdict, refused promote, ...). */
21
+ export function blocked(message, hint) {
22
+ throw new ExitSignal(EXIT_BLOCKED, message, hint);
23
+ }
24
+ /** The harness could not answer: config/engine/input error. Never exit 0 on this class. */
25
+ export function cannotAnswer(message, hint) {
26
+ throw new ExitSignal(EXIT_CANNOT_ANSWER, message, hint);
27
+ }
28
+ /** Single-line rendering used by the CLI boundary for every ExitSignal. */
29
+ export function renderExitSignal(signal) {
30
+ const base = `abathur: ${signal.message}`;
31
+ return signal.hint === undefined ? base : `${base}\n hint: ${signal.hint}`;
32
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "label": "toy-smoke",
3
+ "repoPath": ".",
4
+ "bench": {
5
+ "type": "toy",
6
+ "units": [
7
+ { "id": "add", "path": "units/add.mjs", "split": "train" },
8
+ { "id": "mul", "path": "units/mul.mjs", "split": "train" },
9
+ { "id": "explode", "path": "units/explode.mjs", "split": "train" },
10
+ { "id": "sub", "path": "units/sub.mjs", "split": "val" }
11
+ ],
12
+ "runCommand": "node {unit.path}",
13
+ "graderCommand": "node grader.mjs {unit.path}",
14
+ "timeoutS": 10,
15
+ "stats": {
16
+ "halfWidth": 0.25,
17
+ "minEffect": 0.5,
18
+ "nReps": { "initial": 2, "max": 4 }
19
+ }
20
+ },
21
+ "budget": {
22
+ "maxCandidates": 4,
23
+ "maxModelCalls": 16,
24
+ "maxTokens": 100000,
25
+ "maxWallS": 300
26
+ },
27
+ "kernel": {
28
+ "immutableGlobs": ["grader.mjs"]
29
+ }
30
+ }
@@ -0,0 +1,61 @@
1
+ // Toy grader (plan todo 5): script-first, model-free. Consumes a unit path (argv[2],
2
+ // resolved against cwd), imports the unit module, and scores it from its exported
3
+ // `checks(): boolean[]` contract. Prints EXACTLY one JSON line on success:
4
+ // {unit, score 0..1, pass, metrics {tokensEst, turns}}
5
+ // Any problem (missing file, import throw, contract violation) => one stderr line
6
+ // and exit 1 with NO JSON — the adapter maps that to an inconclusive-for-unit.
7
+ // NOTE: importing executes unit top-level code; units must guard side effects with
8
+ // the standard `isMain` check (see units/*.mjs), exactly like Python __main__.
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+
14
+ const BYTES_PER_TOKEN = 4;
15
+
16
+ const unitArg = process.argv[2];
17
+ if (typeof unitArg !== "string" || unitArg.length === 0) fail("usage: node grader.mjs <unitPath>");
18
+
19
+ const unitAbs = path.resolve(process.cwd(), unitArg);
20
+ let bytes;
21
+ try {
22
+ bytes = readFileSync(unitAbs);
23
+ } catch {
24
+ fail(`unit file not readable: ${unitArg}`);
25
+ }
26
+
27
+ let mod;
28
+ try {
29
+ mod = await import(pathToFileURL(unitAbs).href);
30
+ } catch (cause) {
31
+ fail(`unit import failed: ${cause instanceof Error ? cause.message : String(cause)}`);
32
+ }
33
+
34
+ if (typeof mod.checks !== "function") fail("unit does not export checks(): boolean[]");
35
+
36
+ let results;
37
+ try {
38
+ results = mod.checks();
39
+ } catch (cause) {
40
+ fail(`checks() threw: ${cause instanceof Error ? cause.message : String(cause)}`);
41
+ }
42
+
43
+ if (!Array.isArray(results) || results.length === 0) fail("checks() must return a non-empty array");
44
+ if (!results.every((r) => typeof r === "boolean")) fail("checks() must return booleans only");
45
+
46
+ const passes = results.filter((r) => r).length;
47
+ const line = {
48
+ unit: unitArg,
49
+ score: passes / results.length,
50
+ pass: passes === results.length,
51
+ metrics: {
52
+ tokensEst: Math.ceil(bytes.length / BYTES_PER_TOKEN),
53
+ turns: results.length,
54
+ },
55
+ };
56
+ process.stdout.write(`${JSON.stringify(line)}\n`);
57
+
58
+ function fail(reason) {
59
+ process.stderr.write(`grader: ${reason}\n`);
60
+ process.exit(1);
61
+ }
@@ -0,0 +1,63 @@
1
+ // Toy-smoke self-init (plan todo 5): `node init.mjs <destDir>` materializes an
2
+ // independent toy genome repo in destDir:
3
+ // 1. copy this template dir (skipping any .git/.state) into destDir,
4
+ // 2. rewrite genome.jsonc repoPath to the absolute destDir (paths are GENERATED
5
+ // at runtime — nothing on disk hardcodes a machine path, D7),
6
+ // 3. when no .git exists, `git init` + full commit so todo-3 worktree flows work.
7
+ // Prints the destDir on stdout. Usage errors exit 2.
8
+
9
+ import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { spawnSync } from "node:child_process";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ const destArg = process.argv[2];
17
+ if (typeof destArg !== "string" || destArg.length === 0) {
18
+ process.stderr.write("usage: node init.mjs <destDir>\n");
19
+ process.exit(2);
20
+ }
21
+ const dest = path.resolve(destArg);
22
+ if (dest === here) {
23
+ process.stderr.write("init: destDir must differ from the template directory\n");
24
+ process.exit(2);
25
+ }
26
+
27
+ if (!existsSync(path.join(here, "genome.jsonc"))) {
28
+ process.stderr.write(`init: template dir ${here} has no genome.jsonc\n`);
29
+ process.exit(2);
30
+ }
31
+
32
+ cpSync(here, dest, {
33
+ recursive: true,
34
+ filter: (src) => {
35
+ const rel = path.relative(here, src);
36
+ return rel !== ".git" && !rel.startsWith(`.git${path.sep}`) && rel !== ".state" && !rel.startsWith(`.state${path.sep}`);
37
+ },
38
+ });
39
+
40
+ const specPath = path.join(dest, "genome.jsonc");
41
+ const spec = JSON.parse(readFileSync(specPath, "utf8"));
42
+ spec.repoPath = dest;
43
+ writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`, "utf8");
44
+
45
+ if (!existsSync(path.join(dest, ".git"))) {
46
+ git(["init", "-b", "main"]);
47
+ git(["add", "-A"]);
48
+ // identity pinned via -c BEFORE the subcommand (global gitconfig immune;
49
+ // `git init` itself rejects -c after the subcommand — learnings todo 3).
50
+ git(["-c", "user.name=abathur", "-c", "user.email=abathur@harness.local", "commit", "-m", "toy-smoke fixture seed"]);
51
+ }
52
+
53
+ process.stdout.write(`${dest}\n`);
54
+
55
+ function git(args) {
56
+ const run = spawnSync("git", args, { cwd: dest, encoding: "utf8" });
57
+ if (run.error !== undefined && run.error !== null) {
58
+ throw new Error(`init: cannot spawn git: ${run.error.message}`);
59
+ }
60
+ if (run.status !== 0) {
61
+ throw new Error(`git ${args.join(" ")} failed (${String(run.status)}): ${(run.stderr ?? "").trim()}`);
62
+ }
63
+ }
@@ -0,0 +1,17 @@
1
+ // Toy unit: add — shipped WITH A SEEDED BUG (subtraction). The stub-mutators
2
+ // 'fix-add' scripted patch flips it to the correct sum; grader checks() grade it.
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const isMain =
7
+ process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
8
+
9
+ export function add(a, b) {
10
+ return a - b; // seeded bug: must be `return a + b;`
11
+ }
12
+
13
+ export function checks() {
14
+ return [add(2, 3) === 5, add(0, 7) === 7, add(-1, 1) === 0];
15
+ }
16
+
17
+ if (isMain) console.log(`add(2,3)=${add(2, 3)}`);
@@ -0,0 +1,4 @@
1
+ // Toy unit: explode — exits 1 at import time. run() sees status ok + exitCode 1;
2
+ // score() sees the grader die mid-import with exit 1 and must record the unit as
3
+ // inconclusive, never crash the bench (plan AC (d)).
4
+ process.exit(1);
@@ -0,0 +1,16 @@
1
+ // Toy unit: hang — blocks far longer than any test timeout via `exec sleep`
2
+ // (learnings: execFile-without-input closes stdin, so hang fixtures must exec
3
+ // sleep, never cat). Fixture for AC (e): the adapter must SIGKILL the whole
4
+ // process group (node + sh + sleep) and record status timeout.
5
+ import { execSync } from "node:child_process";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const isMain =
10
+ process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
11
+
12
+ export function checks() {
13
+ return [true];
14
+ }
15
+
16
+ if (isMain) execSync("sleep 31.7", { stdio: "ignore" });
@@ -0,0 +1,16 @@
1
+ // Toy unit: mul — correct baseline; grader score 1.0, pass true.
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const isMain =
6
+ process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
7
+
8
+ export function mul(a, b) {
9
+ return a * b;
10
+ }
11
+
12
+ export function checks() {
13
+ return [mul(2, 3) === 6, mul(0, 7) === 0, mul(-2, 3) === -6];
14
+ }
15
+
16
+ if (isMain) console.log(`mul(2,3)=${mul(2, 3)}`);
@@ -0,0 +1,18 @@
1
+ // Toy unit: mutate — run() writes ./poison.txt into the sandbox cwd (state mutation;
2
+ // import for grading stays pure via the isMain guard). Fixture for AC (c):
3
+ // reset+seed must restore a byte-identical tree digest.
4
+ import { writeFileSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const isMain =
9
+ process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
10
+
11
+ export function checks() {
12
+ return [true];
13
+ }
14
+
15
+ if (isMain) {
16
+ writeFileSync(path.join(process.cwd(), "poison.txt"), "mutated\n");
17
+ console.log("mutated sandbox state");
18
+ }
@@ -0,0 +1,16 @@
1
+ // Toy unit: sub — the required val-split unit; correct baseline (score 1.0).
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const isMain =
6
+ process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
7
+
8
+ export function sub(a, b) {
9
+ return a - b;
10
+ }
11
+
12
+ export function checks() {
13
+ return [sub(9, 4) === 5, sub(0, 0) === 0, sub(-3, -3) === 0];
14
+ }
15
+
16
+ if (isMain) console.log(`sub(9,4)=${sub(9, 4)}`);