@erclx/canon 5.4.0 → 5.6.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 (54) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/bash-cli-script/REQUIREMENT.md +10 -9
  3. package/claude/skills/bash-cli-script/SKILL.md +10 -7
  4. package/claude/skills/bash-cli-script/references/template.md +4 -28
  5. package/claude/skills/codebase-layout/REQUIREMENT.md +3 -0
  6. package/claude/skills/codebase-layout/SKILL.md +28 -0
  7. package/claude/skills/codebase-layout/references/adopted.md +6 -0
  8. package/claude/skills/plan-feature/REQUIREMENT.md +4 -0
  9. package/claude/skills/plan-feature/SKILL.md +4 -0
  10. package/claude/skills/read-frames/REQUIREMENT.md +1 -0
  11. package/claude/skills/read-frames/SKILL.md +1 -0
  12. package/claude/skills/teach-workspace/REQUIREMENT.md +3 -0
  13. package/claude/skills/teach-workspace/SKILL.md +6 -5
  14. package/claude/skills/teach-workspace/references/lesson-craft.md +8 -1
  15. package/claude/skills/test-craft/REQUIREMENT.md +4 -1
  16. package/claude/skills/test-craft/SKILL.md +20 -2
  17. package/claude/skills/test-craft/references/adopted.md +3 -1
  18. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -0
  19. package/claude/skills/ui-checklist/SKILL.md +11 -1
  20. package/claude/skills/ux-measure/REQUIREMENT.md +2 -1
  21. package/claude/skills/ux-measure/SKILL.md +3 -1
  22. package/claude/skills/ux-walkthrough/REQUIREMENT.md +3 -0
  23. package/claude/skills/ux-walkthrough/SKILL.md +1 -0
  24. package/docs/agents/architecture-record.md +5 -3
  25. package/docs/agents/context-audit-checks.md +1 -1
  26. package/docs/agents/context-audit.md +3 -3
  27. package/docs/agents/index.md +3 -3
  28. package/docs/agents/skills-audit.md +6 -3
  29. package/docs/agents/teach-authoring.md +3 -3
  30. package/docs/agents/teach.md +11 -5
  31. package/docs/workflow/skill-map.md +1 -2
  32. package/governance/rules/lang/120-bash.md +4 -5
  33. package/package.json +1 -1
  34. package/src/audits/catalog.ts +7 -1
  35. package/src/claude/cases/authoring.ts +0 -5
  36. package/src/claude/skills-audit.ts +81 -1
  37. package/src/commands/claude.ts +33 -0
  38. package/src/commands/context.ts +25 -2
  39. package/src/commands/serve.ts +2 -92
  40. package/src/commands/teach.ts +142 -17
  41. package/src/context/architecture.ts +82 -5
  42. package/src/context/gate.ts +10 -3
  43. package/src/gate/measures.ts +52 -5
  44. package/src/gate/stages.ts +10 -1
  45. package/src/serve/report.ts +110 -0
  46. package/src/teach/nav.ts +12 -1
  47. package/standards/architecture.md +14 -3
  48. package/standards/index.md +1 -0
  49. package/standards/skill-practice.md +51 -0
  50. package/standards/skill.md +3 -1
  51. package/claude/skills/bash-script/REQUIREMENT.md +0 -36
  52. package/claude/skills/bash-script/SKILL.md +0 -100
  53. package/claude/skills/bash-script/references/patterns.md +0 -250
  54. package/claude/skills/bash-script/references/prompts.md +0 -100
@@ -65,7 +65,7 @@ Fenced blocks are excluded, which keeps a pinned version in an install command f
65
65
 
66
66
  ## The architecture record
67
67
 
68
- Four findings read `canon/ARCHITECTURE.md` rather than a folder: an entry cap and a length ceiling, each gating only where the record states it for itself, a claim coverage report, and a word count. What each reads and why the last two gate nothing are in `architecture-record.md`.
68
+ Five findings read `canon/ARCHITECTURE.md` rather than a folder: an entry cap, a revisit sentence closing each decision, and a length ceiling, each gating only where the record states it for itself, a claim coverage report, and a word count. What each reads and why the last two gate nothing are in `architecture-record.md`.
69
69
 
70
70
  ## Wireframe states
71
71
 
@@ -5,7 +5,7 @@ description: Running the audit, its flags and folder scope, the exit codes, the
5
5
 
6
6
  # Context audit
7
7
 
