@erclx/aitk 3.30.0 → 3.32.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.30.0",
4
+ "version": "3.32.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-autoship
3
- description: Chains implement → verify → review → ship after a feature plan is approved. Reads the plan for the current branch, runs the full pipeline in one session, and stops on any failure or non-minor review finding. Use when asked to "autoship", "ship this feature end to end", or "run the chain". Do NOT auto-trigger. Requires an approved plan file.
3
+ description: Chains implement → verify → review → ship after a feature plan is approved. Reads the plan the caller names, or the plan for the current branch when none is named, runs the full pipeline in one session, and stops on any failure or non-minor review finding. Use when asked to "autoship", "ship this feature end to end", or "run the chain". Do NOT auto-trigger. Requires an approved plan file.
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -11,8 +11,8 @@ Chain the post-plan pipeline in a single run. Every step has a stop condition. S
11
11
  ## Guards
12
12
 
13
13
  - All `.claude/plans/` and `.claude/review/` reads resolve at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`.
14
- - Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.`
15
- - If `.claude/plans/feature-<slug>.md` does not exist at the main worktree root, stop: `❌ No approved plan at .claude/plans/feature-<slug>.md. Run /claude-feature first.`
14
+ - Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.` Every later step keys its output on this slug, being the worktree, the review receipt, the branch, and the memory proposal, regardless of which plan Step 1 reads.
15
+ - Resolve `<plan>` in Step 1, ahead of any other read.
16
16
  - If the working tree has uncommitted changes unrelated to the plan, stop: `❌ Uncommitted changes outside the plan. Commit or stash before autoshipping.`
17
17
 
18
18
  ## Diff baseline
@@ -39,7 +39,12 @@ If the two commands differ, the session is already in a linked worktree. Continu
39
39
 
40
40
  ## Step 1: read the plan
41
41
 
42
- Read `.claude/plans/feature-<slug>.md` at the main worktree root. This file is the scope for this run.
42
+ Resolve `<plan>` in this order, stopping at the first match:
43
+
44
+ 1. **Caller-supplied.** The invocation carried an argument. Accept it as a plan path or a bare slug, in the same position `claude-worktree` tier 0 accepts its name. A bare slug resolves to `.claude/plans/feature-<slug>.md`, and a path is taken as given, relative to the main worktree root. If it does not resolve to a file, stop: `❌ No plan at <path>. Path was supplied, not derived, so check it and re-run.`
45
+ 2. **Derived.** `.claude/plans/feature-<slug>.md`, from the `<slug>` the Guards derived. If it does not exist, stop: `❌ No approved plan at .claude/plans/feature-<slug>.md. Run /claude-feature first.`
46
+
47
+ Read `<plan>` at the main worktree root. This file is the scope for this run.
43
48
 
44
49
  Its sections and its answer contract are fixed by `${CLAUDE_SKILL_DIR}/../../standards/plan.md`. A blank `- Answer:` accepts the `- Suggested:` line above it, so an unanswered question is a decision this run executes rather than a reason to stop.
45
50
 
@@ -152,7 +157,8 @@ Every stop point leaves recoverable state. The user resumes manually from the ap
152
157
 
153
158
  | Stop point | Recovery |
154
159
  | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
155
- | No plan | Run `/claude-feature` to create one |
160
+ | No plan (derived) | Run `/claude-feature` to create one |
161
+ | No plan (caller-supplied) | Check the path or slug passed to autoship, then re-run |
156
162
  | No diff baseline | Fetch origin so a merge base resolves against `main`, then re-run autoship |
157
163
  | Empty changed-file list | Re-run once the plan produces tracked output. Ship gitignored output outside the chain, never by tracking it. |
158
164
  | Branch collision on worktree entry | `claude-worktree` Step 5 found `<slug>` already as a local branch. Resolve manually (rename or delete the stale branch), then re-run autoship. |
@@ -7,9 +7,9 @@ Run this at loop step 4, for a `## Run now` row whose plan is verified and whose
7
7
 
8
8
  ## Derive the candidate
9
9
 
