@erclx/canon 4.115.0 → 4.117.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 (68) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +12 -12
  4. package/claude/skills/create-skill/REQUIREMENT.md +3 -0
  5. package/claude/skills/create-skill/SKILL.md +3 -1
  6. package/claude/skills/docs-fold/SKILL.md +1 -1
  7. package/claude/skills/memory-review/SKILL.md +4 -4
  8. package/claude/skills/review-pr/REQUIREMENT.md +2 -0
  9. package/claude/skills/review-pr/SKILL.md +2 -2
  10. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
  11. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  12. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
  13. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  14. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
  15. package/claude/skills/role-worker/REQUIREMENT.md +2 -0
  16. package/claude/skills/role-worker/SKILL.md +2 -2
  17. package/claude/skills/session-compact/REQUIREMENT.md +2 -1
  18. package/claude/skills/session-compact/SKILL.md +5 -1
  19. package/claude/skills/teach-workspace/SKILL.md +5 -3
  20. package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
  21. package/claude/skills/write-human/references/source-material.md +2 -2
  22. package/docs/agents/commands.md +3 -1
  23. package/docs/agents/context-audit-checks.md +1 -1
  24. package/docs/agents/index.md +1 -1
  25. package/docs/agents/install-and-sync.md +1 -1
  26. package/docs/agents/markdown-audit.md +7 -3
  27. package/docs/agents/sandbox.md +1 -1
  28. package/docs/agents/sessions.md +84 -1
  29. package/docs/agents/skills-audit.md +8 -1
  30. package/docs/agents/teach.md +20 -4
  31. package/docs/workflow/ai-workflow.md +9 -9
  32. package/governance/rules/ui/440-surface-capture.md +2 -0
  33. package/package.json +1 -1
  34. package/scripts/core/check-capability-seeding.sh +4 -2
  35. package/src/claude/cases/misc.ts +5 -0
  36. package/src/claude/skills-audit.ts +59 -2
  37. package/src/commands/claude.ts +30 -0
  38. package/src/commands/markdown.ts +63 -2
  39. package/src/commands/sessions.ts +222 -0
  40. package/src/commands/teach.ts +18 -3
  41. package/src/context/audit.ts +6 -8
  42. package/src/gate/measures.ts +60 -0
  43. package/src/gate/stages.ts +12 -2
  44. package/src/markdown/ceiling.ts +70 -0
  45. package/src/markdown/structure.ts +22 -0
  46. package/src/records/backup.ts +1 -1
  47. package/src/sandbox/census.ts +1 -1
  48. package/src/sessions/registry.ts +11 -7
  49. package/src/sessions/transfer/bundle.ts +546 -0
  50. package/src/sessions/transfer/transcript.ts +148 -0
  51. package/src/teach/components/paragraph.tsx +19 -2
  52. package/src/teach/components/refs.tsx +28 -0
  53. package/src/teach/cross-references.ts +78 -0
  54. package/src/teach/lesson.ts +1 -1
  55. package/src/teach/nav.ts +75 -7
  56. package/src/teach/render.ts +94 -6
  57. package/src/tooling/inject.ts +13 -5
  58. package/src/tooling/manifest.ts +9 -0
  59. package/src/tooling/scan.ts +23 -6
  60. package/src/tooling/subfolder.ts +3 -3
  61. package/standards/context.md +2 -2
  62. package/standards/markdown.md +2 -0
  63. package/standards/skill.md +1 -0
  64. package/tooling/base/reference.md +2 -2
  65. package/tooling/web/configs/e2e/screenshot.ts +43 -7
  66. package/tooling/web/reference.md +1 -1
  67. /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
  68. /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -0
@@ -27,9 +27,9 @@ A bare run measures every markdown file git lists, tracked plus untracked-and-no
27
27
 
28
28
  ## Where the rules come from
29
29
 
