@feigi/fleet-ctl 3.80.2 → 3.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.80.2",
3
+ "version": "3.81.0",
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": {
@@ -51,14 +51,17 @@
51
51
  // GitHub's `mergeable` — the merge queue, which PRs are owed a
52
52
  // review, which conflict, and the PR half of the In-flight count:
53
53
  // every open PR that closes an issue, whoever opened it and
54
- // whatever its state. Also, for each ticket holding the
55
- // implementer row on a tier mismatch or owed a replacement,
56
- // whether its issue is CLOSED (`gh issue view`): a closed
57
- // ticket's mismatch holds nothing and its owed replacement is not
58
- // in flight. And, for each in-flight `review=` token whose PR
59
- // the open list does not carry, whether that PR is MERGED or
60
- // CLOSED (`gh pr view`): a review cannot be running against a
61
- // finished PR, so its dangling token is not a review unit.
54
+ // whatever its state. Also, for each in-flight `review=` token
55
+ // whose PR the open list does not carry, and for each ticket's
56
+ // latest settled `impl-*=PR#<M>` PR the open list does not carry,
57
+ // that PR's state (`gh pr view`, once per PR): a review cannot
58
+ // be running against a finished PR, so its dangling token is not
59
+ // a review unit, and a PR CLOSED unmerged prints an ESCALATE line
60
+ // for its ticket. And, for each ticket holding the implementer
61
+ // row on a tier mismatch, owed a replacement, or whose keyed PR
62
+ // read CLOSED or unread, whether its issue is CLOSED (`gh issue
63
+ // view`): a closed ticket's mismatch holds nothing, its owed
64
+ // replacement is not in flight, and its PR prints no line.
62
65
  // main The main checkout against the run-start baseline
63
66
  // `.fleet/main-checkout.sha`, through main-checkout.mjs.
64
67
  // Anything but `clean` — dirty, unknown, no baseline — prints one
@@ -146,16 +149,38 @@ export function reconcile(s) {
146
149
  const rev = reviewers(s);
147
150
  // The conflict rows only ask the controller to write a record — never to
148
151
  // dispatch — so they stand outside the dispatch hold: the fix-applier a
149
- // conflict hold leads to obeys the hold through its own role's row.
152
+ // conflict hold leads to obeys the hold through its own role's row. The
153
+ // closed-PR rows dispatch nothing either.
150
154
  return [
151
155
  ...dispatchHold(s, [...implementers(s), ...rev.rows]),
152
156
  ...rev.conflictRows,
157
+ ...prClosedRows(s),
153
158
  ...dispatchHold(s, [mergeBot(s)]),
154
159
  ...shortlistRows(s),
155
160
  ...routerRows(s),
156
161
  ];
157
162
  }
158
163
 
164
+ // A ticket whose PR closed unmerged while its issue stays open: unknown
165
+ // intent, so the line is the maintainer's and not actionable. One line per
166
+ // ticket, beside the implementer row and never in it — it takes no slot and
167
+ // counts toward no bound. While a member on PR M is live the line names it:
168
+ // nothing resolves the ticket until that member settles. A state probe that
169
+ // could not answer prints its own line, and the next tick probes again.
170
+ function prClosedRows(s) {
171
+ const live = (k) => (k.live.length ? ` — ${k.live.join(", ")} live` : "");
172
+ return [
173
+ ...s.prClosed.map((k) => ({
174
+ role: "tickets", action: `ESCALATE pr-closed #${k.n} PR#${k.pr}${live(k)}`, acts: false,
175
+ detail: `PR#${k.pr} closed unmerged and #${k.n} is open: the maintainer's — dispatch nothing for #${k.n}`,
176
+ })),
177
+ ...s.prUnchecked.map((k) => ({
178
+ role: "tickets", action: `ESCALATE pr-unchecked #${k.n} PR#${k.pr}`, acts: false,
179
+ detail: `gh could not read PR#${k.pr}'s state; read again next tick`,
180
+ })),
181
+ ];
182
+ }
183
+
159
184
  // A main checkout changed since the run's baseline — or one the tick
160
185
  // could not compare — holds every DISPATCHING row: one HOLD per role,
161
186
  // keeping its counts, in place of whatever it would have dispatched. Placed
