@feigi/fleet-ctl 3.23.6 → 3.23.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.23.6",
3
+ "version": "3.23.8",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,7 +43,9 @@
43
43
  // `fleet-implementer-<cell>` definition was most recently added, ruled by its
44
44
  // last tier-outcomes.tsv row (a `+`-joined `ticket` field rules each ticket
45
45
  // it names); a ticket counts once however many Pulls it took. A verdict FAILS
46
- // the quality floor on `minted_false_claim=yes` or `closed_own_ticket=no`.
46
+ // the quality floor on `minted_false_claim=yes` or `closed_own_ticket=no`; a
47
+ // counted ruling holding anything but `yes` or `no` in either column, blank
48
+ // included, is refused rather than read as a pass.
47
49
  // X is to be withdrawn once it has at least STOP.verdicts verdicts and
48
50
  // floor failures ÷ verdicts is at least STOP.failRate.
49
51
  //
@@ -56,6 +58,7 @@ import { isCLI } from "./is-cli.mjs";
56
58
  import { CELL, POLICY_CELL } from "./ledger-grammar.mjs";
57
59
  import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
58
60
  import { parseFeatures } from "./pr-cost.mjs";
61
+ import { VERDICT, VERDICT_COLUMNS } from "./tier-outcomes.mjs";
59
62
 
60
63
  const NAME = "cell-readout";
61
64
  export const GATE = Object.freeze({ comparisons: 10, runDates: 5 });
