@erclx/canon 4.114.0 → 4.115.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 (33) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/document-health/references/axes.md +1 -1
  3. package/claude/skills/target-setup/SKILL.md +1 -1
  4. package/claude/skills/target-setup/references/detect.md +43 -9
  5. package/claude/skills/teach-workspace/SKILL.md +5 -3
  6. package/docs/agents/audits.md +4 -2
  7. package/docs/agents/commands.md +1 -1
  8. package/docs/agents/context-audit-checks.md +6 -6
  9. package/docs/agents/context-audit.md +1 -1
  10. package/docs/agents/context-classify.md +1 -1
  11. package/docs/agents/teach.md +6 -0
  12. package/docs/target-projects.md +4 -2
  13. package/package.json +1 -1
  14. package/src/audits/catalog.ts +52 -0
  15. package/src/commands/context.ts +30 -10
  16. package/src/commands/teach.ts +4 -1
  17. package/src/context/audit.ts +54 -18
  18. package/src/teach/nav.ts +203 -9
  19. package/standards/teach.md +13 -12
  20. package/tooling/go/configs/.golangci.yml +25 -0
  21. package/tooling/go/configs/scripts/verify.sh +95 -0
  22. package/tooling/go/manifest.toml +13 -0
  23. package/tooling/go/reference.md +60 -0
  24. package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
  25. package/tooling/go/seeds/smoke_test.go +9 -0
  26. package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
  27. package/tooling/php/configs/phpstan.neon +5 -0
  28. package/tooling/php/configs/phpunit.xml +18 -0
  29. package/tooling/php/configs/scripts/verify.sh +100 -0
  30. package/tooling/php/manifest.toml +15 -0
  31. package/tooling/php/reference.md +74 -0
  32. package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
  33. package/tooling/php/seeds/tests/SmokeTest.php +13 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.114.0",
4
+ "version": "4.115.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -40,7 +40,7 @@ Weight is a judgment a reader settles rather than a defect, which the verb state
40
40
 
41
41
  **Evidence class:** `measured` for the audit's findings, `classified` for the sweep's verdicts.
42
42
 
43
- The audit answers required sections, entry length, citation resolution, reference form, catalog tables, provenance, and index drift. A citation resolving to nothing is a fact and gates. Entry length, reference form, table shape, and provenance are judgments under every flag.
43
+ The audit answers required sections, entry length, citation resolution, reference form, catalog tables, provenance, and index drift. Required sections reach wireframes as well as context entries, naming each wireframe short of the regions, states, copy, or exclusions section its standard requires, which is how a surface on an older wireframe shape reports rather than reading as clean. A citation resolving to nothing is a fact and gates. Entry length, reference form, table shape, and provenance are judgments under every flag.
44
44
 
45
45
  The sweep classifies each section as keep, replace or rewrite, history, or move. A `move` verdict is the placement finding proper, naming content sitting in the wrong document. Each verdict carries its own confidence, which is why the class is `classified` rather than `measured`, and a low-confidence `move` is a prompt to look rather than an instruction to cut.
46
46
 
@@ -92,7 +92,7 @@ Step 2: `canon tooling sync <tooling-stack> --write` installs stack deps, script
92
92
  CANON_NON_INTERACTIVE=1 canon tooling sync <tooling-stack> <target> --write
93
93
  ```
94
94
 
95
- Monorepo with multiple language roots: run `canon init` once at the repo root so `base` (husky, prettier, cspell, commitlint, CI) lands single, then sync each subtree with `--skip base` so the shared layer is not re-dropped.
95
+ Monorepo with multiple language roots: run `canon init` once at the repo root so `base` (husky, prettier, cspell, commitlint, CI) lands single, then sync each subtree with `--skip base` so the shared layer is not re-dropped. Take the subtree commands from the per-root rows the preview printed rather than picking folders by hand. Detection found each root and its stack, and the two commands below show the shape.
96
96
 
97
97
  ```bash
98
98
  CANON_NON_INTERACTIVE=1 canon tooling sync vite-react ./frontend --skip base --write
@@ -27,19 +27,53 @@ since the chain reaches the tooling resolution a moment later.
27
27
 
28
28
  ## Detect
29
29
 
30
- Read these from the project root in parallel, skipping any that do not exist:
30
+ A monorepo keeps its languages in subfolders, so read the markers from every
31
+ folder to depth two rather than from the root alone. Collect the language roots
32
+ first:
33
+
34
+ ```bash
35
+ git ls-files --cached --others --exclude-standard \
36
+ | grep -E '(^|/)(package\.json|pyproject\.toml|go\.mod|composer\.json|Cargo\.toml)$' \
37
+ | awk -F/ 'NF <= 3'
38
+ ```
39
+
40
+ Every folder holding a marker is a language root, and the repository root is
41
+ one when it holds a marker itself. Going through git keeps ignored trees such as
42
+ `node_modules/`, `vendor/`, and `.venv/` off the list, since each carries
43
+ markers of its own. Outside a git repository, fall back to
44
+ `find . -maxdepth 3 \( -path '*/.*' -o -name node_modules -o -name vendor \) -prune -o -type f -print`
45
+ filtered to the same five names.
46
+
47
+ Depth two reaches a service folder at depth one and a workspace package under
48
+ `packages/<name>/` at depth two. Deeper, the walk starts finding fixtures and
49
+ examples as false roots.
50
+
51
+ Then read these per language root in parallel, skipping any that do not exist:
31
52
 
32
53
  - `package.json`: `dependencies` and `devDependencies`
