@erclx/canon 4.37.1 → 4.39.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.37.1",
4
+ "version": "4.39.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -35,7 +35,7 @@ Read these on both paths, skipping any that do not exist:
35
35
  - `${CLAUDE_SKILL_DIR}/../../standards/markdown.md`: word, punctuation, and formatting constraints
36
36
  - The `write-human` skill: tone and sentence construction constraints
37
37
 
38
- On the source path, also read the UI surfaces matched in Step 1 plus any `docs/agents/output-shape.md` and `docs/index.md` the project itself carries, for output shape or framing rules already documented. Those two are the toolkit's own spelling, so a project keeping its framing rules elsewhere is read there instead.
38
+ On the source path, also read the UI surfaces matched in Step 1 plus `canon docs output-shape` and `canon docs index`, for output shape or framing rules already documented in the toolkit's own reference. Skip either that fails to resolve, since a project keeping its framing rules elsewhere is read there instead.
39
39
 
40
40
  On the greenfield path, also read `.claude/ARCHITECTURE.md` for platform, tech stack, and surface type. Do not scan `src/`, stylesheets, or UI modules. Step 1 already established they hold nothing.
41
41
 
@@ -105,7 +105,7 @@ Read each changed file in scope. Skip deleted files. Run reads in parallel.
105
105
 
106
106
  ## Step 3: review
107
107
 
108
- Review the diff and files for the same axes as `claude-review` (bugs, edge cases, error handling, logic flaws, security, rule violations), then add the three lenses a self-review structurally cannot apply:
108
+ Review the diff and files for the same axes as `claude-review` (bugs, edge cases, error handling, logic flaws, security, rule violations, checkout assumptions in a shipped file), then add the three lenses a self-review structurally cannot apply:
109
109
 
110
110
  - Integration: does this fit the board's order, the shared wiring seam, and any sibling PR in flight?
111
111
  - Contract: does a contract downstream features depend on land correctly, and should the plan itself be questioned?
@@ -66,6 +66,7 @@ Review the full diff and changed file contents for:
66
66
  3. Logic flaws that will cause problems when the code is extended
67
67
  4. Security issues relevant to the project context
68
68
  5. Violations of rules from `.claude/rules/` that apply to the changed files
69
+ 6. When the diff touches a file this project ships to a target holding none of its own history: a repository-relative path, a phase label naming a gitignored board, a same-repository pull request or commit reference, or a layout, stack, or config-path claim true of this checkout but stated as if it were general
69
70
 
70
71
  Use `CLAUDE.md`, `.claude/REQUIREMENTS.md`, `.claude/ARCHITECTURE.md`, and the auto-loaded `.claude/rules/` as project context to inform what is intentional vs problematic. Do not fix, rewrite, or suggest refactors outside the scope of a finding.
71
72
 
@@ -31,7 +31,7 @@ Accept work whose origin is the conversation itself only when the user says so e
31
31
 
32
32
  List the existing filenames in `.canon/tasks/` and read `index.md` for what each holds. Propose the next label from what is already on the board, and say which neighbors it sits between.
33
33
 
34
- Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the board is the only surface that knows what a label currently means. Pad the phase to two digits in the filename, since index entries sort by filename alone and a bare `v9.0` sorts after `v15.0`.
34
+ Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the board is the only surface that knows what a label currently means. Pad the phase to two digits in the filename, since index entries sort by filename alone and a bare `v9.0` sorts after `v15.0`. <!-- canon-allow-reference: illustrates the padding rule's own sort collision, not a citation of a real task -->
35
35
 
36
36
  ### Step 3: write the file
37
37
 
@@ -7,7 +7,7 @@ description: Bootstraps the toolkit's `index.md` system in a target project. Sca
7
7
 
8
8
  Adds the index.md system to a project that does not have it. Operates in the active Claude session: Claude reads files, drafts frontmatter, confirms with the user, and writes. The CLI handles validation and regeneration only.