30
- The three ban sets and all nine checkpoints ship with the `canon` package as data, in `src/markdown/bans.ts` and `src/markdown/structure.ts`. Every project is measured against the same sets whether or not it installed any standards, and no file has to resolve for a run to mean something.
30
+ The three ban sets and all ten checkpoints ship with the `canon` package as data, in `src/markdown/bans.ts` and `src/markdown/structure.ts`. Every project is measured against the same sets whether or not it installed any standards, and no file has to resolve for a run to mean something.
31
31
 
32
- Six of the nine are stated in `markdown.md` and the three cadence numbers are stated in the `write-human` skill. That split is the content boundary rather than an accident: `markdown.md` carries the enforced rules a scan can decide, and the skill carries the rhythm rules a ban list cannot express. A cadence number moved in the skill and left in the code drifts the same way, so move both.
32
+ Seven of the ten are stated in `markdown.md` and the three cadence numbers are stated in the `write-human` skill. That split is the content boundary rather than an accident: `markdown.md` carries the enforced rules a scan can decide, and the skill carries the rhythm rules a ban list cannot express. A cadence number moved in the skill and left in the code drifts the same way, so move both.
33
33
 
34
34
  Reading them out of the standards per run was the original design. It put a parser contract on a document authored for people, and the standard had to carry a paragraph of its own warning an author that a one-word backticked example in a `- Do not use ` bullet would be lifted into a literal ban set and ban that word everywhere. A rule existing to protect a parser from the prose it parses is the argument for separating them.
35
35
 
@@ -128,9 +128,13 @@ Two of the rules `write-human` states are deliberately not implemented. A senten
128
128
 
129
129
  The condition on that was something identifying a finite verb rather than guessing at one, and two parsers have now been run against it over 11,389 paragraph sentences. They disagree by a factor of four. `compromise` reports 2 percent and reads a fronted past participle as a finite verb, so `Measured at <sha> on <date>.` counts as carrying one. `wink-pos-tagger` reports 9 percent, fixes that class, and is still wrong on roughly three in four, because an imperative's verb tags as a proper noun and a noun-ambiguous predicate tags as a noun, which makes `Each maps to a skill.` read verbless. Separating those needs to know which token is the predicate, and that is syntax rather than a tag. The measure stays unimplemented, now against a mechanism rather than against the idea of one.
130
130
 
131
+ ### Length
132
+
133
+ The Length step sums rendered lines over the whole source, frontmatter and fenced blocks included, and lists each document past the 300-line ceiling, longest first. A changelog and a document carrying a whole-line `<!-- canon-length-exempt: <reason> -->` outside a fence are counted in one line rather than listed. Each `--json` entry carries `renderedLines` and `exempt`, the stated reason or `null`, and `checkpoints` carries `ceiling`.
134
+
131
135
  ## Exit codes
132
136
 
133
- Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit or a dead relative link, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling and a relative link resolving to nothing on disk each fail the run, both facts a scan settles rather than a reader judging. Bullet, paragraph, and depth weight are judgments a reader settles, and cadence is a distribution whose healthy range moves with the surface, so all four report under every code.
137
+ Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit or a dead relative link, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling and a relative link resolving to nothing on disk each fail the run, both facts a scan settles rather than a reader judging. Bullet, paragraph, and depth weight are judgments a reader settles, and cadence is a distribution whose healthy range moves with the surface, so all four report under every code. Length reports the same way, since the audit also runs on a plan kept out of version control, and the Document ceiling stage in `canon gate run` owns that verdict instead.
134
138
 
135
139
  `3` is separate from `1` because the two want different responses from a caller. A refusal means no corpus was built, and the `Markdown bans` stage in `canon gate run` is right to report it as unmeasured rather than as a pass. An empty set means the corpus was walked and nothing was looked for, so that stage fails the push on `3` rather than skipping.
136
140
 
