@erclx/canon 4.118.0 → 4.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-rollout/SKILL.md +2 -2
  4. package/claude/skills/create-skill/SKILL.md +2 -1
  5. package/claude/skills/design-extract/SKILL.md +1 -1
  6. package/claude/skills/docs-fold/SKILL.md +2 -2
  7. package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
  8. package/claude/skills/draft-ready/SKILL.md +1 -1
  9. package/claude/skills/draft-screencast/SKILL.md +1 -1
  10. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  11. package/claude/skills/git-pr/SKILL.md +39 -7
  12. package/claude/skills/memory-capture/SKILL.md +3 -3
  13. package/claude/skills/memory-review/SKILL.md +3 -3
  14. package/claude/skills/plan-feature/SKILL.md +1 -1
  15. package/claude/skills/review-branch/SKILL.md +1 -1
  16. package/claude/skills/review-pr/SKILL.md +13 -242
  17. package/claude/skills/review-pr/references/close-out.md +30 -0
  18. package/claude/skills/review-pr/references/post.md +145 -0
  19. package/claude/skills/review-pr/references/scope.md +82 -0
  20. package/claude/skills/role-orchestrator/REQUIREMENT.md +9 -16
  21. package/claude/skills/role-orchestrator/SKILL.md +9 -153
  22. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -193
  23. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +22 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +85 -0
  25. package/claude/skills/role-orchestrator/references/orchestrator-output.md +61 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  27. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  28. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +56 -0
  29. package/claude/skills/role-orchestrator/references/orchestrator-review-fallback.md +18 -0
  30. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +1 -1
  31. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  32. package/claude/skills/role-planner/SKILL.md +1 -1
  33. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  34. package/claude/skills/role-worker/SKILL.md +10 -41
  35. package/claude/skills/seed-sync/SKILL.md +1 -1
  36. package/claude/skills/session-compact/SKILL.md +1 -1
  37. package/claude/skills/session-map/SKILL.md +1 -1
  38. package/claude/skills/session-relay/REQUIREMENT.md +7 -1
  39. package/claude/skills/session-relay/SKILL.md +4 -7
  40. package/claude/skills/session-worktree/REQUIREMENT.md +5 -1
  41. package/claude/skills/session-worktree/SKILL.md +7 -1
  42. package/claude/skills/session-worktree/references/main-root-writes.md +24 -0
  43. package/claude/skills/task-board/SKILL.md +4 -3
  44. package/claude/skills/teach-workspace/SKILL.md +4 -4
  45. package/claude/skills/ui-checklist/SKILL.md +1 -1
  46. package/claude/skills/ux-audit/SKILL.md +1 -1
  47. package/claude/skills/ux-measure/SKILL.md +1 -1
  48. package/claude/skills/ux-walkthrough/references/relay.md +1 -1
  49. package/docs/agents/commands.md +4 -3
  50. package/docs/agents/counts.md +1 -1
  51. package/docs/agents/index.md +8 -3
  52. package/docs/agents/install-and-sync.md +11 -6
  53. package/docs/agents/pr-evidence.md +19 -3
  54. package/docs/agents/pr-local.md +102 -0
  55. package/docs/agents/pr-preview.md +2 -2
  56. package/docs/agents/records-push.md +61 -0
  57. package/docs/agents/records-validate.md +73 -0
  58. package/docs/agents/records.md +3 -119
  59. package/docs/agents/sessions-transfer.md +91 -0
  60. package/docs/agents/sessions.md +3 -82
  61. package/docs/agents/skills-audit.md +2 -2
  62. package/docs/agents/tasks.md +4 -4
  63. package/docs/agents/teach-authoring.md +112 -0
  64. package/docs/agents/teach.md +3 -103
  65. package/docs/target-projects.md +2 -2
  66. package/docs/workflow/ai-workflow.md +10 -10
  67. package/docs/workflow/visual-design-workflow.md +2 -2
  68. package/governance/renames.toml +13 -0
  69. package/governance/rules/claude/570-skill.md +2 -1
  70. package/governance/rules/core/085-worktrees.md +2 -8
  71. package/package.json +1 -1
  72. package/scripts/core/check-ignore-parity.sh +1 -1
  73. package/scripts/lib/sandbox-git.sh +1 -1
  74. package/src/claude/cases/workflow.ts +10 -0
  75. package/src/commands/pr.ts +324 -2
  76. package/src/commands/sync.ts +11 -3
  77. package/src/commands/tasks.ts +9 -8
  78. package/src/context/classify/extract.ts +2 -2
  79. package/src/counts/scan.ts +1 -1
  80. package/src/design/base.css +19 -19
  81. package/src/design/board.ts +18 -7
  82. package/src/design/neutral.ts +127 -0
  83. package/src/design/regen.ts +6 -4
  84. package/src/design/tokens.ts +6 -3
  85. package/src/gate/stages.ts +6 -6
  86. package/src/gov/adapter.ts +51 -1
  87. package/src/gov/install.ts +13 -3
  88. package/src/gov/renames.ts +76 -0
  89. package/src/gov/stacks.ts +5 -1
  90. package/src/pr/evidence.ts +42 -2
  91. package/src/pr/local.ts +125 -0
  92. package/src/sync/check.ts +19 -4
  93. package/src/sync/engine.ts +67 -3
  94. package/src/tasks/archive.ts +28 -7
  95. package/src/tasks/record.ts +16 -9
  96. package/src/tasks/validate.ts +3 -1
  97. package/standards/index.md +1 -0
  98. package/standards/skill-requirement.md +72 -0
  99. package/standards/skill.md +34 -152
  100. package/standards/tasks.md +5 -3
  101. package/tooling/astro/reference.md +1 -1
  102. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +32 -0
  103. package/governance/rules/core/091-channel.md +0 -13
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Records
3
- description: The two roots a record folder resolves at, validating the session records and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, claiming the ordinal intake and groundwork share, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
3
+ description: The two roots a record folder resolves at, migrating a record a frontmatter change orphaned, claiming the ordinal intake and groundwork share, reading each folder's size and growth, pruning stale scratch, and where validation and the private-remote backup are described
4
4
  ---
5
5
 
6
6
  # Records