33
- - Root configs: `astro.config.*`, `next.config.*`, `vite.config.*`, `tailwind.config.*`, `tsconfig.json`, `pyproject.toml`, `go.mod`, `composer.json`, `Cargo.toml`
54
+ - The root's own configs: `astro.config.*`, `next.config.*`, `vite.config.*`, `tailwind.config.*`, `tsconfig.json`, `pyproject.toml`, `go.mod`, `composer.json`, `Cargo.toml`
55
+
56
+ A non-JavaScript marker outranks a `package.json` in the same folder. A Go or
57
+ Composer service often carries a `package.json` only to host scripts, so that
58
+ folder is a Go or PHP root rather than a JavaScript one.
59
+
60
+ Read these once, from the repository root:
61
+
34
62
  - `canon/REQUIREMENTS.md` and `canon/ARCHITECTURE.md` if present
35
63
  - Directory structure via `ls -1` of the project root and `src/` if present
36
64
 
37
65
  ## Resolve arguments
38
66
 
39
- - **Stack:** pick the closest governance stack by matching detected runtime or framework against stack names in the catalog. If nothing matches, fall back to `base` and carry the fallback into the preview.
40
- - **Tooling stack:** pick the closest tooling stack from `canon tooling list --json` (e.g. `vite-react`, `astro`, `nextjs`). Distinct from the governance stack. Fall back to `base` if no framework match, and carry that fallback into the preview too.
41
- - **Next.js:** map `next` in `package.json` dependencies, or a root `next.config.ts`/`.js`/`.mjs` file, to the `nextjs` governance stack and the `nextjs` tooling stack. The dependency name does not match either stack name by itself.
42
- - **Extras:** identify technologies not already covered by the picked stack. For each, find a rule whose `description` or `paths` points at that technology and pass it via `--add`. Dedupe against the stack's own rules. Do not add a rule the stack already pulls in.
67
+ Governance installs once, at the repository root, and a rule's `paths:` globs
68
+ already reach every subfolder. Tooling installs per folder. So resolve one
69
+ governance stack for the whole repository and one tooling stack per language
70
+ root.
71
+
72
+ - **Stack:** pick the closest governance stack by matching the repository root's detected runtime or framework against stack names in the catalog. If the root matched nothing, `base` is the stack, including when subfolders matched, since no subfolder owns the repository and picking one by order would state a preference nobody gave. Mark it `fallback` in the preview only when no root matched anywhere.
73
+ - **Tooling stack:** for each language root, pick the closest tooling stack from `canon tooling list --json` (e.g. `vite-react`, `astro`, `nextjs`). Distinct from the governance stack. Fall back to `base` for a root with no framework or language match, and carry that fallback into its preview row.
74
+ - **Next.js:** map `next` in a root's `package.json` dependencies, or a `next.config.ts`/`.js`/`.mjs` file in that root, to the `nextjs` governance stack and the `nextjs` tooling stack. The dependency name does not match either stack name by itself.
75
+ - **Extras:** identify technologies not already covered by the picked governance stack, across every language root. A subfolder that matched a governance stack of its own contributes that stack's rules, read from `canon gov list --json`, and every root contributes its language rule. For each technology, find a rule whose `description` or `paths` points at it and pass it via `--add`. Dedupe against the picked stack's own rules. Do not add a rule the stack already pulls in.
76
+ - **Subfolder tooling:** a language root below the repository root takes its own `canon tooling sync <stack> <path> --skip base`, since `canon init` already lands `base` once at the root. Leave a root whose stack resolves to `base` out of the commands, since that sync would land nothing. A repository-root `package.json` hosting only formatters and hooks is that case, and it adds no row.
43
77
  - **Skip (`--skip`):** `wiki` installs by default. Add `--skip wiki` only when the user explicitly wants it left out.
44
78
 
45
79
  ## Gap handling
@@ -67,10 +101,10 @@ toolkit repository, and option 2 hands the project-local file to `create-rule`.
67
101
 
68
102
  Before executing, output:
69
103
 
70
- - **Detected:** each technology with its evidence file
104
+ - **Detected:** each technology with its evidence file, path included, so a subfolder marker reads as one
71
105
  - **Stack:** picked governance stack and resolved rule count. Mark it `fallback` when no detected runtime or framework matched a catalog name.
72
- - **Tooling stack:** picked tooling stack. Mark it `fallback` on the same test, and name what `base` lands: configs, seeds, and gitignore entries in every case, plus the JavaScript development dependencies, scripts, and hook activation wherever a `package.json` exists to carry them. A project outside that ecosystem runs none of the second group and keeps the first. Omit this line entirely on the `gov` phase, which syncs no tooling.
73
- - **Extras:** each `--add` rule with a one-line reason
106
+ - **Tooling stack:** one row per language root, each carrying its path, its picked tooling stack, a `fallback` mark on the same test, and its own command. On the repository root's row, name what `base` lands: configs, seeds, and gitignore entries in every case, plus the JavaScript development dependencies, scripts, and hook activation wherever a `package.json` exists to carry them. A project outside that ecosystem runs none of the second group and keeps the first. Omit these rows entirely on the `gov` phase, which syncs no tooling.
107
+ - **Extras:** each `--add` rule with a one-line reason, listed in full. A second matched stack reaches the install only through this list, since `canon init --stack` takes one stack, so a shortened list reads as that stack dropped.
74
108
  - **Skip:** any `--skip` entries with reason
75
109
  - **Target:** resolved target path
76
110
  - **Commands:** the full chain that will run
@@ -86,7 +86,7 @@ Reading the last record alone is what the schedule replaces. A topic missed thre
86
86
  Two outputs with two lifetimes, and the split decides the format.
87
87
 
88
88
  - A lesson is a self-contained page carrying its own quiz, a teach-back block, and the feedback for each question. It embeds one shared stylesheet rather than restating styles, and it is disposable and never promoted.
