@erclx/canon 4.71.0 → 4.72.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-and-pick/REQUIREMENT.md +3 -1
- package/claude/skills/draft-and-pick/SKILL.md +10 -6
- package/claude/skills/git-ship/SKILL.md +17 -6
- package/claude/skills/markdown-propose/references/format.md +1 -1
- package/claude/skills/plan-groundwork/REQUIREMENT.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/plan-intake/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -0
- package/claude/skills/role-planner/SKILL.md +5 -0
- package/claude/skills/role-worker/SKILL.md +6 -0
- package/claude/skills/session-relay/REQUIREMENT.md +41 -0
- package/claude/skills/session-relay/SKILL.md +36 -0
- package/docs/agents/key-changes.md +5 -1
- package/docs/agents/tasks.md +33 -0
- package/docs/workflow/ai-workflow.md +1 -0
- package/governance/rules/ui/440-surface-capture.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +5 -0
- package/src/commands/pr.ts +128 -6
- package/src/commands/tasks.ts +154 -0
- package/src/git-files.ts +69 -0
- package/src/pr/bijection.ts +59 -1
- package/src/tasks/reach.ts +383 -0
- package/standards/groundwork.md +1 -1
- package/standards/intake.md +1 -1
- package/standards/memory.md +2 -2
- package/standards/plan.md +3 -1
- package/standards/tasks.md +2 -0
|
@@ -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
|
|
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
|
|
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
|
|
69
|
-
3.
|
|
70
|
-
4.
|
|
71
|
-
5.
|
|
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.
|
|
31
|
-
6. Invoke `canon:git-
|
|
32
|
-
7. Invoke `canon:git-
|
|
33
|
-
8.
|
|
34
|
-
9.
|
|
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
|
|
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
|
|
|
@@ -88,7 +88,7 @@ A proposal outlives its own diff. The source moves under it, so an approved chan
|
|
|
88
88
|
|
|
89
89
|
What remains is the record of what changed and why, which is the half no commit message holds. The folder stays where it is, beside every earlier pass, so a later one reads what an earlier one settled instead of asking it again.
|
|
90
90
|
|
|
91
|
-
The folder is gitignored and
|
|
91
|
+
The folder is gitignored, and backed wherever a records remote is configured. It carries decision state rather than generated data, which is why `src/records/backup.ts` names it in `BACKED_FOLDERS` so `canon records push` and `canon records pull` carry it there, refusing with `no-remote` otherwise, but nothing else about its contents is enforced.
|
|
92
92
|
|
|
93
93
|
## Retrieval
|
|
94
94
|
|
|
@@ -25,7 +25,7 @@ A track that closes with several separable findings writes only one task, and th
|
|
|
25
25
|
- Place the closing task's row through `task-board` Step 4 rather than writing `priority.md` or `backlog.md` directly
|
|
26
26
|
- Link every claim about a source outside the project, and list an unread source as a lead rather than citing it
|
|
27
27
|
- Put a fixture a headless run is pointed at outside the repository
|
|
28
|
-
- Write the next-session file self-contained, since the folder
|
|
28
|
+
- Write the next-session file self-contained, since backing the folder through `canon records push` and `canon records pull` holds only where a records remote is configured, protects only against the machine being lost even there, and never against a compaction dropping this session's reasoning before it has pushed
|
|
29
29
|
|
|
30
30
|
## Must not
|
|
31
31
|
|
|
@@ -26,7 +26,7 @@ Read `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md` before writing any file
|
|
|
26
26
|
- A `draft-and-pick` run invoked from inside a live track follows its own branch for this, writing to `evidence/` here rather than `.canon/tmp/<slug>/`.
|
|
27
27
|
- Reading is not restricted. External research is in scope, so read documentation, comparable projects, and papers whenever a live question needs them.
|
|
28
28
|
- Every claim about a source outside the project carries a link to it. A source found and not read is listed as a lead and is never cited.
|
|
29
|
-
- Treat the folder as gitignored and
|
|
29
|
+
- Treat the folder as gitignored, and backed only where a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there and refuse with `no-remote` otherwise, and neither protects against a compaction dropping this session's own reasoning before anyone has pushed, so `07-next-session.md` repeats what it needs instead of pointing at its siblings.
|
|
30
30
|
|
|
31
31
|
## Running a spike
|
|
32
32
|
|
|
@@ -32,7 +32,7 @@ Using the wrong one fails in two shapes. Intake on a question that needs measuri
|
|
|
32
32
|
- Write only inside `.canon/intake/<nn>-<slug>/`. A plan file, a task file, a source change, a standard, and a rule all live outside that folder, so this one rule forbids every one of them.
|
|
33
33
|
- There is no exception. Promoting an item onto the board runs through `task-board` after the operator has answered, which is a separate invocation.
|
|
34
34
|
- Reading is unrestricted inside the project. Measuring is the work.
|
|
35
|
-
- Treat the folder as gitignored and
|
|
35
|
+
- Treat the folder as gitignored, and backed only where a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there and refuse with `no-remote` otherwise, and neither protects against a compaction dropping a session's reasoning before anyone has pushed. No check reaches its contents, so every rule stated here holds only while a session reads it.
|
|
36
36
|
|
|
37
37
|
Nothing outside this body carries the write-scope floor, and no path-scoped rule can. A misrouted write lands on a path the folder's glob never matches, so the rule that ships beside the intake standard carries the item format and the answer contract instead, for a session editing the folder with this skill unloaded.
|
|
38
38
|
|
|
@@ -81,7 +81,7 @@ Write `00-overview.md` last, once the clusters are filed and the counts are real
|
|
|
81
81
|
|
|
82
82
|
Each open question in the index is a labeled markdown link to its owning item's heading anchor. The index points and the item owns, so no answer slot appears in the index. One question in two answerable places has no rule for which wins, and retrieval walks item headings, so an answer typed into the index is found by nothing and lost silently.
|
|
83
83
|
|
|
84
|
-
Add `99-next-session.md` only where the pass ends holding context no cluster file carries, such as a dump half filed or a measurement that has to be redone. Write it self-contained, since
|
|
84
|
+
Add `99-next-session.md` only where the pass ends holding context no cluster file carries, such as a dump half filed or a measurement that has to be redone. Write it self-contained, since a compaction drops the conversation behind it regardless of whether the folder is backed.
|
|
85
85
|
|
|
86
86
|
## Output
|
|
87
87
|
|
|
@@ -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.
|
|
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
|
package/docs/agents/tasks.md
CHANGED
|
@@ -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
|
@@ -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
|
{
|
package/src/commands/pr.ts
CHANGED
|
@@ -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
|
|
356
|
-
changed:
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
}
|