@erclx/canon 4.62.0 → 4.63.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.62.0",
4
+ "version": "4.63.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: canon-cli
3
- description: Why the overwrite contract is stated before a sync runs, and why the skill reads rather than executes
3
+ description: Why a session is pointed at the verb catalog, the reference docs, and the overwrite contract from one place, and why the skill reads rather than writes
4
4
  ---
5
5
 
6
6
  # Canon cli requirement
@@ -17,17 +17,20 @@ Being reachable is a separate problem from being right. This is a pure reference
17
17
 
18
18
  The two bodies now carrying an inline pointer are `claude-seed-sync` and `canon-operator`, each at the point it runs or prints an overwriting command.
19
19
 
20
+ A third gap sits beside the first two, aimed at a different question. `canon --help` lists every top-level verb and `canon docs` emits the toolkit's own reference corpus, but no reference skill pointed a session at either. The one skill that does, `canon-operator`, is user-invoked only and reaches them as a side effect of its own orientation step. A session guessing at a verb's name, or restating what a doc already answers, is the same missing-fact failure the overwrite gap names.
21
+
20
22
  ## Must
21
23
 
22
24
  - State per surface and per command whether an existing file is overwritten, merged, written once, or left alone
23
25
  - Warn about a destructive run before it happens, naming the surface that will be lost
24
26
  - Name the section-preserving path for a standard or seed the project has customized
25
27
  - Defer to the toolkit's own context docs when they and this summary disagree, since the summary is a copy and they are the source
28
+ - Point to `canon docs agents` for the verb catalog and to `canon docs` for the reference corpus, rather than restating either
26
29
 
27
30
  ## Must not
28
31
 
29
- - Run any `canon` command. The skill is read before acting.
30
- - Restate the full semantics. It is a target-session summary and the detail lives with its owner.
32
+ - Run, or send the session to run, a `canon` command that writes or installs. The skill is read before acting on a sync.
33
+ - Restate the verb catalog or a reference doc's content. Point to `canon docs agents` or `canon docs <topic>` instead. The overwrite table is this skill's one authored exception, and it too is a target-session summary, with the detail living with its owner.
31
34
  - Take a mention in a sibling's requirement file as an inbound route. The three bodies named in the gap above carry the pointer, and adding a fourth means editing that body rather than its requirement.
32
35
 
33
36
  ## Guards
