@erclx/canon 4.105.0 → 4.107.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,27 +1,92 @@
1
1
  ---
2
2
  name: session-relay
3
- description: Composes the relay a worker or planner owes its controller when the session holds no message-sending tool, carrying the sender's own name, the addressee the calling role's own ladder resolves, and the message body that role already names, opened with a header naming all three so an operator can copy the block whole. Fires only from inside `role-worker` or `role-planner`'s own `## The channel` section, at the point that section finds no tool to send through. Do NOT invoke it when a message-sending tool exists, which is the ordinary channel, and do NOT invoke it to resolve an addressee for anything other than a channel obligation those two roles already state.
3
+ description: Relays a message to another session, turning the sessionId the caller named into an address at the moment of sending and composing a copyable block where no send tool exists. Use when asked to "relay this", "message the controller", "send this over to the session that dispatched me", or "tell the other session", with or without a role. Do NOT use to decide what the message says, and do NOT use to assert what a session may write or is on the hook for, which is `role-worker` or `role-planner`.
4
4
  ---
5
5
 
6
6
  # Session relay
7
7
 
8
- `role-worker` and `role-planner` each owe their controller a set of messages, and each assumes a message-sending tool exists to carry them. A session holding none has nothing to send through, and the paragraph stating what to do about that used to sit inline in both bodies, repeating the same protocol twice. This skill is that paragraph, and both role bodies point to it instead of each carrying their own copy.
8
+ This is the one place a session sends to another session. It holds the
9
+ mechanical half of the send: naming the sender, turning a `sessionId` or a
10
+ branch into an address that reaches, and putting the text through whichever
11
+ route exists. It holds none of the other half. What the message says and who it
12
+ goes to come from whatever dispatched this session, and from the calling role's
13
+ own `## The channel` section where the session holds a role.
9
14
 
10
- Read the calling body's `## The channel` section for the message owed and the ladder that resolves its addressee. This skill does not restate either.
15
+ It fires for a session holding a role and for one holding none. A roleless
16
+ session reaches this body directly and finds everything it owes stated here,
17
+ rather than reading a ladder written for a role it does not have.
11
18
 
12
- ## Guards
19
+ ## Step 1: name the sender
13
20
 
14
- - Refuse to fire when the calling session holds neither `role-worker` nor `role-planner`. Say so, and state what it owes instead: resolve the addressee at send time through `canon sessions list --json`, never by name prefix, and send a block out as a message before it becomes an interactive prompt, leaving the message content to whatever dispatched it. Checked first, since a caller that never held either role never had a ladder here to run, and the tool guard below reads as the whole reason only once this one has cleared.
15
- - Refuse to fire when a message-sending tool is available. Say so, name the tool, and send through it. This skill exists for the gap, never as an alternative to the ordinary channel.
21
+ Read this session's own name from `canon sessions list --self --json`. A
22
+ message an operator relays by hand needs a sender as much as an addressee, and
23
+ a message arriving through a tool is read against whoever sent it.
16
24
 
17
- ## Steps
25
+ A refusal carrying `no-self-identity` or `no-self-row` means the roster holds
26
+ nothing to name the sender with. Report the refusal and its reason in place of a
27
+ name rather than guessing one.
18
28
 
19
- 1. Read this session's own name from `canon sessions list --self --json`. A refusal here, `no-self-identity` or `no-self-row`, means the roster carries nothing to name the sender with. Report the refusal and its reason in place of a name rather than guessing one.
20
- 2. Resolve the addressee. Run the calling role's own `## The channel` ladder to the letter rather than a second one here, and take its outcome, whatever it is, as this skill's addressee.
21
- 3. Carry the message body the calling role's bullet already names, verbatim. This skill composes and never drafts, so the pull request announcement, the address-review announcement, or the blocked question is exactly the text that bullet states, with its own facts filled in.
22
- 4. Open the composed text with a header naming the sender, the addressee, and what the message is. Close the turn with nothing after it, so the block above is the whole of what an operator copies.
29
+ Where the installed CLI answers `--self` with an unknown option, that flag is
30
+ newer than the release this session holds, so there is no refusal and no reason
31
+ to report. Name the sender from what the environment states instead, and say the
32
+ roster did not answer, so the reader knows the name was not confirmed against
33
+ it.
23
34
 
