@erclx/canon 4.69.0 → 4.71.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.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/SKILL.md +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +4 -0
  5. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +37 -0
  6. package/claude/skills/deploy-cloudflare/SKILL.md +61 -0
  7. package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
  8. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  9. package/claude/skills/{identity → draft-identity}/REQUIREMENT.md +2 -2
  10. package/claude/skills/{identity → draft-identity}/SKILL.md +2 -2
  11. package/claude/skills/{canon-screencast → draft-screencast}/REQUIREMENT.md +4 -4
  12. package/claude/skills/{canon-screencast → draft-screencast}/SKILL.md +4 -4
  13. package/claude/skills/{canon-slides-draft → draft-slides}/REQUIREMENT.md +3 -3
  14. package/claude/skills/{canon-slides-draft → draft-slides}/SKILL.md +2 -2
  15. package/claude/skills/{canon-frames-read → read-frames}/REQUIREMENT.md +2 -2
  16. package/claude/skills/{canon-frames-read → read-frames}/SKILL.md +3 -3
  17. package/claude/skills/{canon-record → record-screencast}/REQUIREMENT.md +5 -5
  18. package/claude/skills/{canon-record → record-screencast}/SKILL.md +4 -4
  19. package/claude/skills/review-pr/SKILL.md +55 -7
  20. package/claude/skills/role-orchestrator/SKILL.md +2 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +7 -3
  22. package/claude/skills/role-orchestrator/scripts/poll.sh +79 -34
  23. package/claude/skills/role-worker/SKILL.md +2 -1
  24. package/claude/skills/ux-audit/SKILL.md +3 -0
  25. package/docs/agents/commands.md +2 -0
  26. package/docs/agents/demo.md +3 -3
  27. package/docs/agents/index.md +1 -1
  28. package/docs/agents/pr-reads.md +47 -12
  29. package/docs/agents/tasks.md +46 -3
  30. package/docs/workflow/ai-workflow.md +19 -19
  31. package/docs/workflow/visual-design-workflow.md +1 -1
  32. package/package.json +3 -2
  33. package/scripts/core/regen-web-previews.ts +94 -0
  34. package/src/claude/cases/misc.ts +5 -1
  35. package/src/claude/cases/workflow.ts +4 -4
  36. package/src/commands/demo.ts +1 -1
  37. package/src/commands/pr.ts +130 -1
  38. package/src/commands/tasks.ts +178 -1
  39. package/src/commands/teach.ts +2 -0
  40. package/src/demo/beats.ts +1 -1
  41. package/src/gate/measures.ts +66 -0
  42. package/src/gate/stages.ts +11 -0
  43. package/src/migrate/skill-names.ts +25 -1
  44. package/src/pr/review-scope.ts +177 -0
  45. package/src/tasks/archive.ts +206 -3
  46. package/src/tasks/label.ts +14 -6
  47. package/src/tasks/validate.ts +22 -0
  48. package/src/teach/workspace.ts +60 -23
  49. package/standards/plan.md +1 -1
  50. package/standards/tasks.md +15 -1
  51. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +103 -0
  52. package/tooling/cloudflare/manifest.toml +5 -0
  53. package/tooling/cloudflare/reference.md +24 -0
@@ -45,12 +45,10 @@ BASE_BRANCH="${BASE_REF#origin/}"
45
45
  # `## Review response` because neither answers a comment already on the thread,
46
46
  # which is why both were kept outside the `## Review` family rather than folded
47
47
  # into it.