89
- - A reference page goes to `reference/<slug>.md`, written for a reader with no learner in it. This is the half that survives the workspace, so it is written in markdown to pass the authoring gates a promotion would put it through.
89
+ - A reference page goes to `reference/<slug>.md`, written for a reader with no learner in it. This is the half that survives the workspace, so it is written in markdown to pass the authoring gates a promotion would put it through. `canon teach nav` renders it to a `reference/<slug>.html` sibling, and a lesson links that sibling, since a served `.md` opens as plain text.
90
90
 
91
91
  Resolve the lesson before writing it, rather than composing its name or its quiz order by hand:
92
92
 
@@ -167,12 +167,14 @@ Follow `${CLAUDE_SKILL_DIR}/references/lesson-craft.md` for what makes a lesson
167
167
 
168
168
  ### Hand over a link, never a path
169
169
 
170
- A lesson is a page carrying a script, and an editor preview cannot run it, so a path alone opens markup with no working quiz. Serve the teach root and give the learner a link they can click:
170
+ A lesson is a page carrying a script, and an editor preview cannot run it, so a path alone opens markup with no working quiz. Serve the teach root and give the learner a link they can click. Take the serve line from the `Open` step of `canon teach list <topic>`, which resolves the teach folder against the directory the session runs in:
171
171
 
172
172
  ```bash
173
- canon serve .canon/teach --entry <nn>-<topic>/index.html --json
173
+ canon teach list <topic>
174
174
  ```
175
175
 
176
+ Run the line it prints with `--json` appended, rather than writing `canon serve .canon/teach` by hand. `canon serve` resolves its folder against the cwd, so that literal serves an absent folder from a linked worktree, where the list verb reads the main worktree root.
177
+
176
178
  Start it in the background so the session keeps going, and read `url` off the record rather than composing one. The verb walks past a port already in use, so the port it took is exactly the half a guessed URL gets wrong. Report the refusal and its `reason` when `ok` is false, and report it rather than proceeding silently when the verb does not resolve at all, which is an installed CLI predating it.
177
179
 
178
180
  Do this on every run that opens or resumes a workspace, including one that writes no lesson, since the learner's route into what is already there is the same link.
@@ -24,11 +24,13 @@ canon audits list --json
24
24
 
25
25
  ## What it runs
26
26
 
27
- Nineteen verbs, listed by `canon audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
27
+ Twenty-one verbs, listed by `canon audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
28
28
 
29
29
  The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `canon deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's: three clean runs measured 44.7, 77.6, and 62.9 seconds, and a stalled lookup runs to an explicit 120-second timeout rather than to bun's own 299-second ceiling. `--corpus` is what keeps a caller from paying that cost when it wants only the corpora on this disk.
30
30
 
31
- Sixteen of the nineteen read a tree on this disk, the one added by `restated.md` and the one added by `census.md` among them, since the first reads four such trees against each other and the second counts every file in one. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
31
+ Eighteen of the twenty-one read a tree on this disk, the one added by `restated.md` and the one added by `census.md` among them, since the first reads four such trees against each other and the second counts every file in one. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
32
+
33
+ The placement entry runs `canon context classify sweep` pinned to `--backend off`, so it reads the regex layer alone whatever classifier setting the project resolves. A count that moves with whichever model a machine runs is not comparable across clones, and the model layer turned a sub-second read into one measured at about 17 minutes over this repository's context and wireframe sections on the authoring machine. It retains one tally per verdict, `keep`, `rewrite`, and `move`, and gates on nothing, since each verdict is a judgment. `document-health` is the surface that reads the same sections with the model.
32
34
 
33
35
  Each is invoked as the CLI the caller is running rather than as a global `canon`. A globally installed binary resolves to the main checkout no matter which worktree is executing, so the aggregate would measure a tree the branch never touched and report a pass over it.
34
36
 
@@ -47,7 +47,7 @@ Full help: `canon <command> --help`. Bare `canon --help` lists every top-level c
47
47
  | `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
48
48
  | `canon teach lesson` | Resolve the numbered path, shared stylesheet, mission success lines, and one quiz option order per question for the next lesson, writing nothing (`--json`) |
49
49
  | `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
50
- | `canon teach nav` | Rewrite the teach-root listing, a workspace's contents page, and each lesson's chrome from its four marker regions (`--json`) |
50
+ | `canon teach nav` | Rewrite the teach-root listing, a workspace's contents page, each lesson's chrome from its four marker regions, and each reference page's rendered HTML sibling (`--json`) |
51
51
  | `canon teach render` | Render a JSON block list (heading, paragraph, list, raw) from stdin to HTML through the shared components, refusing `bad-input` by block index (`--json`) |
52
52
  | `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
53
53
  | `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
@@ -9,11 +9,11 @@ What each finding from `canon context audit` means. The command surface, its fla
9
9
 
10
10
  ## Required sections
11
11
 
12
- The required-section check reports what does not declare `## Overview` and `## Layout`, the two sections `standards/context.md` marks required. The list is held in code beside the numeric checkpoints rather than parsed out of the standard, so it fails on a defect rather than on a rewrite of the wording around it. A heading at any level counts, because a domain that split into a folder carries its overview in a sibling named `overview.md` where the section is the `#` title and an `##` beneath it would repeat the filename. Matching exactly is what keeps `## Layout catalog` from satisfying `Layout`.
12
+ The required-section check reports what does not declare the sections its folder's standard requires. A context entry owes `## Overview` and `## Layout`, the two sections `standards/context.md` marks required. A wireframe owes `## Regions`, `## States`, `## Copy`, and `## Not on this surface`, the sections answering the four questions `standards/wireframes.md` tests a surface against. `## Behavior` is expected of a wireframe rather than required, since a static surface has no interaction to state. The lists are held in code beside the numeric checkpoints rather than parsed out of the standard, so it fails on a defect rather than on a rewrite of the wording around it. A heading at any level counts, because a domain that split into a folder carries its overview in a sibling named `overview.md` where the section is the `#` title and an `##` beneath it would repeat the filename. Matching exactly is what keeps `## Layout catalog` from satisfying `Layout`.
13
13
 