@@ -122,6 +125,8 @@ export function readout({ features, members }) {
122
125
  * `[{ ticket, pr, run_date, closed_own_ticket, minted_false_claim, failed }]`
123
126
  * sorted by ticket, `run_date` the ruling's; `stop` is whether the stopping
124
127
  * rule withdraws the cell. `verdicts` are parsed tier-outcomes.tsv rows.
128
+ * Throws on a counted ruling whose `closed_own_ticket` or `minted_false_claim`
129
+ * is not `yes` or `no`.
125
130
  */
126
131
  export function stoppingRule({ features, members, verdicts, added }) {
127
132
  const byKey = new Map(members.map((m) => [key(m.session, m.agent), m]));
@@ -132,6 +137,9 @@ export function stoppingRule({ features, members, verdicts, added }) {
132
137
  const tickets = judged.get(p.chosen_cell);
133
138
  if (!tickets || p.run_date < added[p.chosen_cell] || !ruling.has(p.ticket) || !admissibleMember(p, byKey)) continue;
134
139
  const v = ruling.get(p.ticket);
140
+ for (const c of VERDICT_COLUMNS) {
141
+ if (!VERDICT.includes(v[c])) throw new Error(`ticket #${p.ticket} (PR #${v.pr}): ${c} is '${v[c]}', expected yes or no`);
142
+ }
135
143
  const failed = v.minted_false_claim === "yes" || v.closed_own_ticket === "no";
136
144
  tickets.set(p.ticket, {
137
145
  ticket: p.ticket, pr: v.pr, run_date: v.run_date,
@@ -441,3 +441,31 @@ test("stopping rule: a ticket counts once, judged by its last tier-outcomes row,
441
441
  assert.equal(at.verdicts.find((v) => v.ticket === second.ticket).failed, true);
442
442
  assert.equal(at.stop, true);
443
443
  });
444
+
445
+ // A verdict that is neither `yes` nor `no` matches neither floor half, so read
446
+ // as a pass it would lower the failure rate and keep the cell alive unreported.
447
+ test("stopping rule: a counted ruling whose closed_own_ticket or minted_false_claim is not exactly yes or no is refused, naming the ticket", () => {
448
+ for (const [col, value] of [["closed_own_ticket", "No"], ["minted_false_claim", "YES"], ["closed_own_ticket", ""], ["minted_false_claim", ""], ["closed_own_ticket", "yes "]]) {
449
+ const w = world();
450
+ addVerdicts(w, "smol-high", 10, 0);
451
+ const bad = w.verdicts[3];
452
+ bad[col] = value;
453
+ assert.throws(() => judged(w, { "smol-high": "2026-09-01" }, "smol-high"),
454
+ new RegExp(`ticket #${bad.ticket} \\(PR #${bad.pr}\\): ${col} is '${value}', expected yes or no`), `${col}=${JSON.stringify(value)}`);
455
+ }
456
+ });
457
+
458
+ // What the refusal must ACCEPT: a malformed ruling on a ticket the rule does not
459
+ // count, and every yes/no combination on one it does.
460
+ test("stopping rule: a malformed ruling on a ticket with no counted Pull is not read, and all four yes/no pairs are", () => {
461
+ const w = world();
462
+ addVerdicts(w, "smol-high", 4, 0);
463
+ [["yes", "no"], ["yes", "yes"], ["no", "no"], ["no", "yes"]].forEach(([closed, minted], i) => {
464
+ Object.assign(w.verdicts[i], { closed_own_ticket: closed, minted_false_claim: minted });
465
+ });
466
+ w.verdicts.push(verdict({ ticket: "1381", pr: "1392", closed_own_ticket: "", minted_false_claim: "" }));
467
+ w.verdicts.push(verdict({ ticket: "7", pr: "8", closed_own_ticket: "No", minted_false_claim: "YES" }));
468
+ const at = judged(w, { "smol-high": "2026-09-01" }, "smol-high");
469
+ assert.equal(at.verdicts.length, 4);
470
+ assert.deepEqual(at.verdicts.map((v) => v.failed), [false, true, true, true]);
471
+ });
@@ -0,0 +1,112 @@
1
+ // reaping.md's reap section said reap.sh's `git update-ref -d` is
2
+ // "authorized by that cherry check and only by it". The script halts the
3
+ // delete on a second thing too: a fresh worktree check (`wt_holding`) over a
4
+ // listing re-read right before it, which keeps a `[gone]` branch the cherry
5
+ // check cleared whenever a worktree holds it or cannot be resolved. A sentence
6
+ // naming the cherry check as the only authorizer is false as written.
7
+ //
8
+ // The reap-side twin of reaping-release-authorizers-prose.test.mjs, which pins
9
+ // the release section's authorizer list. Kept apart so each section's pin can
10
+ // be edited independently.
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { between, bullet, phrase, unemphasized } from "./prose-pin.mjs";
16
+
17
+ const REPO = join(import.meta.dirname, "..");
18
+ const REAPING = readFileSync(
19
+ join(REPO, "skills", "run-team", "references", "reaping.md"),
20
+ "utf8",
21
+ );
22
+ const SCRIPT = readFileSync(join(REPO, "scripts", "reap.sh"), "utf8");
23
+
24
+ const reapSection = () =>
25
+ between(
26
+ REAPING,
27
+ "## Why the reap is shaped the way it is",
28
+ "## Why a second sweep, over worktrees rather than branches",
29
+ "reaping.md",
30
+ );
31
+
32
+ // The bullet that names the delete, bounded to its own list item: it ends at
33
+ // the next list marker, so a sibling bullet (the holder-check bullet sits
34
+ // right after it) cannot supply the words, and rewording that sibling does
35
+ // not move the bound.
36
+ const deleteBullet = () =>
37
+ bullet(
38
+ reapSection(),
39
+ "- **The delete is `git update-ref -d`'s compare-and-swap",
40
+ "\n- ",
41
+ "reaping.md's reap section",
42
+ );
43
+
44
+ // The clause that lists the authorizers: from "authorized by" to the
45
+ // sentence after the bold lead, which explains the compare-and-swap. Sliced
46
+ // emphasis-tolerantly, and every assertion below reads it through
47
+ // `unemphasized()`, so a `**` moved onto or into the clause stays green: the
48
+ // slice only locates the clause, and `phrase()` is matched against raw text.
49
+ const authorizers = () =>
50
+ between(
51
+ deleteBullet(),
52
+ "authorized by that cherry check",
53
+ "Tip read before the cherry",
54
+ "reaping.md's reap delete bullet",
55
+ { emphasisTolerant: true },
56
+ );
57
+
58
+ test("reaping.md: the reap delete's authorizer clause names the fresh worktree-holds-branch check", () => {
59
+ // ONE contiguous span, not keywords: "fresh" and the negation each
60
+ // satisfied somewhere in the slice pass a clause with either one gutted
61
+ // ("a check that no worktree holds the branch" has no freshness; "a fresh
62
+ // check ... that a worktree holds the branch" has the opposite meaning).
63
+ // Only a span binds the two to each other.
64
+ assert.match(
65
+ unemphasized(authorizers()),
66
+ phrase("a fresh check, re-read immediately before the delete, that no worktree holds the branch"),
67
+ "reaping.md's reap section names what authorizes the `update-ref` delete but not the fresh worktree check — reap.sh keeps a branch the cherry check cleared whenever a worktree holds it",
68
+ );
69
+ });
70
+
71
+ test("reaping.md: the reap delete's authorizer clause does not make any one check the only authorizer", () => {
72
+ // The exclusivity the defect carried, in the spellings of it a reword can
73
+ // restore after the new words: "only by it", "only by them", "nothing
74
+ // else", "solely", "alone". Pinned on its own because the presence pin
75
+ // above stays green with any of them restored. A word list, so a spelling
76
+ // outside it goes unseen; `\s+` keeps a reflow between its words green.
77
+ assert.doesNotMatch(
78
+ unemphasized(authorizers()),
79
+ /\bonly\s+by\b|\bnothing\s+else\b|\bsolely\b|\balone\b/,
80
+ "reaping.md's reap section says one check alone authorizes the `update-ref` delete — reap.sh's worktree check stops that delete too",
81
+ );
82
+ });
83
+
84
+ test("reaping.md: the reap delete's authorizer clause still names the cherry check", () => {
85
+ // Input the new assertions must ACCEPT: naming the worktree check must not
86
+ // cost the clause the check it already had.
87
+ assert.match(unemphasized(deleteBullet()), phrase("authorized by that cherry check"));
88
+ });
89
+
90
+ // reap.sh's executable lines, comments and `echo` diagnostics dropped. The
91
+ // check's name and the delete command both recur in comments: an indexOf over
92
+ // the raw text lands on either and reads as the code, so a real delete moved
93
+ // above the check, or the check commented out, stays green. Only whole-line
94
+ // comments drop here, so each pattern below is anchored at the start of its
95
+ // statement: a command spelled after a `#` inside a live line is not live.
96
+ const liveLines = () => SCRIPT.split("\n").filter((l) => !/^\s*(?:#|echo\b)/.test(l));
97
+
98
+ // The one live line matching `re`. Exactly one: a first-hit search would
99
+ // bind the pin to whichever copy comes first.
100
+ const liveLineOf = (re, what) => {
101
+ const hits = liveLines().flatMap((l, i) => (re.test(l) ? [i] : []));
102
+ assert.equal(hits.length, 1, `reap.sh: expected exactly one live line that ${what}, found ${hits.length}`);
103
+ return hits[0];
104
+ };
105
+
106
+ test("reap.sh: the worktree check the prose names sits before the delete", () => {
107
+ // The prose names a check the script runs; pin that the script still does,
108
+ // and runs it before the delete, so the prose cannot outlive the check.
109
+ const check = liveLineOf(/^\s*if\s+wt_holding\s+"refs\/heads\/\$b"/, "runs wt_holding on the branch");
110
+ const del = liveLineOf(/^\s*if\s+!\s+err=\$\(git\s+update-ref\s+--no-deref\s+-d\s+"refs\/heads\/\$b"\s+"\$tip"/, "runs `git update-ref -d` on the branch at its tip");
111
+ assert.ok(check < del, "reap.sh runs its worktree check after the delete, not before it");
112
+ });
@@ -660,7 +660,7 @@ test("--check stays green when a verdict or member row lands after the fit, for
660
660
  const fitted = readFileSync(p.table, "utf8");
661
661
  assert.equal(JSON.parse(fitted).fitted_through, "2026-10-01");
662
662
  // The ruling lands later and flips the ticket to a failure, and a fix round books against it.
663
- writeFileSync(p.verdicts, tsv(VERDICT_COLUMNS, [verdictRow({ pr: "11", ticket: "1" }), verdictRow({ run_date: "2026-10-09", pr: "11", ticket: "1", minted_false_claim: "yes" })], true));
663
+ writeFileSync(p.verdicts, `# ${tsv(VERDICT_COLUMNS, [verdictRow({ pr: "11", ticket: "1" }), verdictRow({ run_date: "2026-10-09", pr: "11", ticket: "1", minted_false_claim: "yes" })], true)}`);
664
664
  writeFileSync(p.members, tsv(MEMBER_COLUMNS, [...members, { run_date: "2026-10-09", agent: "fix-pr-11", member: "fix-pr-11", ticket: "1", pr: "11" }].map(memberRow), true));
665
665
  const r = cli(["--check", ...p.fitArgs]);
666
666
  assert.equal(r.status, 0, r.stderr);
@@ -63,7 +63,8 @@ const LEDGER_SCRIPT = join(SCRIPT_DIR, "ledger.mjs");
63
63
 
64
64
  // ---------------------------------------------------------------------------
65
65
  // pure core — exercised through the CLI by tier-outcomes.test.mjs; only
66
- // COLUMNS and TIER_SWITCH_DATE are imported directly, the rest through argv
66
+ // COLUMNS, LEGACY_WIDTH and TIER_SWITCH_DATE are imported directly, the rest
67
+ // through argv
67
68
  // ---------------------------------------------------------------------------
68
69
 
69
70
  // APPENDED TO, never inserted into: every awk read-out in the file's header
@@ -81,6 +82,11 @@ export const TIER_SWITCH_DATE = "2026-09-29";
81
82
 
82
83
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
83
84
  const BASE_DEFINITION = "fleet-implementer";
85
+ // The two quality-floor columns `append` writes. Every reader fails the floor
86
+ // on exactly `minted_false_claim=yes` or `closed_own_ticket=no`, so any other
87
+ // spelling would read as a pass.
88
+ export const VERDICT = Object.freeze(["yes", "no"]);
89
+ export const VERDICT_COLUMNS = Object.freeze(["closed_own_ticket", "minted_false_claim"]);
84
90
 
85
91
  // `fleet-implementer` -> `default`, `fleet-implementer-<x>` -> `<x>`, anything
86
92
  // else (a generic `task`, a blank pre-#1066 subagent_type) -> null.
@@ -94,6 +100,11 @@ export function shortName(definition) {
94
100
  // Data rows as objects keyed by COLUMNS, plus the raw `line`. A row of any
95
101
  // width but the legacy or the full one is refused: a tab typed into `note`
96
102
  // shifts every field after it, and a reader keyed by position cannot tell.
103
+ // So is a verdict column holding anything but `yes`, `no` or blank — blank
104
+ // because a row backfilled without a ruling leaves both empty, so a row with
105
+ // exactly one of the two blank is refused too: it was never written whole, and
106
+ // a blanked `closed_own_ticket=no` or `minted_false_claim=yes` would read as a
107
+ // pass.
97
108
  export function parseTierOutcomes(text) {
98
109
  return String(text ?? "").split("\n")
99
110
  .filter((l) => l.trim() && !l.startsWith("#"))
@@ -104,6 +115,15 @@ export function parseTierOutcomes(text) {
104
115
  }
105
116
  const row = { line };
106
117
  COLUMNS.forEach((c, i) => { row[c] = cells[i] ?? ""; });
118
+ for (const c of VERDICT_COLUMNS) {
119
+ if (row[c] !== "" && !VERDICT.includes(row[c])) {
120
+ throw new Error(`malformed row: ${c} is '${row[c]}', expected yes, no or blank — ${line.slice(0, 60)}`);
121
+ }
122
+ }
123
+ const [first, second] = VERDICT_COLUMNS;
124
+ if ((row[first] === "") !== (row[second] === "")) {
125
+ throw new Error(`malformed row: ${first} is '${row[first]}' but ${second} is '${row[second]}', expected both blank or both yes or no — ${line.slice(0, 60)}`);
126
+ }
107
127
  return row;
108
128
  });
109
129
  }
@@ -388,8 +408,8 @@ function append(pr, paths) {
388
408
  for (const [col, value] of Object.entries(fields)) {
389
409
  if (/[\t\r\n]/.test(value)) die(`--${col.replaceAll("_", "-")} holds a tab or newline — it would shift every field after it`);
390
410
  }
391
- for (const col of ["closed_own_ticket", "minted_false_claim"]) {
392
- if (!["yes", "no"].includes(fields[col])) die(`--${col.replaceAll("_", "-")} must be yes or no, got '${fields[col]}'`);
411
+ for (const col of VERDICT_COLUMNS) {
412
+ if (!VERDICT.includes(fields[col])) die(`--${col.replaceAll("_", "-")} must be yes or no, got '${fields[col]}'`);
393
413
  }
394
414
  if (fields.sizing && !["light", "heavy"].includes(fields.sizing)) die(`--sizing must be light or heavy, got '${fields.sizing}'`);
395
415
  for (const col of ["loc", "files"]) {
@@ -10,7 +10,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { COLUMNS, TIER_SWITCH_DATE } from "./tier-outcomes.mjs";
13
+ import { COLUMNS, LEGACY_WIDTH, TIER_SWITCH_DATE } from "./tier-outcomes.mjs";
14
14
  import { COLUMNS as MEMBER_COLUMNS } from "./member-outcomes.mjs";
15
15
  import { writeExecStub } from "./exec-stub.mjs";
16
16
 
@@ -29,8 +29,8 @@ function memberRow({ member, ticket, type, role = "implementer" }) {
29
29
  return MEMBER_COLUMNS.map((c) => r[c]).join("\t");
30
30
  }
31
31
 
32
- function tierRow({ date = TIER_SWITCH_DATE, pr, ticket, tier }) {
33
- return [date, pr, ticket, "routine", tier, "yes", "no", "a note with spaces", "light", "production", "40", "2"].join("\t");
32
+ function tierRow({ date = TIER_SWITCH_DATE, pr, ticket, tier, closed = "yes", minted = "no" }) {
33
+ return [date, pr, ticket, "routine", tier, closed, minted, "a note with spaces", "light", "production", "40", "2"].join("\t");
34
34
  }
35
35
 
36
36
  function ledgerText(rows = [], dispatched = []) {
@@ -275,6 +275,24 @@ test("append: --class is refused as an unknown flag before anything is written",
275
275
  assert.deepEqual(f.ghCalls(), [], "refused before gh was asked");
276
276
  });
277
277
 
278
+ test("append: a verdict that is not exactly yes or no is refused naming the flag, before gh is asked or anything is written", (t) => {
279
+ for (const [flag, closed, minted] of [
280
+ ["closed-own-ticket", "YES", "no"],
281
+ ["closed-own-ticket", "yes ", "no"],
282
+ ["minted-false-claim", "yes", "No"],
283
+ ["minted-false-claim", "yes", "maybe"],
284
+ ]) {
285
+ const f = fixture(t, { members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }] });
286
+ const before = readFileSync(f.tier, "utf8");
287
+ const r = f.run("append", "20", "--closed-own-ticket", closed, "--minted-false-claim", minted, "--note", "n");
288
+ const got = flag === "closed-own-ticket" ? closed : minted;
289
+ assert.equal(r.code, 2, `${flag}=${JSON.stringify(got)}\n${r.stdout}${r.stderr}`);
290
+ assert.ok(r.stderr.includes(`--${flag} must be yes or no, got '${got}'`), r.stderr);
291
+ assert.equal(readFileSync(f.tier, "utf8"), before);
292
+ assert.deepEqual(f.ghCalls(), [], "refused before gh was asked");
293
+ }
294
+ });
295
+
278
296
  // ---------------------------------------------------------------------------
279
297
  // check
280
298
  // ---------------------------------------------------------------------------
@@ -467,6 +485,65 @@ test("check: a missing tier-outcomes.tsv file fails loudly instead of passing 0
467
485
  assert.match(r.stderr, /no .*tier-outcomes\.tsv — no rows to check/);
468
486
  });
469
487
 
488
+ // Every reader of the file judges the floor as `minted_false_claim === "yes" ||
489
+ // closed_own_ticket === "no"`, so any other spelling would read as a pass.
490
+ test("check: a verdict column that is not yes, no or blank is refused as malformed, on full and legacy rows", (t) => {
491
+ for (const [col, row] of [
492
+ ["closed_own_ticket", tierRow({ pr: 21, ticket: 11, tier: "default", closed: "No" })],
493
+ ["minted_false_claim", tierRow({ pr: 21, ticket: 11, tier: "default", minted: "YES" })],
494
+ ["minted_false_claim", tierRow({ pr: 21, ticket: 11, tier: "default", minted: "no " })],
495
+ ["closed_own_ticket", tierRow({ date: PRE_SWITCH, pr: 21, ticket: 11, tier: "opus", closed: "y" }).split("\t").slice(0, LEGACY_WIDTH).join("\t")],
496
+ ]) {
497
+ const f = fixture(t, {
498
+ members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }],
499
+ tierRows: [tierRow({ pr: 20, ticket: 10, tier: "default" }), row],
500
+ });
501
+ const r = f.run("check");
502
+ assert.equal(r.code, 2, `${row}\n${r.stdout}${r.stderr}`);
503
+ const value = row.split("\t")[COLUMNS.indexOf(col)];
504
+ assert.ok(r.stderr.includes(`malformed row: ${col} is '${value}', expected yes, no or blank`), r.stderr);
505
+ assert.equal(r.stdout, "");
506
+ }
507
+ });
508
+
509
+ // What the refusal must ACCEPT: the committed file carries never-ruled rows
510
+ // with both verdict columns blank, and every reader parses the whole file.
511
+ test("check: blank verdict columns and every yes/no pair parse, on full and legacy rows", (t) => {
512
+ const f = fixture(t, {
513
+ members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }],
514
+ tierRows: [
515
+ tierRow({ date: PRE_SWITCH, pr: 19, ticket: 9, tier: "", closed: "", minted: "" }).split("\t").slice(0, LEGACY_WIDTH).join("\t"),
516
+ tierRow({ pr: 20, ticket: 10, tier: "default", closed: "", minted: "" }),
517
+ tierRow({ pr: 21, ticket: 10, tier: "default", closed: "yes", minted: "yes" }),
518
+ tierRow({ pr: 22, ticket: 10, tier: "default", closed: "no", minted: "no" }),
519
+ tierRow({ pr: 23, ticket: 10, tier: "default", closed: "no", minted: "yes" }),
520
+ ],
521
+ });
522
+ const r = f.run("check");
523
+ assert.equal(r.code, 0, r.stdout + r.stderr);
524
+ assert.match(r.stdout, /4 checked, 0 failed/);
525
+ });
526
+
527
+ // A row with ONE verdict column blank is what a hand-edit leaves when it blanks
528
+ // a failing value; `append` never writes it and a no-ruling backfill blanks both.
529
+ test("check: a row with exactly one verdict column blank is refused as malformed, on full and legacy rows", (t) => {
530
+ for (const [closed, minted] of [["", "no"], ["", "yes"], ["yes", ""], ["no", ""]]) {
531
+ for (const legacy of [false, true]) {
532
+ const full = tierRow({ date: legacy ? PRE_SWITCH : TIER_SWITCH_DATE, pr: 21, ticket: 11, tier: "default", closed, minted });
533
+ const row = legacy ? full.split("\t").slice(0, LEGACY_WIDTH).join("\t") : full;
534
+ const f = fixture(t, {
535
+ members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }],
536
+ tierRows: [tierRow({ pr: 20, ticket: 10, tier: "default" }), row],
537
+ });
538
+ const r = f.run("check");
539
+ const label = `${JSON.stringify({ closed, minted, legacy })}\n${r.stdout}${r.stderr}`;
540
+ assert.equal(r.code, 2, label);
541
+ assert.ok(r.stderr.includes(`malformed row: closed_own_ticket is '${closed}' but minted_false_claim is '${minted}', expected both blank or both yes or no`), label);
542
+ assert.equal(r.stdout, "");
543
+ }
544
+ }
545
+ });
546
+
470
547
  test("check --live: warns on each reviewed PR with no row, and the warning alone never fails", (t) => {
471
548
  const f = fixture(t, {
472
549
  members: [{ member: "impl-10", ticket: 10, type: "fleet-implementer" }],
@@ -22,7 +22,7 @@ Every precondition recomputed **inside** same command as delete:
22
22
  - **`for-each-ref`, not `git branch | grep`.** `%(upstream:track)` emits exactly `[gone]` as own field. Nothing to pattern-match, no `-v`/`-vv` trap.
23
23
  - **Recompute per branch, in this command.** Branch list from earlier tool call already false: one observed run listed 28 gone branches, two calls later 27 reaped by concurrent session. Benign direction is no-op; dangerous one is worktree that gained work *after* check.
24
24
  - **`git cherry origin/main`, not `git diff main..`.** Against `origin/main` — local `main` you never fast-forwarded reads every merged branch as unmerged. Any `+` line is commit that exists nowhere else.
25
- - **The delete is `git update-ref -d`'s compare-and-swap on a tip read once (ADR 0018), authorized by that cherry check and only by it.** Tip read before the cherry, cherry runs against that SHA, delete refuses unless ref still equals it — commit landing between check and delete is kept with git's own `cannot lock ref`, never force-deleted, as it was under `git branch -D` until #2219. `--no-deref`, so symref branch deletes the symref, not its target (measured). Branch's config section removed after, as `-D` did — left behind, re-created branch of same name inherits dead upstream and reads `[gone]` at once. `git branch -d` would refuse everything here: upstream gone, so falls back to comparing against `HEAD`, a possibly-behind local `main`. Never delete branch whose cherry output you did not just read.
25
+ - **The delete is `git update-ref -d`'s compare-and-swap on a tip read once (ADR 0018), authorized by that cherry check and by a fresh check, re-read immediately before the delete, that no worktree holds the branch.** Tip read before the cherry, cherry runs against that SHA, delete refuses unless ref still equals it — commit landing between check and delete is kept with git's own `cannot lock ref`, never force-deleted, as it was under `git branch -D` until #2219. `--no-deref`, so symref branch deletes the symref, not its target (measured). Branch's config section removed after, as `-D` did — left behind, re-created branch of same name inherits dead upstream and reads `[gone]` at once. `git branch -d` would refuse everything here: upstream gone, so falls back to comparing against `HEAD`, a possibly-behind local `main`. Never delete branch whose cherry output you did not just read.
26
26
  - **A branch any worktree holds is a KEEP, checked right before the delete.** `update-ref` consults no worktree; `-D` did. So listing is re-read and worktree.sh's `wt_holding` asks: held by a `branch` line, or by detached worktree stopped mid `rebase -i` / mid `bisect` on it (admin dir's `head-name` / `BISECT_START`) — both routes `-D` refused on (#2218). Worktree it cannot resolve, or listing that will not re-read, is a KEEP. `--apply` only, like the delete: dry run cannot predict this refusal — not the same limitation `-D`'s dry run had, since an unrelated worktree elsewhere in the listing that this check cannot resolve keeps every `[gone]` branch in the pass (see below), a repo-wide case `-D` never produced.
27
27
  - **A cherry that cannot answer is a KEEP.** Capture git's output; never pipe git itself: `cmd | grep -q` takes grep's exit status, never cmd's, so a `git cherry` that dies (rc 128 — one unreadable loose object is enough) prints nothing, grep exits 1, and a dead probe reads identical to "nothing unmerged" (verified, git 2.50.1). Unanswerable probe authorizes no delete — same fail-closed shape as `worktree remove` below. Read the `+` anchored, too: the capture folds stderr in, so an unanchored match reads a `+` in a diagnostic as a commit. Pinned by a test.
28
28
  - **A worktree registry that cannot be read is a KEEP, for every `[gone]` branch in that pass.** The lookup decides whether the dirty, ignored-files and removal checks run at all; an unread registry reads identical to "no worktree", and `-D`, the delete then, needed no answer from the registry to delete a branch that has none — so failing open force-deleted merged branches blind (#622). The holder check before the delete asks only whether a worktree holds the branch, never those other questions, so the lookup still fails closed on its own. The listing is re-read per branch, so one repo-level failure keeps every `[gone]` branch in the pass, each with git's own cause. Reap runs after every merge pass, so the next one retries: nothing is lost, nothing is reaped blind.