@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,187 @@
1
+ // Graft re-bench (todo 13): the all-green path. Apply the bundle's contained
2
+ // primary tree as byte-truth into a FRESH todo-3 worktree off the local
3
+ // incumbent HEAD, seal with the harness-fixed identity, then run the LOCAL
4
+ // bench at LOCAL reps/thresholds through the same adapter + bench-sandbox root
5
+ // the run-loop uses (<configDir>/bench-sandboxes/<invId>/..., so todo-12
6
+ // evidence export walks graft transcripts too). todo-7 stats decide; the
7
+ // bundle's numbers never touch this file.
8
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { cannotAnswer } from "../exit.js";
11
+ import { compactUtc, fingerprint, genId } from "./ids.js";
12
+ import { LEDGER_KIND_GENERATION_COMPLETE } from "./ledger.js";
13
+ import { newGeneration, openGenome, sealGeneration, snapshotCommit } from "./worktree.js";
14
+ import { git, tryGit } from "../util/git.js";
15
+ import { asReplicates, benchTarget, cloneMatrixRow, copyProvenance } from "./evolve/run-bench.js";
16
+ import { addCounters, readResume } from "./evolve/run-rows.js";
17
+ import { ChildTracker, reapOrphans } from "./evolve/child-track.js";
18
+ import { clampReps, evaluate, VERDICT_EXIT } from "./stats.js";
19
+ import { echo, sha12 } from "./graft-support.js";
20
+ export async function graftAndBench(req, ctx) {
21
+ const { ledger, spec, manifest, tree, bundleSha256, now, env } = withClock(req, ctx);
22
+ const lines = [];
23
+ // reap only under the genome lock (todo-9 discipline): leftovers are dead-run children
24
+ reapOrphans(spec.repoPath);
25
+ const opened = await openGenome(spec.repoPath, [], { env });
26
+ if (opened.dirtyWorktree.length > 0) {
27
+ lines.push(`notice: ${String(opened.dirtyWorktree.length)} unrelated dirty path(s) in the genome repo — graft benches the SEALED bundle tree only`);
28
+ }
29
+ const genome = { repoPath: spec.repoPath, genomeFp: ctx.genomeFp };
30
+ const graftGenId = genId(fingerprint({ graft: bundleSha256, sourceGenome: manifest.genome.fingerprint, parent: opened.headCommit }), now());
31
+ const gen = await newGeneration(genome, opened.headCommit, graftGenId, { env });
32
+ await applyBundleTree(gen.worktreePath, tree);
33
+ const sealed = await sealGraftedTree(genome, graftGenId, gen.worktreePath, opened.headCommit, bundleSha256, env);
34
+ lines.push(`graft applied: bundle ${sha12(bundleSha256)} → fresh worktree ${graftGenId} (commit ${sealed.commitSha.slice(0, 12)}, tree ${sealed.treeSha.slice(0, 12)})`);
35
+ const reps = clampReps(undefined, spec.bench.stats.nReps); // LOCAL initial — bundle reps are peerClaim only
36
+ const caps = {
37
+ maxCandidates: spec.budget.maxCandidates,
38
+ maxModelCalls: spec.budget.maxModelCalls,
39
+ maxTokens: spec.budget.maxTokens,
40
+ maxWallS: spec.budget.maxWallS,
41
+ };
42
+ const resume = readResume(ledger);
43
+ const invId = `graft-${compactUtc(now())}-${bundleSha256.slice(0, 8)}`;
44
+ const sandboxBase = path.join(req.configDir, "bench-sandboxes", invId);
45
+ const tracker = new ChildTracker(spec.repoPath);
46
+ const configEnv = env;
47
+ let counters = resume.counters;
48
+ const storedInc = resume.incumbentByHead.get(opened.headCommit);
49
+ let incUnits;
50
+ if (storedInc !== undefined) {
51
+ incUnits = storedInc.units;
52
+ lines.push(`incumbent baseline @ ${opened.headCommit.slice(0, 12)} already benched — reusing ${String(incUnits.length)} unit rows`);
53
+ }
54
+ else {
55
+ // committed bytes of the incumbent HEAD, never the possibly-dirty worktree
56
+ const snap = await snapshotCommit(genome, opened.headCommit, { env });
57
+ const incGen = genId(fingerprint({ graftIncumbent: opened.headCommit, at: now().getTime() }), now());
58
+ const out = await benchTarget({
59
+ spec: { ...spec, repoPath: snap.snapshotPath },
60
+ genId: incGen,
61
+ reps,
62
+ caps,
63
+ countersBefore: counters,
64
+ sandboxRoot: path.join(sandboxBase, "incumbent"),
65
+ source: "incumbent",
66
+ tracker,
67
+ configDir: req.configDir,
68
+ env: configEnv,
69
+ });
70
+ counters = addCounters(counters, out.spent);
71
+ const data = {
72
+ source: "incumbent",
73
+ headCommit: opened.headCommit,
74
+ complete: out.complete,
75
+ reps,
76
+ units: out.units.map(cloneMatrixRow),
77
+ counters: out.spent,
78
+ manifest: out.manifest.map((m) => ({ glob: m.glob, path: m.path, sha256: m.sha256 })),
79
+ benchProvenance: copyProvenance(out.provenance),
80
+ ...(opened.dirtyWorktree.length === 0 ? {} : { dirtyWorktree: opened.dirtyWorktree.map((d) => ({ xy: d.xy, file: d.file })) }),
81
+ };
82
+ ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId: incGen, data });
83
+ incUnits = out.units;
84
+ }
85
+ const out = await benchTarget({
86
+ spec: { ...spec, repoPath: gen.worktreePath },
87
+ genId: graftGenId,
88
+ reps,
89
+ caps,
90
+ countersBefore: counters,
91
+ sandboxRoot: path.join(sandboxBase, graftGenId),
92
+ source: "candidate",
93
+ tracker,
94
+ configDir: req.configDir,
95
+ ...(configEnv === undefined ? {} : { env: configEnv }),
96
+ });
97
+ counters = addCounters(counters, out.spent);
98
+ // the candidate's own slot must not self-trip the cap inside evaluate (todo-9 rule)
99
+ const evalCounters = { ...counters, candidates: counters.candidates - out.spent.candidates };
100
+ const gate = evaluate({
101
+ candidate: { runId: invId, units: asReplicates(out.units), counters: evalCounters },
102
+ incumbent: { units: asReplicates(incUnits) },
103
+ stats: spec.bench.stats,
104
+ budgetCaps: caps,
105
+ nPairs: 1,
106
+ });
107
+ // EXACTLY ONE graft_import decision row lands BEFORE any further writes.
108
+ ctx.recordDecision(gate.verdict, localScoreReason(gate, manifest, reps), { graftGenId, ...sealed });
109
+ const data = {
110
+ source: "candidate",
111
+ candidateId: `graft-${bundleSha256.slice(0, 12)}`,
112
+ rationale: `graft of bundle ${sha12(bundleSha256)} from source genome ${manifest.genome.fingerprint.slice(0, 16)}… (peer claim: ${manifest.stats.verdict ?? "n/a"})`,
113
+ headCommit: opened.headCommit,
114
+ commitSha: sealed.commitSha,
115
+ treeSha: sealed.treeSha,
116
+ complete: out.complete,
117
+ reps,
118
+ units: out.units.map(cloneMatrixRow),
119
+ counters: out.spent,
120
+ manifest: out.manifest.map((m) => ({ glob: m.glob, path: m.path, sha256: m.sha256 })),
121
+ verdict: gate.verdict,
122
+ exitCode: gate.exitCode,
123
+ gain: Number.isFinite(gate.gain) ? gate.gain : null,
124
+ gateFailures: [...gate.failures],
125
+ benchProvenance: copyProvenance(out.provenance),
126
+ };
127
+ ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId: graftGenId, data });
128
+ await tracker.drain();
129
+ const gain = gate.gain === null ? "n/a (truncated)" : gate.gain.toFixed(4);
130
+ lines.push(`graft ${gate.verdict}: local score is authoritative (gain ${gain}, reps ${String(reps)}, LOCAL thresholds) — peer claimed ${manifest.stats.verdict ?? "n/a"} @ nRepeats ${String(manifest.benchProvenance.nRepeats ?? "?")}; imported:true + source ${manifest.genome.fingerprint.slice(0, 16)}… booked`);
131
+ for (const failure of gate.failures)
132
+ lines.push(` gate: ${echo(failure)}`);
133
+ if (gate.verdict === "nominated") {
134
+ lines.push(` human gate: 'abathur promote ${echo(req.genomeLabel, 80)} ${graftGenId}' — graft itself never promotes`);
135
+ }
136
+ return { exitCode: VERDICT_EXIT[gate.verdict], lines };
137
+ }
138
+ function localScoreReason(gate, manifest, reps) {
139
+ return `local re-bench: verdict ${gate.verdict}, gain ${gate.gain === null ? "n/a" : gate.gain.toFixed(4)}, reps ${String(reps)} (LOCAL spec) — peer claimed ${manifest.stats.verdict ?? "n/a"} @ nRepeats ${String(manifest.benchProvenance.nRepeats ?? "?")}`;
140
+ }
141
+ function withClock(req, ctx) {
142
+ return {
143
+ ...ctx,
144
+ now: req.now ?? (() => new Date()),
145
+ env: req.env ?? process.env,
146
+ };
147
+ }
148
+ /** Tree bytes are the byte-truth: overwrite every member, remove tracked files the bundle lacks. */
149
+ async function applyBundleTree(worktreePath, tree) {
150
+ const wt = path.resolve(worktreePath);
151
+ const listed = await tryGit(["ls-tree", "-r", "--name-only", "-z", "HEAD"], { cwd: wt });
152
+ if (!listed.ok)
153
+ cannotAnswer(`graft: cannot list the incumbent tree in ${wt}: ${echo(listed.error.stderr, 160)}`);
154
+ for (const rel of listed.stdout.split("\0").filter((entry) => entry.length > 0)) {
155
+ if (!tree.has(rel))
156
+ rmSync(safeJoin(wt, rel), { force: true });
157
+ }
158
+ for (const [rel, content] of tree) {
159
+ const abs = safeJoin(wt, rel);
160
+ mkdirSync(path.dirname(abs), { recursive: true });
161
+ writeFileSync(abs, content);
162
+ }
163
+ }
164
+ /** readTar refused traversal/absolute names already; this is the write-side belt. */
165
+ function safeJoin(root, rel) {
166
+ const bad = () => cannotAnswer(`graft: unsafe bundle member path '${echo(rel, 80)}'`);
167
+ if (rel.length === 0 || path.isAbsolute(rel) || rel.includes("\\") || rel.includes("\0"))
168
+ bad();
169
+ for (const segment of rel.split("/")) {
170
+ if (segment === ".." || segment === "")
171
+ bad();
172
+ }
173
+ const abs = path.resolve(root, rel);
174
+ if (!abs.startsWith(`${root}${path.sep}`) || abs === path.join(root, ".git"))
175
+ bad();
176
+ return abs;
177
+ }
178
+ async function sealGraftedTree(genome, graftGenId, worktreePath, headCommit, bundleSha256, env) {
179
+ const porcelain = await git(["status", "--porcelain", "--untracked-files=all"], { cwd: worktreePath });
180
+ if (porcelain.stdout.trim().length === 0) {
181
+ // bundle tree == incumbent HEAD content: the graft generation IS this commit
182
+ const tree = await git(["rev-parse", "HEAD^{tree}"], { cwd: worktreePath });
183
+ return { commitSha: headCommit, treeSha: tree.stdout.trim() };
184
+ }
185
+ // harness-authored message + fixed committer identity (todo-3 sealGeneration)
186
+ return sealGeneration(genome, graftGenId, `graft: import crystallization from bundle ${sha12(bundleSha256)} (see docs/federation.md)`, { env });
187
+ }
@@ -0,0 +1,181 @@
1
+ // Graft support (todo 13): the pending-bench queue file convention, the
2
+ // `graft_import` ledger-row shape, and the echo sanitizer shared by `graft`
3
+ // and `status`. Kept deliberately dependency-light (fs + text only, no git,
4
+ // no bench, no ledger class) so `status` can render graft state WITHOUT ever
5
+ // calling Ledger.open — the todo-10 rule: status side-effects must not poison
6
+ // the genome-rm ledgerless check. The queue file IS the graft_import record
7
+ // when no local repo exists to hold a ledger.
8
+ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { cannotAnswer } from "../exit.js";
11
+ export const LEDGER_KIND_GRAFT_IMPORT = "graft_import";
12
+ /** <configDir>/graft-queue/<bundle-sha256>.json — one explicit entry per
13
+ * pending-bench bundle (plan todo 13: "explicit queue entry in status"). */
14
+ export const GRAFT_QUEUE_DIRNAME = "graft-queue";
15
+ const HEX64 = /^[0-9a-f]{64}$/;
16
+ const DECISIONS = [
17
+ "quarantined",
18
+ "pending-bench",
19
+ "nominated",
20
+ "culled",
21
+ "indeterminate",
22
+ "inconclusive",
23
+ ];
24
+ function asDecision(value) {
25
+ return DECISIONS.find((decision) => decision === value) ?? null;
26
+ }
27
+ /** A terminal decision blocks re-grafting the same bundle; pending does not
28
+ * (the plan's eligible path is "register genome then re-run graft"). */
29
+ export const isTerminalDecision = (decision) => decision !== "pending-bench";
30
+ export function graftQueueDir(configDir) {
31
+ return path.join(configDir, GRAFT_QUEUE_DIRNAME);
32
+ }
33
+ export function graftQueuePath(configDir, bundleSha256) {
34
+ if (!HEX64.test(bundleSha256)) {
35
+ cannotAnswer(`graft: queue key '${bundleSha256}' is not a 64-char sha256 hex`);
36
+ }
37
+ return path.join(graftQueueDir(configDir), `${bundleSha256}.json`);
38
+ }
39
+ export function writeGraftQueueEntry(configDir, entry) {
40
+ const file = graftQueuePath(configDir, entry.bundleSha256);
41
+ mkdirSync(path.dirname(file), { recursive: true });
42
+ writeFileSync(file, `${JSON.stringify(entry, null, 2)}\n`, "utf8");
43
+ return file;
44
+ }
45
+ /** Idempotent consume: the success-path decision retires the queue entry. */
46
+ export function removeGraftQueueEntry(configDir, bundleSha256) {
47
+ rmSync(graftQueuePath(configDir, bundleSha256), { force: true });
48
+ }
49
+ function parseQueueFile(file, name) {
50
+ let doc;
51
+ try {
52
+ doc = JSON.parse(readFileSync(file, "utf8"));
53
+ }
54
+ catch (cause) {
55
+ return cannotAnswer(`graft queue: ${file} is not readable JSON — refusing to render a partial truth: ${cause instanceof Error ? cause.message : String(cause)}`);
56
+ }
57
+ const raw = (typeof doc === "object" && doc !== null && !Array.isArray(doc) ? doc : undefined);
58
+ const bad = () => cannotAnswer(`graft queue: ${file} violates the queue-entry shape — fix or remove the file by hand`);
59
+ if (raw === undefined)
60
+ bad();
61
+ const body = raw;
62
+ const str = (key) => {
63
+ const value = body[key];
64
+ return typeof value === "string" && value.length > 0 ? value : bad();
65
+ };
66
+ const bundleSha256 = str("bundleSha256");
67
+ // the filename is the address: a foreign key inside is tampering, not data
68
+ if (!HEX64.test(bundleSha256) || `${bundleSha256}.json` !== name)
69
+ bad();
70
+ const fp = body.sourceGenomeFingerprint;
71
+ if (fp !== undefined && (typeof fp !== "string" || !HEX64.test(fp)))
72
+ bad();
73
+ return {
74
+ bundleSha256,
75
+ bundlePath: str("bundlePath"),
76
+ genomeLabel: str("genomeLabel"),
77
+ reason: str("reason"),
78
+ queuedAt: str("queuedAt"),
79
+ ...(typeof fp === "string" ? { sourceGenomeFingerprint: fp } : {}),
80
+ };
81
+ }
82
+ /** Sorted, fail-closed read of the whole queue (status + graft dedup view). */
83
+ export function listGraftQueue(configDir) {
84
+ const dir = graftQueueDir(configDir);
85
+ let names;
86
+ try {
87
+ names = readdirSync(dir).filter((n) => n.endsWith(".json")).sort();
88
+ }
89
+ catch (error) {
90
+ if (error.code === "ENOENT")
91
+ return []; // no queue yet = empty, not an error
92
+ throw error; // unreadable-but-present (EACCES/EIO) must never render as an empty queue
93
+ }
94
+ return names.map((name) => parseQueueFile(path.join(dir, name), name));
95
+ }
96
+ const asStr = (value) => (typeof value === "string" ? value : null);
97
+ const asStrOrNull = (value) => value === null || typeof value === "string" ? value : undefined;
98
+ /** Lenient read of a graft_import row (ledger data is record<string,unknown>). */
99
+ export function decodeGraftImport(data) {
100
+ const decision = asDecision(data.decision);
101
+ if (decision === null)
102
+ return null;
103
+ const genomeLabel = asStr(data.genomeLabel);
104
+ const bundleSha256 = asStr(data.bundleSha256);
105
+ const reason = asStr(data.reason);
106
+ if (genomeLabel === null || bundleSha256 === null || reason === null)
107
+ return null;
108
+ if (data.imported !== true)
109
+ return null;
110
+ const sourceGenomeFingerprint = asStrOrNull(data.sourceGenomeFingerprint);
111
+ const sourceBenchDigest = asStrOrNull(data.sourceBenchDigest);
112
+ const graftGenId = asStrOrNull(data.graftGenId);
113
+ const commitSha = asStrOrNull(data.commitSha);
114
+ const treeSha = asStrOrNull(data.treeSha);
115
+ const bundlePath = asStr(data.bundlePath) ?? "";
116
+ if (sourceGenomeFingerprint === undefined || sourceBenchDigest === undefined)
117
+ return null;
118
+ if (graftGenId === undefined || commitSha === undefined || treeSha === undefined)
119
+ return null;
120
+ const rawClaim = data.peerClaim;
121
+ let peerClaim = null;
122
+ if (typeof rawClaim === "object" && rawClaim !== null && !Array.isArray(rawClaim)) {
123
+ const claim = rawClaim;
124
+ const verdict = asStrOrNull(claim.verdict);
125
+ const nRepeats = claim.nRepeats;
126
+ const opencodeVersion = asStrOrNull(claim.opencodeVersion);
127
+ const agentModel = asStrOrNull(claim.agentModel);
128
+ const judgeModel = asStrOrNull(claim.judgeModel);
129
+ const statsConfigDigest = asStrOrNull(claim.statsConfigDigest);
130
+ const rationale = asStr(claim.rationale);
131
+ if (verdict !== undefined &&
132
+ (nRepeats === null || (typeof nRepeats === "number" && Number.isInteger(nRepeats))) &&
133
+ opencodeVersion !== undefined &&
134
+ agentModel !== undefined &&
135
+ judgeModel !== undefined &&
136
+ statsConfigDigest !== undefined &&
137
+ rationale !== null) {
138
+ peerClaim = {
139
+ verdict,
140
+ nRepeats: typeof nRepeats === "number" ? nRepeats : null,
141
+ opencodeVersion,
142
+ agentModel,
143
+ judgeModel,
144
+ statsConfigDigest,
145
+ rationale,
146
+ };
147
+ }
148
+ }
149
+ return {
150
+ decision,
151
+ genomeLabel,
152
+ bundleSha256,
153
+ bundlePath,
154
+ sourceGenomeFingerprint,
155
+ sourceBenchDigest,
156
+ reason,
157
+ imported: true,
158
+ graftGenId,
159
+ commitSha,
160
+ treeSha,
161
+ peerClaim,
162
+ };
163
+ }
164
+ /** The quarantine/pending row's peerClaim projection of a bundle manifest. */
165
+ export function peerClaimFromManifest(manifest) {
166
+ return {
167
+ verdict: manifest.stats.verdict,
168
+ nRepeats: manifest.benchProvenance.nRepeats,
169
+ opencodeVersion: manifest.benchProvenance.opencodeVersion,
170
+ agentModel: manifest.benchProvenance.agentModel,
171
+ judgeModel: manifest.benchProvenance.judgeModel,
172
+ statsConfigDigest: manifest.benchProvenance.statsConfigDigest,
173
+ rationale: manifest.rationale,
174
+ };
175
+ }
176
+ /** Single-line print sanitizer for untrusted echo content (todo-8 flat discipline). */
177
+ export function echo(value, max = 140) {
178
+ const one = value.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
179
+ return one.length > max ? `${one.slice(0, max)}…` : one;
180
+ }
181
+ export const sha12 = (sha) => (HEX64.test(sha) ? sha.slice(0, 12) : echo(sha, 12));
@@ -0,0 +1,218 @@
1
+ // `graft` (plan todo 13): cross-instance crystallization merge with a LOCAL
2
+ // re-bench. A lineage bundle is UNTRUSTED input — the bundle's scores, verdicts
3
+ // and reps are recorded as `peerClaim` metadata and NEVER feed nomination math.
4
+ // v1 trust model, byte-for-byte (docs/federation.md — todo 15):
5
+ //
6
+ // inspect (todo 12) → genome-fingerprint gate → benchDigest gate →
7
+ // scoring-provenance gate → requires[] probes (todo 6) → fresh todo-3
8
+ // worktree from the LOCAL incumbent HEAD carrying the bundle's contained
9
+ // tree bytes → LOCAL bench at LOCAL reps/thresholds (NEVER the bundle's) →
10
+ // todo-7 stats decide: nominated (human promote gate only) or culled.
11
+ //
12
+ // * integrity/provenance mismatch ⇒ quarantined — exactly one graft_import
13
+ // ledger row, NO worktree, NO bench; `status` lists it.
14
+ // * genome unregistered ⇒ pending-bench — explicit queue entry under
15
+ // <configDir>/graft-queue/, zero bench runs; the operator registers the
16
+ // genome and re-runs graft to resolve the entry. Probes failing is the
17
+ // same pending-bench shape (prerequisite repair, then re-run).
18
+ // * the same bundle + genome never grafts twice (terminal-row refusal).
19
+ //
20
+ // No noise-tolerance band: byte-equality is the rule. No --force, no
21
+ // quarantine/pending-bench bypass, no merge of two incumbent branches, no
22
+ // auto-promote (core/promote.js is never imported here). Single-shot by design
23
+ // — graft is NOT a resumable run: the queue file + graft_import rows are the
24
+ // durable state, and "resume" means re-running `abathur graft`.
25
+ import path from "node:path";
26
+ import { EXIT_BLOCKED, EXIT_CANNOT_ANSWER, ExitSignal } from "../exit.js";
27
+ import { inspectBundle } from "./bundle-inspect.js";
28
+ import { bundleGenIds, primaryGenId, sha256Hex, treeOf } from "./bundle-common.js";
29
+ import { fingerprint16 } from "./genome.js";
30
+ import { fingerprint } from "./ids.js";
31
+ import { acquireGenomeLock, Ledger } from "./ledger.js";
32
+ import { LEDGER_KIND_GRAFT_IMPORT, echo, peerClaimFromManifest, removeGraftQueueEntry, sha12, writeGraftQueueEntry, } from "./graft-support.js";
33
+ import { assertNotGrafted, bestEffortClaim, localBenchDigest, parseBundleBytes, probeGraftPrerequisites, provenanceGateFailures, readBundleBytes, } from "./graft-gates.js";
34
+ import { graftAndBench } from "./graft-rebench.js";
35
+ export async function graftBundle(req) {
36
+ const now = req.now ?? (() => new Date());
37
+ const bundlePath = path.resolve(req.bundlePath);
38
+ const bytes = readBundleBytes(bundlePath);
39
+ const bundleSha256 = sha256Hex(bytes);
40
+ // ---- STEP 1: FULL inspect first (todo 12). Integrity failures are decided,
41
+ // not negotiated: quarantine-booked, then the inspect wording is echoed.
42
+ // Cannot-answer-class failures (garbage container / unreadable manifest)
43
+ // leave no state to book — a refusal line is the whole story.
44
+ try {
45
+ inspectBundle({ bundlePath, home: req.home });
46
+ }
47
+ catch (cause) {
48
+ if (cause instanceof ExitSignal && cause.code === EXIT_BLOCKED) {
49
+ const claim = bestEffortClaim(bytes);
50
+ return quarantine(req, bundleSha256, bundlePath, claim, `integrity: ${cause.message}`);
51
+ }
52
+ throw cause;
53
+ }
54
+ const { manifest, members } = parseBundleBytes(bytes);
55
+ // ---- STEP 2: genome not registered locally ⇒ pending-bench. There is no
56
+ // repo to hold a ledger, so the queue file IS the graft_import record.
57
+ if (req.entry === null) {
58
+ const reason = `genome '${req.genomeLabel}' is not registered locally`;
59
+ const file = writeGraftQueueEntry(req.configDir, {
60
+ bundleSha256,
61
+ bundlePath,
62
+ genomeLabel: req.genomeLabel,
63
+ reason,
64
+ queuedAt: now().toISOString(),
65
+ sourceGenomeFingerprint: manifest.genome.fingerprint,
66
+ });
67
+ return pendingOutcome(req, bundleSha256, reason, file, EXIT_BLOCKED);
68
+ }
69
+ const entry = req.entry;
70
+ const spec = entry.spec;
71
+ const ledger = Ledger.open(spec.repoPath, { now });
72
+ assertNotGrafted(ledger, bundleSha256, req.genomeLabel);
73
+ // ---- STEP 3: the byte-exact gates; any miss quarantines before state or spawns.
74
+ const localFingerprint = fingerprint(spec);
75
+ if (localFingerprint !== manifest.genome.fingerprint) {
76
+ return quarantineLedger(ledger, req, bundleSha256, bundlePath, manifest, `genome fingerprint mismatch — local spec fingerprints to ${localFingerprint}, bundle claims ${manifest.genome.fingerprint}`);
77
+ }
78
+ const primary = primaryGenId(bundleGenIds(members));
79
+ const tree = treeOf(members, primary);
80
+ const digest = localBenchDigest(spec, tree, primary);
81
+ if (digest !== manifest.benchDigest) {
82
+ return quarantineLedger(ledger, req, bundleSha256, bundlePath, manifest, `benchDigest mismatch — recomputed from the contained tree under the LOCAL spec: local ${digest}, bundle claims ${manifest.benchDigest}`);
83
+ }
84
+ const provenanceFailures = provenanceGateFailures(manifest, spec, tree, primary, ledger);
85
+ if (provenanceFailures.length > 0) {
86
+ return quarantineLedger(ledger, req, bundleSha256, bundlePath, manifest, `scoring provenance not comparable — ${provenanceFailures.join("; ")}`);
87
+ }
88
+ // ---- STEP 4: requires[] probes BEFORE any worktree or bench (todo-6 machinery).
89
+ try {
90
+ await probeGraftPrerequisites(spec, req.opencodeBin);
91
+ }
92
+ catch (cause) {
93
+ if (cause instanceof ExitSignal && cause.code === EXIT_CANNOT_ANSWER) {
94
+ const reason = `requires[] probe failed: ${cause.message}`;
95
+ appendGraftImport(ledger, req, bundleSha256, bundlePath, manifest, {
96
+ decision: "pending-bench",
97
+ reason,
98
+ sourceGenomeFingerprint: manifest.genome.fingerprint,
99
+ });
100
+ const file = writeGraftQueueEntry(req.configDir, {
101
+ bundleSha256,
102
+ bundlePath,
103
+ genomeLabel: req.genomeLabel,
104
+ reason,
105
+ queuedAt: now().toISOString(),
106
+ sourceGenomeFingerprint: manifest.genome.fingerprint,
107
+ });
108
+ return pendingOutcome(req, bundleSha256, reason, file, EXIT_CANNOT_ANSWER);
109
+ }
110
+ throw cause;
111
+ }
112
+ // ---- STEP 5: all green ⇒ fresh worktree + LOCAL re-bench under the genome lock.
113
+ const lease = acquireGenomeLock({ ledger, configDir: req.configDir, genomeFp: fingerprint16(spec), now });
114
+ try {
115
+ return await graftAndBench(req, {
116
+ ledger,
117
+ spec,
118
+ genomeFp: entry.fingerprint,
119
+ manifest,
120
+ tree,
121
+ bundleSha256,
122
+ recordDecision: (decision, reason, sealed) => {
123
+ // EXACTLY ONE graft_import decision row, booked before the candidate
124
+ // generation row; the terminal decision retires any pending-bench entry.
125
+ appendGraftImport(ledger, req, bundleSha256, bundlePath, manifest, {
126
+ decision,
127
+ reason,
128
+ sourceGenomeFingerprint: manifest.genome.fingerprint,
129
+ graftGenId: sealed.graftGenId,
130
+ commitSha: sealed.commitSha,
131
+ treeSha: sealed.treeSha,
132
+ });
133
+ removeGraftQueueEntry(req.configDir, bundleSha256);
134
+ },
135
+ });
136
+ }
137
+ finally {
138
+ lease.release();
139
+ }
140
+ }
141
+ function appendGraftImport(ledger, req, bundleSha256, bundlePath, manifest, detail) {
142
+ ledger.append({
143
+ kind: LEDGER_KIND_GRAFT_IMPORT,
144
+ ...(detail.graftGenId === undefined ? {} : { genId: detail.graftGenId }),
145
+ data: {
146
+ decision: detail.decision,
147
+ genomeLabel: req.genomeLabel,
148
+ bundleSha256,
149
+ bundlePath,
150
+ sourceGenomeFingerprint: detail.sourceGenomeFingerprint,
151
+ sourceBenchDigest: manifest?.benchDigest ?? null,
152
+ reason: detail.reason,
153
+ imported: true,
154
+ graftGenId: detail.graftGenId ?? null,
155
+ commitSha: detail.commitSha ?? null,
156
+ treeSha: detail.treeSha ?? null,
157
+ peerClaim: manifest === null ? null : peerClaimFromManifest(manifest),
158
+ },
159
+ });
160
+ }
161
+ /** Inspect-time integrity failure: quarantine on the LOCAL ledger when a repo
162
+ * exists; refuse clean (exit 1, no state) when the genome is unknown — the
163
+ * pending-bench queue records bundles AWAITING setup, not garbage. */
164
+ function quarantine(req, bundleSha256, bundlePath, claim, reason) {
165
+ if (req.entry === null) {
166
+ throw new ExitSignal(EXIT_BLOCKED, `graft: quarantined — ${reason}`, "nothing was applied or benched; 'abathur bundle inspect' names the failing member");
167
+ }
168
+ const ledger = Ledger.open(req.entry.spec.repoPath);
169
+ assertNotGrafted(ledger, bundleSha256, req.genomeLabel);
170
+ ledger.append({
171
+ kind: LEDGER_KIND_GRAFT_IMPORT,
172
+ data: {
173
+ decision: "quarantined",
174
+ genomeLabel: req.genomeLabel,
175
+ bundleSha256,
176
+ bundlePath,
177
+ sourceGenomeFingerprint: claim.fingerprint,
178
+ sourceBenchDigest: claim.benchDigest,
179
+ reason,
180
+ imported: true,
181
+ graftGenId: null,
182
+ commitSha: null,
183
+ treeSha: null,
184
+ peerClaim: null,
185
+ },
186
+ });
187
+ return quarantineOutcome(req, bundleSha256, reason);
188
+ }
189
+ function quarantineLedger(ledger, req, bundleSha256, bundlePath, manifest, reason) {
190
+ appendGraftImport(ledger, req, bundleSha256, bundlePath, manifest, {
191
+ decision: "quarantined",
192
+ reason,
193
+ sourceGenomeFingerprint: manifest.genome.fingerprint,
194
+ });
195
+ return quarantineOutcome(req, bundleSha256, reason);
196
+ }
197
+ function quarantineOutcome(req, bundleSha256, reason) {
198
+ return {
199
+ exitCode: EXIT_BLOCKED,
200
+ lines: [
201
+ `graft: quarantined — bundle ${sha12(bundleSha256)} refused under genome '${echo(req.genomeLabel, 80)}'`,
202
+ ` gate: ${echo(reason, 400)}`,
203
+ ` decision booked to the ledger as ${LEDGER_KIND_GRAFT_IMPORT} (imported:true); review with 'abathur status ${echo(req.genomeLabel, 80)}'`,
204
+ ` NEVER applied, NEVER benched — v1 has no noise-tolerance band and no bypass flag (docs/federation.md)`,
205
+ ],
206
+ };
207
+ }
208
+ function pendingOutcome(req, bundleSha256, reason, queueFile, exitCode) {
209
+ return {
210
+ exitCode,
211
+ lines: [
212
+ `graft: pending-bench — bundle ${sha12(bundleSha256)} queued for genome '${echo(req.genomeLabel, 80)}'`,
213
+ ` reason: ${echo(reason, 300)}`,
214
+ ` queue: ${echo(queueFile, 200)}`,
215
+ ` zero bench runs launched; the eligible path is to register/repair the local genome, then re-run 'abathur graft <this bundle> --genome ${echo(req.genomeLabel, 80)}'`,
216
+ ],
217
+ };
218
+ }