@webpieces/repo-workflow-core 0.0.1

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.
Files changed (46) hide show
  1. package/package.json +31 -0
  2. package/src/agent-worktree-lock.d.ts +21 -0
  3. package/src/agent-worktree-lock.js +77 -0
  4. package/src/agent-worktree-lock.js.map +1 -0
  5. package/src/branch-archiver.d.ts +70 -0
  6. package/src/branch-archiver.js +157 -0
  7. package/src/branch-archiver.js.map +1 -0
  8. package/src/branch-identity.d.ts +6 -0
  9. package/src/branch-identity.js +59 -0
  10. package/src/branch-identity.js.map +1 -0
  11. package/src/branch-mutation-log.d.ts +77 -0
  12. package/src/branch-mutation-log.js +221 -0
  13. package/src/branch-mutation-log.js.map +1 -0
  14. package/src/branch-reaper.d.ts +122 -0
  15. package/src/branch-reaper.js +261 -0
  16. package/src/branch-reaper.js.map +1 -0
  17. package/src/harness-agent-activity.d.ts +154 -0
  18. package/src/harness-agent-activity.js +346 -0
  19. package/src/harness-agent-activity.js.map +1 -0
  20. package/src/index.d.ts +14 -0
  21. package/src/index.js +18 -0
  22. package/src/index.js.map +1 -0
  23. package/src/main-sync-file.d.ts +121 -0
  24. package/src/main-sync-file.js +281 -0
  25. package/src/main-sync-file.js.map +1 -0
  26. package/src/main-sync-status.d.ts +197 -0
  27. package/src/main-sync-status.js +555 -0
  28. package/src/main-sync-status.js.map +1 -0
  29. package/src/merged-branch-verdicts.d.ts +189 -0
  30. package/src/merged-branch-verdicts.js +251 -0
  31. package/src/merged-branch-verdicts.js.map +1 -0
  32. package/src/merged-branches.d.ts +140 -0
  33. package/src/merged-branches.js +521 -0
  34. package/src/merged-branches.js.map +1 -0
  35. package/src/review-identity-stamp.d.ts +82 -0
  36. package/src/review-identity-stamp.js +172 -0
  37. package/src/review-identity-stamp.js.map +1 -0
  38. package/src/worktree-lock-verdicts.d.ts +149 -0
  39. package/src/worktree-lock-verdicts.js +225 -0
  40. package/src/worktree-lock-verdicts.js.map +1 -0
  41. package/src/worktree-reaper.d.ts +116 -0
  42. package/src/worktree-reaper.js +318 -0
  43. package/src/worktree-reaper.js.map +1 -0
  44. package/src/worktrees.d.ts +113 -0
  45. package/src/worktrees.js +260 -0
  46. package/src/worktrees.js.map +1 -0
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WorktreeLockVerdicts = exports.LockDecision = exports.LockEvidence = exports.HARNESS_NOT_CONSULTED = exports.LOCK_LIVENESS_UNVERIFIABLE = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const inversify_1 = require("inversify");
6
+ const agent_worktree_lock_1 = require("./agent-worktree-lock");
7
+ const harness_agent_activity_1 = require("./harness-agent-activity");
8
+ const worktrees_1 = require("./worktrees");
9
+ /**
10
+ * What a `git worktree lock` DOES to a worktree's cleanup verdict.
11
+ *
12
+ * Separate from agent-worktree-lock.ts (which only parses a lock reason) and from
13
+ * harness-agent-activity.ts (which only asks the harness about an agent): this is the POLICY built on
14
+ * top of that evidence — what stops a reap, what a spared lock is allowed to CLAIM about who took it,
15
+ * and what an overridden one has to leave on the verdict so the reaper knows to clear it.
16
+ *
17
+ * ─── WHY THE POLICY CHANGED ──────────────────────────────────────────────────────────────────────
18
+ * It used to be one question, asked of the kernel: is the pid in the lock reason still running? That
19
+ * pid is the Claude Code SESSION process, shared by every subagent, so the answer was yes for the
20
+ * whole life of a session and every agent worktree was spared forever — nine against a cap of five,
21
+ * with one agent running, each carrying the sentence "that agent is working in here" about agents
22
+ * whose PRs had already merged. Both halves were defects: the reap never happened, and the message
23
+ * asserted something nobody knew.
24
+ *
25
+ * ─── THE DECISION TABLE, AND THE ONE RULE BEHIND IT ──────────────────────────────────────────────
26
+ *
27
+ * HARNESS STATE MAY ONLY VETO A REAP. IT MAY NEVER LICENSE ONE.
28
+ *
29
+ * The licence comes solely from the branch/commit evidence wp-cleanup already computes and already
30
+ * gets right. So, for a lock this repo can read as the harness's own:
31
+ *
32
+ * branch evidence does NOT say deletable → SPARE. The harness is not even consulted; it
33
+ * cannot promote a spare into a reap.
34
+ * deletable, but the worktree is DIRTY → SPARE, unconditionally. Uncommitted or
35
+ * untracked files are work no archive tag can
36
+ * bring back. This one overrides everything,
37
+ * including the explicit flag.
38
+ * deletable, clean, harness says the agent is
39
+ * mid-tool-call and writing → SPARE. The veto.
40
+ * deletable, clean, harness says returned /
41
+ * long-stale / cannot tell → judge it on its branch like any unlocked tree,
42
+ * clearing the lock on the way out.
43
+ *
44
+ * `--ignore-stale-locks` moves ONE line of that table: it lets a locked worktree whose branch is not
45
+ * provably dead be classified on its real branch and commit state anyway (so a zero-commit husk gets
46
+ * reaped and a worktree holding unique commits reaches the numbered block a human can answer). It
47
+ * does not touch the dirty rail or the live-agent veto, because neither of those is what the caller
48
+ * is complaining about when they pass it.
49
+ *
50
+ * ─── SCOPE: AGENT LOCKS ONLY ─────────────────────────────────────────────────────────────────────
51
+ * Every override applies ONLY to a lock this file can read as the harness's own. A lock reason naming
52
+ * anything else — a human's "dean is debugging", or nothing at all — is still an instruction from
53
+ * somebody, and wp-cleanup still obeys it.
54
+ */
55
+ /**
56
+ * The exact phrase a spared "we cannot tell" verdict carries, exported so wp-cleanup can spot those
57
+ * worktrees and offer the flag that judges them on their branch instead.
58
+ *
59
+ * ONE spelling, shared: the alternative is the caller re-deriving the same condition from a second
60
+ * copy of the sentence, which is how a message and the code that keys off it drift apart.
61
+ */
62
+ exports.LOCK_LIVENESS_UNVERIFIABLE = 'whether that agent is still running cannot be verified';
63
+ /**
64
+ * The stand-in for "the harness was never asked", used on the paths where asking could not change the
65
+ * answer. It is UNKNOWN, so a caller that consults it anyway still fails safe.
66
+ */
67
+ exports.HARNESS_NOT_CONSULTED = new harness_agent_activity_1.AgentActivity(harness_agent_activity_1.AGENT_ACTIVITY_UNKNOWN, 'the harness was not consulted');
68
+ // Data-only (per CLAUDE.md, classes for data). Everything `decide` rules on, as one value — see
69
+ // `gather`, which is the only thing that builds one outside a test.
70
+ class LockEvidence {
71
+ /** The lock reason verbatim, straight out of `git worktree list --porcelain`. */
72
+ lockReason;
73
+ /** What the harness says about the agent named in that reason, or HARNESS_NOT_CONSULTED. */
74
+ activity;
75
+ /** Does `git status --porcelain` report anything, or could it not answer? Either way: held. */
76
+ workInFlight;
77
+ /** Why it is held, in git's terms — printed verbatim, because the two reasons differ for a human. */
78
+ workInFlightReason;
79
+ /** Is the branch this worktree holds ALREADY provably dead by the ordinary verdicts? */
80
+ branchDeletable;
81
+ /** The caller said to treat a standing agent lock as no evidence at all. */
82
+ ignoreStaleAgentLocks;
83
+ constructor(lockReason, activity, workInFlight, workInFlightReason, branchDeletable, ignoreStaleAgentLocks) {
84
+ this.lockReason = lockReason;
85
+ this.activity = activity;
86
+ this.workInFlight = workInFlight;
87
+ this.workInFlightReason = workInFlightReason;
88
+ this.branchDeletable = branchDeletable;
89
+ this.ignoreStaleAgentLocks = ignoreStaleAgentLocks;
90
+ }
91
+ }
92
+ exports.LockEvidence = LockEvidence;
93
+ /**
94
+ * Data-only. The one verdict this file produces: spare or override, the sentence that says WHY, and
95
+ * the agent lock the reaper will have to clear if the worktree goes.
96
+ *
97
+ * ONE class rather than a spare-path method and an override-path method, because the two answers are
98
+ * mutually exclusive readings of the same evidence, and two methods is how they drift apart — which
99
+ * is precisely what happened before: the old spared message and the old stale-lock test were computed
100
+ * from the same pid by different code, and only one of them was ever corrected.
101
+ */
102
+ class LockDecision {
103
+ /** True: leave this worktree alone, and `reason` is the whole story. */
104
+ spare;
105
+ /** Spared: why it was left. Overridden: the clause appended to the branch's own verdict. */
106
+ reason;
107
+ /** The agent lock to clear on the way out — null when nothing may be cleared. */
108
+ lock;
109
+ constructor(spare, reason, lock) {
110
+ this.spare = spare;
111
+ this.reason = reason;
112
+ this.lock = lock;
113
+ }
114
+ }
115
+ exports.LockDecision = LockDecision;
116
+ let WorktreeLockVerdicts = class WorktreeLockVerdicts {
117
+ agentLocks;
118
+ harness;
119
+ worktrees;
120
+ // Defaulted like BranchReaper's collaborators, so the non-DI call sites can just
121
+ // `new WorktreeLockVerdicts()` while inversify still injects the singletons from a container.
122
+ constructor(agentLocks = new agent_worktree_lock_1.AgentWorktreeLockReader(), harness = new harness_agent_activity_1.HarnessAgentActivityReader(), worktrees = new worktrees_1.WorktreeService()) {
123
+ this.agentLocks = agentLocks;
124
+ this.harness = harness;
125
+ this.worktrees = worktrees;
126
+ }
127
+ /**
128
+ * Collect what `decide` rules on — and, just as deliberately, DECLINE to collect the two
129
+ * expensive facts when they cannot change the answer.
130
+ *
131
+ * `git status --porcelain` and a walk of the harness's state tree are gathered only when a reap
132
+ * is otherwise on the table: the lock is one this repo can read as the harness's own, and either
133
+ * the branch is already provably dead or the caller asked for locks to be ignored. That is not an
134
+ * optimisation dressed up as a rule — harness state may only VETO a reap, never license one, so on
135
+ * a path with no reap to veto there is nothing to ask, and asking would invite a later reader to
136
+ * let the answer decide something.
137
+ */
138
+ gather(tree, branchDeletable, ignoreStaleAgentLocks) {
139
+ const lock = this.agentLocks.parse(tree.lockReason);
140
+ if (lock === null || (!branchDeletable && !ignoreStaleAgentLocks)) {
141
+ return new LockEvidence(tree.lockReason, exports.HARNESS_NOT_CONSULTED, false, '', branchDeletable, ignoreStaleAgentLocks);
142
+ }
143
+ const held = this.worktrees.workInFlight(tree.path);
144
+ return new LockEvidence(tree.lockReason, this.harness.activityOf(lock.agent, tree.path), held.held, held.reason, branchDeletable, ignoreStaleAgentLocks);
145
+ }
146
+ /**
147
+ * Spare this locked worktree, or judge it on its branch like any unlocked one — with the sentence
148
+ * that says which piece of evidence decided it.
149
+ *
150
+ * The order below IS the decision table in the class header, in the same order, for the same
151
+ * reasons. Read it there.
152
+ */
153
+ decide(evidence) {
154
+ const lock = this.agentLocks.parse(evidence.lockReason);
155
+ if (lock === null)
156
+ return new LockDecision(true, this.foreignLockReason(evidence.lockReason), null);
157
+ if (!evidence.branchDeletable && !evidence.ignoreStaleAgentLocks) {
158
+ return new LockDecision(true, this.unverifiableReason(lock), null);
159
+ }
160
+ if (evidence.workInFlight) {
161
+ return new LockDecision(true, `locked by claude agent ${lock.agent}, and the worktree ${evidence.workInFlightReason} — `
162
+ + 'nothing archives that, so it is left exactly where it is', null);
163
+ }
164
+ if (evidence.activity.state === harness_agent_activity_1.AGENT_ACTIVITY_LIVE) {
165
+ return new LockDecision(true, `locked by claude agent ${lock.agent}, and the Claude Code harness says `
166
+ + `${evidence.activity.detail} — that agent is working in here`, null);
167
+ }
168
+ if (evidence.branchDeletable) {
169
+ return new LockDecision(false, `the lock from claude agent ${lock.agent} did not stop this — the worktree is clean, its `
170
+ + `branch is already dead, and ${evidence.activity.detail}`, lock);
171
+ }
172
+ return new LockDecision(false, `the lock from claude agent ${lock.agent} was treated as no evidence, as asked`, lock);
173
+ }
174
+ /**
175
+ * WHY a locked worktree was left alone when the lock is NOT the harness's — reporting what the
176
+ * reason SAYS, never who we imagine wrote it.
177
+ *
178
+ * This used to read `locked by a human — do not touch` for every lock in existence, which named an
179
+ * actor nothing in the evidence identifies: the lock reason is the ONLY thing we have, a human may
180
+ * have locked it, so may some other tool, and asserting either is the same defect whichever way it
181
+ * lands.
182
+ */
183
+ foreignLockReason(lockReason) {
184
+ if (lockReason === '') {
185
+ return 'locked with no reason recorded — nothing says who locked it or why, so it is left alone';
186
+ }
187
+ return `locked, reason "${lockReason}" — that is not a claude agent lock, so who locked it `
188
+ + 'is unknown; left alone';
189
+ }
190
+ /**
191
+ * The sentence that replaced "pid N still running — that agent is working in here".
192
+ *
193
+ * It says only what is known: which agent the reason names, that the pid in it cannot answer the
194
+ * question because it is the shared session process, and that nothing else made the worktree
195
+ * provably dead either. No claim about anybody working — and no claim about the harness, which on
196
+ * this path was deliberately never asked, because its answer could not have changed the outcome.
197
+ */
198
+ unverifiableReason(lock) {
199
+ return `locked by claude agent ${lock.agent}; ${exports.LOCK_LIVENESS_UNVERIFIABLE} — the recorded pid `
200
+ + `${String(lock.pid)} is the shared Claude Code session process, not the agent's. Its branch `
201
+ + 'is not provably dead either, so it is left alone';
202
+ }
203
+ /**
204
+ * Stamp an overridden verdict with the lock the reap must CLEAR: git refuses to remove a locked
205
+ * worktree, and the reason has to say why a lock is being overridden at all.
206
+ *
207
+ * Applied to every verdict the fall-through can produce, not just the deletable one, because the
208
+ * spared cases are exactly where a human reads the reason and asks "wasn't that locked?".
209
+ */
210
+ annotate(verdict, decision) {
211
+ if (decision === null || decision.spare || decision.lock === null)
212
+ return verdict;
213
+ verdict.unlockBeforeRemove = true;
214
+ verdict.reason = `${verdict.reason}; ${decision.reason}`;
215
+ return verdict;
216
+ }
217
+ };
218
+ exports.WorktreeLockVerdicts = WorktreeLockVerdicts;
219
+ exports.WorktreeLockVerdicts = WorktreeLockVerdicts = tslib_1.__decorate([
220
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
221
+ tslib_1.__metadata("design:paramtypes", [agent_worktree_lock_1.AgentWorktreeLockReader,
222
+ harness_agent_activity_1.HarnessAgentActivityReader,
223
+ worktrees_1.WorktreeService])
224
+ ], WorktreeLockVerdicts);
225
+ //# sourceMappingURL=worktree-lock-verdicts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktree-lock-verdicts.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/worktree-lock-verdicts.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\nimport { AgentWorktreeLock, AgentWorktreeLockReader } from './agent-worktree-lock';\nimport {\n AGENT_ACTIVITY_LIVE,\n AGENT_ACTIVITY_UNKNOWN,\n AgentActivity,\n HarnessAgentActivityReader,\n} from './harness-agent-activity';\nimport { DeletableWorktree } from './merged-branch-verdicts';\nimport { Worktree, WorktreeService } from './worktrees';\n\n/**\n * What a `git worktree lock` DOES to a worktree's cleanup verdict.\n *\n * Separate from agent-worktree-lock.ts (which only parses a lock reason) and from\n * harness-agent-activity.ts (which only asks the harness about an agent): this is the POLICY built on\n * top of that evidence — what stops a reap, what a spared lock is allowed to CLAIM about who took it,\n * and what an overridden one has to leave on the verdict so the reaper knows to clear it.\n *\n * ─── WHY THE POLICY CHANGED ──────────────────────────────────────────────────────────────────────\n * It used to be one question, asked of the kernel: is the pid in the lock reason still running? That\n * pid is the Claude Code SESSION process, shared by every subagent, so the answer was yes for the\n * whole life of a session and every agent worktree was spared forever — nine against a cap of five,\n * with one agent running, each carrying the sentence \"that agent is working in here\" about agents\n * whose PRs had already merged. Both halves were defects: the reap never happened, and the message\n * asserted something nobody knew.\n *\n * ─── THE DECISION TABLE, AND THE ONE RULE BEHIND IT ──────────────────────────────────────────────\n *\n * HARNESS STATE MAY ONLY VETO A REAP. IT MAY NEVER LICENSE ONE.\n *\n * The licence comes solely from the branch/commit evidence wp-cleanup already computes and already\n * gets right. So, for a lock this repo can read as the harness's own:\n *\n * branch evidence does NOT say deletable → SPARE. The harness is not even consulted; it\n * cannot promote a spare into a reap.\n * deletable, but the worktree is DIRTY → SPARE, unconditionally. Uncommitted or\n * untracked files are work no archive tag can\n * bring back. This one overrides everything,\n * including the explicit flag.\n * deletable, clean, harness says the agent is\n * mid-tool-call and writing → SPARE. The veto.\n * deletable, clean, harness says returned /\n * long-stale / cannot tell → judge it on its branch like any unlocked tree,\n * clearing the lock on the way out.\n *\n * `--ignore-stale-locks` moves ONE line of that table: it lets a locked worktree whose branch is not\n * provably dead be classified on its real branch and commit state anyway (so a zero-commit husk gets\n * reaped and a worktree holding unique commits reaches the numbered block a human can answer). It\n * does not touch the dirty rail or the live-agent veto, because neither of those is what the caller\n * is complaining about when they pass it.\n *\n * ─── SCOPE: AGENT LOCKS ONLY ─────────────────────────────────────────────────────────────────────\n * Every override applies ONLY to a lock this file can read as the harness's own. A lock reason naming\n * anything else — a human's \"dean is debugging\", or nothing at all — is still an instruction from\n * somebody, and wp-cleanup still obeys it.\n */\n\n/**\n * The exact phrase a spared \"we cannot tell\" verdict carries, exported so wp-cleanup can spot those\n * worktrees and offer the flag that judges them on their branch instead.\n *\n * ONE spelling, shared: the alternative is the caller re-deriving the same condition from a second\n * copy of the sentence, which is how a message and the code that keys off it drift apart.\n */\nexport const LOCK_LIVENESS_UNVERIFIABLE = 'whether that agent is still running cannot be verified';\n\n/**\n * The stand-in for \"the harness was never asked\", used on the paths where asking could not change the\n * answer. It is UNKNOWN, so a caller that consults it anyway still fails safe.\n */\nexport const HARNESS_NOT_CONSULTED = new AgentActivity(\n AGENT_ACTIVITY_UNKNOWN, 'the harness was not consulted');\n\n// Data-only (per CLAUDE.md, classes for data). Everything `decide` rules on, as one value — see\n// `gather`, which is the only thing that builds one outside a test.\nexport class LockEvidence {\n /** The lock reason verbatim, straight out of `git worktree list --porcelain`. */\n lockReason: string;\n /** What the harness says about the agent named in that reason, or HARNESS_NOT_CONSULTED. */\n activity: AgentActivity;\n /** Does `git status --porcelain` report anything, or could it not answer? Either way: held. */\n workInFlight: boolean;\n /** Why it is held, in git's terms — printed verbatim, because the two reasons differ for a human. */\n workInFlightReason: string;\n /** Is the branch this worktree holds ALREADY provably dead by the ordinary verdicts? */\n branchDeletable: boolean;\n /** The caller said to treat a standing agent lock as no evidence at all. */\n ignoreStaleAgentLocks: boolean;\n\n constructor(\n lockReason: string,\n activity: AgentActivity,\n workInFlight: boolean,\n workInFlightReason: string,\n branchDeletable: boolean,\n ignoreStaleAgentLocks: boolean,\n ) {\n this.lockReason = lockReason;\n this.activity = activity;\n this.workInFlight = workInFlight;\n this.workInFlightReason = workInFlightReason;\n this.branchDeletable = branchDeletable;\n this.ignoreStaleAgentLocks = ignoreStaleAgentLocks;\n }\n}\n\n/**\n * Data-only. The one verdict this file produces: spare or override, the sentence that says WHY, and\n * the agent lock the reaper will have to clear if the worktree goes.\n *\n * ONE class rather than a spare-path method and an override-path method, because the two answers are\n * mutually exclusive readings of the same evidence, and two methods is how they drift apart — which\n * is precisely what happened before: the old spared message and the old stale-lock test were computed\n * from the same pid by different code, and only one of them was ever corrected.\n */\nexport class LockDecision {\n /** True: leave this worktree alone, and `reason` is the whole story. */\n spare: boolean;\n /** Spared: why it was left. Overridden: the clause appended to the branch's own verdict. */\n reason: string;\n /** The agent lock to clear on the way out — null when nothing may be cleared. */\n lock: AgentWorktreeLock | null;\n\n constructor(spare: boolean, reason: string, lock: AgentWorktreeLock | null) {\n this.spare = spare;\n this.reason = reason;\n this.lock = lock;\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class WorktreeLockVerdicts {\n // Defaulted like BranchReaper's collaborators, so the non-DI call sites can just\n // `new WorktreeLockVerdicts()` while inversify still injects the singletons from a container.\n constructor(\n private readonly agentLocks: AgentWorktreeLockReader = new AgentWorktreeLockReader(),\n private readonly harness: HarnessAgentActivityReader = new HarnessAgentActivityReader(),\n private readonly worktrees: WorktreeService = new WorktreeService(),\n ) {}\n\n /**\n * Collect what `decide` rules on — and, just as deliberately, DECLINE to collect the two\n * expensive facts when they cannot change the answer.\n *\n * `git status --porcelain` and a walk of the harness's state tree are gathered only when a reap\n * is otherwise on the table: the lock is one this repo can read as the harness's own, and either\n * the branch is already provably dead or the caller asked for locks to be ignored. That is not an\n * optimisation dressed up as a rule — harness state may only VETO a reap, never license one, so on\n * a path with no reap to veto there is nothing to ask, and asking would invite a later reader to\n * let the answer decide something.\n */\n gather(tree: Worktree, branchDeletable: boolean, ignoreStaleAgentLocks: boolean): LockEvidence {\n const lock = this.agentLocks.parse(tree.lockReason);\n if (lock === null || (!branchDeletable && !ignoreStaleAgentLocks)) {\n return new LockEvidence(tree.lockReason, HARNESS_NOT_CONSULTED, false, '',\n branchDeletable, ignoreStaleAgentLocks);\n }\n const held = this.worktrees.workInFlight(tree.path);\n return new LockEvidence(\n tree.lockReason, this.harness.activityOf(lock.agent, tree.path), held.held, held.reason,\n branchDeletable, ignoreStaleAgentLocks);\n }\n\n /**\n * Spare this locked worktree, or judge it on its branch like any unlocked one — with the sentence\n * that says which piece of evidence decided it.\n *\n * The order below IS the decision table in the class header, in the same order, for the same\n * reasons. Read it there.\n */\n decide(evidence: LockEvidence): LockDecision {\n const lock = this.agentLocks.parse(evidence.lockReason);\n if (lock === null) return new LockDecision(true, this.foreignLockReason(evidence.lockReason), null);\n\n if (!evidence.branchDeletable && !evidence.ignoreStaleAgentLocks) {\n return new LockDecision(true, this.unverifiableReason(lock), null);\n }\n if (evidence.workInFlight) {\n return new LockDecision(true,\n `locked by claude agent ${lock.agent}, and the worktree ${evidence.workInFlightReason} — `\n + 'nothing archives that, so it is left exactly where it is', null);\n }\n if (evidence.activity.state === AGENT_ACTIVITY_LIVE) {\n return new LockDecision(true,\n `locked by claude agent ${lock.agent}, and the Claude Code harness says `\n + `${evidence.activity.detail} — that agent is working in here`, null);\n }\n if (evidence.branchDeletable) {\n return new LockDecision(false,\n `the lock from claude agent ${lock.agent} did not stop this — the worktree is clean, its `\n + `branch is already dead, and ${evidence.activity.detail}`, lock);\n }\n return new LockDecision(false,\n `the lock from claude agent ${lock.agent} was treated as no evidence, as asked`, lock);\n }\n\n /**\n * WHY a locked worktree was left alone when the lock is NOT the harness's — reporting what the\n * reason SAYS, never who we imagine wrote it.\n *\n * This used to read `locked by a human — do not touch` for every lock in existence, which named an\n * actor nothing in the evidence identifies: the lock reason is the ONLY thing we have, a human may\n * have locked it, so may some other tool, and asserting either is the same defect whichever way it\n * lands.\n */\n private foreignLockReason(lockReason: string): string {\n if (lockReason === '') {\n return 'locked with no reason recorded — nothing says who locked it or why, so it is left alone';\n }\n return `locked, reason \"${lockReason}\" — that is not a claude agent lock, so who locked it `\n + 'is unknown; left alone';\n }\n\n /**\n * The sentence that replaced \"pid N still running — that agent is working in here\".\n *\n * It says only what is known: which agent the reason names, that the pid in it cannot answer the\n * question because it is the shared session process, and that nothing else made the worktree\n * provably dead either. No claim about anybody working — and no claim about the harness, which on\n * this path was deliberately never asked, because its answer could not have changed the outcome.\n */\n private unverifiableReason(lock: AgentWorktreeLock): string {\n return `locked by claude agent ${lock.agent}; ${LOCK_LIVENESS_UNVERIFIABLE} — the recorded pid `\n + `${String(lock.pid)} is the shared Claude Code session process, not the agent's. Its branch `\n + 'is not provably dead either, so it is left alone';\n }\n\n /**\n * Stamp an overridden verdict with the lock the reap must CLEAR: git refuses to remove a locked\n * worktree, and the reason has to say why a lock is being overridden at all.\n *\n * Applied to every verdict the fall-through can produce, not just the deletable one, because the\n * spared cases are exactly where a human reads the reason and asks \"wasn't that locked?\".\n */\n annotate(verdict: DeletableWorktree, decision: LockDecision | null): DeletableWorktree {\n if (decision === null || decision.spare || decision.lock === null) return verdict;\n verdict.unlockBeforeRemove = true;\n verdict.reason = `${verdict.reason}; ${decision.reason}`;\n return verdict;\n }\n}\n"]}
@@ -0,0 +1,116 @@
1
+ import { BranchArchiver } from './branch-archiver';
2
+ import { BranchMutationLog, MutationVerb } from './branch-mutation-log';
3
+ import { DeletableWorktree } from './merged-branch-verdicts';
4
+ import { WorktreeService } from './worktrees';
5
+ /**
6
+ * The EXECUTOR for the dead-WORKTREE verdicts that merged-branches.ts has been computing all along.
7
+ *
8
+ * WHY this exists: `DeletableWorktree` was designed for reaping — it carries `path` (what
9
+ * `git worktree remove` takes) AND `branch` (what `git branch -D` takes afterwards) precisely because
10
+ * a reap is always those two steps in that order. The reaping was then never wired up. The verdicts
11
+ * had exactly one consumer, branch-creation-guard, which used them only to BLOCK: at the worktree cap
12
+ * it printed the reap commands and refused to create the next worktree. Nothing ever ran them.
13
+ *
14
+ * That composes into a deadlock, not merely a missing feature. A merged worktree HOLDS its branch, so
15
+ * the branch lands in `keep` with "checked out in worktree '<path>' — remove that worktree before
16
+ * deleting the branch". Nothing removes the worktree. Both accumulate forever, and the guard's only
17
+ * remaining advice is to loosen its own cap — which is the failure the cap exists to prevent. Observed
18
+ * twice in one day: a `wp-cleanup` run that spared three worktree-held branches with that exact line,
19
+ * and another that spared seven.
20
+ *
21
+ * THE ORDER IS FIXED AND LOAD-BEARING: archive the branch as a tag → `git worktree remove <path>` →
22
+ * `git branch -D <branch>`.
23
+ * - Archive FIRST, and if it fails nothing is deleted. Same rule BranchArchiver already enforces for
24
+ * branches: a branch we could not tag is a branch whose only copy would be the reflog.
25
+ * - Remove the worktree BEFORE the branch, because git flatly refuses to delete a branch that is
26
+ * still checked out somewhere.
27
+ *
28
+ * AND NEVER `--force`. Git refuses to remove a worktree with uncommitted changes or untracked files,
29
+ * and that refusal is the entire safety property here: a worktree removal deletes real FILES, not just
30
+ * a ref, and an untracked file is by definition something no archive tag captured. A failed removal is
31
+ * reported and moved past, exactly as a failed branch delete already is.
32
+ */
33
+ export declare class ReapedWorktree {
34
+ path: string;
35
+ branch: string;
36
+ sha: string;
37
+ reason: string;
38
+ pr: number;
39
+ ok: boolean;
40
+ error: string;
41
+ archiveTag: string;
42
+ /**
43
+ * Did the BRANCH delete also succeed? A worktree removal that succeeds while the branch delete
44
+ * fails is a real, reportable half-state — the directory is gone, the branch is still there — and
45
+ * collapsing it into `ok` would hide it. `ok` means the DIRECTORY is gone; this means the pair is.
46
+ */
47
+ branchDeleted: boolean;
48
+ constructor(treePath: string, branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string);
49
+ }
50
+ /**
51
+ * The outcome of one worktree reap. `spared` carries the worktrees we refused to touch, each with its
52
+ * reason, for the same reason ReapResult does: a cleanup silent about what it did NOT remove reads as
53
+ * "there was nothing else", when those are exactly the ones only a human can rule on.
54
+ */
55
+ export declare class WorktreeReapResult {
56
+ reaped: ReapedWorktree[];
57
+ failed: ReapedWorktree[];
58
+ spared: DeletableWorktree[];
59
+ constructor(reaped: ReapedWorktree[], failed: ReapedWorktree[], spared: DeletableWorktree[]);
60
+ }
61
+ export declare class WorktreeReaper {
62
+ private readonly worktrees;
63
+ private readonly mutationLog;
64
+ private readonly archiver;
65
+ constructor(worktrees?: WorktreeService, mutationLog?: BranchMutationLog, archiver?: BranchArchiver);
66
+ /**
67
+ * Reap every worktree in `targets`, skipping any the safety rails refuse.
68
+ *
69
+ * `cwd` is passed in rather than read from `process.cwd()` here so the "never remove the tree I am
70
+ * standing in" rule is testable and so a caller running from a subdirectory still gets the right
71
+ * answer — the containing WORKTREE is what matters, not the exact directory.
72
+ *
73
+ * `targets` is caller-chosen on purpose. wp-cleanup passes the provably-dead ones unattended, and
74
+ * passes human-approved probably-dead ones on a second call. The safety rails below apply to both:
75
+ * no answer at a prompt can authorise removing your own cwd or the primary clone.
76
+ */
77
+ reapWorktrees(repoRoot: string, cwd: string, verb: MutationVerb, targets: DeletableWorktree[], retention?: string): WorktreeReapResult;
78
+ /**
79
+ * The two directories that must never be removed, resolved to absolute paths so a relative
80
+ * `../foo` in a verdict can still be compared against them.
81
+ *
82
+ * - THE PRIMARY CLONE. It owns `.git`; `git worktree remove` cannot take it, and a caller who
83
+ * somehow got it into a target list has a bug we must not execute.
84
+ * - THE TREE WE ARE STANDING IN. Removing your own cwd mid-command deletes the files underneath
85
+ * the running process — including, when the tooling is invoked by an agent, the checkout the
86
+ * agent's next tool call will try to read. merged-branches.ts already declines to mark it
87
+ * deletable; this is the second, independent line, because the caller supplies the list.
88
+ */
89
+ private protectedPaths;
90
+ private currentTree;
91
+ private refuseReason;
92
+ /**
93
+ * One worktree: ARCHIVE the branch, REMOVE the directory, then DELETE the branch — and stop at the
94
+ * first step that fails.
95
+ *
96
+ * The prunable case is genuinely different and is why the classification token rides along on the
97
+ * verdict: the directory is ALREADY gone, so `git worktree remove` fails on it and the reap is
98
+ * `git worktree prune`. There is also nothing to archive from a directory that no longer exists —
99
+ * the branch itself is still archived, since it may well still hold the only copy of some work.
100
+ */
101
+ private removeOne;
102
+ private archiveBranch;
103
+ private archiveFailed;
104
+ /**
105
+ * git refused to remove the directory — nearly always because it holds uncommitted changes or
106
+ * untracked files. Reported with git's own words and moved past. We do NOT retry with `--force`:
107
+ * an untracked file is work no archive tag captured, and `--force` is how a cleanup command turns
108
+ * into a data-loss command. The branch is left alone too, since it is still checked out here.
109
+ */
110
+ private removalFailed;
111
+ private log;
112
+ /** The literal command that puts BOTH the directory and the branch back. */
113
+ restoreCommand(target: ReapedWorktree): string;
114
+ private revParse;
115
+ private capture;
116
+ }