@erclx/canon 4.1.0 → 4.2.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/README.md CHANGED
@@ -80,7 +80,7 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
80
80
  Scaffolding your first project? Start with target projects, then the AI workflow loop. Everything else answers questions that arrive later.
81
81
 
82
82
  - [AI workflow](docs/ai-workflow.md): feature-development loop inside a toolkit-managed project
83
- - [Operating model](docs/operating-model.md): orchestrator and worker roles for building across parallel sessions
83
+ - [Operating model](docs/operating-model.md): orchestrator, planner, and worker roles for building across parallel sessions
84
84
  - [Visual design workflow](docs/visual-design-workflow.md): tiered guide for design and wireframe authoring
85
85
  - [Target projects](docs/target-projects.md): scaffold, add a domain later, sync upstream drift
86
86
  - [Agents](docs/agents/index.md): CLI flags, exit codes, and JSON output shapes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.1.0",
4
+ "version": "4.2.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -89,6 +89,8 @@ The file follows the template in `${CLAUDE_SKILL_DIR}/../../standards/plan.md`.
89
89
 
90
90
  Run `canon records validate plans` after writing the file when the CLI is on PATH. It reports a section, a filename, or an answer slot that does not hold, and it writes nothing.
91
91
 
92
+ Run `canon markdown audit .claude/plans/feature-<slug>.md` beside it, naming the file. `.claude/plans/` is gitignored and the audit's default path set is what git lists, so no other gate ever opens a plan, and six ban hits landed across four plans written without this call. Rewrite the sentence carrying a hit rather than swapping the token for a near-synonym.
93
+
92
94
  Then output in chat:
93
95
 
94
96
  ```markdown
@@ -108,7 +108,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
108
108
 
109
109
  ## The loop
110
110
 
111
- 1. Plan the next feature. Run `claude-feature` here, with the cross-feature context, to write a plan to `.claude/plans/`. Planning stays in this warm session so the plan front-loads reasoning a cold worker would otherwise re-derive. Every plan written from here also carries a constraint per track in flight, which the paragraph below this list states.
111
+ 1. Plan the next feature. The cross-feature call stays in this warm session, being which rows collide, what merges before what, and whether a row should run at all. Per-row planning runs either way: `claude-feature` here with that context, or a cold planner dispatched under `claude-planner` through the planning shape in `${CLAUDE_SKILL_DIR}/references/orchestrator-dispatch.md`. Every plan written from here also carries a constraint per track in flight, which the paragraph below this list states.
112
112
  2. Decide parallelism and merge order. Note which plans touch a shared wiring seam so their PRs merge in sequence, not at once.
113
113
  3. Verify the plan against the tree. Reading it is not enough, since a plan goes stale from whatever merged after it was written. Grep for each construct it names and count the sites against the count it claims. Check that every phase label it cites is still open. Open each file it describes rather than trusting its account of the contents. Correct the plan before handing it over.
114
114
  4. Hand off. Read `${CLAUDE_SKILL_DIR}/references/orchestrator-dispatch.md` and follow it: check the branch is unclaimed, check the row's file set against every track in flight, then dispatch a background worker with `claude --bg`. Fall back to the human-launch line it replaces when the check refuses, the sets overlap, or a stated reason serializes the row behind something already out.
@@ -129,6 +129,8 @@ What the worker owes on its own side is stated in the `claude-worker` skill that
129
129
 
130
130
  What arrives there does not become a record by being read, so place it by what it changes. An answer that settles a finding goes onto the pull request through the next pass, which withdraws or regrades that finding and names the fact behind it, per `claude-pr-review`. An answer that changes what this session believes about the world instead, which is a mapping correction or a constraint on what a worker can do, settles no finding and reaches no thread, so route it the way Boundaries below routes a change found while orchestrating, which lands it on the task owning the surface it describes. Writing a tracked file to hold either is forbidden here, which leaves the pull request and the board as the two surfaces this session writes.
131
131
 
132
+ Step 1 splits a rule that used to hold every plan in this session, and the evidence narrows it rather than retiring it. Two trials on 2026-08-31 put a cold planner on four rows, and it reported ten things the task files got wrong, corrected this session's own premise twice, and overturned one row's closing conclusion. What that measures is finding quality. The rule's own claim is that a warm plan front-loads reasoning a cold worker would otherwise re-derive, which is a statement about a plan's downstream value, and no plan from either trial has been built. So the per-row measurement goes cold on the evidence and the cross-feature call stays here on the boundary the same trials confirmed, which is that a planner reading the board sees blockers and file sets and can write a confident merge order off a partial picture.
133
+
132
134
  A plan written here is written against a tree several branches are already changing, so it names the file set of every track in flight as a constraint, one set per track, read from the Touches column of that track's row. State for each set which of the two acts it forbids, per Constraints in `${CLAUDE_SKILL_DIR}/../../standards/plan.md`. A bare path list leaves the worker guessing, which is how a plan ends up forbidding the repair of a citation the change broke.
133
135
 
134
136
  Stamp the block with the commit this session read the tree at, which the same section fixes the form of. A plan written during a refill sits in the ready queue while the wave it names merges, so the constraint is true when written and false when a worker reads it. The stamp is what lets that worker test the difference, and the standard carries the test.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Orchestrator dispatch runbook
3
- description: The collision check before a self-dispatch, the file-set disjointness gate, the branch and model the launch names, and the loop's stopping condition
3
+ description: The collision check before a self-dispatch, the file-set disjointness gate, the branch and model the launch names, the planning and handback dispatch shapes, and the loop's stopping condition
4
4
  ---
5
5
 
6
6
  Run this at loop step 4, for a `## Run now` row whose plan is verified, in place of handing the worktree to a human. The disjointness gate below is where that row's file set is tested against every track in flight.
@@ -65,10 +65,50 @@ Read `<dispatcher-id>` with `canon sessions list --self --json` and interpolate
65
65
 
66
66
  Where the installed CLI answers `--self` with an unknown option, that flag is newer than the release the target holds. Read the `sessionId` from the record the client writes for this session under its configuration directory, and say which route answered so the reader knows whether the id was read or inferred.
67
67
 
68
+ The worker resolves that id back to a name through `canon sessions list --json`, which carries `sessionId` per row, rather than through the agent listing, which prints a name and a short ref and no id at all. A worker reaching for the listing first therefore finds no lookup and can conclude there is none. That failure is silent in both directions: the session has nothing useful to do with the message it owes and goes idle holding it, and nothing on this side reports the quiet, so the loss surfaces as a missing worktree or a pull request that never opens rather than as anything watching for it.
69
+
68
70
  The worktree call comes first and carries the branch as its argument, which is tier 0 of `claude-worktree` Step 2 and the only tier a caller can reach. `claude-autoship` Step 0 then finds the session already in a linked worktree and continues, which is a path it already documents. The autoship call carries `<plan>`, the same file this runbook already read to derive the branch, so its Step 1 takes it as the caller-supplied plan rather than re-deriving one from the slug the worker's branch happens to carry.