@@ -17,70 +17,7 @@ A refusal names every root it looked at, so a message reading `no-folder` says w
17
17
 
18
18
  ## Validate
19
19
 
20
- `canon records validate <kind>` reports where a file and the standard governing it disagree. Five kinds are gitignored folders under the record root, resolved at `.canon/` first: `plans`, `groundwork`, `intake`, `memory`, and `teach`. The sixth is `standards`, the authoring corpus, which is tracked and installed rather than scratch.
21
-
22
- ```bash
23
- canon records validate plans
24
- canon records validate memory
25
- canon records validate standards
26
- canon records validate intake --json
27
- ```
28
-
29
- | Option | Behavior |
30
- | --------------- | ------------------------------------------------------------------- |
31
- | `--json` | Add a machine-readable record on stdout |
32
- | `--root <path>` | Project root, defaulting to the main worktree except on `standards` |
33
-
34
- It reads and never writes, and the reason splits by kind. A session record is per-machine scratch with no history behind it, so a repair that guessed wrong could not be undone. A standard installs into every target and is cited by bare filename, so a rename the verb performed would reach further than the file it moved, which is why a finding naming one says so.
35
-
36
- `standards` reads the authoring root at `standards/` where it exists and a copy at `.claude/standards/` otherwise. The authoring root wins because it is the only tree anyone authors in and the only one the resolver answers from, so a finding fixed anywhere else is fixed where nothing reads it. No repository generates the second candidate any more, which leaves it as a floor under a target holding a copy an older toolkit installed. The walk stays flat, matching the catalog.
37
-
38
- Nothing fires it automatically. The five record folders are gitignored, so the standards-audit hook exits early on them and any check reading changed files from git never lists one. The corpus is tracked and still unreached, since the markdown audit reads content across the files git lists and rules on no filename. The verb runs at the moment a session claims the record is finished, which is the same placement `canon tasks validate` takes over the board.
39
-
40
- ### What each kind checks
41
-
42
- | Kind | What it reports |
43
- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
- | `plans` | A filename that is not `feature-<slug>.md`, a missing `# Feature:` heading, a missing required section, a files-to-touch entry naming no file or saying nothing about one, a question carrying no suggestion or no answer slot, and a batch still staged with a `**Batch N**` sub-heading inside the one file rather than split into its own plan |
45
- | `groundwork` | A track with no `README.md` or no `01-current-state.md`, a file missing `title` or `description`, a `README.md` with no `date` as `YYYY-MM-DD`, an unnumbered file, and a track holding a decision without its handoff or the reverse |
46
- | `intake` | A dump with no `00-overview.md`, the same frontmatter and numbering checks, an item missing any of `Problem`, `Fix`, `Worth it`, or `You`, and an item carrying `Open` with no `Suggested` |
47
- | `memory` | A filename whose prefix names none of the four types, an entry missing `title`, `description`, or `category`, a `category` disagreeing with that prefix, a title repeating the filename, and a rule-bearing body missing a part |
48
- | `standards` | A standard missing `title` or `description`, an absent `## Scope` section, a scope section carrying no `Does not govern:` list, a statement anchoring nothing, and a filename naming no part of the path the statement governs |
49
- | `teach` | A workspace folder carrying no two-digit ordinal, an absent `MISSION.md`, `RESOURCES.md`, or `GLOSSARY.md`, a file missing `title` or `description`, a mission with no `date` as `YYYY-MM-DD` or no `## Success looks like` section, an unnumbered learning record, and a reference page opening with an ordinal |
50
-
51
- The half-closed track is the groundwork check a reader cannot run by eye. A folder holding `06` without `07` reads as closed to anyone scanning filenames while the file a returning session actually opens is absent.
52
-
53
- The item check skips `00-overview.md` and `99-next-session.md`, since neither holds items and running it over the handoff would report every heading it carries. The memory walk skips `index.md` for the same reason, since the catalog is generated from its siblings rather than authored as an entry.
54
-
55
- A memory `category` is compared against the sentence-case form of the filename prefix rather than checked field by field, so one finding covers a prefix outside the four types, a field disagreeing with the prefix, and a casing drift that would open a second group in the catalog. The body check runs on `feedback` and `project` entries alone, because a `user` or `reference` entry is a single sentence by design and has no rule to apply.
56
-
57
- The standards filename check derives a word from the governed path rather than counting words in the name. Every member of the corpus is named for the artifact its scope statement governs, and one word is what that produces rather than the rule itself, so a check keyed on word count would pass a conforming single word naming the wrong artifact. Each path segment offers its own word, a dotted container segment offers none, a hyphenated segment offers its parts, and the singular and the plural both match.
58
-
59
- The scope statement is read exactly as `scripts/standards/list.sh` reads it for the catalog's `appliesTo` field: backticked spans in the first sentence alone, with an attribute standard resolving where that sentence backticks nothing and the statement says it governs an attribute. One sentence read two ways would let a standard pass the check while publishing a different jurisdiction to every consumer of the catalog. An attribute standard is exempt from the filename derivation because it governs no path to derive from, and a statement that anchors nothing and claims no attribute is reported as `scope-unanchored` rather than passed, since silence there would let the backticks be removed to disable the check.
60
-
61
- An absent `## Scope` section suppresses the filename finding. The name derives from the statement, so a missing section leaves nothing to derive against, and reporting both would name one defect twice and point the fix at the wrong file. The walk skips `index.md`, which is generated from its siblings rather than authored.
62
-
63
- A plan section opens as a bold label or as an H2 and the check counts both, naming the standard's spelling when it reports one missing. The corpus splits roughly four to one between the two forms, so failing the variant would report nearly every plan on a rule that costs a reader nothing.
64
-
65
- A section runs to the next marker-shaped line whatever it names, so a plan carrying a label of its own closes the section above it rather than collecting into it. Fenced blocks are dropped before any of this, since a plan showing the shape it writes puts real-looking bullets and headings inside a fence.
66
-
67
- ### Exit codes and refusals
68
-
69
- Exit codes: `0` every check passed, `1` refused, `2` at least one record carries a finding. A `reason` field carries which gate fired: `no-folder` when none of the kind's directories exist, and `unknown-kind` when the argument names no published kind. A `no-folder` message names every candidate, so a record kind's refusal names both record roots and the `standards` refusal names the authoring root and the installed copy.
70
-
71
- An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `findings` array and its `reason` rather than the exit when a skill consumes this.
72
-
73
- The four record folders are shared scratch at the main worktree root, so `--root` defaults to the first entry of `git worktree list` rather than the working directory. A linked worktree validates the same records every other session reads.
74
-
75
- `standards` takes the other default, the root of the checkout the caller stands in. The corpus is tracked, so a linked worktree holds its own edited copy, and resolving the main root there would report on a tree the session never touched while saying nothing about which one it read. A session that adds or renames a standard inside a worktree is the case, and it is the one the check exists for.
76
-
77
- Skills branch on the findings rather than on the exit code:
78
-
79
- ```bash
80
- canon records validate plans --json | jq -r '.findings[] | "\(.kind): \(.subject)"'
81
- ```
82
-
83
- For the shapes each check enforces, see `standards/plan.md`, `standards/groundwork.md`, `standards/intake.md`, `standards/memory.md`, and `standards/standard.md`.
20
+ `canon records validate <kind>` reports where a record folder or the standards corpus disagrees with the standard governing it. Its flags, the per-kind checks, and the refusals are in `records-validate.md`.
84
21
 