14
- Which unit answers depends on the folder. Entries of the folder named under `.claude/` are one domain each, so each answers for itself and a finding names the entry. Entries of a folder a domain split into describe that one domain between them, so any sibling answers and a finding names the folder. Holding a split folder to the rule per file would report every child beside its `overview.md`, and rolling the named folder up would let one conforming entry stand in for every other domain sitting next to it.
14
+ Which unit answers depends on the folder. Entries of the folder named under `.claude/` are one domain each, so each answers for itself and a finding names the entry. Entries of a folder a domain split into describe that one domain between them, so any sibling answers and a finding names the folder. Holding a split folder to the rule per file would report every child beside its `overview.md`, and rolling the named folder up would let one conforming entry stand in for every other domain sitting next to it. A wireframe answers for itself in every folder, nested or not, since its standard keeps one surface per file and a subfolder of wireframes groups surfaces rather than splitting one.
15
15
 
16
- It reports rather than gates by default, the closer call because a missing section reads more like a fact than the other judgments. What settles it is that the standard sanctions omitting `## Layout` from a domain owning no paths in the repo, and no measure separates that from an entry that forgot it. A domain covering only external tools is that case, and it reports on every run. The JSON record carries the findings as `missingSections` and the list as `checkpoints.requiredSections`.
16
+ It reports rather than gates by default, the closer call because a missing section reads more like a fact than the other judgments. What settles it is that the standard sanctions omitting `## Layout` from a domain owning no paths in the repo, and no measure separates that from an entry that forgot it. A domain covering only external tools is that case, and it reports on every run. The JSON record carries the findings as `missingSections`, the lists per folder name as `checkpoints.requiredSectionsByFolder`, and the context list alone as `checkpoints.requiredSections`, the flat key it carried before wireframes owed a section.
17
17
 
18
18
  `--gate` promotes the finding to a failing exit code, which the seed stage runs and no other caller does. That mode needs an answer to the sanctioned omission above, so a file declaring `stub: true` in its frontmatter is dropped before the check and reported nowhere. Both are described in `context-audit.md`.
19
19
 
@@ -127,11 +127,11 @@ All three wireframe findings are printed under a bare run. The states-mismatch f
127
127
 
128
128
  ## Which folders each check reaches
129
129
 
130
- The provenance, required-section, and narration checks cover `canon/context/` alone, the reference-form check covers the split folders inside it, length and the table finding reach every audited folder, and the wireframe states check covers `canon/wireframes/` alone.
130
+ The provenance and narration checks cover `canon/context/` alone, the reference-form check covers the split folders inside it, the required-section check covers `canon/context/` and `canon/wireframes/`, length and the table finding reach every audited folder, and the wireframe states check covers `canon/wireframes/` alone.
131
131
 
132
- What narrows the three is stated in `standards/context.md`, which opens its scope by handing diagrams and wireframes to `diagrams.md` and `wireframes.md`, and the sibling standards do not restate it. A marker reported in a diagram entry would cite a rule that entry's own standard routes elsewhere, and a diagram entry carries a heading per kind rather than a run of bullets deciding anything. The split is between kinds of rule rather than kinds of folder, and what decides it is which tier states the rule rather than what the check measures.
132
+ What narrows the first two is stated in `standards/context.md`, which opens its scope by handing diagrams and wireframes to `diagrams.md` and `wireframes.md`, and the sibling standards do not restate it. A marker reported in a diagram entry would cite a rule that entry's own standard routes elsewhere, and a diagram entry carries a heading per kind rather than a run of bullets deciding anything. The split is between kinds of rule rather than kinds of folder, and what decides it is which tier states the rule rather than what the check measures.
133
133
 
134
- Length and the table finding generalize as judgments about how far a reader travels, so both reach wherever the audit is pointed. Required sections narrow for a plainer reason: the names are the context standard's own, and neither sibling standard states a required section at all.
134
+ Length and the table finding generalize as judgments about how far a reader travels, so both reach wherever the audit is pointed. Required sections narrow for a plainer reason: each list is one standard's own, so it reaches the folder that standard governs. The context and wireframe standards each state a set, and the diagram standard states none.
135
135
 
136
136
  The same test is what moved depth and bullet weight out of this command entirely. A rule stated at the attribute tier reaches every markdown file, and a check reaching every markdown file has no reason to require a folder that resolves.
137
137
 
@@ -88,7 +88,7 @@ The pattern spells both record-root prefixes, so a citation into a folder that h
88
88
 
89
89
  ## The wireframe states gate
90
90
 
91
- Every entry under `canon/wireframes/` carrying a `## States` table is checked one-to-one against its evidence folders, matching the table's `State` and `Evidence` columns by header text rather than position, so a project reordering the template's own columns still resolves. An entry with no such table reports nothing, which is what leaves a project on an older wireframe shape silent until it adopts one.
91
+ Every entry under `canon/wireframes/` carrying a `## States` table is checked one-to-one against its evidence folders, matching the table's `State` and `Evidence` columns by header text rather than position, so a project reordering the template's own columns still resolves. An entry with no such table has no rows to match, and the required-section check reports it as short of `## States` instead, so a project on an older wireframe shape is told what it lacks rather than read as clean.
92
92
 