24
- Where the installed CLI answers `--self` with an unknown option, that flag is newer than the release this session holds, so step 1 meets no refusal with a reason to report. Name the sender from what the environment states instead, and say the roster did not answer, so the reader knows the name was not confirmed against it.
35
+ ## Step 2: resolve the addressee
36
+
37
+ The caller names the addressee as a `sessionId` or as a branch, never as a name.
38
+ A name is derived from what a session turns out to be doing and goes stale
39
+ inside the window a build or a plan takes, so resolve it at the moment of
40
+ sending rather than at launch.
41
+
42
+ - From a `sessionId`: run `canon sessions list --json`, find the row carrying that id, and take the `name` on it.
43
+ - From a branch: run the same read and take the row whose `branch` matches. Nothing renames a session when it takes a role, which is why an id is the stronger key and a branch is the fallback rather than the default.
44
+
45
+ Then check that name against the agent listing before sending it. A name is not
46
+ unique, and the roster carries no field separating two live sessions holding
47
+ one, so the resolution can end on a string that reaches the wrong session.
48
+
49
+ - One row under the name: send it bare.
50
+ - More than one row: complete the address with the `[ref]` that listing prints beside each row, rather than sending to the name alone, which lands on whichever row the channel resolves first. Two sessions differing only by a trailing ` (3)` is the shape this meets in practice.
51
+
52
+ Never filter that roster by a name prefix. Every self-dispatched worker is named
53
+ `worker-<project>-<slug>`, so a prefix scan returns a sibling or this session
54
+ itself, which is the defect that sent messages owed to a controller somewhere
55
+ else.
56
+
57
+ Report a resolution that returns nothing rather than falling back to a guess.
58
+ The roster and the send channel enumerate different populations in both
59
+ directions, measured at one moment: a live background session sat on the roster
60
+ that the agent listing did not carry, and nine sessions were addressable there
61
+ with no roster row at all. Those nine were driving through Remote Control, which
62
+ writes no local process record.
63
+
64
+ Where the caller named nobody at all, the calling role's own `## The channel`
65
+ section carries the last rung, since a worker and a planner infer differently
66
+ and a roleless caller has no ladder to defer to. Ask the operator where one is
67
+ present, put the candidate rows through the structured question surface so they
68
+ pick a row rather than recall a name, and say the addressee was inferred
69
+ wherever an inference decided it.
70
+
71
+ ## Step 3: send
72
+
73
+ - A message-sending tool exists: send through it, addressed to what Step 2 resolved. This is the ordinary route.
74
+ - No message-sending tool, or Step 2 found no row to address: compose the block below and close the turn with nothing after it, so the block is the whole of what an operator copies.
75
+
76
+ Send the block out as a message before it becomes an interactive prompt. A
77
+ session already waiting on input never reaches the tool round that drains an
78
+ inbound message, so an answer relayed afterwards arrives under the open question
79
+ and changes nothing. This holds on both routes.
80
+
81
+ The shipped channel rule states the same thing for a session holding neither
82
+ role, so the restatement here is for the target that installed the plugin and
83
+ not governance, where that rule never arrives. A session that does load it reads
84
+ the rule and this body in agreement rather than one of them alone.
85
+
86
+ Carry the message body verbatim from whatever names it. This skill composes and
87
+ never drafts, so a pull request announcement, an address-review announcement, or
88
+ a blocked question is exactly the text the calling bullet or the dispatch
89
+ states, with its own facts filled in.
25
90
 
26
91
  ## Header
27
92
 
@@ -30,8 +95,12 @@ Relay: from <sender> to <addressee>, <what the message is>
30
95
  <message body>
31
96
  ```
32
97
 
33
- States a default rather than a fixed vocabulary. A session may write the header in its own words, since it exists to be read once and copied rather than parsed by anything downstream. Name the same three parts however phrased.
98
+ States a default rather than a fixed vocabulary. A session may write the header
99
+ in its own words, since it exists to be read once and copied rather than parsed
100
+ by anything downstream. Name the same three parts however phrased.
34
101
 
35
102
  ## Output
36
103
 
37
- Nothing beyond the composed block. Report the Guards refusal instead when the roster cannot answer for the sender, and report the calling body's own halt or inference when its ladder cannot resolve the addressee.
104
+ The sent message, or the composed block and nothing beyond it. Report Step 1's
105
+ refusal in place of a sender the roster cannot answer for, and report the halt
106
+ or the inference in place of an addressee Step 2 cannot resolve.
@@ -18,7 +18,7 @@ canon capture https://example.com --selector .window --out preview.png
18
18
 
19
19
  ## What this repository captures
20
20
 
21
- A capture set here spans two folders. `assets/captures/` holds five sources and the three templates they are written from, and `assets/evidence/` holds their images and the built-page capture a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the sources is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
21
+ A capture set here spans two folders. `assets/captures/` holds five sources and the three templates they are written from, and `assets/evidence/` holds their images and the built-page capture a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the sources is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them. A branch commits none of them for a moved count or token, since the refresh workflow regenerates them from `main` and opens one pull request, and `bun run check` runs the script with `--check`, which fills every template into a temp folder and fails when the regeneration cannot run.
22
22
 
23
23
  Two of the three templates take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` is the exception, since it holds terminal text from a real run in its template rather than derived from a live catalog at build time. `assets/evidence/arrival.png` is the one image with no source here: it is the built page's `.fold` element captured in dark mode against a local `web/` preview, since `canon capture` takes no color scheme and the page follows the browser's default of light, so it is retaken by hand when the page's first screen changes.
