@erclx/canon 4.71.0 → 4.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.71.0",
4
+ "version": "4.72.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -28,7 +28,9 @@ Without this skill, a session facing a decision nobody can settle from a diff:
28
28
  - Render before reporting on any arm, and report a missing browser binary as a refusal naming the repair rather than describing an arm nobody has seen.
29
29
  - Take the pick through the structured question surface, with the recommendation ranked first and marked, and every option carrying its cost.
30
30
  - Keep every write inside the run's scratch folder until the pick is final.
31
- - Apply the winning arm and delete the losing ones in the same change.
31
+ - Apply the winning arm in one change.
32
+ - Capture every arm from the final round as an image before deleting the run's scratch folder, rather than discarding the losing arms with it.
33
+ - Resolve the archival capture's destination against the main worktree root, never against a linked worktree the run happens to be building in.
32
34
  - Serve the candidates live where the decision is one the operator has to drive, since a still cannot answer how a gesture feels or how a pace reads.
33
35
 
34
36
  ## Must not
@@ -23,9 +23,10 @@ Some decisions are settled by looking rather than by reasoning, and no draft is
23
23
 
24
24
  ## Step 2: author the candidate set as one page
25
25
 
26
- Write every arm side by side on one self-contained HTML page at `<dest>/candidates.html`.
26
+ Write every arm side by side on one self-contained HTML page at `<dest>/candidates.html`, and write each arm again as its own self-contained whole-page file at `<dest>/arms/arm-<id>.html`. Clear `<dest>/arms/` first on every pass through this step, including from Step 5's loop, so a file left behind by a wider earlier round never survives into a narrower one.
27
27
 
28
- - One page, never one file per arm. The comparison is the artifact, and several images handed over separately ask the operator to hold the differences in memory.
28
+ - One page for the pick, never a set of separate images handed to the operator to compare from memory. The comparison they judge is `candidates.html`, which Step 3 renders and Step 4 asks about. The per-arm files exist only for Step 6's archival capture, once the pick is made, and only the last pass through this step is what Step 6 finds there.
29
+ - Wrap each arm's markup in the same class on both files, chosen once per run and reused everywhere, so one selector addresses an arm on the combined page and on its own standalone file alike.
29
30
  - Label each arm on the page with its id and its cost, so the render carries what the question will ask about.
30
31
  - Inline every style, script, and asset the page needs. The render reads the file off disk, so a page reaching for a build step or a network font renders without it and the arms differ by something nobody chose.
31
32
  - Declare a font stack the machine resolves, such as `system-ui` behind a generic fallback. The render refuses a page that would rewrap against a substitute rather than shipping a false comparison, so a page naming no font at all is refused on whatever the default resolves to.