69
71
 
70
72
  Naming the branch in prose instead was tried and closes nothing, because no tier of that ladder reads the prompt. A worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there. Four workers took the right branch that way, by inference rather than by contract, which is the same judgment both live disagreements came from.
71
73
 
74
+ ### The prompt expands its leading slash command and nothing after it
75
+
76
+ The client expands the first slash command in a launch prompt as a user
77
+ invocation, which is the route `disable-model-invocation: true` permits. Every
78
+ later command in the same prompt reaches the session as prose, leaving the model
79
+ to invoke it through the `Skill` tool, and that route answers a flagged skill
80
+ inconsistently. `claude-autoship` has carried the flag since `#365` and seven
81
+ other shipped skills carry it too.
82
+
83
+ The block above therefore leads with the call that does not need the user route
84
+ and leaves the one that does to the tool. Four sessions made the same tool call
85
+ against the same plugin cache on 2026-08-31. Two were answered with the body and
86
+ shipped, and two were refused with `Skill canon:claude-autoship cannot be used
87
+ with Skill tool due to disable-model-invocation`. Prefixing separated nothing,
88
+ since three of the four carried the namespace and those three landed on both
89
+ answers, so nothing a dispatcher writes predicts which answer a launch gets.
90
+
91
+ Read that as a route a dispatch may not depend on rather than one that usually
92
+ works. The refusal closes the fallback in the same message, telling the session
93
+ not to replicate the workflow by other means, so a refused worker has no route
94
+ left and stops with a clean worktree. Both failed dispatches produced nothing
95
+ rather than a degraded run, which is the correct outcome and not a thing to
96
+ soften.
97
+
98
+ Recovery belongs to whoever writes the next prompt, since a blocked session
99
+ cannot replay its own launch. The refusal is sticky inside a session rather than
100
+ something a retry clears, measured when one refused worker repeated the identical
101
+ prefixed call and got the byte-identical error back. So re-dispatch onto the same
102
+ branch with the autoship call leading the prompt, which puts the one command the
103
+ first launch left as prose in the position the client expands.
104
+
105
+ A launch that leads with `/canon:claude-autoship <plan>` and names no worktree
106
+ call is the candidate for closing this on the first dispatch, since that chain's
107
+ Step 0 enters the worktree itself and `claude-worktree` carries no flag to
108
+ refuse the tool route. Nobody has run it. Treat it as untested rather than as the
109
+ shape to switch to, because a launch expansion cannot be read from inside the
110
+ session it launched.
111
+
72
112
  ### What the brief may carry
73
113
 
74
114
  The prompt carries pointers and standing context, and stops there. The branch and the plan stay arguments, because a skill resolves an argument through a documented ladder and reads no prose at all. What the prose reaches is the worker's judgment, so it holds only what a session has to weigh:
@@ -123,6 +163,43 @@ registered branch off `main`, so `canon sessions list --branch` reports nothing
123
163
  holding it. A dispatch landing on a branch worked that way collides with
124
164
  nothing the check can see.
125
165
 
166
+ ## Dispatch to plan a row
167
+
168
+ `claude-feature` is a procedure rather than a role, so a launch naming it alone
169
+ reaches no `claude-planner` and takes no role, which owes no message either.
170
+ Both trials on 2026-08-31 ran on prose the controller retyped into each launch,
171
+ which held every obligation those sessions took and is where the first one's
172
+ in-flight read went wrong. Reach the role directly on this launch, the way the
173
+ build shape above reaches `claude-worker`.
174
+
175
+ No branch and no worktree exist here and none is created. A planner writes one
176
+ gitignored file at the main worktree root, so this shape names the row's task
177
+ file rather than a branch and opens with the role instead of a worktree call.
178
+
179
+ ```bash
180
+ claude --bg --model <model> -n "planner-<slug>" "Run /canon:claude-planner, then /canon:claude-feature <task>. Your controller is the session whose sessionId is <dispatcher-id>. Resolve its current name from that id at the moment you send, and never resolve an addressee by name prefix. Message it when the plan lands, carrying the path and what the task file got wrong, and message it again if you stop on a question."
181
+ ```
182
+
183
+ `<task>` is the row's task file path and `<slug>` the slug its plan will take,
184
+ resolved off the row the way the build shape resolves one off a plan.
185
+ `<dispatcher-id>` and `<model>` resolve the same way they do above. The prefix
186
+ reads `planner-` for the reason the worker's reads `worker-`, which is that it
187
+ marks the role of the session it names rather than the one that launched it.
188
+
189
+ Neither check above binds this shape. The branch check has no candidate to read,
190
+ and the disjointness gate has nothing to compare, since a planner writes one file
191
+ no track in flight can hold. What a planning dispatch owes instead is the
192
+ reverse reading, because the plan it produces carries a constraint per track in
193
+ flight and a row planned during a wave is planned against a tree that wave is
194
+ changing. `claude-planner` states that read as a command over open pull
195
+ requests, which is why the brief carries no branch list for it.
196
+
197
+ One row per dispatch. A session reused across a batch pays the context load once
198
+ and ages its picture of the tree while it works, which is what puts the in-flight
199
+ read on the task rather than on the batch, and one that compacts mid-batch loses
200
+ the reasoning behind its earlier plans with nothing reporting it. Cap a reused
201
+ session where the saving is worth it and say what the cap was.
202
+
126
203
  ## Fall back to the human
127
204
 