24
24
 
@@ -26,9 +26,9 @@ The README shows each still in two themes through `<picture>`, so a frame is a p
26
26
 
27
27
  A stamp for a URL source digests the address string rather than the page it served, so two themes of one address carry one `source-sha256` and only `image-sha256` tells the frames apart. That holds for `examples/teach/evidence/root-listing-light.png`, which is retaken by serving a copy of the fixture with `data-theme="light"` on its `<html>` element at the same address, since the page follows the dark default and the command takes no scheme.
28
28
 
29
- `assets/captures/` is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source nested a further folder down is skipped by all three with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/captures/` no longer mixes markup in with the images.
29
+ `assets/captures/` is read flat and never descends, by the regeneration script and by this command alike. A source nested a further folder down is skipped by both with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/captures/` no longer mixes markup in with the images.
30
30
 
31
- Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets/captures --selector .window --out assets/evidence` when the check reports the HTML changed.
31
+ A branch is not asked whether the HTML is current. The PNG is a chromium render whose bytes move with the browser version, so the refresh workflow rebuilds it with `canon capture assets/captures --selector .window --out assets/evidence` only when the regenerated HTML changed.
32
32
 
33
33
  Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, holding the source filename, a `source-sha256` over the markup bytes it read, and an `image-sha256` over the image bytes it wrote. Both digests are what `bun run check` compares, so a markup edit committed without a capture and a PNG swapped under unchanged markup each fail. The stamp is tracked and commits alongside the pair. A capture that cannot write it reports that source as failed and exits 1, so an image whose stamp never landed is reported rather than passed over.
34
34
 
@@ -5,7 +5,7 @@ description: Every project-level command and every domain subcommand, plus the s
5
5
 
6
6
  # Command catalog
7
7
 
8
- Full help: `canon <command> --help`. Behavior notes for the install and sync verbs live in `install-and-sync.md`.
8
+ Full help: `canon <command> --help`. Bare `canon --help` lists every top-level command under five headings, Project, Domains, Author and render, Session and ship, and Repo reports, with descriptions cut at 80 columns. Behavior notes for the install and sync verbs live in `install-and-sync.md`.
9
9
 
10
10
  ## Project-level
11
11
 
@@ -54,6 +54,8 @@ An outcome whose body is struck reads as cut rather than open or closed, whateve
54
54
 
55
55
  The task carries its plan with it. When the closing task is the last live one whose `Plan:` line resolves onto that file, the plan moves to `.canon/plans/archive/` under its own name and the archived task's line is rewritten as `Plan: [feature-<slug>](../../plans/archive/feature-<slug>.md)`, a folder deeper than the live task wrote it. The `plan` field on the success record carries that `from` and `to`, and is `null` when nothing moved.
56
56
 
57
+ Every other relative link in the task is re-resolved for the deeper folder too, whether its target moved or not: `Groundwork:`, `Intake:`, a `Plan:` or `Ready:` line whose target stayed put, and a link in the body. Fenced blocks, inline code spans, absolute URLs, `#` anchors, and rooted paths are left alone. `canon tasks decline` applies the same rebase, since `declined/` sits at the same depth. The rebase runs before the plan and ready retargets, so a moved plan keeps its own line.
58
+
57
59
  The ready folder the task's `Ready:` line names moves with the plan to `.canon/ready/archive/`, falling back to a `.canon/ready/<nn>-<slug>/` path in the plan when the line is absent. The `Ready:` line and the plan's literal path to the folder are retargeted, and the `ready` field on the success record carries `from` and `to`, or `null` when nothing moved. A folder that resolves to nothing, sits already archived, or meets a taken destination moves nothing and refuses nothing.
58
60
 
59
61
  A plan several tasks share stays where it is, and its ready folder with it, and the task archives anyway. Moving it on the first task to close strands every sibling's pointer at a path that has gone, and `.canon/plans/` is gitignored so no history recovers the target. A `Plan:` line resolving to no file leaves the plan alone too, since a pointer somebody typed wrong is not a plan to move and holding the whole archive over it would park the board behind a repair the merge cannot make.
@@ -174,6 +174,8 @@ With no topic it rewrites every workspace. The teach-root listing is always rewr
174
174
 
175
175
  A lesson carries its chrome as four marker pairs the authoring skill writes empty: `canon:teach:style`, `canon:teach:header`, `canon:teach:footnav`, and `canon:teach:scripts`. This verb splices each one from the current workspace state, embedding the shared stylesheet, rebuilding the breadcrumb and jump menus, the prev/next footer nav, and the behavior scripts, and leaves the authored `<h1>`, lede, body, and quiz between the header and the footnav untouched.
176
176
 
