@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.
@@ -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
+ }
@@ -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=abathur@harness.local"];
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=abathur@harness.local", "commit", "-m", "toy-smoke fixture seed"]);
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`);
@@ -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=abathur@harness.local", "commit", "-am", "bundle: declare maskLiterals");
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=abathur@harness.local", "commit", "-m", "fixloop seed"]);
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=abathur@harness.local", "-C", cwd, ...args], {
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();
@@ -13,7 +13,7 @@ export const GIT_ID = [
13
13
  "-c",
14
14
  "user.name=abathur-test",
15
15
  "-c",
16
- "user.email=test@abathur.local",
16
+ "user.email=ci@host.example",
17
17
  "-c",
18
18
  "commit.gpgsign=false",
19
19
  ];
@@ -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=abathur@harness.local", "-C", repo, ...args], { stdio: "pipe" });
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=abathur@harness.local", "-C", repo, "add", "-A"], { stdio: "pipe" });
268
- execFileSync("git", ["-c", "user.name=abathur", "-c", "user.email=abathur@harness.local", "-C", repo, "commit", "-m", "genome"], { stdio: "pipe" });
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");
@@ -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=abathur@harness.local", "commit", "-am", "graft fixture: spec edit");
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: "test-provider/test-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], "test-provider/test-model");
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 core is CLI-only; no auto-promote import path; no --force anywhere", () => {
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
  });