10
- Resolve `<slug>` from the row's plan the way `claude-worktree` Step 2 resolves a plan-matched name, per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Resolve `<type>` off that plan's `## Summary` and `**Files to touch:**` lines, per `${CLAUDE_SKILL_DIR}/../../standards/branch.md`, defaulting to `feat` when the lines settle nothing. The candidate branch is `<type>/<slug>`.
10
+ Resolve `<slug>` from `<plan>`, the row's plan file, the way `claude-worktree` Step 2 resolves a plan-matched name, per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Resolve `<type>` off that plan's `## Summary` and `**Files to touch:**` lines, per `${CLAUDE_SKILL_DIR}/../../standards/branch.md`, defaulting to `feat` when the lines settle nothing. The candidate branch is `<type>/<slug>`.
11
11
 
12
- This is the branch the worker takes, not a guess at one it will derive for itself. Carry the exact string into the launch below. Both halves of that derivation have already disagreed in production: one run checked `docs/remaining-skill-verdicts` against a worker that took `docs/skill-verdicts-decide`, and a later one checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`. A check against a branch nobody uses verifies nothing, and the run dies anyway, since `claude-autoship` resolves its plan from the slug the worker actually took.
12
+ This is the branch the worker takes, not a guess at one it will derive for itself. Carry the exact string into the launch below. Both halves of that derivation have already disagreed in production: one run checked `docs/remaining-skill-verdicts` against a worker that took `docs/skill-verdicts-decide`, and a later one checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`. A check against a branch nobody uses verifies nothing, and a slug mismatch no longer fails the run downstream on its own, since `claude-autoship` now takes `<plan>` directly rather than resolving it from the worker's own branch. The check above is what has to catch a wrong candidate now.
13
13
 
14
14
  ## Check the branch is unclaimed
15
15
 
@@ -46,12 +46,12 @@ Name `<model>` on the launch, and pick it against the task rather than copying w
46
46
  ## Dispatch
47
47
 
48
48
  ```bash
49
- claude --bg --model <model> -n "orchestrator-<slug>" "Run /aitk:claude-worktree <type>/<slug>, then /aitk:claude-autoship .claude/tasks/<task-file>.md"
49
+ claude --bg --model <model> -n "orchestrator-<slug>" "Run /aitk:claude-worktree <type>/<slug>, then /aitk:claude-autoship <plan>"
50
50
  ```
51
51
 
52
52
  `--bg, --background` starts the session as a background agent and returns immediately, `-n, --name` sets the display name `aitk sessions list` reads back for the worker cap, and `--model` overrides the inheritance the section above measured.
53
53
 
54
- The worktree call comes first and carries the branch as its argument, which is tier 0 of `claude-worktree` Step 2 and the only tier a caller can reach. `claude-autoship` Step 0 then finds the session already in a linked worktree and continues, which is a path it already documents. Autoship still resolves its plan, since the slug inside a dispatcher-named `<type>/<slug>` is the slug the dispatcher read off that plan.
54
+ The worktree call comes first and carries the branch as its argument, which is tier 0 of `claude-worktree` Step 2 and the only tier a caller can reach. `claude-autoship` Step 0 then finds the session already in a linked worktree and continues, which is a path it already documents. The autoship call carries `<plan>`, the same file this runbook already read to derive the branch, so its Step 1 takes it as the caller-supplied plan rather than re-deriving one from the slug the worker's branch happens to carry.
55
55
 
56
56
  Naming the branch in prose instead was tried and closes nothing, because no tier of that ladder reads the prompt. A worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there. Four workers took the right branch that way, by inference rather than by contract, which is the same judgment both live disagreements came from.
57
57
 
@@ -10,6 +10,8 @@ Sweep the board as orchestrator after merging. Run this once per batch of merges
10
10
  3. Re-verify every plan already written, not only the ones this sweep writes. A queued plan goes stale from whatever merged while it waited, and the loop's verify step fires at handoff rather than after a merge, so nothing else catches it. Grep each construct the plan names and count the sites against its claim, then open each file rather than trusting its account.
11
11
  4. Re-check any precondition a plan states about live state outside the repository. A remote branch, an open issue, or an installed version was true when the plan was written and is not a fact about the tree.
12
12
 
13
+ Run `aitk worktrees list` after the pull, which is the first point at which the merges this sweep followed are readable. It owns the reclaim rule and it removes nothing, so read its rows and act on the ones you mean to. Never substitute a git ancestry check for it.
14
+
13
15
  Run `aitk tasks validate` once the board is rewritten and before reporting it. It resolves every plan pointer, accounts every task file against the board and the backlog both, tests the `## Run now` file sets for overlap, and re-takes the two blocker kinds a command can settle. It reports and never writes, so fix each row it names and run it again. A finding it reports is a board defect rather than a task finding, so it goes nowhere but the board.