85
22
  ## Migrate
86
23
 
@@ -181,57 +118,4 @@ Exit codes: `0` nothing to prune, or `--write` deleted every candidate. `1` refu
181
118
 
182
119
  ## Push and pull
183
120
 
184
- `canon records push` commits the backed record folders to a private remote and pushes them. `canon records pull` fetches the other direction and writes them back. Both take `--json` and `--root` the way `validate` does, and both exit `0` on agreement and `1` on a refusal.
185
-
186
- ```bash
187
- canon records push
188
- canon records push --json
189
- canon records pull
190
- ```
191
-
192
- At the `.canon` root, the backed folders are every top-level directory less three: `tmp`, which is deletable without loss, `ordinal-locks`, whose entries are transient per claim and would race the claim they guard, and `.records.git`, which is the history the rest are pushed into. Nothing bounds the set from outside, since the claude manifest ships one `.canon/` root entry and names no folder, so a record folder added later enters the payload on its own rather than waiting on a name written here. A push names each folder in scope that the records index has never tracked before, so a folder that picked up a name by mistake, such as a misrouted scratch write, is visible in the report rather than entering the payload silently. The report also lists every file new to the history under `added`, since a stray file inside a folder already tracked is visible only by name.
193
-
194
- The three excluded names bound the records index as well as the disk. A name an older binary committed, such as `tmp`, is removed from the history once and reported under `dropped`, and no later push stages it again. That removes it from the tip going forward and purges nothing from earlier commits the remote already holds. A project's own drafts folder needs no config to be backed: moved to `.canon/<name>/`, it is a top-level directory like any other and enters the payload on the next push. The legacy `.claude` root keeps a fixed allowlist instead, since that root also holds tracked `skills/`, `rules/`, and `hooks/` a push must never carry, and an exclusion set there would stage all three. Each name is a top-level record folder and every archive sits inside the one it archives, so the set stays at one entry per surface however many archives appear, and it deliberately does not match the six record kinds `validate` hardcodes.
195
-
196
- Records are gitignored by design, so the history lives in a second git directory at `.records.git` inside the record root, with that root as its work tree. Both resolve off the root together rather than folder by folder, since a history opened at one root beside a work tree at the other would stage the deletion of every folder a move relocated. Every path stays where it is, which is what a separate checkout could not do. The verbs stage the backed folders by explicit pathspec with `--force`, so nothing outside them can enter the index however the ignore rules read, and the project working tree and its index are never touched. Each pathspec is a bare folder name and git reads it against the current directory rather than against the work tree the same call names, so the invocation carries `-C` at the work tree beside the other two flags. That is what lets either verb run from a linked worktree under `.claude/worktrees/`, which sits inside the records work tree and would otherwise prefix every name with its own path.
197
-
198
- ### Setup
199
-
200
- A person creates the records repository once per machine, and the verbs refuse with the commands when it is absent:
201
-
202
- ```bash
203
- git --git-dir=.canon/.records.git init
204
- git --git-dir=.canon/.records.git remote add origin <private-repo-url>
205
- printf '.canon/\n' >> .gitignore
206
- ```
207
-
208
- The ignore line is repeated here rather than left to the install, because the person running these commands is the one who creates the directory and the rule is worth reading beside the command that needs it. The claude manifest ships `.canon/` too, so a project that ran `canon tooling sync` already carries it and this line is a no-op there. One root entry covers the history and every record beside it, which is what makes the rule worth stating once rather than per folder.
209
-
210
- Point it at a private repository, and at one that is not a remote of the project. Records carry the memory pen, the review reports, and the groundwork trails, so a public project publishes all of it to anyone who fetches all refs. `push` compares the configured origin against every remote of the project and refuses on a match. A read of that list which fails refuses as well, since an empty list clears the comparison for every origin and a gate that passes on its own failure is no gate.
211
-
212
- ### Refusals
213
-
214
- | Reason | What fired |
215
- | ------------------- | -------------------------------------------------------------------------------------- |
216
- | `split-roots` | Record folders sit under both roots, so the resolved work tree is not the whole set |
217
- | `no-repository` | No `.canon/.records.git`, answered with the two setup commands |
218
- | `no-remote` | The records history has no `origin` |
219
- | `remote-unreadable` | The project's own remotes could not be read, so the shared-origin gate could not run |
220
- | `remote-shared` | The records origin is also a remote of the project |
221
- | `no-remote-records` | `pull` found no branch on the records origin |
222
- | `local-changes` | `pull` found records on disk that the history does not carry |
223
- | `local-ahead` | `pull` found local commits that never reached the origin |
224
- | `unsafe-payload` | `push` found a pending file over 25 MB or carrying a credential, named under `blocked` |
225
- | `git-failed` | A git call failed, with its stderr in the message |
226
-
227
- `split-roots` runs ahead of every gate below it and fires on a half-migrated tree, which is what a `canon migrate records` run that failed partway leaves. `recordRoot` answers for the whole tree on the first root that exists, so a folder left at the old root is absent from the work tree while the records index still names it, and an unguarded `add -A` would stage its deletion and drop it from the remote on the next push. Finish the move, or put the stranded folders back beside the others.
228
-
229
- `unsafe-payload` runs before anything is staged, so a refusal leaves the index, the log, and the object store as it found them. It reads every new or changed file the push would stage and blocks one over 25 MB, which is read off the file's size without opening it, or one carrying a value `canon secrets scan` would report, through the same patterns and the same `canon-allow-secret` marker. It refuses the whole push rather than skipping the file, and `--json` lists each path with its cause and a detail that names the credential's kind and line, never its value. Move the file out of the record folders, such as into `.canon/tmp/`, or remove the credential, then push again. The guard reads only what this push would add, so a credential committed before it existed stays in the history unreported.
230
-
231
- A push the remote rejects undoes the commit that run made and leaves the records on disk, so the next run does not send a payload the remote already refused. A history an older binary committed and left unpushed is outside that undo, and the guard never reads it either, so every push sends the same blob again. Reset it by hand from the project root to the last commit the origin holds, `git --git-dir=.canon/.records.git --work-tree=.canon reset <commit>`, and push again. The `--work-tree` is required, since the setup `init` marks the history bare and git refuses that reset on a bare repository.
232
-
233
- The two `pull` refusals exist because the directions are not symmetric. A push only adds, while a pull onto a machine holding work that never left it would discard that work. Resolve either by running `push` first, or by moving the local folders aside. A machine holding no backed folders has nothing to lose, so a restore onto a fresh checkout runs straight through.
234
-
235
- ### When it runs
236
-
237
- `.husky/post-merge` runs `push` after the task archiving loop, on every merge rather than only on one that archived a task. A review report and a memory entry both land on runs that close nothing. The call sits inside an `if` and last in the file, so an unreachable remote neither aborts the hook nor delays the archiving above it, and a checkout that never ran the setup reports nothing. Anything the hook misses is covered by running the verb by hand.
121
+ `canon records push` backs the record folders to a private remote and `canon records pull` restores them. The payload, the setup, and the refusals are in `records-push.md`.
@@ -0,0 +1,91 @@
1
+ ---
2
+ title: Sessions transfer
3
+ description: Moving a session to another machine, the export bundle and its manifest, placing it with import, the refusal reasons, and what a resume on the target depends on
4
+ ---
5
+
6
+ # Sessions transfer
7
+
8
+ The roster these verbs check an id against is in `sessions.md`.
9
+
10
+ ## Export
11
+
12
+ `canon sessions export <id>` packs one session into a single gzip tarball so it can resume on another machine. The bundle holds the transcript, its side folder with every path kept, and a manifest.
13
+
14
+ ```bash
15
+ canon sessions export <id>
16
+ canon sessions export <id> --out ~/session.tar.gz --json
17
+ ```
18
+
19
+ | Option | Behavior |
20
+ | -------------- | ---------------------------------------------------------- |
21
+ | `--out <path>` | Write the bundle here rather than under the scratch folder |
22
+ | `--json` | Add a machine-readable record on stdout |
23
+
24
+ Without `--out` the bundle lands at `.canon/tmp/session-export/<id>.tar.gz` under the main worktree root, which is gitignored and reclaimed by `canon records prune-tmp`. It writes locally and never uploads. A transcript holds whatever tool output passed through the session, credentials included where one was printed, so treat the bundle as private.
25
+
26
+ Exit codes: `0` the bundle was written, `1` refused. The refusal carries a `reason` of `not-found`, where no project folder holds a transcript for the id, or `no-archive`, where the running Bun predates `Bun.Archive`.
27
+
28
+ The lookup walks every folder under the configuration directory's `projects/`, so a transcript filed under any encoding is found. The transcript is read up to its last newline and never rewritten, so a session exporting itself mid-append drops at most the half line it was writing.
29
+
30
+ Export runs no skill. Write a handoff with `canon:session-compact` and push it with `canon records push` first where one is wanted. The manifest reports whether a handoff exists and whether it reached the records history, and whatever calls the verb owns that order.
31
+
32
+ ### The manifest
33
+
34
+ | Field | Holds |
35
+ | ------------------ | ---------------------------------------------------------------------------- |
36
+ | `format` | The bundle format version, `1` |
37
+ | `sessionId` | The exported id |
38
+ | `title` | The last custom title the transcript recorded, or `null` |
39
+ | `cwd` | The last working directory the transcript recorded, or `null` |
40
+ | `root` | The main worktree root of the repository the export read |
41
+ | `origin` | The `origin` remote reduced to `host/path`, or `null` |
42
+ | `branch`, `head` | The branch and commit of the checkout the export ran in |
43
+ | `recordsHead` | The records history `HEAD`, or `null` where no records history exists |
44
+ | `compacted` | Whether the transcript carries a compaction boundary |
45
+ | `live` | Whether the id was live in the roster at export |
46
+ | `handoff` | The newest `.canon/compact/` note written since the session began, or `null` |
47
+ | `handoffCommitted` | Whether that note is committed in the records history |
48
+ | `recommend` | `handoff` where a handoff exists, `transcript` otherwise |
49
+
50
+ `recommend` prefers the handoff even though the transcript resumes in full. A resumed transcript carries the source machine's absolute paths into a session working somewhere else, and a handoff states the work without them.
51
+
52
+ ## Import
53
+
54
+ `canon sessions import <bundle>` places an exported session under this machine's configuration directory so `claude` can resume it.
55
+
56
+ ```bash
57
+ canon sessions import session.tar.gz
58
+ canon sessions import session.tar.gz --root ~/repos/canon --json
59
+ ```
60
+
61
+ | Option | Behavior |
62
+ | --------------- | ---------------------------------------------------------- |
63
+ | `--root <path>` | The repository to resume in. Defaults to the main worktree |
64
+ | `--json` | Add a machine-readable record on stdout |
65
+
66
+ The transcript and side folder land under `projects/<encoding>/`, where the encoding is the root with every character outside `[A-Za-z0-9]` turned into a dash. Import writes new files only and never overwrites.
67
+
68
+ Exit codes: `0` the session was placed, `1` refused. The refusal carries one of these reasons:
69
+
70
+ - `not-a-bundle`: the file is no gzip tarball, carries no manifest at format `1`, or names a path outside its own session, such as one climbing out with `..` or an absolute one
71
+ - `other-repository`: the local `origin` differs from the one the bundle recorded
72
+ - `exists`: the target transcript or side folder is already there, or any other project folder already holds a transcript for the id, which `--resume <id>` could reach first
73
+ - `live-here`: the id is live in the local roster, so a second copy would fork it
74
+ - `write-failed`: a write failed partway for a reason other than a copy appearing, such as a bundle carrying a file where it also needs a folder
75
+ - `no-archive`: the running Bun predates `Bun.Archive`
76
+
77
+ A write that fails partway removes every file this run placed and every folder it created before refusing, so a retry of the same bundle does not refuse on its own leftovers. Any path it could not remove is named in the message. A copy that appears between the existence check and the write refuses as `exists` on the same terms.
78
+
79
+ A placed session can still lag the source. The record's `behind` list carries `repository-behind` when this checkout lacks the exported `HEAD`, and `records-behind` when the records history lacks the exported records commit, which `canon records pull` brings. Neither refuses, since each has a remedy the reader runs before resuming.
80
+
81
+ On success the record carries two `routes`. One runs `claude --resume <id>` from the root, and the other starts `claude` there and runs `/canon:session-resume` bare against the handoff.
82
+
83
+ ### What resume depends on
84
+
85
+ Measured on `claude` 2.1.281 on 2026-09-24, `--resume <id>` found a copied transcript in any folder under `projects/` and chained the next turn onto its full history, run from a repository path the transcript never recorded. `--continue` found it only under the local encoding, which is why import derives the folder rather than keeping the source machine's.
86
+
87
+ The encoding is inferred from observed folders rather than read from a published rule, and how a client shortens a path past its length limit was never observed. A client changing the rule leaves `--continue` looking elsewhere while `--resume <id>` still works. The framed report names the derived folder so a reader can compare.
88
+
89
+ The side folder did not change the conversation sent. What it carries is `tool-results/`, which a `persisted-output` pointer in the transcript names by the source machine's absolute path, and `subagents/`, which holds background agent transcripts. On a machine whose path matches, those pointers resolve. On one whose path differs, they dangle.
90
+
91
+ Resuming on the target while the source session keeps running forks the conversation, and nothing detects that across machines. The manifest's `live` field and the printed routes are the only warning.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Sessions
3
- description: Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, what each session surface can see, and moving a session to another machine with export and import
3
+ description: Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, what each session surface can see, and where moving a session to another machine is described
4
4
  ---
