@erclx/canon 4.22.1 → 4.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.22.1",
4
+ "version": "4.23.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -138,7 +138,7 @@ Stamp every entry that passed. Set `verified` to the short SHA of `HEAD` and tod
138
138
  git rev-parse --short HEAD
139
139
  ```
140
140
 
141
- The result is one line, `<sha> <date>`, as in `73e9a3f8 2026-08-02`.
141
+ The result is one line, `<sha> <date>`, as in `73e9a3f8 2026-08-02`. <!-- canon-allow-reference: illustrates the verified field's two-token format, which a qualified sha would misstate -->
142
142
 
143
143
  Stamp nothing else. An entry Step 2 left alone was never re-checked, an entry whose defect survived is known wrong, and an entry whose render was skipped was never seen. All three keep the marker they already carry, and Step 7 names the last two. The field means a render was read back and judged correct, so stamping any of these spends the only signal a reader has on a diagram nobody confirmed.
144
144
 
@@ -27,7 +27,7 @@ Most of these name a folder rather than a file, because the number an entry anch
27
27
 
28
28
  The third rule reaches folders alone, which is what keeps it off `CLAUDE.md`. Breadth is the whole reason it exists, and a root file has none: it names one path the diff either carries or does not.
29
29
 
30
- What it does stop is a citation of `.claude/` matching every branch. `src/`, `scripts/`, and `.claude/` each name a tree a project organizes most of its work under, so a prefix match on one fires constantly and rebuilds the ignored warning this sweep exists to avoid. Measured against this repository's record at `87b11e87`, the skip drops 13 of the 26 folder citations and keeps the ones carrying a signal, among them `src/tooling/`, `.claude/rules/`, and `governance/rules/`.
30
+ What it does stop is a citation of `.claude/` matching every branch. `src/`, `scripts/`, and `.claude/` each name a tree a project organizes most of its work under, so a prefix match on one fires constantly and rebuilds the ignored warning this sweep exists to avoid. Measured against this repository's record at `erclx/canon@87b11e87`, the skip drops 13 of the 26 folder citations and keeps the ones carrying a signal, among them `src/tooling/`, `.claude/rules/`, and `governance/rules/`.
31
31
 
32
32
  A decision whose folder citations are all a single segment, and which cites no file, therefore never fires. That is the correct outcome rather than a gap: a count over a whole tree moves on nearly every branch, so flagging it every time tells a reader that time has passed and nothing else.
33
33
 
@@ -119,7 +119,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
119
119
  - Resolve the target at the moment of sending with `canon sessions list --branch`, never from a mapping written down earlier, since names rotate as sessions end and one recorded earlier in a session has failed inside the hour. The runbook read at step 5 routes on the count and the confidence it answers with
120
120
  - Open the message with the worktree and branch the sender believes the reader holds, asking to be corrected, whenever that mapping is inferred rather than confirmed
121
121
  - Name the skill for the reader to run rather than writing an invocation, which arrives as text
122
- - Read the pull request's own draft flag rather than the state a worker reports, since two reported a draft that read ready inside ninety seconds, and report what the read returned and when rather than the state alone. The flag settles the question only once the worker's chain has run its undo, and nothing marks that moment, so a read taken between the pull request opening and that call sees a genuinely ready pull request about to become a draft. That is how a poll on `#1307` told an operator the opposite of what the worker had said.
122
+ - Read the pull request's own draft flag rather than the state a worker reports, since two reported a draft that read ready inside ninety seconds, and report what the read returned and when rather than the state alone. The flag settles the question only once the worker's chain has run its undo, and nothing marks that moment, so a read taken between the pull request opening and that call sees a genuinely ready pull request about to become a draft. That is how a poll on `erclx/canon#1307` told an operator the opposite of what the worker had said.
123
123
 
124
124
  A session is reachable when it appears in a live listing, which reads what each session registered on disk rather than probing it, and a message carries plain text and no authority. When no live session holds the branch, report the invocation for the human, naming the branch, the pull request, and the skill to run, then stop. Retrying or waiting leaves the loop believing it is open while nothing acts on it. Every dispatch in the trial behind this step found a live session, so this branch stands on reasoning rather than on observation.
125
125
 
@@ -100,7 +100,7 @@ The client expands the first slash command in a launch prompt as a user
100
100
  invocation, which is the route `disable-model-invocation: true` permits. Every
101
101
  later command in the same prompt reaches the session as prose, leaving the model
102
102
  to invoke it through the `Skill` tool, and that route answers a flagged skill
103
- inconsistently. `claude-autoship` has carried the flag since `#365` and seven
103
+ inconsistently. `claude-autoship` has carried the flag since `erclx/canon#365` and seven
104
104
  other shipped skills carry it too.
105
105
 
106
106
  The block above therefore leads with the call that does not need the user route
@@ -148,7 +148,7 @@ Report the dispatch as loudly as the human-launch line it replaces: name the bra
148
148
 
149
149
  `claude-address-review` is a single pass, not a chain, so a launch naming it