@@ -39,7 +39,7 @@ The verdict `state` is `pass`, `fail`, or `unchecked`. An arm with no `expect.to
39
39
 
40
40
  Omitting `--writes`, `--escapes`, or `--envelope` does not silently drop the assertion kinds that need them. Write scope, escape scope, the turn ceiling, and the reply assertion report as unchecked and appear in the count, so the standalone command cannot claim more coverage than it had. Supplying `--escapes` without `--escapes-watched` reports the same way: a zero-escape result with no root confirmed watched is unmeasured rather than a pass. A verdict never reports `pass` with zero assertions.
41
41
 
42
- An arm declaring `escape_scope` asserts a bound on `run.sh`'s own escape watch rather than on the sandbox tree. `write_scope` skips when the run wrote nothing, since a required output missing is itself a finding, but `escape_scope` passes on zero escapes outright, since a clean run producing none is the expected outcome for a destination nothing requires a skill to touch, provided `--escapes-watched` confirms a root held something to watch. Declaring `escape_scope = []` asserts that a correct run reaches none of the watched destinations at all. `canon/context/sandbox/overview.md` names what the watch reaches and what it cannot.
42
+ An arm declaring `escape_scope` asserts a bound on `run.sh`'s own escape watch rather than on the sandbox tree. `write_scope` skips when the run wrote nothing, since a required output missing is itself a finding, but `escape_scope` passes on zero escapes outright, since a clean run producing none is the expected outcome for a destination nothing requires a skill to touch, provided `--escapes-watched` confirms a root held something to watch. Declaring `escape_scope = []` asserts that a correct run reaches none of the watched destinations at all. `canon/context/sandbox/isolation.md` names what the watch reaches and what it cannot.
43
43
 
44
44
  An envelope that parses but carries no `result` field skips the reply assertion the same way an absent file does. An envelope carrying an empty `result` fails it, since a run that returned no text is a finding rather than a gap in the input.
45
45
 
@@ -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, and what each session surface can see
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
4
4
  ---
5
5
 
6
6
  # Sessions
@@ -117,6 +117,89 @@ 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
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.
202
+
120
203
  ## What the read depends on
121
204
 
122
205
  The records live under the Claude Code configuration directory, which the verb resolves from `CLAUDE_CONFIG_DIR` and falls back to `~/.claude`. Their location, their filenames, and the fields inside them are a client implementation detail rather than a published interface, so a client change can move them. The verb reports an absent registry as a refusal rather than as a machine running no sessions, which is what surfaces the move instead of burying it in an empty roster.
@@ -35,18 +35,25 @@ Each check traces to a line in the standard.
35
35
  - No `README.md` inside a skill folder
36
36
  - Folder name in kebab-case
37
37
  - Each `REQUIREMENT.md` declaring `Gap` and `Must`, matched at any heading level
38
+ - No ISO date in `SKILL.md` or any file under `references/`, reported as `datedProvenance` in the JSON record's `findings`
38
39
 
39
40
  A body whose frontmatter does not parse reports as declaring neither field rather than ending the run, so one malformed file cannot hide the corpus behind it. A key present with an empty value reads as absent, since a blank `name` would otherwise report as a name disagreeing with every folder. A folder carrying no requirement is reported once under presence rather than counted again for the sections it therefore lacks.
40
41
 
42
+ The date check reads prose alone. A date inside a fence or a code span is example data, such as a sample frontmatter block, and a date in the body's own frontmatter is metadata, so neither reports. `REQUIREMENT.md` and `EVAL.md` are not read, since the requirement's `Gap` is where the standard sends the incident and the date that earned a rule.
43
+
44
+ Each finding names the file and the line, such as `claude/skills/<name>/references/<file>.md line 12: <date>`. A date after "measured" or "verified" reports like any other, unlike the context audit, because a skill body states the rule a session follows today rather than a reading of the tree.
45
+
41
46
  ## What it leaves alone
42
47
 
43
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.
44
49
 
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
+
45
52
  A check with no rule behind it prints an opinion as a defect, which is where the list stops.
46
53
 
47
54
  ## Exit codes
48
55
 
49
- Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a skill folder carrying no `REQUIREMENT.md`. Only presence sets a failing code. Name, description, folder, and requirement-section findings print and return `0`, because each is a judgment a reader settles and failing a push on one would make the check something to route around.
56
+ Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a skill folder carrying no `REQUIREMENT.md`. Only presence sets a failing code. Name, description, folder, requirement-section, and dated-provenance findings print and return `0`, because each is a judgment a reader settles and failing a push on one would make the check something to route around.
50
57
 
51
58
  ## The requirement gate
52
59
 
@@ -141,7 +141,7 @@ The order is drawn here rather than instructed, and that is the point of the ver
141
141
 
142
142
  ## Render
143
143
 
144
- `canon teach render` renders a lesson body's structural blocks to HTML, through the three lesson components. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
144
+ `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.
145
145
 
