@erclx/canon 4.52.0 → 4.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.52.0",
4
+ "version": "4.53.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -150,12 +150,17 @@ the regen rebuilt, and close the same way. The heading stays outside the
150
150
  `## Review` family so the close-out's equality test on the first line never
151
151
  matches it.
152
152
 
153
- Before posting, run the scan in
154
- `${CLAUDE_SKILL_DIR}/../../standards/publish.md`
155
- against the reply. The hook skips `.canon/tmp/`, so this scan is the
156
- only gate on the published reply. Post it to the PR and capture the posted
157
- comment's id, since Step 7 edits this exact comment rather than trusting
158
- whichever one `gh` considers last:
153
+ Before posting, follow `${CLAUDE_SKILL_DIR}/../../standards/publish.md`
154
+ and run its scan against the reply:
155
+
156
+ ```bash
157
+ canon labels scan --body-file .canon/tmp/address-review/reply-<number>.md
158
+ ```
159
+
160
+ The hook skips `.canon/tmp/`, so this scan is the only gate on the published
161
+ reply. Fix any hit the standard names, then post the reply to the PR and
162
+ capture the posted comment's id, since Step 7 edits this exact comment rather
163
+ than trusting whichever one `gh` considers last:
159
164
 
160
165
  ```bash
161
166
  comment_url=$(gh pr comment <number> --body-file .canon/tmp/address-review/reply-<number>.md)
@@ -182,13 +187,25 @@ carries none is false on a surface nothing else checks:
182
187
  printf '\nāœ… Rebased onto origin/main, CI green. No review findings were open.\n' >> .canon/tmp/address-review/reply-<number>.md
183
188
  ```
184
189
 
185
- Re-run the `${CLAUDE_SKILL_DIR}/../../standards/publish.md` scan against the
186
- updated file, since the appended line is new content the Step 6 scan never saw.
190
+ Re-run the scan against the updated file, since the appended line is new
191
+ content the Step 6 scan never saw:
192
+
193
+ ```bash
194
+ canon labels scan --body-file .canon/tmp/address-review/reply-<number>.md
195
+ ```
196
+
187
197
  Then edit the exact comment Step 6 posted, read back from the id it saved,
188
- rather than posting a second comment:
198
+ rather than posting a second comment. A shell variable assigned from `cat` and
199
+ handed to `gh` still carries a runtime value the isolation rule cannot verify,
200
+ so read the id as its own plain command and write the printed value as a
201
+ literal in the `gh api` call rather than a substitution or a variable:
202
+
203
+ ```bash
204
+ cat .canon/tmp/address-review/reply-<number>.id
205
+ ```
189
206
 
190
207
  ```bash
191
- gh api -X PATCH "repos/{owner}/{repo}/issues/comments/$(cat .canon/tmp/address-review/reply-<number>.id)" \
208
+ gh api -X PATCH "repos/{owner}/{repo}/issues/comments/<id printed above>" \
192
209
  -F body=@.canon/tmp/address-review/reply-<number>.md
193
210
  ```
194
211
 
@@ -226,7 +243,7 @@ Do not merge. Hand back to the orchestrator for re-review.
226
243
 
227
244
  ## Post-review findings
228
245
 
229
- Not everything worth reaching the reviewing session surfaces inside the numbered flow above. A worker that settled a risk, filed a follow-up, or found something else worth reporting after Step 7 already closed the review posts it directly rather than waiting on a review pass that has nothing left to trigger it. Write the body the way Step 6 writes a reply: load `write-human` for voice, follow `${CLAUDE_SKILL_DIR}/../../standards/markdown.md` for the banned words, and run the `${CLAUDE_SKILL_DIR}/../../standards/publish.md` scan before posting.
246
+ Not everything worth reaching the reviewing session surfaces inside the numbered flow above. A worker that settled a risk, filed a follow-up, or found something else worth reporting after Step 7 already closed the review posts it directly rather than waiting on a review pass that has nothing left to trigger it. Write the body the way Step 6 writes a reply: load `write-human` for voice, follow `${CLAUDE_SKILL_DIR}/../../standards/markdown.md` for the banned words, and run the `${CLAUDE_SKILL_DIR}/../../standards/publish.md` scan before posting with `canon labels scan --body-file .canon/tmp/address-review/reply-<number>.md`.
230
247
 
231
248
  Open with `## Post-review findings` rather than `## Review response`, since nothing on the thread is being answered. `claude-pr-review` states the full heading set this belongs to and routes it the same as a response: `claude-orchestrate`'s poll picks it up and sends the reviewing session back for a pass. Close the body with `šŸ¤– Addressed by Claude Code` on its own line, matching the reply's footer.
232
249
 
@@ -26,7 +26,7 @@ This gate runs ahead of the two collision checks because it is the cheapest read
26
26
 
27
27
  It also reads the plan rather than a cell describing one, which is the input the gate below it does not have. The disjointness gate compares the sets a dispatcher wrote into the constraints and the Touches column, so a cell omitting a file clears a check the tree would fail. That happened on 2026-08-31, when two rows were cleared against each other with one constraints block leaving out the context entry both were about to write, and what caught it was a worker sending a message rather than any check.
28
28
 