150
150
  alone reaches no `claude-worker` and takes no role, which owes no message
151
- either. `#1251`'s replacement session was launched that way, onto the branch a
151
+ either. `erclx/canon#1251`'s replacement session was launched that way, onto the branch a
152
152
  review had already posted findings against, and it answered by posting a
153
153
  thread reply and telling its controller nothing. Reach the role directly on
154
154
  this launch instead of wrapping a second chain around one skill that has none
@@ -70,7 +70,7 @@ The state reaches every stalled dispatch, since one threshold governs the headin
70
70
 
71
71
  The report is also where the count in `## Parallelism` is legible. That threshold trips on open pull requests awaiting a first pass, which is what `OPENED` and a pull request with no prior pass name here and what `SEEN` excludes, so read the count off these lines rather than off `gh pr list`, which counts a branch closed out and waiting on a merge the same as one nobody has read. It is a separate condition from the poll-start fallback above, which decides when this loop runs rather than where a review runs.
72
72
 
73
- The count reads low, and it errs in the direction that breaks the trigger. A review's `commit.oid` is stamped with the head at submission rather than with the commit the reviewer read, so an author pushing between the diff read and the post leaves the pass recorded against a commit it never saw, and `SEEN` then fires on a head still awaiting its first look at that delta. Measured on `#1299` on 2026-08-31, where a pass written against `5653721` landed stamped `a5ceb40` and the delta it skipped was a real fix. So a `SEEN` on a head you do not recognize is worth one `gh pr view --json reviews` before it is believed, and a wave past three is likelier to trip the fallback late than early.
73
+ The count reads low, and it errs in the direction that breaks the trigger. A review's `commit.oid` is stamped with the head at submission rather than with the commit the reviewer read, so an author pushing between the diff read and the post leaves the pass recorded against a commit it never saw, and `SEEN` then fires on a head still awaiting its first look at that delta. Measured on `erclx/canon#1299` on 2026-08-31, where a pass written against `erclx/canon@5653721` landed stamped `erclx/canon@a5ceb40` and the delta it skipped was a real fix. So a `SEEN` on a head you do not recognize is worth one `gh pr view --json reviews` before it is believed, and a wave past three is likelier to trip the fallback late than early.
74
74
 
75
75
  The other side of that comparison used to lag as well, which made the two errors compound rather than cancel. `gh pr view --json headRefOid` answers from the pull request object and that object trails the branch ref by up to a minute after a push, reporting nothing about the trail, so a pass stamped ahead of the commit it read was compared against a head stamped behind the commit that exists. Two sessions were wrong off that field on 2026-09-01: a reviewing session posted a finding calling a pushed commit unpushed, and a worker fired a green claim it retracted on its own thread minutes later. The head now comes from `canon pr head`, which resolves the tip through `git ls-remote` and reports which commit each source names, and the object's head stays behind it as the fallback for a target whose CLI predates the verb. The review stamp is unchanged, so the paragraph above still holds on its own half.
76
76
 
@@ -95,7 +95,7 @@ A non-empty result carries the comment id Step 4 needs for the third filename se
95
95
 
96
96
  A `## Post-review findings` reply carries no argued finding behind it, since it asserts a new defect rather than answering one, and this pass is its first independent reader. Restating it as a finding without opening anything is repeating the worker's claim rather than checking it. Read the file the comment names at `<headRefOid>`, the same `git show <headRefOid>:<path>` read Step 3 already runs to confirm a ticked box, and confirm the defect before it becomes a finding of this pass's own.
97
97
 
98
- A moved head has its own way to add nothing, which the ancestor test cannot see. When `<prior-heading>` reads `## Review closed`, the standing verdict already reports the branch clear, so a pass over the new commits that raises nothing posts a comment saying what the one above it said. Two close-outs landed on `#1201` that way on 2026-08-28, and the operator caught the pair rather than any check.
98
+ A moved head has its own way to add nothing, which the ancestor test cannot see. When `<prior-heading>` reads `## Review closed`, the standing verdict already reports the branch clear, so a pass over the new commits that raises nothing posts a comment saying what the one above it said. Two close-outs landed on `erclx/canon#1201` that way on 2026-08-28, and the operator caught the pair rather than any check.
99
99
 
100
100
  The producing shape is narrow. A prior pass tells the author a change is their own call, the author makes it, and the delta reaching the next pass has nothing left to say by construction.
101
101
 
@@ -29,4 +29,4 @@ Read these files in parallel:
29
29
 
30
30
  Every skill carries a requirement. A skill created without one is a gap someone closes in a later sweep, and the sweep has to reconstruct what the skill was for from the body it already shipped.
31
31
 