93
93
  An evidence cell resolves as the literal path it names, taken from the project root. A state whose cited folder does not exist is a states mismatch, and so is a folder sitting under a cited evidence root that no row names. A `not captured` cell is the one exception the standard admits, and it excuses that row from both readings. A `plaintext` sketch still carried once a state's evidence exists on disk is a third finding this check adds, reading the entry as a whole rather than one row.
94
94
 
@@ -96,4 +96,4 @@ Sweep mode's findings carry a `heading` field alongside `file` and use the sweep
96
96
  | `0` | the run completed, whatever the findings say |
97
97
  | `1` | refused: a bad range, an unreadable file, or a malformed flag |
98
98
 
99
- Findings never set a failing exit code, in either mode. Diff mode refuses on a git range it cannot resolve. Both modes refuse on a file the run could not read.
99
+ Findings never set a failing exit code, in either mode. `canon audits run` carries sweep mode as its placement entry for the same reason, pinned to `--backend off` so the tally it retains is the regex layer's on every machine. `docs/agents/audits.md` states why. Diff mode refuses on a git range it cannot resolve. Both modes refuse on a file the run could not read.
@@ -192,6 +192,12 @@ One detector decides both regions. A lesson carrying `<button class="opt"`, the
192
192
 
193
193
  Splicing the stepper into a lesson of either shape was measured against a browser and reverted. Its rules match a button question, which can never hold a checked radio, so a lesson given both hides every question past the first for good and outranks the class the script reveals feedback with. Nothing else caught that: the markup is valid, the splice succeeds, and the page renders.
194
194
 
195
+ ### Reference pages
196
+
197
+ Each reference page is rendered to a `reference/<slug>.html` sibling carrying the same chrome, and the contents page links that sibling rather than the markdown, which `canon serve` hands over as plain text. The render drops the frontmatter, turns tables into `<table>` markup, and escapes raw HTML written in the markdown rather than passing it through. A body with no H1 gets one from the frontmatter `title`. A relative link to another reference page's markdown points at that page's `.html` sibling, and one to the workspace `GLOSSARY.md` points at the glossary on the contents page, `../index.html#gloss`. Every other link, such as `../RESOURCES.md`, stays as written. The markdown file is never touched.
198
+
199
+ Every rendered page carries a `<meta name="generator" content="canon teach nav">` tag. A `.html` file under `reference/` whose markdown is gone is deleted when it carries that tag, and a hand-written HTML file there, carrying none, is left alone. The record's `reference` field counts the pages rendered. On a Bun with no `Bun.markdown`, each reference page lands in `skipped` with `Bun.markdown` as what is missing, and the rest of the run goes on.
200
+
195
201
  ## Opening a workspace
196
202
 
197
203
  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:
@@ -86,13 +86,15 @@ Each diagram entry records the commit and date it was last verified against, and
86
86
 
87
87
  The default path is `base`. `canon init` on `base` installs base tooling configs, Claude seeds, and governance core rules, and scaffolds an empty `.claude/wiki/`. Most projects need nothing more.
88
88
 
89
- Escalate only for real web apps. The `target-setup` skill reads `package.json` and root configs, then picks the matching tooling stack (`canon tooling list --json` names the current set) and the matching governance stack (`react`, `nextjs`, `astro`, `node`). A Next.js app lands on `nextjs`, which extends `react` with the App Router rule a Vite React app does not get.
89
+ Escalate only for real web apps. The `target-setup` skill reads `package.json` and the framework configs, then picks the matching tooling stack (`canon tooling list --json` names the current set) and the matching governance stack (`react`, `nextjs`, `astro`, `node`). A Next.js app lands on `nextjs`, which extends `react` with the App Router rule a Vite React app does not get.
90
+
91
+ In a monorepo the skill reads each folder holding a `package.json`, `pyproject.toml`, `go.mod`, `composer.json`, or `Cargo.toml` to depth two, skipping what git ignores. Each of those folders gets its own tooling stack and a `canon tooling sync <stack> <folder> --skip base` in the preview. Governance still installs once at the root, since a rule's `paths:` globs reach every subfolder, so a subfolder's language rules join the root install through `--add`.
90
92
 
91
93
  `node-server` is named rather than detected. It carries the server-side security and persistence rules for a project writing request handlers or a persistence layer in TypeScript, and the detect step matches a runtime or a framework against stack names, so nothing there marks a project as a backend. Pass it deliberately with `canon init --stack node-server` or `canon gov install node-server <target>`.
92
94
 
93
95
  Markdown-heavy projects, CLI tools, docs sites, research notebooks, and scripting repos stay on `base`. Escalation is a ceiling move, not a default.
94
96
 
95
- A project the toolkit ships no stack for lands on `base` the same way, and the skill marks that resolution as a fallback in its preview rather than reporting it as a match. Configs, seeds, and gitignore entries land either way, and the JavaScript development dependencies, scripts, and hook activation land only where a `package.json` exists to carry them. A project outside that ecosystem declines at the preview and takes `canon:target-setup gov` for the governance layer alone, which is language-neutral. Go and PHP are the exception: they ship language and testing rules but no stack, so a project in either lands on `base` and picks those rules up through `--add`, for example `canon gov install base --add 130-go,335-testing-go .`
97
+ A project the toolkit ships no stack for lands on `base` the same way, and the skill marks that resolution as a fallback in its preview rather than reporting it as a match. Configs, seeds, and gitignore entries land either way, and the JavaScript development dependencies, scripts, and hook activation land only where a `package.json` exists to carry them. A project outside that ecosystem declines at the preview and takes `canon:target-setup gov` for the governance layer alone, which is language-neutral. Go and PHP ship tooling stacks but no governance stack, so a project in either takes the `go` or `php` tooling stack, lands on `base` for governance, and picks up its language and testing rules through `--add`, for example `canon gov install base --add 130-go,335-testing-go .`
96
98
 