29
- A blank `- Answer:` is not an unanswered question. `${CLAUDE_SKILL_DIR}/../../standards/plan.md` fixes an empty slot as accepting the `- Suggested:` line above it, which is what makes a plan decision-ready in one pass. The narrow case this reads is `- Suggested: needs your call, <why>` over an empty slot, the form that same standard writes where the answer turns on preference rather than on a technical default. A gate reading every blank slot as open would refuse every plan in the folder.
29
+ A blank `- Answer:` is not an unanswered question. `${CLAUDE_SKILL_DIR}/../../standards/plan.md` fixes an empty slot as accepting the `- Suggested:` line above it, which is what makes a plan decision-ready in one pass. The narrow case this reads is `- Suggested: needs your call, <why>` and its two demonstrated paraphrases, `needs operator's call` and `needs the operator's call`, over an empty slot, the form that same standard writes where the answer turns on preference rather than on a technical default. A gate reading every blank slot as open would refuse every plan in the folder.
30
30
 
31
31
  What it prevents is a halt nobody is watching for. `claude-worker` instructs a session to stop on a question written as needing the operator's call, correctly and by its own body, so a dispatch that never reads the plan lands a worker in a wait for a person who does not know it is waiting. The worker's halt is not the defect, and the dispatch that made it necessary is.
32
32
 
@@ -9,84 +9,84 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
9
9
 
10
10
  ## Project-level
11
11
 