5
5
 
6
6
  # Sessions
@@ -117,88 +117,9 @@ Every row carries `statusUpdatedAt`, the stamp a client writes beside `status` a
117
117
 
118
118
  The dwell is what separates a status that resolves on its own from one that does not. `busy` and `idle` transition without help, so a long dwell there is ordinary. `waiting` does not: a session in that state is blocked on something outside itself, and a dwell that keeps growing past the ordinary span of a prompt is a session stalled rather than paused. `canon sessions list` renders the dwell beside the status at the coarsest unit that keeps it a whole number, and the JSON record carries both fields on every row.
119
119
 
120
- ## Export
120
+ ## Export and import
121
121
 
122
- `canon sessions export <id>` packs one session into a single gzip tarball so it can resume on another machine. The bundle holds the transcript, its side folder with every path kept, and a manifest.
123
-
124
- ```bash
125
- canon sessions export <id>
126
- canon sessions export <id> --out ~/session.tar.gz --json
127
- ```
128
-
129
- | Option | Behavior |
130
- | -------------- | ---------------------------------------------------------- |
131
- | `--out <path>` | Write the bundle here rather than under the scratch folder |
132
- | `--json` | Add a machine-readable record on stdout |
133
-
134
- Without `--out` the bundle lands at `.canon/tmp/session-export/<id>.tar.gz` under the main worktree root, which is gitignored and reclaimed by `canon records prune-tmp`. It writes locally and never uploads. A transcript holds whatever tool output passed through the session, credentials included where one was printed, so treat the bundle as private.
135
-
136
- Exit codes: `0` the bundle was written, `1` refused. The refusal carries a `reason` of `not-found`, where no project folder holds a transcript for the id, or `no-archive`, where the running Bun predates `Bun.Archive`.
137
-
138
- The lookup walks every folder under the configuration directory's `projects/`, so a transcript filed under any encoding is found. The transcript is read up to its last newline and never rewritten, so a session exporting itself mid-append drops at most the half line it was writing.
139
-
140
- Export runs no skill. Write a handoff with `canon:session-compact` and push it with `canon records push` first where one is wanted. The manifest reports whether a handoff exists and whether it reached the records history, and whatever calls the verb owns that order.
141
-
142
- ### The manifest
143
-
144
- | Field | Holds |
145
- | ------------------ | ---------------------------------------------------------------------------- |
146
- | `format` | The bundle format version, `1` |
147
- | `sessionId` | The exported id |
148
- | `title` | The last custom title the transcript recorded, or `null` |
149
- | `cwd` | The last working directory the transcript recorded, or `null` |
150
- | `root` | The main worktree root of the repository the export read |
151
- | `origin` | The `origin` remote reduced to `host/path`, or `null` |
152
- | `branch`, `head` | The branch and commit of the checkout the export ran in |
153
- | `recordsHead` | The records history `HEAD`, or `null` where no records history exists |
154
- | `compacted` | Whether the transcript carries a compaction boundary |
155
- | `live` | Whether the id was live in the roster at export |
156
- | `handoff` | The newest `.canon/compact/` note written since the session began, or `null` |
157
- | `handoffCommitted` | Whether that note is committed in the records history |
158
- | `recommend` | `handoff` where a handoff exists, `transcript` otherwise |
159
-
160
- `recommend` prefers the handoff even though the transcript resumes in full. A resumed transcript carries the source machine's absolute paths into a session working somewhere else, and a handoff states the work without them.
161
-
162
- ## Import
163
-
164
- `canon sessions import <bundle>` places an exported session under this machine's configuration directory so `claude` can resume it.
165
-
166
- ```bash
167
- canon sessions import session.tar.gz
168
- canon sessions import session.tar.gz --root ~/repos/canon --json
169
- ```
170
-
171
- | Option | Behavior |
172
- | --------------- | ---------------------------------------------------------- |
173
- | `--root <path>` | The repository to resume in. Defaults to the main worktree |
174
- | `--json` | Add a machine-readable record on stdout |
175
-
176
- The transcript and side folder land under `projects/<encoding>/`, where the encoding is the root with every character outside `[A-Za-z0-9]` turned into a dash. Import writes new files only and never overwrites.
177
-
178
- Exit codes: `0` the session was placed, `1` refused. The refusal carries one of these reasons:
179
-
180
- - `not-a-bundle`: the file is no gzip tarball, carries no manifest at format `1`, or names a path outside its own session, such as one climbing out with `..` or an absolute one
181
- - `other-repository`: the local `origin` differs from the one the bundle recorded
182
- - `exists`: the target transcript or side folder is already there, or any other project folder already holds a transcript for the id, which `--resume <id>` could reach first
183
- - `live-here`: the id is live in the local roster, so a second copy would fork it
184
- - `write-failed`: a write failed partway for a reason other than a copy appearing, such as a bundle carrying a file where it also needs a folder
185
- - `no-archive`: the running Bun predates `Bun.Archive`
186
-
187
- A write that fails partway removes every file this run placed and every folder it created before refusing, so a retry of the same bundle does not refuse on its own leftovers. Any path it could not remove is named in the message. A copy that appears between the existence check and the write refuses as `exists` on the same terms.
188
-
189
- A placed session can still lag the source. The record's `behind` list carries `repository-behind` when this checkout lacks the exported `HEAD`, and `records-behind` when the records history lacks the exported records commit, which `canon records pull` brings. Neither refuses, since each has a remedy the reader runs before resuming.
190
-
191
- On success the record carries two `routes`. One runs `claude --resume <id>` from the root, and the other starts `claude` there and runs `/canon:session-resume` bare against the handoff.
192
-
193
- ### What resume depends on
194
-
195
- Measured on `claude` 2.1.281 on 2026-09-24, `--resume <id>` found a copied transcript in any folder under `projects/` and chained the next turn onto its full history, run from a repository path the transcript never recorded. `--continue` found it only under the local encoding, which is why import derives the folder rather than keeping the source machine's.
196
-
197
- The encoding is inferred from observed folders rather than read from a published rule, and how a client shortens a path past its length limit was never observed. A client changing the rule leaves `--continue` looking elsewhere while `--resume <id>` still works. The framed report names the derived folder so a reader can compare.
198
-
199
- The side folder did not change the conversation sent. What it carries is `tool-results/`, which a `persisted-output` pointer in the transcript names by the source machine's absolute path, and `subagents/`, which holds background agent transcripts. On a machine whose path matches, those pointers resolve. On one whose path differs, they dangle.
200
-
201
- Resuming on the target while the source session keeps running forks the conversation, and nothing detects that across machines. The manifest's `live` field and the printed routes are the only warning.
122
+ `canon sessions export <id>` packs one session into a bundle and `canon sessions import <bundle>` places it on another machine. Both are in `sessions-transfer.md`.
202
123
 