97
99
  Run `canon tooling list --json` and `canon gov list --json` to see the current catalogs. Never hardcode stack names.
98
100
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.114.0",
4
+ "version": "4.115.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -2,6 +2,7 @@ import type { SkillsAuditRefusal } from '@/claude/skills-audit'
2
2
  import type { RankRefusal } from '@/claude/skills-rank'
3
3
  import type { ReachRefusal } from '@/claude/skills-reach'
4
4
  import type { ContextAuditRefusal } from '@/context/audit'
5
+ import type { SweepVerdict } from '@/context/classify/patterns'
5
6
  import type { AuditRefusal } from '@/deps/audit'
6
7
  import type { RestatedRefusal } from '@/gov/restated'
7
8
  import type { LabelAuditRefusal } from '@/labels/audit'
@@ -199,6 +200,45 @@ function architectureCounts(
199
200
  }
200
201
  }
201
202
 
203
+ /**
204
+ * The retained key for each verdict the sweep can return. A record rather than
205
+ * a list, so the compiler fails here when the classifier gains a verdict,
206
+ * instead of that verdict's sections dropping out of the tally.
207
+ */
208
+ const PLACEMENT_KEYS: Record<SweepVerdict, string> = {
209
+ KEEP: 'keep',
210
+ REWRITE: 'rewrite',
211
+ MOVE: 'move',
212
+ }
213
+
214
+ function isPlacementVerdict(value: unknown): value is SweepVerdict {
215
+ return typeof value === 'string' && Object.hasOwn(PLACEMENT_KEYS, value)
216
+ }
217
+
218
+ /**
219
+ * Tallies the sweep's findings by verdict, with a zero for a verdict nothing
220
+ * returned, so a baseline reads the same three keys on every run.
221
+ *
222
+ * `KEEP` is retained beside the two that ask for a change. It moves when a
223
+ * section is added or split, which is what tells a falling `rewrite` count
224
+ * from a corpus that shrank.
225
+ */
226
+ function placementCounts(record: unknown): Record<string, number> | undefined {
227
+ const findings = asObject(record)?.findings
228
+ if (!Array.isArray(findings)) return undefined
229
+
230
+ const counts: Record<string, number> = Object.fromEntries(
231
+ Object.values(PLACEMENT_KEYS).map((key) => [key, 0]),
232
+ )
233
+ for (const finding of findings) {
234
+ const verdict = asObject(finding)?.verdict
235
+ if (!isPlacementVerdict(verdict)) return undefined
236
+ counts[PLACEMENT_KEYS[verdict]] += 1
237
+ }
238
+
239
+ return counts
240
+ }
241
+
202
242
  function contextCounts(record: unknown): Record<string, number> | undefined {
203
243
  const root = asObject(record)
204
244
  if (root === undefined) return undefined
@@ -502,6 +542,18 @@ export const AUDITS: readonly AuditSpec[] = [
502
542
  absentReasons: ['no-folders'] satisfies ContextAuditRefusal[],
503
543
  counts: contextCounts,
504
544
  },
545
+ {
546
+ id: 'placement',
547
+ label: 'Context placement',
548
+ // Pinned to the regex layer rather than the resolved classifier setting.
549
+ // A count that moves with whichever model a machine runs is not
550
+ // comparable across clones, and the model layer turns a sub-second read
551
+ // into a multi-minute one. `document-health` is where the model reads.
552
+ argv: ['context', 'classify', 'sweep', '--backend', 'off', '--json'],
553
+ gatingExits: [],
554
+ corpus: 'tracked',
555
+ counts: placementCounts,
556
+ },
505
557
  {
506
558
  id: 'markdown',
507
559
  label: 'Markdown corpus',
@@ -14,6 +14,8 @@ import {
14
14
  missingSections,
15
15
  PROVENANCE_FOLDER,
16
16
  REQUIRED_SECTIONS,
17
+ REQUIRED_SECTIONS_BY_FOLDER,
18
+ requiredSections,
17
19
  type SectionFinding,
18
20
  } from '@/context/audit'
19
21
  import {
@@ -129,8 +131,9 @@ export function register(program: Command): void {
129
131
  'it holds more decisions than the cap. A record stating neither is',
130
132
  'reported and never gated. --gate widens the gate to the other two',
131
133
  'findings that are facts rather than judgments: a missing required',
132
- 'section and index',
133
- 'drift. Entry length, reference form, table, provenance, narration,',
134
+ 'section and index drift. A context entry requires Overview and',
135
+ 'Layout, and a wireframe requires Regions, States, Copy, and Not on',
136
+ 'this surface. Entry length, reference form, table, provenance, narration,',
134
137
  'and the record claim classification are judgments under both.',
135
138
  '',
136
139
  'Depth and bullet weight are stated over every markdown file rather',
@@ -740,6 +743,10 @@ async function runAudit(
740
743
  renderWidth: RENDER_WIDTH,
741
744
  provenanceFolder: PROVENANCE_FOLDER,
742
745
  requiredSections: REQUIRED_SECTIONS,
746
+ // Keyed on the audited folder name. The flat key above stays for a
747
+ // caller that read it before wireframes owed a section, since turning
748
+ // it into this map would break that caller without an error.
749
+ requiredSectionsByFolder: REQUIRED_SECTIONS_BY_FOLDER,
743
750
  // Three states rather than two, so a record showing no finding says
744
751
  // which terms were looked for. The key is absent when the run never
745
752
  // scanned, which is `--citations-only`, and null when it scanned and
@@ -946,7 +953,8 @@ function reportReferenceForm(
946
953
 
947
954
  /**
948
955
  * Names the path each finding belongs to, which is an entry in the folder named
949
- * under `.claude/` and the folder itself in a domain split across one. States
956
+ * under `.claude/`, the folder itself in a domain split across one, and the
957
+ * file in any wireframe folder. States
950
958
  * the reach on every run for the reason the provenance report does.
951
959
  *
952
960
  * This prints ahead of the four readability measures because a missing section
@@ -959,19 +967,31 @@ function reportSections(
959
967
  ): void {
960
968
  logStep('Sections')
961
969
 
962
- const governed = folders.filter(governsContent)
963
- if (governed.length === 0) {
970
+ const names = [
971
+ ...new Set(
972
+ folders
973
+ .filter((folder) => requiredSections(folder).length > 0)
974
+ .map((folder) => folder.name),
975
+ ),
976
+ ]
977
+ if (names.length === 0) {
964
978
  logInfo(
965
- `Out of scope. The list is stated in the standard governing canon/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
979
+ `Out of scope. The lists are stated in the standards governing ${Object.keys(
980
+ REQUIRED_SECTIONS_BY_FOLDER,
981
+ )
982
+ .map((name) => `canon/${name}/`)
983
+ .join(' and ')}, and no audited folder is one of them.`,
966
984
  )
967
985
  return
968
986
  }
969
987
 
988
+ for (const name of names) {
989
+ logInfo(
990
+ `canon/${name}/ requires ${REQUIRED_SECTIONS_BY_FOLDER[name].join(', ')}.`,
991
+ )
992
+ }
970
993
  logInfo(
971
- `Covers canon/${PROVENANCE_FOLDER}/ alone, whose standard requires ${REQUIRED_SECTIONS.join(' and ')}.`,
972
- )
973
- logInfo(
974
- 'A heading at any level counts. Each entry answers for itself, except in a domain split across a folder, where a sibling answers for the rest.',
994
+ 'A heading at any level counts. Each entry answers for itself, except in a context domain split across a folder, where a sibling answers for the rest. A wireframe answers for itself in every folder.',
975
995
  )
976
996
 
977
997
  if (missing.length === 0) {
@@ -797,6 +797,7 @@ function reportNav(
797
797
  root: outcome.root,
798
798
  contents: outcome.contents,
799
799
  lessons: outcome.lessons,
800
+ reference: outcome.reference,
800
801
  skipped: outcome.skipped,
801
802
  })}\n`,
802
803
  )
@@ -810,9 +811,11 @@ function reportNav(
810
811
  for (const path of outcome.contents) logInfo(path)
811
812
  logStep('Lessons rewritten')
812
813
  logInfo(String(outcome.lessons))
814
+ logStep('Reference pages rendered')
815
+ logInfo(String(outcome.reference))
813
816
 
814
817
  if (outcome.skipped.length > 0) {
815
- logStep('Refused, missing a chrome marker')
818
+ logStep('Refused, missing a chrome marker or the markdown renderer')
816
819
  for (const skip of outcome.skipped) {
817
820
  logWarn(`${skip.file}: no ${skip.missing}`)
818
821
  }
@@ -45,19 +45,35 @@ export const CATALOG_ROW_CHECKPOINT = 6
45
45
  const CATALOG_NAMED_RATIO = 0.6
46
46
 
47
47
  /**
48
- * Sections `standards/context.md` marks required, in the order it states them.
48
+ * Required sections per audited folder name, each in the order its standard
49
+ * states them.
49
50
  *
50
- * The list is held here rather than read out of the standard, the way the four
51
- * checkpoints above quote their numbers. A parser over the standard's prose
51
+ * The lists are held here rather than read out of the standards, the way the
52
+ * four checkpoints above quote their numbers. A parser over a standard's prose
52
53
  * would decide which sections are required from the wording around them, so it
53
54
  * fails on a rewrite of that wording rather than on a defect in an entry.
54
55
  *
55
56
  * These names do not generalize the way a length threshold does, which is why
56
- * the measure is scoped to the folder `governsContent` names. A diagram entry
57
- * declares a heading per kind and a wireframe entry per screen, and neither
58
- * sibling standard states a required section at all.
57
+ * each list is keyed on the folder whose standard states it. `context.md`
58
+ * requires an overview and a layout. `wireframes.md` asks four questions of a
59
+ * surface and answers each in a section, so those four are required and
60
+ * `## Behavior` is not, since a static surface has no interaction to state. A
61
+ * diagram entry declares a heading per kind and its standard requires none.
59
62
  */
60
- export const REQUIRED_SECTIONS: readonly string[] = ['Overview', 'Layout']
63
+ export const REQUIRED_SECTIONS_BY_FOLDER: Readonly<
64
+ Record<string, readonly string[]>
65
+ > = {
66
+ context: ['Overview', 'Layout'],
67
+ wireframes: ['Regions', 'States', 'Copy', 'Not on this surface'],
68
+ }
69
+
70
+ /**
71
+ * The context standard's list, kept as its own export because the audit record
72
+ * has published it flat under `checkpoints.requiredSections` since before any
73
+ * other folder required a section.
74
+ */
75
+ export const REQUIRED_SECTIONS: readonly string[] =
76
+ REQUIRED_SECTIONS_BY_FOLDER.context
61
77
 
62
78
  const HEADING_TEXT = /^#{1,6}\s+(.+?)\s*$/
63
79
  const TABLE_ROW = /^\s*\|/
@@ -226,7 +242,7 @@ export interface EntryReport {
226
242
  readonly bareReferences: readonly BareReferenceFinding[]
227
243
  /**
228
244
  * Required sections this entry declares, in the standard's order, and empty
229
- * outside the folder whose standard names them. What the folder is short of
245
+ * outside a folder whose standard names them. What the folder is short of
230
246
  * is `missingSections`, since one entry answers for its siblings.
231
247
  */
232
248
  readonly sections: readonly string[]
@@ -550,17 +566,20 @@ function narration(
550
566
  * Fenced blocks are skipped for the reason the scans above skip them. A
551
567
  * standard quoted inside an example declares nothing about the entry quoting it.
552
568
  */
553
- function declaredSections(lines: readonly BodyLine[]): string[] {
569
+ function declaredSections(
570
+ lines: readonly BodyLine[],
571
+ required: readonly string[],
572
+ ): string[] {
554
573
  const found = new Set<string>()
555
574
 
556
575
  for (const line of lines) {
557
576
  if (line.fenced) continue
558
577
 
559
578
  const match = line.text.match(HEADING_TEXT)
560
- if (match && REQUIRED_SECTIONS.includes(match[1])) found.add(match[1])
579
+ if (match && required.includes(match[1])) found.add(match[1])
561
580
  }
562
581
 
563
- return REQUIRED_SECTIONS.filter((section) => found.has(section))
582
+ return required.filter((section) => found.has(section))
564
583
  }
565
584
 
566
585
  /**
@@ -611,7 +630,9 @@ function bareReferences(
611
630
  * The caller passes jurisdiction rather than deriving it from `rel`, because a
612
631
  * path prefix hardcodes what `--folder` exists to override and misses a domain
613
632
  * split into `context/<sub-area>/`. Sibling names arrive the same way and for
614
- * the same reason, since the folder an entry sits in is what holds them.
633
+ * the same reason, since the folder an entry sits in is what holds them. The
634
+ * required sections arrive apart from jurisdiction, since a wireframe owes
635
+ * sections under a standard that states no provenance rule.
615
636
  */
616
637
  export function measureEntry(
617
638
  rel: string,
@@ -619,6 +640,7 @@ export function measureEntry(
619
640
  governsContent = true,
620
641
  terms?: NarrationTerms,
621
642
  siblings: readonly string[] = [],
643
+ required: readonly string[] = governsContent ? REQUIRED_SECTIONS : [],
622
644
  ): EntryReport {
623
645
  const lines = bodyLines(source)
624
646
 
@@ -632,7 +654,7 @@ export function measureEntry(
632
654
  provenance: governsContent ? provenance(lines) : [],
633
655
  narration: governsContent && terms ? narration(lines, terms) : [],
634
656
  bareReferences: bareReferences(lines, siblings),
635
- sections: governsContent ? declaredSections(lines) : [],
657
+ sections: declaredSections(lines, required),
636
658
  stub: isStubSeed(source),
637
659
  governed: governsContent,
638
660
  }
@@ -713,6 +735,7 @@ export async function measureFolders(
713
735
  governsContent(folder),
714
736
  terms,
715
737
  names.filter((name) => name !== self),
738
+ requiredSections(folder),
716
739
  ),
717
740
  )
718
741
  }
@@ -726,6 +749,13 @@ export function governsContent(folder: AuditedFolder): boolean {
726
749
  return folder.name === PROVENANCE_FOLDER
727
750
  }
728
751
 
752
+ /** Names the sections the folder's standard requires, empty where it states none. */
753
+ export function requiredSections(folder: AuditedFolder): readonly string[] {
754
+ return Object.hasOwn(REQUIRED_SECTIONS_BY_FOLDER, folder.name)
755
+ ? REQUIRED_SECTIONS_BY_FOLDER[folder.name]
756
+ : []
757
+ }
758
+
729
759
  /**
730
760
  * Reports whether a bare sibling name here is a reference by construction.
731
761
  *
@@ -765,6 +795,11 @@ export function matchesSiblings(folder: AuditedFolder): boolean {
765
795
  * The standard sanctions omitting `## Layout` from a domain owning no paths,
766
796
  * which no measure can tell from an entry that forgot it. An entry of that
767
797
  * shape therefore reports, which is a reason this is printed and never gated on.
798
+ *
799
+ * A wireframe folder answers per file whether nested or not, since its
800
+ * standard keeps one surface per file. A subfolder of wireframes groups
801
+ * surfaces the way the named context folder groups domains, so a conforming
802
+ * sibling there says nothing about the surface beside it.
768
803
  */
769
804
  export function missingSections(
770
805
  root: string,
@@ -774,11 +809,12 @@ export function missingSections(
774
809
  const byRel = new Map(entries.map((entry) => [entry.rel, entry]))
775
810
  const findings: SectionFinding[] = []
776
811
 
777
- const shortOf = (declared: readonly string[]): string[] =>
778
- REQUIRED_SECTIONS.filter((name) => !declared.includes(name))
779
-
780
812
  for (const folder of folders) {
781
- if (!governsContent(folder) || folder.entries.length === 0) continue
813
+ const required = requiredSections(folder)
814
+ if (required.length === 0 || folder.entries.length === 0) continue
815
+
816
+ const shortOf = (declared: readonly string[]): string[] =>
817
+ required.filter((name) => !declared.includes(name))
782
818
 
783
819
  // A stub owes no sections, so it is dropped before either branch rather
784
820
  // than inside them. Leaving one in the split-folder aggregate would let a
@@ -790,7 +826,7 @@ export function missingSections(
790
826
 
791
827
  if (reports.length === 0) continue
792
828
 
793
- if (folder.nested) {
829
+ if (folder.nested && governsContent(folder)) {
794
830
  const missing = shortOf(reports.flatMap((entry) => entry.sections))
795
831
  if (missing.length > 0) findings.push({ rel: folder.rel, missing })
796
832
  continue