@tachikomagundam/abathur 0.2.6 → 0.2.7
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/README.md +3 -3
- package/dist/cli.js +4 -0
- package/dist/commands/readjudicate.js +28 -0
- package/dist/commands/retract.js +53 -0
- package/dist/commands/tombstone.js +28 -15
- package/dist/core/evolve/run-rows.js +14 -0
- package/dist/core/ledger.js +2 -0
- package/dist/core/promote.js +61 -46
- package/dist/core/readjudicate.js +247 -0
- package/dist/core/retract.js +166 -0
- package/dist/core/worktree.js +1 -1
- package/dist/genomes/toy-smoke/init.mjs +1 -1
- package/dist/test/bundle.test.js +1 -1
- package/dist/test/fixture-loop.test.js +1 -1
- package/dist/test/fixtures-self.js +1 -1
- package/dist/test/fixtures-wt.js +1 -1
- package/dist/test/friction.test.js +3 -3
- package/dist/test/graft.test.js +1 -1
- package/dist/test/historian-run-scenario.test.js +2 -2
- package/dist/test/promote.test.js +222 -5
- package/dist/test/readjudicate.test.js +212 -0
- package/dist/test/reflect.test.js +1 -1
- package/dist/test/repopath-seams.test.js +1 -0
- package/dist/test/seam-gates.test.js +6 -0
- package/dist/test/self-snapshot.test.js +2 -2
- package/graders/historian/judge-poststage.mjs +8 -3
- package/package.json +1 -1
- package/plugin/abathur-command.md +3 -3
- package/plugin/abathur.ts +6 -6
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// retractGeneration — the human gate that undoes a promote (design gap found
|
|
2
|
+
// 2026-09-28 when the human ordered a rollback of self-cli and the engine had
|
|
3
|
+
// no path: tombstone refuses promoted gens by design, incumbent.ts moves the
|
|
4
|
+
// ref forward-only). Doctrine:
|
|
5
|
+
// - History is append-only: the promote row STAYS; retract appends its own
|
|
6
|
+
// row. No commit moves, no lineage deletes (retract ≠ delete).
|
|
7
|
+
// - LIFO only: a later LIVE promote must be retracted first.
|
|
8
|
+
// - Descendant guard: a generation seeded FROM this incumbent (a
|
|
9
|
+
// generation_complete row appended after the promote row) blocks the
|
|
10
|
+
// retract unless its lineage is dead (tombstoned or retracted).
|
|
11
|
+
// - ref-absent is NOT fail-open: when the incumbent ref is already gone (a
|
|
12
|
+
// documented manual rollback), the retract is recordable only when the gen
|
|
13
|
+
// being retracted is the ONLY live promote — anything else is a divergence
|
|
14
|
+
// the human must resolve first. The row then carries refState:"absent".
|
|
15
|
+
// - The incumbent branch must be idle in EVERY worktree (worktree list
|
|
16
|
+
// --porcelain), not just the primary checkout.
|
|
17
|
+
// - The whole read→guard→mutate→append sequence runs under the genome lock
|
|
18
|
+
// plus a repo-local gate lock (cross-config-home safety). The descendant
|
|
19
|
+
// guard is point-in-time BY DESIGN: a run seeded from this incumbent before
|
|
20
|
+
// the retract may still append its generation_complete afterwards — that row
|
|
21
|
+
// is honest and its commits are preserved (retract ≠ delete); what the lock
|
|
22
|
+
// guarantees is that no decision RACES the retract, not that no lineage
|
|
23
|
+
// outlives it.
|
|
24
|
+
// Import policy mirrors core/promote.ts: this module is imported ONLY by
|
|
25
|
+
// src/commands/retract.ts (pinned by the structural test in promote.test.ts).
|
|
26
|
+
// No force flag: every refusal is a bug report, not a flag.
|
|
27
|
+
import { existsSync, rmSync } from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { blocked, cannotAnswer } from "../exit.js";
|
|
30
|
+
import { tryGit } from "../util/git.js";
|
|
31
|
+
import { gitOpts } from "./genome-paths.js";
|
|
32
|
+
import { INCUMBENT_BRANCH } from "./incumbent.js";
|
|
33
|
+
import { manifestPathFor } from "./kernel.js";
|
|
34
|
+
import { acquireGenomeLock, acquireLock, LEDGER_KIND_PROMOTE, LEDGER_KIND_TOMBSTONE, Ledger } from "./ledger.js";
|
|
35
|
+
import { effectiveRepoPath } from "./spec.js";
|
|
36
|
+
export const LEDGER_KIND_RETRACT = "retract";
|
|
37
|
+
/** Every worktree (primary + linked) that has the incumbent branch checked out. */
|
|
38
|
+
async function incumbentCheckouts(repo, opts) {
|
|
39
|
+
const run = await tryGit(["worktree", "list", "--porcelain"], gitOpts(opts, repo));
|
|
40
|
+
if (!run.ok)
|
|
41
|
+
cannotAnswer(`retract: cannot enumerate worktrees in ${repo}: ${run.error.kind}`);
|
|
42
|
+
// entries are blank-line-separated blocks; attributes (branch, prunable …)
|
|
43
|
+
// come AFTER the worktree line, so evaluate whole blocks, not a line stream
|
|
44
|
+
const busy = [];
|
|
45
|
+
for (const block of run.stdout.split("\n\n")) {
|
|
46
|
+
const lines = block.split("\n");
|
|
47
|
+
const wt = lines.find((l) => l.startsWith("worktree "));
|
|
48
|
+
if (wt === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
// "prunable <reason>" = stale metadata for a deleted directory — it must
|
|
51
|
+
// not permanently block the rollback valve
|
|
52
|
+
if (lines.some((l) => l.startsWith("prunable")))
|
|
53
|
+
continue;
|
|
54
|
+
if (lines.some((l) => l === `branch refs/heads/${INCUMBENT_BRANCH}`))
|
|
55
|
+
busy.push(wt.slice("worktree ".length));
|
|
56
|
+
}
|
|
57
|
+
return busy;
|
|
58
|
+
}
|
|
59
|
+
export async function retractGeneration(req) {
|
|
60
|
+
const repo = effectiveRepoPath(req.entry.spec.repoPath);
|
|
61
|
+
const opts = req.env === undefined ? {} : { env: req.env };
|
|
62
|
+
const gopts = gitOpts(opts, repo);
|
|
63
|
+
const ledger = Ledger.open(repo);
|
|
64
|
+
const lease = acquireGenomeLock({ ledger, configDir: req.configDir, genomeFp: req.entry.fingerprint });
|
|
65
|
+
try {
|
|
66
|
+
// the genome lock is keyed (configDir, fingerprint): two registry entries or two
|
|
67
|
+
// config homes pointing at the SAME repo hold different locks. The ledger and the
|
|
68
|
+
// incumbent ref are repo-global, so the gate sequence also takes a REPO-local
|
|
69
|
+
// lock — same lock regardless of which config home the caller came through.
|
|
70
|
+
// Nested inside the genome-lock try so a contended repo lock never strands it.
|
|
71
|
+
const repoLease = acquireLock({ configDir: path.join(repo, ".state", "abathur"), key: "gate", label: "gate lock" });
|
|
72
|
+
try {
|
|
73
|
+
const records = ledger.readAll();
|
|
74
|
+
const promoteIndex = records.findIndex((r) => r.kind === LEDGER_KIND_PROMOTE && r.genId === req.genId);
|
|
75
|
+
if (promoteIndex < 0) {
|
|
76
|
+
blocked(`retract: no promote ledger row for gen '${req.genId}' of '${req.entry.label}' — nothing to retract`, "retract undoes a promote; for a never-promoted candidate use `abathur tombstone`");
|
|
77
|
+
}
|
|
78
|
+
const promoteRow = records[promoteIndex];
|
|
79
|
+
if (records.some((r) => r.kind === LEDGER_KIND_RETRACT && r.genId === req.genId)) {
|
|
80
|
+
blocked(`retract: gen '${req.genId}' is already retracted — nothing to do`);
|
|
81
|
+
}
|
|
82
|
+
// a lineage is dead once tombstoned (never promoted) or retracted (promoted,
|
|
83
|
+
// then undone): it no longer sits on the incumbent chain.
|
|
84
|
+
const released = new Set(records.filter((r) => r.kind === LEDGER_KIND_TOMBSTONE || r.kind === LEDGER_KIND_RETRACT).map((r) => r.genId));
|
|
85
|
+
const livePromotes = records.filter((r) => r.kind === LEDGER_KIND_PROMOTE && !released.has(r.genId));
|
|
86
|
+
const later = records.slice(promoteIndex + 1);
|
|
87
|
+
if (later.some((r) => r.kind === LEDGER_KIND_PROMOTE && !released.has(r.genId))) {
|
|
88
|
+
blocked(`retract: a later promote exists for '${req.entry.label}' — retract is LIFO`, "retract the later generation first, then re-run this retract");
|
|
89
|
+
}
|
|
90
|
+
const orphans = later.filter((r) => r.kind === "generation_complete" && !released.has(r.genId));
|
|
91
|
+
if (orphans.length > 0) {
|
|
92
|
+
blocked(`retract: ${String(orphans.length)} generation(s) were seeded from this incumbent after the promote — retracting would orphan them`, "let them conclude and tombstone the culled ones first, then re-run this retract");
|
|
93
|
+
}
|
|
94
|
+
const to = promoteRow.data.to;
|
|
95
|
+
if (typeof to !== "string" || !/^[0-9a-f]{40}$/.test(to)) {
|
|
96
|
+
cannotAnswer(`retract: promote row for '${req.genId}' lacks a full commit sha — ledger inconsistent, refusing`);
|
|
97
|
+
}
|
|
98
|
+
const busy = await incumbentCheckouts(repo, opts);
|
|
99
|
+
if (busy.length > 0) {
|
|
100
|
+
blocked(`retract: ${INCUMBENT_BRANCH} is checked out in ${busy.join(", ")} — retract needs it idle`, "switch that worktree to another branch first; if the listing is stale, run `git worktree prune` in the genome repo");
|
|
101
|
+
}
|
|
102
|
+
// for-each-ref: ok+empty = genuinely absent; ok+sha = present; !ok = git
|
|
103
|
+
// itself failed (corruption/lock) — never launder a git failure into "absent"
|
|
104
|
+
const ref = await tryGit(["for-each-ref", "--format=%(objectname)", `refs/heads/${INCUMBENT_BRANCH}`], gopts);
|
|
105
|
+
if (!ref.ok) {
|
|
106
|
+
cannotAnswer(`retract: cannot read ${INCUMBENT_BRANCH} in ${repo}: ${ref.error.kind} — nothing was touched`);
|
|
107
|
+
}
|
|
108
|
+
let refState;
|
|
109
|
+
let removedSha = null;
|
|
110
|
+
const current = ref.stdout.trim();
|
|
111
|
+
if (current !== "") {
|
|
112
|
+
if (current !== to) {
|
|
113
|
+
blocked(`retract: ${INCUMBENT_BRANCH} now points at ${current.slice(0, 12)} but the promote row recorded ${to.slice(0, 12)} — state diverged, refusing`, "investigate with `git -C <repo> log abathur/incumbent` and the ledger before any manual surgery");
|
|
114
|
+
}
|
|
115
|
+
// compare-and-delete: the old value pins what we verified, so a ref moved
|
|
116
|
+
// between the check and the delete makes git refuse instead of clobbering
|
|
117
|
+
const del = await tryGit(["update-ref", "-d", `refs/heads/${INCUMBENT_BRANCH}`, current], gopts);
|
|
118
|
+
if (!del.ok) {
|
|
119
|
+
blocked(`retract: ${INCUMBENT_BRANCH} moved between verification and deletion — refusing rather than deleting an unverified ref`, "re-run the retract; if it keeps failing, inspect the ref with git directly");
|
|
120
|
+
}
|
|
121
|
+
removedSha = current;
|
|
122
|
+
refState = "deleted";
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// Manual-rollback path (documented 2026-09-28): recordable ONLY when this
|
|
126
|
+
// gen is the sole live promote — otherwise the absent ref means a
|
|
127
|
+
// divergence retract must not launder into an official-looking row.
|
|
128
|
+
if (livePromotes.length !== 1) {
|
|
129
|
+
blocked(`retract: ${INCUMBENT_BRANCH} is absent but ${String(livePromotes.length)} live promotes exist — cannot verify what the retract undoes`, "restore the ref to the promoted commit (or resolve the divergence) before retracting");
|
|
130
|
+
}
|
|
131
|
+
refState = "absent";
|
|
132
|
+
}
|
|
133
|
+
const manifestFile = manifestPathFor(req.configDir, req.entry.fingerprint);
|
|
134
|
+
const manifestBefore = existsSync(manifestFile);
|
|
135
|
+
rmSync(manifestFile, { force: true });
|
|
136
|
+
const manifestGone = !existsSync(manifestFile);
|
|
137
|
+
// verbatim in the ledger (JSON-escaped on disk); printable-ASCII echo only
|
|
138
|
+
ledger.append({
|
|
139
|
+
kind: LEDGER_KIND_RETRACT,
|
|
140
|
+
genId: req.genId,
|
|
141
|
+
data: { actor: "cli", genId: req.genId, reason: req.reason, retractedTo: to, refState },
|
|
142
|
+
});
|
|
143
|
+
const flat = req.reason.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
|
|
144
|
+
const echoReason = flat.length > 120 ? `${flat.slice(0, 120)}…` : flat;
|
|
145
|
+
const lines = [
|
|
146
|
+
`retract recorded: '${req.genId}' (reason: ${echoReason})`,
|
|
147
|
+
removedSha === null
|
|
148
|
+
? `${INCUMBENT_BRANCH} was already absent (manual rollback) — recorded with refState=absent`
|
|
149
|
+
: `${INCUMBENT_BRANCH} removed (was ${removedSha.slice(0, 12)}) — genome returns to incumbent: none`,
|
|
150
|
+
manifestBefore && manifestGone
|
|
151
|
+
? `kernel manifest cleared: ${manifestFile}`
|
|
152
|
+
: !manifestBefore && manifestGone
|
|
153
|
+
? `kernel manifest already absent: ${manifestFile}`
|
|
154
|
+
: `kernel manifest REMOVAL UNVERIFIED: ${manifestFile} still exists — investigate`,
|
|
155
|
+
`the promote row remains in the append-only ledger; candidate commit ${to.slice(0, 12)} preserved (retract ≠ delete)`,
|
|
156
|
+
];
|
|
157
|
+
return { genId: req.genId, lines };
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
repoLease.release();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
lease.release();
|
|
165
|
+
}
|
|
166
|
+
}
|
package/dist/core/worktree.js
CHANGED
|
@@ -23,7 +23,7 @@ export { snapshotCommit } from "./snapshot.js";
|
|
|
23
23
|
export { INCUMBENT_BRANCH, fastForwardIncumbent } from "./incumbent.js";
|
|
24
24
|
const DAY_MS = 86_400_000;
|
|
25
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=
|
|
26
|
+
const SEAL_IDENTITY = ["-c", "user.name=abathur", "-c", "user.email=agent@host.example"];
|
|
27
27
|
// ------------------------------------------------------------------ openGenome
|
|
28
28
|
/** Porcelain v1 with -z: "XY path" records; rename/copy records carry the source as an extra NUL field. */
|
|
29
29
|
function parsePorcelain(raw) {
|
|
@@ -47,7 +47,7 @@ if (!existsSync(path.join(dest, ".git"))) {
|
|
|
47
47
|
git(["add", "-A"]);
|
|
48
48
|
// identity pinned via -c BEFORE the subcommand (global gitconfig immune;
|
|
49
49
|
// `git init` itself rejects -c after the subcommand — learnings todo 3).
|
|
50
|
-
git(["-c", "user.name=abathur", "-c", "user.email=
|
|
50
|
+
git(["-c", "user.name=abathur", "-c", "user.email=agent@host.example", "commit", "-m", "toy-smoke fixture seed"]);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
process.stdout.write(`${dest}\n`);
|
package/dist/test/bundle.test.js
CHANGED
|
@@ -81,7 +81,7 @@ async function bundleFixture(t, opts = {}) {
|
|
|
81
81
|
writeFileSync(path.join(repo, "genome.jsonc"), `${JSON.stringify({ ...spec, bundle: { maskLiterals: [...opts.maskLiterals] } }, null, 2)}\n`, "utf8");
|
|
82
82
|
// commit the edit: export self-describes from the gen COMMIT tree, so the
|
|
83
83
|
// registry spec and the committed spec must agree by construction.
|
|
84
|
-
gitC(repo, "-c", "user.name=abathur", "-c", "user.email=
|
|
84
|
+
gitC(repo, "-c", "user.name=abathur", "-c", "user.email=agent@host.example", "commit", "-am", "bundle: declare maskLiterals");
|
|
85
85
|
}
|
|
86
86
|
registerGenome(configDir, path.join(repo, "genome.jsonc"));
|
|
87
87
|
const entry = requireGenomesByLabel(configDir, "toy-smoke").entries[0];
|
|
@@ -149,7 +149,7 @@ async function fixtureLoop(t) {
|
|
|
149
149
|
git(repo, ["init", "-b", "main"]);
|
|
150
150
|
git(repo, ["add", "-A"]);
|
|
151
151
|
// identity pinned via -c BEFORE the subcommand (toy init.mjs convention)
|
|
152
|
-
git(repo, ["-c", "user.name=abathur", "-c", "user.email=
|
|
152
|
+
git(repo, ["-c", "user.name=abathur", "-c", "user.email=agent@host.example", "commit", "-m", "fixloop seed"]);
|
|
153
153
|
registerGenome(configDir, specPath);
|
|
154
154
|
const entry = requireGenomesByLabel(configDir, "fixloop-germ").entries[0];
|
|
155
155
|
if (entry === undefined)
|
|
@@ -42,7 +42,7 @@ test("self-sanity: stub mutators deterministic", () => {
|
|
|
42
42
|
});
|
|
43
43
|
`;
|
|
44
44
|
function git(cwd, ...args) {
|
|
45
|
-
return execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=
|
|
45
|
+
return execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=agent@host.example", "-C", cwd, ...args], {
|
|
46
46
|
encoding: "utf8",
|
|
47
47
|
env: { ...process.env, LC_ALL: "C" },
|
|
48
48
|
}).trim();
|
package/dist/test/fixtures-wt.js
CHANGED
|
@@ -250,7 +250,7 @@ test("AC-2: canary planted in the val unit NEVER appears in the friction queue",
|
|
|
250
250
|
assert.deepEqual(val, { count: 1, aliases: ["val-1"], samples: 4 }, "scrubbed val material is present as counts/aliases");
|
|
251
251
|
});
|
|
252
252
|
function gitIn(repo, ...args) {
|
|
253
|
-
execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=
|
|
253
|
+
execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=agent@host.example", "-C", repo, ...args], { stdio: "pipe" });
|
|
254
254
|
}
|
|
255
255
|
test("friction: run CLI on drifted kernel records cause cli-error (spawned exit 1)", (t) => {
|
|
256
256
|
const root = mkdtempSync(path.join(os.tmpdir(), "abathur-drift-"));
|
|
@@ -264,8 +264,8 @@ test("friction: run CLI on drifted kernel records cause cli-error (spawned exit
|
|
|
264
264
|
touchSeal(repo, ["units/add.mjs", "units/mul.mjs", "units/explode.mjs", "units/sub.mjs", "grader.mjs"]);
|
|
265
265
|
writeFileSync(path.join(repo, "genome.jsonc"), toySpec(repo), "utf8");
|
|
266
266
|
execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" });
|
|
267
|
-
execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=
|
|
268
|
-
execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=
|
|
267
|
+
execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=agent@host.example", "-C", repo, "add", "-A"], { stdio: "pipe" });
|
|
268
|
+
execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=agent@host.example", "-C", repo, "commit", "-m", "genome"], { stdio: "pipe" });
|
|
269
269
|
registerGenome(configDir, path.join(repo, "genome.jsonc"));
|
|
270
270
|
// drift the sealed grader AFTER registration:
|
|
271
271
|
writeFileSync(path.join(repo, "grader.mjs"), "// tampered\n", "utf8");
|
package/dist/test/graft.test.js
CHANGED
|
@@ -96,7 +96,7 @@ async function makeInstance(s, name, opts = {}) {
|
|
|
96
96
|
...(opts.requires === undefined ? {} : { requires: opts.requires }),
|
|
97
97
|
};
|
|
98
98
|
writeFileSync(path.join(repo, "genome.jsonc"), `${JSON.stringify(edited, null, 2)}\n`, "utf8");
|
|
99
|
-
gitC(repo, "-c", "user.name=abathur", "-c", "user.email=
|
|
99
|
+
gitC(repo, "-c", "user.name=abathur", "-c", "user.email=agent@host.example", "commit", "-am", "graft fixture: spec edit");
|
|
100
100
|
}
|
|
101
101
|
registerGenome(configDir, path.join(repo, "genome.jsonc"));
|
|
102
102
|
const entry = requireGenomesByLabel(configDir, "toy-smoke").entries[0];
|
|
@@ -188,12 +188,12 @@ test("model pin: ABATHUR_AGENT_MODEL set ⇒ argv carries --model <value>, brief
|
|
|
188
188
|
const argvFile = path.join(path.dirname(h.promptFile), "argv-pinned.txt");
|
|
189
189
|
await runScenario(h, {
|
|
190
190
|
argvFile,
|
|
191
|
-
env: { ABATHUR_AGENT_MODEL: "
|
|
191
|
+
env: { ABATHUR_AGENT_MODEL: "fake-provider/fake-model" },
|
|
192
192
|
});
|
|
193
193
|
const tokens = argvTokens(argvFile);
|
|
194
194
|
const i = tokens.indexOf("--model");
|
|
195
195
|
assert.notEqual(i, -1, `--model missing from argv: ${JSON.stringify(tokens)}`);
|
|
196
|
-
assert.equal(tokens[i + 1], "
|
|
196
|
+
assert.equal(tokens[i + 1], "fake-provider/fake-model");
|
|
197
197
|
assert.equal(tokens.at(-2), "--message", "pin must not disturb the brief channel");
|
|
198
198
|
assert.equal(prompt(h), BRIEF_TEXT);
|
|
199
199
|
});
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
// real seals via todo-3 worktree APIs, commands driven through the real CLI.
|
|
12
12
|
import assert from "node:assert/strict";
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
15
15
|
import { mkdtemp } from "node:fs/promises";
|
|
16
16
|
import * as os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import test, {} from "node:test";
|
|
20
|
-
import { Ledger } from "../core/ledger.js";
|
|
20
|
+
import { acquireGenomeLock, acquireLock, Ledger } from "../core/ledger.js";
|
|
21
21
|
import { fingerprint, genId } from "../core/ids.js";
|
|
22
22
|
import { newGeneration, openGenome, sealGeneration } from "../core/worktree.js";
|
|
23
23
|
import { manifestPathFor, readManifestFile } from "../core/kernel.js";
|
|
@@ -315,6 +315,196 @@ test("tombstone fail-closed + prompt_injection hygiene", async (t) => {
|
|
|
315
315
|
const tombPromoted = cli(f, "tombstone", "toy-smoke", promoted.genId, "--reason", "too late");
|
|
316
316
|
assert.equal(tombPromoted.status, 1, "promoted gens cannot be tombstoned");
|
|
317
317
|
});
|
|
318
|
+
// -------------------------------------------------------------------- retract
|
|
319
|
+
test("AC(r1): retract undoes a fresh promote — ref deleted, manifest cleared, ledger append-only", async (t) => {
|
|
320
|
+
const f = await gateFixture(t);
|
|
321
|
+
const gen = await handSeal(f, "retract-happy", CLEAN_MUTATION, "nominated");
|
|
322
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
323
|
+
assert.equal(incumbentSha(f), gen.commitSha);
|
|
324
|
+
const manifestFile = manifestPathFor(f.configDir, f.entry.fingerprint);
|
|
325
|
+
assert.ok(existsSync(manifestFile), "manifest exists after promote");
|
|
326
|
+
const before = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
327
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "zero-gain change, human-ordered rollback");
|
|
328
|
+
assert.equal(run.status, 0, `retract: ${run.stderr}`);
|
|
329
|
+
assert.equal(incumbentSha(f), null, "incumbent ref deleted");
|
|
330
|
+
assert.ok(!existsSync(manifestFile), "kernel manifest cleared");
|
|
331
|
+
const retractRows = rowsOfKind(f, "retract");
|
|
332
|
+
assert.equal(retractRows.length, 1);
|
|
333
|
+
const data = retractRows[0]?.data;
|
|
334
|
+
assert.equal(data.genId, gen.genId);
|
|
335
|
+
assert.equal(data.retractedTo, gen.commitSha);
|
|
336
|
+
assert.equal(data.reason, "zero-gain change, human-ordered rollback");
|
|
337
|
+
assert.equal(rowsOfKind(f, "promote").length, 1, "promote row remains (history never rewritten)");
|
|
338
|
+
const after = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
339
|
+
assert.ok(after.startsWith(before), "ledger stays append-only");
|
|
340
|
+
const cat = spawnSync("git", ["-C", f.repo, "cat-file", "-e", `${gen.commitSha}^{commit}`], { encoding: "utf8" });
|
|
341
|
+
assert.equal(cat.status, 0, "candidate commit preserved (retract ≠ delete)");
|
|
342
|
+
const st = cli(f, "status", "toy-smoke");
|
|
343
|
+
assert.equal(st.status, 0);
|
|
344
|
+
assert.match(st.stdout, /incumbent: none/);
|
|
345
|
+
assert.match(st.stdout, /ref is missing/, "status honestly warns about the retracted target");
|
|
346
|
+
});
|
|
347
|
+
test("retract fail-closed: missing reason, unknown gen, never-promoted, double retract", async (t) => {
|
|
348
|
+
const f = await gateFixture(t);
|
|
349
|
+
const gen = await handSeal(f, "retract-guards", CLEAN_MUTATION, "nominated");
|
|
350
|
+
assert.equal(cli(f, "retract", "toy-smoke", gen.genId).status, 2, "--reason is required");
|
|
351
|
+
assert.equal(cli(f, "retract", "toy-smoke", "g-nope", "--reason", "x").status, 1);
|
|
352
|
+
const neverPromoted = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "not promoted yet");
|
|
353
|
+
assert.equal(neverPromoted.status, 1);
|
|
354
|
+
assert.match(neverPromoted.stderr + neverPromoted.stdout, /no promote ledger row/);
|
|
355
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
356
|
+
assert.equal(cli(f, "retract", "toy-smoke", gen.genId, "--reason", "undo").status, 0);
|
|
357
|
+
const twice = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "again");
|
|
358
|
+
assert.equal(twice.status, 1);
|
|
359
|
+
assert.match(twice.stderr + twice.stdout, /already retracted/);
|
|
360
|
+
});
|
|
361
|
+
test("retract LIFO + descendant guard: live descendant blocks, released descendant frees", async (t) => {
|
|
362
|
+
const f = await gateFixture(t);
|
|
363
|
+
const genA = await handSeal(f, "retract-A", { file: "units/mul.mjs", append: "\n// A\n" }, "nominated");
|
|
364
|
+
assert.equal(cli(f, "promote", "toy-smoke", genA.genId).status, 0);
|
|
365
|
+
// handSeal forks from the repo HEAD, so fast-forward main onto the new
|
|
366
|
+
// incumbent first — the child must be a descendant of A or its own promote
|
|
367
|
+
// would hit the not-an-ancestor stale_state guard.
|
|
368
|
+
git(f, "merge", "--ff-only", "-q", genA.commitSha);
|
|
369
|
+
const genChild = await handSeal(f, "retract-child", { file: "units/add.mjs", append: "\n// child\n" }, "nominated");
|
|
370
|
+
const blockedByChild = cli(f, "retract", "toy-smoke", genA.genId, "--reason", "too late");
|
|
371
|
+
assert.equal(blockedByChild.status, 1);
|
|
372
|
+
assert.match(blockedByChild.stderr + blockedByChild.stdout, /orphan/);
|
|
373
|
+
assert.equal(incumbentSha(f), genA.commitSha, "refusal leaves the ref where it was");
|
|
374
|
+
assert.equal(cli(f, "promote", "toy-smoke", genChild.genId).status, 0);
|
|
375
|
+
const lifo = cli(f, "retract", "toy-smoke", genA.genId, "--reason", "out of order");
|
|
376
|
+
assert.equal(lifo.status, 1);
|
|
377
|
+
assert.match(lifo.stderr + lifo.stdout, /LIFO/);
|
|
378
|
+
assert.equal(cli(f, "retract", "toy-smoke", genChild.genId, "--reason", "child first").status, 0);
|
|
379
|
+
assert.equal(incumbentSha(f), null);
|
|
380
|
+
// child retracted ⇒ its lineage released ⇒ A retracts without a tombstone
|
|
381
|
+
assert.equal(cli(f, "retract", "toy-smoke", genA.genId, "--reason", "now clear").status, 0);
|
|
382
|
+
assert.equal(incumbentSha(f), null);
|
|
383
|
+
});
|
|
384
|
+
test("retract ref-absent: sole live promote records refState=absent; two live promotes refuse", async (t) => {
|
|
385
|
+
const f = await gateFixture(t);
|
|
386
|
+
const genA = await handSeal(f, "absent-A", CLEAN_MUTATION, "nominated");
|
|
387
|
+
assert.equal(cli(f, "promote", "toy-smoke", genA.genId).status, 0);
|
|
388
|
+
// documented manual-rollback shape: ref deleted out-of-band, sole live promote
|
|
389
|
+
git(f, "update-ref", "-d", `refs/heads/${INCUMBENT_BRANCH}`);
|
|
390
|
+
const ok = cli(f, "retract", "toy-smoke", genA.genId, "--reason", "manual rollback already done");
|
|
391
|
+
assert.equal(ok.status, 0, `retract: ${ok.stderr}`);
|
|
392
|
+
const row = rowsOfKind(f, "retract")[0]?.data;
|
|
393
|
+
assert.equal(row.refState, "absent", "row honestly marks the ref was already gone");
|
|
394
|
+
});
|
|
395
|
+
test("retract ref-absent with two live promotes refuses (no laundering of divergence)", async (t) => {
|
|
396
|
+
const f = await gateFixture(t);
|
|
397
|
+
const genA = await handSeal(f, "absent2-A", { file: "units/mul.mjs", append: "\n// A\n" }, "nominated");
|
|
398
|
+
assert.equal(cli(f, "promote", "toy-smoke", genA.genId).status, 0);
|
|
399
|
+
git(f, "merge", "--ff-only", "-q", genA.commitSha);
|
|
400
|
+
const genB = await handSeal(f, "absent2-B", { file: "units/add.mjs", append: "\n// B\n" }, "nominated");
|
|
401
|
+
assert.equal(cli(f, "promote", "toy-smoke", genB.genId).status, 0);
|
|
402
|
+
git(f, "update-ref", "-d", `refs/heads/${INCUMBENT_BRANCH}`); // out-of-band divergence
|
|
403
|
+
const run = cli(f, "retract", "toy-smoke", genB.genId, "--reason", "try to launder");
|
|
404
|
+
assert.equal(run.status, 1);
|
|
405
|
+
assert.match(run.stderr + run.stdout, /cannot verify what the retract undoes/);
|
|
406
|
+
assert.equal(rowsOfKind(f, "retract").length, 0, "no row written on refusal");
|
|
407
|
+
});
|
|
408
|
+
test("retract refuses while a LINKED worktree has the incumbent branch checked out", async (t) => {
|
|
409
|
+
const f = await gateFixture(t);
|
|
410
|
+
const gen = await handSeal(f, "retract-wt", CLEAN_MUTATION, "nominated");
|
|
411
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
412
|
+
const wt2 = path.join(f.root, "wt2");
|
|
413
|
+
git(f, "worktree", "add", "-q", wt2, INCUMBENT_BRANCH);
|
|
414
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "x");
|
|
415
|
+
assert.equal(run.status, 1);
|
|
416
|
+
assert.match(run.stderr + run.stdout, /checked out/);
|
|
417
|
+
assert.equal(incumbentSha(f), gen.commitSha, "ref untouched while a linked worktree holds the branch");
|
|
418
|
+
});
|
|
419
|
+
test("retract echo hygiene: control bytes never reach stdout or the ledger file", async (t) => {
|
|
420
|
+
const f = await gateFixture(t);
|
|
421
|
+
const gen = await handSeal(f, "retract-evil", CLEAN_MUTATION, "nominated");
|
|
422
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
423
|
+
const evil = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "evil\n\u001b[31mred\u001b[0m reason");
|
|
424
|
+
assert.equal(evil.status, 0, `evil reason: ${evil.stderr}`);
|
|
425
|
+
assert.ok(!evil.stdout.includes("\u001b"), "raw ANSI never reaches stdout");
|
|
426
|
+
const raw = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
427
|
+
assert.ok(!raw.includes("\u001b"), "no raw ESC byte in ledger text");
|
|
428
|
+
const stored = rowsOfKind(f, "retract")[0]?.data;
|
|
429
|
+
assert.equal(stored.reason, "evil\n\u001b[31mred\u001b[0m reason", "ledger stores verbatim (JSON-escaped on disk)");
|
|
430
|
+
});
|
|
431
|
+
test("gate locks: a live genome-lock holder blocks promote with a named pid (exit 2)", async (t) => {
|
|
432
|
+
const f = await gateFixture(t);
|
|
433
|
+
const gen = await handSeal(f, "lock-holder", CLEAN_MUTATION, "nominated");
|
|
434
|
+
const lease = acquireGenomeLock({ ledger: Ledger.open(f.repo), configDir: f.configDir, genomeFp: f.entry.fingerprint });
|
|
435
|
+
t.after(() => lease.release());
|
|
436
|
+
const run = cli(f, "promote", "toy-smoke", gen.genId);
|
|
437
|
+
assert.equal(run.status, 2, `promote while locked: ${run.stderr}`);
|
|
438
|
+
assert.match(run.stderr + run.stdout, /held by live pid/);
|
|
439
|
+
assert.equal(incumbentSha(f), null, "no ref movement while locked out");
|
|
440
|
+
});
|
|
441
|
+
test("gate locks: the repo-scoped gate lock contends across config homes", async (t) => {
|
|
442
|
+
const f = await gateFixture(t);
|
|
443
|
+
const gen = await handSeal(f, "repo-lock", CLEAN_MUTATION, "nominated");
|
|
444
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
445
|
+
// a second config home registering the SAME repo (different genome lock, same gate lock)
|
|
446
|
+
const configDir2 = path.join(f.root, "config2");
|
|
447
|
+
mkdirSync(configDir2, { recursive: true });
|
|
448
|
+
writeFileSync(path.join(configDir2, "config.jsonc"), '{ "opencodeBin": null }\n', "utf8");
|
|
449
|
+
const doc = JSON.parse(readFileSync(path.join(f.repo, "genome.jsonc"), "utf8"));
|
|
450
|
+
doc["label"] = "toy-smoke";
|
|
451
|
+
const spec2 = path.join(f.root, "private2.jsonc");
|
|
452
|
+
writeFileSync(spec2, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
453
|
+
registerGenome(configDir2, spec2);
|
|
454
|
+
const repoLease = acquireLock({ configDir: path.join(f.repo, ".state", "abathur"), key: "gate", label: "gate lock" });
|
|
455
|
+
t.after(() => repoLease.release());
|
|
456
|
+
const run = spawnSync(process.execPath, [CLI, "retract", "toy-smoke", gen.genId, "--reason", "x"], {
|
|
457
|
+
env: { ...process.env, ABATHUR_CONFIG: path.join(configDir2, "config.jsonc"), HOME: path.join(f.root, "home") },
|
|
458
|
+
encoding: "utf8",
|
|
459
|
+
cwd: f.root,
|
|
460
|
+
});
|
|
461
|
+
assert.equal(run.status, 2, `retract via second config while gate-locked: ${run.stderr}`);
|
|
462
|
+
assert.match(run.stderr + run.stdout, /gate lock 'gate' is held by live pid/);
|
|
463
|
+
assert.equal(incumbentSha(f), gen.commitSha, "ref untouched while gate-locked");
|
|
464
|
+
assert.equal(rowsOfKind(f, "retract").length, 0, "no row appended by the locked-out process");
|
|
465
|
+
});
|
|
466
|
+
test("retract refuses when the incumbent ref moved out-of-band after the promote", async (t) => {
|
|
467
|
+
const f = await gateFixture(t);
|
|
468
|
+
const gen = await handSeal(f, "diverge", CLEAN_MUTATION, "nominated");
|
|
469
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
470
|
+
git(f, "update-ref", `refs/heads/${INCUMBENT_BRANCH}`, gen.parent); // rewind out-of-band
|
|
471
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "x");
|
|
472
|
+
assert.equal(run.status, 1);
|
|
473
|
+
assert.match(run.stderr + run.stdout, /state diverged/);
|
|
474
|
+
assert.equal(incumbentSha(f), gen.parent, "the moved ref is NOT deleted");
|
|
475
|
+
});
|
|
476
|
+
test("promote after retract refuses — a retracted gen stays dead (no re-promote lockup)", async (t) => {
|
|
477
|
+
const f = await gateFixture(t);
|
|
478
|
+
const gen = await handSeal(f, "re-promote", CLEAN_MUTATION, "nominated");
|
|
479
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
480
|
+
assert.equal(cli(f, "retract", "toy-smoke", gen.genId, "--reason", "undo").status, 0);
|
|
481
|
+
const again = cli(f, "promote", "toy-smoke", gen.genId);
|
|
482
|
+
assert.equal(again.status, 1);
|
|
483
|
+
assert.match(again.stderr + again.stdout, /already promoted/);
|
|
484
|
+
});
|
|
485
|
+
test("retract: a PRUNABLE worktree (deleted directory) does not block the rollback valve", async (t) => {
|
|
486
|
+
const f = await gateFixture(t);
|
|
487
|
+
const gen = await handSeal(f, "retract-prunable", CLEAN_MUTATION, "nominated");
|
|
488
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
489
|
+
const wt2 = path.join(f.root, "wt-stale");
|
|
490
|
+
git(f, "worktree", "add", "-q", wt2, INCUMBENT_BRANCH);
|
|
491
|
+
rmSync(wt2, { recursive: true, force: true }); // directory gone, metadata stale ⇒ porcelain marks it prunable
|
|
492
|
+
const list = spawnSync("git", ["-C", f.repo, "worktree", "list", "--porcelain"], { encoding: "utf8" });
|
|
493
|
+
assert.match(list.stdout, /prunable/, "fixture sanity: git itself marks the stale worktree prunable");
|
|
494
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "stale worktree must not block");
|
|
495
|
+
assert.equal(run.status, 0, `retract: ${run.stderr}`);
|
|
496
|
+
assert.equal(incumbentSha(f), null);
|
|
497
|
+
});
|
|
498
|
+
test("retract refuses while the incumbent branch is checked out", async (t) => {
|
|
499
|
+
const f = await gateFixture(t);
|
|
500
|
+
const gen = await handSeal(f, "retract-checkout", CLEAN_MUTATION, "nominated");
|
|
501
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
502
|
+
git(f, "checkout", "-q", INCUMBENT_BRANCH);
|
|
503
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "x");
|
|
504
|
+
assert.equal(run.status, 1);
|
|
505
|
+
assert.match(run.stderr + run.stdout, /checked out/i);
|
|
506
|
+
assert.equal(incumbentSha(f), gen.commitSha, "ref untouched while checked out");
|
|
507
|
+
});
|
|
318
508
|
// -------------------------------------------------------------------- status
|
|
319
509
|
test("AC(d): status renders incumbent, generations, quarantine depth, budget; never fabricates", async (t) => {
|
|
320
510
|
const f = await gateFixture(t);
|
|
@@ -368,7 +558,7 @@ test("genome rm: ledgerless genomes unregister (manifest stays), ledger history
|
|
|
368
558
|
assert.equal(empty.status, 0, "no ledger rows ⇒ still removable");
|
|
369
559
|
});
|
|
370
560
|
// -------------------------------------------------------------- structural
|
|
371
|
-
test("structural: promote
|
|
561
|
+
test("structural: promote/retract cores are CLI-only; no auto-gate import path; no --force anywhere", () => {
|
|
372
562
|
const importers = [];
|
|
373
563
|
const scan = (dir) => {
|
|
374
564
|
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -386,9 +576,36 @@ test("structural: promote core is CLI-only; no auto-promote import path; no --fo
|
|
|
386
576
|
scan(path.join(REPO_ROOT, "src"));
|
|
387
577
|
const relative = importers.map((p) => path.relative(REPO_ROOT, p).split(path.sep).join("/")).sort();
|
|
388
578
|
assert.deepEqual(relative, ["src/commands/promote.ts"], "core/promote imported ONLY by commands/promote");
|
|
579
|
+
const retractImporters = [];
|
|
580
|
+
const scanRetract = (dir) => {
|
|
581
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
582
|
+
const p = path.join(dir, ent.name);
|
|
583
|
+
if (ent.isDirectory()) {
|
|
584
|
+
if (ent.name !== "node_modules")
|
|
585
|
+
scanRetract(p);
|
|
586
|
+
}
|
|
587
|
+
else if (ent.name.endsWith(".ts")) {
|
|
588
|
+
const src = readFileSync(p, "utf8");
|
|
589
|
+
if (/(?:from\s+|import\(\s*)["'](?:[^"']*\/)?core\/retract\.js["']|from\s+["']\.\/retract\.js["']/.test(src))
|
|
590
|
+
retractImporters.push(p);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
scanRetract(path.join(REPO_ROOT, "src"));
|
|
595
|
+
const relRetract = retractImporters.map((p) => path.relative(REPO_ROOT, p).split(path.sep).join("/")).sort();
|
|
596
|
+
assert.deepEqual(relRetract, ["src/commands/retract.ts"], "core/retract imported ONLY by commands/retract");
|
|
389
597
|
for (const rel of ["src/core/evolve/run-loop.ts", "src/core/evolve/reflect.ts", "src/core/evolve/candidate.ts"]) {
|
|
390
598
|
assert.doesNotMatch(readFileSync(path.join(REPO_ROOT, rel), "utf8"), /from\s+"[^"]*promote\.js"/, `${rel} must not import promote`);
|
|
599
|
+
assert.doesNotMatch(readFileSync(path.join(REPO_ROOT, rel), "utf8"), /from\s+"[^"]*retract\.js"/, `${rel} must not import retract`);
|
|
600
|
+
}
|
|
601
|
+
for (const gateFile of ["src/core/promote.ts", "src/core/retract.ts"]) {
|
|
602
|
+
assert.doesNotMatch(readFileSync(path.join(REPO_ROOT, gateFile), "utf8"), /--force/, `no force flag in ${gateFile}`);
|
|
603
|
+
}
|
|
604
|
+
// the plugin tool allowlist must never grow a human gate (terminal-only class)
|
|
605
|
+
const pluginSrc = readFileSync(path.join(REPO_ROOT, "plugin", "abathur.ts"), "utf8");
|
|
606
|
+
const allowBlock = pluginSrc.match(/const ALLOWED_COMMANDS[\s\S]*?;/)?.[0] ?? "";
|
|
607
|
+
assert.ok(allowBlock.length > 0, "ALLOWED_COMMANDS block must be found — a vacuous match tests nothing");
|
|
608
|
+
for (const gate of ['"promote"', '"tombstone"', '"retract"']) {
|
|
609
|
+
assert.ok(!allowBlock.includes(gate), `${gate} must NOT be plugin-reachable`);
|
|
391
610
|
}
|
|
392
|
-
const gate = readFileSync(path.join(REPO_ROOT, "src/core/promote.ts"), "utf8");
|
|
393
|
-
assert.doesNotMatch(gate, /--force/, "no force flag in the gate");
|
|
394
611
|
});
|