203
124
  ## What the read depends on
204
125
 
@@ -5,7 +5,7 @@ description: Measuring both skill corpora against standards/skill.md, the checks
5
5
 
6
6
  # Skill audit
7
7
 
8
- `canon claude skills audit [path]` reports both skill corpora against the rules `standards/skill.md` states mechanically. It reads and reports. Fixing what it finds is separate work.
8
+ `canon claude skills audit [path]` reports both skill corpora against the rules `standards/skill.md` and `standards/skill-requirement.md` state mechanically. It reads and reports. Fixing what it finds is separate work.
9
9
 
10
10
  ```bash
11
11
  canon claude skills audit
@@ -45,7 +45,7 @@ Each finding names the file and the line, such as `claude/skills/<name>/referenc
45
45
 
46
46
  ## What it leaves alone
47
47
 
48
- The report names its own blind spots on every run, including the run where everything passed. Whether each `Must` traces to a stated gap is the rule in that standard worth the most and no parser reads it. Whether a gap states an observed failure rather than an intent, and whether a description routes, are the same kind of judgment. The 150-line body checkpoint is the one mechanical rule still absent here, and adding it would print a count rather than a defect, since the standard makes it a prompt to look with nothing enforcing it.
48
+ The report names its own blind spots on every run, including the run where everything passed. Whether each `Must` traces to a stated gap is the rule in the requirement standard worth the most and no parser reads it. Whether a gap states an observed failure rather than an intent, and whether a description routes, are the same kind of judgment. The 150-line body checkpoint is the one mechanical rule still absent here, and adding it would print a count rather than a defect, since the standard makes it a prompt to look with nothing enforcing it.
49
49
 
