@erclx/canon 4.31.1 → 4.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-docs/references/anchor-sweep.md +1 -1
- package/claude/skills/claude-orchestrate/SKILL.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +4 -5
- package/claude/skills/claude-orchestrate/references/orchestrator-handoff.md +3 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +1 -1
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/create-skill/SKILL.md +1 -1
- package/claude/skills/write-human/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/references/source-material.md +1 -1
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/key-changes.md +2 -2
- package/docs/agents/markdown-audit.md +2 -2
- package/docs/agents/sessions.md +4 -0
- package/docs/agents/superseded.md +1 -1
- package/docs/agents/worktrees.md +5 -2
- package/package.json +1 -1
- package/src/client-commands.ts +99 -0
- package/src/commands/worktrees.ts +24 -13
- package/src/exempt-marker.ts +1 -1
- package/src/gate/measures.ts +75 -3
- package/src/gate/stages.ts +8 -0
- package/src/shipped/references.ts +36 -0
- package/src/worktrees/reclaim.ts +95 -25
- package/standards/publish.md +6 -2
|
@@ -27,7 +27,7 @@ Most of these name a folder rather than a file, because the number an entry anch
|
|
|
27
27
|
|
|
28
28
|
The third rule reaches folders alone, which is what keeps it off `CLAUDE.md`. Breadth is the whole reason it exists, and a root file has none: it names one path the diff either carries or does not.
|
|
29
29
|
|
|
30
|
-
What it does stop is a citation of `.claude/` matching every branch. `src/`, `scripts/`, and `.claude/` each name a tree a project organizes most of its work under, so a prefix match on one fires constantly and rebuilds the ignored warning this sweep exists to avoid. Measured against this repository's record
|
|
30
|
+
What it does stop is a citation of `.claude/` matching every branch. `src/`, `scripts/`, and `.claude/` each name a tree a project organizes most of its work under, so a prefix match on one fires constantly and rebuilds the ignored warning this sweep exists to avoid. Measured against this repository's record, the skip drops 13 of the 26 folder citations and keeps the ones carrying a signal, among them `src/tooling/`, `.claude/rules/`, and `governance/rules/`.
|
|
31
31
|
|
|
32
32
|
A decision whose folder citations are all a single segment, and which cites no file, therefore never fires. That is the correct outcome rather than a gap: a count over a whole tree moves on nearly every branch, so flagging it every time tells a reader that time has passed and nothing else.
|
|
33
33
|
|
|
@@ -119,7 +119,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
|
|
|
119
119
|
- Resolve the target at the moment of sending with `canon sessions list --branch`, never from a mapping written down earlier, since names rotate as sessions end and one recorded earlier in a session has failed inside the hour. The runbook read at step 5 routes on the count and the confidence it answers with
|
|
120
120
|
- Open the message with the worktree and branch the sender believes the reader holds, asking to be corrected, whenever that mapping is inferred rather than confirmed
|
|
121
121
|
- Name the skill for the reader to run rather than writing an invocation, which arrives as text
|
|
122
|
-
- Read the pull request's own draft flag rather than the state a worker reports, and report what the read returned and when rather than the state alone. `canon docs pr-reads` states why a reported field can lag and say nothing about it. The flag settles the question only once the worker's chain has run its undo, and nothing marks that moment, so a read taken between the pull request opening and that call sees a genuinely ready pull request about to become a draft
|
|
122
|
+
- Read the pull request's own draft flag rather than the state a worker reports, and report what the read returned and when rather than the state alone. `canon docs pr-reads` states why a reported field can lag and say nothing about it. The flag settles the question only once the worker's chain has run its undo, and nothing marks that moment, so a read taken between the pull request opening and that call sees a genuinely ready pull request about to become a draft and has told an operator the opposite of what the worker had said.
|
|
123
123
|
|
|
124
124
|
A session is reachable when it appears in a live listing, which reads what each session registered on disk rather than probing it, and a message carries plain text and no authority. When no live session holds the branch, report the invocation for the human, naming the branch, the pull request, and the skill to run, then stop. Retrying or waiting leaves the loop believing it is open while nothing acts on it. Every dispatch in the trial behind this step found a live session, so this branch stands on reasoning rather than on observation.
|
|
125
125
|
|
|
@@ -103,15 +103,14 @@ The client expands a slash command at position zero of a launch prompt as a
|
|
|
103
103
|
user invocation, which is the route `disable-model-invocation: true` permits
|
|
104
104
|
and gates. Everything that reaches the session as prose instead falls to the
|
|
105
105
|
model, which invokes it through the `Skill` tool, and that route answers a
|
|
106
|
-
flagged skill inconsistently. `claude-autoship` has carried the flag since
|
|
107
|
-
|
|
106
|
+
flagged skill inconsistently. `claude-autoship` has carried the flag since early in its life, and seven other
|
|
107
|
+
shipped skills carry it too.
|
|
108
108
|
|
|
109
109
|
Three launches on 2026-08-31 and 2026-09-02 bound what makes a command take
|
|
110
110
|
that route. Observation A is the first refused worker, launched as `Run
|
|
111
111
|
/canon:claude-worktree ..., then /canon:claude-autoship ...`, which expanded
|
|
112
112
|
nothing. Observation B is a re-dispatch launched as `/canon:claude-autoship
|
|
113
|
-
<plan>` with a space before the path, which expanded and shipped
|
|
114
|
-
`erclx/canon#1382`.
|
|
113
|
+
<plan>` with a space before the path, which expanded and shipped.
|
|
115
114
|
|
|
116
115
|
Observation C is a planning dispatch launched as `/canon:claude-planner, then
|
|
117
116
|
/canon:claude-feature <task>` with a comma glued to the command name at
|
|
@@ -178,7 +177,7 @@ Report the dispatch as loudly as the human-launch line it replaces: name the bra
|
|
|
178
177
|
|
|
179
178
|
`claude-address-review` is a single pass, not a chain, so a launch naming it
|
|
180
179
|
alone reaches no `claude-worker` and takes no role, which owes no message
|
|
181
|
-
either.
|
|
180
|
+
either. A replacement session was launched that way once, onto the branch a
|
|
182
181
|
review had already posted findings against, and it answered by posting a
|
|
183
182
|
thread reply and telling its controller nothing. Reach the role directly on
|
|
184
183
|
this launch instead of wrapping a second chain around one skill that has none
|
|
@@ -17,6 +17,8 @@ Name the planner as the case always in this set, and scope the act no narrower.
|
|
|
17
17
|
|
|
18
18
|
Retire what this session dispatched. Report a hand-launched session and leave the act to the operator, since the knowledge making the act safe is a delivery this session received and it received nothing from a session it never launched. The `worker-` and `planner-` name prefixes separate the two populations, per `orchestrator-dispatch.md`.
|
|
19
19
|
|
|
20
|
+
A capability gap sits beside the knowledge gap, and it holds even where delivery is somehow known. A hand-launched session is as often interactive as background, and `claude rm <id>` has no id to take for an interactive one at all, so the command cannot reach it whatever this session learned about its delivery.
|
|
21
|
+
|
|
20
22
|
### Telling a delivered session from a blocked one
|
|
21
23
|
|
|
22
24
|
No status field carries the difference. `status` reads `busy` or `waiting` straight off the client record, and both describe the last turn rather than the work, so `waiting` is the ordinary answer for a session that reported and stopped, which is what a correct delivery looks like. The `statusDwellMs` beside it separates a long idle from a short one and separates nothing else.
|
|
@@ -25,7 +27,7 @@ The read is the report this session is holding. Retire a session whose delivery
|
|
|
25
27
|
|
|
26
28
|
### The act, and the order it runs in
|
|
27
29
|
|
|
28
|
-
`claude rm <id>`
|
|
30
|
+
`claude rm <id>` is meant to remove a background session and its worktree together, though its own report is not reliable, so `canon worktrees list` is the reading to trust afterward. It takes one target per call, so a wave costs one call per session. The argument is the id rather than the name, which `claude agents --json` carries beside the name on every row, so a session read off a roster by name is matched to its id there before the call. Both readings are from 2026-09-02, where the name form answered `No job matching`.
|
|
29
31
|
|
|
30
32
|
The command belongs to the client rather than to this toolkit, so a target running another client performs whatever removal that client offers.
|
|
31
33
|
|
|
@@ -70,7 +70,7 @@ The state reaches every stalled dispatch, since one threshold governs the headin
|
|
|
70
70
|
|
|
71
71
|
The report is also where the count in `## Parallelism` is legible. That threshold trips on open pull requests awaiting a first pass, which is what `OPENED` and a pull request with no prior pass name here and what `SEEN` excludes, so read the count off these lines rather than off `gh pr list`, which counts a branch closed out and waiting on a merge the same as one nobody has read. It is a separate condition from the poll-start fallback above, which decides when this loop runs rather than where a review runs.
|
|
72
72
|
|
|
73
|
-
The count reads low, and it errs in the direction that breaks the trigger. A review's `commit.oid` is stamped with the head at submission rather than with the commit the reviewer read, so an author pushing between the diff read and the post leaves the pass recorded against a commit it never saw, and `SEEN` then fires on a head still awaiting its first look at that delta.
|
|
73
|
+
The count reads low, and it errs in the direction that breaks the trigger. A review's `commit.oid` is stamped with the head at submission rather than with the commit the reviewer read, so an author pushing between the diff read and the post leaves the pass recorded against a commit it never saw, and `SEEN` then fires on a head still awaiting its first look at that delta. That has fired for real, and the delta it skipped once was a genuine fix. So a `SEEN` on a head you do not recognize is worth one `gh pr view --json reviews` before it is believed, and a wave past three is likelier to trip the fallback late than early.
|
|
74
74
|
|
|
75
75
|
The other side of that comparison used to lag as well, which made the two errors compound rather than cancel. `gh pr view --json headRefOid` answers from the pull request object and that object trails the branch ref by up to a minute after a push, reporting nothing about the trail, so a pass stamped ahead of the commit it read was compared against a head stamped behind the commit that exists. Two sessions were wrong off that field on 2026-09-01: a reviewing session posted a finding calling a pushed commit unpushed, and a worker fired a green claim it retracted on its own thread minutes later. The head now comes from `canon pr head`, which resolves the tip through `git ls-remote` and reports which commit each source names, and the object's head stays behind it as the fallback for a target whose CLI predates the verb. The review stamp is unchanged, so the paragraph above still holds on its own half.
|
|
76
76
|
|
|
@@ -95,7 +95,7 @@ A non-empty result carries the comment id Step 4 needs for the third filename se
|
|
|
95
95
|
|
|
96
96
|
A `## Post-review findings` reply carries no argued finding behind it, since it asserts a new defect rather than answering one, and this pass is its first independent reader. Restating it as a finding without opening anything is repeating the worker's claim rather than checking it. Read the file the comment names at `<headRefOid>`, the same `git show <headRefOid>:<path>` read Step 3 already runs to confirm a ticked box, and confirm the defect before it becomes a finding of this pass's own.
|
|
97
97
|
|
|
98
|
-
A moved head has its own way to add nothing, which the ancestor test cannot see. When `<prior-heading>` reads `## Review closed`, the standing verdict already reports the branch clear, so a pass over the new commits that raises nothing posts a comment saying what the one above it said. Two close-outs landed
|
|
98
|
+
A moved head has its own way to add nothing, which the ancestor test cannot see. When `<prior-heading>` reads `## Review closed`, the standing verdict already reports the branch clear, so a pass over the new commits that raises nothing posts a comment saying what the one above it said. Two close-outs landed that way on 2026-08-28, and the operator caught the pair rather than any check.
|
|
99
99
|
|
|
100
100
|
The producing shape is narrow. A prior pass tells the author a change is their own call, the author makes it, and the delta reaching the next pass has nothing left to say by construction.
|
|
101
101
|
|
|
@@ -29,4 +29,4 @@ Read these files in parallel:
|
|
|
29
29
|
|
|
30
30
|
Every skill carries a requirement. A skill created without one is a gap someone closes in a later sweep, and the sweep has to reconstruct what the skill was for from the body it already shipped.
|
|
31
31
|
|
|
32
|
-
The two-question check has a shipped precedent.
|
|
32
|
+
The two-question check has a shipped precedent. A pull request dropped `git-stash` for wrapping a single git command that needed no skill body, and `release-changelog` for being low-frequency and invoked by no other skill. The first is question one, and the second is question three read back after the fact, which is why it stays a review criterion rather than something this skill can gate on.
|
|
@@ -13,7 +13,7 @@ The output of that arrangement is specific rather than general. Paragraphs come
|
|
|
13
13
|
|
|
14
14
|
A session also has no name for the patterns no closed word set can match. Rule-of-three, synonym cycling, false ranges, inline-header lists, and adverb propping each survive a clean scan, so a passage carrying all five reports zero findings and still reads wrong. Nothing tells the session those patterns exist, and a ban list cannot be extended to reach them, because each is a shape rather than a token.
|
|
15
15
|
|
|
16
|
-
Importing that catalog wholesale fails on a different measurement. External material of this kind bans abstract metaphor nouns and names `surface` among them, which this corpus uses 593 times in tracked markdown
|
|
16
|
+
Importing that catalog wholesale fails on a different measurement. External material of this kind bans abstract metaphor nouns and names `surface` among them, which this corpus uses 593 times in tracked markdown. A session handed the catalog with no stated position either rewrites established vocabulary or silently ignores the item, and neither outcome is recoverable by a later reader who cannot tell which happened.
|
|
17
17
|
|
|
18
18
|
## Must
|
|
19
19
|
|
|
@@ -24,7 +24,7 @@ The ambiguity rules travel with that layer for the same reason. Compression is w
|
|
|
24
24
|
- **Any restatement of a banned word set.** The bans ship as package data that `canon markdown audit` reads, and a copy here would be a second source for one list with a machine reading the other.
|
|
25
25
|
- **A voice register per surface.** One baseline covers every surface. A surface needing a warmer register states its own voice and claims the yield, which lifts voice alone and leaves the bans, the rhythm rules, and the tells in force.
|
|
26
26
|
|
|
27
|
-
The first of those four rests on a count. The source names `surface` among the nouns it bans, and tracked markdown in the authoring repository used `surface` 593 times, `harness` 95, `scaffolding` 17, and `primitive` 4, measured
|
|
27
|
+
The first of those four rests on a count. The source names `surface` among the nouns it bans, and tracked markdown in the authoring repository used `surface` 593 times, `harness` 95, `scaffolding` 17, and `primitive` 4, measured on 2026-08-19. Adopting the item either rewrites established vocabulary across the corpus or is ignored in silence, and a reader arriving later cannot tell which happened.
|
|
28
28
|
|
|
29
29
|
## Where this diverges
|
|
30
30
|
|
|
@@ -55,7 +55,7 @@ The table check reports a catalog that grows a row per shipped thing, not a tabl
|
|
|
55
55
|
|
|
56
56
|
The provenance check reports the markers narrating how a domain reached its shape rather than describing what it is: a date, a change number, or a release label. The standard admits a rejected alternative and the reasoning that killed it while refusing the provenance attached to it, so a marker names a line to read rather than a line to delete. Findings group by entry and sort left to right within a line, since what a reader acts on is which file to open.
|
|
57
57
|
|
|
58
|
-
A date stamping a measurement is excluded, because the standard cuts a date attached to a change and permits one dating a figure. The check reads the clause in front of the date, back to the nearest sentence boundary, for one of five verbs: measured, verified, driven, passed, and fired. The noun `run` counts only where it sits against the date, so `A run on 2026-08-14` is excluded and `Runs on erclx/canon#632 and erclx/canon#634 landed 2026-08-02` reports. The set is closed, and a date it cannot place reports as a change marker rather than as a state of its own, which names one date too many rather than clearing one the rule cuts.
|
|
58
|
+
A date stamping a measurement is excluded, because the standard cuts a date attached to a change and permits one dating a figure. The check reads the clause in front of the date, back to the nearest sentence boundary, for one of five verbs: measured, verified, driven, passed, and fired. The noun `run` counts only where it sits against the date, so `A run on 2026-08-14` is excluded and `Runs on erclx/canon#632 and erclx/canon#634 landed 2026-08-02` reports. The set is closed, and a date it cannot place reports as a change marker rather than as a state of its own, which names one date too many rather than clearing one the rule cuts. <!-- canon-allow-reference: illustrates the input shape the rule reads -->
|
|
59
59
|
|
|
60
60
|
A release label reports with or without its leading `v` at three segments, since the rule cuts the label rather than a spelling of it. Two segments still need the `v`, which keeps a dollar cost and a duration out. Another tool's version reports too, and the check cannot tell one from a release, so treat a version beside a tool name as a line to read rather than one to cut.
|
|
61
61
|
|
|
@@ -28,7 +28,7 @@ The positional is the pull request to read, defaulting to the one open on this b
|
|
|
28
28
|
|
|
29
29
|
Without `--body` the body, the file list, and the head commit come back from one `gh pr view` call, because the three have to describe the same commit and reading them apart leaves a window where a push between them compares a body against another head's files.
|
|
30
30
|
|
|
31
|
-
That view caps its file list at 100 rows and says nothing about having done so,
|
|
31
|
+
That view caps its file list at 100 rows and says nothing about having done so, measured against a pull request carrying 101 files where the view reported 100. A pull request at the cap therefore takes a second read through the paginated endpoint, and a failure there refuses with `gh-truncated` rather than comparing against a set known to be short. A set silently one file short is the worst input this takes, since the missing file is exactly what a correct bullet would then be accused of inventing.
|
|
32
32
|
|
|
33
33
|
## The two directions
|
|
34
34
|
|
|
@@ -105,7 +105,7 @@ The bound is that the corpus is one repository writing to one house style. A pro
|
|
|
105
105
|
|
|
106
106
|
What it cannot see is a bullet claiming a change to a file the branch did touch for another reason, and a path written without backticks. A claim in a second coordinated clause after the comma is read and credited, and it stays out of the graded direction, since nothing here separates it from a file the same clause cites for context.
|
|
107
107
|
|
|
108
|
-
One class stays open and is named rather than closed. A bullet can cite where something is defined while claiming an edit somewhere else, as `erclx/canon#1274` does with "Name the slug transform in `standards/slug.md` inline in the same step". The path is a definition site, the edit target is the step, and separating the two needs the sentence parsed rather than cut. A finding on such a bullet is this class rather than a stale claim, and a reader weighing an `unmet` path checks whether the bullet's real target is a locative the path does not name.
|
|
108
|
+
One class stays open and is named rather than closed. A bullet can cite where something is defined while claiming an edit somewhere else, as `erclx/canon#1274` does with "Name the slug transform in `standards/slug.md` inline in the same step". The path is a definition site, the edit target is the step, and separating the two needs the sentence parsed rather than cut. A finding on such a bullet is this class rather than a stale claim, and a reader weighing an `unmet` path checks whether the bullet's real target is a locative the path does not name. <!-- canon-allow-reference: illustrates the input shape the rule reads -->
|
|
109
109
|
|
|
110
110
|
## Where it runs
|
|
111
111
|
|
|
@@ -120,7 +120,7 @@ The unit is the paragraph and each file names its worst on each measure, which f
|
|
|
120
120
|
|
|
121
121
|
Neither number gates and neither names a file wrong. This is a weaker claim than the one the weight checkpoints make, because a healthy range differs by surface: a catalog entry is several short sentences carrying one fact each, and a page arguing a decision is not, so one range applied across the corpus would report the surfaces that are correct. The run therefore states where the numbers came from beside them, and the counts are what a reader compares against.
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
A reading of that spread across the whole corpus is what backs the range above, taken once against this repository's own tracked markdown rather than against a target project's.
|
|
124
124
|
|
|
125
125
|
That reading travels with the command rather than staying here. `BASELINE` in `src/markdown/structure.ts` carries the overall share, the per-file range, and the ten-paragraph floor beneath which a file's own rate says nothing, and the run prints all four in the legend beside the rate it measured. A count with no range beside it reads as a finding, and naming that a healthy range differs by surface states that a range exists rather than what it looks like. This page is toolkit-internal, so a reader running the command in a project that installed no standards would otherwise have two counts and nothing to place them against.
|
|
126
126
|
|
|
@@ -138,7 +138,7 @@ Exit codes are `0` for a completed run with no gating finding, `1` for a refusal
|
|
|
138
138
|
|
|
139
139
|
A banned character is a fact rather than a judgment, which is the test that admits it to a gate. What held it back was that gating on day one against a corpus never checked mechanically fails loudly on work nobody has had a chance to fix. The order was to land the verb reporting, measure the corpus once, fix what it finds, and turn the gate on as its own change, and the gate is the last of the four.
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
Of the five measures a first full reading took, the ban count is the only one a gate should ever read.
|
|
142
142
|
|
|
143
143
|
The ban half reached zero, which was the precondition the gate waited on, and it was re-measured against the same corpus at the moment the gate landed. Three sweep slices then took the structural half, and the corpus reports 1 heavy bullet and 22 heavy paragraphs across 6 files on 2026-08-06, every one on a ground the triage record holds as exempt.
|
|
144
144
|
|
package/docs/agents/sessions.md
CHANGED
|
@@ -42,6 +42,10 @@ A bare run reports every repository and carries a `repository` field on each row
|
|
|
42
42
|
|
|
43
43
|
The match can return more than one session. Read the count rather than the first row, since nothing stops two sessions holding one branch, and a caller that treats the result as singular picks among candidates without knowing it.
|
|
44
44
|
|
|
45
|
+
Resolution reads a session's registered `cwd`, taken at whatever `locate()` finds there rather than wherever it has since worked. That registration updates whenever the harness enters a worktree: a worker dispatched onto `main` re-registers at its own branch once `claude-autoship` Step 0 runs. What stays fixed is a session moved by shell command, `cd` or `git -C` against another root, with no worktree entry behind it, so it answers only for the branch its last registered `cwd` sits in.
|
|
46
|
+
|
|
47
|
+
A session whose `cwd` reflects a harness worktree entry, at launch or mid-session, resolves fully, covering every dispatched worker once Step 0 runs. A zero for a different branch it is genuinely working on is not evidence the branch is unclaimed, only that a shell-moved registration cannot see it.
|
|
48
|
+
|
|
45
49
|
## Whether a branch is already claimed
|
|
46
50
|
|
|
47
51
|
With `--branch`, the JSON record also carries `worktree` (the path of any worktree already checked out to it, or `null`), `refs` (the refs that already name the branch, the local head and the `origin` remote-tracking ref alike), and `claimed` (`true` when any of the three holds it).
|
|
@@ -38,7 +38,7 @@ Pass an empty replacement to retire a value outright, as in `canon gov supersede
|
|
|
38
38
|
|
|
39
39
|
## The templated citation
|
|
40
40
|
|
|
41
|
-
A citation naming a family as a pattern carries neither value. `canon-*` and `internal-<X>` are the two forms this corpus writes, and a literal comparison matches neither, so a rename running the verb once per name reports clean while the family citations stay stale. Two
|
|
41
|
+
A citation naming a family as a pattern carries neither value. `canon-*` and `internal-<X>` are the two forms this corpus writes, and a literal comparison matches neither, so a rename running the verb once per name reports clean while the family citations stay stale. Two such citations have inverted this way, stating the old prefix as a guard, which left a rule instructing the next session to flag the correct naming as a mismatch.
|
|
42
42
|
|
|
43
43
|
Beside the literal comparison the run matches a family stem, derived from the segment the two values actually differ on, with everything they share carried ahead of it. `toolkit-operator` to `canon-operator` derives `toolkit`, and `canon-cli` to `canon-shell` derives `canon-cli` rather than `canon`, which is what keeps a rename of one folder off every sibling in its family.
|
|
44
44
|
|
package/docs/agents/worktrees.md
CHANGED
|
@@ -89,11 +89,14 @@ The read is one `gh pr list --state merged` for the whole repository rather than
|
|
|
89
89
|
|
|
90
90
|
`route` names which one applies rather than choosing it, since picking wrong strands state.
|
|
91
91
|
|
|
92
|
-
- `session`: a live session holds the directory, and
|
|
92
|
+
- `session`: a live session holds the directory, and the `sessions` field carries `{name, kind, id}` per holder rather than a bare name, since `claude rm <id>` takes the id and never the name. `kind` reads `bg` or `interactive` off this repository's session registry.
|
|
93
|
+
- A resolved background holder: `id` carries the value `claude agents --json` reports, so `claude rm <id>` is the command to run. Its own report is not reliable: two runs under matched conditions on 2026-09-03 both printed `kept`, and one of them had already removed the worktree, so read `canon worktrees list` again afterward rather than trust what the command printed.
|
|
94
|
+
- An unresolved background holder: `id` is `null` because the binary is missing, fails, or has not reported that pid yet, so its id has to come from a manual read of `claude agents --json` instead.
|
|
95
|
+
- An interactive holder: `id` is always `null`, since no id exists for one at all, and nothing removes it until a person closes its terminal.
|
|
93
96
|
- `worktree`: the session has ended, and `canon worktrees reclaim` is what runs the remove and the branch delete.
|
|
94
97
|
- `null`: the main worktree, which no removal shape reaches.
|
|
95
98
|
|
|
96
|
-
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.
|
|
99
|
+
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, and an interactive holder is the one shape inside that refusal with no removal route at all.
|
|
97
100
|
|
|
98
101
|
## What an unreadable input does
|
|
99
102
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { escapeForPattern, isMarked } from '@/exempt-marker'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A client command this repository quotes, with the argument spelling every
|
|
5
|
+
* quotation of it has to carry.
|
|
6
|
+
*
|
|
7
|
+
* `source` is the fact grounding `canonicalArgument`, so a reader correcting a
|
|
8
|
+
* flagged line can see why the flagged form is wrong rather than taking the
|
|
9
|
+
* table on faith.
|
|
10
|
+
*/
|
|
11
|
+
export interface ClientCommand {
|
|
12
|
+
readonly command: string
|
|
13
|
+
readonly canonicalArgument: string
|
|
14
|
+
readonly source: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const CLIENT_COMMANDS: readonly ClientCommand[] = [
|
|
18
|
+
{
|
|
19
|
+
command: 'claude rm',
|
|
20
|
+
canonicalArgument: 'id',
|
|
21
|
+
source:
|
|
22
|
+
'`claude rm <id>` takes the session id `claude agents --json` carries beside a name, never the name itself, confirmed against `claude rm --help` on 2026-09-02.',
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export const CLIENT_COMMAND_MARKER = 'canon-allow-client-command'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A bracketed placeholder or a template interpolation, each optionally
|
|
30
|
+
* single-quoted, which is the one call form this scanner reads. A sentence
|
|
31
|
+
* naming the command with no bracketed argument carries no placeholder at all
|
|
32
|
+
* and never matches, which is deliberate: the defect this closes is a wrong
|
|
33
|
+
* argument shown as an invocation, not a sentence that never showed one.
|
|
34
|
+
*
|
|
35
|
+
* The interpolation half admits a dotted path, since a property access is the
|
|
36
|
+
* ordinary shape a real call site interpolates, such as `${session.id}`, and a
|
|
37
|
+
* bare `${id}` is the rarer one that only a fixture writes. A capture stopping
|
|
38
|
+
* at the first `.` matched the command literal and missed the property access
|
|
39
|
+
* beside it entirely, which is how the check went unable to read the one call
|
|
40
|
+
* form this repository's own worktree listing writes.
|
|
41
|
+
*/
|
|
42
|
+
const PLACEHOLDER = `'?(?:<([\\w-]+)>|\\$\\{(\\w+(?:\\.\\w+)*)\\})'?`
|
|
43
|
+
|
|
44
|
+
export interface ClientCommandCitation {
|
|
45
|
+
readonly file: string
|
|
46
|
+
/** One-based, matching the `file:line` form a reader clicks. */
|
|
47
|
+
readonly line: number
|
|
48
|
+
readonly command: string
|
|
49
|
+
/** The invocation as written, so a report names the text to correct. */
|
|
50
|
+
readonly text: string
|
|
51
|
+
readonly argument: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every quotation of a listed command in one file whose argument disagrees
|
|
56
|
+
* with the table, and no marker mutes.
|
|
57
|
+
*
|
|
58
|
+
* `commands` defaults to the shipped table and takes a narrower one only for
|
|
59
|
+
* the empty-table case a measure guards against, since production code never
|
|
60
|
+
* has a reason to check against anything else.
|
|
61
|
+
*/
|
|
62
|
+
export function clientCommandCitationsIn(
|
|
63
|
+
file: string,
|
|
64
|
+
text: string,
|
|
65
|
+
commands: readonly ClientCommand[] = CLIENT_COMMANDS,
|
|
66
|
+
): ClientCommandCitation[] {
|
|
67
|
+
const lines = text.split('\n')
|
|
68
|
+
const citations: ClientCommandCitation[] = []
|
|
69
|
+
|
|
70
|
+
for (const { command, canonicalArgument } of commands) {
|
|
71
|
+
const pattern = new RegExp(
|
|
72
|
+
`${escapeForPattern(command)}\\s+${PLACEHOLDER}`,
|
|
73
|
+
'g',
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
for (const [index, line] of lines.entries()) {
|
|
77
|
+
if (isMarked(lines, index, CLIENT_COMMAND_MARKER)) continue
|
|
78
|
+
|
|
79
|
+
for (const match of line.matchAll(pattern)) {
|
|
80
|
+
const dotted = match[2]
|
|
81
|
+
const argument =
|
|
82
|
+
dotted !== undefined
|
|
83
|
+
? (dotted.split('.').at(-1) ?? dotted)
|
|
84
|
+
: (match[1] ?? '')
|
|
85
|
+
if (argument === canonicalArgument) continue
|
|
86
|
+
|
|
87
|
+
citations.push({
|
|
88
|
+
file,
|
|
89
|
+
line: index + 1,
|
|
90
|
+
command,
|
|
91
|
+
text: match[0],
|
|
92
|
+
argument,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return citations
|
|
99
|
+
}
|
|
@@ -145,10 +145,12 @@ export function register(program: Command): void {
|
|
|
145
145
|
...CONDITIONS,
|
|
146
146
|
'',
|
|
147
147
|
'This reports and removes nothing. "route" names which removal shape',
|
|
148
|
-
'applies: "session" when a live session holds the directory,
|
|
149
|
-
'`claude rm <id>`
|
|
150
|
-
'
|
|
151
|
-
'
|
|
148
|
+
'applies: "session" when a live session holds the directory,',
|
|
149
|
+
'where `claude rm <id>` is meant to remove it and its worktree',
|
|
150
|
+
'together, but its own report is not reliable, so run this command',
|
|
151
|
+
'again afterward to see what happened. "worktree" is the shape once',
|
|
152
|
+
'the session has ended, where `canon worktrees reclaim` is the pair',
|
|
153
|
+
'of a remove and a branch delete.',
|
|
152
154
|
'',
|
|
153
155
|
'Examples:',
|
|
154
156
|
' canon worktrees list',
|
|
@@ -185,8 +187,10 @@ export function register(program: Command): void {
|
|
|
185
187
|
'gone sweeps stale registrations once before the branch deletes.',
|
|
186
188
|
'',
|
|
187
189
|
'It deletes only what a reading called reclaimable. A worktree a live',
|
|
188
|
-
'session holds is refused rather than removed
|
|
189
|
-
'is
|
|
190
|
+
'session holds is refused rather than removed here. `claude rm <id>`',
|
|
191
|
+
'is meant to remove the session and its worktree together, but its',
|
|
192
|
+
'own report is not reliable, so run `canon worktrees list` again',
|
|
193
|
+
'afterward to see what happened.',
|
|
190
194
|
'',
|
|
191
195
|
'The stale-registration sweep is the one step that reaches wider, since',
|
|
192
196
|
'git takes no path to scope it. It clears the bookkeeping for every',
|
|
@@ -429,20 +433,27 @@ function reportWorktrees(worktrees: readonly WorktreeVerdict[]): void {
|
|
|
429
433
|
* what to remove, so a row phrased for the first alone leaves the second
|
|
430
434
|
* removing a directory on a conclusion it cannot check.
|
|
431
435
|
*/
|
|
432
|
-
function describe(verdict: WorktreeVerdict): string {
|
|
436
|
+
export function describe(verdict: WorktreeVerdict): string {
|
|
433
437
|
const held = verdict.branch ?? 'detached'
|
|
434
438
|
const head = `${verdict.path} ${held}`
|
|
435
439
|
|
|
436
440
|
if (!verdict.reclaimable) {
|
|
437
441
|
const reasons = verdict.refusals.map((refusal) => REFUSALS[refusal])
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
442
|
+
// Three cases rather than one line, since only a resolved background
|
|
443
|
+
// holder has a real command to print. An interactive holder has no id to
|
|
444
|
+
// look up at all, and an unresolved background one has an id somewhere in
|
|
445
|
+
// `claude agents --json` that this reading could not cross-reference.
|
|
441
446
|
const routes =
|
|
442
447
|
verdict.route === 'session'
|
|
443
|
-
? verdict.sessions.map(
|
|
444
|
-
(
|
|
445
|
-
|
|
448
|
+
? verdict.sessions.map((session) => {
|
|
449
|
+
if (session.kind === 'interactive') {
|
|
450
|
+
return `\n ${session.name}: interactive, so nothing removes it until its terminal closes.`
|
|
451
|
+
}
|
|
452
|
+
if (session.id === null) {
|
|
453
|
+
return `\n ${session.name}: match it against claude agents --json for its id, then claude rm '<id>'.`
|
|
454
|
+
}
|
|
455
|
+
return `\n Removal there goes through: claude rm '${session.id}'`
|
|
456
|
+
})
|
|
446
457
|
: []
|
|
447
458
|
return `${head}\n Refused: ${reasons.join('; ')}.${routes.join('')}`
|
|
448
459
|
}
|
package/src/exempt-marker.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* wrong lines and one holding a parenthesis throws, and neither failure is the
|
|
8
8
|
* caller's to anticipate.
|
|
9
9
|
*/
|
|
10
|
-
function escapeForPattern(token: string): string {
|
|
10
|
+
export function escapeForPattern(token: string): string {
|
|
11
11
|
return token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
12
12
|
}
|
|
13
13
|
|
package/src/gate/measures.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
CLIENT_COMMAND_MARKER,
|
|
6
|
+
CLIENT_COMMANDS,
|
|
7
|
+
type ClientCommand,
|
|
8
|
+
clientCommandCitationsIn,
|
|
9
|
+
} from '@/client-commands'
|
|
10
|
+
import { listRepositoryFiles } from '@/git-files'
|
|
4
11
|
import {
|
|
5
12
|
isShippedCorpus,
|
|
6
13
|
REFERENCE_MARKER,
|
|
@@ -522,13 +529,78 @@ export const shippedReferences: Measure = async (ctx) => {
|
|
|
522
529
|
return {
|
|
523
530
|
emissions: found.map((reference) =>
|
|
524
531
|
warn(
|
|
525
|
-
|
|
532
|
+
reference.selfCitation
|
|
533
|
+
? `${reference.file}:${reference.line} carries ${reference.text}, a citation of this repository's own history that resolves for no reader outside it, qualified or not`
|
|
534
|
+
: `${reference.file}:${reference.line} carries ${reference.text}, a ${reference.kind === 'commit' ? 'commit sha that resolves nowhere' : 'pull request number that resolves elsewhere'} for a reader in a target`,
|
|
535
|
+
),
|
|
536
|
+
),
|
|
537
|
+
failure:
|
|
538
|
+
found.length === 1
|
|
539
|
+
? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, state a same-repository citation as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
|
|
540
|
+
: `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, state a same-repository citation as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Every git-tracked file quoting a listed client command with the wrong
|
|
546
|
+
* argument, over the whole tree rather than one corpus, since a wrong
|
|
547
|
+
* quotation can land in any file this repository writes.
|
|
548
|
+
*
|
|
549
|
+
* `commands` defaults to the shipped table and takes a narrower one only to
|
|
550
|
+
* cover the case where that table ships empty, which is a broken check rather
|
|
551
|
+
* than a clean tree, exactly as an empty ban set is for `markdownBans`.
|
|
552
|
+
*/
|
|
553
|
+
export const clientCommandCitations = async (
|
|
554
|
+
ctx: MeasureContext,
|
|
555
|
+
commands: readonly ClientCommand[] = CLIENT_COMMANDS,
|
|
556
|
+
): Promise<MeasureReport> => {
|
|
557
|
+
if (commands.length === 0) {
|
|
558
|
+
return {
|
|
559
|
+
emissions: [],
|
|
560
|
+
failure:
|
|
561
|
+
'The client command table is empty, so the corpus was walked and nothing was looked for. Check src/client-commands.ts.',
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const files = await listRepositoryFiles(ctx.root)
|
|
566
|
+
if (files === undefined) {
|
|
567
|
+
return {
|
|
568
|
+
emissions: [],
|
|
569
|
+
unmeasured:
|
|
570
|
+
'The tracked file list could not be read, so no client command citation was checked.',
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const found = files.flatMap((file) => {
|
|
575
|
+
let text: string
|
|
576
|
+
try {
|
|
577
|
+
text = readFileSync(join(ctx.root, file), 'utf8')
|
|
578
|
+
} catch {
|
|
579
|
+
return []
|
|
580
|
+
}
|
|
581
|
+
return clientCommandCitationsIn(file, text, commands)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
if (found.length === 0) {
|
|
585
|
+
return {
|
|
586
|
+
emissions: [
|
|
587
|
+
info(
|
|
588
|
+
`No client command carries a wrong argument across ${files.length} tracked files`,
|
|
589
|
+
),
|
|
590
|
+
],
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
emissions: found.map((citation) =>
|
|
596
|
+
warn(
|
|
597
|
+
`${citation.file}:${citation.line} carries ${citation.text}, which quotes \`${citation.command}\` with the wrong argument`,
|
|
526
598
|
),
|
|
527
599
|
),
|
|
528
600
|
failure:
|
|
529
601
|
found.length === 1
|
|
530
|
-
? `One
|
|
531
|
-
: `${found.length}
|
|
602
|
+
? `One tracked citation quotes a client command with the wrong argument. Match it against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`
|
|
603
|
+
: `${found.length} tracked citations quote a client command with the wrong argument. Match each against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`,
|
|
532
604
|
}
|
|
533
605
|
}
|
|
534
606
|
|
package/src/gate/stages.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
auditSet,
|
|
3
3
|
captureStamps,
|
|
4
|
+
clientCommandCitations,
|
|
4
5
|
markdownBans,
|
|
5
6
|
type Measure,
|
|
6
7
|
pluginManifests,
|
|
@@ -370,6 +371,13 @@ export const STAGES: readonly Stage[] = [
|
|
|
370
371
|
label: 'Markdown bans',
|
|
371
372
|
checks: [{ kind: 'measure', measure: markdownBans }],
|
|
372
373
|
},
|
|
374
|
+
{
|
|
375
|
+
// Unscoped, since a wrong quotation of a client command can land in any
|
|
376
|
+
// tracked file rather than in one corpus.
|
|
377
|
+
id: 'client-command-citations',
|
|
378
|
+
label: 'Client command citations',
|
|
379
|
+
checks: [{ kind: 'measure', measure: clientCommandCitations }],
|
|
380
|
+
},
|
|
373
381
|
{
|
|
374
382
|
// Scoped to the corpora it reads rather than run unconditionally, so a
|
|
375
383
|
// branch touching only `src/` or `.claude/` skips it and says so. The
|
|
@@ -105,6 +105,26 @@ const PULL_REQUEST = /(?<![0-9A-Za-z_])#([0-9]+)(?![0-9A-Za-z_])/g
|
|
|
105
105
|
*/
|
|
106
106
|
const COMMIT_SHA = /(?<![0-9A-Za-z_@/#])([0-9a-f]{7,40})(?![0-9A-Za-z_])/g
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A same-repository citation, qualified or not. Qualifying `#123` or a sha
|
|
110
|
+
* against `erclx/canon` is the repair `PULL_REQUEST` and `COMMIT_SHA` above
|
|
111
|
+
* read as the fix, and it fixes nothing here: a reader holding only the
|
|
112
|
+
* plugin cache or the published package still cannot open this repository's
|
|
113
|
+
* own history, so the qualified form resolves exactly as badly as the bare
|
|
114
|
+
* one.
|
|
115
|
+
*
|
|
116
|
+
* The literal is hardcoded rather than read from `package.json` or
|
|
117
|
+
* `git remote`, matching `src/github-format.ts` and `src/commands/repo.ts`,
|
|
118
|
+
* which already hardcode it.
|
|
119
|
+
*
|
|
120
|
+
* Same word-boundary discipline as the two patterns above: a word character
|
|
121
|
+
* ahead of `erclx` would mean this match is a suffix of some other token, and
|
|
122
|
+
* a word character behind the digits would mean the citation continues past
|
|
123
|
+
* what was captured.
|
|
124
|
+
*/
|
|
125
|
+
const SAME_REPOSITORY =
|
|
126
|
+
/(?<![0-9A-Za-z_/])erclx\/canon(#[0-9]+|@[0-9a-f]{7,40})(?![0-9A-Za-z_])/g
|
|
127
|
+
|
|
108
128
|
export interface ShippedReference {
|
|
109
129
|
readonly file: string
|
|
110
130
|
/** One-based, matching the `file:line` form a reader clicks. */
|
|
@@ -112,6 +132,12 @@ export interface ShippedReference {
|
|
|
112
132
|
readonly kind: 'pull-request' | 'commit'
|
|
113
133
|
/** The reference as written, so a report names the token to qualify. */
|
|
114
134
|
readonly text: string
|
|
135
|
+
/**
|
|
136
|
+
* Set only for a citation of this repository's own history. Qualifying it
|
|
137
|
+
* is not the fix `kind` alone would suggest, so a caller reads this before
|
|
138
|
+
* choosing a remedy.
|
|
139
|
+
*/
|
|
140
|
+
readonly selfCitation?: true
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
/**
|
|
@@ -154,6 +180,16 @@ export function referencesIn(file: string, text: string): ShippedReference[] {
|
|
|
154
180
|
text: match[0],
|
|
155
181
|
})
|
|
156
182
|
}
|
|
183
|
+
|
|
184
|
+
for (const match of line.matchAll(SAME_REPOSITORY)) {
|
|
185
|
+
references.push({
|
|
186
|
+
file,
|
|
187
|
+
line: index + 1,
|
|
188
|
+
kind: match[1]?.startsWith('#') ? 'pull-request' : 'commit',
|
|
189
|
+
text: match[0],
|
|
190
|
+
selfCitation: true,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
157
193
|
}
|
|
158
194
|
|
|
159
195
|
return references
|
package/src/worktrees/reclaim.ts
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
|
|
17
17
|
const GH_TIMEOUT_MS = 30_000
|
|
18
18
|
|
|
19
|
+
/** A local binary read, not a network round trip, so the bound is tighter. */
|
|
20
|
+
const CLAUDE_AGENTS_TIMEOUT_MS = 10_000
|
|
21
|
+
|
|
19
22
|
/**
|
|
20
23
|
* How many merged pull requests one read covers. A worktree older than this
|
|
21
24
|
* many merges reads as having none and is refused, which is the safe direction:
|
|
@@ -47,6 +50,21 @@ export type Route = 'session' | 'worktree'
|
|
|
47
50
|
/** No removal shape reaches the main worktree, which is what `null` says. */
|
|
48
51
|
export type RemovalRoute = Route | null
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* One live session holding a worktree, enriched with the id `claude rm` takes.
|
|
55
|
+
*
|
|
56
|
+
* `id` is null on two occasions a caller cannot tell apart from the name
|
|
57
|
+
* alone: an interactive holder, which carries no id at all since nothing but a
|
|
58
|
+
* person closing its terminal removes it, and a background holder whose id
|
|
59
|
+
* `claude agents --json` could not resolve, whether the binary is missing,
|
|
60
|
+
* failing, or simply has not reported that pid.
|
|
61
|
+
*/
|
|
62
|
+
export interface HeldSession {
|
|
63
|
+
readonly name: string
|
|
64
|
+
readonly kind: string
|
|
65
|
+
readonly id: string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
export interface WorktreeVerdict {
|
|
51
69
|
readonly path: string
|
|
52
70
|
readonly branch: string | null
|
|
@@ -55,12 +73,8 @@ export interface WorktreeVerdict {
|
|
|
55
73
|
readonly refusals: readonly Refusal[]
|
|
56
74
|
/** The pull request that retired the branch, so a report can name what it read. */
|
|
57
75
|
readonly pullRequest: number | null
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
* id rather than a name, so a caller acting on this matches each name to the
|
|
61
|
-
* id `claude agents --json` carries beside it.
|
|
62
|
-
*/
|
|
63
|
-
readonly sessions: readonly string[]
|
|
76
|
+
/** The live sessions holding this worktree. */
|
|
77
|
+
readonly sessions: readonly HeldSession[]
|
|
64
78
|
readonly route: RemovalRoute
|
|
65
79
|
/**
|
|
66
80
|
* True when the directory is already gone and only the registration remains,
|
|
@@ -107,6 +121,9 @@ export interface ReclaimOptions {
|
|
|
107
121
|
readonly mergedPullRequests?: (cwd: string) => Promise<MergedReport>
|
|
108
122
|
readonly worktreeStatus?: (path: string) => Promise<StatusReport>
|
|
109
123
|
readonly resolve?: () => Promise<SessionReport>
|
|
124
|
+
readonly claudeAgentIds?: (
|
|
125
|
+
cwd: string,
|
|
126
|
+
) => Promise<ReadonlyMap<number, string>>
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
/**
|
|
@@ -170,6 +187,45 @@ async function mergedPullRequests(cwd: string): Promise<MergedReport> {
|
|
|
170
187
|
}
|
|
171
188
|
}
|
|
172
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Reads every live agent's session id, keyed by its process id, from `claude
|
|
192
|
+
* agents --json`.
|
|
193
|
+
*
|
|
194
|
+
* Guarded like `mergedPullRequests`, reading the same way, but degrading
|
|
195
|
+
* rather than refusing: the id enriches a caller's report and is never an
|
|
196
|
+
* input the reclaim logic depends on, where the merge state decides whether a
|
|
197
|
+
* removal is safe at all. A missing or failing binary leaves every entry
|
|
198
|
+
* unresolved instead of refusing the whole reading.
|
|
199
|
+
*/
|
|
200
|
+
async function claudeAgentIds(
|
|
201
|
+
cwd: string,
|
|
202
|
+
): Promise<ReadonlyMap<number, string>> {
|
|
203
|
+
if (Bun.which('claude') === null) return new Map()
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const result = await execa('claude', ['agents', '--json'], {
|
|
207
|
+
cwd,
|
|
208
|
+
timeout: CLAUDE_AGENTS_TIMEOUT_MS,
|
|
209
|
+
env: gitEnv(),
|
|
210
|
+
extendEnv: false,
|
|
211
|
+
})
|
|
212
|
+
const rows = JSON.parse(result.stdout) as readonly {
|
|
213
|
+
pid?: unknown
|
|
214
|
+
id?: unknown
|
|
215
|
+
}[]
|
|
216
|
+
|
|
217
|
+
const ids = new Map<number, string>()
|
|
218
|
+
for (const row of rows) {
|
|
219
|
+
if (typeof row.pid === 'number' && typeof row.id === 'string') {
|
|
220
|
+
ids.set(row.pid, row.id)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return ids
|
|
224
|
+
} catch {
|
|
225
|
+
return new Map()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
173
229
|
/**
|
|
174
230
|
* Reports whether a worktree holds work no history is behind.
|
|
175
231
|
*
|
|
@@ -214,16 +270,14 @@ function holders(
|
|
|
214
270
|
entry: WorktreeEntry,
|
|
215
271
|
sessions: readonly ResolvedSession[],
|
|
216
272
|
repository: string | null,
|
|
217
|
-
): readonly
|
|
218
|
-
return sessions
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
.map((candidate) => candidate.name)
|
|
273
|
+
): readonly ResolvedSession[] {
|
|
274
|
+
return sessions.filter(
|
|
275
|
+
(candidate) =>
|
|
276
|
+
candidate.worktree === entry.path ||
|
|
277
|
+
(entry.branch !== null &&
|
|
278
|
+
candidate.branch === entry.branch &&
|
|
279
|
+
candidate.repository === repository),
|
|
280
|
+
)
|
|
227
281
|
}
|
|
228
282
|
|
|
229
283
|
function verdict(
|
|
@@ -234,8 +288,20 @@ function verdict(
|
|
|
234
288
|
merged: ReadonlyMap<string, number>,
|
|
235
289
|
sessions: readonly ResolvedSession[],
|
|
236
290
|
repository: string | null,
|
|
291
|
+
agentIds: ReadonlyMap<number, string>,
|
|
237
292
|
): WorktreeVerdict {
|
|
238
293
|
const held = holders(entry, sessions, repository)
|
|
294
|
+
// An interactive holder carries no id to look up at all, so the branch is
|
|
295
|
+
// explicit rather than relying on `claude agents --json` never reporting one
|
|
296
|
+
// for a pid this repository's own registry marked interactive.
|
|
297
|
+
const heldSessions: HeldSession[] = held.map((session) => ({
|
|
298
|
+
name: session.name,
|
|
299
|
+
kind: session.kind,
|
|
300
|
+
id:
|
|
301
|
+
session.kind === 'interactive'
|
|
302
|
+
? null
|
|
303
|
+
: (agentIds.get(session.pid) ?? null),
|
|
304
|
+
}))
|
|
239
305
|
const pullRequest =
|
|
240
306
|
entry.branch === null ? null : (merged.get(entry.branch) ?? null)
|
|
241
307
|
const refusals: Refusal[] = []
|
|
@@ -262,7 +328,7 @@ function verdict(
|
|
|
262
328
|
reclaimable: refusals.length === 0,
|
|
263
329
|
refusals,
|
|
264
330
|
pullRequest,
|
|
265
|
-
sessions:
|
|
331
|
+
sessions: heldSessions,
|
|
266
332
|
// No removal shape reaches the main worktree, and reporting one there
|
|
267
333
|
// offers a command whose only effect is to break the checkout. Deciding it
|
|
268
334
|
// here rather than in the reporter keeps the record and the framed output
|
|
@@ -301,14 +367,17 @@ export async function reclaimReport(
|
|
|
301
367
|
const readMerged = opts.mergedPullRequests ?? mergedPullRequests
|
|
302
368
|
const readStatus = opts.worktreeStatus ?? worktreeStatus
|
|
303
369
|
const resolve = opts.resolve ?? resolveSessions
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
370
|
+
const readAgentIds = opts.claudeAgentIds ?? claudeAgentIds
|
|
371
|
+
|
|
372
|
+
const [entries, merged, sessions, repository, current, agentIds] =
|
|
373
|
+
await Promise.all([
|
|
374
|
+
listAll(cwd),
|
|
375
|
+
readMerged(cwd),
|
|
376
|
+
resolve(),
|
|
377
|
+
repositoryOf(cwd),
|
|
378
|
+
currentWorktreeRoot(cwd),
|
|
379
|
+
readAgentIds(cwd),
|
|
380
|
+
])
|
|
312
381
|
|
|
313
382
|
if (merged.kind === 'unreadable') {
|
|
314
383
|
return {
|
|
@@ -344,6 +413,7 @@ export async function reclaimReport(
|
|
|
344
413
|
byBranch,
|
|
345
414
|
sessions.sessions,
|
|
346
415
|
repository,
|
|
416
|
+
agentIds,
|
|
347
417
|
),
|
|
348
418
|
)
|
|
349
419
|
|
package/standards/publish.md
CHANGED
|
@@ -62,9 +62,13 @@ A number referring to a pull request or an issue takes the form its destination
|
|
|
62
62
|
|
|
63
63
|
Text published to a remote is the auto-linking half: a pull request body, an issue body, a comment on either, and a commit message. Write `#123` there, and `owner/repo#123` where the reference crosses repositories, which the same auto-linking reaches. A markdown file browsed from the repository tree is the other half, where neither spelling links and the backticked one marks the number as an identifier. <!-- canon-allow-reference: the two spellings this section defines, which a qualified form would destroy -->
|
|
64
64
|
|
|
65
|
-
Content that installs into another repository is the third destination, and
|
|
65
|
+
Content that installs into another repository is the third destination, and it splits on which repository the number names. A skill body loads from a plugin cache, a docs page is read through `canon docs`, and a standard through `canon standards`, so the reader holds their own repository rather than the one a bare number belongs to. Qualifying a number against a different, resolvable repository fixes that, since the reader can open that repository even without this one.
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
Qualifying it against `erclx/canon` fixes nothing, because a reader holding only the plugin cache or the published package still cannot open this repository's own history. A same-repository citation takes no number here at all, bare or qualified. State the fact the citation was standing in for instead, and where the identifier is the only trace of a one-off incident rather than a reproducible count, carry it into the `.claude/context/` entry that owns the surface.
|
|
68
|
+
|
|
69
|
+
Write `owner/repo#123` and `owner/repo@abc1234` for a citation of a different, resolvable repository, across every such surface. `canon gate run` fails a push on a bare reference there, and a line whose bare form is the point carries `canon-allow-reference: <reason>` on itself or on the line above.
|
|
70
|
+
|
|
71
|
+
Choose the number over the sha where the citation names a decided change, since a pull request number resolves to the diff and the review behind it. Take the sha only for a tree or measurement state, the `Measured at <sha>` shape `.claude/ARCHITECTURE.md` already uses. The choice holds for both spellings this section fixes, the bare form a remote auto-links and the qualified form a cross-repository citation carries.
|
|
68
72
|
|
|
69
73
|
A commit message takes the bare form even though it is also read through the log, where nothing links. The remote is what the form is chosen for, since a reader in the log loses only a link that plain text never carried.
|
|
70
74
|
|