32
- The two-question check has a shipped precedent. Pull request `#516` dropped `git-stash` for wrapping a single git command that needed no skill body, and `release-changelog` for being low-frequency and invoked by no other skill. The first is question one, and the second is question three read back after the fact, which is why it stays a review criterion rather than something this skill can gate on.
32
+ The two-question check has a shipped precedent. Pull request `erclx/canon#516` dropped `git-stash` for wrapping a single git command that needed no skill body, and `release-changelog` for being low-frequency and invoked by no other skill. The first is question one, and the second is question three read back after the fact, which is why it stays a review criterion rather than something this skill can gate on.
@@ -13,7 +13,7 @@ The output of that arrangement is specific rather than general. Paragraphs come
13
13
 
14
14
  A session also has no name for the patterns no closed word set can match. Rule-of-three, synonym cycling, false ranges, inline-header lists, and adverb propping each survive a clean scan, so a passage carrying all five reports zero findings and still reads wrong. Nothing tells the session those patterns exist, and a ban list cannot be extended to reach them, because each is a shape rather than a token.
15
15
 
16
- Importing that catalog wholesale fails on a different measurement. External material of this kind bans abstract metaphor nouns and names `surface` among them, which this corpus uses 593 times in tracked markdown at `57ee7467`. A session handed the catalog with no stated position either rewrites established vocabulary or silently ignores the item, and neither outcome is recoverable by a later reader who cannot tell which happened.
16
+ Importing that catalog wholesale fails on a different measurement. External material of this kind bans abstract metaphor nouns and names `surface` among them, which this corpus uses 593 times in tracked markdown at `erclx/canon@57ee7467`. A session handed the catalog with no stated position either rewrites established vocabulary or silently ignores the item, and neither outcome is recoverable by a later reader who cannot tell which happened.
17
17
 
18
18
  ## Must
19
19
 
@@ -24,7 +24,7 @@ The ambiguity rules travel with that layer for the same reason. Compression is w
24
24
  - **Any restatement of a banned word set.** The bans ship as package data that `canon markdown audit` reads, and a copy here would be a second source for one list with a machine reading the other.
25
25
  - **A voice register per surface.** One baseline covers every surface. A surface needing a warmer register states its own voice and claims the yield, which lifts voice alone and leaves the bans, the rhythm rules, and the tells in force.
26
26
 
27
- The first of those four rests on a count. The source names `surface` among the nouns it bans, and tracked markdown in the authoring repository used `surface` 593 times, `harness` 95, `scaffolding` 17, and `primitive` 4, measured at `57ee7467` on 2026-08-19. Adopting the item either rewrites established vocabulary across the corpus or is ignored in silence, and a reader arriving later cannot tell which happened.
27
+ The first of those four rests on a count. The source names `surface` among the nouns it bans, and tracked markdown in the authoring repository used `surface` 593 times, `harness` 95, `scaffolding` 17, and `primitive` 4, measured at `erclx/canon@57ee7467` on 2026-08-19. Adopting the item either rewrites established vocabulary across the corpus or is ignored in silence, and a reader arriving later cannot tell which happened.
28
28
 
29
29
  ## Where this diverges
30
30
 
@@ -55,7 +55,7 @@ The table check reports a catalog that grows a row per shipped thing, not a tabl
55
55
 
56
56
  The provenance check reports the markers narrating how a domain reached its shape rather than describing what it is: a date, a change number, or a release label. The standard admits a rejected alternative and the reasoning that killed it while refusing the provenance attached to it, so a marker names a line to read rather than a line to delete. Findings group by entry and sort left to right within a line, since what a reader acts on is which file to open.
57
57
 
58
- A date stamping a measurement is excluded, because the standard cuts a date attached to a change and permits one dating a figure. The check reads the clause in front of the date, back to the nearest sentence boundary, for one of five verbs: measured, verified, driven, passed, and fired. The noun `run` counts only where it sits against the date, so `A run on 2026-08-14` is excluded and `Runs on #632 and #634 landed 2026-08-02` reports. The set is closed, and a date it cannot place reports as a change marker rather than as a state of its own, which names one date too many rather than clearing one the rule cuts.
58
+ A date stamping a measurement is excluded, because the standard cuts a date attached to a change and permits one dating a figure. The check reads the clause in front of the date, back to the nearest sentence boundary, for one of five verbs: measured, verified, driven, passed, and fired. The noun `run` counts only where it sits against the date, so `A run on 2026-08-14` is excluded and `Runs on erclx/canon#632 and erclx/canon#634 landed 2026-08-02` reports. The set is closed, and a date it cannot place reports as a change marker rather than as a state of its own, which names one date too many rather than clearing one the rule cuts.
59
59
 
60
60
  A release label reports with or without its leading `v` at three segments, since the rule cuts the label rather than a spelling of it. Two segments still need the `v`, which keeps a dollar cost and a duration out. Another tool's version reports too, and the check cannot tell one from a release, so treat a version beside a tool name as a line to read rather than one to cut.
61
61
 
@@ -28,7 +28,7 @@ The positional is the pull request to read, defaulting to the one open on this b
28
28
 
29
29
  Without `--body` the body, the file list, and the head commit come back from one `gh pr view` call, because the three have to describe the same commit and reading them apart leaves a window where a push between them compares a body against another head's files.