50
50
  The date check reads ISO dates alone. A date written in words, a month with no day, and provenance carrying no date at all pass it.
51
51
 
@@ -50,7 +50,7 @@ canon tasks archive --pull-request 673 --json
50
50
  | `--json` | Emit a machine-readable record on stdout |
51
51
  | `--root <path>` | Board root, defaulting to the main worktree |
52
52
 
53
- Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, or `bad-input`.
53
+ Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, `earlier-slice`, or `bad-input`. `earlier-slice` fires when `--pull-request` names a number that is not the last on the task's `Pull request:` line, since outcomes are ticked at ship time and only the last slice's merge closes the task.
54
54
 
55
55
  An outcome whose body is struck reads as cut rather than open or closed, whatever its checkbox holds: `- ~~<outcome>~~ <why>`. A task carrying only cut outcomes archives, since the gate refuses `no-outcomes` only when both the closed and the cut counts are zero. The success record carries `closed` and `cut` as counts, so a reader tells a shipped task from an abandoned one without opening the file.
56
56
 
@@ -245,13 +245,13 @@ canon tasks plan-link v28.1-trigger-escalation .canon/plans/feature-dispatch-ans
245
245
 
246
246
  The plan resolves the same way `canon tasks plan-answers` resolves one, against the project root first and `.canon/tasks/` second, so a bare slug and a board-relative path both work. A reference resolving to no file refuses as `no-plan`, naming every base it looked under.