146
146
  ```bash
147
147
  echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach render --json
@@ -151,9 +151,21 @@ echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach re
151
151
  | -------- | ---------------------------------------- |
152
152
  | `--json` | Emit a machine-readable record on stdout |
153
153
 
154
- It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, or `raw` block, and reports `{ ok: true, html }` on `--json` or the bare rendered HTML on stdout otherwise. Content none of the three 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.
154
+ 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.
155
155
 
156
- 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.
156
+ 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.
157
+
158
+ ```json
159
+ [
160
+ { "type": "paragraph", "text": "North is fixed.", "cites": [1] },
161
+ {
162
+ "type": "refs",
163
+ "items": [{ "title": "Compass manual", "url": "https://example.com" }]
164
+ }
165
+ ]
166
+ ```
167
+
168
+ 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`.
157
169
 
158
170
  ## Nav
159
171
 
@@ -172,7 +184,9 @@ canon teach nav regular-expressions --json
172
184
 
173
185
  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.
174
186
 
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 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, and wraps them in `<main>` when the lesson holds none, since the column width hangs off that element. A lesson that already carries a `<main>` keeps its own.
187
+ 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.
188
+
189
+ 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.
176
190
 
177
191
  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.
178
192
 
@@ -184,6 +198,8 @@ That seeding fires on the absent file alone, so a workspace already holding a `c
184
198
 
185
199
  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.
186
200
 
201
+ 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.
202
+
187
203
  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.
188
204
 
189
205
  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.
@@ -273,15 +273,15 @@ This section is the corpus the coverage claim is measured against: every name `c
273
273
 
274
274
  ### Run several tracks at once
275
275
 
276
- | Skill | When to use |
277
- | ------------------------- | ---------------------------------------------------------------------------------------------------- |
278
- | `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR |
279
- | `canon:role-planner` | To assert the thinking role for a cold session writing a plan, a groundwork track, or an intake pass |
280
- | `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan |
281
- | `canon:session-relay` | When this session owes another session a message, with or without a role |
282
- | `canon:session-resume` | At the start of a session, to pick up what a previous one left |
283
- | `canon:session-compact` | Before a compaction, to write a plain session's handoff note outside the board |
284
- | `canon:session-map` | At the close of an orchestrating session, to write its board-side handoff |
276
+ | Skill | When to use |
277
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
278
+ | `canon:role-orchestrator` | To assert the control session that owns the queue and reviews each worker's PR |
279
+ | `canon:role-planner` | To assert the thinking role for a cold session writing a plan, a groundwork track, or an intake pass |
280
+ | `canon:role-worker` | To assert the worker role for a cold session building one branch under one plan |
281
+ | `canon:session-relay` | When this session owes another session a message, with or without a role |
282
+ | `canon:session-resume` | At the start of a session, to pick up what a previous one left |
283
+ | `canon:session-compact` | Before a compaction or a move to another machine, to write a plain session's handoff note outside the board |
284
+ | `canon:session-map` | At the close of an orchestrating session, to write its board-side handoff |
285
285
 
286
286
  ### Keep the project current with the toolkit
287
287
 
@@ -44,5 +44,7 @@ paths:
44
44
  - Commit a flagged case's evidence output so the pull request carries the comparison, rather than attaching it by hand.
45
45
  - A project whose own established convention already commits that same comparison may route a flagged case's evidence there instead of a separate evidence folder. Only an established convention already committing the same comparison qualifies, not a preference for skipping a second folder.