@@ -65,10 +66,13 @@ Put the choice to the operator through the structured question surface, per `.cl
65
66
  ## Step 6: close
66
67
 
67
68
  1. Apply the winning arm to the real surface, in one change.
68
- 2. Close out whatever document stated the decision as open, in the same change, naming the arm that won and the ones that stayed defensible. A pick that changes a surface and records nothing about why leaves the next reader to re-derive it from a diff, and the losing arms are gone by the next step where `<dest>` is deleted. Skip this where nothing stated the decision.
69
- 3. Delete `<dest>` and every losing arm with it, when `<dest>` is the scratch path. A variant left behind there is a second design nobody maintains. Leave `<dest>` in place when it is a live track's `evidence/<slug>/`: `plan-groundwork`'s write scope treats evidence as durable rather than as scratch a session may delete, and the render a decision file cites has to stay where that file points.
70
- 4. Report `<dest>` as still standing when the scratch-path delete is refused, naming the path for the operator to remove, rather than closing on a report the tree contradicts. The pick is applied either way, so the run has done its work and the folder is what outlives it.
71
- 5. Report every surface that changed, each on its own line, and name the arm that won by its id and its cost.
69
+ 2. Close out whatever document stated the decision as open, in the same change, naming the arm that won and the ones that stayed defensible. A pick that changes a surface and records nothing about why leaves the next reader to re-derive it from a diff. Skip this where nothing stated the decision.
70
+ 3. Batch-capture the final round's arm files, when `<dest>` is the scratch path: `canon capture <dest>/arms --selector <wrapper-class> --out <archive-dir>`, naming Step 2's chosen class. This is the directory-batch convention `draft-identity` Step 6 already uses.
71
+ 4. Resolve `<archive-dir>` as `.canon/review/evidence/<slug>/` against the main worktree root, per `.claude/rules/canon/core/085-worktrees.md`, never against a linked worktree this run happens to be building in. The capture is what keeps every arm past the pick, the losing ones included, as a durable revert record distinct from the live comparison page.
72
+ 5. Delete `<dest>` and every file inside it, when `<dest>` is the scratch path, now that every arm sits at the durable path above. A variant left behind there is a second design nobody maintains.
73
+ 6. Leave `<dest>` in place when it is a live track's `evidence/<slug>/`: `plan-groundwork`'s write scope treats evidence as durable rather than as scratch a session may delete, and the arms already sit at a durable path there.
74
+ 7. Report `<dest>` as still standing when the scratch-path delete is refused, naming the path for the operator to remove, rather than closing on a report the tree contradicts. The pick is applied either way, so the run has done its work and the folder is what outlives it.
75
+ 8. Report every surface that changed, each on its own line, name the arm that won by its id and its cost, and report the archival path from Step 3 where it ran.
72
76
 
73
77
  ## Reading a measurement
74
78
 
@@ -27,13 +27,24 @@ Run `git diff --cached --name-only 2>/dev/null` to check for staged files. If ou
27
27
  2. Invoke `canon:docs-fold` to sync internal planning docs against session decisions, folding in the routed facts
28
28
  3. Invoke `canon:docs-sync` to sync public docs against changes since main
29
29
  4. Run `git add -A` to stage any files the sync skills wrote
30
- 5. Invoke `canon:git-stage` to group staged changes and commit by concern
31
- 6. Invoke `canon:git-branch` to rename branch to match conventional format
32
- 7. Invoke `canon:git-pr` to push branch and open pull request
33
- 8. After the PR opens, watch CI. Poll `canon pr checks <number> --json` until the record's `state` leaves `pending`, branching on that field rather than on the exit, and fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb. On `passing`, continue. On `failing`, stop the sequence and report the failing check with its URL. Do not auto-fix. This step may output on failure, the one exception to the no-text-between-steps rule.
34
- 9. If step 1 wrote or updated at least one memory file, invoke `canon:memory-review` scoped to those entries to propose fixes while session context is fresh. If the pen got nothing, skip this step.
30
+ 5. Run `canon tasks plan-reach <plan> --json` and report both lists it carries. Name the plan this branch built under, by path or by slug. Read `claimed` first and say who holds each path, since that is the half a reader acts on, then say how many of the changed paths `undeclared` names. Branch on the record rather than on the exit code, which a shell function wrapping `canon` can flatten to zero. This step reports and never stops the sequence.
31
+ 6. Invoke `canon:git-stage` to group staged changes and commit by concern
32
+ 7. Invoke `canon:git-branch` to rename branch to match conventional format
33
+ 8. Invoke `canon:git-pr` to push branch and open pull request
34
+ 9. After the PR opens, watch CI. Poll `canon pr checks <number> --json` until the record's `state` leaves `pending`, branching on that field rather than on the exit, and fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb. On `passing`, continue. On `failing`, stop the sequence and report the failing check with its URL. Do not auto-fix. This step may output on failure, the one exception to the no-text-between-steps rule.
35
+ 10. If step 1 wrote or updated at least one memory file, invoke `canon:memory-review` scoped to those entries to propose fixes while session context is fresh. If the pen got nothing, skip this step.
35
36
 
36
- A caller wrapping this sequence may act between step 7 and step 8, which is the one gap the order leaves open, since the pull request exists there and nothing has read its checks yet. `auto-ship` marks the pull request draft in it. Nothing else may go there, and a caller that needs a step anywhere else in the sequence is asking for a change to this body rather than for a place to stand.
37
+ A caller wrapping this sequence may act between step 8 and step 9, which is the one gap the order leaves open, since the pull request exists there and nothing has read its checks yet. `auto-ship` marks the pull request draft in it. Nothing else may go there, and a caller that needs a step anywhere else in the sequence is asking for a change to this body rather than for a place to stand.
38
+
39
+ ### Why the reach reads at step 5
40
+
41
+ The branch is whole there and nowhere earlier. Steps 2 and 3 write past whatever the plan declared, so a reading taken ahead of them misses the chain's own additions, and no pull request exists until step 8 to carry the answer anywhere.
42
+
43
+ It is the one step here that reports rather than acts, which is why it never stops the sequence. Every crossing measured on the wave it was filed against merged clean, and the undeclared list runs long enough on an ordinary branch that a gate would fire on nearly every ship.
44
+
45
+ ### When the verb is absent
46
+
47
+ An installed binary carrying no `plan-reach` subcommand reports the reach unread rather than clear. Say that, and continue. The verb ships with the CLI and this body ships with the plugin, so a target on an older binary meets a missing subcommand, and a body reading that absence as a clean answer would report the check passing on every branch that never ran it.
37
48
 
38
49
  Capture leads the sequence because a routed fact lands in a context entry, which is a tracked file. Running it after the pull request opens leaves that edit off the branch entirely, so the fact reaches nothing. Memory files are gitignored either way, which is what hid the ordering while capture wrote only those.
39
50
 
@@ -69,6 +69,8 @@ Take the comparison at the file path rather than at a folder above it. `canon ta
69
69
 