30
30
 
31
- That view caps its file list at 100 rows and says nothing about having done so, which was measured against `#1250`: the pull request carries 101 files and the view reports 100. A pull request at the cap therefore takes a second read through the paginated endpoint, and a failure there refuses with `gh-truncated` rather than comparing against a set known to be short. A set silently one file short is the worst input this takes, since the missing file is exactly what a correct bullet would then be accused of inventing.
31
+ That view caps its file list at 100 rows and says nothing about having done so, which was measured against `erclx/canon#1250`: the pull request carries 101 files and the view reports 100. A pull request at the cap therefore takes a second read through the paginated endpoint, and a failure there refuses with `gh-truncated` rather than comparing against a set known to be short. A set silently one file short is the worst input this takes, since the missing file is exactly what a correct bullet would then be accused of inventing.
32
32
 
33
33
  ## The two directions
34
34
 
@@ -105,7 +105,7 @@ The bound is that the corpus is one repository writing to one house style. A pro
105
105
 
106
106
  What it cannot see is a bullet claiming a change to a file the branch did touch for another reason, and a path written without backticks. A claim in a second coordinated clause after the comma is read and credited, and it stays out of the graded direction, since nothing here separates it from a file the same clause cites for context.
107
107
 
108
- One class stays open and is named rather than closed. A bullet can cite where something is defined while claiming an edit somewhere else, as `#1274` does with "Name the slug transform in `standards/slug.md` inline in the same step". The path is a definition site, the edit target is the step, and separating the two needs the sentence parsed rather than cut. A finding on such a bullet is this class rather than a stale claim, and a reader weighing an `unmet` path checks whether the bullet's real target is a locative the path does not name.
108
+ One class stays open and is named rather than closed. A bullet can cite where something is defined while claiming an edit somewhere else, as `erclx/canon#1274` does with "Name the slug transform in `standards/slug.md` inline in the same step". The path is a definition site, the edit target is the step, and separating the two needs the sentence parsed rather than cut. A finding on such a bullet is this class rather than a stale claim, and a reader weighing an `unmet` path checks whether the bullet's real target is a locative the path does not name.
109
109
 
110
110
  ## Where it runs
111
111
 
@@ -114,7 +114,7 @@ The unit is the paragraph and each file names its worst on each measure, which f
114
114
 
115
115
  Neither number gates and neither names a file wrong. This is a weaker claim than the one the weight checkpoints make, because a healthy range differs by surface: a catalog entry is several short sentences carrying one fact each, and a page arguing a decision is not, so one range applied across the corpus would report the surfaces that are correct. The run therefore states where the numbers came from beside them, and the counts are what a reader compares against.
116
116
 
117
- Measured at `6c273324` across the corpus, 2119 paragraphs reached the floor, 165 sat at or under the spread checkpoint and 50 past the opener checkpoint. Over the 60 files carrying at least ten measured paragraphs the flat rate runs from zero to 21 percent with a median near 6, which is the spread that makes the measure worth reading. Two wiki pages sit at either end of it: a server catalog reports 19 percent, its flattest paragraph five sentences within four words of each other and three of them verbless, while a page arguing where the prose rules came from reports none.
117
+ Measured at `erclx/canon@6c273324` across the corpus, 2119 paragraphs reached the floor, 165 sat at or under the spread checkpoint and 50 past the opener checkpoint. Over the 60 files carrying at least ten measured paragraphs the flat rate runs from zero to 21 percent with a median near 6, which is the spread that makes the measure worth reading. Two wiki pages sit at either end of it: a server catalog reports 19 percent, its flattest paragraph five sentences within four words of each other and three of them verbless, while a page arguing where the prose rules came from reports none.
118
118
 
119
119
  That reading travels with the command rather than staying here. `BASELINE` in `src/markdown/structure.ts` carries the overall share, the per-file range, and the ten-paragraph floor beneath which a file's own rate says nothing, and the run prints all four in the legend beside the rate it measured. A count with no range beside it reads as a finding, and naming that a healthy range differs by surface states that a range exists rather than what it looks like. This page is toolkit-internal, so a reader running the command in a project that installed no standards would otherwise have two counts and nothing to place them against.
120
120
 
@@ -132,7 +132,7 @@ Exit codes are `0` for a completed run with no gating finding, `1` for a refusal
132
132
 
133
133
  A banned character is a fact rather than a judgment, which is the test that admits it to a gate. What held it back was that gating on day one against a corpus never checked mechanically fails loudly on work nobody has had a chance to fix. The order was to land the verb reporting, measure the corpus once, fix what it finds, and turn the gate on as its own change, and the gate is the last of the four.
134
134
 