9
9
 
10
- Read `.claude/context/indexes.md` from the toolkit if context on the system is needed before scanning.
10
+ Read `canon docs indexes` from the toolkit if context on the system is needed before scanning.
11
11
 
12
12
  ## Scope
13
13
 
@@ -147,7 +147,4 @@ Replace bracketed values with the values from this run. Drop the "Folders bootst
147
147
 
148
148
  ## Reference
149
149
 
150
- Both pages sit in the toolkit and install nowhere, so a target reads them there rather than in its own tree.
151
-
152
- - The toolkit's `.claude/context/indexes.md`: system rationale, frontmatter contract, when to adopt
153
- - The toolkit's `docs/agents/indexes.md`: `canon indexes regen` flags, exit codes, JSON shape
150
+ - Run `canon docs indexes` for `canon indexes regen` flags, exit codes, and JSON shape. It resolves from the toolkit rather than from the target's own tree.
@@ -12,7 +12,7 @@ description: Selecting a shipped task by stem or pull request, recording a numbe
12
12
  Name the task by its filename stem, or by the pull request it carries:
13
13
 
14
14
  ```bash
15
- canon tasks archive v28.1-trigger-escalation
15
+ canon tasks archive v28.1-trigger-escalation # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task
16
16
  canon tasks archive --pull-request 673 --json
17
17
  ```
18
18
 
@@ -66,7 +66,7 @@ Branch on `reason` rather than on the exit code, which is the rule the archive s
66
66
  A `live` location with an empty `citedBy` is the sweep to run. One whose `citedBy` names a sibling is a plan several tasks share, which the sweep leaves alone and the archive gate lets through.
67
67
 
68
68
  ```bash
69
- canon tasks plan-citations v28.1-trigger-escalation --json | jq -r '.location'
69
+ canon tasks plan-citations v28.1-trigger-escalation --json | jq -r '.location' # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task
70
70
  ```
71
71
 
72
72
  ## Plan answers
@@ -111,7 +111,7 @@ canon tasks plan-answers dispatch-answer-gate --json | jq -r '.launchable'
111
111
  Name the task by its filename stem, or by the plan its `Plan:` line points at:
112
112
 
113
113
  ```bash
114
- canon tasks pull-request 673 v28.1-trigger-escalation
114
+ canon tasks pull-request 673 v28.1-trigger-escalation # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task
115
115
  canon tasks pull-request 673 --plan worktree-scratch-routing --json
116
116
  ```
117
117
 
@@ -132,7 +132,7 @@ A malformed argument refuses as `bad-input` instead, which sits outside that set
132
132
  `canon tasks outcome` marks outcomes `[x]` on a task by their position in its outcome list, counting every checkbox in file order from 1.
133
133
 
134
134
  ```bash
135
- canon tasks outcome v28.1-trigger-escalation --close 1 --close 3
135
+ canon tasks outcome v28.1-trigger-escalation --close 1 --close 3 # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task
136
136
  canon tasks outcome --plan worktree-scratch-routing --close 2 --json
137
137
  ```
138
138
 
@@ -188,7 +188,7 @@ The comparative phrase is bounded the same way and for the same reason. A closed
188
188
 
189
189
  The collision check is the one a person cannot run by eye. Paths come from the backticked spans in the Touches column, a span naming no file is dropped, and a directory collides with any file beneath it. A `## Run now` row whose column parses to nothing is reported rather than skipped, since a row stating no file set makes a claim nothing can check.
190
190
 
191
- Where a directory holds the other row's file, the finding names the row that claimed it, reading `both touch src/tasks, which v2.0-second claims as a folder.` The shared strings alone leave an over-broad cell and a genuine overlap identical, which is how a correct report was once read as the verb comparing folders rather than files.
191
+ Where a directory holds the other row's file, the finding names the row that claimed it, reading `both touch src/tasks, which v2.0-second claims as a folder.` The shared strings alone leave an over-broad cell and a genuine overlap identical, which is how a correct report was once read as the verb comparing folders rather than files. <!-- canon-allow-reference: illustrates the finding's own sentence shape, not a citation of a real task -->
192
192
 