46
46
  - Commit an evidence case for the first time only after running the capture twice with no code change between the runs and confirming the two outputs are byte-identical.
47
+ - Mask the element carrying a value that moves between runs, such as an elapsed-time counter, a clock, or a relative date, in the state's capture case before the double capture, so the check still runs on everything else in the frame. Mask the smallest element holding the value, never the state.
48
+ - Do not commit the state as evidence when the masked outputs still differ, which happens when the value's width reflows its neighbors. Name the variance in the pull request instead, and never re-run toward a match.
47
49
  - Recommitting an unbounded sweep on every run reaches a gigabyte of repository history inside a hundred merges. A small, committed evidence set is what a reviewer needs and what lets GitHub draw its own before-and-after comparison on the pull request.
48
50
  - `git-pr` and `git-followup` maintain a comparison comment on the pull request automatically once a case is committed, so a reviewer never has to open Files Changed to see it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.115.0",
4
+ "version": "4.117.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -60,7 +60,8 @@ check_capability "Hooks" "$PROJECT_ROOT/.claude/hooks" \
60
60
  "$PROJECT_ROOT"/tooling/claude/seeds/.claude/hooks/*
61
61
 
62
62
  check_capability "Workflows" "$PROJECT_ROOT/.github/workflows" \
63
- "$PROJECT_ROOT"/tooling/*/configs/.github/workflows/*
63
+ "$PROJECT_ROOT"/tooling/*/configs/.github/workflows/* \
64
+ "$PROJECT_ROOT"/tooling/*/seeds/.github/workflows/*
64
65
 
65
66
  check_capability "Husky" "$PROJECT_ROOT/.husky" \
66
67
  "$PROJECT_ROOT"/tooling/base/configs/.husky/*
@@ -90,7 +91,8 @@ check_orphans() {
90
91
  check_orphans "Hooks" "$PROJECT_ROOT/.claude/hooks" \
91
92
  "$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
92
93
 
93
- for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows; do
94
+ for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows \
95
+ "$PROJECT_ROOT"/tooling/*/seeds/.github/workflows; do
94
96
  check_orphans "Workflows" "$PROJECT_ROOT/.github/workflows" "$dir"
95
97
  done
96
98
 
@@ -36,6 +36,11 @@ export const MISC_CASES: readonly SkillCase[] = [
36
36
  "We're about to hit the context limit, write the handoff before we lose state.",
37
37
  expect: 'session-compact',
38
38
  },