@@ -919,7 +944,10 @@ export function foldLedger({ rows, dispatched }, open, { lenient = false } = {})
919
944
  export const pastPinDue = (st, head) => st !== undefined && st.pastPinHalt && !st.inFlight && st.reviewedHead !== null
920
945
  && !String(head).toLowerCase().startsWith(st.reviewedHead);
921
946
 
922
- export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed = new Set(), finished = new Set()) {
947
+ // The states `gh pr view --json state` answers: anything else is no answer.
948
+ const PR_STATES = ["OPEN", "MERGED", "CLOSED"];
949
+
950
+ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed = new Set(), prStates = new Map()) {
923
951
  const open = new Set(prs.map((p) => p.number));
924
952
  const { members, byPr, verdicts, claimed, excluded } = foldLedger({ rows, dispatched }, open);
925
953
  // `## Retired` lines as every reader reads them: a line's name before its
@@ -951,6 +979,41 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
951
979
  // A still-open ticket keeps its hold.
952
980
  const mismatched = (m) => m.outcome === "tier-mismatch" || verdicts.mismatch.has(m.name);
953
981
  const tierMismatch = newest.filter((m) => mismatched(m) && !closed.has(m.number)).map((m) => m.name);
982
+ // A ticket whose PR closed unmerged. Each ticket is keyed on its LATEST impl
983
+ // member settled `PR#<M>`, walked the way `newest` is — never on rowPr(),
984
+ // which stays first-wins for the per-PR fold — and left alone while an impl
985
+ // member after that one is live (a replacement is running) or once one
986
+ // after it settled `released` (the claim was released). A later `bailed`,
987
+ // `blocked` or `killed` leaves the key on M. So does a later
988
+ // `tier-mismatch`, but the ticket says nothing while the implementer row
989
+ // holds on that mismatch: the row owes the replacement at its tier — the
990
+ // maintainer's `replace` carried on — and holds every Pull until it is
991
+ // dispatched, so a line saying to dispatch nothing for the ticket would
992
+ // stall it. Once that replacement settles the walk reads it like any other.
993
+ // A mismatch on the keyed member itself is no replacement of M and still
994
+ // prints. A keyed PR the open list carries is normal flow; one it does not
995
+ // is named in `prKeyed` for the caller's state probe, `prStates` its
996
+ // answers (null for a probe that could not answer, none by default).
997
+ // CLOSED escalates, an unanswered probe escalates as unchecked, OPEN and
998
+ // MERGED say nothing, and a ticket whose issue the caller found CLOSED
999
+ // (`closed`) says nothing either way.
1000
+ const settledPr = (m) => (m.outcome?.startsWith("PR#") ? Number(m.outcome.slice("PR#".length)) : null);
1001
+ const supersedes = (o) => settledPr(o) !== null || o.outcome === null || o.outcome === "released";
1002
+ const latestPr = impls.filter((m, i) => settledPr(m) !== null
1003
+ && !impls.some((o, j) => j > i && o.number === m.number && supersedes(o)));
1004
+ const keyed = new Set(latestPr.map((m) => m.name));
1005
+ const replacementHeld = new Set(newest.filter((m) => tierMismatch.includes(m.name) && !keyed.has(m.name)).map((m) => m.number));
1006
+ const prKeyed = latestPr.filter((m) => !replacementHeld.has(m.number)).map((m) => ({ n: m.number, pr: settledPr(m) }))
1007
+ .filter((k) => !open.has(k.pr)).sort((a, b) => a.n - b.n);
1008
+ const prSurfaced = prKeyed.filter((k) => !closed.has(k.n));
1009
+ // Who on PR M is live: an unsettled fix-applier or finisher bound to it, and
1010
+ // a live merge bot when M's latest finisher attempt settled `labelled` with
1011
+ // no `label-off=` after it — the ledger's reading of M in the merge queue.
1012
+ const queuedOnLedger = new Set(latestFinisherAttempts({ rows, dispatched }, new Set(prSurfaced.map((k) => k.pr)))
1013
+ .filter((u) => u.outcome === "labelled").map((u) => u.pr));
1014
+ const liveOn = (pr) => all.filter((m) => m.outcome === null
1015
+ && (((m.family === "fix-pr" || m.family === "finisher-pr") && m.number === pr) || (m.family === "merge-bot" && queuedOnLedger.has(pr))))
1016
+ .map((m) => m.name).sort();
954
1017
  const cleared = (m) => verdicts.ok.has(m.name) || verdicts.unverifiable.has(m.name);
955
1018
  const tierUnchecked = newest.filter((m) => !mismatched(m) && !cleared(m)).map((m) => m.name);
956
1019
  // The In-flight count: tickets between a Claim and their PR's merge. Read off
@@ -1005,12 +1068,13 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
1005
1068
  // A `review=` token with no `reviewed=` after it reads as in flight, and the
1006
1069
  // ledger alone never says otherwise: a review whose own `reviewed=` write
1007
1070
  // never landed stays in flight after its PR merges, counting as a review unit
1008
- // for good. `finished` is the PR numbers the caller has found MERGED or
1009
- // CLOSED, and a finished PR has no review running against it. A PR on the
1010
- // open list is open whatever `finished` says, and none is the default, so a
1071
+ // for good. A PR the caller's probe found MERGED or CLOSED (`prStates`) is
1072
+ // finished, and a finished PR has no review running against it. A PR on the
1073
+ // open list is open whatever the probe says, and none is the default, so a
1011
1074
  // PR nobody has asked about keeps its review live.
1012
1075
  const reviewing = [...byPr.entries()].filter(([, st]) => st.inFlight);
1013
- const liveReviews = reviewing.filter(([n]) => open.has(n) || !finished.has(n));
1076
+ const finished = (n) => prStates.get(n) === "MERGED" || prStates.get(n) === "CLOSED";
1077
+ const liveReviews = reviewing.filter(([n]) => open.has(n) || !finished(n));
1014
1078
  const unsettled = all.filter((m) => m.outcome === null).map((m) => m.name);
1015
1079
  return {
1016
1080
  implLive: live("impl"),
@@ -1019,6 +1083,15 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
1019
1083
  inFlightPrs: prsIn.length,
1020
1084
  owedReplacement,
1021
1085
  owedCounted,
1086
+ // Each ticket's keyed PR the open list does not carry, `{n, pr}` ascending
1087
+ // by ticket: the PRs the caller probes the state of.
1088
+ prKeyed,
1089
+ // The tickets whose keyed PR the probe read CLOSED, each with `live`, the
1090
+ // members on PR M still unsettled; and those whose probe did not answer.
1091
+ // Additive lines beside the implementer row: neither counts toward
1092
+ // anything above.
1093
+ prClosed: prSurfaced.filter((k) => prStates.get(k.pr) === "CLOSED").map((k) => ({ ...k, live: liveOn(k.pr) })),
1094
+ prUnchecked: prSurfaced.filter((k) => !PR_STATES.includes(prStates.get(k.pr))),
1022
1095
  fixLive: live("fix-pr"),
1023
1096
  mergeBotLive,
1024
1097
  // The number of every merge bot settled `done`: each is a merge pass
@@ -1026,7 +1099,7 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
1026
1099
  mergeBotDone: all.filter((m) => m.family === "merge-bot" && m.outcome === "done").map((m) => m.number).sort(asc),
1027
1100
  reviewsLive: liveReviews.length,
1028
1101
  // The in-flight reviews of PRs the open list does not carry: the ones
1029
- // `finished` could retire, so the caller asks gh about these and no others.
1102
+ // the probe could retire, so the caller asks gh about these and no others.
1030
1103
  reviewsOffList: reviewing.filter(([n]) => !open.has(n)).map(([n]) => n).sort(asc),
1031
1104
  // A returned review whose survived or unverified findings no review
1032
1105
  // fix-applier has answered — `ledger.mjs dispatch` refuses a finisher
@@ -1687,21 +1760,27 @@ function liftedPremise(excluded, entries, prs) {
1687
1760
  }
1688
1761
 
1689
1762
  // The tickets whose issue is CLOSED, of those the tick would otherwise keep
1690
- // counting or holding on: every ticket holding the implementer row on a tier
1691
- // mismatch, and every ticket owed a replacement. One live read deriveRun needs
1692
- // to lift a hold, and uncount a ticket, whose replacement has nothing left to
1693
- // replace. One `gh issue view` per ticket, deduped (a tier-mismatch outcome is
1763
+ // counting, holding on or escalating: every ticket holding the implementer row
1764
+ // on a tier mismatch, every ticket owed a replacement, and every ticket whose
1765
+ // keyed PR the state probe read CLOSED or could not read (`surfacing`). One
1766
+ // live read deriveRun needs to lift a hold, uncount a ticket whose replacement
1767
+ // has nothing left to replace, and drop a closed-PR line for a ticket that is
1768
+ // done. One `gh issue view` per ticket, deduped (a tier-mismatch outcome is
1694
1769
  // both), and none when nothing is owed. A probe that cannot answer — a nonzero
1695
1770
  // exit, or a reply carrying no issue state — is disclosed, and the ticket
1696
- // stays counted and its hold stands: an unreadable state never uncounts.
1697
- function closedTickets(mismatched, owed) {
1771
+ // stays counted, its hold stands and its line prints: an unreadable state
1772
+ // never uncounts.
1773
+ function closedTickets(mismatched, owed, surfacing) {
1698
1774
  const closed = new Set();
1699
1775
  const mismatchedNumbers = new Set(mismatched.map((name) => parseMember(name).number));
1700
- const numbers = new Set([...mismatchedNumbers, ...owed.map((name) => parseMember(name).number)]);
1776
+ const owedNumbers = new Set(owed.map((name) => parseMember(name).number));
1777
+ const numbers = new Set([...mismatchedNumbers, ...owedNumbers, ...surfacing]);
1701
1778
  for (const number of numbers) {
1702
1779
  const unconfirmed = mismatchedNumbers.has(number)
1703
1780
  ? `tier mismatch on #${number} unconfirmed closed, hold stands`
1704
- : `replacement owed on #${number} unconfirmed closed, ticket stays counted`;
1781
+ : owedNumbers.has(number)
1782
+ ? `replacement owed on #${number} unconfirmed closed, ticket stays counted`
1783
+ : `#${number} unconfirmed closed, its closed-PR line stands`;
1705
1784
  const r = spawnSync("gh", ["issue", "view", String(number), "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
1706
1785
  if (r.error || r.status !== 0) {
1707
1786
  console.error(`${NAME}: ${failure(r, `gh issue view ${number}`)} — ${unconfirmed}`);
@@ -1719,29 +1798,36 @@ function closedTickets(mismatched, owed) {
1719
1798
  return closed;
1720
1799
  }
1721
1800
 
1722
- // The PRs, of those whose review the ledger reads as in flight yet the open
1723
- // list does not carry, that gh says are MERGED or CLOSED. A review cannot be
1724
- // running against a finished PR, so the ledger's dangling `review=` token must
1725
- // not count as a review unit for good. A probe that cannot answer — a nonzero
1726
- // exit, a reply carrying no state, a state that is none of the three — is
1727
- // disclosed and the review keeps its unit: an unreadable state is never read
1728
- // as finished. A PR gh calls OPEN keeps it quietly.
1729
- function finishedReviewPrs(offList) {
1730
- const finished = new Set();
1731
- for (const number of offList) {
1801
+ // The state of each PR the tick asks gh about, one `gh pr view --json state`
1802
+ // per PR however many readings need it: `reviews`, the PRs whose review the
1803
+ // ledger reads as in flight yet the open list does not carry, and `keyed`,
1804
+ // each ticket's keyed PR the open list does not carry (deriveRun's
1805
+ // `prKeyed`). The answer is OPEN, MERGED or CLOSED, or null for a probe that
1806
+ // could not answer — a nonzero exit, a reply carrying no state, a state that
1807
+ // is none of the three — which is disclosed with what it leaves standing.
1808
+ function prStates(reviews, keyed) {
1809
+ const states = new Map();
1810
+ const reviewed = new Set(reviews);
1811
+ // Two tickets can be keyed on one PR: each is named, on its one probe.
1812
+ const ticketsOf = new Map();
1813
+ for (const k of keyed) ticketsOf.set(k.pr, [...(ticketsOf.get(k.pr) ?? []), k.n]);
1814
+ for (const number of [...new Set([...reviews, ...ticketsOf.keys()])].sort((a, b) => a - b)) {
1815
+ const unconfirmed = [
1816
+ ...(reviewed.has(number) ? [`review on PR#${number} unconfirmed finished, its unit stands`] : []),
1817
+ ...(ticketsOf.get(number) ?? []).map((n) => `#${n}'s PR#${number} unconfirmed closed, printed as pr-unchecked`),
1818
+ ].join("; ");
1819
+ states.set(number, null);
1732
1820
  const r = spawnSync("gh", ["pr", "view", String(number), "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
1733
1821
  if (r.error || r.status !== 0) {
1734
- console.error(`${NAME}: ${failure(r, `gh pr view ${number}`)} — review on PR#${number} unconfirmed finished, its unit stands`);
1822
+ console.error(`${NAME}: ${failure(r, `gh pr view ${number}`)} — ${unconfirmed}`);
1735
1823
  continue;
1736
1824
  }
1737
1825
  let st = null;
1738
1826
  try { st = JSON.parse(r.stdout).state; } catch { /* no state: disclosed below */ }
1739
- if (st === "MERGED" || st === "CLOSED") finished.add(number);
1740
- else if (st !== "OPEN") {
1741
- console.error(`${NAME}: gh pr view ${number} printed no PR state — review on PR#${number} unconfirmed finished, its unit stands`);
1742
- }
1827
+ if (PR_STATES.includes(st)) states.set(number, st);
1828
+ else console.error(`${NAME}: gh pr view ${number} printed no PR state — ${unconfirmed}`);
1743
1829
  }
1744
- return finished;
1830
+ return states;
1745
1831
  }
1746
1832
 
1747
1833
  // shortlist.mjs, run by the tick itself. Its per-ticket stderr is
@@ -1885,15 +1971,21 @@ export function controllerState(controller, name = NAME) {
1885
1971
  }
1886
1972
  }
1887
1973
 
1888
- // The run off the ledger, with the closed tickets and finished reviews gh
1889
- // says the ledger cannot. A ledger it cannot read refuses the tick.
1974
+ // The run off the ledger, with the closed tickets, finished reviews and keyed
1975
+ // PR states gh says the ledger cannot. A review cannot be running against a
1976
+ // MERGED or CLOSED PR, so its dangling `review=` token stops counting as a
1977
+ // review unit; an unread state is never read as finished. The issue probe asks
1978
+ // only about the tickets whose keyed PR read CLOSED or could not be read: OPEN
1979
+ // and MERGED print nothing whatever the issue says. A ledger it cannot read
1980
+ // refuses the tick.
1890
1981
  function readRun(prs) {
1891
1982
  try {
1892
1983
  const ledger = readLedger();
1893
1984
  const run = deriveRun(ledger, prs);
1894
- const closed = closedTickets(run.tierMismatch, run.owedReplacement);
1895
- const finished = finishedReviewPrs(run.reviewsOffList);
1896
- return closed.size || finished.size ? deriveRun(ledger, prs, closed, finished) : run;
1985
+ const states = prStates(run.reviewsOffList, run.prKeyed);
1986
+ const surfacing = run.prKeyed.filter((k) => states.get(k.pr) !== "OPEN" && states.get(k.pr) !== "MERGED").map((k) => k.n);
1987
+ const closed = closedTickets(run.tierMismatch, run.owedReplacement, surfacing);
1988
+ return deriveRun(ledger, prs, closed, states);
1897
1989
  } catch (e) {
1898
1990
  if (e instanceof LedgerError) die(e.message);
1899
1991
  throw e;
@@ -18,7 +18,13 @@
18
18
  // be read, is `none`.
19
19
  // dead every open in-progress issue with no open PR: `resume` when
20
20
  // exactly one worktree for it sits under this checkout's
21
- // `.worktrees/` and its directory is there, `report` otherwise
21
+ // `.worktrees/` and its directory is there, `report` otherwise.
22
+ // That worktree's branch is listed first, every PR state: every
23
+ // one CLOSED unmerged — none OPEN, none MERGED — is `report` with
24
+ // `why` `PR #<M> closed unmerged` — an unmerged close is the
25
+ // maintainer's to rule on — and a list that does not answer, or a
26
+ // detached worktree whose branch cannot be asked about, is
27
+ // `report` too, never `resume`.
22
28
  // ancestor this session's earlier run: no claim listed — its members may
23
29
  // still be live, and rotate carried their tokens forward;
24
30
  // `carried` lists them instead, for the controller to reconcile
@@ -95,6 +101,30 @@ function ghList(what, args) {
95
101
  return rows;
96
102
  }
97
103
 
104
+ // The PRs a claim's branch opened, every state: `{closed}`, the highest PR
105
+ // number when every one of them is CLOSED unmerged — none OPEN, none MERGED,
106
+ // at least one — else null; or `{failed}`, why the list did not answer. Never
107
+ // a refusal of the step: an unanswered list leaves this one claim unresumed,
108
+ // and the others classify.
109
+ function closedHeadPr(branch) {
110
+ const r = spawnSync("gh", ["pr", "list", "--state", "all", "--head", branch, "--json", "number,state", "--limit", String(LIMIT)], {
111
+ encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }),
112
+ });
113
+ if (r.error || r.status !== 0) return { failed: how(r) };
114
+ let rows;
115
+ try {
116
+ rows = JSON.parse(r.stdout);
117
+ } catch (e) {
118
+ return { failed: `unparsable: ${e.message}` };
119
+ }
120
+ if (!Array.isArray(rows) || !rows.every((p) => Number.isInteger(p?.number) && ["OPEN", "CLOSED", "MERGED"].includes(p.state))) {
121
+ return { failed: "not a list of {number, state}" };
122
+ }
123
+ if (rows.length >= LIMIT) return { failed: `at its cap of ${LIMIT}` };
124
+ const closed = rows.length > 0 && rows.every((p) => p.state === "CLOSED");
125
+ return { closed: closed ? Math.max(...rows.map((p) => p.number)) : null };
126
+ }
127
+
98
128
  // Every registered worktree: its path, branch (null when detached) and
99
129
  // whether git calls it prunable — its directory gone.
100
130
  function worktrees(cwd) {
@@ -205,13 +235,21 @@ function main() {
205
235
  const inside = mine.filter((w) => real(dirname(w.path)) === home);
206
236
  const outside = mine.filter((w) => !inside.includes(w));
207
237
  const one = inside.length === 1 ? inside[0] : null;
238
+ // A dead claim's branch that opened a PR: every one CLOSED unmerged is
239
+ // reported in place of the resume, and so is a branch whose PRs cannot be
240
+ // listed — or cannot be asked about at all, its worktree detached. A
241
+ // branch that never opened one resumes as before.
242
+ const head = prior === "dead" && one?.branch ? closedHeadPr(one.branch) : null;
208
243
  let why;
209
244
  if (prior !== "dead") why = "no record proves the previous run dead, so nothing is resumed";
245
+ else if (one && !one.branch) why = "its worktree is detached, so whether its branch opened a PR cannot be told — nothing is resumed";
246
+ else if (head?.failed) why = `gh pr list --head ${one.branch} did not answer (${head.failed}) — whether its branch opened a PR is unknown, so nothing is resumed`;
247
+ else if (head?.closed) why = `PR #${head.closed} closed unmerged`;
210
248
  else if (one) why = "its worktree is in this checkout";
211
249
  else if (inside.length > 1) why = `several worktrees for it in this checkout: ${inside.map((w) => w.path).join(", ")}`;
212
250
  else if (outside.length > 0) why = `its worktree is outside this checkout's .worktrees/: ${outside.map((w) => w.path).join(", ")}`;
213
251
  else why = "no worktree for it in this checkout";
214
- const action = prior === "dead" && one && mentioned.length === 0 ? "resume" : "report";
252
+ const action = prior === "dead" && one?.branch && mentioned.length === 0 && !head?.failed && !head?.closed ? "resume" : "report";
215
253
  // Not taken for its PR — but never resumed past one that might be.
216
254
  if (mentioned.length > 0) why += `; ${mentioned.join(", ")} carries the number outside the ticket position, so check whether it is this claim's`;
217
255
  claims.push({ n: issue.number, t: issue.title, action, worktree: one?.path ?? null, branch: one?.branch ?? null, why });
@@ -74,13 +74,14 @@ phase, or in any later one, asks the maintainer which tickets to take.
74
74
  run `phase0.mjs` again. `RECIPE NOT PROVEN` → stall: report `STALL —
75
75
  Recipe not proven: <cause>` to the maintainer with the deriver's reason
76
76
  verbatim, and claim, Pull and review nothing.
77
- - `NEED resume #<n> <worktree> <branch>` — a dead run's claim with no PR.
77
+ - `NEED resume #<n> <worktree> <branch>` — a dead run's claim: no PR, or not only `CLOSED` ones.
78
78
  Dispatch a stranded `impl-<n>-b` the ordinary way (record it with
79
79
  `ledger.mjs row`, render its brief, `ledger.mjs dispatch`, the `task`
80
80
  call, the tier check), in that worktree and branch, and never with
81
81
  `claim-ticket.sh`: the claim stands. A re-run finds the need met once its
82
82
  row is written. A `report #<n> — <why>` line is the maintainer's and
83
83
  nothing else, and a `carried` line is a live token to reconcile first.
84
+ A closed-PR `report` takes `replace`/`release #<n>`.
84
85
  - `NEED review PR#<pr> (closes no issue)` — an open PR a prior run left
85
86
  that no row can queue. Advice, never an exit status: hand-dispatch its
86
87
  review exactly as ticket work, or leave it.
@@ -273,6 +274,7 @@ depth** guard table applied in code. Act on each line as it reads:
273
274
  - `ESCALATE unlabelled PR#<M> …` — a second finisher settled `labelled` and the label is still off: not actionable, the maintainer's.
274
275
  - `CONFLICT PR#<M> …` — record the hold with `fleet-event.mjs conflict <M>`, never with the `ledger.mjs row` form; the tick then prints `DISPATCH fix-pr`.
275
276
  - `ESCALATE conflict PR#<M> …` — PR M reads CONFLICTING again after two conflict fix-appliers: comment on the PR and flag it for a human, write no hold, dispatch no fix-applier.
277
+ - `ESCALATE pr-closed #<N> PR#<M>`, `… pr-unchecked …` — the maintainer's: tell them once per ticket per run; no dispatch or comment for #N. Reopening M or closing #N clears it. Only `replace #N` (`NEED resume`, same row) or `release #N` (closed-PR `release-ticket.sh --apply`, settle `released`) acts, once no live member names M.
276
278
  - `HOLD (…)` — the row is held and its detail names the clearing step: dispatch nothing on that row until it clears; a tier mismatch is yours to clear by dispatching the replacement.
277
279
  - `AT CAP`, `IDLE OK` — nothing to do on that row.
278
280
  - `MAIN-CHECKOUT-DIRTY …` — dispatch nothing new and tell the maintainer; once they have resolved the paths, `main-checkout.mjs --record`; never revert the paths or re-baseline over them yourself.
@@ -1218,13 +1220,8 @@ Names follow the unit of work: `impl-<issue#>`, `fix-pr-<pr#>`,
1218
1220
  `review-pr-<pr#>`, `finisher-pr-<pr#>`, `merge-bot-<n>`. See
1219
1221
  references/member-lifecycle.md.
1220
1222
 
1221
- **Fresh context per member.** One member, one unit of work, gone. Never
1222
- re-task a finished member — waking it drags the old ticket back in. Refill =
1223
- **new** member, **new** name. Sending is still right for pinging a live
1224
- member for a report it owes, or resuming a truncated reply — never for
1225
- handing a finished member the next ticket.
1226
-
1227
- `hub send` to an idle peer wakes it into its old transcript the same way; re-dispatching under the same name does not reset it — omp auto-suffixes a fresh peer (`name-2`) instead.
1223
+ **Fresh context per member.** One member, one unit of work, gone. Refill =
1224
+ **new** member, **new** name.
1228
1225
 
1229
1226
  See references/member-lifecycle.md.
1230
1227
 
@@ -389,7 +389,12 @@ token, row}]}`:
389
389
 
390
390
  - `prior: dead` — every open `in-progress` issue with no open PR (one with
391
391
  an open PR is the fold-in's): `resume` when exactly one worktree for it
392
- sits under this checkout's `.worktrees/`, `report` otherwise.
392
+ sits under this checkout's `.worktrees/`, `report` otherwise. That
393
+ worktree's branch is asked `gh pr list --state all --head <branch>` first:
394
+ PRs that all read CLOSED unmerged — none OPEN, none MERGED — make it
395
+ `report` with `why` `PR #<M> closed unmerged`, and so does a list that
396
+ does not answer, or a detached worktree whose branch cannot be asked
397
+ about, never `resume`.
393
398
  - `prior: ancestor` — this session's own earlier run: no claim is listed.
394
399
  `carried` lists every live token rotate carried — its member or runner
395
400
  `name` (a `review=wf:` launch's runId), the `token` as the ledger holds
@@ -428,7 +433,10 @@ impl-<N>-b …`, then `ledger.mjs dispatch <N> impl-<N>-b`, then the `task`
428
433
  call naming the `agent` it printed, then the tier check — and never
429
434
  `claim-ticket.sh`: the claim already stands. A `report` goes to the
430
435
  maintainer as `#<N> — <why>` and nothing else: no dispatch, no label change,
431
- no release. Exit 2 is a list that did not answer — a `gh` or `git`
436
+ no release. One whose `why` reads `PR #<M> closed unmerged` waits on the
437
+ maintainer's `replace #<N>` or `release #<N>`, the two words the tick's
438
+ `ESCALATE pr-closed` line takes (references/tick-rows.md), and on nothing
439
+ else. Exit 2 is a list that did not answer — a `gh` or `git`
432
440
  failure, a list at its cap, a worktree whose presence cannot be told, or,
433
441
  after `ancestor`, a ledger that cannot be read — and is never nothing
434
442
  stranded.
@@ -60,6 +60,41 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
60
60
  and flag it for a human; write no hold and dispatch no fix-applier. Not
61
61
  actionable; it repeats while the PR reads CONFLICTING and clears once it
62
62
  stops, closes or merges.
63
+ - `ESCALATE pr-closed #<N> PR#<M>` — PR M, ticket N's latest PR (the latest
64
+ `impl-<N>…=PR#<M>` on the row), is CLOSED unmerged while issue #N is not
65
+ read CLOSED (an issue gh cannot read counts as open), and no
66
+ `impl-<N>-<x>` after it is live or settled `released`. An unmerged
67
+ close is unknown intent, so the line is the maintainer's and not
68
+ actionable: tell the maintainer once per ticket per run, dispatch nothing
69
+ for #N, and post no GitHub comment. It prints beside the implementer row,
70
+ never in it, takes no implementer slot and counts toward no bound. While a
71
+ member on PR M is live — `fix-pr-<M>…`, `finisher-pr-<M>…`, or a live
72
+ `merge-bot-<n>` while M's latest finisher settled `labelled` with no
73
+ `label-off=` after it — the line ends ` — <member> live`. Reopening PR M,
74
+ or closing #N, clears it by itself on the next tick. Act only on the
75
+ maintainer's `replace #N` or `release #N`, and only once the line names no
76
+ live member:
77
+ - `replace #N` — the `NEED resume` procedure (phase 0): `impl-<N>-b`, or
78
+ one letter past the highest `impl-<N>-<x>` the ticket has used, in the
79
+ existing worktree and branch, never `claim-ticket.sh`. Its new PR settles
80
+ onto the same row, and that two-PR row is a legitimate shape: the tick
81
+ keys the row on the newer PR from then on.
82
+ - `release #N` — `release-ticket.sh <N> impl-<N> implementer --apply`,
83
+ which runs its closed-PR mode on a branch whose PRs all read CLOSED, then
84
+ settle `released`: `ledger.mjs row <N> "<text> · impl-<N>-<x>"`, the next
85
+ letter, and `ledger.mjs settle impl-<N>-<x> released`, which ends the
86
+ line.
87
+
88
+ A replacement that settles `bailed`, `blocked` or `killed` brings the line
89
+ back on M, and one whose own PR M′ closes unmerged prints it on M′. One the
90
+ implementer row holds on a tier mismatch (`HOLD (tier mismatch …)`) keeps
91
+ the line off while that row asks for the next replacement — the `replace`
92
+ carried on at the right tier — and once that one settles, the line reads it
93
+ the same way. A tier mismatch on the member that settled `PR#<M>` itself
94
+ replaces nothing, so the line stands beside that hold.
95
+ - `ESCALATE pr-unchecked #<N> PR#<M>` — the same ticket, but `gh pr view
96
+ <M>` could not answer, so whether M closed is unknown. The maintainer's on
97
+ the same terms as `pr-closed`; the next tick probes again.
63
98
  - `HOLD (…)` — the row is held and says why: draining, a tier mismatch, an
64
99
  unchecked tier, the In-flight bound reached (`HOLD (in-flight <n>/<B>)` —
65
100
  nothing to do unless its detail names a replacement to dispatch, else a