135
- Measured at `4b7b13a2` across 444 files with the paragraph checkpoint at 600: 8 word hits, no character or spelling hits, 119 heavy bullets, 221 heavy paragraphs across 86 files, and 41 files carrying a run past the depth checkpoint. Of the paragraphs, 88 fire on weight alone. That is the baseline the corpus sweep started from, and the ban count is the only one of the five a gate should ever read.
135
+ Measured at `erclx/canon@4b7b13a2` across 444 files with the paragraph checkpoint at 600: 8 word hits, no character or spelling hits, 119 heavy bullets, 221 heavy paragraphs across 86 files, and 41 files carrying a run past the depth checkpoint. Of the paragraphs, 88 fire on weight alone. That is the baseline the corpus sweep started from, and the ban count is the only one of the five a gate should ever read.
136
136
 
137
137
  The ban half reached zero, which was the precondition the gate waited on, and it was re-measured against the same corpus at the moment the gate landed. Three sweep slices then took the structural half, and the corpus reports 1 heavy bullet and 22 heavy paragraphs across 6 files on 2026-08-06, every one on a ground the triage record holds as exempt.
138
138
 
@@ -60,7 +60,7 @@ The check is preventive rather than diagnostic. Every mechanical rule passed acr
60
60
 
61
61
  ```bash
62
62
  canon claude skills drift HEAD~20
63
- canon claude skills drift 02d7b265 --json
63
+ canon claude skills drift 02d7b265 --json # canon-allow-reference: a copyable command takes a literal git ref, which no qualified form is
64
64
  ```
65
65
 
66
66
  | Option | Behavior |
@@ -38,7 +38,7 @@ Pass an empty replacement to retire a value outright, as in `canon gov supersede
38
38
 
39
39
  ## The templated citation
40
40
 
41
- A citation naming a family as a pattern carries neither value. `canon-*` and `internal-<X>` are the two forms this corpus writes, and a literal comparison matches neither, so a rename running the verb once per name reports clean while the family citations stay stale. Two of those on `#1250` had inverted, stating the old prefix as a guard, which left a rule instructing the next session to flag the correct naming as a mismatch.
41
+ A citation naming a family as a pattern carries neither value. `canon-*` and `internal-<X>` are the two forms this corpus writes, and a literal comparison matches neither, so a rename running the verb once per name reports clean while the family citations stay stale. Two of those on `erclx/canon#1250` had inverted, stating the old prefix as a guard, which left a rule instructing the next session to flag the correct naming as a mismatch.
42
42
 
43
43
  Beside the literal comparison the run matches a family stem, derived from the segment the two values actually differ on, with everything they share carried ahead of it. `toolkit-operator` to `canon-operator` derives `toolkit`, and `canon-cli` to `canon-shell` derives `canon-cli` rather than `canon`, which is what keeps a rename of one folder off every sibling in its family.
44
44
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.22.1",
4
+ "version": "4.23.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,6 +1,12 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
+ import {
5
+ isShippedCorpus,
6
+ REFERENCE_MARKER,
7
+ referencesIn,
8
+ SHIPPED_CORPORA,
9
+ } from '@/shipped/references'
4
10
 
5
11
  export interface CommandResult {
6
12
  readonly exitCode: number
@@ -434,6 +440,98 @@ export const standardCriteria: Measure = async (ctx) => {
434
440
  }
435
441
  }
436
442
 