8
- `canon context audit [path]` reports the structural state of the folders following the index-plus-entry contract, meaning a generated `index.md` beside entries carrying frontmatter, and it measures `canon/ARCHITECTURE.md` beside them. It reads and reports. Fixing what it finds is separate work. What each finding means is in `context-audit-checks.md`, and what the architecture record's four findings mean is in `architecture-record.md`.
8
+ `canon context audit [path]` reports the structural state of the folders following the index-plus-entry contract, meaning a generated `index.md` beside entries carrying frontmatter, and it measures `canon/ARCHITECTURE.md` beside them. It reads and reports. Fixing what it finds is separate work. What each finding means is in `context-audit-checks.md`, and what the architecture record's five findings mean is in `architecture-record.md`.
9
9
 
10
10
  Findings stated over every markdown file rather than over a context entry are measured by `canon markdown audit`, described in `markdown-audit.md`. That command resolves no folder, so it reaches trees this one refuses.
11
11
 
@@ -37,9 +37,9 @@ A run where no requested name resolves refuses, whichever list it read. Naming t
37
37
 
38
38
  ## Exit codes
39
39
 
40
- Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a gating finding. An unresolved citation gates under every mode. An architecture record that states its own line allowances gates when it is past the ceiling those derive, on any run that measures it, which is every mode except `--citations-only`. A record that states an entry cap gates the same way when it holds more decisions than the cap, and a record stating neither limit is reported and never gated.
40
+ Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a gating finding. An unresolved citation gates under every mode. An architecture record that states its own line allowances gates when it is past the ceiling those derive, on any run that measures it, which is every mode except `--citations-only`. A record that states an entry cap gates the same way when it holds more decisions than the cap, and one stating that every decision closes with a revisit sentence gates when a decision lacks it. A record stating none of the three is reported and never gated.
41
41
 
42
- `bun run check` in the toolkit repository reads both limits through its own Architecture record stage rather than through this verb, since the verb's only gating stage there runs `--citations-only` and never opens the record.
42
+ `bun run check` in the toolkit repository reads all three of the record's rules through its own Architecture record stage rather than through this verb, since the verb's only gating stage there runs `--citations-only` and never opens the record.
43
43
 
44
44
  Entry length, reference form, table, provenance, the record's claim classification, and every word figure print and return `0` under every mode, because each is a judgment or a weight read alongside one, and failing a push on either would make the check something to route around.
45
45
 
@@ -8,7 +8,7 @@ category: Agent surface
8
8
 
9
9
  CLI catalog and invocation rules for agents, split by command domain. Start with overview.
10
10
 
11
- - [Architecture record](architecture-record.md): The four findings canon context audit reads off the architecture record, being the entry cap and length gates the record states for itself, the claim coverage report, and the word counts
11
+ - [Architecture record](architecture-record.md): The five findings canon context audit reads off the architecture record, being the entry cap, revisit sentence, and length gates the record states for itself, the claim coverage report, and the word counts
12
12
  - [Audits](audits.md): Running every health check as one set, what the single verdict means, the exit code each outcome takes, the retained baseline and the delta it reports, and which corpora are kept out of the record
13
13
  - [Capture](capture.md): Rendering HTML sources to PNG, what the command asserts about fonts, and why the selector has no default
14
14
  - [Census](census.md): Tracked-plus-untracked file count, a breakdown by extension, and a line total that skips whatever reads as binary
@@ -50,7 +50,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
50
50
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
51
51
  - [Sessions transfer](sessions-transfer.md): 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
52
52
  - [Sessions](sessions.md): 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
