@erclx/canon 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-feature/SKILL.md +2 -0
- package/claude/skills/claude-orchestrate/SKILL.md +3 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +78 -1
- package/claude/skills/claude-planner/REQUIREMENT.md +85 -0
- package/claude/skills/claude-planner/SKILL.md +97 -0
- package/docs/agents/targets.md +14 -6
- package/docs/ai-workflow.md +1 -1
- package/docs/index.md +1 -1
- package/docs/operating-model.md +33 -13
- package/package.json +1 -1
- package/src/claude/cases/claude-workflow.ts +5 -0
- package/src/commands/targets.ts +130 -3
- package/src/targets/registry.ts +23 -0
- package/src/targets/resolve.ts +14 -6
- package/src/targets/sweep.ts +22 -6
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
|
|
@@ -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.
|
|
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.
|
package/docs/agents/targets.md
CHANGED
|
@@ -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`,
|
|
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.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -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
|
|
package/docs/operating-model.md
CHANGED
|
@@ -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
|
|
11
|
-
reviews.
|
|
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
|
-
##
|
|
17
|
+
## Three roles
|
|
17
18
|
|
|
18
|
-
The split is by vantage, not by capability.
|
|
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 |
|
|
23
|
-
|
|
|
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,
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
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
|
@@ -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',
|
package/src/commands/targets.ts
CHANGED
|
@@ -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
|
|
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(
|
package/src/targets/registry.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/targets/resolve.ts
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
readonly
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
legacyPaths: isLegacyStamped(path) ? [path] : [],
|
|
144
152
|
}
|
|
145
153
|
}
|
package/src/targets/sweep.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
48
|
-
readonly
|
|
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
|
-
/**
|
|
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({
|
|
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
|
-
|
|
256
|
+
legacyPaths: group.filter((path) => isLegacyStamped(path)),
|
|
241
257
|
}))
|
|
242
258
|
|
|
243
259
|
return [...merged, ...alone].sort((a, b) =>
|