177
+ A workspace carrying no `course.css` gets its stylesheet pair seeded first, so the contents page it links is never unstyled, and an existing `course.css` is never overwritten. The embedded copy drops the seed's `@import` line, which resolved against the lesson's own folder and never loaded anything.
178
+
177
179
  A lesson missing one of the four marker pairs is refused by name rather than rewritten, and every other lesson in the run still rewrites. The record's `skipped` list carries the refused files and which marker each is missing.
178
180
 
179
181
  The `style` region carries the quiz stepper after the workspace stylesheet, for every lesson but the one shape named three paragraphs down, so it wins the cascade at equal specificity and reaches a workspace seeded before it existed. The stepper is CSS over the radio inputs a quiz is written from: it hides every question that follows one holding no checked input, and hides a question's feedback until that question does. The general sibling combinator is what keeps that from depending on the markup contract holding, since it selects the same questions as the adjacent one for a quiz written correctly and goes on gating when anything sits between two of them, where the adjacent one matches nothing and shows them all at once. Gating there rather than in a script is what keeps a later stem from answering an earlier question with nothing to bind, and the whole block sits inside `@supports selector(:has(*))`, so an engine without `:has()` renders every question rather than a stepper showing nothing. Feedback there follows the workspace stylesheet, shown by one that says nothing about `.fb` and hidden by one grown under the retired script.