70
70
  The finding names which row contributed the containing path, and a bare-folder cell reports as a claim of its own beside the findings. Read that output as a candidate list, settle each pair by file, and narrow the cell that over-claimed rather than discounting the collision it caused.
71
71
 
72
+ A declared set is what a branch sets out to write rather than a bound on it, so this gate clears against a prediction and the branch outgrows it hours later. Three tracks crossed a set they had cleared against in the wave of 2026-09-08 and every one merged clean, which is a gate reporting disjoint while a real overlap stood. Two classes account for nearly all of it. The ship chain's own steps write past every plan, since `canon:docs-fold` refreshes whichever context entry a change reaches and `canon:docs-sync` reaches the public docs, and neither surface is one a planner can name before the change exists. The drift stages `bun run check` regenerates and asserts are the second, together with the test and sandbox siblings a source change drags in. Nothing here prevents either, because the files at issue are written long after this gate clears. What reads the other end is `canon tasks plan-reach`, which `canon:git-ship` runs at step 5 against the branch's own diff, so a crossing this gate could not see is named before the pull request opens rather than after it merges.
73
+
72
74
  Disjointness is necessary and not sufficient, so hold a candidate whose sets do not touch when a stated reason serializes it, and write the reason on the hold. One row creating a skill and another auditing that catalog and counting it write nothing in common, measured 2026-08-27, and dispatching both still leaves the audit counting a denominator that moves underneath it. Nothing verifies that a reason was written, so the rule holds only while the dispatcher applies it.
73
75
 
74
76
  What binds past that is review attention rather than a count, and `## Parallelism` in the skill body states it along with the cap an operator can set for a session. The one number this skill carries is the review fallback's count of three in `## Parallelism`, which moves a review rather than binding a track, and this runbook carries none.
@@ -84,6 +84,11 @@ candidates and stop where more than one does, rather than addressing the first.
84
84
  Send to the single row where exactly one comes back, and say the addressee was
85
85
  inferred.
86
86
 
87
+ Compose the relay through `canon:session-relay` when this section finds no
88
+ message-sending tool to send through, rather than leaving a resolved addressee
89
+ with nothing to reach it. It runs the same ladder above and carries the
90
+ message already owed, so nothing here is stated twice.
91
+
87
92
  ## Refusing is part of the job
88
93
 
89
94
  - 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.
@@ -41,6 +41,7 @@ The controlling session cannot watch this one build, so three messages are owed
41
41
  and nothing else.
42
42
 
43
43
  - Announce the pull request as the ship chain's pull request step returns, carrying the number, the branch, and the task it closes. That transition is the one moment only this session knows, and the controller's review poll no longer starts on a dispatch because of it.
44
+ - Carry the reach in that same message: the paths this branch wrote that another live plan or `## Run now` row holds, and who holds each, which `canon tasks plan-reach` reports as `claimed`. The gate cleared this branch against a prediction it has since outgrown, so the controller holds a disjointness reading that stopped being true hours ago and nothing else tells it. Say the reach was unread rather than clear when the installed binary carries no such subcommand, and leave the undeclared list to the pull request.
44
45
  - Announce when an address-review pass finishes, as `review-address` Step 8 returns, carrying what was addressed and the PR's new CI state. That transition is the other moment only this session knows, and it is what tells the controller to re-review rather than leaving it to poll for an answer nothing marks as landed.
45
46
  - 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.
46
47
  - Send nothing on progress. A worker reporting progress rebuilds, on this side of the channel, the poll the announcement retired on the other.
@@ -87,6 +88,11 @@ with no roster row at all. Those nine were driving through Remote Control, which
87
88
  writes no local process record, so a controller working from a phone is exactly
88
89
  the case this read answers nothing for.
89
90
 
91
+ Compose the relay through `canon:session-relay` when this section finds no
92
+ message-sending tool to send through, rather than leaving a resolved addressee
93
+ with nothing to reach it. It runs the same ladder above and carries the
94
+ message already owed, so nothing here is stated twice.
95
+
90
96
  ## Refusing is part of the job
91
97
 