53
- - [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
53
+ - [Skill audit](skills-audit.md): Measuring both skill corpora against the skill standards, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
54
54
  - [Citation reach](skills-reach.md): Reporting the skill bodies that cite a path no target project receives, which corpus the verb reads, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
55
55
  - [Standard success criteria](standards-audit.md): Reading the corpus against the success-criterion gate, the two headings it accepts, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
56
56
  - [State-scoped risk](state-scoped-risk.md): Reading committed state rather than an arriving change, the shipped-tree corpus the secret scan reads, what it keys on and how a deliberate value is exempted, the advisory check and its network failure mode, and why one gates while the other reports
@@ -61,6 +61,6 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
61
61
  - [Tasks validate](tasks-validate.md): Validating the board against the tree, the seven checks, the untested, claims, and unplaced arrays, the exit codes, and the refusal reasons
62
62
  - [Tasks](tasks.md): Claiming the next phase label, recording a pull request number and closing an outcome, listing each task's readiness, and where archiving, the plan verbs, and board validation are described
63
63
  - [Teach authoring](teach-authoring.md): 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
64
- - [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, where the lesson authoring verbs are described, the refusal reasons, and why every write here runs through a verb
64
+ - [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, viewing one with its chrome refreshed, where the lesson authoring verbs are described, the refusal reasons, and why every write here runs through a verb
65
65
  - [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
66
66
  - [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, removing the ones that are, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  title: Skill audit
3
- description: Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
3
+ description: Measuring both skill corpora against the skill standards, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
4
4
  ---
5
5
 
6
6
  # Skill audit
7
7
 
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.
8
+ `canon claude skills audit [path]` reports both skill corpora against the rules `standards/skill.md`, `standards/skill-requirement.md`, and `standards/skill-practice.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
@@ -36,6 +36,7 @@ Each check traces to a line in the standard.
36
36
  - Folder name in kebab-case
37
37
  - Each `REQUIREMENT.md` declaring `Gap` and `Must`, matched at any heading level
38
38
  - No ISO date in `SKILL.md` or any file under `references/`, reported as `datedProvenance` in the JSON record's `findings`
39
+ - Each listed practice skill closing with `## Excuses and rebuttals`, `## Red flags`, and `## Before handing over`, and carrying `references/adopted.md`, reported as `practiceShape`
39
40
 
40
41
  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.
41
42
 
@@ -43,6 +44,8 @@ The date check reads prose alone. A date inside a fence or a code span is exampl
43
44
 
44
45
  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
46
 
47
+ The practice check reads only the skills the audit lists, which the JSON record carries as `checkpoints.practiceSkills`. The list names folders under the toolkit's shipped `claude/skills/` corpus alone, so a project's own skill is never read as a practice skill and a same-named folder under `.claude/skills/` produces no finding. Where `claude/skills/` resolves, a listed folder it does not hold reports as a missing skill, so a renamed or misspelled entry cannot pass unread. Each closing section matches only as an exact H2 outside a fence, so an H3 or a heading carrying more words reports as missing.
48
+
46
49
  ## What it leaves alone
47
50
 
48
51
  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.
@@ -53,7 +56,7 @@ A check with no rule behind it prints an opinion as a defect, which is where the
53
56
 
54
57
  ## Exit codes
55
58
 
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.
59
+ 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, dated-provenance, and practice shape 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. In the toolkit, two `bun run check` stages read the same report in-process and fail a push on dated provenance and on practice shape, since its own corpus is held at zero on both.
57
60
 
58
61
  ## The requirement gate
59
62
 
@@ -30,7 +30,7 @@ It reports three things. `lesson` is the numbered path the lesson takes, derived
30
30
 
31
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
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.
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, so a change to the shared chrome reaches a workspace that already exists. `canon teach nav` and `canon teach up` rewrite it too. `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
34
 
35
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
36
 
@@ -50,7 +50,7 @@ echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach re
50
50
 
51
51
  It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, `refs`, `code`, 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
52
 
53
- A `code` block carries `text` and an optional `lang`, a highlight.js name or alias. The source is highlighted at render time into `hljs-*` class spans the workspace stylesheet colors in both themes, so the page carries no script. A `lang` the renderer does not register renders as plain escaped code, and one that does not match `^[a-z0-9+#-]+$` is refused. A workspace seeded before the syntax colors existed shows the spans uncolored until `canon teach stylesheet <topic>` rewrites its base stylesheet.
53
+ A `code` block carries `text` and an optional `lang`, a highlight.js name or alias. The source is highlighted at render time into `hljs-*` class spans the workspace stylesheet colors in both themes, so the page carries no script. A `lang` the renderer does not register renders as plain escaped code, and one that does not match `^[a-z0-9+#-]+$` is refused. A workspace seeded before the syntax colors existed shows the spans uncolored until `canon teach nav`, `canon teach up`, or `canon teach stylesheet <topic>` rewrites its base stylesheet.
54
54
 
55
55
  ```json
56
56
  [{ "type": "code", "lang": "ts", "text": "const a: number = 1" }]
@@ -97,7 +97,7 @@ It also deletes two links a session used to write by hand into a lesson's `<head
97
97
 
98
98
  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.
99
99
 
100
- 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.
100
+ Every run also rewrites `assets/base.css` wherever the workspace already holds one, so a change to the shared chrome reaches an existing workspace through this verb as well as through `canon teach stylesheet <topic>`. `course.css` is still never rewritten. A legacy workspace holding only a `course.css` is not handed a `base.css`, since its lessons were written without the link. `canon teach up` runs this pass before serving, which is the route that keeps a viewed workspace current.
101
101
 
102
102
  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.
103
103
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Teach
3
- description: Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, where the lesson authoring verbs are described, the refusal reasons, and why every write here runs through a verb
3
+ description: Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, viewing one with its chrome refreshed, where the lesson authoring verbs are described, the refusal reasons, and why every write here runs through a verb
4
4
  ---
5
5
 
6
6
  # Teach
@@ -116,15 +116,19 @@ The entry lands as the standard's shape, leading with the term as a bolded span.
116
116
 
117
117
  ## Opening a workspace
118
118
 
119
- No `canon teach` verb serves the workspace. `canon serve` does, taking the teach root as its directory and the workspace contents page as its entry:
119
+ `canon teach up` runs the `nav` pass and then serves the teach root, so the page a reader opens carries the chrome the installed CLI generates:
120
120
 
121
121
  ```bash
122
- canon serve .canon/teach --entry 03-fde-system-design/index.html --json
122
+ canon teach up 03-fde-system-design --json
123
123
  ```
124
124
 
125
- `canon teach list <topic>` ends its human output with this line for that workspace, with the teach folder written relative to the cwd, or absolute when it sits outside it, since `canon serve` resolves its directory against the cwd while the list verb reads the main worktree root. The JSON record carries no such field.
125
+ The link opens the named workspace's contents page, or the root listing when no topic is named. The verb serves the teach folder at its absolute path, so a run from a linked worktree still reaches the main worktree root. `--port` sets the first port tried and `--root` the teach root, as on every other verb. It runs until interrupted, so a session starts it in the background and reads the record off stdout.
126
126
 
127
- It stays general rather than becoming `canon teach serve`, because nothing about serving a directory is specific to a learning workspace, and the same verb carries a slide render and a design preview.
127
+ The JSON record is one line carrying the `nav` fields, then `served`, the absolute folder, and the server's `host`, `port`, `entry`, `url`, and `entryExists`. A lesson `nav` skips lands in `skipped` and the server still starts, since a partial refresh is still worth viewing. A `nav` refusal starts no server.
128
+
129
+ Viewing writes. Every page and generated stylesheet `nav` owns is rewritten first, which on a committed fixture shows as a diff. To serve without rewriting anything, `canon teach list <topic>` ends its human output with a `canon serve` line for that workspace, with the teach folder written relative to the cwd, or absolute when it sits outside it. The JSON record of `list` carries no such field.
130
+
131
+ `canon serve` itself stays general, since nothing about serving a directory is specific to a learning workspace and the same verb carries a slide render and a design preview. What `up` adds in front of it is the refresh, which is teach-specific, and it serves through the same server rather than a second one.
128
132
 
129
133
  Read `url` off the record rather than building one from the port that was asked for. The verb walks forward past a port already in use, which is routine when a second workspace is already open, and the port it took is the one thing a composed URL gets wrong.
130
134
 
@@ -142,4 +146,6 @@ Read `url` off the record rather than building one from the port that was asked
142
146
  | `defined` | A term already carries a glossary entry |
143
147
  | `bad-input` | The command line is malformed, before any folder is read |
144
148
 
149
+ `up` also passes through the refusals `canon serve` raises once `nav` has run, `no-port` when every port in reach is taken and `bind-failed` for any other bind error, such as a privileged port, in the same record shape with the server's detail as `message`.
150
+
145
151
  A `bad-input` refusal reports the working directory as its root rather than the resolved one, since the command line is rejected before the root is worth resolving. `lesson` raises it for a slug that is not kebab-case, for a quiz carrying no question, and for a question carrying fewer than two options, since a question with one option has nothing to confuse the right answer with.
@@ -125,8 +125,7 @@ This page is the corpus the coverage claim is measured against: every name `cano
125
125
  | `canon:draft-wiki` | For a brand-new wiki reference page on a subject owned outside the project, drafted against `standards/wiki.md` |
126
126
  | `canon:draft-figure` | For a hand-drawn figure inside an existing doc, drafted against `standards/figures.md` in Mermaid or freehand SVG |
127
127
  | `canon:draft-readme` | For a project's `README.md`, drafted against `standards/readme.md` |
128
- | `canon:bash-script` | For an interactive, human-facing shell tool |
129
- | `canon:bash-cli-script` | For a non-interactive automation, CI, or pipeline script |
128
+ | `canon:bash-cli-script` | For a small non-interactive shell wrapper, stopping at 100 lines in favor of TypeScript on Bun |
130
129
  | `canon:ci-workflow` | For a GitHub Actions workflow file |
131
130
  | `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
132
131
  | `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Route bash script authoring to the interactive or non-interactive skill, and name the lint gate
2
+ description: Route bash script authoring to the wrapper skill, and name the lint gate
3
3
  paths:
4
4
  - '**/*.sh'
5
5
  ---
@@ -8,10 +8,9 @@ paths:
8
8
 
9
9
  ## Skill routing
10
10
 
11
- - Use `canon:bash-script` for an interactive or human-facing script: prompts, a visual timeline UI, framed terminal output.
12
- - Use `canon:bash-cli-script` for a non-interactive script: automation, CI, cron, a pipeline helper, or anything run by an agent rather than watched by a person.
13
- - Load the matched skill's own reference templates rather than hand-rolling interactivity or logging patterns outside them.
14
- - Report it rather than proceeding silently when the matched skill does not resolve. Both ship with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have them.
11
+ - Use `canon:bash-cli-script` before writing a script, and follow its guards on what a shell script may be.
12
+ - Load the skill's own reference template rather than hand-rolling logging patterns outside it.
13
+ - Report it rather than proceeding silently when the skill does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
15
14
 
16
15
  ## Lint gate
17
16
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.4.0",
4
+ "version": "5.6.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -140,7 +140,7 @@ function allOf(
140
140
  }
141
141
 
142
142
  /**
143
- * Reads the architecture record's three measures, or nothing when the project
143
+ * Reads the architecture record's measures, or nothing when the project
144
144
  * carries no record.
145
145
  *
146
146
  * Three states rather than two, matching what the verb publishes. The key
@@ -172,8 +172,11 @@ function architectureCounts(
172
172
  const entryCap =
173
173
  typeof record.entryCap === 'number' ? record.entryCap : undefined
174
174
 
175
+ const revisitRequired = record.revisitRequired === true
176
+
175
177
  let unverifiable = 0
176
178
  let unchecked = 0
179
+ let lackingRevisit = 0
177
180
  for (const raw of decisions) {
178
181
  const entry = asObject(raw)
179
182
  const claim = entry?.claim
@@ -182,6 +185,7 @@ function architectureCounts(
182
185
 
183
186
  if (claim === 'neither') unverifiable += 1
184
187
  else if (checks === 0) unchecked += 1
188
+ if (revisitRequired && entry?.revisit !== true) lackingRevisit += 1
185
189
  }
186
190
 
187
191
  return {
@@ -195,6 +199,8 @@ function architectureCounts(
195
199
  ...(entryCap !== undefined && {
196
200
  recordOverCount: decisions.length > entryCap ? 1 : 0,
197
201
  }),
202
+ // Absent on a record stating no revisit clause, for the same reason.
203
+ ...(revisitRequired && { recordMissingRevisit: lackingRevisit }),
198
204
  recordUnverifiable: unverifiable,
199
205
  recordUnchecked: unchecked,
200
206
  }
@@ -5,11 +5,6 @@ import type { SkillCase } from '@/claude/skills-rank'
5
5
  * skills that revise rather than generate.
6
6
  */
7
7
  export const AUTHORING_CASES: readonly SkillCase[] = [
8
- {
9
- prompt:
10
- 'I need a shell tool with prompts and a nice terminal UI for people to run by hand.',
11
- expect: 'bash-script',
12
- },
13
8
  {
14
9
  prompt:
15
10
  'Wire up a GitHub Actions pipeline with parallel jobs for this repo.',
@@ -12,16 +12,40 @@ export const DESCRIPTION_LIMIT = 1024
12
12
  /** The two headings the requirement template declares, matched at any level. */
13
13
  export const REQUIREMENT_SECTIONS: readonly string[] = ['Gap', 'Must']
14
14
 
15
+ /** The one tree that ships to a target, and the only one the practice list names. */
16
+ export const SHIPPED_CORPUS = join('claude', 'skills')
17
+
15
18
  /**
16
19
  * Both trees the standard governs. `claude/skills/` ships to a target and
17
20
  * `.claude/skills/` stays here, and every rule measured below applies to each,
18
21
  * so a corpus reading one of them reports a pass over half the subject.
19
22
  */
20
23
  export const CORPORA: readonly string[] = [
21
- join('claude', 'skills'),
24
+ SHIPPED_CORPUS,
22
25
  join('.claude', 'skills'),
23
26
  ]
24
27
 
28
+ /**
29
+ * The practice skills `standards/skill-practice.md` describes, keyed by corpus-relative
30
+ * folder rather than by name, so a target's own `.claude/skills/` folder that
31
+ * shares a name is never swept in. The list lives here rather than in a skill's
32
+ * frontmatter because a skill declaring its own kind could exempt itself.
33
+ */
34
+ export const PRACTICE_SKILLS: readonly string[] = [
35
+ join(SHIPPED_CORPUS, 'codebase-layout'),
36
+ join(SHIPPED_CORPUS, 'test-craft'),
37
+ ]
38
+
39
+ /** The closing H2s a practice skill carries, matched exactly outside fences. */
40
+ export const PRACTICE_SECTIONS: readonly string[] = [
41
+ 'Excuses and rebuttals',
42
+ 'Red flags',
43
+ 'Before handing over',
44
+ ]
45
+
46
+ /** The source ledger every practice skill carries beside its body. */
47
+ export const PRACTICE_LEDGER = join('references', 'adopted.md')
48
+
25
49
  /**
26
50
  * The one reason this audit refuses. A project carrying neither corpus is the
27
51
  * ordinary state of a target that has not adopted either skills convention,
@@ -34,6 +58,8 @@ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
34
58
 
35
59
  const HEADING = /^#{1,6}\s+(.+?)\s*$/
36
60
 
61
+ const SECTION = /^##\s+(.+?)\s*$/
62
+
37
63
  /**
38
64
  * An ISO date, read as provenance wherever a reader is told it. The context
39
65
  * audit's detector clears a date after "measured" or "verified", and a skill
@@ -69,6 +95,7 @@ export interface SkillsAudit {
69
95
  readonly longDescription: readonly SkillFinding[]
70
96
  readonly requirementSections: readonly SkillFinding[]
71
97
  readonly datedProvenance: readonly SkillFinding[]
98
+ readonly practiceShape: readonly SkillFinding[]
72
99
  }
73
100
 
74
101
  interface SkillSource {
@@ -79,6 +106,9 @@ interface SkillSource {
79
106
  readonly description: string | undefined
80
107
  /** Undefined when the folder carries no `REQUIREMENT.md` at all. */
81
108
  readonly requirementHeadings: readonly string[] | undefined
109
+ /** Every H2 in `SKILL.md` outside a fence, for the practice shape. */
110
+ readonly sections: readonly string[]
111
+ readonly hasLedger: boolean
82
112
  /**
83
113
  * `SKILL.md` and every reference below the folder, keyed by the path a
84
114
  * finding names. `REQUIREMENT.md` and `EVAL.md` are left out, since the
@@ -134,6 +164,13 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
134
164
  datedProvenance: sources.flatMap((source) =>
135
165
  source.bodies.flatMap(dateFindings),
136
166
  ),
167
+ practiceShape: [
168
+ ...missingPracticeSkills(
169
+ present.some((corpus) => corpus.rel === SHIPPED_CORPUS),
170
+ sources,
171
+ ),
172
+ ...sources.flatMap(practiceFindings),
173
+ ],
137
174
  }
138
175
  }
139
176
 
@@ -190,6 +227,11 @@ async function readSkill(
190
227
  description: declared(readField(fields, 'description')),
191
228
  requirementHeadings:
192
229
  requirement === undefined ? undefined : headings(requirement),
230
+ sections: bodyLines(body)
231
+ .filter((line) => !line.fenced)
232
+ .map((line) => SECTION.exec(line.text)?.[1])
233
+ .filter((text): text is string => text !== undefined),
234
+ hasLedger: existsSync(join(skillDir, PRACTICE_LEDGER)),
193
235
  bodies: [
194
236
  { rel: join(skillRel, 'SKILL.md'), text: body },
195
237
  ...references.map((path, index) => ({
@@ -252,6 +294,44 @@ function sectionFindings(source: SkillSource): SkillFinding[] {
252
294
  ]
253
295
  }
254
296
 
297
+ /**
298
+ * A listed folder the shipped corpus does not hold, such as a renamed skill or
299
+ * a misspelled append, would otherwise read as a pass. Silent where the
300
+ * shipped corpus is absent, since a target carries none of the listed skills.
301
+ */
302
+ function missingPracticeSkills(
303
+ hasShippedCorpus: boolean,
304
+ sources: readonly SkillSource[],
305
+ ): SkillFinding[] {
306
+ if (!hasShippedCorpus) return []
307
+ const found = new Set(sources.map((source) => source.rel))
308
+ return PRACTICE_SKILLS.filter((rel) => !found.has(rel)).map((rel) => ({
309
+ rel,
310
+ detail: 'missing skill: no folder under the shipped corpus',
311
+ }))
312
+ }
313
+
314
+ /**
315
+ * Matches each section as an exact H2, so a heading carrying trailing text or
316
+ * sitting at another level reads as missing rather than as a near match the
317
+ * standard never names.
318
+ */
319
+ function practiceFindings(source: SkillSource): SkillFinding[] {
320
+ if (!PRACTICE_SKILLS.includes(source.rel)) return []
321
+
322
+ const sections = PRACTICE_SECTIONS.filter(
323
+ (section) => !source.sections.includes(section),
324
+ ).map((section) => ({
325
+ rel: source.rel,
326
+ detail: `missing section: ${section}`,
327
+ }))
328
+ const ledger = source.hasLedger
329
+ ? []
330
+ : [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
331
+
332
+ return [...sections, ...ledger]
333
+ }
334
+
255
335
  /**
256
336
  * A date inside a fence or a code span is example data a reader is shown, such
257
337
  * as a sample frontmatter block, so only prose outside both is read.
@@ -26,7 +26,11 @@ import {
26
26
  auditSkills,
27
27
  CORPORA,
28
28
  DESCRIPTION_LIMIT,
29
+ PRACTICE_LEDGER,
30
+ PRACTICE_SECTIONS,
31
+ PRACTICE_SKILLS,
29
32
  REQUIREMENT_SECTIONS,
33
+ SHIPPED_CORPUS,
30
34
  type SkillFinding,
31
35
  type SkillsAudit,
32
36
  type SkillsAuditRefusal,
@@ -1398,6 +1402,7 @@ async function runSkillsAudit(
1398
1402
  reportFolder(report)
1399
1403
  reportRequirementShape(report)
1400
1404
  reportProvenance(report)
1405
+ reportPracticeShape(report)
1401
1406
  reportUnmeasured()
1402
1407
  outro()
1403
1408
  }
@@ -1420,11 +1425,14 @@ async function runSkillsAudit(
1420
1425
  folderName: report.folderName,
1421
1426
  requirementSections: report.requirementSections,
1422
1427
  datedProvenance: report.datedProvenance,
1428
+ practiceShape: report.practiceShape,
1423
1429
  },
1424
1430
  checkpoints: {
1425
1431
  descriptionLimit: DESCRIPTION_LIMIT,
1426
1432
  requirementSections: REQUIREMENT_SECTIONS,
1427
1433
  corpora: CORPORA,
1434
+ practiceSkills: PRACTICE_SKILLS,
1435
+ practiceSections: PRACTICE_SECTIONS,
1428
1436
  },
1429
1437
  })}\n`,
1430
1438
  )
@@ -1615,6 +1623,31 @@ function reportProvenance(report: SkillsAudit): void {
1615
1623
  reportFindings(report.datedProvenance)
1616
1624
  }
1617
1625
 
1626
+ /**
1627
+ * Reports without failing the run for the reason provenance does. The list
1628
+ * names shipped folders alone, which the step says so a target reading a pass
1629
+ * knows its own skills were never in scope.
1630
+ */
1631
+ function reportPracticeShape(report: SkillsAudit): void {
1632
+ logStep('Practice shape')
1633
+ logInfo(
1634
+ `A listed practice skill closes with ${PRACTICE_SECTIONS.map((section) => `## ${section}`).join(', ')}, and carries ${PRACTICE_LEDGER}.`,
1635
+ )
1636
+ logInfo(
1637
+ `The list is kept here and names ${plural(PRACTICE_SKILLS.length, 'shipped skill')}, so a skill outside ${SHIPPED_CORPUS} is never read.`,
1638
+ )
1639
+
1640
+ if (report.practiceShape.length === 0) {
1641
+ logInfo('Every listed practice skill carries its shape.')
1642
+ return
1643
+ }
1644
+
1645
+ logWarn(
1646
+ `${plural(report.practiceShape.length, 'missing part')} across the listed practice skills`,
1647
+ )
1648
+ reportFindings(report.practiceShape)
1649
+ }
1650
+
1618
1651
  /**
1619
1652
  * Stated on every run, including the run where everything above passed. A
1620
1653
  * report that lists only what it measured reads as a verdict on the standard
@@ -25,6 +25,7 @@ import {
25
25
  isOverCount,
26
26
  isOverLength,
27
27
  measureArchitecture,
28
+ missingRevisit,
28
29
  testableCount,
29
30
  } from '@/context/architecture'
30
31
  import { auditCitations, type CitationReport } from '@/context/citations'
@@ -122,8 +123,10 @@ export function register(program: Command): void {
122
123
  'states its own line allowances gates when it is past the ceiling',
123
124
  'those derive, on any run except --citations-only, which never',
124
125
  'measures it. A record stating an entry cap gates the same way when',
125
- 'it holds more decisions than the cap. A record stating neither is',
126
- 'reported and never gated. --gate widens the gate to the other two',
126
+ 'it holds more decisions than the cap, and a record stating that',
127
+ 'every decision closes with a revisit sentence gates when one lacks',
128
+ 'it. A record stating none of the three is reported and never',
129
+ 'gated. --gate widens the gate to the other two',
127
130
  'findings that are facts rather than judgments: a missing required',
128
131
  'section and index drift. A context entry requires Overview and',
129
132
  'Layout, and a wireframe requires Regions, States, Copy, and Not on',
@@ -739,6 +742,8 @@ async function runAudit(
739
742
  unresolvedCitations: citations.unresolved.length,
740
743
  recordOverLength: record !== undefined && isOverLength(record),
741
744
  recordOverCount: record !== undefined && isOverCount(record),
745
+ recordMissingRevisit:
746
+ record !== undefined && missingRevisit(record).length > 0,
742
747
  sections,
743
748
  drift,
744
749
  wireframes,
@@ -1194,6 +1199,24 @@ function reportRecord(
1194
1199
  )
1195
1200
  }
1196
1201
 
1202
+ const revisited = report.decisions.filter((entry) => entry.revisit).length
1203
+ const lackingRevisit = missingRevisit(report)
1204
+ if (!report.revisitRequired) {
1205
+ logInfo(
1206
+ `${revisited} of ${plural(decisions, 'decision')} close with a revisit sentence, and the record states no revisit clause, so this is reported and never gated.`,
1207
+ )
1208
+ } else if (lackingRevisit.length > 0) {
1209
+ for (const heading of lackingRevisit) {
1210
+ logError(
1211
+ `"${heading}" carries no sentence opening "Revisit when", which the record requires of every decision.`,
1212
+ )
1213
+ }
1214
+ } else {
1215
+ logInfo(
1216
+ `Every one of ${plural(decisions, 'decision')} closes with the revisit sentence the record requires.`,
1217
+ )
1218
+ }
1219
+
1197
1220
  if (decisions === 0) {
1198
1221
  logWarn('The record declares no decision, so nothing was classified.')
1199
1222
  return
@@ -1,7 +1,6 @@
1
1
  import type { Command } from 'commander'
2
- import { relative } from 'node:path'
3
- import { DEFAULT_PORT, type ServeOutcome, startServer } from '@/serve/static'
4
- import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
2
+ import { parsePort, report, waitForInterrupt } from '@/serve/report'
3
+ import { DEFAULT_PORT, startServer } from '@/serve/static'
5
4
 
6
5
  const DEFAULT_DIR = '.'
7
6
 
@@ -78,92 +77,3 @@ async function runServe(
78
77
  await waitForInterrupt(outcome.stop)
79
78
  return 0
80
79
  }
81
-
82
- /**
83
- * The relative path where it stays inside the working directory, and the
84
- * absolute one where it climbs out. A run from a linked worktree serving the
85
- * main root reports `../../teach`, which names the directory without giving a
86
- * reader anything they can open.
87
- */
88
- function displayPath(root: string): string {
89
- const near = relative(process.cwd(), root)
90
- if (near === '') return '.'
91
- return near.startsWith('..') ? root : near
92
- }
93
-
94
- /**
95
- * Undefined for a value that is not a port, distinct from an absent flag,
96
- * which takes the default. A `Number` of a typo is `NaN`, and passing that on
97
- * asks the runtime to bind a port nobody named.
98
- */
99
- function parsePort(raw: string | undefined): number | undefined {
100
- if (raw === undefined) return DEFAULT_PORT
101
- const value = Number(raw)
102
- if (!Number.isInteger(value) || value < 0 || value > 65535) return undefined
103
- return value
104
- }
105
-
106
- function report(outcome: ServeOutcome, emitJson: boolean): number {
107
- if (!outcome.ok) {
108
- if (emitJson) {
109
- process.stdout.write(
110
- `${JSON.stringify({ ok: false, reason: outcome.reason, detail: outcome.detail })}\n`,
111
- )
112
- return 1
113
- }
114
- intro('canon serve')
115
- logError(outcome.detail)
116
- outro()
117
- return 1
118
- }
119
-
120
- if (emitJson) {
121
- process.stdout.write(
122
- `${JSON.stringify({
123
- ok: true,
124
- root: outcome.root,
125
- host: outcome.host,
126
- port: outcome.port,
127
- entry: outcome.entry,
128
- url: outcome.url,
129
- entryExists: outcome.entryExists,
130
- })}\n`,
131
- )
132
- return 0
133
- }
134
-
135
- intro('canon serve')
136
- logStep('Serving')
137
- logInfo(displayPath(outcome.root))
138
- logStep('Open')
139
- logInfo(outcome.url)
140
-
141
- if (!outcome.entryExists) {
142
- logStep('No entry page')
143
- logWarn(
144
- `${outcome.entry} is not in that directory, so the link opens a 404`,
145
- )
146
- }
147
-
148
- logStep('Stop')
149
- logInfo('Ctrl-C')
150
- return 0
151
- }
152
-
153
- /**
154
- * Resolves when the process is asked to stop. The frame is closed here rather
155
- * than in the reporter, because the run is the serving rather than the start,
156
- * and closing at start would print the frame's end while the server ran on.
157
- */
158
- function waitForInterrupt(stop: () => Promise<void>): Promise<void> {
159
- return new Promise((settle) => {
160
- const finish = () => {
161
- void stop().then(() => {
162
- outro()
163
- settle()
164
- })
165
- }
166
- process.once('SIGINT', finish)
167
- process.once('SIGTERM', finish)
168
- })
169
- }