193
193
  The blocker check re-takes a measurement the board records once and never repeats. Two of the five blocker kinds put a fact on disk: a dependency is settled by the cited task being archived or by its work reaching the trunk, and a collision is settled by nothing under `## Run now` still holding the file the cell cites.
194
194
 
@@ -207,7 +207,7 @@ The other three kinds rest on a person's judgment, so a row neither half reached
207
207
  "untested": [
208
208
  {
209
209
  "group": "Needs a plan",
210
- "subject": "v50.6-a-standard-no-skill-reads",
210
+ "subject": "v50.6-a-standard-no-skill-reads", // canon-allow-reference: shows the subject field's real vXX.Y-slug shape, not a citation of a real task
211
211
  "message": "..."
212
212
  }
213
213
  ]
@@ -223,7 +223,7 @@ A `## Run now` row whose Touches column names a bare folder lands in a third arr
223
223
  "claims": [
224
224
  {
225
225
  "group": "Run now",
226
- "subject": "v1.0-first",
226
+ "subject": "v1.0-first", // canon-allow-reference: shows the subject field's real vXX.Y-slug shape, not a citation of a real task
227
227
  "message": "claims the whole src/tasks folder, so it collides with every row written under it."
228
228
  }
229
229
  ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.37.1",
4
+ "version": "4.39.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -88,7 +88,7 @@ collect_docs() {
88
88
  # It stops at the folder where listTopics goes on to name each sub-area file.
89
89
  # This is the downstream catalog and that one answers what a caller could have
90
90
  # typed, so a reachable name absent here is the same divergence the internal
91
- # topics already carry, and docs/agents/docs.md states it.
91
+ # topics already carry, and `canon docs docs` states it.
92
92
  collect_context() {
93
93
  local file name description
94
94
  {
@@ -263,6 +263,15 @@ function reportRecords(plan: RecordsPlan, records: number): void {
263
263
  if (plan.excluded.length > 0) {
264
264
  logInfo(`${plural(plan.excluded.length, 'file')} excluded from the sweep.`)
265
265
  }
266
+
267
+ // A list rather than a count. A coupled file is the handful an operator has
268
+ // to open by hand, and a bare count gives them nothing to act on.
269
+ if (plan.coupled.length > 0) {
270
+ logInfo(
271
+ `${plural(plan.coupled.length, 'file')} couple to an excluded path:`,
272
+ )
273
+ for (const path of plan.coupled) logInfo(` ${path}`)
274
+ }
266
275
  }
267
276
 
268
277
  function toRecordsRecord(
@@ -279,6 +288,7 @@ function toRecordsRecord(
279
288
  rewritten: plan.rewritten,
280
289
  kept: plan.kept,
281
290
  excluded: plan.excluded.length,
291
+ coupled: plan.coupled,
282
292
  records,
283
293
  paths: plan.entries.map((entry) => ({
284
294
  path: entry.path,
@@ -13,6 +13,7 @@ import {
13
13
  REFERENCE_MARKER,
14
14
  referencesIn,
15
15
  SHIPPED_CORPORA,
16
+ type ShippedReference,
16
17
  } from '@/shipped/references'
17
18
 
18
19
  export interface CommandResult {
@@ -482,9 +483,28 @@ function shippedCorpusFiles(root: string): string[] {
482
483
  return files.sort()
483
484
  }
484
485
 
486
+ /** The clause `shippedReferences` appends after naming a reference's text. */
487
+ function describeShippedReference(reference: ShippedReference): string {
488
+ if (reference.selfCitation) {
489
+ return "a citation of this repository's own history that resolves for no reader outside it, qualified or not"
490
+ }
491
+
492
+ switch (reference.kind) {
493
+ case 'commit':
494
+ return 'a commit sha that resolves nowhere for a reader in a target'
495
+ case 'pull-request':
496
+ return 'a pull request number that resolves elsewhere for a reader in a target'
497
+ case 'docs-path':
498
+ return 'a path into this repository that a registry install never carries'
499
+ case 'phase-label':
500
+ return 'a phase label that names a board no target holds'
501
+ }
502
+ }
503
+
485
504
  /**
486
- * A pull request number or a commit sha that a reader in a target cannot
487
- * resolve, over the seven corpora that reach one.
505
+ * A pull request number, a commit sha, a docs-path citation, or a phase
506
+ * label that a reader in a target cannot resolve, over the seven corpora
507
+ * that reach one.
488
508
  *
489
509
  * Every instance on the trunk was written by a branch that passed review, this
490
510
  * row's own planning session included, so the only instrument before this stage
@@ -513,7 +533,7 @@ export const shippedReferences: Measure = async (ctx) => {
513
533
  }
514
534
 
515
535
  const found = files.flatMap((file) =>
516
- referencesIn(file, readFileSync(join(ctx.root, file), 'utf8')),
536
+ referencesIn(file, readFileSync(join(ctx.root, file), 'utf8'), ctx.root),
517
537
  )
518
538
 
519
539
  if (found.length === 0) {
@@ -529,15 +549,13 @@ export const shippedReferences: Measure = async (ctx) => {
529
549
  return {
530
550
  emissions: found.map((reference) =>
531
551
  warn(
532
- reference.selfCitation
533
- ? `${reference.file}:${reference.line} carries ${reference.text}, a citation of this repository's own history that resolves for no reader outside it, qualified or not`
534
- : `${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`,
552
+ `${reference.file}:${reference.line} carries ${reference.text}, ${describeShippedReference(reference)}`,
535
553
  ),
536
554
  ),
537
555
  failure:
538
556
  found.length === 1
539
- ? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, state a same-repository citation as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
540
- : `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, state a same-repository citation as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
557
+ ? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
558
+ : `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
541
559
  }
542
560
  }
543
561
 
@@ -91,6 +91,13 @@ function escape(value: string): string {
91
91
  * pair into two copies of the new one and shellcheck reports a pattern that can
92
92
  * never match. The guard then stops firing in a project the move has not
93
93
  * reached, which is silent: the index goes stale while every save succeeds.
94
+ *
95
+ * A file outside this list can still couple to one inside it, naming an
96
+ * excluded path as plain text while it also carries a live citation of its
97
+ * own, which `referencesExcluded` reports separately below. Widening this list
98
+ * or `EXCLUDED_SUFFIXES` to catch that case would mean guessing at a naming
99
+ * convention no project here declares, where the coupling check instead reads
100
+ * what the file's own text already says.
94
101
  */
95
102
  const EXCLUDED_PREFIXES: readonly string[] = [
96
103
  'src/migrate/',
@@ -109,6 +116,25 @@ export function isExcludedPath(path: string): boolean {
109
116
  return EXCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix))
110
117
  }
111
118
 
119
+ /**
120
+ * Whether `text` names an `EXCLUDED_PREFIXES` or `EXCLUDED_PATHS` entry as
121
+ * literal substring text, which is how a rewritten file can couple to one this
122
+ * module leaves alone: a citation gets rewritten clean while a line a few away
123
+ * still spells the excluded path it was testing against.
124
+ *
125
+ * `EXCLUDED_SUFFIXES` plays no part here, since a suffix names a file's own
126
+ * shape rather than text its content could quote. The one gap this cannot
127
+ * close is a file that names the excluded surface by description rather than
128
+ * by path, such as "the exemption hook" rather than `.claude/hooks/`, which is
129
+ * the same limit `isExcludedPath` already carries for content it cannot parse.
130
+ */
131
+ export function referencesExcluded(text: string): boolean {
132
+ return (
133
+ EXCLUDED_PATHS.some((path) => text.includes(path)) ||
134
+ EXCLUDED_PREFIXES.some((prefix) => text.includes(prefix))
135
+ )
136
+ }
137
+
112
138
  /**
113
139
  * The roots holding nothing but records, so a path under one is a record
114
140
  * whatever it is named.
@@ -299,6 +325,7 @@ export interface RecordsPlan {
299
325
  readonly collisions: readonly string[]
300
326
  readonly entries: readonly CitationEntry[]
301
327
  readonly excluded: readonly string[]
328
+ readonly coupled: readonly string[]
302
329
  readonly rewritten: number
303
330
  readonly kept: number
304
331
  }
@@ -318,6 +345,7 @@ export function planRecordsMove(
318
345
  const moves = planFolderMoves(root)
319
346
  const entries: CitationEntry[] = []
320
347
  const excluded: string[] = []
348
+ const coupled: string[] = []
321
349
  let kept = 0
322
350
 
323
351
  for (const source of sources) {
@@ -339,6 +367,8 @@ export function planRecordsMove(
339
367
  kept += counts.kept
340
368
  if (counts.rewritten === 0) continue
341
369
 
370
+ if (referencesExcluded(source.text)) coupled.push(source.path)
371
+
342
372
  entries.push({
343
373
  path: source.path,
344
374
  text: rewriteText(source.text),
@@ -352,6 +382,7 @@ export function planRecordsMove(
352
382
  collisions: collisions(root, moves),
353
383
  entries,
354
384
  excluded,
385
+ coupled,
355
386
  rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
356
387
  kept,
357
388
  }
@@ -1,3 +1,5 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
1
3
  import { isMarked } from '@/exempt-marker'
2
4
 
3
5
  /**
@@ -125,11 +127,53 @@ const COMMIT_SHA = /(?<![0-9A-Za-z_@/#])([0-9a-f]{7,40})(?![0-9A-Za-z_])/g
125
127
  const SAME_REPOSITORY =
126
128
  /(?<![0-9A-Za-z_/])erclx\/canon(#[0-9]+|@[0-9a-f]{7,40})(?![0-9A-Za-z_])/g
127
129
 
130
+ /**
131
+ * A repository-relative path under `docs/`, reported only where `isResolvable`
132
+ * confirms it resolves against this checkout.
133
+ *
134
+ * Shape alone cannot separate a citation of this repository's own reference
135
+ * corpus from an illustration naming a target's own tree, since both are
136
+ * `docs/...md` tokens: `docs/agents/tasks.md` and `docs/retry.md` read
137
+ * identically to a pattern with no filesystem behind it. Of 124 such tokens
138
+ * across the shipped corpora, four distinct paths resolved here and the rest
139
+ * named a target's own tree, measured on 2026-09-03. Resolving against `root`
140
+ * is what turns that 124-hit pattern into the four-hit gate.
141
+ *
142
+ * `.claude/context/` carries no equivalent pattern. The same resolution test
143
+ * cannot separate a path every scaffolded project holds, such as
144
+ * `.claude/context/index.md`, from this repository's own domain entry, such
145
+ * as `.claude/context/indexes.md`, since both resolve here and only the
146
+ * second is a defect. That is a semantic read no pattern makes, so it stays
147
+ * with the rule and the review checklist rather than joining this reader.
148
+ */
149
+ const DOCS_PATH = /(?<![\w./-])docs\/[^\s`)\]]*\.md\b/g
150
+
151
+ /**
152
+ * A phase-label-shaped token: exactly two numeric groups, with a negative
153
+ * lookahead rejecting a third.
154
+ *
155
+ * This does not reuse `VERSION_TOKEN` from `src/labels/phase.ts`, which
156
+ * admits one or two decimal groups and therefore matches a three-group
157
+ * semver tag as readily as a two-group phase label. That is the right shape
158
+ * there, since `scanPhaseLabels` sorts the two namespaces apart by asking
159
+ * whether the pull request carrying the token is release-please's own, a
160
+ * signal this reader has no equivalent of: a shipped-corpus file carries no
161
+ * pull request to test. Shape is the only discriminator available here, and
162
+ * every phase label this board issues carries exactly two groups while every
163
+ * semver tag carries three, so narrowing to two removes the semver class by
164
+ * construction rather than by a marker standing in for the missing signal.
165
+ *
166
+ * A target holds no board to resolve a label against, so a bare instance
167
+ * here is unresolvable the same way a same-repository citation is, and is
168
+ * muted the same way when it names the label format rather than a real row.
169
+ */
170
+ const PHASE_LABEL = /\bv\d+\.\d+(?!\.\d)\b/g
171
+
128
172
  export interface ShippedReference {
129
173
  readonly file: string
130
174
  /** One-based, matching the `file:line` form a reader clicks. */
131
175
  readonly line: number
132
- readonly kind: 'pull-request' | 'commit'
176
+ readonly kind: 'pull-request' | 'commit' | 'docs-path' | 'phase-label'
133
177
  /** The reference as written, so a report names the token to qualify. */
134
178
  readonly text: string
135
179
  /**
@@ -140,13 +184,36 @@ export interface ShippedReference {
140
184
  readonly selfCitation?: true
141
185
  }
142
186
 
187
+ /**
188
+ * Whether a `DOCS_PATH` match names a real file in this checkout.
189
+ *
190
+ * `file` gates the `docs/` corpus out by the caller's own location rather
191
+ * than by a pattern exemption: a citation from inside `docs/` is read
192
+ * together with the rest of that corpus through the same `canon docs`
193
+ * resolution, which is a weaker claim than one from a skill body with no
194
+ * `docs/` sibling at all.
195
+ */
196
+ function isDocsPathResolvable(
197
+ file: string,
198
+ path: string,
199
+ root: string,
200
+ ): boolean {
201
+ if (file.startsWith('docs/')) return false
202
+ return existsSync(join(root, path))
203
+ }
204
+
143
205
  /**
144
206
  * Every reference in one shipped file that no marker mutes.
145
207
  *
146
- * The corpus walk is deliberately absent, which lets the shape be tested
147
- * against a string rather than against a fixture. That is the seam
208
+ * The corpus walk is deliberately absent, which lets most of the shape be
209
+ * tested against a string rather than against a fixture. That is the seam
148
210
  * `headingCitationsIn` draws in `src/claude/skills-headings.ts` and `citationsIn`
149
- * draws in `skills-reach.ts`.
211
+ * draws in `skills-reach.ts`. `DOCS_PATH` is the one pattern that still needs
212
+ * a filesystem, since resolving against this checkout is the only thing that
213
+ * separates its two readings, so it takes `root` as the one caller-supplied
214
+ * exception to that rule. `root` is required rather than defaulted, since a
215
+ * caller that dropped it silently would report zero docs-path findings
216
+ * rather than raising, which is the wrong failure direction for a gate.
150
217
  *
151
218
  * The unit is the match rather than the line, unlike those two, because one
152
219
  * line here can carry three separate tokens each needing its own repair and a
@@ -156,7 +223,11 @@ export interface ShippedReference {
156
223
  * marker mutes a line and nothing narrower. A real citation later added beside
157
224
  * a marked illustration ships unreported.
158
225
  */
159
- export function referencesIn(file: string, text: string): ShippedReference[] {
226
+ export function referencesIn(
227
+ file: string,
228
+ text: string,
229
+ root: string,
230
+ ): ShippedReference[] {
160
231
  const lines = text.split('\n')
161
232
  const references: ShippedReference[] = []
162
233
 
@@ -190,6 +261,25 @@ export function referencesIn(file: string, text: string): ShippedReference[] {
190
261
  selfCitation: true,
191
262
  })
192
263
  }
264
+
265
+ for (const match of line.matchAll(DOCS_PATH)) {
266
+ if (!isDocsPathResolvable(file, match[0], root)) continue
267
+ references.push({
268
+ file,
269
+ line: index + 1,
270
+ kind: 'docs-path',
271
+ text: match[0],
272
+ })
273
+ }
274
+
275
+ for (const match of line.matchAll(PHASE_LABEL)) {
276
+ references.push({
277
+ file,
278
+ line: index + 1,
279
+ kind: 'phase-label',
280
+ text: match[0],
281
+ })
282
+ }
193
283
  }
194
284
 
195
285
  return references
@@ -31,8 +31,8 @@ Does not govern:
31
31
  ├── priority.md ← hand-maintained execution order
32
32
  ├── backlog.md ← unordered, what is not being scheduled
33
33
  ├── session-<slug>.md ← optional, what a compaction is about to destroy
34
- ├── v09.0-sync-paths.md
35
- └── v13.0-toolkit-drift.md
34
+ ├── v09.0-sync-paths.md # canon-allow-reference: illustrates the vXX.Y-slug filename this section defines
35
+ └── v13.0-toolkit-drift.md # canon-allow-reference: illustrates the vXX.Y-slug filename this section defines
36
36
  ```
37
37
 
38
38
  One file per task is what keeps the board safe under parallel sessions. Two sessions working different tasks never write the same file, which matters because a gitignored board has no history to recover a clobbered write from.
@@ -137,13 +137,13 @@ Add no fourth readiness group in place of this file. The three group names are t
137
137
 
138
138
  ## Validation
139
139
 
140
- `canon tasks validate` reads the board against the tree, and what it checks, what it refuses on, and what it reports are at `docs/agents/tasks.md`. Run it when the readiness claim is made rather than on a schedule, since the board is gitignored per-machine scratch and no shared moment exists to hang it on.
140
+ `canon tasks validate` reads the board against the tree, and what it checks, what it refuses on, and what it reports are at `canon docs tasks`. Run it when the readiness claim is made rather than on a schedule, since the board is gitignored per-machine scratch and no shared moment exists to hang it on.
141
141
 
142
142
  ## Filenames
143
143
 
144
144
  `vXX.Y-<slug>.md`, where the version is the phase label zero-padded to two digits and the slug is kebab-case.
145
145
 
146
- Padding is load-bearing. Index entries sort by filename and nothing else, so a bare `v9.0` sorts after `v15.0` and the catalog reads out of board order. `standards/versioning.md` governs the label itself and permits free renumbering, so expect the occasional rename. Nothing points at a task filename, since a `Plan:` line runs from task to plan rather than the reverse.
146
+ Padding is load-bearing. Index entries sort by filename and nothing else, so a bare `v9.0` sorts after `v15.0` and the catalog reads out of board order. `standards/versioning.md` governs the label itself and permits free renumbering, so expect the occasional rename. Nothing points at a task filename, since a `Plan:` line runs from task to plan rather than the reverse. <!-- canon-allow-reference: illustrates the padding rule's own sort collision, not a citation of a real task -->
147
147
 
148
148
  ## Frontmatter
149
149
 
@@ -151,7 +151,7 @@ Every task file carries both fields. The index walker fails the whole folder whe
151
151
 
152
152
  ```yaml
153
153
  ---
154
- title: 'v13.0: Detect and close toolkit drift in target projects'
154
+ title: 'v13.0: Detect and close toolkit drift in target projects' # canon-allow-reference: illustrates the quoted title shape, not a citation of a real task
155
155
  description: Record what a target installed and report the delta against the toolkit
156
156
  ---
157
157
  ```
@@ -245,7 +245,7 @@ The line is what lets a merge close its own task. Every merge on `main` is a squ
245
245
 
246
246
  ## Archiving
247
247
 
248
- Never delete a task file. A shipped task moves to `.canon/tasks/archive/` under its own name, and the live index regenerates without it. `canon tasks archive` owns that move, and what it does and what it refuses on are at `docs/agents/tasks.md`.
248
+ Never delete a task file. A shipped task moves to `.canon/tasks/archive/` under its own name, and the live index regenerates without it. `canon tasks archive` owns that move, and what it does and what it refuses on are at `canon docs tasks`.
249
249
 
250
250
  The archive nests inside `.canon/tasks/` rather than sitting beside it as a flat `.claude/task-archive/`. Nesting is what lets a reader tell the two shapes apart on sight: the flat sibling is what a binary predating this convention still writes, so meeting one names an older checkout rather than a second archive to reconcile against this one.
251
251
 
@@ -25,7 +25,7 @@ Internal coordination vocabulary used in the task board and chat.
25
25
 
26
26
  - Format is project-specific.
27
27
  - Used to order work and disambiguate streams during planning.
28
- - Re-numbers freely as scope shifts. Inserting a half-step between two existing labels (a `v1.5` between `v1` and `v2`) is fine.
28
+ - Re-numbers freely as scope shifts. Inserting a half-step between two existing labels (a `v1.5` between `v1` and `v2`) is fine. <!-- canon-allow-reference: illustrates the renumbering rule's own format, not a citation of a real row -->
29
29
  - Does not have to map to any external release.
30
30
 
31
31
  ## Semver tags
@@ -4,7 +4,7 @@
4
4
 
5
5
  The base layer covers every project the toolkit scaffolds, whatever language sits on top. It ships formatting, spelling, shell linting, conventional commits, git hooks, CI, and one maintenance script. Every other stack extends it, so a decision made here is one every stack inherits.
6
6
 
7
- A repository with several language roots layers this stack once at the root and skips it per subtree, since git honors only one `core.hooksPath` and a re-dropped husky silently breaks. `docs/target-projects.md` carries the recipe.
7
+ A repository with several language roots layers this stack once at the root and skips it per subtree, since git honors only one `core.hooksPath` and a re-dropped husky silently breaks. `canon docs target-projects` carries the recipe.
8
8
 
9
9
  ## What ships as golden configs
10
10
 
@@ -97,4 +97,4 @@ Sticky negative knowledge. Do not relearn.
97
97
 
98
98
  `bun run check` repairs a checkout rather than gating one. It runs `format` first to auto-fix drifted code, then asserts only what the formatters could not fix. The pull request workflow calls `check:format`, `check:spell`, and `check:shell` directly, and those three are the gate.
99
99
 
100
- `scripts/verify.sh`'s `Markdown bans` stage is the exception: it gates rather than repairs. It runs `canon markdown audit` over every tracked markdown file except `CHANGELOG.md`, since a generated changelog carries commit subjects nobody wrote against the ban set, and exits `2` on a finding or `3` on a shipped-empty ban set, failing `bun run check` and the `pre-push` hook that calls it. A refusal to measure exits `1` and is logged as a skip rather than a failure, as does a corpus with no file left to check once `CHANGELOG.md` is set aside. The stage needs `canon` on PATH. `docs/target-projects.md` names the install command. Without it, the stage is skipped and logged, and coverage falls back to the three checks above.
100
+ `scripts/verify.sh`'s `Markdown bans` stage is the exception: it gates rather than repairs. It runs `canon markdown audit` over every tracked markdown file except `CHANGELOG.md`, since a generated changelog carries commit subjects nobody wrote against the ban set, and exits `2` on a finding or `3` on a shipped-empty ban set, failing `bun run check` and the `pre-push` hook that calls it. A refusal to measure exits `1` and is logged as a skip rather than a failure, as does a corpus with no file left to check once `CHANGELOG.md` is set aside. The stage needs `canon` on PATH. `canon docs target-projects` names the install command. Without it, the stage is skipped and logged, and coverage falls back to the three checks above.