39
+ {
40
+ prompt:
41
+ "I'm moving this session to another machine, write the handoff before I pack it up.",
42
+ expect: 'session-compact',
43
+ },
39
44
  {
40
45
  prompt:
41
46
  'I am the orchestrating session and about to compact, so write the board handoff as a session map on the task board.',
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
4
+ import { bodyLines, maskDisplayed } from '@/markdown/scan'
4
5
 
5
6
  /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
6
7
  export const EXIT_MISSING_REQUIREMENT = 2
@@ -33,6 +34,14 @@ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
33
34
 
34
35
  const HEADING = /^#{1,6}\s+(.+?)\s*$/
35
36
 
37
+ /**
38
+ * An ISO date, read as provenance wherever a reader is told it. The context
39
+ * audit's detector clears a date after "measured" or "verified", and a skill
40
+ * body keeps no such carve-out, since the standard sends every incident and its
41
+ * date to the requirement's Gap or to git.
42
+ */
43
+ const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/g
44
+
36
45
  export interface SkillFinding {
37
46
  readonly rel: string
38
47
  readonly detail: string
@@ -59,6 +68,7 @@ export interface SkillsAudit {
59
68
  readonly nameMismatch: readonly SkillFinding[]
60
69
  readonly longDescription: readonly SkillFinding[]
61
70
  readonly requirementSections: readonly SkillFinding[]
71
+ readonly datedProvenance: readonly SkillFinding[]
62
72
  }
63
73
 
64
74
  interface SkillSource {
@@ -69,6 +79,17 @@ interface SkillSource {
69
79
  readonly description: string | undefined
70
80
  /** Undefined when the folder carries no `REQUIREMENT.md` at all. */
71
81
  readonly requirementHeadings: readonly string[] | undefined
82
+ /**
83
+ * `SKILL.md` and every reference below the folder, keyed by the path a
84
+ * finding names. `REQUIREMENT.md` and `EVAL.md` are left out, since the
85
+ * requirement is where the standard sends a date.
86
+ */
87
+ readonly bodies: readonly SkillText[]
88
+ }
89
+
90
+ interface SkillText {
91
+ readonly rel: string
92
+ readonly text: string
72
93
  }
73
94
 
74
95
  /**
@@ -110,6 +131,9 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
110
131
  nameMismatch: sources.flatMap(nameFindings),
111
132
  longDescription: sources.flatMap(lengthFindings),
112
133
  requirementSections: sources.flatMap(sectionFindings),
134
+ datedProvenance: sources.flatMap((source) =>
135
+ source.bodies.flatMap(dateFindings),
136
+ ),
113
137
  }
114
138
  }
115
139
 
@@ -138,23 +162,41 @@ async function readSkill(
138
162
  const skillDir = join(dir, folder)
139
163
  const requirementPath = join(skillDir, 'REQUIREMENT.md')
140
164
 
141
- const [body, requirement] = await Promise.all([
165
+ const references = [
166
+ ...new Bun.Glob('references/**/*.md').scanSync({
167
+ cwd: skillDir,
168
+ onlyFiles: true,
169
+ }),
170
+ ].sort()
171
+
172
+ const [body, requirement, referenceTexts] = await Promise.all([
142
173
  Bun.file(join(skillDir, 'SKILL.md')).text(),
143
174
  existsSync(requirementPath)
144
175
  ? Bun.file(requirementPath).text()
145
176
  : Promise.resolve(undefined),
177
+ Promise.all(
178
+ references.map((path) => Bun.file(join(skillDir, path)).text()),
179
+ ),
146
180
  ])
147
181
 
148
182
  const fields = parseFrontmatter(body)
183
+ const skillRel = join(rel, folder)
149
184
 
150
185
  return {
151
- rel: join(rel, folder),
186
+ rel: skillRel,
152
187
  folder,
153
188
  hasReadme: existsSync(join(skillDir, 'README.md')),
154
189
  name: declared(readField(fields, 'name')),
155
190
  description: declared(readField(fields, 'description')),
156
191
  requirementHeadings:
157
192
  requirement === undefined ? undefined : headings(requirement),
193
+ bodies: [
194
+ { rel: join(skillRel, 'SKILL.md'), text: body },
195
+ ...references.map((path, index) => ({
196
+ rel: join(skillRel, path),
197
+ text: referenceTexts[index] ?? '',
198
+ })),
199
+ ],
158
200
  }
159
201
  }
160
202
 
@@ -209,3 +251,18 @@ function sectionFindings(source: SkillSource): SkillFinding[] {
209
251
  },
210
252
  ]
211
253
  }
254
+
255
+ /**
256
+ * A date inside a fence or a code span is example data a reader is shown, such
257
+ * as a sample frontmatter block, so only prose outside both is read.
258
+ */
259
+ function dateFindings(source: SkillText): SkillFinding[] {
260
+ return bodyLines(source.text)
261
+ .filter((line) => !line.fenced)
262
+ .flatMap((line) =>
263
+ [...maskDisplayed(line.text).matchAll(ISO_DATE)].map((match) => ({
264
+ rel: source.rel,
265
+ detail: `line ${line.number}: ${match[0]}`,
266
+ })),
267
+ )
268
+ }
@@ -1397,6 +1397,7 @@ async function runSkillsAudit(
1397
1397
  reportFrontmatter(report)
1398
1398
  reportFolder(report)
1399
1399
  reportRequirementShape(report)
1400
+ reportProvenance(report)
1400
1401
  reportUnmeasured()
1401
1402
  outro()
1402
1403
  }