@@ -272,15 +272,15 @@ This section is the corpus the coverage claim is measured against: every name `c
272
272
 
273
273
  ### Run several tracks at once
274
274
 
275
- | Skill | When to use |
276
- | ------------------------- | ------------------------------------------------------------------------------- |
277
- | `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR |
278
- | `canon:role-planner` | To assert the planner role for a cold session writing one plan under one task |
279
- | `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan |
280
- | `canon:session-relay` | When a worker or planner owes its controller a message and holds no send tool |
281
- | `canon:session-resume` | At the start of a session, to pick up what a previous one left |
282
- | `canon:session-compact` | Before a compaction, to write a plain session's handoff note outside the board |
283
- | `canon:session-map` | At the close of an orchestrating session, to write its board-side handoff |
275
+ | Skill | When to use |
276
+ | ------------------------- | ---------------------------------------------------------------------------------------------------- |
277
+ | `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR |
278
+ | `canon:role-planner` | To assert the thinking role for a cold session writing a plan, a groundwork track, or an intake pass |
279
+ | `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan |
280
+ | `canon:session-relay` | When this session owes another session a message, with or without a role |
281
+ | `canon:session-resume` | At the start of a session, to pick up what a previous one left |
282
+ | `canon:session-compact` | Before a compaction, to write a plain session's handoff note outside the board |
283
+ | `canon:session-map` | At the close of an orchestrating session, to write its board-side handoff |
284
284
 
285
285
  ### Keep the project current with the toolkit
286
286
 
@@ -42,7 +42,7 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
42
42
  - `canon:ux-measure` for what a running surface costs to paint, read against published thresholds
43
43
  - `canon:draft-and-pick` for a call settled by looking, drafting several candidates onto one page and taking your pick
44
44
  - `canon:ux-walkthrough` for a multi-finding inspection pass over a running app with the operator, drafting arms from the app's own markup and recording each pick for a builder
45
- - `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
45
+ - `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. On an Astro project holding the card route scaffold, the card lands as a route the project keeps and is captured through a running server, so it reads the project's real stylesheet rather than typed copies of every color. Everywhere else it ships as a PNG and the run says so.
46
46
  - Anthropic's `frontend-design` plugin optional for light visual steering
47
47
 
48
48
  ### 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.105.0",
4
+ "version": "4.107.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -46,6 +46,21 @@ PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
46
46
  ASSET_DIR="$PROJECT_ROOT/assets/captures"
47
47
  LISTED=10
48
48
 
49
+ # `--check` fills every template into a temp folder and discards it, so a renamed
50
+ # featured skill, an empty catalog, or an unresolved placeholder still fails a
51
+ # branch while no frame file changes. Frames are refreshed after a merge rather
52
+ # than committed by each branch, so a branch asks whether the regen can run and
53
+ # never whether its output is current.
54
+ OUT_DIR="$ASSET_DIR"
55
+ case "${1:-}" in
56
+ '') ;;
57
+ --check) OUT_DIR="$(mktemp -d)" ;;
58
+ *)
59
+ echo "regen-hero: unknown argument $1" >&2
60
+ exit 1
61
+ ;;
62
+ esac
63
+
49
64
  # `bun src/cli.ts` rather than `canon`, since a globally linked binary resolves to
50
65
  # the main checkout no matter which worktree is running.
51
66
  catalog() {
@@ -97,7 +112,7 @@ done
97
112
  # fails the exec with E2BIG before any stage can report a stale count. A file
98
113
  # path is bounded whatever the catalogs grow to.
99
114
  PAYLOAD_DIR="$(mktemp -d)"
100
- trap 'rm -rf "$PAYLOAD_DIR"' EXIT
115
+ trap 'rm -rf "$PAYLOAD_DIR"; [ "$OUT_DIR" = "$ASSET_DIR" ] || rm -rf "$OUT_DIR"' EXIT
101
116
 
102
117
  printf '%s' "$COUNTS_JSON" >"$PAYLOAD_DIR/counts.json"
103
118
  printf '%s' "$SKILLS_JSON" >"$PAYLOAD_DIR/skills.json"
@@ -109,12 +124,12 @@ printf '%s' "$COMMANDS_HELP" >"$PAYLOAD_DIR/commands-help.txt"
109
124
  printf '%s' "$TOKEN_CSS" >"$PAYLOAD_DIR/tokens.css"
110
125
 
111
126
  export PAYLOAD_DIR
112
- export ASSET_DIR LISTED PROJECT_ROOT
127
+ export ASSET_DIR OUT_DIR LISTED PROJECT_ROOT
113
128
 
114
129
  bun --eval '
115
130
  const { readFileSync, readdirSync } = require("node:fs")
116
131
 
117
- const { PAYLOAD_DIR, ASSET_DIR, LISTED, PROJECT_ROOT } = process.env
132
+ const { PAYLOAD_DIR, ASSET_DIR, OUT_DIR, LISTED, PROJECT_ROOT } = process.env
118
133
 
119
134
  const payload = (name) => readFileSync(PAYLOAD_DIR + "/" + name + ".json", "utf8")
120
135
 
@@ -438,6 +453,6 @@ for (const { template, out, tokens } of frames) {
438
453
  process.exit(1)
439
454
  }
440
455
 
441
- await Bun.write(ASSET_DIR + "/" + out, html)
456
+ await Bun.write(OUT_DIR + "/" + out, html)
442
457
  }
443
458
  '
@@ -155,7 +155,7 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
155
155
  },
156
156
  {
157
157
  prompt:
158
- 'I owe my controller the pull request announcement but I have no message-sending tool. Compose the relay so an operator can copy it over.',
158
+ 'Relay this over to the session that dispatched me. It gave me a sessionId rather than a name, so work out who that is and send it.',
159
159
  expect: 'session-relay',
160
160
  },
161
161
 
package/src/cli.ts CHANGED
@@ -45,6 +45,95 @@ import { register as upgrade } from '@/commands/upgrade'
45
45
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
46
46
  import { palette } from '@/ui'
47
47
 
48
+ const HELP_WIDTH = 80
49
+ const SIGNATURE_WIDTH = 19
50
+ const ROW_INDENT = '│ '.length
51
+
52
+ type CommandRow = [signature: string, description: string]
53
+
54
+ // The grouping mirrors the README's domain table and has no other source, so a
55
+ // command added to the program needs a row here or help-option.test.ts fails.
56
+ const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
57
+ {
58
+ title: 'Project',
59
+ rows: [
60
+ ['init [path]', 'Bootstrap a project with toolkit domains'],
61
+ ['sync [path]', 'Sync all installed domains in a project'],
62
+ ['upgrade', 'Reinstall the CLI with the manager that installed it'],
63
+ ['migrate [cmd]', 'Move a project off a retired name or layout'],
64
+ ['targets [cmd]', 'Projects this toolkit installed into (list, pulls)'],
65
+ ],
66
+ },
67
+ {
68
+ title: 'Domains',
69
+ rows: [
70
+ ['gov [cmd]', 'Governance rules (install, sync, list)'],
71
+ ['standards [cmd]', 'Standards (install, sync, list, <name>)'],
72
+ ['snippets [cmd]', 'Prompt snippets (create, list)'],
73
+ ['tooling [cmd]', 'Tooling stacks (sync, ref, create)'],
74
+ ['claude [cmd]', 'Claude workflow (init, sync, setup)'],
75
+ ['wiki [cmd]', 'Wiki pages (init)'],
76
+ ['indexes [cmd]', 'Regenerate index.md files (regen)'],
77
+ ['docs [cmd|topic]', 'Emit toolkit reference docs (list, <topic>)'],
78
+ ],
79
+ },
80
+ {
81
+ title: 'Author and render',
82
+ rows: [
83
+ ['design [cmd]', 'Design system (render, board)'],
84
+ ['slides [cmd]', 'Slide decks (render, list)'],
85
+ ['capture [source]', 'Render HTML capture sources to PNG'],
86
+ ['serve [dir]', 'Serve a directory on localhost, print the link'],
87
+ ['demo [cmd]', 'Record a running app (compile, run)'],
88
+ ['inventory [subj]', 'Report one computed property across every route'],
89
+ ['drive <url> <run>', 'Walk a page through interactions, measure each'],
90
+ ['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
91
+ ['teach [cmd]', 'Learning workspaces (list, open, resource, glossary)'],
92
+ ],
93
+ },
94
+ {
95
+ title: 'Session and ship',
96
+ rows: [
97
+ ['sandbox [cat:cmd]', 'Provision and run sandbox scenarios'],
98
+ ['tasks [cmd]', 'Task board (validate, archive)'],
99
+ ['intake [cmd]', 'Intake folders under .canon/intake/ (list, answer)'],
100
+ ['records [cmd]', 'Session records (validate, size, push, pull)'],
101
+ ['sessions [cmd]', 'Resolve live sessions to worktree and branch'],
102
+ ['worktrees [cmd]', 'Reclaim worktrees whose branches merged'],
103
+ ['autoship [cmd]', 'Decide whether a changed set needs review'],
104
+ ['pr [cmd]', 'Read a pull request (key-changes, head, checks)'],
105
+ ['feedback', 'Write toolkit feedback from stdin'],
106
+ ],
107
+ },
108
+ {
109
+ title: 'Repo reports',
110
+ rows: [
111
+ ['audits [cmd]', 'Run every health check as one set (run, list)'],
112
+ ['gate [cmd]', 'Run the merge gate stage by stage (run)'],
113
+ ['secrets [cmd]', 'Scan the shipped tree for credentials (scan)'],
114
+ ['deps [cmd]', 'Read the dependency set for advisories (audit)'],
115
+ ['labels [cmd]', 'Read a changed set against the label map (audit)'],
116
+ ['comments [cmd]', 'Measure comment density and trend (scan)'],
117
+ ['context [cmd]', 'Report context folder health (audit)'],
118
+ ['markdown [cmd]', 'Report markdown against the standards (audit)'],
119
+ ['repo [cmd]', 'Remote metadata (metadata propose, apply)'],
120
+ ['census [path]', 'Report file count, extensions, and line totals'],
121
+ ],
122
+ },
123
+ ]
124
+
125
+ // A row past the width is cut rather than wrapped, since a wrapped row breaks
126
+ // the alignment the grouping exists to buy.
127
+ function commandRow(signature: string, description: string): string {
128
+ const { GREY, NC } = palette(process.stdout)
129
+ const room = HELP_WIDTH - ROW_INDENT - SIGNATURE_WIDTH - '# '.length
130
+ const text =
131
+ description.length > room
132
+ ? `${description.slice(0, room - 1)}…`
133
+ : description
134
+ return `${GREY}│${NC} ${signature.padEnd(SIGNATURE_WIDTH)}${GREY}# ${text}${NC}`
135
+ }
136
+
48
137
  function showHelp(): void {
49
138
  // The help text is the one framed surface written to stdout, so it asks
50
139
  // about that stream rather than the stderr every other writer here uses.
@@ -54,46 +143,13 @@ function showHelp(): void {
54
143
  `${GREY}├${NC} ${WHITE}Usage:${NC} canon [command]`,
55
144
  `${GREY}│${NC}`,
56
145
  `${GREY}│${NC} ${WHITE}Commands:${NC}`,
57
- `${GREY}│${NC} init [path] ${GREY}# Bootstrap a project with toolkit domains${NC}`,
58
- `${GREY}│${NC} sync [path] ${GREY}# Sync all installed domains in a project${NC}`,
59
- `${GREY}│${NC} sandbox [cat:cmd] ${GREY}# Provision and run sandbox scenarios${NC}`,
60
- `${GREY}│${NC} gov [command] ${GREY}# Governance commands (install, sync)${NC}`,
61
- `${GREY}│${NC} standards [cmd] ${GREY}# Standards commands (install, sync, list, <name>)${NC}`,
62
- `${GREY}│${NC} snippets [cmd] ${GREY}# Snippets commands (create, list)${NC}`,
63
- `${GREY}│${NC} tooling [cmd] ${GREY}# Manage tooling stacks (sync, ref, create)${NC}`,
64
- `${GREY}│${NC} claude [cmd] ${GREY}# Claude workflow (init, sync, setup)${NC}`,
65
- `${GREY}│${NC} wiki [cmd] ${GREY}# Wiki commands (init)${NC}`,
66
- `${GREY}│${NC} indexes [cmd] ${GREY}# Regenerate index.md files (regen)${NC}`,
67
- `${GREY}│${NC} docs [cmd|topic] ${GREY}# Emit toolkit reference docs (list, <topic>)${NC}`,
68
- `${GREY}│${NC} design [cmd] ${GREY}# Design system commands (render, board)${NC}`,
69
- `${GREY}│${NC} slides [cmd] ${GREY}# Slide deck commands (render, list)${NC}`,
70
- `${GREY}│${NC} capture [source] ${GREY}# Render HTML capture sources to PNG${NC}`,
71
- `${GREY}│${NC} serve [dir] ${GREY}# Serve a directory over localhost and print the preview link${NC}`,
72
- `${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
73
- `${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
74
- `${GREY}│${NC} drive <url> <run> ${GREY}# Walk a page through named interactions and measure each state${NC}`,
75
- `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .canon/feedback/${NC}`,
76
- `${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
77
- `${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
78
- `${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .canon/intake/ (list, answer)${NC}`,
79
- `${GREY}│${NC} teach [cmd] ${GREY}# Learning workspaces under .canon/teach/ (list, open, resource, glossary)${NC}`,
80
- `${GREY}│${NC} comments [cmd] ${GREY}# Measure comment density and trend (scan)${NC}`,
81
- `${GREY}│${NC} context [cmd] ${GREY}# Report context folder health (audit)${NC}`,
82
- `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
83
- `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, size, push, pull)${NC}`,
84
- `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
85
- `${GREY}│${NC} targets [cmd] ${GREY}# Report the projects this toolkit installed into (list, pulls)${NC}`,
86
- `${GREY}│${NC} worktrees [cmd] ${GREY}# Reclaim the worktrees whose branches merged (list, reclaim)${NC}`,
87
- `${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
88
- `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
89
- `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
90
- `${GREY}│${NC} autoship [cmd] ${GREY}# Decide whether a changed set needs the review pass (classify)${NC}`,
91
- `${GREY}│${NC} pr [cmd] ${GREY}# Read a pull request against its own diff and its branch tip (key-changes, head, checks)${NC}`,
92
- `${GREY}│${NC} repo [cmd] ${GREY}# This repository's own remote metadata (metadata propose, apply)${NC}`,
93
- `${GREY}│${NC} census [path] ${GREY}# Report tracked file count, extension breakdown, and line totals${NC}`,
94
- `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
95
- `${GREY}│${NC} gate [cmd] ${GREY}# Run the merge gate stage by stage (run)${NC}`,
96
- `${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
146
+ ...COMMAND_GROUPS.flatMap((group) => [
147
+ `${GREY}│${NC}`,
148
+ `${GREY}│${NC} ${WHITE}${group.title}${NC}`,
149
+ ...group.rows.map(([signature, description]) =>
150
+ commandRow(signature, description),
151
+ ),
152
+ ]),
97
153
  `${GREY}│${NC}`,
98
154
  `${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
99
155
  `${GREY}│${NC} canon sandbox ${GREY}# Interactive scenario picker${NC}`,
@@ -265,6 +265,8 @@ const TEACH_CHROME: Component = {
265
265
  --teach-shadow: none;
266
266
  }
267
267
 
268
+ html { scroll-padding-top: calc(var(--teach-mast-h) + 1rem); }
269
+
268
270
  /* No horizontal padding on the body, so the sticky bar reaches both window
269
271
  edges. The gutter moves onto \`main\`, which is the only thing that needed it. */
270
272
  body {
@@ -282,7 +284,7 @@ body {
282
284
  listing page and a lesson start at the same left edge as the chrome above
283
285
  them. A narrower \`.wide-body\` was what put the navigation and the content on
284
286
  two different measures. */
285
- main { max-width: calc(var(--teach-measure) + 3rem); margin: 0 auto; padding: 2.25rem 1.5rem 0; }
287
+ main { box-sizing: border-box; max-width: calc(var(--teach-measure) + 3rem); margin: 0 auto; padding: 2.25rem 1.5rem 0; }
286
288
  .wide-body { max-width: calc(var(--teach-measure) + 3rem); }
287
289
 
288
290
  .bar {
@@ -346,6 +348,8 @@ main { max-width: calc(var(--teach-measure) + 3rem); margin: 0 auto; padding: 2.
346
348
 
347
349
  .nav > a:hover { border-color: var(--color-accent); background: var(--color-teach-accent-bg); }
348
350
  .nav > .end { color: var(--color-muted); border-style: dashed; font-weight: 400; box-shadow: none; }
351
+ .nav > :only-child { flex: 0 1 calc(50% - 0.3rem); }
352
+ .nav > .to-next:only-child { margin-left: auto; }
349
353
 
350
354
  .nav .lbl {
351
355
  display: block;
@@ -360,7 +364,6 @@ main { max-width: calc(var(--teach-measure) + 3rem); margin: 0 auto; padding: 2.
360
364
  .nav > a:hover .lbl { color: var(--color-accent); }
361
365
  .nav .to-next { text-align: right; }
362
366
  .nav .to-index { flex: 0 0 auto; text-align: center; }
363
- .nav-foot { margin: 4.5rem 0 0; }
364
367
 
365
368
  /* ---- Masthead contents ---- */
366
369
 
@@ -476,7 +479,13 @@ main { max-width: calc(var(--teach-measure) + 3rem); margin: 0 auto; padding: 2.
476
479
 
477
480
  /* ---- Footer navigation, the only place it appears ---- */
478
481
 
479
- .nav { margin: 4rem 0 0; align-items: stretch; }
482
+ .nav {
483
+ max-width: calc(var(--teach-measure) + 3rem);
484
+ margin: 4rem auto 0;
485
+ padding: 0 1.5rem;
486
+ box-sizing: border-box;
487
+ align-items: stretch;
488
+ }
480
489
 
481
490
  .nav > a, .nav > span { display: flex; flex-direction: column; justify-content: center; }
482
491
 
@@ -919,6 +928,7 @@ const TEACH_QUIZ: Component = {
919
928
  font-family: var(--teach-sans);
920
929
  font-size: var(--t4);
921
930
  background: transparent;
931
+ box-sizing: border-box;
922
932
  width: 100%;
923
933
  text-align: left;
924
934
  color: inherit;
@@ -818,9 +818,17 @@ export const readmeCitations: Measure = async (ctx) => {
818
818
  }
819
819
  }
820
820
 
821
+ /**
822
+ * Globs `pr-visual-checks.yml` carries with no `deploy-site.yml` counterpart,
823
+ * because the path changes no byte of the built site. The capture harness is
824
+ * the one case: a change there should report the capture job and never deploy.
825
+ */
826
+ const VISUAL_ONLY_GLOBS: readonly string[] = ['tooling/web/configs/e2e/**']
827
+
821
828
  /**
822
829
  * `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
823
- * copy of the eight path globs a landing-page change should trigger on, per
830
+ * copy of the eight path globs a landing-page change should trigger on, plus
831
+ * the `VISUAL_ONLY_GLOBS` the visual workflow adds for itself, per
824
832
  * Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
825
833
  * anchor cannot cross files here, so the two copies are read live and compared
826
834
  * rather than one asserting a literal the other could drift behind unnoticed,
@@ -858,14 +866,25 @@ export const visualPathGlobs: Measure = async (ctx) => {
858
866
  }
859
867
  }
860
868
 
861
- const onlyDeploy = deployPaths.filter((path) => !visualPaths.includes(path))
862
- const onlyVisual = visualPaths.filter((path) => !deployPaths.includes(path))
869
+ const onlyDeploy = deployPaths.filter(
870
+ (path) => !visualPaths.includes(path) || VISUAL_ONLY_GLOBS.includes(path),
871
+ )
872
+ const onlyVisual = visualPaths.filter(
873
+ (path) => !deployPaths.includes(path) && !VISUAL_ONLY_GLOBS.includes(path),
874
+ )
875
+ const declaredVisualOnly = visualPaths.filter((path) =>
876
+ VISUAL_ONLY_GLOBS.includes(path),
877
+ )
863
878
 
864
879
  if (onlyDeploy.length === 0 && onlyVisual.length === 0) {
880
+ const visualOnlyNote =
881
+ declaredVisualOnly.length > 0
882
+ ? `, plus visual-only ${declaredVisualOnly.join(', ')}`
883
+ : ''
865
884
  return {
866
885
  emissions: [
867
886
  info(
868
- `${deployPaths.length} path glob(s) agree between ${deployPath} and ${visualPath}`,
887
+ `${deployPaths.length} path glob(s) agree between ${deployPath} and ${visualPath}${visualOnlyNote}`,
869
888
  ),
870
889
  ],
871
890
  }
@@ -874,7 +893,11 @@ export const visualPathGlobs: Measure = async (ctx) => {
874
893
  return {
875
894
  emissions: [
876
895
  ...onlyDeploy.map((path) =>
877
- warn(`${deployPath} carries ${path}, absent from ${visualPath}`),
896
+ warn(
897
+ VISUAL_ONLY_GLOBS.includes(path)
898
+ ? `${deployPath} carries ${path}, which is visual-only and must not trigger a deploy`
899
+ : `${deployPath} carries ${path}, absent from ${visualPath}`,
900
+ ),
878
901
  ),
879
902
  ...onlyVisual.map((path) =>
880
903
  warn(`${visualPath} carries ${path}, absent from ${deployPath}`),
@@ -197,12 +197,10 @@ export const STAGES: readonly Stage[] = [
197
197
  id: 'hero',
198
198
  label: 'Hero',
199
199
  checks: [
200
- script('regen-hero.sh', 'Hero regen failed'),
201
200
  {
202
- kind: 'drift',
203
- pathspec: 'assets/captures/*.html',
204
- failure:
205
- 'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets/captures --selector .window --out assets/evidence, and commit each assets/captures/*.html with its .png and .stamp.',
201
+ kind: 'command',
202
+ argv: ['bash', 'scripts/core/regen-hero.sh', '--check'],
203
+ failure: 'Hero regen health check failed',
206
204
  },
207
205
  { kind: 'measure', measure: captureStamps },
208
206
  ],