128
205
  Hand the row to the human-launch line in step 4 instead of dispatching when any of these hold, and name which one: the collision check refused, the row's file set overlaps a track already out, or a stated reason holds the row behind one.
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: claude-planner
3
+ description: What a planning session is, how it reads what is in flight, the surfaces it may not write, and what it hands back to whoever dispatched it
4
+ ---
5
+
6
+ # Claude planner requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a planning session is told what to plan and never what it
11
+ is. `claude-worker` states the building role and `claude-autoship` Step 0 reaches
12
+ it on every build, so a builder takes a role whether a person launched it or a
13
+ dispatcher did. Planning has no equivalent, and two trials on 2026-08-31 ran
14
+ entirely on prose the controller retyped into each launch. That is the shape
15
+ `claude-orchestrate` already bans on the building side, where one half held the
16
+ only written copy of obligations the other half performs.
17
+
18
+ Six rules went into each of those launches by hand: no worktree, no branch, no
19
+ tracked file, no board write, report the path alone, and re-measure rather than
20
+ trusting the task file. A seventh was added only after a trial got it wrong.
21
+ Every one of them is a property of the role rather than of the row, so each
22
+ dispatch reproduced them from memory and the one nobody remembered was the one
23
+ that failed.
24
+
25
+ The in-flight read is that failure. The first trial counted worktrees and local
26
+ branches and named four branches as in flight, all four of which had merged,
27
+ because this repository squash-merges and leaves both behind. A corrected brief
28
+ naming `gh pr list` as the source closed it and no constraint in the second
29
+ batch was wrong, which puts the fix in a launch string that the next dispatch
30
+ has to remember to carry.
31
+
32
+ Measuring rather than trusting the row is the other half and it is what the
33
+ trials were strongest on. Across four plans the planner reported ten things the
34
+ task files got wrong, and one plan overturned its own row's closing conclusion.
35
+ Nothing in any skill body tells a planner to open the source rather than quote
36
+ the row, so a session that skips that read produces a plan built on the same
37
+ stale count the row already carried.
38
+
39
+ A planner reading the board sees blockers and file sets and can write a
40
+ confident merge order off a partial picture. Which rows collide, what merges
41
+ before what, and whether a row should run at all stay with the controlling
42
+ session, and no body states that boundary for the planning side.
43
+
44
+ Nothing scans a plan for banned words either. `.gitignore` ignores
45
+ `.claude/plans/`, and the audit's default path set is what git lists, so no gate
46
+ ever opens one. Six ban hits landed across the four trial plans and three of the
47
+ four carried at least one, caught only because the planner ran
48
+ `canon markdown audit` unprompted.
49
+
50
+ ## Must
51
+
52
+ - Assert what a planning session is, what it may not write, and how long the role lasts, since `claude-feature` carries the procedure and no body carries the role
53
+ - State the in-flight read as a command over open pull requests, and say that a branch and a worktree are not evidence, since a count of either reported merged work as live
54
+ - Run that read once per task rather than once per batch, since a reused session ages its picture of the tree while it works
55
+ - Name every read a plan needed and a launch string did not carry, the task file's findings and the source files among them, since a count quoted from a row was wrong or stale in ten places across four plans
56
+ - Name the validate and audit calls the written plan passes through, since nothing else opens a plan and the audit caught ban hits in three of four trial plans
57
+ - State the shared board as read-only and name the cross-feature call as the controller's, since a planner reading blockers and file sets can write a merge order off a partial picture and will not notice that it has
58
+ - Owe an announcement when the plan lands, carrying the path and what the row got wrong, since the controller cannot watch the read happen
59
+ - Owe a message before a block becomes an interactive prompt, since a session already waiting on input never reaches the tool round an inbound message drains at
60
+ - Keep correcting the dispatcher a first-class move carrying its evidence, since a trial corrected a brief's premise, planned every row anyway, and carried the consequence into a plan question
61
+ - Point at `claude-feature` for the plan's shape and the steps that write it
62
+
63
+ ## Must not
64
+
65
+ - Restate the plan's sections, its suggested-and-answer contract, or the steps `claude-feature` carries, since thinness is what keeps one body correct for a dispatched planner reading it as its whole contract
66
+ - Restate a boundary `claude-orchestrate` or `claude-worker` states about itself
67
+ - Tell a planner to halt on a plan question, which the suggested line already answers, rather than on what blocks writing the plan at all
68
+ - Tell a planner to report the path with no account of what the row got wrong. That instruction came from a trial condition holding a blind comparison intact, and it is not a durable obligation.
69
+ - Report progress through the channel, which rebuilds on the sender's side the poll the announcement exists to retire
70
+ - Write the priority board, the backlog, or the task file, at any size
71
+ - Be a skill nothing invokes but its author typing the name. `orchestrator-dispatch.md` names it on the planning launch the way it names `claude-worker` on a build, so a stretch where only a typed invocation reaches it is the signal that the role never took.
72
+
73
+ ## Guards
74
+
75
+ - Task file does not resolve from where the session stands: report it unreadable and name the main-root path, rather than reporting the row as absent
76
+ - A read of what is in flight returns nothing: report that no pull request is open rather than falling back to a branch or worktree count
77
+ - Launch named no controlling session and an operator is present: ask which row to address rather than inferring
78
+ - Launch named no controlling session and nobody is present: infer from the sessions holding no feature branch, never from a name prefix, and say the addressee was inferred
79
+
80
+ ## Out of scope
81
+
82
+ - The plan's sections and its answer contract, which `standards/plan.md` fixes, and the steps that write the file, which `claude-feature` owns
83
+ - The branch, the build, and the pull request, which `claude-worker` and `claude-autoship` own
84
+ - Deciding which rows run and in what order, which is the controlling session's call and stated in `claude-orchestrate`
85
+ - The dispatch itself, its collision check, and its disjointness gate, which `orchestrator-dispatch.md` holds
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: claude-planner
3
+ description: Asserts the planner role for a session writing one plan under one task, holding what it reads before deciding, the surfaces it may not write, and what it hands back. Use when asked to "be the planner", "you are a planner session", at the start of a dispatched or hand-launched planning run, or when a planning session needs to know what it may not write. Do NOT use to write the plan itself, which is `claude-feature`, to make the cross-feature merge call, or to implement.
4
+ ---
5
+
6
+ # Claude planner
7
+
8
+ This session is a planner: one session writing one plan for one task. It reads
9
+ the row, measures what the row claims against the tree, writes the plan, and
10
+ hands back the path.
11
+
12
+ It does not implement, it does not decide what merges before what, and it does
13
+ not review. Those belong to a worker, to the controlling session, and to the
14
+ human.
15
+
16
+ This body states the role, the reads, the boundaries, and the channel, and it
17
+ starts no step of its own. `claude-feature` owns the steps that write the plan
18
+ and cites the standard fixing its sections and its suggested-and-answer
19
+ contract, so read the procedure there and reach it from the launch rather than
20
+ from here.
21
+
22
+ ## Where the session stands
23
+
24
+ - Write one file, the plan, at `.claude/plans/feature-<slug>.md` on the main worktree root. Everything else this session touches is a read.
25
+ - Never enter a worktree, never create a branch, and never write a tracked file. A planner that builds has stopped being one, and the row loses the independent read the worker's own session was going to bring to it.
26
+ - Resolve `.claude/plans/`, `.claude/tasks/`, and `.claude/review/` at the main worktree root. Those folders are gitignored, so a copy beside a linked worktree is absent rather than empty.
27
+ - Plan the row the launch named. Report a second row met while reading rather than planning it, since which rows run is the controller's call.
28
+
29
+ ## What to read before deciding
30
+
31
+ Each item below is something a plan needed and a launch string did not carry.
32
+
33
+ - The task file, and its `## Findings` before deciding anything. A row can carry its own disproof under a title that still states the original claim, which is how one trial nearly planned against a premise the file had already recorded as dead.
34
+ - The source files themselves, opened rather than summarized. Never a count quoted from the task file, which was wrong or stale in ten places across four plans.
35
+ - `CLAUDE.md` and `.claude/ARCHITECTURE.md`, for the decision and the alternative it was taken against rather than for the decision alone.
36
+ - The plan of any row this one is sequenced behind, including one already moved to `.claude/plans/archive/`. Reading a shipped plan produced the strongest constraint in the first trial and no brief asked for it.
37
+ - `.claude/tasks/priority.md` for the row's Touches column and its stated blocker. That column is the file set the dispatch disjointness gate already reads.
38
+ - `${CLAUDE_SKILL_DIR}/../../standards/plan.md` for the shape, then `canon records validate plans` and `canon markdown audit <the plan file>` on the file once it is written. Nothing else opens a plan, since `.claude/plans/` is gitignored and the audit's default path set is what git lists.
39
+
40
+ ## Read what is in flight rather than inferring it
41
+
42
+ - Run `gh pr list --json number,headRefName`, then `git diff --name-only main...<headRefName>` per open pull request, and take every in-flight file set from that pair.
43
+ - A branch is not evidence and neither is a worktree. This repository squash-merges and leaves both behind, so a count of either reports merged work as live. One trial named four branches as in flight and all four had merged.
44
+ - Run the read once per task rather than once per batch. A session planning several rows ages its picture of the tree while it works, and this read is what dates it.
45
+ - Name each in-flight set as a constraint, say which act it forbids, and stamp the block with the commit the tree was read at, per Constraints in the plan standard. A bare path list leaves the worker guessing.
46
+
47
+ ## The board is read-only
48
+
49
+ - Never write `.claude/tasks/priority.md` or `.claude/tasks/backlog.md`. Both are gitignored, so an overwrite drops a row with no history to recover it from.
50
+ - Never write the task file either. The plan is the whole output, and a row edited from here changes what the controller reads back as the state of the board.
51
+ - Report what the row got wrong rather than repairing it. A stale count, a moved line, or a path that no longer resolves goes into the plan and into the handback, and the controller decides which of the two carries the correction.
52
+ - Leave the cross-feature call alone. Which rows collide, what merges before what, and whether a row should run at all stay with the controller. `priority.md` shows blockers and file sets and is enough to write a confident merge order off a partial picture, which is the failure this boundary exists to prevent.
53
+
54
+ ## The channel
55
+
56
+ The controlling session cannot watch this one read, so two messages are owed and
57
+ nothing else.
58
+
59
+ - Announce the plan as the file lands, carrying its path and what the task file got wrong. Write no summary of the plan beside it, which is a second account of a document the reader is about to open.
60
+ - Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round that drains an inbound message, so a relayed answer arrives under the open question and changes nothing.
61
+ - Send nothing on progress. A planner reporting progress rebuilds, on this side of the channel, the poll the announcement retires on the other.
62
+
63
+ Address the session the launch named. It names a `sessionId` rather than a name,
64
+ so read `canon sessions list --json`, find the row carrying that id, and send to
65
+ the `name` on it. Resolve that name at the moment of sending rather than at
66
+ launch, since a name is derived from what a session turned out to be doing and
67
+ goes stale inside the window a plan takes to write.
68
+
69
+ Check that name against the agent listing before sending it. A name is not
70
+ unique and the roster carries no field separating two live sessions holding one,
71
+ so send it bare where the listing shows a single row under it and complete it
72
+ with the `[ref]` that listing prints beside each row where it shows more.
73
+
74
+ Ask the operator when the launch named nobody and a person is there to answer,
75
+ putting the candidate rows through the structured question surface so they pick a
76
+ row rather than recall a name. Never filter that roster by name prefix, which
77
+ returns a sibling or this session itself.
78
+
79
+ Inferring is the last rung and it discriminates less here than it does for a
80
+ worker. That read takes the sessions holding no feature branch, which separates a
81
+ controller from a worker because a worker holds one, and a planner holds none
82
+ either, so every sibling planner comes back beside the controller. Report the
83
+ candidates and stop where more than one does, rather than addressing the first.
84
+ Send to the single row where exactly one comes back, and say the addressee was
85
+ inferred.
86
+
87
+ ## Refusing is part of the job
88
+
89
+ - Refuse an instruction the tree contradicts, and carry the evidence with it. Name the commands read and what complying would produce, rather than reporting reluctance.
90
+ - Correct a premise the brief carried when the tree disagrees with it. One trial was told two rows were free of a renamed token and found they carried it as command names rather than path segments, planned all three rows anyway, and put the consequence into a plan question. Correct, continue, and record what the correction changed.
91
+ - Answer a plan question rather than halting on it. Every question carries a `- Suggested:` line the operator accepts or overrules, so a judgment written down is the deliverable. Halt only on what blocks writing the plan at all, and send that out as a message before it becomes a prompt.
92
+
93
+ ## Lifetime
94
+
95
+ - The role runs from the launch to the plan landing, and one plan closes it. A planner handed a second row plans it under the same role and reads the tree again for that row.
96
+ - A reused session pays the context load once and stales differently. Its picture of the tree ages while it works, which is why the in-flight read runs per task, and it accumulates toward a compaction that drops the reasoning behind its earlier plans with nothing reporting it. Cap a reused session rather than letting it run the board.
97
+ - Treat this body as possibly older than the branch under it. A plugin skill loads from the marketplace cache rather than from the working tree.
@@ -157,7 +157,11 @@ Respond with one line, using the `url` the final command printed:
157
157
 