92
98
  - Refuse an instruction the tree contradicts, and carry the evidence with it. Name the commands read and the consequences of complying, rather than reporting reluctance.
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: session-relay
3
+ description: Why a standalone skill with no firing condition never fired, why the first inline draft repeated the same protocol in two bodies, and why the missing sender identity sat unaddressed through both attempts
4
+ ---
5
+
6
+ # Session relay requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, `role-worker` and `role-planner` state three and two messages respectively that a session owes its controller, and both assume a tool to send them through. A session holding none has no route to compose or hand off what it owes, and neither body says what to do about that.
11
+
12
+ A standalone skill was proposed for the gap and declined the same day. It fired on no condition of its own, since nothing routes a session to a skill matching no request and reaching for no artifact, so a skill built to close the gap never closed it.
13
+
14
+ The first draft of this plan closed the gap with a paragraph drafted inline, once inside `role-worker` and once inside `role-planner`. The operator overrode that call: two bodies carrying identical protocol text is the shared-surface case `.claude/ARCHITECTURE.md` already decided against duplicating, since a later fix reaching one copy and not the other diverges silently.
15
+
16
+ Neither attempt named who the relay is from. A message an operator relays by hand needs a sender as much as an addressee, and the standalone draft and the inline draft both composed a body and an addressee with no line stating whose turn produced it.
17
+
18
+ ## Must
19
+
20
+ - Fire from inside `role-worker` or `role-planner`'s own `## The channel` section, at the point that section finds no message-sending tool
21
+ - Read the sender's own name off `canon sessions list --self --json` before composing anything, and degrade to naming itself from the environment when the installed CLI answers `--self` with an unknown option rather than a refusal carrying a reason
22
+ - Resolve the addressee by running the calling role's own ladder rather than a second one
23
+ - Carry the message body the calling role's bullet already names, verbatim
24
+ - Open the composed text with a header naming the sender, the addressee, and what the message is
25
+ - State the header as a default a session may write around, rather than a fixed vocabulary
26
+
27
+ ## Must not
28
+
29
+ - Restate the addressee ladder `role-worker` or `role-planner` already states
30
+ - Draft a message body of its own. The text it composes belongs to the calling role's bullet.
31
+ - Fire when a message-sending tool exists
32
+
33
+ ## Guards
34
+
35
+ - A message-sending tool is available: refuse, name it, and send through it instead
36
+
37
+ ## Out of scope
38
+
39
+ - The addressee resolution ladder itself, which stays in `role-worker` and `role-planner`
40
+ - The content of each owed message, which stays in the calling role's own bullet
41
+ - Inbound reach: a controller relaying back to a worker or planner holding no tool. The gap this closes is the outbound leg alone, since a controller can already reach either side directly.
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: session-relay
3
+ description: Composes the relay a worker or planner owes its controller when the session holds no message-sending tool, carrying the sender's own name, the addressee the calling role's own ladder resolves, and the message body that role already names, opened with a header naming all three so an operator can copy the block whole. Fires only from inside `role-worker` or `role-planner`'s own `## The channel` section, at the point that section finds no tool to send through. Do NOT invoke it when a message-sending tool exists, which is the ordinary channel, and do NOT invoke it to resolve an addressee for anything other than a channel obligation those two roles already state.
4
+ ---
5
+
6
+ # Session relay
7
+
8
+ `role-worker` and `role-planner` each owe their controller a set of messages, and each assumes a message-sending tool exists to carry them. A session holding none has nothing to send through, and the paragraph stating what to do about that used to sit inline in both bodies, repeating the same protocol twice. This skill is that paragraph, and both role bodies point to it instead of each carrying their own copy.
9
+
10
+ Read the calling body's `## The channel` section for the message owed and the ladder that resolves its addressee. This skill does not restate either.
11
+
12
+ ## Guards
13
+
14
+ - Refuse to fire when a message-sending tool is available. Say so, name the tool, and send through it. This skill exists for the gap, never as an alternative to the ordinary channel.
15
+
16
+ ## Steps
17
+
18
+ 1. Read this session's own name from `canon sessions list --self --json`. A refusal here, `no-self-identity` or `no-self-row`, means the roster carries nothing to name the sender with. Report the refusal and its reason in place of a name rather than guessing one.
19
+ 2. Resolve the addressee. Run the calling role's own `## The channel` ladder to the letter rather than a second one here, and take its outcome, whatever it is, as this skill's addressee.
20
+ 3. Carry the message body the calling role's bullet already names, verbatim. This skill composes and never drafts, so the pull request announcement, the address-review announcement, or the blocked question is exactly the text that bullet states, with its own facts filled in.
21
+ 4. Open the composed text with a header naming the sender, the addressee, and what the message is. Close the turn with nothing after it, so the block above is the whole of what an operator copies.
22
+
23
+ Where the installed CLI answers `--self` with an unknown option, that flag is newer than the release this session holds, so step 1 meets no refusal with a reason to report. Name the sender from what the environment states instead, and say the roster did not answer, so the reader knows the name was not confirmed against it.
24
+
25
+ ## Header
26
+
27
+ ```plaintext
28
+ Relay: from <sender> to <addressee>, <what the message is>
29
+ <message body>
30
+ ```
31
+
32
+ States a default rather than a fixed vocabulary. A session may write the header in its own words, since it exists to be read once and copied rather than parsed by anything downstream. Name the same three parts however phrased.
33
+
34
+ ## Output
35
+
36
+ Nothing beyond the composed block. Report the Guards refusal instead when the roster cannot answer for the sender, and report the calling body's own halt or inference when its ladder cannot resolve the addressee.
@@ -37,7 +37,7 @@ They are reported apart because they want different tolerances.
37
37
  - **`unmet`** is a whole path the body claims ahead of its bullet's first comma and the diff does not carry. This is the graded direction and it sets the exit code. A bullet naming an untouched file is wrong more often than not, and it corrupts the record that reaches the trunk.