12
- | Command | Purpose |
13
- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
- | `canon init [path]` | Bootstrap a project with selected toolkit domains |
15
- | `canon sync [path]` | Sync all installed domains in a target project |
16
- | `canon sync --check` | Report toolkit drift and the installed version against the newest published (`--json`, `--exit-code`) |
17
- | `canon sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
18
- | `canon sandbox reset` | Reset sandbox to baseline |
19
- | `canon sandbox clean` | Wipe the sandbox |
20
- | `canon sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
21
- | `canon sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
22
- | `canon indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
- | `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
24
- | `canon design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
25
- | `canon design regen` | Rewrite this repository's `.claude/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
26
- | `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
27
- | `canon design install` | Install the base stylesheet into a project at `.claude/design/base.css` |
28
- | `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
29
- | `canon slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck, reporting any unrecognized layout name on stderr |
30
- | `canon slides list` | List the available slide layouts (`--json` for the catalog) |
31
- | `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 |
32
- | `canon transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
33
- | `canon tasks archive` | Move a shipped task and its plan off the board, clear its ordering row, and regenerate the index |
34
- | `canon tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
35
- | `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`) |
36
- | `canon tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
37
- | `canon tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
38
- | `canon intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
39
- | `canon intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
40
- | `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds and what its records schedule (`--json`) |
41
- | `canon teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
42
- | `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
43
- | `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
44
- | `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
45
- | `canon teach nav` | Rewrite the teach-root listing, a workspace's contents page, and each lesson's chrome from its four marker regions (`--json`) |
46
- | `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
47
- | `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
48
- | `canon records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
49
- | `canon records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
50
- | `canon records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
51
- | `canon migrate rename` | Rewrite every unprotected `aitk` token to `canon` and move the paths that carry the name, reporting the plan without `--write` (`--scope`, `--json`) |
52
- | `canon migrate records` | Move the gitignored session records to `.canon/` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
53
- | `canon migrate record-tree` | Repoint the old-root citations inside the records themselves, scoped to the live folders and reporting every line without `--write` (`--root`, `--json`) |
54
- | `canon migrate rule-layout` | Move a target's installed rules from the flat `.claude/rules/<subdir>/` layout to `.claude/rules/canon/<subdir>/`, reporting the plan without `--write` (`--root`, `--json`) |
55
- | `canon sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
56
- | `canon worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
57
- | `canon worktrees reclaim` | Remove every reclaimable worktree and the branch behind it, reporting without acting under `--dry-run` (`--json`) |
58
- | `canon comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
59
- | `canon context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
60
- | `canon markdown audit` | Fail any markdown path on a banned character, word, or spelling, or a dead relative link, and report the structural checkpoints |
61
- | `canon claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
62
- | `canon standards audit` | Report the corpus against the success-criterion gate `standards/standard.md` states, failing only on a standard new to the branch (`--json`, `--arrivals-only`) |
63
- | `canon claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
64
- | `canon claude skills reach` | Report the bodies in either skill corpus citing a toolkit path no target project receives, exiting 2 on an unqualified one |
65
- | `canon claude skills rank` | Score either skill corpus's descriptions against a case corpus by TF-IDF cosine similarity, reporting rank-one and top-three (`--cases <path>`) |
66
- | `canon claude routing` | Report per `CLAUDE.md` section how many bullets name a path and how many of those a path-scoped rule already covers (`--json`) |
67
- | `canon gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
68
- | `canon gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value and on the family stem behind a templated citation (`--json`) |
69
- | `canon gov restated` | Report every instruction the always-loaded file or a rule shares with the seed, a shipped skill body, or another rule, classed and with its anchors named (`--json`) |
70
- | `canon gov citations` | Resolve every path a rule cites and every internal frontmatter glob, failing on one reaching nothing (`--json`) |
71
- | `canon secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
72
- | `canon deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
73
- | `canon labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
74
- | `canon labels scan` | Fail a pull request or a posted review whose title, body, or review comment carries a phase label, a label a code span quotes, a gitignored record path, a session link, or a title word no dictionary holds (`--event`, `--json`) |
75
- | `canon autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
76
- | `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`) |
77
- | `canon pr head` | Compare the head a pull request object reports against the branch tip the remote carries, naming both shas (`--root`, `--json`) |
78
- | `canon pr checks` | Report the check runs belonging to the branch tip, reading pending for a tip carrying none rather than clean (`--root`, `--json`) |
79
- | `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`) |
80
- | `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`) |
81
- | `canon census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
82
- | `canon audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`, `--corpus`) |
83
- | `canon audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
84
- | `canon gate run` | Run every stage that guards a branch here, scoping shell, types, and tests to the changed set (`--all`, `--no-write`, `--nested`, `--json`) |
85
- | `canon inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
86
- | `canon drive <url> <run>` | Walk a page through named interactions and measure each state it reaches, reporting findings rather than gating (`--json`) |
87
- | `canon capture [source]` | Render HTML sources to PNG and prove the font each one declares resolved (`--selector` required, `--out`) |
88
- | `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--json`) |
89
- | `canon upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
12
+ | Command | Purpose |
13
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
+ | `canon init [path]` | Bootstrap a project with selected toolkit domains |
15
+ | `canon sync [path]` | Sync all installed domains in a target project |
16
+ | `canon sync --check` | Report toolkit drift and the installed version against the newest published (`--json`, `--exit-code`) |
17
+ | `canon sandbox [cat:cmd]` | Run sandbox scenarios (interactive or routed), toolkit-only like the tree it reads |
18
+ | `canon sandbox reset` | Reset sandbox to baseline |
19
+ | `canon sandbox clean` | Wipe the sandbox |
20
+ | `canon sandbox check` | Score a provisioned sandbox against a scenario expectation (`--json` for the verdict) |
21
+ | `canon sandbox coverage` | Report which scenarios declare expectations (`--json`, `--strict`, `--skills`) |
22
+ | `canon indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
+ | `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
24
+ | `canon design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
25
+ | `canon design regen` | Rewrite this repository's `.claude/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
26
+ | `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
27
+ | `canon design install` | Install the base stylesheet into a project at `.claude/design/base.css` |
28
+ | `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
29
+ | `canon slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck, reporting any unrecognized layout name on stderr |
30
+ | `canon slides list` | List the available slide layouts (`--json` for the catalog) |
31
+ | `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 |
32
+ | `canon transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
33
+ | `canon tasks archive` | Move a shipped task and its plan off the board, clear its ordering row, and regenerate the index |
34
+ | `canon tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
35
+ | `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`) |
36
+ | `canon tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
37
+ | `canon tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
38
+ | `canon intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
39
+ | `canon intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
40
+ | `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds and what its records schedule (`--json`) |
41
+ | `canon teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
42
+ | `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
43
+ | `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
44
+ | `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
45
+ | `canon teach nav` | Rewrite the teach-root listing, a workspace's contents page, and each lesson's chrome from its four marker regions (`--json`) |
46
+ | `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
47
+ | `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
48
+ | `canon records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
49
+ | `canon records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
50
+ | `canon records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
51
+ | `canon migrate rename` | Rewrite every unprotected `aitk` token to `canon` and move the paths that carry the name, reporting the plan without `--write` (`--scope`, `--json`) |
52
+ | `canon migrate records` | Move the gitignored session records to `.canon/` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
53
+ | `canon migrate record-tree` | Repoint the old-root citations inside the records themselves, scoped to the live folders and reporting every line without `--write` (`--root`, `--json`) |
54
+ | `canon migrate rule-layout` | Move a target's installed rules from the flat `.claude/rules/<subdir>/` layout to `.claude/rules/canon/<subdir>/`, reporting the plan without `--write` (`--root`, `--json`) |
55
+ | `canon sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
56
+ | `canon worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
57
+ | `canon worktrees reclaim` | Remove every reclaimable worktree and the branch behind it, reporting without acting under `--dry-run` (`--json`) |
58
+ | `canon comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
59
+ | `canon context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
60
+ | `canon markdown audit` | Fail any markdown path on a banned character, word, or spelling, or a dead relative link, and report the structural checkpoints |
61
+ | `canon claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
62
+ | `canon standards audit` | Report the corpus against the success-criterion gate `standards/standard.md` states, failing only on a standard new to the branch (`--json`, `--arrivals-only`) |
63
+ | `canon claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
64
+ | `canon claude skills reach` | Report the bodies in either skill corpus citing a toolkit path no target project receives, exiting 2 on an unqualified one |
65
+ | `canon claude skills rank` | Score either skill corpus's descriptions against a case corpus by TF-IDF cosine similarity, reporting rank-one and top-three (`--cases <path>`) |
66
+ | `canon claude routing` | Report per `CLAUDE.md` section how many bullets name a path and how many of those a path-scoped rule already covers (`--json`) |
67
+ | `canon gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
68
+ | `canon gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value and on the family stem behind a templated citation (`--json`) |
69
+ | `canon gov restated` | Report every instruction the always-loaded file or a rule shares with the seed, a shipped skill body, or another rule, classed and with its anchors named (`--json`) |
70
+ | `canon gov citations` | Resolve every path a rule cites and every internal frontmatter glob, failing on one reaching nothing (`--json`) |
71
+ | `canon secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
72
+ | `canon deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
73
+ | `canon labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
74
+ | `canon labels scan` | Fail a pull request or a posted review whose title, body, or review comment carries a phase label, a label a code span quotes, a gitignored record path, a session link, or a title word no dictionary holds (`--event`, `--body-file`, `--json`) |
75
+ | `canon autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
76
+ | `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`) |
77
+ | `canon pr head` | Compare the head a pull request object reports against the branch tip the remote carries, naming both shas (`--root`, `--json`) |
78
+ | `canon pr checks` | Report the check runs belonging to the branch tip, reading pending for a tip carrying none rather than clean (`--root`, `--json`) |
79
+ | `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`) |
80
+ | `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`) |
81
+ | `canon census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
82
+ | `canon audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`, `--corpus`) |
83
+ | `canon audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
84
+ | `canon gate run` | Run every stage that guards a branch here, scoping shell, types, and tests to the changed set (`--all`, `--no-write`, `--nested`, `--json`) |
85
+ | `canon inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
86
+ | `canon drive <url> <run>` | Walk a page through named interactions and measure each state it reaches, reporting findings rather than gating (`--json`) |
87
+ | `canon capture [source]` | Render HTML sources to PNG and prove the font each one declares resolved (`--selector` required, `--out`) |
88
+ | `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--json`) |
89
+ | `canon upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
90
90
 