247
247
 
248
- The write mirrors `canon tasks pull-request`'s add/correct/unchanged shape, anchored on the H1 rather than on the last origin line, since `Plan:` is the first origin line a task carries rather than the last. The `action` field reports `added`, `corrected`, or `unchanged`, which makes a rerun against the same plan safe.
248
+ The write adds the line when it is absent and corrects the target in place when it exists, anchored on the H1 rather than on the last origin line, since `Plan:` is the first origin line a task carries rather than the last. The `action` field reports `added`, `corrected`, or `unchanged`, which makes a rerun against the same plan safe.
249
249
 
250
250
  Exit codes: `0` recorded, `1` refused. The `reason` field carries `no-board`, `no-match`, `no-plan`, or `bad-input`.
251
251
 
252
252
  ## Pull request
253
253
 
254
- `canon tasks pull-request` records the number a branch's pull request carries onto the task that branch closes. It adds `Pull request: #NNN` under the `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` lines the task already holds, and corrects the number in place when the line exists.
254
+ `canon tasks pull-request` records the number a branch's pull request carries onto the task that branch closes. It adds `Pull request: #NNN` under the `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` lines the task already holds. When the line exists it appends `, #NNN` rather than replacing it, so a task shipped in slices lists every pull request oldest first, and a number already listed leaves the line alone. A line that does not read as a comma-separated list of `#NNN` entries is replaced whole.
255
255
 
256
256
  Name the task by its filename stem, or by the plan its `Plan:` line points at:
257
257
 
@@ -266,7 +266,7 @@ canon tasks pull-request 673 --plan worktree-scratch-routing --json
266
266
  | `--json` | Emit a machine-readable record on stdout |
267
267
  | `--root <path>` | Board root, defaulting to the main worktree |
268
268
 
269
- A plan is matched on the token both spellings share, so `worktree-scratch-routing`, `feature-worktree-scratch-routing`, and `.canon/plans/feature-worktree-scratch-routing.md` all select the same task. The `action` field reports `added`, `corrected`, or `unchanged`, which makes a rerun against the same number safe.
269
+ A plan is matched on the token both spellings share, so `worktree-scratch-routing`, `feature-worktree-scratch-routing`, and `.canon/plans/feature-worktree-scratch-routing.md` all select the same task. The `action` field reports `added`, `appended`, `corrected`, or `unchanged`, which makes a rerun against the same number safe.
270
270
 
271
271
  Exit codes: `0` recorded, `1` refused. The `reason` field carries `no-board`, `no-match`, or `ambiguous`. `git-pr` skips silently on those three, because each is a case where a guessed write would archive the wrong task once the branch merges.