38
38
  - **`unnamed`** is a changed file no bullet reached that a reader might have wanted one for. Reported with no grade, since a change can be too small to describe and still be correctly absent. Grading it would fire on nearly every branch.
39
39
  - **`incidental`** is a changed file no bullet reached that owes none: a test beside its subject, anything under a fixture or snapshot folder, and a lockfile a package manager writes. Held apart so the count above reads, and reported rather than dropped so a run still says what it set aside.
40
- - **`unresolved`** is a path the reading could not judge either way. Two causes reach it: a path written partially, such as `role-worker/SKILL.md` for a file under `claude/skills/`, and a path past its bullet's first comma.
40
+ - **`unresolved`** is a path the reading could not judge either way. Three causes reach it. A path written partially, such as `role-worker/SKILL.md` for a file under `claude/skills/`. A path past its bullet's first comma. And, for a live pull request, a claim landing here rather than `unmet` because the rename or `.gitignore`-addition evidence that might have credited it could not be read, which `evidenceUnread` on the record names.
41
41
 
42
42
  Each direction splits on one question, which is whether the evidence is strong enough to raise with a person. A partial path and a trailing path can each credit a changed file and can never accuse one, because nothing separates a path written short from a path written wrong, or a second claim from a file cited for context. Neither split drops anything: what comes out of `unmet` lands in `unresolved` and what comes out of `unnamed` lands in `incidental`, so a count a reader can act on never costs a file the run stayed silent about.
43
43
 
@@ -105,6 +105,10 @@ The bound is that the corpus is one repository writing to one house style. A pro
105
105
 
106
106
  What it cannot see is a bullet claiming a change to a file the branch did touch for another reason, and a path written without backticks. A claim in a second coordinated clause after the comma is read and credited, and it stays out of the graded direction, since nothing here separates it from a file the same clause cites for context.
107
107
 
108
+ Two shapes credit against evidence the changed-file list itself never carries. A bullet naming the path a git rename moved away from, such as "Move `claude/skills/identity/` to `claude/skills/draft-identity/`", is credited against a local rename read (`git diff --name-status -M`) or, for a live pull request, `gh api repos/{owner}/{repo}/pulls/{number}/files`, keyed on the old path git's own detection reports. A bullet naming a pattern newly added to `.gitignore`, such as "Ignore `web/screenshots/` and `web/evidence/`", is credited against the added, non-comment lines of that file's own diff, with the trailing slash a directory claim carries normalized against the raw pattern. Both credits reach past the anchored-and-leading split that decides `unmet`, and the ignore-addition credit marks `.gitignore` itself as named, so the file also stops reading as `unnamed`.
109
+
110
+ Neither credit can tell a claim that is genuinely stale from one the evidence would have credited had the read gone through, since a failed fetch and a confirmed absence both produce the same empty result. `evidenceUnread` on the record separates the two: when the read that could have credited a claim fails, that claim lands in `unresolved` instead of `unmet`, rather than risk the exact false accusation this section exists to remove.
111
+
108
112
  One class stays open and is named rather than closed. A bullet can cite where something is defined while claiming an edit somewhere else, as `erclx/canon#1274` does with "Name the slug transform in `standards/slug.md` inline in the same step". The path is a definition site, the edit target is the step, and separating the two needs the sentence parsed rather than cut. A finding on such a bullet is this class rather than a stale claim, and a reader weighing an `unmet` path checks whether the bullet's real target is a locative the path does not name. <!-- canon-allow-reference: illustrates the input shape the rule reads -->
109
113
 
110
114
  ## Where it runs
@@ -186,6 +186,39 @@ Both sides of a dispatch call it. The orchestrator's collision check derives its
186
186
  canon tasks plan-branch dispatch-answer-gate --json | jq -r '.branch'