91
91
  `canon serve` drives no browser, which is what separates it from the four that do. All four ship now that `capture` does, so the line between them is the engine rather than the package. A generated page loses its script to an editor preview and to a `file://` open, so the link is the delivery rather than a convenience, and every generated surface here reaches a reader through one. A teach lesson's stylesheet is embedded rather than linked, so only its script still needs a server. It binds `127.0.0.1` and never a wildcard, because what it is pointed at is routinely a gitignored record tree. It sends `cache-control: no-store`, since a preview exists to be edited and reloaded and a cached stylesheet reads as a fix that did not work.
92
92
 
@@ -92,7 +92,7 @@ A relative path resolves against the project root first and against `.canon/task
92
92
 
93
93
  The record carries `launchable` and `open`, the questions still waiting, each with the `label` that names it and the `why` its suggestion gave for needing a person. Exit codes: `0` launchable, `1` refused with `no-plan`, `archived`, or `bad-input`, `2` waiting on the operator.
94
94
 
95
- A blank `- Answer:` is not a waiting question. The plan standard fixes an empty slot as accepting the `- Suggested:` line above it, so the one shape this reads is `- Suggested: needs your call, <why>` over an empty slot, which is what that standard writes where the answer turns on preference rather than on a technical default. A verb reading every blank slot as open would report every plan in the folder.
95
+ A blank `- Answer:` is not a waiting question. The plan standard fixes an empty slot as accepting the `- Suggested:` line above it, so the shapes this reads are `- Suggested: needs your call, <why>` and the two demonstrated paraphrases, `needs operator's call` and `needs the operator's call`, over an empty slot, which is what that standard writes where the answer turns on preference rather than on a technical default. A verb reading every blank slot as open would report every plan in the folder.
96
96
 
97
97
  The question block is read through the same parser `canon tasks validate` runs, so the gate and the conformance check cannot drift into disagreeing about what a question is. A question carrying no suggestion at all is that check's finding rather than this one's, and it goes unread here.
98
98
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.52.0",
4
+ "version": "4.53.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -17,6 +17,7 @@ interface ScanOptions {
17
17
  readonly event?: string
18
18
  readonly title?: string
19
19
  readonly body?: string
20
+ readonly bodyFile?: string
20
21
  readonly head?: string
21
22
  readonly json?: boolean
22
23
  }
@@ -100,6 +101,10 @@ export function register(program: Command): void {
100
101
  )
101
102
  .option('--title <text>', 'Title to scan, overriding the event payload')
102
103
  .option('--body <text>', 'Body to scan, overriding the event payload')
104
+ .option(
105
+ '--body-file <path>',
106
+ 'Body to scan, read from a file, overriding the event payload',
107
+ )
103
108
  .option('--head <ref>', 'Head branch, overriding the event payload')
104
109
  .option('--json', 'Add a machine-readable record on stdout')
105
110
  .addHelpText(
@@ -146,6 +151,7 @@ export function register(program: Command): void {
146
151
  'Examples:',
147
152
  ' canon labels scan --event "$GITHUB_EVENT_PATH"',
148
153
  ' canon labels scan --title "feat: x" --body "planned under v68.5" --head feat/x',
154
+ ' canon labels scan --body-file reply.md',
149
155
  '',
150
156
  ].join('\n'),
151
157
  )
@@ -209,9 +209,11 @@ export function register(program: Command): void {
209
209
  ' 2 the plan waits on the operator, and open names every slot',
210
210
  '',
211
211
  'A blank Answer accepts the Suggested line above it, so only',
212
- '`- Suggested: needs your call, <why>` over an empty slot is a stop.',
213
- 'It reports and never writes. Branch on launchable rather than on the',
214
- 'exit code, which a shell function wrapping canon can flatten to zero.',
212
+ '`- Suggested: needs your call, <why>` and its two demonstrated',
213
+ "paraphrases, needs operator's call and needs the operator's call,",
214
+ 'over an empty slot are a stop. It reports and never writes.',
215
+ 'Branch on launchable rather than on the exit code, which a shell',
216
+ 'function wrapping canon can flatten to zero.',
215
217
  '',
216
218
  'A relative path resolves against the project root first and against',
217
219
  '.canon/tasks/ second, so the ../plans/ link a board row writes works.',
@@ -6,6 +6,8 @@ export type ScanInputRefusal =
6
6
  | 'unreadable-event'
7
7
  | 'not-a-pull-request'
8
8
  | 'unreadable-review'
9
+ | 'conflicting-body-input'
10
+ | 'unreadable-body-file'
9
11
 
10
12
  export type ResolvedScanInput =
11
13
  | {
@@ -26,6 +28,7 @@ export interface ScanInputOptions {
26
28
  readonly event?: string
27
29
  readonly title?: string
28
30
  readonly body?: string
31
+ readonly bodyFile?: string
29
32
  readonly head?: string
30
33
  }
31
34
 
@@ -46,6 +49,27 @@ export function resolveScanInput(opts: ScanInputOptions): ResolvedScanInput {
46
49
  let headRefName = opts.head
47
50
  let source: 'pull-request' | 'review' = 'pull-request'
48
51
 
52
+ if (opts.bodyFile !== undefined) {
53
+ if (body !== undefined) {
54
+ return {
55
+ kind: 'refused',
56
+ reason: 'conflicting-body-input',
57
+ message:
58
+ '--body and --body-file cannot both be given, since only one text can be scanned.',
59
+ }
60
+ }
61
+
62
+ try {
63
+ body = readFileSync(opts.bodyFile, 'utf8')
64
+ } catch {
65
+ return {
66
+ kind: 'refused',
67
+ reason: 'unreadable-body-file',
68
+ message: `${opts.bodyFile} could not be read, so no body was there to scan.`,
69
+ }
70
+ }
71
+ }
72
+
49
73
  if (opts.event !== undefined) {
50
74
  let raw: string
51
75
  try {
@@ -72,6 +72,7 @@ export const FINDING_KINDS = [
72
72
  'closing-partial',
73
73
  'item-incomplete',
74
74
  'category-mismatch',
75
+ 'operator-call-phrasing',
75
76
  ] as const
76
77
 
77
78
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -157,6 +158,53 @@ export function isSharedScratch(kind: RecordKind): boolean {
157
158
  const NONE_IDENTIFIED = 'None identified.'
158
159
  const NUMBERED_FILE = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
159
160
 
161
+ /**
162
+ * The suggestion the plan standard fixes for a question that turns on the
163
+ * operator's preference rather than on a technical default. `src/tasks/answers.ts`
164
+ * reads this alongside `normalizeOperatorCall` so the dispatch gate and this
165
+ * write-time check cannot drift into recognizing different spellings.
166
+ */
167
+ export const OPERATOR_CALL = 'needs your call'
168
+
169
+ /**
170
+ * The two demonstrated paraphrases of `OPERATOR_CALL`, longer variant first so
171
+ * it is not read as a shorter match sitting inside it: `operator's call` is a
172
+ * substring of `the operator's call`, and checking it first would rewrite the
173
+ * `the` into place with the wrong phrase on either side.
174
+ */
175
+ const OPERATOR_CALL_PHRASES = [
176
+ "the operator's call",
177
+ "operator's call",
178
+ ] as const
179
+
180
+ /**
181
+ * Reads a recognized paraphrase as the canonical phrase, so a caller testing
182
+ * `startsWith(OPERATOR_CALL)` sees one spelling regardless of which wording the
183
+ * author used. Case-insensitive, since the corpus capitalizes a suggestion's
184
+ * first word, and it rewrites only the matched span so the reason text either
185
+ * side of it survives untouched.
186
+ *
187
+ * Short-circuits on an already-canonical opening before scanning the rest of
188
+ * the text. Without that, a suggestion already opening with `needs your call`
189
+ * that goes on to mention a paraphrase inside its own reason, such as
190
+ * `needs your call, since the operator's call outranks a default`, has that
191
+ * later occurrence rewritten too, which garbles the reason the dispatcher
192
+ * reports rather than leaving it as the author wrote it.
193
+ */
194
+ export function normalizeOperatorCall(text: string): string {
195
+ const lower = text.toLowerCase()
196
+ if (lower.startsWith(OPERATOR_CALL)) return text
197
+
198
+ for (const phrase of OPERATOR_CALL_PHRASES) {
199
+ const at = lower.indexOf(phrase)
200
+ if (at !== -1) {
201
+ return `${text.slice(0, at)}your call${text.slice(at + phrase.length)}`
202
+ }
203
+ }
204
+
205
+ return text
206
+ }
207
+
160
208
  function finding(
161
209
  kind: FindingKind,
162
210
  record: string,
@@ -295,6 +343,9 @@ function shorten(label: string): string {
295
343
  return label.length > 60 ? `${label.slice(0, 57)}...` : label
296
344
  }
297
345
 
346
+ const SUGGESTED_PREFIX = '- Suggested:'
347
+ const ANSWER_PREFIX = '- Answer:'
348
+
298
349
  function checkQuestionContract(name: string, lines: string[]): Finding[] {
299
350
  if (lines.some((line) => line.trim() === NONE_IDENTIFIED)) return []
300
351
 
@@ -302,8 +353,12 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
302
353
 
303
354
  for (const question of readQuestions(lines)) {
304
355
  const subject = shorten(question.label)
356
+ const suggested = question.body.find((line) =>
357
+ line.startsWith(SUGGESTED_PREFIX),
358
+ )
359
+ const answer = question.body.find((line) => line.startsWith(ANSWER_PREFIX))
305
360
 
306
- if (!question.body.some((line) => line.startsWith('- Suggested:'))) {
361
+ if (!suggested) {
307
362
  findings.push(
308
363
  finding(
309
364
  'suggestion-missing',
@@ -314,7 +369,7 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
314
369
  )
315
370
  }
316
371
 
317
- if (!question.body.some((line) => line.startsWith('- Answer:'))) {
372
+ if (!answer) {
318
373
  findings.push(
319
374
  finding(
320
375
  'question-unanswerable',
@@ -324,6 +379,29 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
324
379
  ),
325
380
  )
326
381
  }
382
+
383
+ if (
384
+ suggested &&
385
+ answer &&
386
+ answer.slice(ANSWER_PREFIX.length).trim() === ''
387
+ ) {
388
+ const text = suggested.slice(SUGGESTED_PREFIX.length).trim()
389
+ const normalized = normalizeOperatorCall(text)
390
+
391
+ if (
392
+ normalized.toLowerCase().startsWith(OPERATOR_CALL) &&
393
+ !text.toLowerCase().startsWith(OPERATOR_CALL)
394
+ ) {
395
+ findings.push(
396
+ finding(
397
+ 'operator-call-phrasing',
398
+ name,
399
+ subject,
400
+ 'defers to the operator over a blank Answer without opening with the canonical needs your call, so a paraphrase reaches the corpus instead of the fixed spelling.',
401
+ ),
402
+ )
403
+ }
404
+ }
327
405
  }
328
406
 
329
407
  return findings
@@ -3,20 +3,17 @@ import { readFile } from 'node:fs/promises'
3
3
  import { isAbsolute, relative, resolve } from 'node:path'
4
4
  import { isUnder } from '@/paths'
5
5
  import { recordDir, recordDirs } from '@/record-root'
6
- import { readQuestions, splitPlanSections } from '@/records/validate'
6
+ import {
7
+ normalizeOperatorCall,
8
+ OPERATOR_CALL,
9
+ readQuestions,
10
+ splitPlanSections,
11
+ } from '@/records/validate'
7
12
 
8
13
  const PLANS = 'plans'
9
14
  const TASKS = 'tasks'
10
15
  const ARCHIVE = 'archive'
11
16
 
12
- /**
13
- * The suggestion the plan standard fixes for a question that turns on the
14
- * operator's preference rather than on a technical default. Every other
15
- * suggestion is accepted by a blank slot, so only this phrase over an empty
16
- * `- Answer:` is a stop.
17
- */
18
- const OPERATOR_CALL = 'needs your call'
19
-
20
17
  const SUGGESTED_PREFIX = '- Suggested:'
21
18
  const ANSWER_PREFIX = '- Answer:'
22
19
 
@@ -112,9 +109,15 @@ function isAnswered(body: readonly string[]): boolean {
112
109
  * behind a full stop, so both separators come off. Reporting the phrase with
113
110
  * whatever punctuation followed it hands the operator a stray mark where the
114
111
  * reason should start.
112
+ *
113
+ * Takes the normalized suggestion rather than the raw one, so the length
114
+ * stripped from the front is always `OPERATOR_CALL`'s own regardless of which
115
+ * recognized wording the author wrote. The `operator's call` and
116
+ * `the operator's call` variants read longer than `your call`, and slicing by
117
+ * the canonical length against the raw text would cut into the reason itself.
115
118
  */
116
- function reasonOf(suggested: string): string {
117
- const rest = suggested.slice(OPERATOR_CALL.length).replace(/^[,.;:\s]+/, '')
119
+ function reasonOf(normalized: string): string {
120
+ const rest = normalized.slice(OPERATOR_CALL.length).replace(/^[,.;:\s]+/, '')
118
121
 
119
122
  return rest.length > 0 ? rest : 'no reason stated'
120
123
  }
@@ -130,10 +133,13 @@ function openQuestions(lines: readonly string[]): OpenQuestion[] {
130
133
 
131
134
  for (const question of readQuestions(lines)) {
132
135
  const suggested = suggestionOf(question.body)
133
- if (!suggested?.toLowerCase().startsWith(OPERATOR_CALL)) continue
136
+ if (!suggested) continue
137
+
138
+ const normalized = normalizeOperatorCall(suggested)
139
+ if (!normalized.toLowerCase().startsWith(OPERATOR_CALL)) continue
134
140
  if (isAnswered(question.body)) continue
135
141
 
136
- open.push({ label: question.label, why: reasonOf(suggested) })
142
+ open.push({ label: question.label, why: reasonOf(normalized) })
137
143
  }
138
144
 
139
145
  return open
@@ -6,6 +6,7 @@ const baseURL = `http://localhost:${4321 + (Number(process.env.WORKTREE_PORT_OFF
6
6
  export default defineConfig({
7
7
  testDir: 'e2e',
8
8
  forbidOnly: isCI,
9
+ // Absorbs shared-runner noise on a fresh scaffold with no flake history, at the cost of hiding a real defect until someone reads the run summary.
9
10
  retries: isCI ? 2 : 0,
10
11
  // No override here: Playwright's own CPU-derived default suits every CI runner
11
12
  reporter: isCI ? 'list' : 'html',
@@ -19,7 +20,9 @@ export default defineConfig({
19
20
  { name: 'webkit', use: { ...devices['Desktop Safari'] } },
20
21
  ],
21
22
  webServer: {
22
- command: 'bun run build && bun run preview',
23
+ command: process.env.DIST_PREBUILT
24
+ ? 'bun run preview'
25
+ : 'bun run build && bun run preview',
23
26
  url: baseURL,
24
27
  reuseExistingServer: false,
25
28
  env: { ASTRO_PREVIEW_BACKGROUND: '0' },
@@ -19,7 +19,7 @@ The astro stack covers Astro + TypeScript projects: content sites, marketing sit
19
19
 
20
20
  - `astro.config.mjs`: `@astrojs/react` integration, `@tailwindcss/vite` in `vite.plugins`, `@/` path alias via `vite.resolve.alias`, `ASTRO_SITE` env for the `site` field. Port `4321` plus `WORKTREE_PORT_OFFSET` at `server.port`, with `strictPort` under `vite.server` and `vite.preview`. Astro merges the user's `vite` block into the config backing both its dev and its static preview server, and feeds `server.port` through as the preview port, so the port sits at the top level while the bind guarantee sits under `vite`.
21
21
  - `vitest.config.ts`: uses `getViteConfig` from `astro/config` (not `mergeConfig`). jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `**/*.astro` in coverage excludes.
22
- - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`.
22
+ - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`. `DIST_PREBUILT` set in the environment drops the `build` half, running `bun run preview` alone against a `dist/` a prior CI job already produced.
23
23
  - `tsconfig.json`: extends `astro/tsconfigs/strict`, adds `skipLibCheck`, `vitest/globals` and `@testing-library/jest-dom` in types, `@/` paths.
24
24
  - `eslint.config.js`: overrides the web layer. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). `src/pages/**` exempt from filename and folder naming conventions because Astro's file-based routing ties names to URL segments.
25
25
 
@@ -6,6 +6,7 @@ const baseURL = `http://localhost:${5173 + (Number(process.env.WORKTREE_PORT_OFF
6
6
  export default defineConfig({
7
7
  testDir: 'e2e',
8
8
  forbidOnly: isCI,
9
+ // Absorbs shared-runner noise on a fresh scaffold with no flake history, at the cost of hiding a real defect until someone reads the run summary.
9
10
  retries: isCI ? 2 : 0,
10
11
  // No override here: Playwright's own CPU-derived default suits every CI runner
11
12
  reporter: isCI ? 'list' : 'html',
@@ -5,6 +5,10 @@ on:
5
5
  branches: [main]
6
6
  workflow_dispatch:
7
7
 
8
+ concurrency:
9
+ group: verify-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
8
12
  jobs:
9
13
  static-checks:
10
14
  name: šŸ” Static Checks
@@ -90,6 +94,13 @@ jobs:
90
94
  - name: Build
91
95
  run: bun run build
92
96
 
97
+ - name: Upload Build Output
98
+ uses: actions/upload-artifact@v4
99
+ with:
100
+ name: build-output
101
+ path: dist/
102
+ retention-days: 1
103
+
93
104
  e2e-tests:
94
105
  name: šŸŽ­ E2E Tests
95
106
  runs-on: ubuntu-latest
@@ -111,18 +122,29 @@ jobs:
111
122
  - name: Install Dependencies
112
123
  run: bun install --frozen-lockfile
113
124
 
125
+ - name: Download Build Output
126
+ uses: actions/download-artifact@v4
127
+ with:
128
+ name: build-output
129
+ path: dist/
130
+
131
+ - name: Install Playwright System Dependencies
132
+ run: bunx playwright install-deps chromium
133
+
114
134
  - name: Cache Playwright Browsers
115
135
  id: playwright-cache
116
136
  uses: actions/cache@v4
117
137
  with:
118
138
  path: ~/.cache/ms-playwright
119
- key: playwright-${{ runner.os }}-${{ hashFiles('**/package.json') }}
139
+ key: playwright-chromium-${{ runner.os }}-${{ hashFiles('**/package.json') }}
120
140
 
121
141
  - name: Install Playwright Chromium
122
142
  if: steps.playwright-cache.outputs.cache-hit != 'true'
123
143
  run: bunx playwright install chromium
124
144
 
125
145
  - name: Run E2E Tests
146
+ env:
147
+ DIST_PREBUILT: 'true'
126
148
  run: bun run test:e2e --project=chromium
127
149
 
128
150
  - name: Upload E2E Report
@@ -24,10 +24,29 @@ const CASES: CaptureCase[] = [
24
24
  },
25
25
  ]
26
26
 
27
+ const args = process.argv.slice(2)
28
+ const checkConsoleClean = args.includes('--check-console-clean')
29
+ const requireBaseUrl = args.includes('--require-base-url')
30
+
31
+ if (requireBaseUrl && !process.env.SCREENSHOT_BASE_URL) {
32
+ console.error('SCREENSHOT_BASE_URL is required with --require-base-url')
33
+ process.exit(1)
34
+ }
35
+
27
36
  const BASE_URL = process.env.SCREENSHOT_BASE_URL ?? 'http://localhost:4173'
28
- const OUT_DIR = 'screenshots'
37
+
38
+ let hostname: string
39
+ try {
40
+ hostname = new URL(BASE_URL).hostname
41
+ } catch {
42
+ console.error(`SCREENSHOT_BASE_URL is not a valid URL: ${BASE_URL}`)
43
+ process.exit(1)
44
+ }
45
+
46
+ const OUT_DIR = path.join('screenshots', hostname)
29
47
 
30
48
  const browser = await chromium.launch()
49
+ const consoleErrors: string[] = []
31
50
 
32
51
  for (const captureCase of CASES) {
33
52
  const context = await browser.newContext({
@@ -35,6 +54,16 @@ for (const captureCase of CASES) {
35
54
  })
36
55
  const page = await context.newPage()
37
56
 
57
+ if (checkConsoleClean) {
58
+ page.on('console', (msg) => {
59
+ if (msg.type() === 'error') {
60
+ consoleErrors.push(
61
+ `${captureCase.section}/${captureCase.theme}: ${msg.text()}`,
62
+ )
63
+ }
64
+ })
65
+ }
66
+
38
67
  if (captureCase.setup) await captureCase.setup(page)
39
68
 
40
69
  await page.goto(`${BASE_URL}${captureCase.route}`)
@@ -51,3 +80,9 @@ for (const captureCase of CASES) {
51
80
  }
52
81
 
53
82
  await browser.close()
83
+
84
+ if (checkConsoleClean && consoleErrors.length > 0) {
85
+ console.error('console errors detected:')
86
+ for (const error of consoleErrors) console.error(` ${error}`)
87
+ process.exit(1)
88
+ }
@@ -47,6 +47,7 @@ packages = [
47
47
  "test:e2e:ui" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test --ui"
48
48
  "test:e2e:report" = "playwright show-report"
49
49
  "check:full" = "./scripts/verify.sh && bun run test:e2e"
50
+ "smoke:prod" = "bun e2e/screenshot.ts --check-console-clean --require-base-url"
50
51
 
51
52
  [scripts.override]
52
53
  "lint" = "eslint . --max-warnings 0"
@@ -55,5 +56,5 @@ packages = [
55
56
  [gitignore]
56
57
  "# Build" = ["dist/"]
57
58
  "# Coverage" = ["coverage/"]
58
- "# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/", "screenshots/"]
59
+ "# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]
59
60
  "# VSCode" = [".vscode/*", "!.vscode/extensions.json", "!.vscode/settings.json"]
@@ -12,7 +12,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
12
12
 
13
13
  - `eslint.config.js`: flat config with `@eslint/js`, `typescript-eslint`, React hooks, import sort, check-file, vitest rules scoped to test files, `eslint-config-prettier` last.
14
14
  - `src/test/setup.ts`: `@testing-library/jest-dom` import, `cleanup` after each test.
15
- - `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<section>/<theme>.png`. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone.
15
+ - `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
16
16
  - `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
17
17
  - `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
18
18
  - `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
@@ -106,11 +106,20 @@ Append rows:
106
106
  | `bun run test:e2e` | Run Playwright E2E tests. |
107
107
  | `bun run test:e2e:changed` | Run Playwright E2E tests for specs the import graph reaches from the current diff. |
108
108
  | `bun run screenshot` | Build, preview, then capture screenshots. |
109
+ | `bun run smoke:prod` | Capture against `SCREENSHOT_BASE_URL`, requiring it set and failing on any console error. |
109
110
 
110
111
  `canon tooling verify <stack>` is the only automated caller of `bun run screenshot`, running it for any stack whose `package.json` declares the script and asserting that PNG files land under `screenshots/`. It counts them with a recursive find carrying no depth limit, so the section folders the seed writes satisfy the assertion without a change to it. Do not flatten the layout to protect that check. No ship chain captures a screenshot, so the output path the seed writes is a contract that one verifier reads rather than a default a ship step depends on.
111
112
 
112
113
  `governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a route changes. It fires on route and page files rather than on every component, so a shared component changing every screen fires nothing and the operator runs the capture by hand.
113
114
 
115
+ The screenshot output now tracks in git rather than getting discarded, so the first capture a scaffolded target runs after this change is the baseline it commits.
116
+
117
+ ## Gitignore (extend)
118
+
119
+ `[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
120
+
121
+ - `"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]`
122
+
114
123
  ## Verify script
115
124
 
116
125
  The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build. Stack adapters may override if their typecheck or build differs.