@@ -1418,6 +1419,7 @@ async function runSkillsAudit(
1418
1419
  readme: report.readme,
1419
1420
  folderName: report.folderName,
1420
1421
  requirementSections: report.requirementSections,
1422
+ datedProvenance: report.datedProvenance,
1421
1423
  },
1422
1424
  checkpoints: {
1423
1425
  descriptionLimit: DESCRIPTION_LIMIT,
@@ -1588,6 +1590,31 @@ function reportRequirementShape(report: SkillsAudit): void {
1588
1590
  reportFindings(report.requirementSections)
1589
1591
  }
1590
1592
 
1593
+ /**
1594
+ * Names where the date belongs, since the finding is a date to move rather
1595
+ * than one to delete, and reports without failing the run for the same reason
1596
+ * the frontmatter measures do.
1597
+ */
1598
+ function reportProvenance(report: SkillsAudit): void {
1599
+ logStep('Provenance')
1600
+ logInfo(
1601
+ 'A SKILL.md and its references state the current rule, with no ISO date outside a fence or code span.',
1602
+ )
1603
+ logInfo(
1604
+ 'The incident and date that earned a rule go in REQUIREMENT.md under Gap, or stay in git.',
1605
+ )
1606
+
1607
+ if (report.datedProvenance.length === 0) {
1608
+ logInfo('No body or reference carries a date.')
1609
+ return
1610
+ }
1611
+
1612
+ logWarn(
1613
+ `${plural(report.datedProvenance.length, 'date')} in a body or reference`,
1614
+ )
1615
+ reportFindings(report.datedProvenance)
1616
+ }
1617
+
1591
1618
  /**
1592
1619
  * Stated on every run, including the run where everything above passed. A
1593
1620
  * report that lists only what it measured reads as a verdict on the standard
@@ -1604,4 +1631,7 @@ function reportUnmeasured(): void {
1604
1631
  logInfo(
1605
1632
  'The 150-line body checkpoint is mechanical and still absent here, and it would print a count rather than a defect.',
1606
1633
  )
1634
+ logInfo(
1635
+ 'Undated provenance, such as "a pull request dropped...", and a date written in words or without its day are not read.',
1636
+ )
1607
1637
  }
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
3
3
  import { dirname, relative, resolve } from 'node:path'
4
4
  import type { Command } from 'commander'
5
5
  import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
6
+ import { lengthExemption } from '@/markdown/ceiling'
6
7
  import { type MarkdownAuditRefusal, resolveMarkdown } from '@/markdown/files'
7
8
  import { isGating } from '@/markdown/gate'
8
9
  import { decodePath, findBrokenLinks, type LinkFinding } from '@/markdown/links'
@@ -16,6 +17,7 @@ import {
16
17
  BASELINE,
17
18
  CHECKPOINTS,
18
19
  type Checkpoints,
20
+ documentHeight,
19
21
  measureStructure,
20
22
  type StructureReport,
21
23
  } from '@/markdown/structure'
@@ -51,6 +53,9 @@ interface FileReport {
51
53
  readonly bans: readonly BanFinding[]
52
54
  readonly links: readonly LinkFinding[]
53
55
  readonly structure: StructureReport
56
+ readonly renderedLines: number
57
+ /** The stated reason, or null where the document is not exempt. */
58
+ readonly exempt: string | null
54
59
  }
55
60
 