187
187
  ```
188
188
 
189
+ ## Plan reach
190
+
191
+ `canon tasks plan-reach <plan>` reads a branch back against what was written down about it. It reports and never writes, and it names the plan the same two ways `canon tasks plan-branch` does.
192
+
193
+ ```bash
194
+ canon tasks plan-reach dispatch-answer-gate
195
+ canon tasks plan-reach dispatch-answer-gate --base origin/main --json
196
+ ```
197
+
198
+ | Option | Effect |
199
+ | --------------- | ---------------------------------------------- |
200
+ | `--base <ref>` | Far side of the range, defaulting to the trunk |
201
+ | `--json` | Emit a machine-readable record on stdout |
202
+ | `--root <path>` | Board root, defaulting to the main worktree |
203
+
204
+ The record carries `claimed`, `undeclared`, `declared`, `base`, `changed`, `plans`, `rows`, and `board`. Exit codes: `0` read with nothing claimed, `1` refused with `no-plan`, `archived`, `bad-input`, `no-base`, or `no-diff`, `2` read with a claim standing. Branch on the record rather than on the exit code, which a shell function wrapping `canon` can flatten to zero.
205
+
206
+ `claimed` leads because it is the short list and the one worth acting on. It carries one entry per path, each holding a `holders` list, so a track carrying both a live plan and a `## Run now` row reads as one holder rather than two. A holder names itself, its `source` of `plan` or `row`, and the `declaration` it matched on, so a folder claim reports which folder rather than leaving the reader to find it. A plan holder also carries `rowed`, whether that plan has a row in `## Run now` at all: a plan with no row is the shape a plan nobody archived takes, and equally the shape of one whose task has yet to be dispatched, so it narrows the reader's search rather than answering it. `undeclared` is every changed path this plan never named, which runs long on an ordinary branch: over the wave this verb was filed against, it ran 22 of 26 paths on one pull request and 18 of 25 on another. Those are the ship chain's own writes rather than scope creep, since the sync skills reach a context entry and the public docs, and the check stages regenerate what they assert.
207
+
208
+ A declaration is a backticked span standing as an entry's subject, ahead of the colon opening its reason. Reading every span was the alternative and it reports pairs that were never going to collide, since a reason routinely cites a file the entry is not about: measured over the same wave, the subject rule reports 6 crossing pairs against 14 for every span. Both sides of a rename declare, since both sit ahead of the colon.
209
+
210
+ The range is read at the current directory and the plans and board at the board root, so a linked worktree reads its own branch against the shared records. Reading both at one root was the alternative and it measures a main checkout sitting on the trunk, where the range closes on itself and every branch reports a reach of nothing.
211
+
212
+ It reads only what is written down, so it inherits the dispatch runbook's blindness: a hand-launched track carries no row and a track with no plan carries no declaration. The `plans`, `rows`, and `board` fields say how much there was to compare against, so a clear reading over an empty corpus does not read as a proof. A missing board reports `board: false` and zero rows rather than refusing, since a project with plans and no board still has a branch worth reading.
213
+
214
+ A claim is only as current as the folder it was read from, and the live folder holds a plan whose work already shipped until something archives it. `canon tasks archive` moves a plan on merge, so a plan stranded by a run that never reached the archive keeps claiming its files against every branch afterwards. Check whether the holder is actually in flight before treating a claim as a collision: the first run of this verb on its own branch reported five paths held by a plan whose verb had already merged, and the whole reading came of a file nobody archived. `canon tasks validate` is what reports the stranded plan itself.
215
+
216
+ `canon:git-ship` runs it at step 5, after `git add -A` and before the commit grouping. That is the first point the branch is whole and the last before a pull request exists to carry the answer.
217
+
218
+ ```bash
219
+ canon tasks plan-reach dispatch-answer-gate --json | jq -r '.claimed[] | "\(.path) held by \([.holders[].name] | join(", "))"'
220
+ ```
221
+
189
222
  ## Plan link
190
223
 
191
224
  `canon tasks plan-link <task> <plan>` writes or corrects a task's `Plan:` line, as `Plan: [<label>](<target>)` right after the H1. `plan-feature` calls it right after a plan file lands, when Step 1 resolved an existing task for the feature, so the line is a mechanical write rather than hand-edited markdown.
@@ -271,6 +271,7 @@ This section is the corpus the coverage claim is measured against: every name `c
271
271
  | `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR |
272
272
  | `canon:role-planner` | To assert the planner role for a cold session writing one plan under one task |
