@erclx/aitk 3.29.1 → 3.31.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-sweep.md +2 -0
- package/docs/agents/commands.md +3 -1
- package/docs/agents/index.md +2 -0
- package/docs/agents/standards-audit.md +38 -0
- package/docs/agents/worktrees.md +62 -0
- package/package.json +1 -1
- package/scripts/core/verify.sh +17 -0
- package/src/cli.ts +4 -0
- package/src/commands/standards.ts +180 -2
- package/src/commands/worktrees.ts +191 -0
- package/src/standards/audit.ts +132 -0
- package/src/worktrees/reclaim.ts +306 -0
- package/standards/design.md +2 -2
|
@@ -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.
|
package/docs/agents/commands.md
CHANGED
|
@@ -42,10 +42,12 @@ 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 |
|
|
48
49
|
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
|
50
|
+
| `aitk standards audit` | Report the corpus against the `## Success criterion` gate, failing only on a standard new to the branch (`--json`, `--arrivals-only`) |
|
|
49
51
|
| `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
|
|
50
52
|
| `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
|
|
51
53
|
| `aitk claude skills rank` | Score the shipped catalog's descriptions against a hand-authored case corpus by TF-IDF cosine similarity, reporting rank-one and top-three |
|
|
@@ -83,7 +85,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
83
85
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
86
|
| `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
|
|
85
87
|
| `snippets` | `list`, `create` |
|
|
86
|
-
| `standards` | `list`, `<name>`
|
|
88
|
+
| `standards` | `list`, `audit`, `<name>` |
|
|
87
89
|
| `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded` |
|
|
88
90
|
| `claude` | `init`, `sync`, `routing`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `skills rank`, `setup [dest]` |
|
|
89
91
|
| `demo` | `compile`, `run` |
|
package/docs/agents/index.md
CHANGED
|
@@ -33,8 +33,10 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
33
33
|
- [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
|
|
34
34
|
- [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
|
|
35
35
|
- [Citation reach](skills-reach.md): Reporting the shipped skill bodies that cite a path no target project receives, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
|
|
36
|
+
- [Standard success criteria](standards-audit.md): Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
|
|
36
37
|
- [State-scoped risk](state-scoped-risk.md): Reading committed state rather than an arriving change, the shipped-tree corpus the secret scan reads, what it keys on and how a deliberate value is exempted, the advisory check and its network failure mode, and why one gates while the other reports
|
|
37
38
|
- [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the exemption marker, the blind spot it cannot reach, and why it reports rather than gates
|
|
38
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
|
|
39
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
|
|
40
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,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Standard success criteria
|
|
3
|
+
description: Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Standard success criteria
|
|
7
|
+
|
|
8
|
+
`aitk standards audit` reads the corpus at `standards/` and reports which files carry a `## Success criterion` section against which do not, per `standards/standard.md`. It fails only on a standard new to the current branch, never on one already short the section.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
aitk standards audit
|
|
12
|
+
aitk standards audit --json
|
|
13
|
+
aitk standards audit --arrivals-only
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Option | Behavior |
|
|
17
|
+
| ----------------- | ------------------------------------------------------------ |
|
|
18
|
+
| `[path]` | Project root, defaulting to the current directory |
|
|
19
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
20
|
+
| `--arrivals-only` | Run the gating check alone, printing nothing on a clean pass |
|
|
21
|
+
|
|
22
|
+
## Why arrival rather than the corpus
|
|
23
|
+
|
|
24
|
+
`standards/standard.md` states that a criterion is added to an existing standard when that standard is next exercised, not in a sweep: a criterion written with no failure to point at is the taste-based edit the rule exists to prevent. Gating the whole corpus would fail every push until every standard already short the section was closed at once, which is the sweep that rule forbids. The check reads the whole corpus and fails only on a file present in the working tree and absent at the branch's merge base, treating a rename into the corpus the same as a standard authored fresh.
|
|
25
|
+
|
|
26
|
+
## Exit codes and refusals
|
|
27
|
+
|
|
28
|
+
| Code | Meaning |
|
|
29
|
+
| ---- | --------------------------------------------------------------- |
|
|
30
|
+
| `0` | every arriving standard carries the section |
|
|
31
|
+
| `1` | refused, with `reason` naming the cause |
|
|
32
|
+
| `2` | a standard new to this branch carries no `## Success criterion` |
|
|
33
|
+
|
|
34
|
+
A project authoring no standards refuses with `no-corpus`, the ordinary state of most targets, the same absence `aitk claude skills audit` reads as its own `no-corpus`.
|
|
35
|
+
|
|
36
|
+
## What it does not measure
|
|
37
|
+
|
|
38
|
+
Presence of the heading is the whole check. The section's content, the questions it must answer or the task it must let a reader complete, is a judgment `aitk standards audit` cannot read, so a standard carrying an empty or token section still passes.
|
|
@@ -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
package/scripts/core/verify.sh
CHANGED
|
@@ -460,6 +460,23 @@ main() {
|
|
|
460
460
|
run_check "cd $PROJECT_ROOT && bun src/cli.ts claude skills audit --requirements-only" "A skill folder carries no REQUIREMENT.md. Run bun src/cli.ts claude skills audit."
|
|
461
461
|
log_info "Skill requirements present"
|
|
462
462
|
|
|
463
|
+
# Scoped to arrival rather than the corpus, since standards/standard.md
|
|
464
|
+
# forbids writing a criterion into an existing standard outside the change
|
|
465
|
+
# that exercises it. Gating the 26 known gaps would fail every push until
|
|
466
|
+
# someone closed them all, which is the sweep that rule exists to prevent.
|
|
467
|
+
log_step "Standard success criteria"
|
|
468
|
+
local standards_output standards_status=0
|
|
469
|
+
standards_output=$(cd "$PROJECT_ROOT" && bun src/cli.ts standards audit --arrivals-only 2>&1) || standards_status=$?
|
|
470
|
+
if [ "$standards_status" -eq 0 ]; then
|
|
471
|
+
log_info "Arriving standards carry a success criterion"
|
|
472
|
+
elif [ "$standards_status" -eq 2 ]; then
|
|
473
|
+
echo "$standards_output" | pipe_output
|
|
474
|
+
log_error "A standard new to this branch carries no ## Success criterion section. Run bun src/cli.ts standards audit."
|
|
475
|
+
else
|
|
476
|
+
echo "$standards_output" | pipe_output
|
|
477
|
+
log_error "aitk standards audit could not read which standards arrived on this branch. Run bun src/cli.ts standards audit --json to see why."
|
|
478
|
+
fi
|
|
479
|
+
|
|
463
480
|
# `aitk sandbox coverage` moves only when a person runs it, so a scenario added
|
|
464
481
|
# with no expectation ships unnoticed. The gate is an absolute count of
|
|
465
482
|
# undeclared scenarios rather than a ratio or a floor under the declared count.
|
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)
|
|
@@ -1,12 +1,33 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
1
2
|
import type { Command } from 'commander'
|
|
2
3
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
4
|
+
import {
|
|
5
|
+
auditExitCode,
|
|
6
|
+
auditStandards,
|
|
7
|
+
type StandardsAudit,
|
|
8
|
+
} from '@/standards/audit'
|
|
3
9
|
import { listStandards, readStandard, resolveStandard } from '@/standards/read'
|
|
4
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
frameError,
|
|
12
|
+
intro,
|
|
13
|
+
logError,
|
|
14
|
+
logInfo,
|
|
15
|
+
logStep,
|
|
16
|
+
logWarn,
|
|
17
|
+
outro,
|
|
18
|
+
pipeOutput,
|
|
19
|
+
plural,
|
|
20
|
+
} from '@/ui'
|
|
21
|
+
|
|
22
|
+
interface StandardsAuditOptions {
|
|
23
|
+
readonly json?: boolean
|
|
24
|
+
readonly arrivalsOnly?: boolean
|
|
25
|
+
}
|
|
5
26
|
|
|
6
27
|
export function register(program: Command): void {
|
|
7
28
|
const standards = program
|
|
8
29
|
.command('standards')
|
|
9
|
-
.description('Standards commands (list, <name>)')
|
|
30
|
+
.description('Standards commands (list, audit, <name>)')
|
|
10
31
|
.argument('[name]', 'Standard to print, by name with or without .md')
|
|
11
32
|
.helpOption('-h, --help', 'Show this help message')
|
|
12
33
|
.addHelpText(
|
|
@@ -37,6 +58,41 @@ export function register(program: Command): void {
|
|
|
37
58
|
})
|
|
38
59
|
|
|
39
60
|
registerPassThroughVerbs(standards, 'standards', ['list'])
|
|
61
|
+
|
|
62
|
+
standards
|
|
63
|
+
.command('audit')
|
|
64
|
+
.description(
|
|
65
|
+
'Report the corpus against the `## Success criterion` gate in standards/standard.md',
|
|
66
|
+
)
|
|
67
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
68
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
69
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
70
|
+
.option(
|
|
71
|
+
'--arrivals-only',
|
|
72
|
+
'Run the gating check for standards new on this branch alone',
|
|
73
|
+
)
|
|
74
|
+
.addHelpText(
|
|
75
|
+
'after',
|
|
76
|
+
[
|
|
77
|
+
'',
|
|
78
|
+
'Exit codes:',
|
|
79
|
+
' 0 the audit completed with every arriving standard carrying the section',
|
|
80
|
+
' 1 refused, with the reason on stderr',
|
|
81
|
+
' 2 a standard new to this branch carries no ## Success criterion section',
|
|
82
|
+
'',
|
|
83
|
+
'A standard already in the corpus without the section is a known gap',
|
|
84
|
+
'standards/standard.md names, not a violation, so only an arrival fails.',
|
|
85
|
+
'',
|
|
86
|
+
'Examples:',
|
|
87
|
+
' aitk standards audit',
|
|
88
|
+
' aitk standards audit --json',
|
|
89
|
+
' aitk standards audit --arrivals-only',
|
|
90
|
+
'',
|
|
91
|
+
].join('\n'),
|
|
92
|
+
)
|
|
93
|
+
.action(async (path: string | undefined, opts: StandardsAuditOptions) => {
|
|
94
|
+
process.exitCode = await runStandardsAudit(path, opts)
|
|
95
|
+
})
|
|
40
96
|
}
|
|
41
97
|
|
|
42
98
|
/**
|
|
@@ -67,3 +123,125 @@ function print(name: string): number {
|
|
|
67
123
|
outro()
|
|
68
124
|
return 0
|
|
69
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Measures the corpus at the cwd rather than the toolkit root the catalog
|
|
129
|
+
* reads, so a linked worktree audits its own branch instead of `main`.
|
|
130
|
+
*/
|
|
131
|
+
async function runStandardsAudit(
|
|
132
|
+
path: string | undefined,
|
|
133
|
+
opts: StandardsAuditOptions,
|
|
134
|
+
): Promise<number> {
|
|
135
|
+
const root = resolve(path ?? process.cwd())
|
|
136
|
+
const gateOnly = opts.arrivalsOnly ?? false
|
|
137
|
+
const audit = await auditStandards(root)
|
|
138
|
+
|
|
139
|
+
if (audit.kind === 'refused') {
|
|
140
|
+
const message =
|
|
141
|
+
audit.reason === 'no-corpus'
|
|
142
|
+
? `No standards/ under ${root}.`
|
|
143
|
+
: audit.reason === 'no-base'
|
|
144
|
+
? 'No merge base against main resolved.'
|
|
145
|
+
: 'Could not read which standards arrived on this branch.'
|
|
146
|
+
|
|
147
|
+
if (gateOnly) {
|
|
148
|
+
frameError(message)
|
|
149
|
+
} else {
|
|
150
|
+
intro('aitk standards audit')
|
|
151
|
+
logStep('Refused')
|
|
152
|
+
logWarn(message)
|
|
153
|
+
outro()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (opts.json) {
|
|
157
|
+
process.stdout.write(
|
|
158
|
+
`${JSON.stringify({ root, reason: audit.reason, message })}\n`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return auditExitCode(audit)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (gateOnly) {
|
|
166
|
+
reportArrivalGate(audit)
|
|
167
|
+
} else {
|
|
168
|
+
intro('aitk standards audit')
|
|
169
|
+
reportCorpus(audit)
|
|
170
|
+
outro()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
process.stdout.write(
|
|
175
|
+
`${JSON.stringify({
|
|
176
|
+
root,
|
|
177
|
+
base: audit.base,
|
|
178
|
+
standards: audit.standards,
|
|
179
|
+
withCriterion: audit.withCriterion,
|
|
180
|
+
withoutCriterion: audit.withoutCriterion,
|
|
181
|
+
arrivals: audit.arrivals,
|
|
182
|
+
arrivalsWithoutCriterion: audit.arrivalsWithoutCriterion,
|
|
183
|
+
})}\n`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return auditExitCode(audit)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Prints nothing when every arriving standard carries the section.
|
|
192
|
+
*
|
|
193
|
+
* `--arrivals-only` is what `verify.sh` runs on every push, and that script
|
|
194
|
+
* pipes a stage's whole output into its own frame. A passing gate that
|
|
195
|
+
* printed its frame would nest one inside the other on every contributor's
|
|
196
|
+
* push.
|
|
197
|
+
*/
|
|
198
|
+
function reportArrivalGate(
|
|
199
|
+
audit: Extract<StandardsAudit, { kind: 'measured' }>,
|
|
200
|
+
): void {
|
|
201
|
+
const missing = audit.arrivalsWithoutCriterion
|
|
202
|
+
if (missing.length === 0) return
|
|
203
|
+
|
|
204
|
+
intro('aitk standards audit')
|
|
205
|
+
logError(
|
|
206
|
+
missing.length === 1
|
|
207
|
+
? '1 standard new to this branch carries no ## Success criterion section'
|
|
208
|
+
: `${missing.length} standards new to this branch carry no ## Success criterion section`,
|
|
209
|
+
)
|
|
210
|
+
pipeOutput(missing.join('\n'))
|
|
211
|
+
outro()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function reportCorpus(
|
|
215
|
+
audit: Extract<StandardsAudit, { kind: 'measured' }>,
|
|
216
|
+
): void {
|
|
217
|
+
logStep('Corpus')
|
|
218
|
+
logInfo(`${plural(audit.standards.length, 'standard')} at standards/`)
|
|
219
|
+
logInfo(
|
|
220
|
+
`${plural(audit.withCriterion.length, 'standard')} carrying ## Success criterion`,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
logStep('Known gaps')
|
|
224
|
+
if (audit.withoutCriterion.length === 0) {
|
|
225
|
+
logInfo('None. Every standard carries the section.')
|
|
226
|
+
} else {
|
|
227
|
+
pipeOutput(audit.withoutCriterion.join('\n'))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
logStep('Arrivals since main')
|
|
231
|
+
if (audit.arrivals.length === 0) {
|
|
232
|
+
logInfo('No standard new to this branch.')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (audit.arrivalsWithoutCriterion.length === 0) {
|
|
237
|
+
logInfo(
|
|
238
|
+
`${plural(audit.arrivals.length, 'standard')} arrived, every one carrying the section.`,
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
logError(
|
|
244
|
+
`${plural(audit.arrivalsWithoutCriterion.length, 'standard')} arrived carrying no ## Success criterion section`,
|
|
245
|
+
)
|
|
246
|
+
pipeOutput(audit.arrivalsWithoutCriterion.join('\n'))
|
|
247
|
+
}
|
|
@@ -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,132 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import { gitEnv } from '@/git-env'
|
|
5
|
+
import { resolveBaseRef } from '@/git-files'
|
|
6
|
+
import { INDEX_FILE, standardsSourceDir } from '@/standards/read'
|
|
7
|
+
|
|
8
|
+
/** Returned when a standard new to this branch carries no `## Success criterion` section, the gating check. */
|
|
9
|
+
export const EXIT_MISSING_CRITERION = 2
|
|
10
|
+
|
|
11
|
+
/** Matched at any casing, level-2 only, per the heading `standards/standard.md` itself uses. */
|
|
12
|
+
const CRITERION_HEADING = /^##\s+success criterion\s*$/im
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The reasons an audit produces no reading. `no-corpus` is the ordinary state
|
|
16
|
+
* of a target that authors no standards of its own, the same absence the
|
|
17
|
+
* skills audit reads as its own `no-corpus`. The other two are a broken git
|
|
18
|
+
* invocation rather than a project stating nothing.
|
|
19
|
+
*/
|
|
20
|
+
export type StandardsAuditRefusal =
|
|
21
|
+
| 'no-corpus'
|
|
22
|
+
| 'no-base'
|
|
23
|
+
| 'unreadable-arrivals'
|
|
24
|
+
|
|
25
|
+
export type StandardsAudit =
|
|
26
|
+
| {
|
|
27
|
+
readonly kind: 'measured'
|
|
28
|
+
readonly base: string
|
|
29
|
+
readonly standards: readonly string[]
|
|
30
|
+
readonly withCriterion: readonly string[]
|
|
31
|
+
readonly withoutCriterion: readonly string[]
|
|
32
|
+
readonly arrivals: readonly string[]
|
|
33
|
+
readonly arrivalsWithoutCriterion: readonly string[]
|
|
34
|
+
}
|
|
35
|
+
| { readonly kind: 'refused'; readonly reason: StandardsAuditRefusal }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Measures the corpus authored at `standards/` under `root` against the
|
|
39
|
+
* `## Success criterion` gate `standards/standard.md` states, and names which
|
|
40
|
+
* of those files are new since the branch's merge base.
|
|
41
|
+
*
|
|
42
|
+
* Reads the working-root corpus alone, never the packaged fallback
|
|
43
|
+
* `src/standards/read.ts` falls through to for a name lookup, since a target
|
|
44
|
+
* with no authored standards of its own has nothing here to gate.
|
|
45
|
+
*/
|
|
46
|
+
export async function auditStandards(root: string): Promise<StandardsAudit> {
|
|
47
|
+
const dir = standardsSourceDir(root)
|
|
48
|
+
if (!existsSync(dir)) return { kind: 'refused', reason: 'no-corpus' }
|
|
49
|
+
|
|
50
|
+
const standards = readdirSync(dir, { withFileTypes: true })
|
|
51
|
+
.filter(
|
|
52
|
+
(entry) =>
|
|
53
|
+
entry.isFile() &&
|
|
54
|
+
entry.name.endsWith('.md') &&
|
|
55
|
+
entry.name !== INDEX_FILE,
|
|
56
|
+
)
|
|
57
|
+
.map((entry) => entry.name)
|
|
58
|
+
.sort()
|
|
59
|
+
|
|
60
|
+
const withCriterion: string[] = []
|
|
61
|
+
const withoutCriterion: string[] = []
|
|
62
|
+
for (const name of standards) {
|
|
63
|
+
const body = readFileSync(join(dir, name), 'utf8')
|
|
64
|
+
;(CRITERION_HEADING.test(body) ? withCriterion : withoutCriterion).push(
|
|
65
|
+
name,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const base = await resolveBaseRef(root)
|
|
70
|
+
if (base === undefined) return { kind: 'refused', reason: 'no-base' }
|
|
71
|
+
|
|
72
|
+
const arrived = await arrivedStandards(root, base)
|
|
73
|
+
if (arrived === undefined) {
|
|
74
|
+
return { kind: 'refused', reason: 'unreadable-arrivals' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const arrivals = standards.filter((name) => arrived.has(name))
|
|
78
|
+
const arrivalsWithoutCriterion = arrivals.filter((name) =>
|
|
79
|
+
withoutCriterion.includes(name),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
kind: 'measured',
|
|
84
|
+
base,
|
|
85
|
+
standards,
|
|
86
|
+
withCriterion,
|
|
87
|
+
withoutCriterion,
|
|
88
|
+
arrivals,
|
|
89
|
+
arrivalsWithoutCriterion,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Only an arrival missing the section sets a failing code. Every other
|
|
95
|
+
* standard without one is a known gap `standards/standard.md` names rather
|
|
96
|
+
* than a violation, so failing the push on the 26 already there teaches
|
|
97
|
+
* contributors to route around the stage.
|
|
98
|
+
*/
|
|
99
|
+
export function auditExitCode(audit: StandardsAudit): number {
|
|
100
|
+
if (audit.kind === 'refused') return 1
|
|
101
|
+
return audit.arrivalsWithoutCriterion.length > 0 ? EXIT_MISSING_CRITERION : 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Filenames under `standards/` present in the working tree and absent at
|
|
106
|
+
* `base`: a plain add, with rename detection forced off so a standard moved
|
|
107
|
+
* into the corpus from elsewhere counts the same as one authored fresh.
|
|
108
|
+
*/
|
|
109
|
+
async function arrivedStandards(
|
|
110
|
+
root: string,
|
|
111
|
+
base: string,
|
|
112
|
+
): Promise<Set<string> | undefined> {
|
|
113
|
+
const [added, untracked] = await Promise.all([
|
|
114
|
+
$`git -C ${root} diff --no-renames --name-only --diff-filter=A ${base} -- standards`
|
|
115
|
+
.env(gitEnv())
|
|
116
|
+
.quiet()
|
|
117
|
+
.nothrow(),
|
|
118
|
+
$`git -C ${root} ls-files --others --exclude-standard -- standards`
|
|
119
|
+
.env(gitEnv())
|
|
120
|
+
.quiet()
|
|
121
|
+
.nothrow(),
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
if (added.exitCode !== 0 || untracked.exitCode !== 0) return undefined
|
|
125
|
+
|
|
126
|
+
const paths = [
|
|
127
|
+
...added.text().split('\n'),
|
|
128
|
+
...untracked.text().split('\n'),
|
|
129
|
+
].filter(Boolean)
|
|
130
|
+
|
|
131
|
+
return new Set(paths.map((path) => basename(path)))
|
|
132
|
+
}
|
|
@@ -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
|
+
}
|
package/standards/design.md
CHANGED
|
@@ -18,14 +18,14 @@ Does not govern:
|
|
|
18
18
|
|
|
19
19
|
## What goes in
|
|
20
20
|
|
|
21
|
-
-
|
|
21
|
+
- A token's exact value, anchored to the surface it was read from and tagged per `## The uncertainty tag` when unconfirmed. Fall back to intent language ("mid gray, muted text") only where no source exists yet to anchor from.
|
|
22
22
|
- Layout constraints and sizing rules not obvious from wireframes
|
|
23
23
|
- Visual rules a developer could get wrong without guidance
|
|
24
24
|
- Non-obvious omissions ("no motion", "no custom icons") that prevent scope creep
|
|
25
25
|
|
|
26
26
|
## What does not go in
|
|
27
27
|
|
|
28
|
-
- CSS classes
|
|
28
|
+
- CSS classes and prop names. Those live in code.
|
|
29
29
|
- Anything that needs updating every time the code is refactored
|
|
30
30
|
|
|
31
31
|
## Format
|