@@ -39,3 +42,4 @@ The two bodies now carrying an inline pointer are `claude-seed-sync` and `canon-
39
42
  - Executing the sync, which the user runs or `canon-operator` routes
40
43
  - Reconciling a customized seed section by section: `claude-seed-sync`
41
44
  - Deciding which stack, rule, or standard a project should install, which the setup skills resolve from live catalogs
45
+ - Diagnosing what a project is behind on, or executing the fix: `canon-operator`
@@ -1,11 +1,17 @@
1
1
  ---
2
2
  name: canon-cli
3
- description: Reference for what canon sync and install commands overwrite, merge, or leave untouched in a target project. Use before running `canon tooling`, `canon standards`, `canon claude sync`, or `canon init`, or when asked "will this overwrite my changes". Do NOT use to run the commands, only to know their effect.
3
+ description: Reference for what a canon verb does before you run it. Run `canon docs agents` for the full command catalog, `canon docs` for any other reference doc, and read this skill's own table for what a sync or install command overwrites, merges, or leaves untouched in a target project. Use when asked "which canon command do I run", "is there a doc for X", "will this overwrite my changes", or before running `canon tooling`, `canon standards`, `canon claude sync`, or `canon init`. Do NOT use to execute a sync, an install, or a docs lookup, only to know which command answers the question.
4
4
  ---
5
5
 
6
6
  # Toolkit CLI contract
7
7
 
8
- What each `canon` sync or install command does to existing files in a target project. Consult before running one, then warn the user about anything destructive. This skill is reference only. It does not run commands.
8
+ Consult before running an unfamiliar `canon` verb, before a sync or install, or when asked "will this overwrite my changes". This skill is reference only. It never runs a sync or an install.
9
+
10
+ ## Verb catalog and reference docs
11
+
12
+ - Run `canon docs agents` for the full command catalog and invocation contract, rather than guessing at a verb from its name.
13
+ - Run `canon docs list` for every other reference doc, then `canon docs <topic>` by name.
14
+ - State neither list here. Both read live off the CLI, and a copy in this body ships on a different cadence than the commands it names.
9
15
 
10
16
  ## Overwrite contract
11
17
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: context-draft
2
+ name: draft-context
3
3
  description: Why a brand-new .claude/context/<domain>.md entry needs a catalog collision check and a confirm step, not the refresh path claude-docs already owns
4
4
  ---
5
5
 
@@ -32,6 +32,6 @@ Without this skill, a session documenting a domain that has no context entry yet
32
32
  ## Out of scope
33
33
 
34
34
  - Refreshing an existing `.claude/context/<domain>.md` entry against a diff: `claude-docs`
35
- - Drafting a `.claude/wireframes/<surface>.md` file: `wireframe-draft`
36
- - Drafting a `docs/*.md` page: `docs-draft`
35
+ - Drafting a `.claude/wireframes/<surface>.md` file: `draft-wireframes`
36
+ - Drafting a `docs/*.md` page: `draft-docs`
37
37
  - Drafting a standard, a snippet, or a governance rule: `create-standard`, `create-snippet`, `create-rule`
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: context-draft
2
+ name: draft-context
3
3
  description: Drafts a brand-new `.claude/context/<domain>.md` entry against the context standard, checks the catalog for a name-or-topic collision, decides flat-file placement, confirms with the user, then writes. Use when asked to "write a context entry for X", "document the X domain", "add a context entry for X", or "create a .claude/context page for X" where no existing entry covers the domain. Do NOT use to refresh an existing entry against a diff, which is `claude-docs`.
4
4
  ---
5
5
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: docs-draft
2
+ name: draft-docs
3
3
  description: Why a brand-new docs/*.md page needs a placement decision and a confirm step, not the rewrite path docs-sync already owns
4
4
  ---
5
5
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: docs-draft
2
+ name: draft-docs
3
3
  description: Drafts a brand-new `docs/*.md` page against the docs standard, decides its placement in the existing catalog, confirms with the user, then writes. Use when asked to "add a docs page for X", "write a new doc for X", "document X under docs/", or "create a docs page for X" where no existing page covers the topic. Do NOT use to rewrite or sync an existing `docs/*.md` section against a diff, which is `docs-sync`.
4
4
  ---
5
5
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: wireframe-draft
2
+ name: draft-wireframes
3
3
  description: Why a brand-new .claude/wireframes/<surface>.md file needs a real draft and a tier-detect step, not the stub the wireframe-sweep already writes
4
4
  ---
5
5
 
@@ -32,7 +32,7 @@ Without this skill, a session drafting a wireframe for a surface with no file ye
32
32
  ## Out of scope
33
33
 
34
34
  - Stubbing a surface a diff touched, or reporting drift in an existing wireframe against a diff: `claude/skills/claude-docs/references/wireframe-sweep.md`
35
- - Drafting a `.claude/context/<domain>.md` entry: `context-draft`
36
- - Drafting a `docs/*.md` page: `docs-draft`
35
+ - Drafting a `.claude/context/<domain>.md` entry: `draft-context`
36
+ - Drafting a `docs/*.md` page: `draft-docs`
37
37
  - Drafting a standard, a snippet, or a governance rule: `create-standard`, `create-snippet`, `create-rule`
38
38
  - Building or wiring an actual tier-1 or tier-2 rendering pipeline: the visual design workflow's own tier guidance, out of reach of a hand-drafting skill
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: wireframe-draft
2
+ name: draft-wireframes
3
3
  description: Drafts a brand-new `.claude/wireframes/<surface>.md` file against the wireframes standard, walks the tree for a name collision, detects an existing higher visual-design tier without building one, confirms with the user, then writes. Use when asked to "draft a wireframe for X", "write the wireframe for this surface", "add a .claude/wireframes entry for X", or "wireframe this screen" where no surface file covers it yet. Do NOT use to fix a stale TODO stub or report wireframe drift against a diff, which is `claude-docs`'s wireframe-sweep step.
4
4
  ---
5
5
 
@@ -19,13 +19,14 @@ Reads what `canon repo metadata propose` computes locally and reports it against
19
19
 
20
20
  1. Run `canon repo metadata propose --json`.
21
21
  2. Read `diff` and `repo` from the record. `repo` is the `--repo` value the later apply step must carry. An empty `diff` means the remote already matches what this run computed: report that and stop, since there is nothing to answer.
22
- 3. For each field `diff` carries, put the change to the operator through the structured question surface: which of the differing fields to write. Rank accepting the proposed value first for a field whose current value is stale or wrong, and give the reject option the cost of leaving the remote as it stands. Never pre-select an answer for the operator.
23
- 4. Report the fields the proposal left absent as unchanged, naming that neither the README nor `package.json` carried a source for them.
24
- 5. Stop. Do not run apply here even when the operator answers immediately, since answering is not yet an apply invocation.
22
+ 3. When `diff` carries a `description` field, compress its proposed value, the raw opening line `canon repo metadata propose` computed, into a single short phrase. Leave the shape and length of the phrase to judgment on each run. No numeric target applies. Keep the raw proposed value beside the compressed phrase in what gets reported, so the operator can check the phrase against the README line it came from.
23
+ 4. For each field `diff` carries, put the change to the operator through the structured question surface: which of the differing fields to write. For `description`, the value offered to accept is the compressed phrase from step 3, not the raw line beside it. Rank accepting the proposed value first for a field whose current value is stale or wrong, and give the reject option the cost of leaving the remote as it stands. Never pre-select an answer for the operator.
24
+ 5. Report the fields the proposal left absent as unchanged, naming that neither the README nor `package.json` carried a source for them.
25
+ 6. Stop. Do not run apply here even when the operator answers immediately, since answering is not yet an apply invocation.
25
26
 
26
27
  ## Apply
27
28
 
28
- 1. Confirm every field about to be written was answered by the operator in this conversation. Carry no field forward unanswered.
29
+ 1. Confirm every field about to be written was answered by the operator in this conversation. Carry no field forward unanswered. For `description`, the answered value is the compressed phrase Propose offered, never the raw README line reported beside it.
29
30
  2. Run `canon repo metadata apply`, always passing `--repo <owner/name>` from the propose record's `repo` field, plus the flags for the answered fields: `--description <text>`, `--homepage <url>`, `--topics <comma-separated list>`. `--topics` is the full desired set, and the command reads the current set itself to compute what to add and remove. The command refuses rather than writing when `--repo` does not match what `--root` resolves to, so never omit it and never guess it from anything but the propose record's `repo` field.
30
31
  3. Report the written state from the JSON record.
31
32
 
@@ -35,7 +36,7 @@ Propose:
35
36
 
36
37
  ```plaintext
37
38
  📋 Repo metadata proposal
38
- description: "<current>" → "<proposed>"
39
+ description: "<current>" → "<compressed>" (derived from the README's opening line: "<raw>")
39
40
  homepage: unchanged, no local source
40
41
  topics: +<added>, -<removed>
41
42
 
@@ -291,9 +291,9 @@ This section is the corpus the coverage claim is measured against: every name `c
291
291
  | `canon:create-skill` | For a new `SKILL.md` |
292
292
  | `canon:create-snippet` | For a reusable prompt |
293
293
  | `canon:create-standard` | For a new authoring convention |
294
- | `canon:docs-draft` | For a brand-new `docs/*.md` page, drafted against `standards/docs.md` |
295
- | `canon:context-draft` | For a brand-new `.claude/context/<domain>.md` entry, drafted against `standards/context.md` |
296
- | `canon:wireframe-draft` | For a brand-new `.claude/wireframes/<surface>.md` file, drafted against `standards/wireframes.md` |
294
+ | `canon:draft-docs` | For a brand-new `docs/*.md` page, drafted against `standards/docs.md` |
295
+ | `canon:draft-context` | For a brand-new `.claude/context/<domain>.md` entry, drafted against `standards/context.md` |
296
+ | `canon:draft-wireframes` | For a brand-new `.claude/wireframes/<surface>.md` file, drafted against `standards/wireframes.md` |
297
297
  | `canon:bash-script` | For an interactive, human-facing shell tool |
298
298
  | `canon:bash-cli-script` | For a non-interactive automation, CI, or pipeline script |
299
299
  | `canon:ci-workflow` | For a GitHub Actions workflow file |
@@ -302,14 +302,14 @@ This section is the corpus the coverage claim is measured against: every name `c
302
302
 
303
303
  ### Answer a question at any point
304
304
 
305
- | Skill | When to use |
306
- | --------------------------- | ------------------------------------------------------------------------------ |
307
- | `canon:canon-cli` | Before a sync or install, to learn what it overwrites, merges, or leaves alone |
308
- | `canon:index-lookup` | To find where a topic is documented across the tracked `index.md` catalogs |
309
- | `canon:youtube-transcripts` | When a video transcript is wanted in the repo as context |
310
- | `canon:claude-teach` | To learn a subject across sessions, in a workspace that holds the progress |
311
- | `canon:write-human` | Before drafting or revising prose, for voice, rhythm, and density |
312
- | `canon:restate-plainly` | When an answer or a document has to be read again in plain words |
305
+ | Skill | When to use |
306
+ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
307
+ | `canon:canon-cli` | Before running an unfamiliar verb, a sync, or an install, to learn which command to run, which reference doc covers it, or what it overwrites, merges, or leaves alone |
308
+ | `canon:index-lookup` | To find where a topic is documented across the tracked `index.md` catalogs |
309
+ | `canon:youtube-transcripts` | When a video transcript is wanted in the repo as context |
310
+ | `canon:claude-teach` | To learn a subject across sessions, in a workspace that holds the progress |
311
+ | `canon:write-human` | Before drafting or revising prose, for voice, rhythm, and density |
312
+ | `canon:restate-plainly` | When an answer or a document has to be read again in plain words |
313
313
 
314
314
  Every row answers a question rather than marking a point in a project's life, so a phase above would send a reader to the wrong group.
315
315
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.62.0",
4
+ "version": "4.63.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -40,17 +40,17 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
40
40
  {
41
41
  prompt:
42
42
  'Write a brand-new docs page for the capture command, nothing under docs/ covers it yet.',
43
- expect: 'docs-draft',
43
+ expect: 'draft-docs',
44
44
  },
45
45
  {
46
46
  prompt:
47
47
  'Write a context entry for the payments domain, there is no .claude/context page for it yet.',
48
- expect: 'context-draft',
48
+ expect: 'draft-context',
49
49
  },
50
50
  {
51
51
  prompt:
52
52
  'Draft a wireframe for the settings panel, nothing under .claude/wireframes covers that surface yet.',
53
- expect: 'wireframe-draft',
53
+ expect: 'draft-wireframes',
54
54
  },
55
55
  {
56
56
  prompt: 'Say what that dense answer actually means in plain terms.',
@@ -1,7 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { execScript } from '@/exec'
4
- import { PROJECT_ROOT } from '@/project-root'
4
+ import { findCheckoutMismatch, PROJECT_ROOT } from '@/project-root'
5
5
  import {
6
6
  injectConfigs,
7
7
  injectGitignore,
@@ -268,6 +268,13 @@ async function runSync(
268
268
  ): Promise<number> {
269
269
  intro('canon tooling sync')
270
270
 
271
+ const mismatch = findCheckoutMismatch(process.cwd())
272
+ if (mismatch !== undefined) {
273
+ logWarn(
274
+ `Resolved via ${PROJECT_ROOT}, not the checkout at ${mismatch}. Run \`bun ${mismatch}/src/cli.ts tooling sync ...\` to sync against that checkout instead.`,
275
+ )
276
+ }
277
+
271
278
  if (opts.check === true && opts.write === true) {
272
279
  logWarn('Pass --check or --write, not both.')
273
280
  outro()
@@ -98,14 +98,18 @@ function citationPattern(folder: string): RegExp {
98
98
  )
99
99
  }
100
100
 
101
- /** One promoted folder's citation pattern, paired with its replacement. */
102
- const REWRITES: readonly {
101
+ interface Rewrite {
103
102
  readonly pattern: RegExp
104
103
  readonly folder: string
105
- }[] = PROMOTED_FOLDERS.map((folder) => ({
106
- pattern: citationPattern(folder),
107
- folder,
108
- }))
104
+ }
105
+
106
+ /** One rewrite per folder, paired with its citation pattern. */
107
+ function buildRewrites(folders: readonly string[]): readonly Rewrite[] {
108
+ return folders.map((folder) => ({
109
+ pattern: citationPattern(folder),
110
+ folder,
111
+ }))
112
+ }
109
113
 