272
272
 
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: Teach authoring
3
+ description: Resolving what the next lesson needs before it is written, rendering a lesson body's blocks to HTML, and rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions
4
+ ---
5
+
6
+ # Teach authoring
7
+
8
+ The verbs a session runs while writing a lesson. Workspace resolution, the verbs that open and record into a workspace, and the refusal reasons they share are in `teach.md`.
9
+
10
+ ## Lesson
11
+
12
+ `canon teach lesson` resolves what the next lesson needs before it is written. It reads and never writes.
13
+
14
+ ```bash
15
+ canon teach lesson regular-expressions \
16
+ --slug capture-groups \
17
+ --questions 3 \
18
+ --options 4
19
+ ```
20
+
21
+ | Option | Behavior |
22
+ | ----------------- | --------------------------------------------------------- |
23
+ | `--slug <kebab>` | The lesson's own topic, required |
24
+ | `--questions <n>` | How many questions the quiz carries, required |
25
+ | `--options <n>` | How many options each question carries, defaulting to `4` |
26
+ | `--json` | Emit a machine-readable record on stdout |
27
+ | `--root <path>` | Teach root, defaulting to the main worktree |
28
+
29
+ It reports three things. `lesson` is the numbered path the lesson takes, derived from the highest ordinal already in `lessons/` the way an open derives a workspace ordinal. `success` carries the mission's success lines, so a session reports progress against the exit criteria without a second read of `MISSION.md`. `quiz` carries one entry per question.
30
+
31
+ A lesson written against this record carries its chrome as four empty marker pairs rather than hand-composed markup: `canon:teach:style`, `canon:teach:header`, `canon:teach:footnav`, and `canon:teach:scripts`. `canon teach nav`, below, fills them from what the workspace holds on disk.
32
+
33
+ `canon teach stylesheet <topic>` writes the pair every lesson in the workspace renders through, seeded from the design source so a workspace renders in the system every other surface does. The two halves are written on different terms. `assets/base.css` holds the generated design layer and is rewritten on every run, which is the only way a change to the shared chrome reaches a workspace that already exists. `assets/course.css` is the half a workspace owns, so it refuses to overwrite, reporting `written` as false where the workspace already carries one, and `--force` takes the seed back over it. Each workspace used to carry a hand-authored palette, which is how the course styling forked once per workspace, so a lesson adds its own rules under the seed and reaches a value through its custom property rather than restating the hex.
34
+
35
+ Each `quiz` entry carries `order`, the authored option indices in presentation order where index `0` is the correct answer, and `answer`, the one-based position that answer lands in. Both travel together because a caller deriving the position itself is a caller that can derive it wrongly.
36
+
37
+ The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
38
+
39
+ ## Render
40
+
41
+ `canon teach render` renders a lesson body's structural blocks to HTML, through the four lesson components. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
42
+
43
+ ```bash
44
+ echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach render --json
45
+ ```
46
+
47
+ | Option | Behavior |
48
+ | -------- | ---------------------------------------- |
49
+ | `--json` | Emit a machine-readable record on stdout |
50
+
51
+ It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, `refs`, or `raw` block, and reports `{ ok: true, html }` on `--json` or the bare rendered HTML on stdout otherwise. Content none of the components can express takes a `raw` block, carrying its own HTML verbatim and unescaped, which is the shape the quiz and the teach-back block travel in.
52
+
53
+ A citation takes two shapes. A `paragraph` carries an optional `cites` array of reference numbers, rendered as `sup.cite` markers after its text. The one `refs` block carries `items` of `{ title, url?, note? }`, rendered as `ol.refs` and numbered from 1, so cite `1` links to the first item.
54
+
55
+ ```json
56
+ [
57
+ { "type": "paragraph", "text": "North is fixed.", "cites": [1] },
58
+ {
59
+ "type": "refs",
60
+ "items": [{ "title": "Compass manual", "url": "https://example.com" }]
61
+ }
62
+ ]
63
+ ```
64
+
65
+ It refuses `bad-input` on empty stdin, on malformed JSON, on stdin that does not parse to an array, and on a block carrying an unrecognized `type` or a field of the wrong shape for its type, naming the block's index in the message. Citations add four refusals, each naming the offending block: a cite that resolves to no reference, `cites` on a lede paragraph, a second `refs` block, and a reference `url` that is not `http` or `https`.
66
+
67
+ ## Nav
68
+
69
+ `canon teach nav` rewrites the teach-root listing, a workspace's contents page, and each of its lessons' chrome, from what the workspace holds on disk.
70
+
71
+ ```bash
72
+ canon teach nav
73
+ canon teach nav regular-expressions --json
74
+ ```
75
+
76
+ | Option | Behavior |
77
+ | --------------- | ------------------------------------------------- |
78
+ | `[topic]` | Workspace folder or topic, scoping the run to one |
79
+ | `--json` | Emit a machine-readable record on stdout |
80
+ | `--root <path>` | Teach root, defaulting to the main worktree |
81
+
82
+ With no topic it rewrites every workspace. The teach-root listing is always rewritten regardless, since it reports on every workspace and a scoped run leaving it stale would be a second kind of drift this verb exists to end.
83
+
84
+ 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 toolkit's favicon, the shared stylesheet, and the script that settles the course panel before the page paints, rebuilding the course sidebar, the breadcrumb and jump menus, the prev/next footer nav, and the behavior scripts. It leaves the authored `<h1>`, lede, body, and quiz between the header and the footnav as written, with two exceptions. It wraps them in `<main>` when the lesson holds none, since the column width hangs off that element, and a lesson that already carries a `<main>` keeps its own.
85
+
86
+ It also links each "lesson NNNN" mention in that region to the lesson file in the same workspace carrying that number, skipping text inside `a`, `code`, `pre`, `script`, `style`, `label`, `summary`, and comments. A link the verb writes carries `data-lesson-ref`, and it is the only kind the verb rewrites: every run points one at the current filename for its number, or unwraps it to plain text once that number names no lesson. A link an author wrote is never touched.
87
+
88
+ Every workspace label in that chrome, being the breadcrumb, the sidebar heading, the workspace switcher, and the root listing's row, reads the mission's `title` and falls back to the folder topic in sentence case where the mission names none.
89
+
90
+ It also deletes two links a session used to write by hand into a lesson's `<head>`, each matched as its exact string: the old icon link and `<link rel="stylesheet" href="../assets/course.css" />`. The page keeps only the icon and the stylesheets the `style` region carries. Any other link an author wrote stays.
91
+
92
+ 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 `style` region links `assets/base.css` ahead of an embedded copy of `course.css`, so the workspace's own rules still win the cascade. The embedded copy drops the seed's `@import` line, which resolved against the lesson's own folder and never loaded anything, and the link is what reaches the base sheet in its place. A workspace with no `assets/base.css`, one holding a hand-authored `course.css` from before the seed, gets no link rather than one pointing at a missing file.
93
+
94
+ That seeding fires on the absent file alone, so a workspace already holding a `course.css` has neither half rewritten here. A change to the shared chrome therefore takes `canon teach stylesheet <topic>` per workspace, which rewrites `assets/base.css`, and every lesson picks it up through its link with no run of this verb. Running this one alone leaves every lesson on the `base.css` the workspace already holds, and nothing reports it. Only an edit to a workspace's own `course.css` needs this verb, since that half is embedded.
95
+
96
+ 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.
97
+
98
+ A mention whose number names no lesson in its workspace stays plain text and lands in the record's `unresolved` list, one `{ file, lesson }` entry per distinct number per lesson, with `file` relative to the root the way a `skipped` entry is. The lesson still rewrites and counts toward `lessons`, and the exit stays 0. A mention of the lesson's own number stays plain and is not reported, and a refused lesson reports nothing.
99
+
100
+ 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.
101
+
102
+ The block hides and shows and sets nothing else, which leaves a workspace's own layout for a question and its own appearance for an option and a feedback panel. The one value it names is `display: block` on revealed feedback, since every stylesheet grown under the retired script carries `.fb { display: none }` and nothing adds the class that used to reveal it.
103
+
104
+ One detector decides both regions. A lesson carrying `<button class="opt"`, the shape written before the stepper, takes the feedback script and no stepper, and every other lesson takes the stepper and no script. That is a detector rather than a mode, since a lesson has one shape or the other, and removing the script outright would leave the lessons already written showing no feedback at all.
105
+
106
+ Splicing the stepper into a lesson of either shape was measured against a browser and reverted. Its rules match a button question, which can never hold a checked radio, so a lesson given both hides every question past the first for good and outranks the class the script reveals feedback with. Nothing else caught that: the markup is valid, the splice succeeds, and the page renders.
107
+
108
+ ### Reference pages
109
+
110
+ Each reference page is rendered to a `reference/<slug>.html` sibling carrying the same chrome, and the contents page links that sibling rather than the markdown, which `canon serve` hands over as plain text. The render drops the frontmatter, turns tables into `<table>` markup, and escapes raw HTML written in the markdown rather than passing it through. A body with no H1 gets one from the frontmatter `title`. A relative link to another reference page's markdown points at that page's `.html` sibling, and one to the workspace `GLOSSARY.md` points at the glossary on the contents page, `../index.html#gloss`. Every other link, such as `../RESOURCES.md`, stays as written. The markdown file is never touched.
111
+
112
+ Every rendered page carries a `<meta name="generator" content="canon teach nav">` tag. A `.html` file under `reference/` whose markdown is gone is deleted when it carries that tag, and a hand-written HTML file there, carrying none, is left alone. The record's `reference` field counts the pages rendered. On a Bun with no `Bun.markdown`, each reference page lands in `skipped` with `Bun.markdown` as what is missing, and the rest of the run goes on.