56
61
  export function register(program: Command): void {
@@ -62,7 +67,7 @@ export function register(program: Command): void {
62
67
  markdown
63
68
  .command('audit')
64
69
  .description(
65
- 'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, and depth',
70
+ 'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, depth, and length',
66
71
  )
67
72
  .argument(
68
73
  '[path...]',
@@ -85,6 +90,11 @@ export function register(program: Command): void {
85
90
  'Bullet, paragraph, and depth weight are judgments a reader settles,',
86
91
  'so all three report and none of them fails a run.',
87
92
  '',
93
+ 'Length reports a whole document past the ceiling and never exits 2.',
94
+ 'The Document ceiling gate stage owns that verdict, since this audit also',
95
+ 'runs on a record kept out of version control, where a long plan is not',
96
+ 'a defect.',
97
+ '',
88
98
  'Cadence reports the same way and carries one more caveat. Its range is',
89
99
  'drawn from prose a person reads, so terse reference prose sits below',
90
100
  'it correctly and a flat paragraph there is not a defect.',
@@ -148,7 +158,8 @@ async function runAudit(
148
158
  const reports: FileReport[] = await Promise.all(
149
159
  scope.files.map(async (rel) => {
150
160
  const abs = resolve(root, rel)
151
- const lines = bodyLines(await readFile(abs, 'utf8'))
161
+ const source = await readFile(abs, 'utf8')
162
+ const lines = bodyLines(source)
152
163
  return {
153
164
  rel,
154
165
  bans: scanBans(lines, bans),
@@ -156,6 +167,8 @@ async function runAudit(
156
167
  existsSync(resolve(dirname(abs), path)),
157
168
  ),
158
169
  structure: measureStructure(rel, lines, checkpoints),
170
+ renderedLines: documentHeight(source, checkpoints.renderWidth),
171
+ exempt: lengthExemption(rel, source),
159
172
  }
160
173
  }),
161
174
  )
@@ -168,6 +181,7 @@ async function runAudit(
168
181
  reportParagraphs(reports, checkpoints)
169
182
  reportCadence(reports, checkpoints)
170
183
  reportDepth(reports, checkpoints)
184
+ reportLength(reports, checkpoints)
171
185
  outro()
172
186
 
173
187
  if (opts.json) {
@@ -192,6 +206,7 @@ async function runAudit(
192
206
  cadence: checkpoints.cadence,
193
207
  spread: checkpoints.spread,
194
208
  opener: checkpoints.opener,
209
+ ceiling: checkpoints.ceiling,
195
210
  },
196
211
  entries: reports.map((report) => ({
197
212
  path: report.rel,
@@ -202,6 +217,8 @@ async function runAudit(
202
217
  heavyBullets: report.structure.heavyBullets,
203
218
  heavyParagraphs: report.structure.heavyParagraphs,
204
219
  cadence: report.structure.cadence,
220
+ renderedLines: report.renderedLines,
221
+ exempt: report.exempt,
205
222
  })),
206
223
  })}\n`,
207
224
  )
@@ -606,3 +623,47 @@ function reportDepth(
606
623
  .join('\n'),
607
624
  )
608
625
  }
626
+
627
+ /**
628
+ * Lists every document past the ceiling and counts the exempt ones rather than
629
+ * naming them, since an exemption is settled where its marker sits.
630
+ */
631
+ function reportLength(
632
+ reports: readonly FileReport[],
633
+ checkpoints: Checkpoints,
634
+ ): void {
635
+ logStep('Length')
636
+ logInfo(
637
+ `Whole documents measure rendered lines at ${checkpoints.renderWidth} columns, frontmatter and fenced blocks included.`,
638
+ )
639
+ logInfo(
640
+ 'A changelog is exempt by name, and so is a document carrying a canon-length-exempt marker that names a reason.',
641
+ )
642
+
643
+ const past = reports.filter(
644
+ (report) => report.renderedLines > checkpoints.ceiling,
645
+ )
646
+ const over = past
647
+ .filter((report) => report.exempt === null)
648
+ .sort((a, b) => b.renderedLines - a.renderedLines)
649
+ const exempt = past.length - over.length
650
+
651
+ if (over.length === 0) {
652
+ logInfo(`No document past the ${checkpoints.ceiling}-line ceiling.`)
653
+ } else {
654
+ logWarn(
655
+ `${plural(over.length, 'document')} past the ${checkpoints.ceiling}-line ceiling`,
656
+ )
657
+ pipeOutput(
658
+ over
659
+ .map(
660
+ (report) => `${report.rel} ${report.renderedLines} rendered lines`,
661
+ )
662
+ .join('\n'),
663
+ )
664
+ }
665
+
666
+ if (exempt > 0) {
667
+ logInfo(`${plural(exempt, 'exempt document')} past the ceiling.`)
668
+ }
669
+ }