14
16
 
15
17
  Then run `orchestrator-parked.md` over the rows the validator listed as untested. Those carry the three blocker kinds no command settles, and the merge this sweep followed changed the tree under every one of them at once while the refill above re-read none. Take the untested rows alone rather than the whole board, since the validator already answered the rest.
@@ -42,6 +42,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
42
42
  | `aitk records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
43
43
  | `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
44
44
  | `aitk sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
45
+ | `aitk worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
45
46
  | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
46
47
  | `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
47
48
  | `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
@@ -39,3 +39,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
39
39
  - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
40
40
  - [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
41
41
  - [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
42
+ - [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
@@ -0,0 +1,62 @@
1
+ ---
2
+ title: Worktrees
3
+ description: Reporting which worktrees are reclaimable, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
4
+ ---
5
+
6
+ # Worktrees
7
+
8
+ ## List
9
+
10
+ `aitk worktrees list` reports every worktree of the current repository with a reclaim verdict and the reason behind it.
11
+
12
+ ```bash
13
+ aitk worktrees list
14
+ aitk worktrees list --json
15
+ ```
16
+
17
+ | Option | Behavior |
18
+ | -------- | --------------------------------------- |
19
+ | `--json` | Add a machine-readable record on stdout |
20
+
21
+ It reads and removes nothing. The question it answers is which worktrees the work has finished with, which nothing in the loop asks: a worktree is created per feature and removed by nobody, so directories left by shipped work accumulate for the life of the checkout.
22
+
23
+ Exit codes: `0` every worktree was read, `1` refused. The refusal carries a `reason` of `gh-missing`, `gh-failed`, or `sessions-unreadable`.
24
+
25
+ An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `reason` rather than the exit when a skill consumes this.
26
+
27
+ ## What makes a worktree reclaimable
28
+
29
+ All three hold: its branch has a merged pull request, its working tree is clean, and no live session holds the directory. Each alone has a case where removal loses something, so `refusals` names every failing condition rather than the first.
30
+
31
+ | Refusal | What it means |
32
+ | ------------------------ | ------------------------------------------------------- |
33
+ | `main-worktree` | The main worktree, which is never reclaimable |
34
+ | `detached-head` | No branch, so nothing names a pull request |
35
+ | `no-merged-pull-request` | Its branch has no merged pull request |
36
+ | `uncommitted-changes` | Work no history stands behind, untracked files included |
37
+ | `unreadable-worktree` | The working tree status could not be read |
38
+ | `held-by-session` | A live session still holds the directory |
39
+
40
+ Uncommitted work is the condition that gates rather than warns. A worktree is gitignored scratch with no history behind it, so a directory removed with unstaged changes takes them somewhere nothing recovers.
41
+
42
+ ## Why the pull request rather than git ancestry
43
+
44
+ Ancestry is the reading anyone reaches for and it fails in both directions on a repository that squash merges. A merged branch is never an ancestor of the trunk there, so `git merge-base --is-ancestor` calls shipped work unmerged. Measured against nine worktrees, it named five of six lingering branches unmerged, each of which had a merged pull request, reporting them 2 to 6 commits ahead.
45
+
46
+ The one branch it did call merged was the one that had to stay. That branch had no pull request at all, sat at a release commit, and its worktree held finished work outside any commit. So the cheap test kept every directory safe to remove and offered the only one that was not.
47
+
48
+ The read is one `gh pr list --state merged` for the whole repository rather than one call per worktree, which would be a network round trip inside a loop. It covers the most recent 200 merges, so a worktree older than that reads as having none and is refused, which fails in the direction that keeps a directory.
49
+
50
+ ## Two removal shapes
51
+
52
+ `route` names which one applies rather than choosing it, since picking wrong strands state.
53
+
54
+ - `session`: a live session holds the directory, and `claude rm <name>` removes the session and its worktree together. The `sessions` field carries the names, and a name is whatever string the session was launched under, spaces included, so quote it.
55
+ - `worktree`: the session has ended, and `git worktree remove` with a branch delete is the pair.
56
+ - `null`: the main worktree, which no removal shape reaches.
57
+
58
+ A held worktree is refused rather than reported reclaimable, and its route is reported for whoever decides to act on it. Deleting a directory underneath a live session is the case that has to refuse.
59
+
60
+ ## What an unreadable input does
61
+
62
+ It refuses the whole report rather than producing verdicts around the gap. An absent merge state and a branch with no merged pull request return the same empty answer, and so do an absent session roster and a worktree nobody holds. Reporting the second when it was the first is a false clean, and here that ends in a removal rather than in a warning.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.30.0",
4
+ "version": "3.32.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -27,6 +27,7 @@ import { register as context } from '@/commands/context'
27
27
  import { register as markdown } from '@/commands/markdown'
28
28
  import { register as records } from '@/commands/records'
29
29
  import { register as sessions } from '@/commands/sessions'
30
+ import { register as worktrees } from '@/commands/worktrees'
30
31
  import { register as audits } from '@/commands/audits'
31
32
  import { register as secrets } from '@/commands/secrets'
32
33
  import { register as deps } from '@/commands/deps'
@@ -71,6 +72,7 @@ function showHelp(): void {
71
72
  `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
72
73
  `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, size, push, pull)${NC}`,
73
74
  `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
75
+ `${GREY}│${NC} worktrees [cmd] ${GREY}# Report which worktrees are reclaimable (list)${NC}`,
74
76
  `${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
75
77
  `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
76
78
  `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
@@ -117,6 +119,7 @@ function showHelp(): void {
117
119
  `${GREY}│${NC} aitk records size --json`,
118
120
  `${GREY}│${NC} aitk records push --json`,
119
121
  `${GREY}│${NC} aitk sessions list --json`,
122
+ `${GREY}│${NC} aitk worktrees list --json`,
120
123
  `${GREY}│${NC} aitk secrets scan --json`,
121
124
  `${GREY}│${NC} aitk deps audit --json`,
122
125
  `${GREY}│${NC} aitk labels audit --json`,
@@ -167,6 +170,7 @@ context(program)
167
170
  markdown(program)
168
171
  records(program)
169
172
  sessions(program)
173
+ worktrees(program)
170
174
  secrets(program)
171
175
  deps(program)
172
176
  labels(program)
@@ -0,0 +1,191 @@
1
+ import type { Command } from 'commander'
2
+ import {
3
+ intro,
4
+ logInfo,
5
+ logStep,
6
+ logWarn,
7
+ outro,
8
+ pipeOutput,
9
+ plural,
10
+ } from '@/ui'
11
+ import {
12
+ type Refusal,
13
+ reclaimReport,
14
+ type Unreadable,
15
+ type WorktreeVerdict,
16
+ } from '@/worktrees/reclaim'
17
+
18
+ interface ListCommandOptions {
19
+ readonly json?: boolean
20
+ }
21
+
22
+ const REFUSALS: Record<Refusal, string> = {
23
+ 'main-worktree': 'the main worktree, which is never reclaimable',
24
+ 'detached-head': 'detached HEAD, so no branch names a pull request',
25
+ 'no-merged-pull-request': 'no merged pull request for its branch',
26
+ 'uncommitted-changes': 'uncommitted work no history stands behind',
27
+ 'unreadable-worktree': 'the working tree could not be read',
28
+ 'held-by-session': 'a live session still holds it',
29
+ }
30
+
31
+ const UNREADABLE: Record<Unreadable, string> = {
32
+ 'gh-missing':
33
+ 'gh is not on the path, so the merge state could not be read. Install it and run this again.',
34
+ 'gh-failed':
35
+ 'The pull request read failed, so every worktree would report as unmerged whatever its real state. Authenticate gh and run this again.',
36
+ 'sessions-unreadable':
37
+ 'The session roster could not be read, so nothing was read about which worktrees are still held.',
38
+ }
39
+
40
+ export function register(program: Command): void {
41
+ const worktrees = program
42
+ .command('worktrees')
43
+ .description('Report which worktrees are reclaimable and which are not')
44
+ .helpOption('-h, --help', 'Show this help message')
45
+
46
+ worktrees
47
+ .command('list')
48
+ .description(
49
+ 'Report every worktree with a reclaim verdict and the reason behind it',
50
+ )
51
+ .helpOption('-h, --help', 'Show this help message')
52
+ .option('--json', 'Add a machine-readable record on stdout')
53
+ .addHelpText(
54
+ 'after',
55
+ [
56
+ '',
57
+ 'Exit codes:',
58
+ ' 0 every worktree was read',
59
+ ' 1 refused, with the reason on stderr',
60
+ '',
61
+ 'A worktree is reclaimable when all three hold: its branch has a merged',
62
+ 'pull request, its working tree is clean, and no live session holds it.',
63
+ 'Each alone has a case where removal loses something, so "refusals"',
64
+ 'carries every failing condition rather than the first.',
65
+ '',
66
+ 'The merged state comes from gh rather than from git ancestry. A',
67
+ 'repository that squash merges never makes a merged branch an ancestor',
68
+ 'of its trunk, so ancestry calls shipped work unmerged and calls an',
69
+ 'abandoned branch sitting at a release commit merged.',
70
+ '',
71
+ 'This reports and removes nothing. "route" names which removal shape',
72
+ 'applies: "session" when a live session holds the directory, where',
73
+ '`claude rm <name>` takes the session and its worktree together, and',
74
+ '"worktree" when the session has ended, where `git worktree remove`',
75
+ 'and a branch delete are the pair.',
76
+ '',
77
+ 'An unreadable input refuses the whole reading rather than reporting',
78
+ 'every worktree as not reclaimable, since an absent merge state and a',
79
+ 'branch with no merged pull request produce the same empty answer.',
80
+ '',
81
+ 'The merge read covers the most recent 200 merged pull requests. A',
82
+ 'worktree older than that reads as having none and is refused, which',
83
+ 'keeps a directory rather than removing one.',
84
+ '',
85
+ 'Examples:',
86
+ ' aitk worktrees list',
87
+ ' aitk worktrees list --json',
88
+ '',
89
+ ].join('\n'),
90
+ )
91
+ .action(async (opts: ListCommandOptions) => {
92
+ process.exitCode = await runList(opts)
93
+ })
94
+ }
95
+
96
+ async function runList(opts: ListCommandOptions): Promise<number> {
97
+ const report = await reclaimReport({ cwd: process.cwd() })
98
+
99
+ intro('aitk worktrees list')
100
+
101
+ if (report.kind === 'unreadable') {
102
+ logStep('Refused')
103
+ logWarn(UNREADABLE[report.reason])
104
+ logInfo(report.detail)
105
+ outro()
106
+
107
+ if (opts.json) {
108
+ process.stdout.write(
109
+ `${JSON.stringify({
110
+ reason: report.reason,
111
+ detail: report.detail,
112
+ worktrees: [],
113
+ })}\n`,
114
+ )
115
+ }
116
+
117
+ return 1
118
+ }
119
+
120
+ reportWorktrees(report.worktrees)
121
+ outro()
122
+
123
+ if (opts.json) {
124
+ process.stdout.write(
125
+ `${JSON.stringify({
126
+ reason: null,
127
+ detail: null,
128
+ worktrees: report.worktrees,
129
+ })}\n`,
130
+ )
131
+ }
132
+
133
+ return 0
134
+ }
135
+
136
+ function reportWorktrees(worktrees: readonly WorktreeVerdict[]): void {
137
+ logStep('Worktrees')
138
+
139
+ if (worktrees.length === 0) {
140
+ logInfo('No worktree resolved here. Run this inside a repository.')
141
+ return
142
+ }
143
+
144
+ logInfo(plural(worktrees.length, 'worktree'))
145
+ pipeOutput(worktrees.map(describe).join('\n'))
146
+
147
+ const reclaimable = worktrees.filter((entry) => entry.reclaimable)
148
+
149
+ logStep('Reclaimable')
150
+ if (reclaimable.length === 0) {
151
+ logInfo('None. Every worktree fails at least one condition.')
152
+ return
153
+ }
154
+
155
+ logWarn(
156
+ `${plural(reclaimable.length, 'worktree')} can be reclaimed. This reports and removes nothing, so run the named command yourself.`,
157
+ )
158
+ }
159
+
160
+ /**
161
+ * Names the evidence beside the conclusion on every row.
162
+ *
163
+ * The reading has two consumers and they act on different halves. The sweep
164
+ * reads the verdict to decide what to offer, and a person reads it to decide
165
+ * what to remove, so a row phrased for the first alone leaves the second
166
+ * removing a directory on a conclusion it cannot check.
167
+ */
168
+ function describe(verdict: WorktreeVerdict): string {
169
+ const held = verdict.branch ?? 'detached'
170
+ const head = `${verdict.path} ${held}`
171
+
172
+ if (!verdict.reclaimable) {
173
+ const reasons = verdict.refusals.map((refusal) => REFUSALS[refusal])
174
+ // A name is quoted because a session carries whatever string it was
175
+ // launched under, spaces included, and one command per name because a
176
+ // joined list reads as a single argument.
177
+ const routes =
178
+ verdict.route === 'session'
179
+ ? verdict.sessions.map(
180
+ (name) => `\n Removal there goes through: claude rm '${name}'`,
181
+ )
182
+ : []
183
+ return `${head}\n Refused: ${reasons.join('; ')}.${routes.join('')}`
184
+ }
185
+
186
+ return [
187
+ head,
188
+ ` Reclaimable. Pull request #${verdict.pullRequest} merged it, the tree is clean, and no session holds it.`,
189
+ ` Remove with: git worktree remove ${verdict.path} && git branch -D ${held}`,
190
+ ].join('\n')
191
+ }
@@ -0,0 +1,306 @@
1
+ import { $ } from 'bun'
2
+ import { execa } from 'execa'
3
+ import { gitEnv } from '@/git-env'
4
+ import {
5
+ repositoryOf,
6
+ type ResolvedSession,
7
+ resolveSessions,
8
+ type SessionReport,
9
+ } from '@/sessions/resolve'
10
+ import { listWorktrees, type WorktreeEntry } from '@/worktree'
11
+
12
+ const GH_TIMEOUT_MS = 30_000
13
+
14
+ /**
15
+ * How many merged pull requests one read covers. A worktree older than this
16
+ * many merges reads as having none and is refused, which is the safe direction:
17
+ * the failure keeps a directory rather than removing one.
18
+ */
19
+ const MERGED_LIMIT = 200
20
+
21
+ /** Why one worktree cannot be reclaimed, one entry per failing condition. */
22
+ export type Refusal =
23
+ | 'main-worktree'
24
+ | 'detached-head'
25
+ | 'no-merged-pull-request'
26
+ | 'uncommitted-changes'
27
+ | 'unreadable-worktree'
28
+ | 'held-by-session'
29
+
30
+ /** Why the whole reading was refused, so no verdict was produced at all. */
31
+ export type Unreadable = 'gh-missing' | 'gh-failed' | 'sessions-unreadable'
32
+
33
+ /**
34
+ * Which removal shape applies. `session` removes the background session and its
35
+ * worktree together, and `worktree` removes a directory whose session has
36
+ * ended. Picking the wrong one strands state, so this is reported rather than
37
+ * assumed.
38
+ */
39
+ export type Route = 'session' | 'worktree'
40
+
41
+ /** No removal shape reaches the main worktree, which is what `null` says. */
42
+ export type RemovalRoute = Route | null
43
+
44
+ export interface WorktreeVerdict {
45
+ readonly path: string
46
+ readonly branch: string | null
47
+ readonly reclaimable: boolean
48
+ /** Every failing condition, so a reader sees what to fix rather than a bare refusal. */
49
+ readonly refusals: readonly Refusal[]
50
+ /** The pull request that retired the branch, so a report can name what it read. */
51
+ readonly pullRequest: number | null
52
+ /** The names of the live sessions holding this worktree, which is what `claude rm` takes. */
53
+ readonly sessions: readonly string[]
54
+ readonly route: RemovalRoute
55
+ }
56
+
57
+ export interface MergedPullRequest {
58
+ readonly branch: string
59
+ readonly number: number
60
+ }
61
+
62
+ export type MergedReport =
63
+ | { readonly kind: 'read'; readonly merged: readonly MergedPullRequest[] }
64
+ | {
65
+ readonly kind: 'unreadable'
66
+ readonly reason: Extract<Unreadable, 'gh-missing' | 'gh-failed'>
67
+ readonly detail: string
68
+ }
69
+
70
+ export interface StatusReport {
71
+ /** False when the status read itself failed, so a clean `dirty` says nothing. */
72
+ readonly readable: boolean
73
+ readonly dirty: boolean
74
+ }
75
+
76
+ export type ReclaimReport =
77
+ | {
78
+ readonly kind: 'unreadable'
79
+ readonly reason: Unreadable
80
+ readonly detail: string
81
+ }
82
+ | { readonly kind: 'read'; readonly worktrees: readonly WorktreeVerdict[] }
83
+
84
+ export interface ReclaimOptions {
85
+ readonly cwd?: string
86
+ readonly listWorktrees?: (cwd: string) => Promise<readonly WorktreeEntry[]>
87
+ readonly mergedPullRequests?: (cwd: string) => Promise<MergedReport>
88
+ readonly worktreeStatus?: (path: string) => Promise<StatusReport>
89
+ readonly resolve?: () => Promise<SessionReport>
90
+ }
91
+
92
+ /**
93
+ * Reads the pull request state for the whole repository in one call.
94
+ *
95
+ * One call rather than one per worktree, since the per-worktree shape is a
96
+ * network round trip inside a loop and the branches being matched are already
97
+ * known before any of them runs.
98
+ */
99
+ async function mergedPullRequests(cwd: string): Promise<MergedReport> {
100
+ if (Bun.which('gh') === null) {
101
+ return {
102
+ kind: 'unreadable',
103
+ reason: 'gh-missing',
104
+ detail: 'gh is not on the path, so no merge state could be read.',
105
+ }
106
+ }
107
+
108
+ const args = [
109
+ 'pr',
110
+ 'list',
111
+ '--state',
112
+ 'merged',
113
+ '--limit',
114
+ String(MERGED_LIMIT),
115
+ '--json',
116
+ 'headRefName,number',
117
+ ]
118
+
119
+ try {
120
+ // `gh` resolves its repository through git, so it reads the same
121
+ // resolution variables a hook exports and they take precedence over `cwd`.
122
+ // A run from inside one would answer with another repository's merged
123
+ // branches, and a branch name that recurs across repositories would then
124
+ // match a merge that happened somewhere else and read a live worktree as
125
+ // reclaimable, which is the unsafe direction on an unrecoverable removal.
126
+ const result = await execa('gh', args, {
127
+ cwd,
128
+ timeout: GH_TIMEOUT_MS,
129
+ env: gitEnv(),
130
+ extendEnv: false,
131
+ })
132
+ const rows = JSON.parse(result.stdout) as readonly {
133
+ headRefName: string
134
+ number: number
135
+ }[]
136
+
137
+ return {
138
+ kind: 'read',
139
+ merged: rows.map((row) => ({
140
+ branch: row.headRefName,
141
+ number: row.number,
142
+ })),
143
+ }
144
+ } catch (error) {
145
+ return {
146
+ kind: 'unreadable',
147
+ reason: 'gh-failed',
148
+ detail: error instanceof Error ? error.message : String(error),
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Reports whether a worktree holds work no history is behind.
155
+ *
156
+ * Untracked files count, since a worktree is gitignored scratch and a directory
157
+ * removed with them takes them nowhere recoverable. A failed read is separated
158
+ * from a clean tree, because the two produce the same empty output and only one
159
+ * of them is safe to act on.
160
+ */
161
+ async function worktreeStatus(path: string): Promise<StatusReport> {
162
+ const result = await $`git -C ${path} status --porcelain`
163
+ .env(gitEnv())
164
+ .quiet()
165
+ .nothrow()
166
+ if (result.exitCode !== 0) return { readable: false, dirty: false }
167
+
168
+ return { readable: true, dirty: result.stdout.toString().trim().length > 0 }
169
+ }
170
+
171
+ /**
172
+ * Names the live sessions holding one worktree.
173
+ *
174
+ * The path match is the direct reading and the branch match is what survives a
175
+ * path spelled differently on either side, such as a symlinked temporary
176
+ * directory. The branch half is scoped to the repository, since a branch name
177
+ * identifies a branch inside one and nothing across a machine.
178
+ */
179
+ function holders(
180
+ entry: WorktreeEntry,
181
+ sessions: readonly ResolvedSession[],
182
+ repository: string | null,
183
+ ): readonly string[] {
184
+ return sessions
185
+ .filter(
186
+ (candidate) =>
187
+ candidate.worktree === entry.path ||
188
+ (entry.branch !== null &&
189
+ candidate.branch === entry.branch &&
190
+ candidate.repository === repository),
191
+ )
192
+ .map((candidate) => candidate.name)
193
+ }
194
+
195
+ function verdict(
196
+ entry: WorktreeEntry,
197
+ isMain: boolean,
198
+ status: StatusReport,
199
+ merged: ReadonlyMap<string, number>,
200
+ sessions: readonly ResolvedSession[],
201
+ repository: string | null,
202
+ ): WorktreeVerdict {
203
+ const held = holders(entry, sessions, repository)
204
+ const pullRequest =
205
+ entry.branch === null ? null : (merged.get(entry.branch) ?? null)
206
+ const refusals: Refusal[] = []
207
+
208
+ if (isMain) refusals.push('main-worktree')
209
+ if (entry.branch === null) refusals.push('detached-head')
210
+ else if (pullRequest === null) refusals.push('no-merged-pull-request')
211
+
212
+ if (!status.readable) refusals.push('unreadable-worktree')
213
+ else if (status.dirty) refusals.push('uncommitted-changes')
214
+
215
+ if (held.length > 0) refusals.push('held-by-session')
216
+
217
+ return {
218
+ path: entry.path,
219
+ branch: entry.branch,
220
+ reclaimable: refusals.length === 0,
221
+ refusals,
222
+ pullRequest,
223
+ sessions: held,
224
+ // No removal shape reaches the main worktree, and reporting one there
225
+ // offers a command whose only effect is to break the checkout. Deciding it
226
+ // here rather than in the reporter keeps the record and the framed output
227
+ // answering the same way, since the two consumers act on different halves.
228
+ route: isMain ? null : held.length > 0 ? 'session' : 'worktree',
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Reports which worktrees are reclaimable and which are not, with the reason on
234
+ * each.
235
+ *
236
+ * Reclaimable means all three of a merged pull request, a clean working tree,
237
+ * and no live session holding the directory. Each alone has a case where
238
+ * removal loses something, and removal is unrecoverable here since a worktree
239
+ * is gitignored and no history stands behind it.
240
+ *
241
+ * The pull request is what decides the first condition rather than git
242
+ * ancestry. A repository that squash merges never makes a merged branch an
243
+ * ancestor of its trunk, so the ancestry reading calls shipped work unmerged
244
+ * and calls an abandoned branch sitting at a release commit merged, which is
245
+ * wrong in the one direction that removes a directory.
246
+ *
247
+ * An unreadable input refuses the whole reading rather than producing verdicts
248
+ * around it. An absent merge state and a branch with no merged pull request
249
+ * produce the same empty answer, as do an absent session roster and a worktree
250
+ * nobody holds, and reporting the second when it was the first is a false clean
251
+ * that ends in a removal.
252
+ */
253
+ export async function reclaimReport(
254
+ opts: ReclaimOptions = {},
255
+ ): Promise<ReclaimReport> {
256
+ const cwd = opts.cwd ?? process.cwd()
257
+ const listAll = opts.listWorktrees ?? listWorktrees
258
+ const readMerged = opts.mergedPullRequests ?? mergedPullRequests
259
+ const readStatus = opts.worktreeStatus ?? worktreeStatus
260
+ const resolve = opts.resolve ?? resolveSessions
261
+
262
+ const [entries, merged, sessions, repository] = await Promise.all([
263
+ listAll(cwd),
264
+ readMerged(cwd),
265
+ resolve(),
266
+ repositoryOf(cwd),
267
+ ])
268
+
269
+ if (merged.kind === 'unreadable') {
270
+ return {
271
+ kind: 'unreadable',
272
+ reason: merged.reason,
273
+ detail: merged.detail,
274
+ }
275
+ }
276
+
277
+ if (sessions.kind !== 'resolved') {
278
+ return {
279
+ kind: 'unreadable',
280
+ reason: 'sessions-unreadable',
281
+ detail: `No session registry at ${sessions.dir}, so nothing was read about which worktrees are still held.`,
282
+ }
283
+ }
284
+
285
+ const byBranch = new Map(
286
+ merged.merged.map((request) => [request.branch, request.number]),
287
+ )
288
+ const statuses = await Promise.all(
289
+ entries.map((entry) => readStatus(entry.path)),
290
+ )
291
+
292
+ // `git worktree list` puts the main worktree first, which is the only signal
293
+ // separating it from a linked one in the porcelain output.
294
+ const worktrees = entries.map((entry, index) =>
295
+ verdict(
296
+ entry,
297
+ index === 0,
298
+ statuses[index] ?? { readable: false, dirty: false },
299
+ byBranch,
300
+ sessions.sessions,
301
+ repository,
302
+ ),
303
+ )
304
+
305
+ return { kind: 'read', worktrees }
306
+ }