@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.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +74 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/config/abathur.jsonc +17 -0
- package/config/genomes/historian.example.jsonc +124 -0
- package/dist/bench/adapter.js +201 -0
- package/dist/bench/fixture-probe.js +92 -0
- package/dist/bench/fixture-support.js +173 -0
- package/dist/bench/fixture.js +236 -0
- package/dist/bench/toy.js +152 -0
- package/dist/cli.js +110 -0
- package/dist/commands/bundle.js +79 -0
- package/dist/commands/genome.js +94 -0
- package/dist/commands/graft.js +71 -0
- package/dist/commands/kernel.js +47 -0
- package/dist/commands/promote.js +25 -0
- package/dist/commands/run.js +145 -0
- package/dist/commands/self-eval.js +240 -0
- package/dist/commands/status.js +186 -0
- package/dist/commands/tombstone.js +72 -0
- package/dist/config.js +161 -0
- package/dist/core/bundle-common.js +119 -0
- package/dist/core/bundle-export.js +212 -0
- package/dist/core/bundle-inspect.js +143 -0
- package/dist/core/bundle-manifest.js +105 -0
- package/dist/core/bundle-mask.js +75 -0
- package/dist/core/bundle-tar.js +240 -0
- package/dist/core/bundle.js +9 -0
- package/dist/core/evolve/brief.js +45 -0
- package/dist/core/evolve/candidate.js +140 -0
- package/dist/core/evolve/child-track.js +197 -0
- package/dist/core/evolve/friction.js +150 -0
- package/dist/core/evolve/reflect.js +191 -0
- package/dist/core/evolve/run-bench.js +170 -0
- package/dist/core/evolve/run-friction.js +63 -0
- package/dist/core/evolve/run-loop.js +282 -0
- package/dist/core/evolve/run-plan.js +39 -0
- package/dist/core/evolve/run-rows.js +145 -0
- package/dist/core/evolve/self-overlay.js +213 -0
- package/dist/core/evolve/self-snapshot.js +170 -0
- package/dist/core/evolve/stub-mutators.mjs +105 -0
- package/dist/core/evolve/udiff.js +189 -0
- package/dist/core/genome-paths.js +76 -0
- package/dist/core/genome.js +176 -0
- package/dist/core/glob.js +106 -0
- package/dist/core/graft-gates.js +184 -0
- package/dist/core/graft-rebench.js +187 -0
- package/dist/core/graft-support.js +181 -0
- package/dist/core/graft.js +218 -0
- package/dist/core/ids.js +154 -0
- package/dist/core/incumbent.js +46 -0
- package/dist/core/kernel.js +112 -0
- package/dist/core/ledger.js +198 -0
- package/dist/core/locks.js +172 -0
- package/dist/core/promote.js +119 -0
- package/dist/core/snapshot.js +61 -0
- package/dist/core/spec.js +178 -0
- package/dist/core/stats-math.js +102 -0
- package/dist/core/stats-pareto.js +57 -0
- package/dist/core/stats.js +184 -0
- package/dist/core/worktree.js +190 -0
- package/dist/exit.js +32 -0
- package/dist/genomes/toy-smoke/genome.jsonc +30 -0
- package/dist/genomes/toy-smoke/grader.mjs +61 -0
- package/dist/genomes/toy-smoke/init.mjs +63 -0
- package/dist/genomes/toy-smoke/units/add.mjs +17 -0
- package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
- package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
- package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
- package/dist/jsonc.js +77 -0
- package/dist/out.js +5 -0
- package/dist/test/bench-adapter.test.js +33 -0
- package/dist/test/bench-fixture.test.js +407 -0
- package/dist/test/bench-toy.test.js +251 -0
- package/dist/test/bundle.test.js +659 -0
- package/dist/test/config.test.js +185 -0
- package/dist/test/d7-gate.test.js +56 -0
- package/dist/test/fixture-loop.test.js +267 -0
- package/dist/test/fixtures/friction-writer.js +16 -0
- package/dist/test/fixtures-historian.js +82 -0
- package/dist/test/fixtures-self.js +143 -0
- package/dist/test/fixtures-wt.js +64 -0
- package/dist/test/friction.test.js +398 -0
- package/dist/test/genome.test.js +453 -0
- package/dist/test/git.test.js +69 -0
- package/dist/test/graft.test.js +567 -0
- package/dist/test/historian-genome.test.js +134 -0
- package/dist/test/historian-grader-io.test.js +148 -0
- package/dist/test/historian-grader.test.js +209 -0
- package/dist/test/ids.test.js +116 -0
- package/dist/test/include-val.test.js +120 -0
- package/dist/test/ledger-lock.test.js +99 -0
- package/dist/test/ledger.test.js +102 -0
- package/dist/test/promote.test.js +394 -0
- package/dist/test/reflect.test.js +410 -0
- package/dist/test/run-loop.test.js +433 -0
- package/dist/test/self-snapshot.test.js +328 -0
- package/dist/test/snapshot.test.js +86 -0
- package/dist/test/stats.test.js +423 -0
- package/dist/test/stub-mutators.test.js +17 -0
- package/dist/test/testutil.js +30 -0
- package/dist/test/worktree.test.js +198 -0
- package/dist/util/freeze.js +30 -0
- package/dist/util/git.js +85 -0
- package/docs/federation.md +184 -0
- package/docs/immutable-kernel.md +87 -0
- package/graders/historian/grader-core.d.mts +53 -0
- package/graders/historian/grader-core.mjs +276 -0
- package/graders/historian/grader-support.d.mts +57 -0
- package/graders/historian/grader-support.mjs +137 -0
- package/graders/historian/grader.mjs +113 -0
- package/graders/historian/mutate.sh +114 -0
- package/graders/historian/reset-sandbox.sh +60 -0
- package/graders/historian/run-scenario.sh +49 -0
- package/graders/historian/seed-wrapped.sh +32 -0
- package/package.json +42 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `abathur status <label>` — a READ-ONLY ledger view. It never opens the Ledger
|
|
2
|
+
// class (that mkdirs on first sight) and never touches worktrees: everything is
|
|
3
|
+
// parsed from the ledger file plus one rev-parse. Ledger-sourced strings are
|
|
4
|
+
// untrusted free text — every one is flattened before printing, so a hostile
|
|
5
|
+
// reason/candidateId cannot forge table rows or terminal escapes.
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { resolveConfigDir } from "../config.js";
|
|
8
|
+
import { EXIT_OK, cannotAnswer } from "../exit.js";
|
|
9
|
+
import { writeStdout } from "../out.js";
|
|
10
|
+
import { INCUMBENT_BRANCH } from "../core/incumbent.js";
|
|
11
|
+
import { ledgerPath, ledgerRecordSchema } from "../core/ledger.js";
|
|
12
|
+
import { gitOpts } from "../core/genome-paths.js";
|
|
13
|
+
import { decodeGenerationRecord } from "../core/evolve/run-bench.js";
|
|
14
|
+
import { tryGit } from "../util/git.js";
|
|
15
|
+
import { readRegistry } from "../core/genome.js";
|
|
16
|
+
import { LEDGER_KIND_GRAFT_IMPORT, decodeGraftImport, listGraftQueue, } from "../core/graft-support.js";
|
|
17
|
+
import { resolveUniqueEntry } from "./run.js";
|
|
18
|
+
const KIND_GENERATION = "generation_complete";
|
|
19
|
+
const KIND_PROMOTE = "promote";
|
|
20
|
+
const KIND_TOMBSTONE = "tombstone";
|
|
21
|
+
const USAGE = "usage: abathur status <label> [--last N]";
|
|
22
|
+
function flat(value, max = 64) {
|
|
23
|
+
const one = value.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
|
|
24
|
+
return one.length > max ? `${one.slice(0, max)}…` : one;
|
|
25
|
+
}
|
|
26
|
+
function readLedgerRecords(file) {
|
|
27
|
+
if (!existsSync(file))
|
|
28
|
+
return [];
|
|
29
|
+
const text = readFileSync(file, "utf8");
|
|
30
|
+
return text.split("\n").filter((l) => l.length > 0).map((line, idx) => {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(line);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return cannotAnswer(`status: ledger row ${String(idx + 1)} in ${file} is not JSON — refusing to render a partial truth`);
|
|
37
|
+
}
|
|
38
|
+
const check = ledgerRecordSchema.safeParse(parsed);
|
|
39
|
+
if (!check.success) {
|
|
40
|
+
return cannotAnswer(`status: ledger row ${String(idx + 1)} in ${file} violates the envelope schema: ${String(check.error.issues[0]?.message ?? "schema")}`);
|
|
41
|
+
}
|
|
42
|
+
return check.data;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function generations(records) {
|
|
46
|
+
return records
|
|
47
|
+
.filter((r) => r.kind === KIND_GENERATION && r.genId !== undefined)
|
|
48
|
+
.map((r) => ({ genId: r.genId, data: decodeGenerationRecord(r) }));
|
|
49
|
+
}
|
|
50
|
+
function verdictCell(data) {
|
|
51
|
+
return `verdict=${data.verdict ?? "—"}`;
|
|
52
|
+
}
|
|
53
|
+
function gainCell(data) {
|
|
54
|
+
if (data.gain === undefined)
|
|
55
|
+
return "gain=—";
|
|
56
|
+
if (data.gain === null)
|
|
57
|
+
return "gain=n/a";
|
|
58
|
+
return `gain=${data.gain >= 0 ? "+" : ""}${data.gain.toFixed(3)}`;
|
|
59
|
+
}
|
|
60
|
+
function counterCell(d) {
|
|
61
|
+
return `cand=${String(d.counters.candidates)} model=${String(d.counters.modelCalls)} tok=${String(d.counters.tokens)} wall=${d.counters.wallS.toFixed(2)}s`;
|
|
62
|
+
}
|
|
63
|
+
// Quarantine depth = candidate generations the stats gate nominated that the human
|
|
64
|
+
// has NEVER decided: latest generation_complete row per genId says verdict=nominated
|
|
65
|
+
// and no promote or tombstone row exists for that genId. Todo-12 graft imports are
|
|
66
|
+
// "quarantined" by the same ledger shape (logged, undecided ⇒ counted here).
|
|
67
|
+
function quarantineEntries(gens, records) {
|
|
68
|
+
const latest = new Map();
|
|
69
|
+
for (const g of gens)
|
|
70
|
+
latest.set(g.genId, g.data);
|
|
71
|
+
const decided = new Set(records.filter((r) => r.kind === KIND_PROMOTE || r.kind === KIND_TOMBSTONE).map((r) => String(r.genId)));
|
|
72
|
+
return [...latest.entries()]
|
|
73
|
+
.filter(([id, d]) => d.source === "candidate" && d.verdict === "nominated" && !decided.has(id))
|
|
74
|
+
.map(([id]) => flat(id, 40));
|
|
75
|
+
}
|
|
76
|
+
function sha12(sha) {
|
|
77
|
+
return sha.slice(0, 12);
|
|
78
|
+
}
|
|
79
|
+
async function runStatus(args) {
|
|
80
|
+
const positional = [];
|
|
81
|
+
let last = 10;
|
|
82
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
83
|
+
const arg = args[i];
|
|
84
|
+
if (arg === "--last") {
|
|
85
|
+
const raw = Number(args[(i += 1)]);
|
|
86
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > 1000)
|
|
87
|
+
cannotAnswer(`status: --last expects an integer 1..1000`, USAGE);
|
|
88
|
+
last = raw;
|
|
89
|
+
}
|
|
90
|
+
else if (arg.startsWith("--")) {
|
|
91
|
+
cannotAnswer(`status: unknown flag ${arg}`, USAGE);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
positional.push(arg);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const label = positional[0];
|
|
98
|
+
if (label === undefined || positional.length !== 1)
|
|
99
|
+
cannotAnswer("status: expected exactly <label>", USAGE);
|
|
100
|
+
const configDir = resolveConfigDir();
|
|
101
|
+
// Todo 13: the pending-bench graft queue is a raw <configDir>/graft-queue/*
|
|
102
|
+
// read (graft-support owns the convention; status never Ledger.opens). An
|
|
103
|
+
// unregistered label with queued bundles still gets a queue view — the
|
|
104
|
+
// operator's "is my graft waiting?" question must be answerable by label.
|
|
105
|
+
const queue = listGraftQueue(configDir).filter((q) => q.genomeLabel === label);
|
|
106
|
+
const matches = readRegistry(configDir).entries.filter((e) => e.label === label);
|
|
107
|
+
if (matches.length > 1 || (matches.length === 0 && queue.length === 0)) {
|
|
108
|
+
resolveUniqueEntry(configDir, label, "status"); // identical ambiguity / not-registered exits
|
|
109
|
+
}
|
|
110
|
+
const entry = matches[0] ?? null;
|
|
111
|
+
if (entry === null) {
|
|
112
|
+
writeStdout(`genome: ${flat(label)} — not registered locally (pending-bench graft queue view)`);
|
|
113
|
+
renderQueue(queue);
|
|
114
|
+
return EXIT_OK;
|
|
115
|
+
}
|
|
116
|
+
const repo = entry.spec.repoPath;
|
|
117
|
+
const records = readLedgerRecords(ledgerPath(repo));
|
|
118
|
+
const gens = generations(records);
|
|
119
|
+
writeStdout(`genome: ${flat(entry.label)} (${entry.fingerprint}) repo: ${flat(repo, 200)}`);
|
|
120
|
+
const ref = await tryGit(["rev-parse", "--verify", "-q", `refs/heads/${INCUMBENT_BRANCH}`], gitOpts({}, repo));
|
|
121
|
+
const refSha = ref.ok ? ref.stdout.trim() : null;
|
|
122
|
+
const promoteRows = records.filter((r) => r.kind === KIND_PROMOTE);
|
|
123
|
+
const lastPromote = promoteRows.at(-1);
|
|
124
|
+
if (refSha === null) {
|
|
125
|
+
writeStdout(lastPromote === undefined
|
|
126
|
+
? "incumbent: none"
|
|
127
|
+
: `incumbent: none (warning: last promote targeted ${String(lastPromote.data.to).slice(0, 12)} — the ${INCUMBENT_BRANCH} ref is missing)`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
writeStdout(`incumbent: ${sha12(refSha)} (${INCUMBENT_BRANCH})`);
|
|
131
|
+
if (lastPromote !== undefined) {
|
|
132
|
+
const data = lastPromote.data;
|
|
133
|
+
const from = data.from === null || data.from === undefined ? "new branch" : String(data.from).slice(0, 12);
|
|
134
|
+
writeStdout(` last promote: ${flat(String(data.genId ?? "?"), 40)} ${from} -> ${String(data.to ?? "?").slice(0, 12)} at ${lastPromote.ts}`);
|
|
135
|
+
if (String(data.to ?? "") !== refSha)
|
|
136
|
+
writeStdout(` warning: ref ${sha12(refSha)} differs from last promote target — lineage moved outside promote`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const quarantine = quarantineEntries(gens, records);
|
|
140
|
+
writeStdout(`quarantine depth: ${String(quarantine.length)}`);
|
|
141
|
+
if (quarantine.length > 0)
|
|
142
|
+
writeStdout(` pending: ${quarantine.slice(0, 10).join(", ")}${quarantine.length > 10 ? ` (+${String(quarantine.length - 10)} more)` : ""}`);
|
|
143
|
+
renderQueue(queue);
|
|
144
|
+
renderGraftDecisions(records);
|
|
145
|
+
writeStdout(`generations (last ${String(Math.min(last, gens.length))} of ${String(gens.length)}):`);
|
|
146
|
+
for (const g of gens.slice(-last)) {
|
|
147
|
+
const who = g.data.source === "candidate" ? `candidate ${flat(g.data.candidateId ?? "?", 40)}` : "incumbent-baseline";
|
|
148
|
+
writeStdout(` # ${flat(g.genId, 40)} ${who} ${verdictCell(g.data)} ${gainCell(g.data)} ${counterCell(g.data)}`);
|
|
149
|
+
}
|
|
150
|
+
const spent = { candidates: 0, modelCalls: 0, tokens: 0, wallS: 0 };
|
|
151
|
+
for (const g of gens) {
|
|
152
|
+
spent.candidates += g.data.counters.candidates;
|
|
153
|
+
spent.modelCalls += g.data.counters.modelCalls;
|
|
154
|
+
spent.tokens += g.data.counters.tokens;
|
|
155
|
+
spent.wallS += g.data.counters.wallS;
|
|
156
|
+
}
|
|
157
|
+
const caps = entry.spec.budget;
|
|
158
|
+
writeStdout(`budget: candidates ${String(spent.candidates)}/${String(caps.maxCandidates)}, modelCalls ${String(spent.modelCalls)}/${String(caps.maxModelCalls)}, tokens ${String(spent.tokens)}/${String(caps.maxTokens)}, wallS ${spent.wallS.toFixed(2)}/${String(caps.maxWallS)}`);
|
|
159
|
+
return EXIT_OK;
|
|
160
|
+
}
|
|
161
|
+
function renderQueue(queue) {
|
|
162
|
+
writeStdout(`pending-bench graft queue: ${String(queue.length)}`);
|
|
163
|
+
for (const q of queue) {
|
|
164
|
+
writeStdout(` # ${sha12(q.bundleSha256)} bundle ${flat(q.bundlePath, 160)} reason ${flat(q.reason, 80)} queued ${flat(q.queuedAt, 40)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Quarantine section extension (todo 13): graft_import decision rows render
|
|
168
|
+
// under their own heading — the generation-based quarantine depth above keeps
|
|
169
|
+
// its exact todo-10 semantics. Undecodable rows are announced, never fabricated.
|
|
170
|
+
function renderGraftDecisions(records) {
|
|
171
|
+
const rows = records.filter((r) => r.kind === LEDGER_KIND_GRAFT_IMPORT);
|
|
172
|
+
const decisions = rows.map((r) => decodeGraftImport(r.data)).filter((d) => d !== null);
|
|
173
|
+
writeStdout(`graft decisions: ${String(decisions.length)}`);
|
|
174
|
+
if (decisions.length !== rows.length) {
|
|
175
|
+
writeStdout(` warning: ${String(rows.length - decisions.length)} graft_import row(s) unreadable — shown as depth, never invented`);
|
|
176
|
+
}
|
|
177
|
+
for (const d of decisions.slice(-10)) {
|
|
178
|
+
const source = d.sourceGenomeFingerprint === null ? "?" : sha12(d.sourceGenomeFingerprint);
|
|
179
|
+
writeStdout(` # ${sha12(d.bundleSha256)} ${d.decision} source ${source} ${flat(d.reason, 100)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export const statusCommand = {
|
|
183
|
+
name: "status",
|
|
184
|
+
summary: "read-only ledger view: incumbent, generations, quarantine depth, budget consumption",
|
|
185
|
+
run: ({ args }) => runStatus(args),
|
|
186
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `abathur tombstone <label> <genId> --reason <text>` — cull is NOT delete:
|
|
2
|
+
// this command only appends one ledger row. Commits, worktrees, and every prior
|
|
3
|
+
// row survive; the reason is stored verbatim (canonicalJson escapes control
|
|
4
|
+
// bytes) but NEVER echoed raw — the confirmation line is sanitized like todo-8's
|
|
5
|
+
// sealMessage so untrusted text cannot forge terminal output.
|
|
6
|
+
import { Ledger } from "../core/ledger.js";
|
|
7
|
+
import { resolveConfigDir } from "../config.js";
|
|
8
|
+
import { EXIT_OK, blocked, cannotAnswer } from "../exit.js";
|
|
9
|
+
import { writeStdout } from "../out.js";
|
|
10
|
+
import { resolveUniqueEntry } from "./run.js";
|
|
11
|
+
const LEDGER_KIND_TOMBSTONE = "tombstone";
|
|
12
|
+
const MAX_REASON_CHARS = 4000;
|
|
13
|
+
const USAGE = "usage: abathur tombstone <label> <genId> --reason <text>";
|
|
14
|
+
function echoed(reason) {
|
|
15
|
+
const flat = reason.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
|
|
16
|
+
return flat.length > 120 ? `${flat.slice(0, 120)}…` : flat;
|
|
17
|
+
}
|
|
18
|
+
function parseArgs(args) {
|
|
19
|
+
const positional = [];
|
|
20
|
+
let reason = null;
|
|
21
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
if (arg === "--reason") {
|
|
24
|
+
const value = args[(i += 1)];
|
|
25
|
+
if (value === undefined || value.startsWith("--"))
|
|
26
|
+
cannotAnswer("tombstone: --reason requires a value", USAGE);
|
|
27
|
+
if (reason !== null)
|
|
28
|
+
cannotAnswer("tombstone: --reason given twice", USAGE);
|
|
29
|
+
reason = value;
|
|
30
|
+
}
|
|
31
|
+
else if (arg.startsWith("--")) {
|
|
32
|
+
cannotAnswer(`tombstone: unknown flag ${arg}`, USAGE);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
positional.push(arg);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const [label, genId] = positional;
|
|
39
|
+
if (label === undefined || genId === undefined || positional.length !== 2)
|
|
40
|
+
cannotAnswer("tombstone: expected exactly <label> <genId>", USAGE);
|
|
41
|
+
if (reason === null || reason.trim().length === 0)
|
|
42
|
+
cannotAnswer("tombstone: --reason <text> is required (why is this lineage culled?)", USAGE);
|
|
43
|
+
if (reason.length > MAX_REASON_CHARS)
|
|
44
|
+
cannotAnswer(`tombstone: --reason exceeds ${String(MAX_REASON_CHARS)} chars`, USAGE);
|
|
45
|
+
return { label, genId, reason };
|
|
46
|
+
}
|
|
47
|
+
function runTombstone(args) {
|
|
48
|
+
const { label, genId, reason } = parseArgs(args);
|
|
49
|
+
const configDir = resolveConfigDir();
|
|
50
|
+
const entry = resolveUniqueEntry(configDir, label, "tombstone");
|
|
51
|
+
const ledger = Ledger.open(entry.spec.repoPath);
|
|
52
|
+
const records = ledger.readAll();
|
|
53
|
+
if (!records.some((r) => r.kind === "generation_complete" && r.genId === genId)) {
|
|
54
|
+
blocked(`tombstone: no generation_complete ledger row for gen '${genId}' of '${label}' — nothing to bury`);
|
|
55
|
+
}
|
|
56
|
+
const promoted = records.find((r) => r.kind === "promote" && r.genId === genId);
|
|
57
|
+
if (promoted !== undefined) {
|
|
58
|
+
blocked(`tombstone: gen '${genId}' was already promoted at ${promoted.ts} — cull is for candidates the human never accepted`);
|
|
59
|
+
}
|
|
60
|
+
const buried = records.find((r) => r.kind === LEDGER_KIND_TOMBSTONE && r.genId === genId);
|
|
61
|
+
if (buried !== undefined) {
|
|
62
|
+
blocked(`tombstone: gen '${genId}' is already tombstoned at ${buried.ts}`);
|
|
63
|
+
}
|
|
64
|
+
ledger.append({ kind: LEDGER_KIND_TOMBSTONE, genId, data: { genId, reason, actor: "cli" } });
|
|
65
|
+
writeStdout(`tombstone recorded: '${genId}' (reason: ${echoed(reason)}) — commits and lineage preserved (cull ≠ delete)`);
|
|
66
|
+
return EXIT_OK;
|
|
67
|
+
}
|
|
68
|
+
export const tombstoneCommand = {
|
|
69
|
+
name: "tombstone",
|
|
70
|
+
summary: "human gate: bury a candidate lineage with a reason (append-only; never deletes)",
|
|
71
|
+
run: ({ args }) => runTombstone(args),
|
|
72
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Config resolution (todo 1 contract):
|
|
2
|
+
// file: $ABATHUR_CONFIG (fail-closed if set) > ~/.config/abathur/config.jsonc
|
|
3
|
+
// > <package>/config/abathur.jsonc > built-in defaults
|
|
4
|
+
// overlay: sibling `<base>.local.jsonc` (gitignored *.local.jsonc) deep-merged
|
|
5
|
+
// over the base before strict validation — machine-local values live there.
|
|
6
|
+
// Every failure throws ConfigError, which the CLI boundary maps to exit 2.
|
|
7
|
+
import { readFileSync, statSync } from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { parseJsonc } from "./jsonc.js";
|
|
13
|
+
// JSONC normalization seam — re-exported so consumers/tests import it from the
|
|
14
|
+
// config module (parser implementation lives in src/jsonc.ts).
|
|
15
|
+
export { stripJsonc } from "./jsonc.js";
|
|
16
|
+
export const configSchema = z.strictObject({
|
|
17
|
+
/** Absolute path to the `opencode` CLI binary; null = resolve from PATH at call time. */
|
|
18
|
+
opencodeBin: z.string().min(1).nullable().default(null),
|
|
19
|
+
/** Data home (ledger, registry, sandboxes); null = $ABATHUR_HOME else ~/.local/share/abathur. */
|
|
20
|
+
stateDir: z.string().min(1).nullable().default(null),
|
|
21
|
+
});
|
|
22
|
+
export class ConfigError extends Error {
|
|
23
|
+
kind;
|
|
24
|
+
filePath;
|
|
25
|
+
keys;
|
|
26
|
+
constructor(kind, message, filePath, keys) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.kind = kind;
|
|
29
|
+
this.filePath = filePath;
|
|
30
|
+
this.keys = keys;
|
|
31
|
+
this.name = "ConfigError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function errorMessage(cause) {
|
|
35
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
36
|
+
}
|
|
37
|
+
function isPlainObject(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function isReadableFile(candidate) {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(candidate).isFile();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Shipped read-only defaults, co-located with the package (package.json "files"). */
|
|
49
|
+
const repoConfigPath = fileURLToPath(new URL("../config/abathur.jsonc", import.meta.url));
|
|
50
|
+
function resolveHome(env) {
|
|
51
|
+
return env.HOME === undefined || env.HOME.length === 0 ? os.homedir() : env.HOME;
|
|
52
|
+
}
|
|
53
|
+
function userConfigPath(env) {
|
|
54
|
+
return path.join(resolveHome(env), ".config", "abathur", "config.jsonc");
|
|
55
|
+
}
|
|
56
|
+
function readJsoncDocument(filePath) {
|
|
57
|
+
let text;
|
|
58
|
+
try {
|
|
59
|
+
text = readFileSync(filePath, "utf8");
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new ConfigError("unreadable", `config: cannot read ${filePath}`, filePath);
|
|
63
|
+
}
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = parseJsonc(text);
|
|
67
|
+
}
|
|
68
|
+
catch (cause) {
|
|
69
|
+
throw new ConfigError("malformed", `config: malformed JSONC in ${filePath}: ${errorMessage(cause)}`, filePath);
|
|
70
|
+
}
|
|
71
|
+
if (!isPlainObject(parsed)) {
|
|
72
|
+
throw new ConfigError("malformed", `config: expected a top-level object in ${filePath}`, filePath);
|
|
73
|
+
}
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
// -------------------------------------------------------------- merge + validate
|
|
77
|
+
/** Plain objects merge recursively; arrays and scalars are replaced wholesale (overlay wins). */
|
|
78
|
+
export function deepMerge(base, overlay) {
|
|
79
|
+
const out = { ...base };
|
|
80
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
81
|
+
const existing = out[key];
|
|
82
|
+
out[key] =
|
|
83
|
+
isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function validateConfig(doc, origin) {
|
|
88
|
+
const result = configSchema.safeParse(doc);
|
|
89
|
+
if (result.success)
|
|
90
|
+
return result.data;
|
|
91
|
+
for (const issue of result.error.issues) {
|
|
92
|
+
if (issue.code === "unrecognized_keys") {
|
|
93
|
+
const listed = issue.keys.map((key) => `"${key}"`).join(", ");
|
|
94
|
+
throw new ConfigError("unknown-key", `config: unknown ${issue.keys.length === 1 ? "key" : "keys"} ${listed} in ${origin} — ` +
|
|
95
|
+
`allowed: ${Object.keys(configSchema.shape).join(", ")}`, origin, issue.keys);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const [first] = result.error.issues;
|
|
99
|
+
const where = first === undefined || first.path.length === 0 ? "<root>" : first.path.join(".");
|
|
100
|
+
const why = first === undefined ? "schema rejected the document" : first.message;
|
|
101
|
+
throw new ConfigError("invalid-value", `config: invalid value for "${where}" in ${origin}: ${why}`, origin);
|
|
102
|
+
}
|
|
103
|
+
// ------------------------------------------------------------------ resolution
|
|
104
|
+
/**
|
|
105
|
+
* First existing file in: $ABATHUR_CONFIG (fail-closed when set) >
|
|
106
|
+
* ~/.config/abathur/config.jsonc > <package>/config/abathur.jsonc; null = built-in defaults.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveConfigPath(env = process.env) {
|
|
109
|
+
const raw = env.ABATHUR_CONFIG;
|
|
110
|
+
if (raw !== undefined) {
|
|
111
|
+
if (raw.length === 0) {
|
|
112
|
+
throw new ConfigError("unreadable", "config: $ABATHUR_CONFIG is set but empty", raw);
|
|
113
|
+
}
|
|
114
|
+
if (!isReadableFile(raw)) {
|
|
115
|
+
throw new ConfigError("unreadable", `config: $ABATHUR_CONFIG points to an unreadable file: ${raw}`, raw);
|
|
116
|
+
}
|
|
117
|
+
return raw;
|
|
118
|
+
}
|
|
119
|
+
const user = userConfigPath(env);
|
|
120
|
+
if (isReadableFile(user))
|
|
121
|
+
return user;
|
|
122
|
+
if (isReadableFile(repoConfigPath))
|
|
123
|
+
return repoConfigPath;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Directory owning the writable config state (genome registry, todo 4+):
|
|
128
|
+
* parent of $ABATHUR_CONFIG when set, else the canonical ~/.config/abathur —
|
|
129
|
+
* which may not exist yet; callers mkdir -p on write. The shipped repo default
|
|
130
|
+
* under <package>/config/ is read-only and never used as a data dir.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveConfigDir(env = process.env) {
|
|
133
|
+
const raw = env.ABATHUR_CONFIG;
|
|
134
|
+
if (raw !== undefined && raw.length > 0)
|
|
135
|
+
return path.dirname(path.resolve(raw));
|
|
136
|
+
return path.join(resolveHome(env), ".config", "abathur");
|
|
137
|
+
}
|
|
138
|
+
function overlayPathFor(basePath) {
|
|
139
|
+
return basePath.endsWith(".jsonc")
|
|
140
|
+
? `${basePath.slice(0, -".jsonc".length)}.local.jsonc`
|
|
141
|
+
: `${basePath}.local.jsonc`;
|
|
142
|
+
}
|
|
143
|
+
/** Read base + `*.local.jsonc` overlay, deep-merge, JSONC-parse, zod-strict-validate. Throws ConfigError (→ exit 2). */
|
|
144
|
+
export function loadConfig(env = process.env) {
|
|
145
|
+
const filePath = resolveConfigPath(env);
|
|
146
|
+
if (filePath === null) {
|
|
147
|
+
return {
|
|
148
|
+
config: validateConfig({}, "<abathur built-in defaults>"),
|
|
149
|
+
path: null,
|
|
150
|
+
overlayPath: null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const base = readJsoncDocument(filePath);
|
|
154
|
+
const overlay = overlayPathFor(filePath);
|
|
155
|
+
if (!isReadableFile(overlay)) {
|
|
156
|
+
return { config: validateConfig(base, filePath), path: filePath, overlayPath: null };
|
|
157
|
+
}
|
|
158
|
+
const merged = deepMerge(base, readJsoncDocument(overlay));
|
|
159
|
+
const origin = `${filePath} (plus overlay ${overlay})`;
|
|
160
|
+
return { config: validateConfig(merged, origin), path: filePath, overlayPath: overlay };
|
|
161
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Shared lineage-bundle plumbing (todo 12): tree access, contained-spec parsing,
|
|
2
|
+
// benchDigest reconstruction and mask-plan construction — one implementation so
|
|
3
|
+
// export and inspect can never drift apart.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { cannotAnswer } from "../exit.js";
|
|
6
|
+
import { parseJsonc } from "../jsonc.js";
|
|
7
|
+
import { benchDigest } from "./ids.js";
|
|
8
|
+
import { genomeSpecSchema } from "./spec.js";
|
|
9
|
+
import { buildMaskPlan } from "./bundle-mask.js";
|
|
10
|
+
export const EVIDENCE_CAP_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
export const TREES_PREFIX = "trees/";
|
|
12
|
+
export const EVIDENCE_PREFIX = "evidence/";
|
|
13
|
+
export const sha256Hex = (data) => createHash("sha256").update(data).digest("hex");
|
|
14
|
+
/** trees/<genId>/... member paths → the generation ids shipped in this bundle. */
|
|
15
|
+
export function bundleGenIds(members) {
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
for (const m of members) {
|
|
18
|
+
if (!m.path.startsWith(TREES_PREFIX))
|
|
19
|
+
continue;
|
|
20
|
+
const rest = m.path.slice(TREES_PREFIX.length);
|
|
21
|
+
const slash = rest.indexOf("/");
|
|
22
|
+
if (slash > 0)
|
|
23
|
+
ids.add(rest.slice(0, slash));
|
|
24
|
+
}
|
|
25
|
+
return [...ids].sort();
|
|
26
|
+
}
|
|
27
|
+
/** The primary generation — lexicographically greatest genId (ids embed a UTC
|
|
28
|
+
* timestamp, so this is the newest). Export and inspect share this rule. */
|
|
29
|
+
export function primaryGenId(genIds) {
|
|
30
|
+
const primary = [...genIds].sort().at(-1);
|
|
31
|
+
if (primary === undefined)
|
|
32
|
+
cannotAnswer("bundle: no trees/<genId>/ generation content inside");
|
|
33
|
+
return primary;
|
|
34
|
+
}
|
|
35
|
+
/** Rel-path → bytes map for one generation's tree members. */
|
|
36
|
+
export function treeOf(members, genId) {
|
|
37
|
+
const prefix = `${TREES_PREFIX}${genId}/`;
|
|
38
|
+
const tree = new Map();
|
|
39
|
+
for (const m of members)
|
|
40
|
+
if (m.path.startsWith(prefix))
|
|
41
|
+
tree.set(m.path.slice(prefix.length), m.content);
|
|
42
|
+
return tree;
|
|
43
|
+
}
|
|
44
|
+
export function parseSpecMember(bytes, origin) {
|
|
45
|
+
let document;
|
|
46
|
+
try {
|
|
47
|
+
document = parseJsonc(new TextDecoder().decode(bytes));
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
return cannotAnswer(`bundle: malformed JSONC in ${origin}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
51
|
+
}
|
|
52
|
+
const parsed = genomeSpecSchema.safeParse(document);
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
cannotAnswer(`bundle: ${origin} is not a valid GenomeSpec: ${String(parsed.error.issues[0]?.message ?? "schema")}`);
|
|
55
|
+
}
|
|
56
|
+
return parsed.data;
|
|
57
|
+
}
|
|
58
|
+
export function containedSpec(tree, genId) {
|
|
59
|
+
const specBytes = tree.get("genome.jsonc");
|
|
60
|
+
if (specBytes === undefined)
|
|
61
|
+
cannotAnswer(`bundle: trees/${genId}/genome.jsonc missing — cannot self-describe the genome`);
|
|
62
|
+
return parseSpecMember(specBytes, `trees/${genId}/genome.jsonc`);
|
|
63
|
+
}
|
|
64
|
+
const SCRIPT_TOKEN = /^[A-Za-z0-9._/-]+\.(?:mjs|cjs|js|py|sh)$/;
|
|
65
|
+
/** File-operand tokens of a command template that exist in the generation tree.
|
|
66
|
+
* {placeholder} tokens never match, so unit paths stay out. */
|
|
67
|
+
export function commandScriptPaths(template) {
|
|
68
|
+
return template
|
|
69
|
+
.split(/\s+/)
|
|
70
|
+
.map((tok) => tok.replace(/^["']|["'],?$/g, ""))
|
|
71
|
+
.filter((tok) => SCRIPT_TOKEN.test(tok) && !tok.includes("{"));
|
|
72
|
+
}
|
|
73
|
+
export function benchScriptsFor(spec, tree) {
|
|
74
|
+
const templates = [spec.bench.graderCommand, spec.bench.seedCommand, spec.bench.resetCommand];
|
|
75
|
+
const byPath = new Map();
|
|
76
|
+
for (const template of templates) {
|
|
77
|
+
if (template === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
for (const p of commandScriptPaths(template)) {
|
|
80
|
+
const content = tree.get(p);
|
|
81
|
+
if (content !== undefined && !byPath.has(p))
|
|
82
|
+
byPath.set(p, { path: p, content });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [...byPath.values()];
|
|
86
|
+
}
|
|
87
|
+
/** benchDigest recomputed from a generation's contained tree + spec (shared by
|
|
88
|
+
* export "recompute at build" and inspect "re-verify"). */
|
|
89
|
+
export function benchDigestFor(spec, tree, genId) {
|
|
90
|
+
const units = spec.bench.units.map((unit) => {
|
|
91
|
+
const content = tree.get(unit.path);
|
|
92
|
+
if (content === undefined)
|
|
93
|
+
cannotAnswer(`bundle: bench unit '${unit.id}' file '${unit.path}' absent from trees/${genId}/`);
|
|
94
|
+
return { unitId: unit.id, content };
|
|
95
|
+
});
|
|
96
|
+
return benchDigest({
|
|
97
|
+
units,
|
|
98
|
+
scripts: benchScriptsFor(spec, tree),
|
|
99
|
+
graderCommand: spec.bench.graderCommand,
|
|
100
|
+
runCommand: spec.bench.runCommand,
|
|
101
|
+
...(spec.bench.judgeCommand === undefined ? {} : { judgeCommand: spec.bench.judgeCommand }),
|
|
102
|
+
...(spec.bench.agentModel === undefined ? {} : { agentModel: spec.bench.agentModel }),
|
|
103
|
+
...(spec.bench.judgeModel === undefined ? {} : { judgeModel: spec.bench.judgeModel }),
|
|
104
|
+
timeoutS: spec.bench.timeoutS,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export function maskPlanFor(spec, home) {
|
|
108
|
+
return buildMaskPlan({ home, repoPath: spec.repoPath, extra: spec.bundle?.maskLiterals ?? [] });
|
|
109
|
+
}
|
|
110
|
+
/** A runId is r-<genId>-<unitId>-<rep>; recover the rep and validate path safety. */
|
|
111
|
+
export function repOfRunId(runId, genId, unitId) {
|
|
112
|
+
const prefix = `r-${genId}-${unitId}-`;
|
|
113
|
+
const rep = runId.startsWith(prefix) ? runId.slice(prefix.length) : "";
|
|
114
|
+
if (!/^\d+$/.test(rep)) {
|
|
115
|
+
cannotAnswer(`bundle: ledger runId '${runId}' does not match unit '${unitId}' of gen ${genId}`);
|
|
116
|
+
}
|
|
117
|
+
return rep;
|
|
118
|
+
}
|
|
119
|
+
export const PATH_SAFE = /^[A-Za-z0-9._-]+$/;
|