110
114
  /**
111
115
  * Marks a line naming a promoted folder's old path on purpose, the same
@@ -125,40 +129,43 @@ function isKept(lines: readonly string[], index: number): boolean {
125
129
  return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
126
130
  }
127
131
 
128
- function rewriteLine(line: string): string {
129
- return REWRITES.reduce(
132
+ function rewriteLine(line: string, rewrites: readonly Rewrite[]): string {
133
+ return rewrites.reduce(
130
134
  (current, { pattern, folder }) =>
131
135
  current.replace(pattern, `.canon/review/evidence/${folder}`),
132
136
  line,
133
137
  )
134
138
  }
135
139
 
136
- /**
137
- * Rewrites every unmarked citation of a promoted folder into its destination
138
- * under `.canon/review/evidence/`, absolute regardless of how the source
139
- * citation was spelled. A file naming no promoted folder returns
140
- * byte-identical, and a marked line is returned unchanged.
141
- */
142
- export function rewriteScratchEvidence(text: string): string {
143
- const lines = text.split('\n')
144
- return lines
145
- .map((line, index) => (isKept(lines, index) ? line : rewriteLine(line)))
146
- .join('\n')
140
+ interface RewriteOutcome {
141
+ readonly text: string
142
+ readonly count: number
147
143
  }
148
144
 
149
- /** How many unmarked citations `rewriteScratchEvidence` would change. */
150
- export function countScratchEvidenceCitations(text: string): number {
145
+ /**
146
+ * Rewrites every unmarked citation of a folder `rewrites` covers into its
147
+ * destination under `.canon/review/evidence/`, absolute regardless of how the
148
+ * source citation was spelled, counting each as it goes. A file naming no
149
+ * such folder returns byte-identical with a count of zero, and a marked line
150
+ * is returned unchanged and uncounted.
151
+ */
152
+ function applyRewrites(
153
+ text: string,
154
+ rewrites: readonly Rewrite[],
155
+ ): RewriteOutcome {
151
156
  const lines = text.split('\n')
152
157
  let count = 0
153
158
 
154
- for (const [index, line] of lines.entries()) {
155
- if (isKept(lines, index)) continue
156
- for (const { pattern } of REWRITES) {
159
+ const rewritten = lines.map((line, index) => {
160
+ if (isKept(lines, index)) return line
161
+
162
+ for (const { pattern } of rewrites) {
157
163
  count += [...line.matchAll(pattern)].length
158
164
  }
159
- }
165
+ return rewriteLine(line, rewrites)
166
+ })
160
167
 
161
- return count
168
+ return { text: rewritten.join('\n'), count }
162
169
  }
163
170
 
164
171
  /** The files under every `BACKED_FOLDERS` entry, archives included. */
@@ -261,17 +268,17 @@ export function planScratchEvidence(
261
268
  sources: readonly ScratchEvidenceSource[],
262
269
  ): ScratchEvidencePlan {
263
270
  const { moves, collisions } = planFolderMoves(root)
271
+ const folders = PROMOTED_FOLDERS.filter(
272
+ (folder) => !collisions.includes(destinationPath(root, folder)),
273
+ )
274
+ const rewrites = buildRewrites(folders)
264
275
  const entries: CitationEntry[] = []
265
276
 
266
277
  for (const source of sources) {
267
- const rewritten = countScratchEvidenceCitations(source.text)
268
- if (rewritten === 0) continue
269
-
270
- entries.push({
271
- path: source.path,
272
- text: rewriteScratchEvidence(source.text),
273
- rewritten,
274
- })
278
+ const { text, count } = applyRewrites(source.text, rewrites)
279
+ if (count === 0) continue
280
+
281
+ entries.push({ path: source.path, text, rewritten: count })
275
282
  }
276
283
 
277
284
  return {
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs'
1
2
  import { dirname, resolve } from 'node:path'
2
3
  import { fileURLToPath } from 'node:url'
3
4
 
@@ -17,3 +18,36 @@ export const PROJECT_ROOT = resolve(
17
18
  dirname(fileURLToPath(import.meta.url)),
18
19
  '..',
19
20
  )
21
+
22
+ const readPackageName = (root: string): string | undefined => {
23
+ try {
24
+ const raw = readFileSync(resolve(root, 'package.json'), 'utf8')
25
+ return (JSON.parse(raw) as { name?: string }).name
26
+ } catch {
27
+ return undefined
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Walks upward from `startDir` for the nearest ancestor `package.json`
33
+ * sharing this package's own `name`, and reports its path when that ancestor
34
+ * is not `PROJECT_ROOT`. A bare `canon` on PATH resolves `PROJECT_ROOT` to the
35
+ * installed package rather than to a checkout the caller may be standing in,
36
+ * which is the case this reports: a caller's cwd sitting inside a second
37
+ * checkout the running binary never resolved against.
38
+ */
39
+ export function findCheckoutMismatch(startDir: string): string | undefined {
40
+ const ownName = readPackageName(PROJECT_ROOT)
41
+ if (ownName === undefined) return undefined
42
+
43
+ let dir = resolve(startDir)
44
+ while (true) {
45
+ if (readPackageName(dir) === ownName) {
46
+ return dir === PROJECT_ROOT ? undefined : dir
47
+ }
48
+
49
+ const parent = dirname(dir)
50
+ if (parent === dir) return undefined
51
+ dir = parent
52
+ }
53
+ }
@@ -168,14 +168,17 @@ const DOCS_PATH = /(?<![\w./-])docs\/[^\s`)\]]*\.md\b/g
168
168
  * lookbehind rejects, so a citation already rewritten to the resolving form
169
169
  * does not fail this check a second time.
170
170
  *
171
- * Reported only where `isStandardsPathResolvable` confirms the match
172
- * resolves against this checkout, the same gate `DOCS_PATH` runs. A body
173
- * illustrating the shape a project's own `standards/<name>.md` takes writes
174
- * the identical placeholder token this pattern matches, such as
175
- * `standards/<slug>.md` in `create-standard/SKILL.md` or `standards/<name>.md`
176
- * in `migration-standards-drop/SKILL.md`, and neither resolves to a real
177
- * file. Four such placeholders surfaced across three files the first time
178
- * this pattern ran unresolved, measured 2026-09-06.
171
+ * Reported unless `isStandardsPathReportable` reads the match as a
172
+ * deliberate placeholder rather than a real citation. A body illustrating
173
+ * the shape a project's own `standards/<name>.md` takes writes a bracketed
174
+ * token this pattern also matches, such as `standards/<slug>.md` in
175
+ * `create-standard/SKILL.md` or `standards/<name>.md` in
176
+ * `migration-standards-drop/SKILL.md`, and neither names a real file. A bare,
177
+ * non-bracketed match now reports whether or not the file exists, unlike
178
+ * `DOCS_PATH`, which still gates on existence: a same-repository citation
179
+ * under this corpus names no target project's own tree the way a `docs/`
180
+ * path can, so shape alone separates a placeholder from a broken citation
181
+ * here with no false-positive class existence was catching.
179
182
  */
180
183
  const STANDARDS_PATH = /(?<![\w./-])standards\/[^\s`)\]]*\.md\b/g
181
184
 
@@ -221,20 +224,37 @@ export interface ShippedReference {
221
224
  }
222
225
 
223
226
  /**
224
- * Whether a `DOCS_PATH` match names a real file in this checkout.
227
+ * Whether a matched path is a deliberate placeholder rather than a real
228
+ * citation, true when the text carries both `<` and `>`. A real path never
229
+ * carries either character, so the two classes cannot collide.
230
+ */
231
+ function isPlaceholderPath(path: string): boolean {
232
+ return path.includes('<') && path.includes('>')
233
+ }
234
+
235
+ /**
236
+ * Whether a `DOCS_PATH` match should be reported.
225
237
  *
226
238
  * `file` gates the `docs/` corpus out by the caller's own location rather
227
239
  * than by a pattern exemption: a citation from inside `docs/` is read
228
240
  * together with the rest of that corpus through the same `canon docs`
229
241
  * resolution, which is a weaker claim than one from a skill body with no
230
242
  * `docs/` sibling at all.
243
+ *
244
+ * The placeholder check runs ahead of `existsSync` rather than replacing it.
245
+ * Shape alone cannot separate a genuinely broken same-repository citation
246
+ * from a legitimate illustration of a target project's own tree, such as
247
+ * `docs/retry.md`, and no signal in the text draws that line, so existence
248
+ * still does the work of muting those. The placeholder check only catches
249
+ * the bracketed illustrations existence alone would report as broken.
231
250
  */
232
- function isDocsPathResolvable(
251
+ function isDocsPathReportable(
233
252
  file: string,
234
253
  path: string,
235
254
  root: string,
236
255
  ): boolean {
237
256
  if (file.startsWith('docs/')) return false
257
+ if (isPlaceholderPath(path)) return false
238
258
  return existsSync(join(root, path))
239
259
  }
240
260
 
@@ -250,9 +270,19 @@ function isStandardsPathScope(file: string): boolean {
250
270
  return file.startsWith('claude/skills/') && !file.endsWith('/REQUIREMENT.md')
251
271
  }
252
272
 
253
- /** Whether a `STANDARDS_PATH` match names a real file in this checkout. */
254
- function isStandardsPathResolvable(path: string, root: string): boolean {
255
- return existsSync(join(root, path))
273
+ /**
274
+ * Whether a `STANDARDS_PATH` match should be reported: every match that is
275
+ * not a placeholder, whether or not the named file exists.
276
+ *
277
+ * Existence used to gate this the same way `isDocsPathReportable` gates its
278
+ * corpus, which conflated a deliberate placeholder with a genuinely broken,
279
+ * real-looking citation: both fail existence and both passed muted. A bare
280
+ * `standards/<name>.md` names no target-project tree the way a `docs/` path
281
+ * can, so shape alone separates the two classes here with no false-positive
282
+ * class existence was catching.
283
+ */
284
+ function isStandardsPathReportable(path: string): boolean {
285
+ return !isPlaceholderPath(path)
256
286
  }
257
287
 
258
288
  /**
@@ -318,7 +348,7 @@ export function referencesIn(
318
348
  }
319
349
 
320
350
  for (const match of line.matchAll(DOCS_PATH)) {
321
- if (!isDocsPathResolvable(file, match[0], root)) continue
351
+ if (!isDocsPathReportable(file, match[0], root)) continue
322
352
  references.push({
323
353
  file,
324
354
  line: index + 1,
@@ -329,7 +359,7 @@ export function referencesIn(
329
359
 
330
360
  if (isStandardsPathScope(file)) {
331
361
  for (const match of line.matchAll(STANDARDS_PATH)) {
332
- if (!isStandardsPathResolvable(match[0], root)) continue
362
+ if (!isStandardsPathReportable(match[0])) continue
333
363
  references.push({
334
364
  file,
335
365
  line: index + 1,
@@ -26,7 +26,7 @@ export default defineConfig([
26
26
  ...tseslint.configs.recommended,
27
27
  ...astro.configs.recommended,
28
28
  {
29
- files: ['**/*.{ts,tsx,js,jsx}'],
29
+ files: ['**/*.{ts,tsx,js,jsx,astro}'],
30
30
  plugins: {
31
31
  'simple-import-sort': simpleImportSort,
32
32
  'check-file': checkFile,
@@ -21,7 +21,7 @@ The astro stack covers Astro + TypeScript projects: content sites, marketing sit
21
21
  - `vitest.config.ts`: uses `getViteConfig` from `astro/config` (not `mergeConfig`). jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `**/*.astro` in coverage excludes.
22
22
  - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`. `DIST_PREBUILT` set in the environment drops the `build` half, running `bun run preview` alone against a `dist/` a prior CI job already produced.
23
23
  - `tsconfig.json`: extends `astro/tsconfigs/strict`, adds `skipLibCheck`, `vitest/globals` and `@testing-library/jest-dom` in types, `@/` paths.
24
- - `eslint.config.js`: overrides the web layer. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). `src/pages/**` exempt from filename and folder naming conventions because Astro's file-based routing ties names to URL segments.
24
+ - `eslint.config.js`: overrides the web layer. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). The shared block's `files` selector includes `.astro`, so `check-file/filename-naming-convention` reaches `.ts`, `.tsx`, and `.astro` under `KEBAB_CASE`, overriding Astro's own PascalCase component convention deliberately, on the ground that a component's name in markup comes from the import binding rather than the filename. `.js` and `.jsx` stay out of the rule's own pattern, matching `web` and `nextjs`. `check-file/folder-naming-convention`'s `src/**/!(__tests__|pages)` pattern reaches no folder under a real ESLint run, `.astro`-only or otherwise. See `.claude/context/tooling.md` for the measurement.
25
25
 
26
26
  ## Typecheck
27
27