273
273
  | `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan |
274
+ | `canon:session-relay` | When a worker or planner owes its controller a message and holds no send tool |
274
275
  | `canon:session-resume` | At the start of a session, to pick up what a previous one left |
275
276
  | `canon:session-map` | At the close of a session, to write the handoff a compaction would destroy |
276
277
 
@@ -34,5 +34,6 @@ paths:
34
34
 
35
35
  - Do not commit the sweep. It stays ignored.
36
36
  - Commit a flagged case's evidence output so the pull request carries the comparison, rather than attaching it by hand.
37
+ - A project whose own established convention already commits that same comparison may route a flagged case's evidence there instead of a separate evidence folder. Only an established convention already committing the same comparison qualifies, not a preference for skipping a second folder.
37
38
  - Commit an evidence case for the first time only after running the capture twice with no code change between the runs and confirming the two outputs are byte-identical.
38
39
  - Recommitting an unbounded sweep on every run reaches a gigabyte of repository history inside a hundred merges. A small, committed evidence set is what a reviewer needs and what lets GitHub draw its own before-and-after comparison on the pull request.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.71.0",
4
+ "version": "4.72.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -142,6 +142,11 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
142
142
  prompt: 'Get me set up in a fresh Claude Code worktree for this branch.',
143
143
  expect: 'session-worktree',
144
144
  },
145
+ {
146
+ prompt:
147
+ 'I owe my controller the pull request announcement but I have no message-sending tool. Compose the relay so an operator can copy it over.',
148
+ expect: 'session-relay',
149
+ },
145
150
 
146
151
  // Negatives: pairs whose bodies already carry an explicit Do NOT boundary.
147
152
  {
@@ -6,7 +6,11 @@ import { execa } from 'execa'
6
6
  import { gitEnv } from '@/git-env'
7
7
  import {
8
8
  listChangedFiles,
9
+ listIgnoreAdditions,
10
+ listRenames,
9
11
  listRepositoryFiles,
12
+ parseIgnoreAdditions,
13
+ type RenamePair,
10
14
  resolveBaseRef,
11
15
  } from '@/git-files'
12
16
  import {
@@ -292,6 +296,9 @@ interface PullRequestRead {
292
296
  readonly changed: readonly string[]
293
297
  readonly head: string | undefined
294
298
  readonly number: number | undefined
299
+ readonly renames: readonly RenamePair[]
300
+ readonly ignoreAdditions: readonly string[]
301
+ readonly evidenceUnread: boolean
295
302
  }
296
303
 
297
304
  type SourceRead =
@@ -349,13 +356,26 @@ async function readFromApi(
349
356
  return { kind: 'refused', reason: 'gh-truncated' }
350
357
  }
351
358
 
359
+ const body = row.body ?? ''
360
+ const sorted = [...changed].sort()
361
+ const evidence = await resolveApiEvidence(
362
+ cwd,
363
+ body,
364
+ sorted,
365
+ row.headRefOid,
366
+ row.number,
367
+ )
368
+
352
369
  return {
353
370
  kind: 'read',
354
371
  source: {
355
- body: row.body ?? '',
356
- changed: [...changed].sort(),
372
+ body,
373
+ changed: sorted,
357
374
  head: row.headRefOid,
358
375
  number: row.number,
376
+ renames: evidence.renames,
377
+ ignoreAdditions: evidence.ignoreAdditions,
378
+ evidenceUnread: evidence.unread,
359
379
  },
360
380
  }
361
381
  } catch {
@@ -363,6 +383,95 @@ async function readFromApi(
363
383
  }
364
384
  }
365
385
 
386
+ /**
387
+ * A rename's source path and a `.gitignore` addition, fetched only when a
388
+ * first pass with neither already reports an unmet claim.
389
+ *
390
+ * The probe pays for a repository listing this read takes again a moment
391
+ * later in `runKeyChanges`, and the `gh api …/files` call besides it only on
392
+ * the pass that already has something to double-check, the same trade the
393
+ * `gh-truncated` pagination fallback makes.
394
+ *
395
+ * `unread` separates a read that failed from one that succeeded and found
396
+ * neither, which an empty `renames`/`ignoreAdditions` cannot do on its own.
397
+ * `listFilesByPage` above refuses the whole comparison on exactly this
398
+ * ground: a set known to be short would let a correct bullet accuse a file
399
+ * nobody changed. This carries the same refusal as a flag rather than a
400
+ * refused `Bijection`, since the base comparison can still run and most
401
+ * claims never touch a rename or a `.gitignore` line at all.
402
+ */
403
+ async function resolveApiEvidence(
404
+ cwd: string,
405
+ body: string,
406
+ changed: readonly string[],
407
+ head: string | undefined,
408
+ number: number | undefined,
409
+ ): Promise<{
410
+ readonly renames: readonly RenamePair[]
411
+ readonly ignoreAdditions: readonly string[]
412
+ readonly unread: boolean
413
+ }> {
414
+ const clean = { renames: [], ignoreAdditions: [], unread: false }
415
+ const unread = { renames: [], ignoreAdditions: [], unread: true }
416
+
417
+ const tracked = await listRepositoryFiles(cwd)
418
+ if (tracked === undefined) return unread
419
+
420
+ const probe = compareKeyChanges({
421
+ body,
422
+ changed,
423
+ roots: treeRoots(tracked, changed),
424
+ ...(head !== undefined && { head }),
425
+ })
426
+ // A refusal here means there is no claim to credit at all, and a clean
427
+ // pass means every claim already resolved without the extra evidence, so
428
+ // neither case leaves anything for unread evidence to have mattered to.
429
+ if (probe.kind !== 'measured' || probe.unmet.length === 0) return clean
430
+
431
+ // Only past this point does a missing pull request number become a real
432
+ // gap: there is a claim the probe could not credit, and no number to fetch
433
+ // the evidence that might explain it.
434
+ if (number === undefined) return unread
435
+
436
+ // No `--jq` filter here, unlike `listFilesByPage` above. Shaping each row to
437
+ // {filename, previous_filename, status, patch} would make `--paginate`
438
+ // concatenate one filtered value per page rather than one combined array,
439
+ // and gh's own pretty-printing of an object result (unlike the scalar
440
+ // strings `--jq '.[].filename'` yields) is not guaranteed to stay
441
+ // line-parseable. Parsing the raw paginated array is safe under both.
442
+ const stdout = await gh(cwd, [
443
+ 'api',
444
+ '--paginate',
445
+ `repos/{owner}/{repo}/pulls/${number}/files`,
446
+ ])
447
+ if (stdout === null) return unread
448
+
449
+ let rows: readonly {
450
+ readonly filename: string
451
+ readonly previous_filename?: string
452
+ readonly status: string
453
+ readonly patch?: string
454
+ }[]
455
+ try {
456
+ rows = JSON.parse(stdout)
457
+ } catch {
458
+ return unread
459
+ }
460
+
461
+ const renames = rows
462
+ .filter(
463
+ (row): row is typeof row & { previous_filename: string } =>
464
+ row.status === 'renamed' && row.previous_filename !== undefined,
465
+ )
466
+ .map((row) => ({ from: row.previous_filename, to: row.filename }))
467
+
468
+ const ignoreRow = rows.find((row) => row.filename === '.gitignore')
469
+ const ignoreAdditions =
470
+ ignoreRow?.patch !== undefined ? parseIgnoreAdditions(ignoreRow.patch) : []
471
+
472
+ return { renames, ignoreAdditions, unread: false }
473
+ }
474
+
366
475
  /**
367
476
  * Every file a pull request changed, read through the paginated endpoint.
368
477
  *
@@ -430,10 +539,11 @@ async function readFromFile(
430
539
  return { kind: 'refused', reason: 'unreadable-changes' }
431
540
  }
432
541
 
433
- const head = await $`git -C ${root} rev-parse HEAD`
434
- .env(gitEnv())
435
- .quiet()
436
- .nothrow()
542
+ const [head, renames, ignoreAdditions] = await Promise.all([
543
+ $`git -C ${root} rev-parse HEAD`.env(gitEnv()).quiet().nothrow(),
544
+ listRenames(root, resolved),
545
+ listIgnoreAdditions(root, resolved),
546
+ ])
437
547
 
438
548
  return {
439
549
  kind: 'read',
@@ -442,6 +552,9 @@ async function readFromFile(
442
552
  changed,
443
553
  head: head.exitCode === 0 ? head.text().trim() : undefined,
444
554
  number: undefined,
555
+ renames: renames ?? [],
556
+ ignoreAdditions: ignoreAdditions ?? [],
557
+ evidenceUnread: renames === undefined || ignoreAdditions === undefined,
445
558
  },
446
559
  }
447
560
  }
@@ -469,6 +582,9 @@ async function runKeyChanges(
469
582
  body: source.source.body,
470
583
  changed: source.source.changed,
471
584
  roots: treeRoots(tracked, source.source.changed),
585
+ renames: source.source.renames,
586
+ ignoreAdditions: source.source.ignoreAdditions,
587
+ evidenceUnread: source.source.evidenceUnread,
472
588
  ...(source.source.head !== undefined && { head: source.source.head }),
473
589
  })
474
590
 
@@ -480,6 +596,11 @@ async function runKeyChanges(
480
596
  report.head === undefined ? '' : ` at ${report.head.slice(0, 8)}`
481
597
  }`,
482
598
  )
599
+ if (report.evidenceUnread) {
600
+ logWarn(
601
+ 'Rename or .gitignore-addition evidence could not be read, so a claim it might have credited or accused landed in unresolved rather than unmet.',
602
+ )
603
+ }
483
604
 
484
605
  logStep(report.unmet.length === 0 ? 'Claimed' : 'Unmet')
485
606
  if (report.unmet.length === 0) {
@@ -545,6 +666,7 @@ async function runKeyChanges(
545
666
  unnamed: report.unnamed,
546
667
  incidental: report.incidental,
547
668
  unresolved: report.unresolved,
669
+ evidenceUnread: report.evidenceUnread,
548
670
  })}\n`,
549
671
  )
550
672
  }