443
+ /**
444
+ * Every file in the gated corpus, walked from `ctx.root` rather than from the
445
+ * changed set.
446
+ *
447
+ * The stage's `scope` already decides whether the run happens at all, and once
448
+ * it does the whole corpus is read. A walk keyed on the diff would pass a
449
+ * branch that moved a reference between two files without changing it, and the
450
+ * corpus is a few hundred text files, so reading it whole costs the stage
451
+ * nothing it would notice.
452
+ *
453
+ * `dot` is what reaches the seeds, which is half of what `tooling/` ships. Every
454
+ * seeded `.claude/` tree, `.cspell/` list, and `.husky/` hook sits behind a
455
+ * dotted segment, so the default scan walks `tooling/` and returns none of the
456
+ * files a scaffolded project actually receives.
457
+ */
458
+ function shippedCorpusFiles(root: string): string[] {
459
+ const files: string[] = []
460
+
461
+ for (const corpus of SHIPPED_CORPORA) {
462
+ const dir = join(root, corpus)
463
+ if (!existsSync(dir)) continue
464
+
465
+ for (const relative of new Bun.Glob('**/*').scanSync({
466
+ cwd: dir,
467
+ onlyFiles: true,
468
+ dot: true,
469
+ })) {
470
+ const path = `${corpus}/${relative}`
471
+ if (isShippedCorpus(path)) files.push(path)
472
+ }
473
+ }
474
+
475
+ return files.sort()
476
+ }
477
+
478
+ /**
479
+ * A pull request number or a commit sha that a reader in a target cannot
480
+ * resolve, over the seven corpora that reach one.
481
+ *
482
+ * Every instance on the trunk was written by a branch that passed review, this
483
+ * row's own planning session included, so the only instrument before this stage
484
+ * was a person noticing. That is the argument for gating rather than reporting:
485
+ * `canon labels audit` already demonstrated that a finding nobody gates on goes
486
+ * unread until a range happens to include it, and the count here grew from six
487
+ * to eighteen while a report was the only instrument.
488
+ *
489
+ * It is a prose pattern rather than a resolution. Nothing here asks GitHub
490
+ * whether a number resolves, so a genuinely reachable citation written bare
491
+ * fails it. That is the intended direction, since the repair is one token and
492
+ * the alternative is a network read inside `bun run check`.
493
+ *
494
+ * The whole set is emitted before the failure returns. A stage halts the run on
495
+ * its first failing check, so a branch carrying several references would
496
+ * otherwise see one and repair one.
497
+ */
498
+ export const shippedReferences: Measure = async (ctx) => {
499
+ const files = shippedCorpusFiles(ctx.root)
500
+
501
+ if (files.length === 0) {
502
+ return {
503
+ emissions: [],
504
+ unmeasured: `No corpus under ${SHIPPED_CORPORA.join(', ')} is present, so no shipped file was read.`,
505
+ }
506
+ }
507
+
508
+ const found = files.flatMap((file) =>
509
+ referencesIn(file, readFileSync(join(ctx.root, file), 'utf8')),
510
+ )
511
+
512
+ if (found.length === 0) {
513
+ return {
514
+ emissions: [
515
+ info(
516
+ `No unresolvable reference across ${files.length} files in ${SHIPPED_CORPORA.length} shipped corpora`,
517
+ ),
518
+ ],
519
+ }
520
+ }
521
+
522
+ return {
523
+ emissions: found.map((reference) =>
524
+ warn(
525
+ `${reference.file}:${reference.line} carries ${reference.text}, a ${reference.kind === 'commit' ? 'commit sha that resolves nowhere' : 'pull request number that resolves elsewhere'} for a reader in a target`,
526
+ ),
527
+ ),
528
+ failure:
529
+ found.length === 1
530
+ ? `One reference in the shipped corpora names this repository without saying so. Qualify it as owner/repo#123 or owner/repo@abc1234, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
531
+ : `${found.length} references in the shipped corpora name this repository without saying so. Qualify each as owner/repo#123 or owner/repo@abc1234, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
532
+ }
533
+ }
534
+
437
535
  /**
438
536
  * `canon sandbox coverage` moves only when a person runs it, so a scenario added
439
537
  * with no expectation ships unnoticed.
@@ -7,9 +7,11 @@ import {
7
7
  recordIdempotence,
8
8
  sandboxCoverage,
9
9
  seedStandards,
10
+ shippedReferences,
10
11
  standardCriteria,
11
12
  unreferencedRules,
12
13
  } from '@/gate/measures'
14
+ import { SHIPPED_CORPORA } from '@/shipped/references'
13
15
 
14
16
  /**
15
17
  * One thing a stage does, as an argument vector rather than a shell line.
@@ -354,6 +356,17 @@ export const STAGES: readonly Stage[] = [
354
356
  label: 'Markdown bans',
355
357
  checks: [{ kind: 'measure', measure: markdownBans }],
356
358
  },
359
+ {
360
+ // Scoped to the corpora it reads rather than run unconditionally, so a
361
+ // branch touching only `src/` or `.claude/` skips it and says so. The
362
+ // measure walks the whole corpus once the scope lets it run, which is what
363
+ // reaches a reference moved between two files rather than written.
364
+ id: 'shipped-references',
365
+ label: 'Shipped references',
366
+ scope: new RegExp(SHIPPED_CORPORA.map((corpus) => `^${corpus}/`).join('|')),
367
+ skipped: 'No shipped corpus changed, so no reference was read',
368
+ checks: [{ kind: 'measure', measure: shippedReferences }],
369
+ },
357
370
  {
358
371
  id: 'seed-standards',
359
372
  label: 'Seed standards',
@@ -0,0 +1,160 @@
1
+ import { isMarked } from '@/exempt-marker'
2
+
3
+ /**
4
+ * The corpora a target reader reaches, which is the `files` field less `src`.
5
+ *
6
+ * The field is the statement of what leaves this repository, and `src/secrets/
7
+ * shipped.ts` reads it directly for the credential sweep. This list is narrower
8
+ * on purpose rather than by omission. What decides membership here is whether
9
+ * anything serves the file to a reader who does not hold this repository: a
10
+ * skill body loads into a session, a docs page is read through `canon docs`, a
11
+ * standard through `canon standards`, a rule through a glob match, a snippet
12
+ * through an `@` expansion, and a seed through an install. A `src/` doc comment
13
+ * is read by someone working on the toolkit, in this checkout, where every
14
+ * number resolves.
15
+ *
16
+ * That is also what keeps the check a prose pattern rather than a parser.
17
+ * `src/design/base.css` and `src/design/tokens.ts` carry sixteen values shaped
18
+ * `#191512`, which no width or boundary rule separates from a pull request
19
+ * number, so telling them apart would need the line's syntactic position.
20
+ *
21
+ * `claude/standards` and `claude/snippets` are symlinks into two of the roots
22
+ * below, and a walk that followed them would read those two corpora twice and
23
+ * report every finding in them under two paths. Nothing is lost by not
24
+ * following, since both are corpora here in their own right.
25
+ */
26
+ export const SHIPPED_CORPORA = [
27
+ 'claude',
28
+ 'docs',
29
+ 'governance',
30
+ 'scripts',
31
+ 'snippets',
32
+ 'standards',
33
+ 'tooling',
34
+ ] as const
35
+
36
+ /**
37
+ * Trees inside a corpus above that no target receives, matching the negations
38
+ * the `files` field already makes. A number under either names something its
39
+ * only reader can already resolve.
40
+ */
41
+ export const SHIPPED_EXCLUSIONS = [
42
+ /^scripts\/sandbox\//,
43
+ /^scripts\/eval\//,
44
+ ] as const
45
+
46
+ /** Whether a repository-relative path sits in the gated corpus. */
47
+ export function isShippedCorpus(path: string): boolean {
48
+ if (SHIPPED_EXCLUSIONS.some((pattern) => pattern.test(path))) return false
49
+ if (path.endsWith('.test.ts')) return false
50
+
51
+ return SHIPPED_CORPORA.some((corpus) => path.startsWith(`${corpus}/`))
52
+ }
53
+
54
+ export const REFERENCE_MARKER = 'canon-allow-reference'
55
+
56
+ /**
57
+ * A pull request number a target reader resolves against their own repository.
58
+ *
59
+ * The leading boundary is what excludes the repair form by construction rather
60
+ * than by exemption, and that is load bearing. A lookbehind rejecting a word
61
+ * character before `#` never matches `erclx/canon#1299`, so a qualified
62
+ * reference passes with no marker, and `claude/skills/claude-worktree/SKILL.md`
63
+ * already ships `anthropics/claude-code#58345` in exactly that form.
64
+ *
65
+ * The trailing boundary is what the first shape of this pattern lacked, and it
66
+ * costs a live finding to omit. `governance/rules/framework/250-tailwind.md`
67
+ * writes `bg-[#316ff6]`, a Tailwind arbitrary hex color, which an unbounded
68
+ * `#[0-9]+` reads as pull request 316. Requiring a non-word character after the
69
+ * digits rejects it and rejects nothing real, since a citation is always
70
+ * followed by a space, a backtick, or punctuation.
71
+ *
72
+ * Any digit run counts. This repository is past `#1300` so a four-digit pattern
73
+ * reads as complete, and `#1`, `#12`, and `#123` all resolve to the wrong thing
74
+ * in a target just as surely.
75
+ */
76
+ const PULL_REQUEST = /(?<![0-9A-Za-z_])#([0-9]+)(?![0-9A-Za-z_])/g
77
+
78
+ /**
79
+ * A commit sha, which is the worse half: it resolves to nothing anywhere rather
80
+ * than to the wrong pull request.
81
+ *
82
+ * Seven characters is git's own short-sha floor and forty is a full one. No
83
+ * rule here requires a letter, because an all-digit sha is real:
84
+ * `orchestrator-poll.md` cites `5653721`, and the first pass at this classifier
85
+ * required a letter and lost it.
86
+ *
87
+ * The lookbehind rejects `@` and `/` so the repair form `erclx/canon@5653721`
88
+ * passes the check that told the author to write it, and rejects `#` so a hex
89
+ * color long enough to reach the floor is not reported twice under two names.
90
+ *
91
+ * Three false-positive classes survive by construction and the marker is what
92
+ * answers each. An all-digit hex color is indistinguishable from a pull request
93
+ * number. A seven-letter word spelled from `a` through `f` alone, such as
94
+ * `defaced`, reads as a sha. Neither occurs anywhere in the corpus today.
95
+ *
96
+ * The third is the one a later author meets. Admitting an all-digit sha admits
97
+ * every run of seven or more decimal digits with it, so a date written without
98
+ * separators, a large count, or a float artifact reads as a commit reference.
99
+ * The corpus carries no instance and that is the exclusions rather than luck:
100
+ * twelve such runs sit under the seven roots, eleven of them under
101
+ * `scripts/sandbox/`, `scripts/eval/`, or a test file, and the twelfth is the
102
+ * genuine sha in `orchestrator-poll.md`. Write a seven-digit measurement into a
103
+ * shipped page and the push fails on it, which the marker answers and no
104
+ * narrowing can, since requiring a letter loses the all-digit sha above.
105
+ */
106
+ const COMMIT_SHA = /(?<![0-9A-Za-z_@/#])([0-9a-f]{7,40})(?![0-9A-Za-z_])/g
107
+
108
+ export interface ShippedReference {
109
+ readonly file: string
110
+ /** One-based, matching the `file:line` form a reader clicks. */
111
+ readonly line: number
112
+ readonly kind: 'pull-request' | 'commit'
113
+ /** The reference as written, so a report names the token to qualify. */
114
+ readonly text: string
115
+ }
116
+
117
+ /**
118
+ * Every reference in one shipped file that no marker mutes.
119
+ *
120
+ * The corpus walk is deliberately absent, which lets the shape be tested
121
+ * against a string rather than against a fixture. That is the seam
122
+ * `headingCitationsIn` draws in `src/claude/skills-headings.ts` and `citationsIn`
123
+ * draws in `skills-reach.ts`.
124
+ *
125
+ * The unit is the match rather than the line, unlike those two, because one
126
+ * line here can carry three separate tokens each needing its own repair and a
127
+ * report naming the line once leaves two of them unsaid.
128
+ *
129
+ * `isMarked` reads the line itself and the line above and stops there, so the
130
+ * marker mutes a line and nothing narrower. A real citation later added beside
131
+ * a marked illustration ships unreported.
132
+ */
133
+ export function referencesIn(file: string, text: string): ShippedReference[] {
134
+ const lines = text.split('\n')
135
+ const references: ShippedReference[] = []
136
+
137
+ for (const [index, line] of lines.entries()) {
138
+ if (isMarked(lines, index, REFERENCE_MARKER)) continue
139
+
140
+ for (const match of line.matchAll(PULL_REQUEST)) {
141
+ references.push({
142
+ file,
143
+ line: index + 1,
144
+ kind: 'pull-request',
145
+ text: match[0],
146
+ })
147
+ }
148
+
149
+ for (const match of line.matchAll(COMMIT_SHA)) {
150
+ references.push({
151
+ file,
152
+ line: index + 1,
153
+ kind: 'commit',
154
+ text: match[0],
155
+ })
156
+ }
157
+ }
158
+
159
+ return references
160
+ }
@@ -37,7 +37,7 @@ An entry that fails these is non-conforming regardless of whether it satisfies e
37
37
  - `title` (required): sentence case, names what the entry answers (`System context`, `Request flow`), not what it draws.
38
38
  - `description` (required): one line on which question the entry settles and which source signal drives it.
39
39
  - `category` (required): the diagram kind, one of the five in Entry kinds. It is the grouping field `canon indexes regen` renders headings from.
40
- - `verified` (required): the short commit SHA an entry was last checked against and the ISO date of that check, separated by a space (`73e9a3f8 2026-08-02`). A stub nobody has drawn yet carries the literal `TODO: never verified` instead, which is the one other accepted value.
40
+ - `verified` (required): the short commit SHA an entry was last checked against and the ISO date of that check, separated by a space (`73e9a3f8 2026-08-02`). A stub nobody has drawn yet carries the literal `TODO: never verified` instead, which is the one other accepted value. <!-- canon-allow-reference: illustrates the field's two-token format, which a qualified sha would misstate -->
41
41
  - `stale` (optional): one line naming what changed under the entry since that check. Nothing writes it on its own, so it sits on an entry because a reader put it there and is absent everywhere else.
42
42
 
43
43
  The first three feed `.canon/diagrams/index.md` when regenerated. The catalog sorts categories alphabetically rather than in narrative order, so an entry cannot rely on its position to introduce another. Each entry names its own starting point, and the catalog's subtitle routes a first-time reader to the system context entry.
@@ -60,7 +60,11 @@ Removing the link before a merge does not substitute for never writing it. One b
60
60
 
61
61
  A number referring to a pull request or an issue takes the form its destination renders. Write it bare where the destination auto-links it, and in backticks where it does not. Both spellings are correct, each in one place, so a reference moved from one destination to the other is rewritten rather than copied.
62
62
 
63
- Text published to a remote is the auto-linking half: a pull request body, an issue body, a comment on either, and a commit message. Write `#123` there, and `owner/repo#123` where the reference crosses repositories, which the same auto-linking reaches. A markdown file browsed from the repository tree is the other half, where neither spelling links and the backticked one marks the number as an identifier.
63
+ Text published to a remote is the auto-linking half: a pull request body, an issue body, a comment on either, and a commit message. Write `#123` there, and `owner/repo#123` where the reference crosses repositories, which the same auto-linking reaches. A markdown file browsed from the repository tree is the other half, where neither spelling links and the backticked one marks the number as an identifier. <!-- canon-allow-reference: the two spellings this section defines, which a qualified form would destroy -->
64
+
65
+ Content that installs into another repository is the third destination, and there the qualified form is required rather than chosen. A skill body loads from a plugin cache, a docs page is read through `canon docs`, and a standard through `canon standards`, so the reader holds their own repository rather than the one the number belongs to. A bare number resolves against theirs and reaches something else, and a bare sha resolves nowhere at all.
66
+
67
+ Write `owner/repo#123` and `owner/repo@abc1234` across every such surface. `canon gate run` fails a push on a bare reference there, and a line whose bare form is the point carries `canon-allow-reference: <reason>` on itself or on the line above.
64
68
 
65
69
  A commit message takes the bare form even though it is also read through the log, where nothing links. The remote is what the form is chosen for, since a reader in the log loses only a link that plain text never carried.
66
70