48
- JQ_LAST_REVIEWED_HEAD='
49
- [ .reviews[]
50
- | select((.body // "") | split("\n")[0] | rtrimstr("\r")
51
- | . == "## Review" or . == "## Review closed")
52
- ] | last | .commit.oid // empty
53
- '
48
+ #
49
+ # The review family reaches this file through `canon pr review-state` rather
50
+ # than through a filter of its own, so the two headings are pinned here only in
51
+ # the fallback that answers for a target whose CLI predates that verb.
54
52
  # The count alone answers whether a reply is new to this script, which is not
55
53
  # the same question as whether it is newer than the pass it answers. The stamp
56
54
  # of the newest reply comes out of the same selection so the recency test costs
@@ -90,38 +88,72 @@ JQ_UNMATCHED_STATE='
90
88
  + (($unclassified | last) // "none")
91
89
  '
92
90
 
93
- # `review-pr` states the threshold and posts `## Review` exactly when a
94
- # pass carries a finding, so the heading of the last review is what says whether
95
- # any work is owed on it. Taking it as well as the commit is what separates a
96
- # thread waiting on a worker from one nothing is owed on. A project editing the
97
- # two filters above for its own headings edits this one with them.
91
+ # Four fields describe the last review pass: the commit it covered, whether it
92
+ # left anything owed, how long it has sat, and the instant it read. All four come
93
+ # out of `canon pr review-state`, which is the one place the read-time marker
94
+ # `review-pr` writes into its own body is parsed. Reading `commit.oid` and
95
+ # `submittedAt` off the thread here instead is what this replaces: GitHub stamps
96
+ # both when a review is submitted, so a push landing between a pass's read and
97
+ # its post moves them onto a commit that pass never opened, and `SEEN` below then
98
+ # reports it covered. That is the failure that loses work silently, measured on
99
+ # a pull request in this toolkit on 2026-09-07.
98
100
  #
99
- # The age of that review comes out of the same selection, because the heading
100
- # alone cannot separate the two. Under the rule above an open heading means a
101
- # dispatch was owed and made, so the ordinary healthy thread is a worker still
102
- # working and every one of them would be reported minutes after the pass posted.
103
- # jq computes the elapsed seconds itself, since `date -d` is GNU-only and this
104
- # script runs wherever the plugin is installed. A review carrying no stamp reads
105
- # as age zero and classifies nothing, which is the same answer the carry-forward
106
- # path gives a pull request this run could not read.
101
+ # `review-pr` posts `## Review` exactly when a dispatch is owed, per the
102
+ # threshold that skill states, so the heading is what separates a thread waiting
103
+ # on a worker from one nothing is owed on. A project editing the two comment
104
+ # filters above for its own headings has nothing to edit here, since the verb
105
+ # owns the review family now.
107
106
  #
108
- # The stamp itself is emitted as a third field beside the age it was derived
109
- # from, because the reply test below compares against the instant rather than
110
- # against the elapsed seconds. A missing stamp reads as zero there too, where it
111
- # sends a reply to be reported rather than suppressed. The two readers therefore
112
- # fail in opposite directions on the same absent field, since zero silences the
113
- # age test above and zero is the value the reply test reports on.
114
- JQ_LAST_REVIEW_STATE='
107
+ # The age is derived from `submittedAt` alone and the pass instant from
108
+ # `readAt // submittedAt`, which is deliberate rather than an oversight. The age
109
+ # measures how long a posted comment has waited on a human, a question about the
110
+ # submission, and the pass instant bounds what that pass had read, a question
111
+ # about the read. jq computes the elapsed seconds itself, since `date -d` is
112
+ # GNU-only and this script runs wherever the plugin is installed. A review
113
+ # carrying no stamp reads as age zero and classifies nothing, which is the same
114
+ # answer the carry-forward path gives a pull request this run could not read, and
115
+ # reads as pass instant zero, where it sends a reply to be reported rather than
116
+ # suppressed. The two readers fail in opposite directions on the same absent
117
+ # field on purpose.
118
+ #
119
+ # `source` is the field this branches on rather than the exit status, since an
120
+ # operator shell profile can wrap canon in a function that flattens every
121
+ # refusal to zero. A refusal record carries `reason` and no `source`, and it
122
+ # yields nothing here, which is what sends the read to the fallback below. A
123
+ # record read as a bare answer instead would report every refusal as a pull
124
+ # request nobody has reviewed.
125
+ JQ_SCOPE_FROM_VERB='
126
+ if (.source // "") == "" then empty else
127
+ (.commit // "none") as $prior
128
+ | (.state // "none") as $heading
129
+ | (if .submittedAt == null then 0
130
+ else (.submittedAt | fromdateiso8601 | floor) end) as $at
131
+ | (if .readAt == null then $at
132
+ else (.readAt | fromdateiso8601 | floor) end) as $read
133
+ | $prior + " " + $heading
134
+ + " " + (if $at == 0 then "0" else (((now | floor) - $at) | tostring) end)
135
+ + " " + ($read | tostring)
136
+ end
137
+ '
138
+ # The fallback for a target whose CLI predates the verb, mirroring the `canon pr
139
+ # head` fallback below. It reads the submission stamps and therefore carries the
140
+ # defect the verb closes, which is the behavior this poll already had. It does
141
+ # not parse the marker: a second reader of that format here is the drift the
142
+ # verb exists to prevent, and one that lags the format silently reports a
143
+ # reviewed commit as unreviewed.
144
+ JQ_SCOPE_FALLBACK='
115
145
  [ .reviews[]
116
146
  | select((.body // "") | split("\n")[0] | rtrimstr("\r")
117
147
  | . == "## Review" or . == "## Review closed")
118
148
  ] | last
119
- | if . == null then "none 0 0"
149
+ | if . == null then "none none 0 0"
120
150
  else (if .submittedAt == null then 0
121
151
  else (.submittedAt | fromdateiso8601 | floor)
122
152
  end) as $at
123
- | ((.body | split("\n")[0] | rtrimstr("\r")
124
- | if . == "## Review" then "open" else "closed" end)
153
+ | ((.commit.oid // "none")
154
+ + " "
155
+ + (.body | split("\n")[0] | rtrimstr("\r")
156
+ | if . == "## Review" then "open" else "closed" end)
125
157
  + " "
126
158
  + (if $at == 0 then "0" else (((now | floor) - $at) | tostring) end)
127
159
  + " "
@@ -164,7 +196,7 @@ carry_forward() {
164
196
  }
165
197
 
166
198
  snapshot() {
167
- local numbers n payload head prior resp merges review_state
199
+ local numbers n payload head prior resp merges scope review_scope review_state
168
200
  git fetch -q origin "$BASE_BRANCH" 2>/dev/null || true
169
201
 
170
202
  # A failed list reaches the caller as no open pull requests, and that reports
@@ -211,16 +243,29 @@ snapshot() {
211
243
  continue
212
244
  fi
213
245
 
214
- prior=$(jq -r "$JQ_LAST_REVIEWED_HEAD" <<<"$payload")
215
246
  # Split here rather than carried whole, because the count keeps the fourth
216
247
  # column every baseline written so far already reads, and the stamp goes to
217
248
  # the end of the line beside the pass stamp it is compared against.
218
249
  reply_state=$(jq -r "$JQ_REPLY_STATE" <<<"$payload")
219
250
  resp=${reply_state%% *}
220
251
  reply_at=${reply_state##* }
221
- # Three space-separated fields, so the line below carries them as its own
222
- # sixth, seventh, and eighth rather than needing a split.
223
- review_state=$(jq -r "$JQ_LAST_REVIEW_STATE" <<<"$payload")
252
+
253
+ # The marker `review-pr` writes is the authority for what a pass covered,
254
+ # and the verb is the only reader of it. The trailing assignment is
255
+ # load-bearing under `set -e` and `set -o pipefail`: every refusal exits 1,
256
+ # and a refusal here is ordinary rather than exceptional, so an unguarded
257
+ # pipeline would end the whole poll on the first pull request it could not
258
+ # answer for.
259
+ scope=$(canon pr review-state "$n" --json 2>/dev/null) || scope=""
260
+ review_scope=$(jq -r "$JQ_SCOPE_FROM_VERB" <<<"$scope" 2>/dev/null) || review_scope=""
261
+ if [ -z "$review_scope" ]; then
262
+ review_scope=$(jq -r "$JQ_SCOPE_FALLBACK" <<<"$payload")
263
+ fi
264
+ # Four space-separated fields. The commit leads, so the line below carries
265
+ # it as its own third the way every baseline already reads, and the other
266
+ # three ride on as the sixth, seventh, and eighth.
267
+ prior=${review_scope%% *}
268
+ review_state=${review_scope#* }
224
269
  # Split the same way as the reply state, carried as the line's tenth and
225
270
  # eleventh fields.
226
271
  unmatched_state=$(jq -r "$JQ_UNMATCHED_STATE" <<<"$payload")
@@ -25,7 +25,8 @@ running and re-invoking it would restart the build.
25
25
  - Resolve `.canon/plans/`, `.canon/tasks/`, `.canon/review/`, and `.canon/memory/` at the main worktree root, never against the linked worktree this session builds in. Those folders are gitignored, so `git worktree add` never creates them and the copy beside the build is absent rather than empty.
26
26
  - Report a plan that fails to resolve as unreadable from here, naming the main-root path. Reporting the task as having no plan is true where this session stands and wrong about the world, and a reader with no second tree to check cannot separate the two.
27
27
  - Build the plan the launch named. Do not write a second one when the path fails to resolve, since a row that cites a plan already has one and drafting another produces two plans for one row.
28
- - Report a draft flag reading ready once, and leave it cleared. The ship chain marks the pull request a draft and no step anywhere un-marks, so a flag reading ready afterwards was lifted by a person readying it to merge, which GitHub requires and is theirs to take. Re-drafting fights them, which four sessions did on 2026-08-31 on a belief no surface in the tree states.
28
+ - Report a draft flag reading ready once, and leave it cleared. The ship chain marks the pull request a draft and no step anywhere un-marks, so a flag reading ready afterwards was lifted by the operator or by the controlling session that closed the review, each acting directly on the pull request, which GitHub requires before a merge. Re-drafting fights them, which four sessions did on 2026-08-31 on a belief no surface in the tree states.
29
+ - Refuse an instruction to lift the mark yourself, whoever sends it. This session cannot verify who is asking or whether review actually closed, so the refusal holds regardless of the sender's claimed authority. A controlling session instructed a worker to lift the mark instead of taking the act itself on 2026-09-07, and the worker complied and reported against the surface rather than the outcome.
29
30
  - Rewrite a plan question's `- Suggested:` line as `overridden at execution to <pick>,` plus the measurement when this build decides against an unanswered one, leaving the `- Answer:` slot blank, and put the same deviation in one line under the open task's `## Findings`, per `${CLAUDE_SKILL_DIR}/../../standards/plan.md`.
30
31
 
31
32
  ## The board is read-only
@@ -18,6 +18,7 @@ Read these in parallel from the project root, skipping any that do not exist:
18
18
  - `.claude/DESIGN.md`: visual intent, tokens, typography, spacing rules
19
19
  - `.claude/wireframes/index.md` + every `.claude/wireframes/<surface>.md`, each a flat file or a grouped surface's own `.claude/wireframes/<surface>/index.md` and the siblings it lists: intended UI layout, UI copy, interaction rules. Surfaces are loaded one file at a time during Step 3. Per `${CLAUDE_SKILL_DIR}/../../standards/wireframes.md`, these carry layout and intent, not implementation detail.
20
20
  - `.claude/REQUIREMENTS.md`: feature scope and non-goals
21
+ - A committed capture beside the surface's own source, when the project has one. A rendered surface reads differently composed than it reads written, and a capture is the only artifact here that shows the composed result rather than the markup. Absent one, note that this audit reads source only, and keep going. This is a stated fallback, not a stop: a project with no capture yet, or a capture mid-rebuild, still gets a source-only audit rather than losing the skill entirely.
21
22
 
22
23
  ## Step 2: identify surfaces
23
24
 
@@ -34,6 +35,8 @@ For each surface, look for:
34
35
 
35
36
  Use `.claude/DESIGN.md` and the per-surface `.claude/wireframes/<surface>.md` files, each a flat file or a grouped surface's own `.claude/wireframes/<surface>/index.md` and the siblings it lists, as ground truth for intent. For each implementation surface, read the matching wireframe file before flagging drift. Observations only, no implementation suggestions or fixes.
36
37
 
38
+ Where Step 1 found a committed capture for this surface, judge composed output (spacing, overlap, contrast, wrapping) against it rather than inferring layout from markup alone, which reports what the surface renders rather than what its source implies. Where none exists, judge from source alone and say so in that surface's findings, since a source-only read cannot see what only a render would show.
39
+
37
40
  ## Step 4: report and persist
38
41
 
39
42
  ### Report format
@@ -32,6 +32,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
32
32
  | `canon feedback` | Write toolkit feedback from stdin to `.canon/review/feedback/`, or open a GitHub issue with `--github`, refusing either when a required field is absent |
33
33
  | `canon transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
34
34
  | `canon tasks archive` | Move a shipped task and its plan off the board, clear its ordering row, and regenerate the index |
35
+ | `canon tasks decline` | Move a decided-against task and its plan into `.canon/tasks/declined/`, recording who decided and why (`--reason`, `--by`, `--json`) |
35
36
  | `canon tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
36
37
  | `canon tasks plan-link` | Write or correct a task's `Plan:` line to point at a plan, by stem and plan path or slug (`--json`) |
37
38
  | `canon tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
@@ -81,6 +82,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
81
82
  | `canon pr key-changes` | Compare the files a pull request body's Key Changes names against its own diff, in both directions (`--body`, `--base`, `--json`) |
82
83
  | `canon pr head` | Compare the head a pull request object reports against the branch tip the remote carries, naming both shas (`--root`, `--json`) |
83
84
  | `canon pr checks` | Report the check runs belonging to the branch tip, reading pending for a tip carrying none rather than clean (`--root`, `--json`) |
85
+ | `canon pr review-state` | Report the commit and instant the last review pass covered, off the pass's own marker rather than the fields GitHub stamps at submission (`--root`, `--json`) |
84
86
  | `canon repo metadata propose` | Compare a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing (`--root`, `--json`) |
85
87
  | `canon repo metadata apply` | Write an explicitly supplied description, homepage, or topic set to the remote through `gh repo edit` (`--description`, `--homepage`, `--topics`, `--root`, `--json`) |
86
88
  | `canon census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
@@ -18,13 +18,13 @@ canon demo frames demos/inline-edit.webm --fps 2
18
18
 
19
19
  ## The draft and the plan are different files
20
20
 
21
- `canon-screencast` drafts beats for a person. A beat carries what is on screen, one verb, what to watch for, an emphasis, and a caption, and none of that names a selector, a URL, a wait condition, or a timing. Those four are what an executor needs and what would ruin the draft, since the format is pre-seeded so the operator edits down rather than fills blanks.
21
+ `draft-screencast` drafts beats for a person. A beat carries what is on screen, one verb, what to watch for, an emphasis, and a caption, and none of that names a selector, a URL, a wait condition, or a timing. Those four are what an executor needs and what would ruin the draft, since the format is pre-seeded so the operator edits down rather than fills blanks.
22
22
 
23
23
  So `canon demo compile` writes a second artifact rather than adding fields to a beat. It maps each beat's verb to a step, seeds the timing, marks which beat the still comes from, and leaves the target and the URL empty for a person to fill. The report names every field it could not supply.
24
24
 
25
25
  The plan is committed, not scratch. Its timing is a starting point tuned by watching a recording, and the draft cannot reproduce a tuned value, so a recompile over an existing plan refuses and names `--force`.
26
26
 
27
- `canon-record` is the routed way to run both verbs from a draft path: it compiles only when no plan exists yet, then runs, stopping to report any field still unresolved rather than guessing one.
27
+ `record-screencast` is the routed way to run both verbs from a draft path: it compiles only when no plan exists yet, then runs, stopping to report any field still unresolved rather than guessing one.
28
28
 
29
29
  | Option | Behavior |
30
30
  | --------------- | ----------------------------------------------------------------- |
@@ -64,7 +64,7 @@ A step waits on its `waitFor` selector becoming visible and then holds for its o
64
64
  | `--fps <n>` | Frames extracted per second of video, default `1` |
65
65
  | `--json` | Add a record on stdout carrying every frame path written |
66
66
 
67
- `canon-frames-read` is the routed way to call this verb and read the frames back: it runs the verb, reads each returned frame with the Read tool, and reports one plain description per frame. It never renders a verdict, since a frame read is evidence a person weighs rather than a pass or fail this toolkit states on their behalf.
67
+ `read-frames` is the routed way to call this verb and read the frames back: it runs the verb, reads each returned frame with the Read tool, and reports one plain description per frame. It never renders a verdict, since a frame read is evidence a person weighs rather than a pass or fail this toolkit states on their behalf.
68
68
 
69
69
  ## The pointer is painted inside the page
70
70
 
@@ -28,7 +28,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
28
28
  - [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
29
29
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
30
30
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
31
- - [Head-sensitive pull request reads](pr-reads.md): Resolving a branch tip from the remote rather than from the pull request object, reading check runs keyed on that tip, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
31
+ - [Head-sensitive pull request reads](pr-reads.md): Resolving a branch tip from the remote rather than from the pull request object, reading what a review pass covered off its own marker rather than off GitHub's submission stamps, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
32
32
  - [Records](records.md): The two roots a record folder resolves at, validating the session records and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
33
33
  - [Restated instructions](restated.md): Counting the instructions the always-loaded file and every path-scoped rule share with the seed, the shipped skill bodies, and each other, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
34
34
  - [Review classification](review-classification.md): Deciding whether a changed set needs the review pass, the two tests it runs, why an empty set refuses rather than skipping, and the written fallback a target on an older binary falls back to
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  title: Head-sensitive pull request reads
3
- description: Resolving a branch tip from the remote rather than from the pull request object, reading check runs keyed on that tip, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
3
+ description: Resolving a branch tip from the remote rather than from the pull request object, reading what a review pass covered off its own marker rather than off GitHub's submission stamps, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
4
4
  ---
5
5
 
6
6
  # Head-sensitive pull request reads
7
7
 
8
- `canon pr head` and `canon pr checks` answer about a commit. Both resolve the branch tip from the remote with `git ls-remote` and report what they found for that sha, rather than taking the pull request object's word for which commit the branch is on.
8
+ `canon pr head`, `canon pr checks`, and `canon pr review-state` each answer about a commit, and each exists because the field GitHub offers answers a different question. The first two resolve the branch tip from the remote with `git ls-remote` rather than taking the pull request object's word for which commit the branch is on. The third reads what a review pass covered off a marker the pass wrote, rather than off the head GitHub stamped when the review was submitted.
9
9
 
10
10
  ```bash
11
11
  canon pr head
12
12
  canon pr head 1341 --json
13
13
  canon pr checks
14
14
  canon pr checks 1341 --json
15
+ canon pr review-state
16
+ canon pr review-state 1341 --json
15
17
  ```
16
18
 
17
19
  ## The pull request object is not the authority for a head
@@ -30,8 +32,40 @@ The argument is stated here once. The shipped skill bodies call the verb rather
30
32
 
31
33
  `canon pr checks` reads `repos/{owner}/{repo}/commits/<tip>/check-runs` and collapses it to `passing`, `failing`, or `pending`. A failure outranks a run still going, since a run in flight cannot clear a job that already failed.
32
34
 
35
+ `canon pr review-state` reports `heading` and `state` for the newest pass in the review family, `commit` and `readAt` for what it covered, `submittedAt` for when GitHub recorded it, and `source` for which of those readings answered. A thread carrying no pass reports `state` and `source` both as `none` with nothing else on the record.
36
+
33
37
  `gh pr checks` cannot be made to answer this question at all, which is the argument for the move rather than a preference between two working commands. Its `--json` field set is `bucket, completedAt, description, event, link, name, startedAt, state, workflow`, with no sha among them, so a caller cannot even learn which commit its answer describes.
34
38
 
39
+ ## A review's stamps describe the submission, not the read
40
+
41
+ `commit.oid` on a review names whatever the pull request head was at the instant the review was submitted. `submittedAt` names that instant. Neither names the commit the reviewing session actually opened, and the two are only the same when nothing was pushed while the comment was being written.
42
+
43
+ A push landing in that window moves both onto a commit nobody reviewed. The next pass then scopes its delta past that commit and reports it covered, which is the failure direction that loses work: the commit is skipped rather than re-read. It fired on a pull request in this toolkit on 2026-09-07, where a pass read one commit, the branch pushed another during the compose, and the poll reported the second as already seen. What it skipped was a genuine fix, and a reader comparing the sha in the report against the sha in the pass caught it.
44
+
45
+ `review-pr` therefore ends every body it posts with a marker naming what it read:
46
+
47
+ ```markdown
48
+ <!-- review-pr: commit=<sha> read-at=<iso8601> -->
49
+ ```
50
+
51
+ Both values are taken at the top of the pass rather than at the post, so the compose window falls outside what the marker claims. An HTML comment renders as nothing on GitHub, so a reader of the thread never meets it.
52
+
53
+ The last non-empty line is where it has to sit, and the reader looks nowhere else. Searching a body for the last match instead reads a marker the body was displaying rather than claiming, since a fenced block showing the format on its own line trims to exactly the pattern, and a pass carrying no marker of its own would then hand the next reader a commit taken from an illustration. That is the same defect through a second door, so position rather than shape is what separates a claim from a quotation.
54
+
55
+ `canon pr review-state` is the only parser of that format. Two parsers was the alternative, one in the shipped skill body and one in the orchestrator poll's jq, and those two ship on different cadences, so a format change to either would leave the other reading a reviewed commit as unreviewed.
56
+
57
+ `source` on the record says which reading answered, and a caller checks it before trusting the rest:
58
+
59
+ | Source | What it means |
60
+ | ---------- | ------------------------------------------------------------------------------------------- |
61
+ | `marker` | The pass wrote its own read-time record, which is the authority |
62
+ | `fallback` | A pass posted before this shipped, read off the submission stamps and carrying their defect |
63
+ | `none` | The thread carries no pass, so the next one is a first pass |
64
+
65
+ The record carries `submittedAt` beside `readAt` rather than in place of it, because each is right about a different question. What a pass had read is a fact about the read, and how long a posted comment has waited on a human is a fact about the submission. The orchestrator poll reads the first for its coverage test and the second for its age test.
66
+
67
+ A `PUT` rewrite of a standing close-out inherits the fix without a mechanism of its own. That request replaces the body and the marker rides in it, so a rewritten comment names the commit the rewriting pass read. `submittedAt` stays pinned to the original submission, which is the reading the age test wants anyway.
68
+
35
69
  ## An empty run list is not a pass
36
70
 
37
71
  Keying the query on a sha is necessary and not sufficient. The endpoint answered with `total_count` 2 and an empty row list during a measured window on 2026-09-02, so a reader that finds no run for the tip and reports `passing` reproduces the false green behind a better query.
@@ -50,15 +84,16 @@ Branch on the record rather than on the exit. Both verbs exit 0 whenever they re
50
84
 
51
85
  Each refusal names a different repair:
52
86
 
53
- | Reason | What it means |
54
- | ------------------ | ------------------------------------------------------------------------------------------ |
55
- | `gh-missing` | `gh` is not on the path, so no pull request resolved |
56
- | `gh-failed` | `gh` could not answer for this branch, so name a number instead |
57
- | `no-branch` | The pull request carries no head branch name, so no ref could be read |
58
- | `unresolvable-ref` | The remote read failed, which is not the same answer as an absent branch |
59
- | `no-remote-branch` | The remote carries no branch by that name, so there is no tip to compare against |
60
- | `no-object-head` | The pull request object reported no head, so `head` has nothing to compare the tip against |
61
- | `runs-unreadable` | The check runs for the tip could not be read, which is unread rather than none |
87
+ | Reason | What it means |
88
+ | -------------------- | ------------------------------------------------------------------------------------------ |
89
+ | `gh-missing` | `gh` is not on the path, so no pull request resolved |
90
+ | `gh-failed` | `gh` could not answer for this branch, so name a number instead |
91
+ | `no-branch` | The pull request carries no head branch name, so no ref could be read |
92
+ | `unresolvable-ref` | The remote read failed, which is not the same answer as an absent branch |
93
+ | `no-remote-branch` | The remote carries no branch by that name, so there is no tip to compare against |
94
+ | `no-object-head` | The pull request object reported no head, so `head` has nothing to compare the tip against |
95
+ | `runs-unreadable` | The check runs for the tip could not be read, which is unread rather than none |
96
+ | `reviews-unreadable` | The reviews on the pull request could not be parsed, which is unread rather than none |
62
97
 
63
98
  A failed remote read and a branch the remote does not carry stay apart rather than collapsing into one reason. Reading the first as the second would report a network refusal as a deleted branch, and the repairs have nothing in common.
64
99
 
@@ -68,6 +103,6 @@ One remote round trip per call, sampled here at 0.41s, 0.55s, and 0.67s on 2026-
68
103
 
69
104
  One head-sensitive read is left on the object deliberately. `canon targets pulls` reads `statusCheckRollup` for every open pull request across every target, where resolving a tip per row would cost one remote read per pull request across a dozen projects, against a surface that reports a listing rather than gating a push.
70
105
 
71
- The orchestrator poll spends that same read per open pull request and repeats it on a three-minute timer, which makes it the heaviest caller here rather than an exception to the paragraph above. Six open pull requests is around 120 remote reads an hour. What separates the two cases is what each reading decides rather than what it costs. The poll's head fires the review trigger, so a stale one sends a pass at a commit nobody read or withholds one that is owed. The listing decides nothing, so the same spend buys a fresher column in a report and no correctness at all.
106
+ The orchestrator poll spends that same read per open pull request and repeats it on a three-minute timer, which makes it the heaviest caller here rather than an exception to the paragraph above. Six open pull requests is around 120 remote reads an hour. It now spends a third read per pull request per tick besides, since `canon pr review-state` makes its own `gh pr view` call beside the payload the poll already fetched. That one is an API round trip rather than a remote ref read, and it buys a coverage test that answers about what a session read. What separates the two cases is what each reading decides rather than what it costs. The poll's head fires the review trigger, so a stale one sends a pass at a commit nobody read or withholds one that is owed. The listing decides nothing, so the same spend buys a fresher column in a report and no correctness at all.
72
107
 
73
108
  `canon pr key-changes` also stays on the object, for a different reason. It reads the body, the file list, and `headRefOid` in one call on purpose, so the three describe one commit. That is a consistency requirement rather than a freshness one, and keying its head elsewhere would break it.
@@ -72,6 +72,36 @@ Skills branch on the reason rather than on the exit code:
72
72
  canon tasks archive --pull-request 673 --json | jq -r 'if .ok then .task else .reason end'
73
73
  ```
74
74
 
75
+ ## Decline
76
+
77
+ `canon tasks decline <task>` moves a task decided against from `.canon/tasks/` into `.canon/tasks/declined/`, clears whichever of `priority.md` or `backlog.md` holds its row, and regenerates the board index. Unlike `canon tasks archive`, it carries no outcome-state gate: a task can be decided against at any outcome state.
78
+
79
+ ```bash
80
+ canon tasks decline v28.1-trigger-escalation --reason "superseded by v30.2" # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task
81
+ canon tasks decline v28.1-trigger-escalation --reason "no longer needed" --by Alex --json
82
+ ```
83
+
84
+ | Option | Behavior |
85
+ | ----------------- | ------------------------------------------------- |
86
+ | `--reason <text>` | Why the task was decided against, required |
87
+ | `--by <name>` | Who decided, defaulting to `git config user.name` |
88
+ | `--json` | Emit a machine-readable record on stdout |
89
+ | `--root <path>` | Board root, defaulting to the main worktree |
90
+
91
+ Exit codes: `0` declined, `1` refused. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, or `bad-input`. `bad-input` covers a missing `--reason` and a `--by` that resolves to nobody, git config included.
92
+
93
+ `DECLINE_REFUSALS` is kept apart from archive's own refusal set on purpose. Archive and decline answer different questions, shipped versus decided-against, and a shared gate would let one archive a task that cannot yet ship or decline one that already has.
94
+
95
+ The decision is written onto the task as a `Declined: <reason>, <who> on <YYYY-MM-DD>` line, anchored the same way `Pull request:` is, after the last origin line the task carries.
96
+
97
+ The task carries its plan with it the same way archive does, when the declining task is the last live citation. A declined task's plan lands in `.canon/plans/archive/`, indistinguishable from a shipped one by folder alone. The task file under `.canon/tasks/declined/` is what records which it was.
98
+
99
+ Skills branch on the reason rather than on the exit code, the same rule `canon tasks archive` states:
100
+
101
+ ```bash
102
+ canon tasks decline v28.1-trigger-escalation --reason "superseded by v30.2" --json | jq -r 'if .ok then .task else .reason end' # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task
103
+ ```
104
+
75
105
  ## Plan citations
76
106
 
77
107
  `canon tasks plan-citations <stem>` answers where a task's plan sits and which other live tasks hold it. It reports and never writes.
@@ -244,7 +274,7 @@ Seven checks run. Plan and Collisions reach one half each of the `## Run now` te
244
274
  | Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
245
275
  | Ordering | A `## Needs a plan` row whose stated position disagrees with where it actually sits, or which states no position at all |
246
276
  | Collisions | Two `## Run now` rows whose Touches columns name a path in common |
247
- | Blockers | A parked row whose blocker has stopped holding, or whose cited task resolves nowhere |
277
+ | Blockers | A parked row whose blocker has stopped holding, whose cited task resolves nowhere, or whose cited task was declined |
248
278
 
249
279
  Shape runs before any other check reads a row, since a row failing it carries no dependable fields for the rest to check. A blank or prose line closes the table above it, so the walk treats the next pipe line as a fresh header candidate rather than as a continuation. That candidate counts as a header only when the line behind it is a separator carrying the same cell count, and one that fails is `row-untabled`, stranded behind a table that already closed. Cell count still has to match the header on every row that clears that test, and a row whose count disagrees is `row-misshapen`, the shape a dropped pipe or a merged column produces.
250
280
 
@@ -264,7 +294,20 @@ The collision check is the one a person cannot run by eye. Paths come from the b
264
294
 
265
295
  Where a directory holds the other row's file, the finding names the row that claimed it, reading `both touch src/tasks, which v2.0-second claims as a folder.` The shared strings alone leave an over-broad cell and a genuine overlap identical, which is how a correct report was once read as the verb comparing folders rather than files. <!-- canon-allow-reference: illustrates the finding's own sentence shape, not a citation of a real task -->
266
296
 
267
- The blocker check re-takes a measurement the board records once and never repeats. Two of the five blocker kinds put a fact on disk: a dependency is settled by the cited task being archived or by its work reaching the trunk, and a collision is settled by nothing under `## Run now` still holding the file the cell cites.
297
+ The blocker check re-takes a measurement the board records once and never repeats. Two of the five blocker kinds put a fact on disk: a dependency is settled by the cited task being archived or by its work reaching the trunk, and a collision is settled by nothing under `## Run now` still holding the file the cell cites. A cited task resolving under `.canon/tasks/declined/` instead reports separately as `blocker-declined`, since a decided-against task is neither the shipped work `blocker-settled` reports nor the dangling pointer `blocker-unresolved` reports.
298
+
299
+ ```json
300
+ {
301
+ "findings": [
302
+ {
303
+ "kind": "blocker-declined",
304
+ "group": "Up next",
305
+ "subject": "v50.6-a-standard-no-skill-reads", // canon-allow-reference: shows the subject field's real vXX.Y-slug shape, not a citation of a real task
306
+ "message": "waits on v9.0-superseded, which was declined." // canon-allow-reference: illustrates the finding's own sentence shape, not a citation of a real task
307
+ }
308
+ ]
309
+ }
310
+ ```
268
311
 
269
312
  A closed outcome is not the fact the dependency half needs. The ship chain marks outcomes as its first step and opens the pull request several steps later, so a check reading the checkbox reports the row settled while the branch is still in review. A live task therefore settles the row only once it closed every outcome and carries a `Pull request:` line the trunk holds. One that names no pull request, and one whose number no trunk ref could answer for, land in the untested array below rather than being settled or left silent.
270
313
 
@@ -272,7 +315,7 @@ The trunk is read as the clone already holds it, `origin/main` first and local `
272
315
 
273
316
  Both halves gate on a citation inside the `Waiting on` cell, never on the columns beside it. The board format gives a collision cell the file held by the running task, so a row whose cell names no file was parked by something else, and testing its Touches column instead reports a cleared collision on a row no collision ever parked while counting that row as re-tested. A cited task is a bare sibling link, the way the Task column spells one, so a pointer into another folder names a plan rather than a task and settles nothing. A cited task carrying no outcome box settles nothing either, since a file the check could not parse is not evidence of a finished one.
274
317
 
275
- A citation resolving in neither the board nor the archive is `blocker-unresolved` rather than a settled row. Reading an absent file as archived states a specific fact about a file nobody ever wrote, which is what a renamed task or a typo produces, and only a task that genuinely closed releases the row waiting on it.
318
+ A citation resolving in none of the board, the archive, or the declined folder is `blocker-unresolved` rather than a settled row. Reading an absent file as archived or declined states a specific fact about a file nobody ever wrote, which is what a renamed task or a typo produces, and only a task that genuinely closed or was genuinely declined releases the row waiting on it.
276
319
 
277
320
  The other three kinds rest on a person's judgment, so a row neither half reached lands in a second array rather than in the findings:
278
321
 
@@ -80,7 +80,7 @@ When features are independent, run them in parallel instead of sequentially. Use
80
80
  - Invoke `canon:plan-feature` in each session. Plans land at the main worktree root as `.canon/plans/feature-<slug>.md`, one per feature, no collisions. Small features stay in chat and skip the file.
81
81
  - Implement, verify, and review each feature independently. `review-branch` writes a per-branch report at the main worktree root (`review/branch/review-<slug>.md`), and `ui-test` writes a per-branch checklist handoff there too (`tmp/ui-checklist/<slug>.md`) that `git-pr` posts to the pull request and removes, so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
82
82
  - Ship each worktree separately with `canon:git-ship`
83
- - For full autonomy per worktree, invoke `canon:auto-ship` instead of the manual chain. Approve the plan, walk away, come back to a pull request the chain marked as a draft and then read the flag back on. The mark says the work has had no review yet, and it holds no window, since readying a pull request to merge lifts it and is the operator's act.
83
+ - For full autonomy per worktree, invoke `canon:auto-ship` instead of the manual chain. Approve the plan, walk away, come back to a pull request the chain marked as a draft and then read the flag back on. The mark says the work has had no review yet, and it holds no window, since readying a pull request to merge lifts it directly, an act reserved to the operator or to the controlling session that closed the review.
84
84
 
85
85
  To run several worktrees as a coordinated flow rather than ad hoc, assert the orchestrator role in one warm session with `canon:role-orchestrator`. It holds the cross-feature call, plans each feature itself or dispatches a cold planner under `canon:role-planner` to write the plan, refills the ready queue so a free worker never waits, and reviews each worker's PR with `canon:review-pr`, then tells the session holding that branch to run `canon:review-address` whenever the pass posted a finding at any severity, which is the same threshold `canon:review-pr` states and posts its open heading under. The human launches workers and merges. See [operating model](operating-model.md) for the full loop.
86
86
 
@@ -289,23 +289,23 @@ This section is the corpus the coverage claim is measured against: every name `c
289
289
 
290
290
  ### Generate an artifact on demand
291
291
 
292
- | Skill | When to use |
293
- | -------------------------- | ------------------------------------------------------------------------------------------------- |
294
- | `canon:create-rule` | For a project-specific governance rule the toolkit does not ship |
295
- | `canon:create-skill` | For a new `SKILL.md` |
296
- | `canon:create-snippet` | For a reusable prompt |
297
- | `canon:create-standard` | For a new authoring convention |
298
- | `canon:draft-docs` | For a brand-new `docs/*.md` page, drafted against `standards/docs.md` |
299
- | `canon:draft-context` | For a brand-new `.claude/context/<domain>.md` entry, drafted against `standards/context.md` |
300
- | `canon:draft-wireframes` | For a brand-new `.claude/wireframes/<surface>.md` file, drafted against `standards/wireframes.md` |
301
- | `canon:draft-readme` | For a project's `README.md`, drafted against `standards/readme.md` |
302
- | `canon:bash-script` | For an interactive, human-facing shell tool |
303
- | `canon:bash-cli-script` | For a non-interactive automation, CI, or pipeline script |
304
- | `canon:ci-workflow` | For a GitHub Actions workflow file |
305
- | `canon:canon-slides-draft` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
306
- | `canon:canon-screencast` | For a recording script with beats and defaults already seeded |
307
- | `canon:canon-record` | For compiling and running a screencast draft into a recording and a still |
308
- | `canon:identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
292
+ | Skill | When to use |
293
+ | ------------------------- | ------------------------------------------------------------------------------------------------- |
294
+ | `canon:create-rule` | For a project-specific governance rule the toolkit does not ship |
295
+ | `canon:create-skill` | For a new `SKILL.md` |
296
+ | `canon:create-snippet` | For a reusable prompt |
297
+ | `canon:create-standard` | For a new authoring convention |
298
+ | `canon:draft-docs` | For a brand-new `docs/*.md` page, drafted against `standards/docs.md` |
299
+ | `canon:draft-context` | For a brand-new `.claude/context/<domain>.md` entry, drafted against `standards/context.md` |
300
+ | `canon:draft-wireframes` | For a brand-new `.claude/wireframes/<surface>.md` file, drafted against `standards/wireframes.md` |
301
+ | `canon:draft-readme` | For a project's `README.md`, drafted against `standards/readme.md` |
302
+ | `canon:bash-script` | For an interactive, human-facing shell tool |
303
+ | `canon:bash-cli-script` | For a non-interactive automation, CI, or pipeline script |
304
+ | `canon:ci-workflow` | For a GitHub Actions workflow file |
305
+ | `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
306
+ | `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
307
+ | `canon:record-screencast` | For compiling and running a screencast draft into a recording and a still |
308
+ | `canon:draft-identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
309
309
 
310
310
  ### Answer a question at any point
311
311
 
@@ -314,7 +314,7 @@ This section is the corpus the coverage claim is measured against: every name `c
314
314
  | `canon:canon-cli` | Before running an unfamiliar verb, a sync, or an install, to learn which command to run, which reference doc covers it, or what it overwrites, merges, or leaves alone |
315
315
  | `canon:index-lookup` | To find where a topic is documented across the tracked `index.md` catalogs |
316
316
  | `canon:youtube-transcripts` | When a video transcript is wanted in the repo as context |
317
- | `canon:canon-frames-read` | To read a recorded demo back frame by frame and report what each one shows, with no verdict on whether the recording looks right |
317
+ | `canon:read-frames` | To read a recorded demo back frame by frame and report what each one shows, with no verdict on whether the recording looks right |
318
318
  | `canon:teach-workspace` | To learn a subject across sessions, in a workspace that holds the progress |
319
319
  | `canon:write-human` | Before drafting or revising prose, for voice, rhythm, and density |
320
320
  | `canon:restate-plainly` | When an answer or a document has to be read again in plain words |
@@ -40,7 +40,7 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
40
40
  - `canon:ux-audit` for UX gap detection on existing surfaces
41
41
  - `canon:ux-measure` for what a running surface costs to paint, read against published thresholds
42
42
  - `canon:draft-and-pick` for a call settled by looking, drafting several candidates onto one page and taking your pick
43
- - `canon:identity` to draft a project's logo mark and compose it into an icon sequence and a social card, through `draft-and-pick`'s own render-and-pick loop
43
+ - `canon:draft-identity` to draft a project's logo mark and compose it into an icon sequence and a social card, through `draft-and-pick`'s own render-and-pick loop
44
44
  - Anthropic's `frontend-design` plugin optional for light visual steering
45
45
 
46
46
  ### When to pick
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.69.0",
4
+ "version": "4.71.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -44,7 +44,8 @@
44
44
  "web:tokens": "bun src/cli.ts design css --no-components > web/src/styles/tokens.css.new && (echo '/* Generated by `canon design css --no-components`. Regenerate with `bun run web:tokens`. Do not hand-edit. */'; cat web/src/styles/tokens.css.new) > web/src/styles/tokens.css && rm web/src/styles/tokens.css.new",
45
45
  "web:dev": "cd web && astro dev",
46
46
  "web:favicon": "bun scripts/core/regen-web-favicon.ts",
47
- "web:build": "bun run web:tokens && bun run web:favicon && cd web && astro check && astro build",
47
+ "web:previews": "bun scripts/core/regen-web-previews.ts",
48
+ "web:build": "bun run web:tokens && bun run web:favicon && bun run web:previews && cd web && astro check && astro build",
48
49
  "web:preview": "cd web && astro preview",
49
50
  "web:e2e": "cd web && playwright test"
50
51
  },