158
158
  `✅ PR: <url>`
159
159
 
160
- Add a second line only when the labelling command printed its warning, quoting the label `gh` refused:
160
+ Add a line for each `uncovered` path the labels step reported, naming the path and the map it belongs in:
161
+
162
+ `⚠️ No label covers <path>. Add a row to .claude/canon/pr-labels.toml or a [declined] entry.`
163
+
164
+ Add a further line only when the labelling command printed its warning, quoting the label `gh` refused:
161
165
 
162
166
  `⚠️ Labels not applied: <what gh reported>`
163
167
 
@@ -13,15 +13,17 @@ description: The projects this toolkit installed into, the record the install wr
13
13
  canon targets list
14
14
  canon targets list --json
15
15
  canon targets list --sweep ~/repos --json
16
+ canon targets list --sweep ~/repos --record
16
17
  ```
17
18
 
18
- | Option | Behavior |
19
- | ----------------- | ------------------------------------------------------- |
20
- | `--json` | Add a machine-readable record on stdout |
21
- | `--sweep <path…>` | Also walk these roots for targets the record never held |
22
- | `--depth <n>` | How deep below each swept root to walk, defaulting to 4 |
19
+ | Option | Behavior |
20
+ | ----------------- | -------------------------------------------------------------------------------- |
21
+ | `--json` | Add a machine-readable record on stdout |
22
+ | `--sweep <path…>` | Also walk these roots for targets the record never held |
23
+ | `--depth <n>` | How deep below each swept root to walk, defaulting to 4 |
24
+ | `--record` | Seed the index from every swept checkout's own stamp, refusing without `--sweep` |
23
25
 
24
- Exit codes: `0` the population was read, `1` refused. A refusal carries a `reason` of `bad-depth`, and an absent index reports as unknown rather than as no targets.
26
+ Exit codes: `0` the population was read, `1` refused. A refusal carries a `reason` of `bad-depth` or, for `--record` given with no `--sweep`, `record-without-sweep`. An absent index reports as unknown rather than as no targets.
25
27
 
26
28
  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 fields rather than the exit when a skill consumes this.
27
29
 
@@ -49,6 +51,12 @@ The row leads with the clone the record knows, because the record only names one
49
51
 
50
52
  Nothing removes a row. A project deleted, moved, or that dropped the toolkit stays in the index, so a count drifts upward over time. It surfaces on use rather than silently, since the read below refuses a path it cannot open.
51
53
 
54
+ ## The retired stamp path
55
+
56
+ Each row names the subset of its checkouts still carrying the install stamp at the retired path: `legacyPaths` in the JSON, a subset of `paths` in the same order. The render says `legacy stamp` plain when every checkout carries it and `legacy stamp (1 of 2 checkouts)` when only some do, which is the common case rather than the rare one.
57
+
58
+ `--record` backfills the index from a sweep rather than from a sync, for a target the record never held. It seeds one row per checkout from that checkout's own installed stamp, taking `stampedAt` from the stamp itself rather than from the moment the backfill ran, so a backfilled row never claims a sync that did not happen. A checkout whose stamp cannot be read is skipped rather than dated by the backfill. It refuses without `--sweep` naming what to walk.
59
+
52
60
  ## Pulls
53
61
 
54
62
  `canon targets pulls` reports, per target, every open pull request with its checks and the heading its newest review pass carries.
@@ -75,7 +75,7 @@ When features are independent, run them in parallel instead of sequentially. Use
75
75
  - Ship each worktree separately with `canon:git-ship`
76
76
  - For full autonomy per worktree, invoke `canon:claude-autoship` instead of the manual chain. Approve the plan, walk away, come back to draft PRs.
77
77
 
78
- To run several worktrees as a coordinated flow rather than ad hoc, assert the orchestrator role in one warm session with `canon:claude-orchestrate`. It plans each feature, refills the ready queue so a free worker never waits, and reviews each worker's PR with `canon:claude-pr-review`, then tells the session holding that branch to run `canon:claude-address-review` whenever the pass posted a finding at any severity, which is the same threshold `canon:claude-pr-review` states and posts its open heading under. The human launches workers and merges. See [operating model](operating-model.md) for the full loop.
78
+ To run several worktrees as a coordinated flow rather than ad hoc, assert the orchestrator role in one warm session with `canon:claude-orchestrate`. It holds the cross-feature call, plans each feature itself or dispatches a cold planner under `canon:claude-planner` to write the plan, refills the ready queue so a free worker never waits, and reviews each worker's PR with `canon:claude-pr-review`, then tells the session holding that branch to run `canon:claude-address-review` whenever the pass posted a finding at any severity, which is the same threshold `canon:claude-pr-review` states and posts its open heading under. The human launches workers and merges. See [operating model](operating-model.md) for the full loop.
79
79
 
80
80
  Execution order comes off `.claude/tasks/priority.md` and nothing sequences work into versions. Scope stays in `.claude/REQUIREMENTS.md` as a statement of what is wanted, and it reaches the board as discrete tasks the orchestrator orders by readiness.
81
81
 
package/docs/index.md CHANGED
@@ -14,7 +14,7 @@ One-line reference for each doc in this folder.
14
14
 
15
15
  ## Workflow
16
16
 
17
- - [Operating model](operating-model.md): Orchestrator and worker roles for building across parallel sessions
17
+ - [Operating model](operating-model.md): Orchestrator, planner, and worker roles for building across parallel sessions
18
18
  - [Visual design workflow](visual-design-workflow.md): Tiered guide for design and wireframe authoring with Claude Code
19
19
  - [Zshrc aliases for Claude Code](zshrc-aliases.md): Shell aliases that shorten common Claude Code invocations
20
20
 
@@ -1,33 +1,53 @@
1
1
  ---
2
2
  title: Operating model
3
- description: Orchestrator and worker roles for building across parallel sessions
3
+ description: Orchestrator, planner, and worker roles for building across parallel sessions
4
4
  category: Workflow
5
5
  ---
6
6
 
7
7
  # Operating model
8
8
 
9
9
  A way to build fast and reliably across parallel Claude Code sessions without a
10
- loop and without losing the human review gate. One warm session plans and
11
- reviews. Cold worker sessions build. The human launches workers and merges.
10
+ loop and without losing the human review gate. One warm session holds the
11
+ cross-feature call and reviews. Planning runs there or in a session of its own.
12
+ Cold worker sessions build. The human launches workers and merges.
12
13
 
13
14
  This page covers the roles and the loop. For the worktree mechanism (isolation, merge
14
15
  order, port collisions), see [Claude Code and git worktrees](../wiki/claude/claude-worktrees.md).
15
16
 
16
- ## Two roles
17
+ ## Three roles
17
18
 
18
- The split is by vantage, not by capability. Both are Claude Code sessions.
19
+ The split is by vantage, not by capability. All three are Claude Code sessions.
19
20
 
20
- | Role | Session | Owns | Does not |
21
- | ------------ | ------------------------------------- | ------------------------------------------------------- | ----------------------------- |
22
- | Orchestrator | One warm, long-lived session | Planning, deep PR review, merge order | Edit tracked files, merge PRs |
23
- | Worker | One cold worktree session per feature | Implement, self-check, open PR, answer the orchestrator | Write the shared board, merge |
21
+ | Role | Session | Owns | Does not |
22
+ | ------------ | ------------------------------------- | ------------------------------------------------------- | --------------------------------- |
23
+ | Orchestrator | One warm, long-lived session | The cross-feature call, deep PR review, merge order | Edit tracked files, merge PRs |
24
+ | Planner | One session per row, warm or cold | Measure the row against the tree, write one plan | Enter a worktree, write the board |
25
+ | Worker | One cold worktree session per feature | Implement, self-check, open PR, answer the orchestrator | Write the shared board, merge |
24
26
 
25
27
  Each role is asserted explicitly rather than inferred. The orchestrator loads
26
- `claude-orchestrate` at the start of its session, and a worker loads
27
- `claude-worker`, which `claude-autoship` invokes at Step 0 so a dispatched build
28
- and a hand-launched one reach it on the same path. Both are framing and
28
+ `claude-orchestrate` at the start of its session, a worker loads `claude-worker`,
29
+ which `claude-autoship` invokes at Step 0 so a dispatched build and a
30
+ hand-launched one reach it on the same path, and a planner loads
31
+ `claude-planner` from the launch that dispatches it. All three are framing and
29
32
  boundaries rather than logic.
30
33
 
34
+ The planner is the one role the orchestrator also performs. Per-row planning
35
+ runs warm inside the orchestrator's own session or cold in a dispatched one, and
36
+ the boundary between them is the cross-feature call: which rows collide, what
37
+ merges before what, and whether a row should run at all stay with the
38
+ orchestrator, because a session reading the board sees blockers and file sets
39
+ and can write a confident merge order off a partial picture. Two trials on
40
+ 2026-08-31 measured a cold planner against four rows and it reported ten things
41
+ the task files got wrong, which is why the per-row measurement is free to go
42
+ cold. No plan from either trial has been built, so a cold plan's value to the
43
+ worker reading it is still unmeasured.
44
+
45
+ A dispatched planner owes two messages: the plan's path as the file lands, with
46
+ what the task file got wrong beside it, and a block before that block becomes an
47
+ interactive prompt. The plan file is its only write. A stale count, a moved line,
48
+ or a path that no longer resolves is reported rather than repaired, since the
49
+ task file and the board stay the orchestrator's to write.
50
+
31
51
  Refusing is part of the worker's job rather than a failure of it. A worker that
32
52
  halts on a plan question it may not answer, or argues back against an
33
53
  instruction the tree contradicts, is working correctly. The four measured halts
@@ -49,7 +69,7 @@ and no later session recovers that vantage.
49
69
 
50
70
  One feature travels this path end to end.
51
71
 
52
- 1. Orchestrator plans the next feature with `claude-feature`, writing a plan to `.claude/plans/`. Planning stays in the warm session because good planning is cross-feature. It needs the contract other features consume and the shared wiring seam. A cold session would re-derive or guess.
72
+ 1. The next feature is planned with `claude-feature`, writing a plan to `.claude/plans/`. The orchestrator runs it warm when the row turns on a contract other features consume or a shared wiring seam, and dispatches a planner under `claude-planner` otherwise. A cold planner measures the row against the tree rather than trusting what the row claims, and it reads what is in flight from open pull requests rather than from branches and worktrees, which this repository leaves behind after a squash merge.
53
73
  2. Orchestrator checks the branch is unclaimed and the plan's file set is disjoint from every track in flight, then dispatches a background worker with `claude --bg` against the plan, naming the branch and the model on the launch rather than leaving the worker to derive either. No count caps how many run at once. The branch travels as the argument to the worker's own worktree call, which is the one place the name is read rather than inferred. It falls back to naming the invocation for a human to run through `claude-worktree` and `claude-autoship` when the check refuses, the sets overlap, or a stated reason serializes the plan behind a track already in flight. Either way, the worker enters its own worktree, builds, self-checks, opens a PR, and stops at the PR boundary.
54
74
  3. Orchestrator reviews the PR with `claude-pr-review` and posts findings to it.
55
75
  4. Orchestrator tells the session holding that branch to run `claude-address-review` once the pass posted a finding at any severity, resolving the target then with `canon sessions list --branch` and reporting the invocation for the human when no live session holds it. The worker addresses the findings, rebases onto `origin/main` when a sibling landed first and left the branch unable to merge, then pushes a follow-up. A pass carrying only minor findings dispatches too, since the grade runs low often enough that a floor at should-fix loses fixes a worker would have made. `claude-pr-review` states that threshold and the heading follows it, so an open heading is itself the signal to send.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.1.0",
4
+ "version": "4.2.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -70,6 +70,11 @@ export const CLAUDE_WORKFLOW_CASES: readonly SkillCase[] = [
70
70
  'Take on the orchestrator role and coordinate the parallel feature builds.',
71
71
  expect: 'claude-orchestrate',
72
72
  },
73
+ {
74
+ prompt:
75
+ 'I am writing the plan for a row another session will build. What may I write, and where do I read what is already in flight?',
76
+ expect: 'claude-planner',
77
+ },
73
78
  {
74
79
  prompt: 'Post a formal review with findings on that open pull request.',
75
80
  expect: 'claude-pr-review',
@@ -1,13 +1,16 @@
1
1
  import type { Command } from 'commander'
2
+ import { readStamp } from '@/sync/stamp'
2
3
  import { readPullsAcross, type TargetPulls } from '@/targets/pulls'
4
+ import { backfillTarget } from '@/targets/registry'
3
5
  import {
4
6
  type KnownTarget,
5
7
  type ResolvedTargets,
6
8
  resolveTargets,
7
9
  } from '@/targets/resolve'
8
- import { DEFAULT_DEPTH, type SweepBound } from '@/targets/sweep'
10
+ import { DEFAULT_DEPTH, type SweepBound, sweepTargets } from '@/targets/sweep'
9
11
  import {
10
12
  intro,
13
+ logError,
11
14
  logInfo,
12
15
  logStep,
13
16
  logWarn,
@@ -20,6 +23,7 @@ interface ListOptions {
20
23
  readonly json?: boolean
21
24
  readonly sweep?: string[]
22
25
  readonly depth?: string
26
+ readonly record?: boolean
23
27
  }
24
28
 
25
29
  interface PullsOptions extends ListOptions {}
@@ -65,6 +69,10 @@ export function register(program: Command): void {
65
69
  'How deep below each swept root to walk',
66
70
  String(DEFAULT_DEPTH),
67
71
  )
72
+ .option(
73
+ '--record',
74
+ "Seed the index from every swept checkout's own stamp, refusing without --sweep",
75
+ )
68
76
  .addHelpText('after', LIST_HELP)
69
77
  .action(async (opts: ListOptions) => {
70
78
  process.exitCode = await runList(opts)
@@ -111,6 +119,15 @@ const LIST_HELP = [
111
119
  'see another machine or a clone under a path nobody named, so read the bound',
112
120
  'before treating the count as the population.',
113
121
  '',
122
+ 'A group reports how many of its checkouts still carry the install stamp',
123
+ 'at the retired path, staying quiet when none do and plain when every one',
124
+ 'does.',
125
+ '',
126
+ '--record backfills the index from a sweep rather than a sync. It seeds one',
127
+ "row per checkout from that checkout's own installed stamp, so a backfilled",
128
+ 'row never claims a sync that did not run, and it refuses without --sweep',
129
+ 'naming what to walk.',
130
+ '',
114
131
  'An exit code says nothing about a call made from a session, since a shell',
115
132
  'profile may wrap the binary in a function taking its status from a later',
116
133
  'command. Read the record rather than the exit when a skill consumes this.',
@@ -119,6 +136,7 @@ const LIST_HELP = [
119
136
  ' canon targets list',
120
137
  ' canon targets list --json',
121
138
  ' canon targets list --sweep ~/repos --json',
139
+ ' canon targets list --sweep ~/repos --record',
122
140
  '',
123
141
  ].join('\n')
124
142
 
@@ -155,6 +173,18 @@ async function runList(opts: ListOptions): Promise<number> {
155
173
 
156
174
  if (depth === null) return refuseDepth(opts)
157
175
 
176
+ let backfillFailed = false
177
+
178
+ if (opts.record === true) {
179
+ if (opts.sweep === undefined || opts.sweep.length === 0) {
180
+ return refuseRecordWithoutSweep(opts)
181
+ }
182
+
183
+ const backfill = await runBackfill(opts.sweep, depth)
184
+ reportBackfill(backfill)
185
+ backfillFailed = backfill.failed > 0
186
+ }
187
+
158
188
  const resolved = await resolveTargets({ sweep: opts.sweep, depth })
159
189
 
160
190
  const unknown = reportTargets(resolved)
@@ -172,7 +202,7 @@ async function runList(opts: ListOptions): Promise<number> {
172
202
  )
173
203
  }
174
204
 
175
- return unknown ? 1 : 0
205
+ return unknown || backfillFailed ? 1 : 0
176
206
  }
177
207
 
178
208
  async function runPulls(paths: string[], opts: PullsOptions): Promise<number> {
@@ -234,6 +264,89 @@ function refuseDepth(opts: ListOptions): number {
234
264
  return 1
235
265
  }
236
266
 
267
+ interface BackfillSummary {
268
+ readonly recorded: number
269
+ readonly skipped: number
270
+ readonly failed: number
271
+ }
272
+
273
+ /**
274
+ * Seeds the index from every checkout the sweep finds. `resolveTargets` walks
275
+ * the same roots again right after this returns, which costs a second walk
276
+ * rather than a restructure, since --record is a deliberate one-off rather
277
+ * than a path any routine run takes.
278
+ */
279
+ async function runBackfill(
280
+ roots: readonly string[],
281
+ depth: number,
282
+ ): Promise<BackfillSummary> {
283
+ const swept = await sweepTargets(roots, { depth })
284
+ const paths = swept.targets.flatMap((target) => target.paths)
285
+
286
+ let recorded = 0
287
+ let skipped = 0
288
+ let failed = 0
289
+
290
+ for (const path of paths) {
291
+ const outcome = backfillTarget({ path, stampedAt: stampedAtOf(path) })
292
+ if (outcome === 'recorded') recorded++
293
+ else if (outcome === 'no-stamp') skipped++
294
+ else failed++
295
+ }
296
+
297
+ return { recorded, skipped, failed }
298
+ }
299
+
300
+ /**
301
+ * The most recent syncedAt across a checkout's stamped domains, so a
302
+ * backfilled row dates itself by the sync that actually touched the checkout
303
+ * rather than by the moment the backfill happened to run.
304
+ */
305
+ function stampedAtOf(path: string): string | null {
306
+ const stamp = readStamp(path)
307
+ if (stamp === undefined) return null
308
+
309
+ const syncedAt = Object.values(stamp.domains)
310
+ .map((domain) => domain?.syncedAt)
311
+ .filter((value): value is string => value !== undefined)
312
+ .sort()
313
+
314
+ return syncedAt.at(-1) ?? null
315
+ }
316
+
317
+ function refuseRecordWithoutSweep(opts: ListOptions): number {
318
+ logStep('Refused')
319
+ logWarn(
320
+ '--record backfills from a walk, so it needs --sweep naming the roots to walk.',
321
+ )
322
+ outro()
323
+
324
+ if (opts.json) {
325
+ process.stdout.write(
326
+ `${JSON.stringify({ reason: 'record-without-sweep', targets: [] })}\n`,
327
+ )
328
+ }
329
+
330
+ return 1
331
+ }
332
+
333
+ function reportBackfill(summary: BackfillSummary): void {
334
+ logStep('Backfill')
335
+ logInfo(`${plural(summary.recorded, 'target')} recorded from its own stamp.`)
336
+
337
+ if (summary.skipped > 0) {
338
+ logWarn(
339
+ `${plural(summary.skipped, 'target')} skipped, carrying no readable stamp to date the row by.`,
340
+ )
341
+ }
342
+
343
+ if (summary.failed > 0) {
344
+ logError(
345
+ `${plural(summary.failed, 'target')} failed to write, so its row is unchanged.`,
346
+ )
347
+ }
348
+ }
349
+
237
350
  /** Returns whether the population is unknown, which is the one refusal this read has. */
238
351
  function reportTargets(resolved: ResolvedTargets): boolean {
239
352
  logStep('Targets')
@@ -256,13 +369,27 @@ function reportTargets(resolved: ResolvedTargets): boolean {
256
369
  }
257
370
 
258
371
  function describe(target: KnownTarget): string {
259
- const flags = [target.source, ...(target.legacy ? ['legacy stamp'] : [])]
372
+ const flags = [target.source, ...legacyFlag(target)]
260
373
  const clones =
261
374
  target.paths.length > 1 ? `\n ${target.paths.slice(1).join('\n ')}` : ''
262
375
 
263
376
  return `${target.paths[0]} ${flags.join(', ')}${clones}`
264
377
  }
265
378
 
379
+ /**
380
+ * "legacy stamp" plain when every checkout carries the retired stamp, the
381
+ * count appended when only some do, and nothing when none do. The mixed case
382
+ * is the one a bare flag could not say, and it is the common one: three of
383
+ * the four affected groups measured on this machine were mixed rather than
384
+ * uniform.
385
+ */
386
+ function legacyFlag(target: KnownTarget): readonly string[] {
387
+ const count = target.legacyPaths.length
388
+ if (count === 0) return []
389
+ if (count === target.paths.length) return ['legacy stamp']
390
+ return [`legacy stamp (${count} of ${target.paths.length} checkouts)`]
391
+ }
392
+
266
393
  function reportBound(bound: SweepBound): void {
267
394
  logStep('Bound')
268
395
  logInfo(
@@ -201,3 +201,26 @@ export function recordTarget(
201
201
  return 'unwritten'
202
202
  }
203
203
  }
204
+
205
+ /** One checkout a backfill is seeding, dated by its own on-disk stamp. */
206
+ export interface BackfillTarget {
207
+ readonly path: string
208
+ /** From that checkout's `readStamp`. Null skips the row rather than dating it by the backfill itself. */
209
+ readonly stampedAt: string | null
210
+ }
211
+
212
+ export type BackfillOutcome = RecordOutcome | 'no-stamp'
213
+
214
+ /**
215
+ * Seeds one row from a checkout's own stamp rather than from a sync, for a
216
+ * target a sweep found that the record never held. Loops `recordTarget`,
217
+ * which already replaces a row by resolved path, so this is a caller of it
218
+ * rather than a second file format.
219
+ */
220
+ export function backfillTarget(
221
+ target: BackfillTarget,
222
+ path: string = registryPath(),
223
+ ): BackfillOutcome {
224
+ if (target.stampedAt === null) return 'no-stamp'
225
+ return recordTarget(target.path, new Date(target.stampedAt), path)
226
+ }
@@ -27,8 +27,8 @@ export interface KnownTarget {
27
27
  readonly source: TargetSource
28
28
  /** When a sync last recorded this target, or null for a row only a sweep found. */
29
29
  readonly stampedAt: string | null
30
- /** True while the install stamp still sits at the retired path. */
31
- readonly legacy: boolean
30
+ /** The subset of `paths` still carrying their stamp at the retired location, in `paths` order. */
31
+ readonly legacyPaths: readonly string[]
32
32
  }
33
33
 
34
34
  export interface ResolvedTargets {
@@ -80,7 +80,7 @@ export async function resolveTargets(
80
80
  origin: null,
81
81
  source: 'record' as const,
82
82
  stampedAt: row.stampedAt,
83
- legacy: isLegacyStamped(row.path),
83
+ legacyPaths: isLegacyStamped(row.path) ? [row.path] : [],
84
84
  }))
85
85
  : []
86
86
 
@@ -110,14 +110,22 @@ export async function resolveTargets(
110
110
  // Every caller reading a single path takes the first, and picking that by
111
111
  // sort order is how a repair ran in one clone while the count was taken
112
112
  // against another and the target read as untouched.
113
+ const paths = [
114
+ ...overlap,
115
+ ...target.paths.filter((path) => !known.has(path)),
116
+ ]
117
+
118
+ // Re-derived against this row's own order rather than carried from
119
+ // `target.legacyPaths`, which is ordered by the sweep's sort and mismatches
120
+ // `paths` the moment the recorded clone is not the one that sorts first.
113
121
  added.push({
114
- paths: [...overlap, ...target.paths.filter((path) => !known.has(path))],
122
+ paths,
115
123
  origin: target.origin,
116
124
  source: overlap.length > 0 ? 'record' : 'sweep',
117
125
  stampedAt:
118
126
  recorded.find((row) => overlap.includes(row.paths[0] ?? ''))
119
127
  ?.stampedAt ?? null,
120
- legacy: target.legacy,
128
+ legacyPaths: paths.filter((path) => isLegacyStamped(path)),
121
129
  })
122
130
  }
123
131
 
@@ -140,6 +148,6 @@ function given(path: string): KnownTarget {
140
148
  origin: null,
141
149
  source: 'given',
142
150
  stampedAt: null,
143
- legacy: isLegacyStamped(path),
151
+ legacyPaths: isLegacyStamped(path) ? [path] : [],
144
152
  }
145
153
  }
@@ -2,7 +2,12 @@ import { readdirSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
- import { isLegacyStamped, legacyStampPath, stampPath } from '@/sync/stamp'
5
+ import {
6
+ isLegacyStamped,
7
+ legacyStampPath,
8
+ retiredNameStampPath,
9
+ stampPath,
10
+ } from '@/sync/stamp'
6
11
  import { isDirectory } from '@/target'
7
12
 
8
13
  /**
@@ -44,8 +49,8 @@ export interface SweptTarget {
44
49
  readonly paths: readonly string[]
45
50
  /** The origin every path agrees on, or null when git resolved none. */
46
51
  readonly origin: string | null
47
- /** True while every path still carries its stamp at the retired location. */
48
- readonly legacy: boolean
52
+ /** The subset of `paths` still carrying their stamp at the retired location, in `paths` order. */
53
+ readonly legacyPaths: readonly string[]
49
54
  }
50
55
 
51
56
  /**
@@ -85,10 +90,17 @@ export interface SweepOptions {
85
90
  readonly originOf?: (path: string) => Promise<string | null>
86
91
  }
87
92
 
88
- /** Whether a folder carries an install stamp at either the current or the retired path. */
93
+ /**
94
+ * Whether a folder carries an install stamp at the current path or either
95
+ * retired one. `aitk@3.57.0` still writes the folder form,
96
+ * `retiredNameStampPath`, as its current path, so a target a pre-rename
97
+ * binary syncs after this check drops the folder form would otherwise vanish
98
+ * from the walk with nothing saying so.
99
+ */
89
100
  function isStamped(path: string): boolean {
90
101
  return (
91
102
  Bun.file(stampPath(path)).size > 0 ||
103
+ Bun.file(retiredNameStampPath(path)).size > 0 ||
92
104
  Bun.file(legacyStampPath(path)).size > 0
93
105
  )
94
106
  }
@@ -225,7 +237,11 @@ async function group(
225
237
  const origin = origins[index]
226
238
 
227
239
  if (origin === null || origin === undefined) {
228
- alone.push({ paths: [path], origin: null, legacy: isLegacyStamped(path) })
240
+ alone.push({
241
+ paths: [path],
242
+ origin: null,
243
+ legacyPaths: isLegacyStamped(path) ? [path] : [],
244
+ })
229
245
  return
230
246
  }
231
247
 
@@ -237,7 +253,7 @@ async function group(
237
253
  const merged = [...byOrigin.entries()].map(([origin, group]) => ({
238
254
  paths: group,
239
255
  origin,
240
- legacy: group.every((path) => isLegacyStamped(path)),
256
+ legacyPaths: group.filter((path) => isLegacyStamped(path)),
241
257
  }))
242
258
 
243
259
  return [...merged, ...alone].sort((a, b) =>