@erclx/canon 4.78.0 → 4.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +9 -9
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-operator/SKILL.md +1 -1
  4. package/claude/skills/canon-rollout/SKILL.md +1 -1
  5. package/claude/skills/create-standard/SKILL.md +1 -1
  6. package/claude/skills/design-extract/REQUIREMENT.md +4 -4
  7. package/claude/skills/design-extract/SKILL.md +11 -11
  8. package/claude/skills/docs-fold/SKILL.md +14 -14
  9. package/claude/skills/docs-fold/references/anchor-sweep.md +1 -1
  10. package/claude/skills/docs-fold/references/wireframe-sweep.md +4 -4
  11. package/claude/skills/draft-context/REQUIREMENT.md +6 -6
  12. package/claude/skills/draft-context/SKILL.md +8 -6
  13. package/claude/skills/draft-diagram/SKILL.md +5 -5
  14. package/claude/skills/draft-identity/SKILL.md +3 -3
  15. package/claude/skills/draft-readme/REQUIREMENT.md +1 -1
  16. package/claude/skills/draft-screencast/SKILL.md +1 -1
  17. package/claude/skills/draft-wireframes/REQUIREMENT.md +7 -7
  18. package/claude/skills/draft-wireframes/SKILL.md +7 -5
  19. package/claude/skills/git-pr/SKILL.md +3 -3
  20. package/claude/skills/git-pr/references/labels.md +1 -1
  21. package/claude/skills/memory-capture/REQUIREMENT.md +1 -1
  22. package/claude/skills/memory-capture/SKILL.md +6 -6
  23. package/claude/skills/memory-review/SKILL.md +1 -1
  24. package/claude/skills/migration-claude-md/REQUIREMENT.md +3 -3
  25. package/claude/skills/migration-claude-md/SKILL.md +7 -5
  26. package/claude/skills/migration-context/REQUIREMENT.md +3 -3
  27. package/claude/skills/migration-context/SKILL.md +11 -9
  28. package/claude/skills/plan-feature/SKILL.md +4 -4
  29. package/claude/skills/plan-groundwork/SKILL.md +2 -2
  30. package/claude/skills/plan-intake/SKILL.md +2 -2
  31. package/claude/skills/project-commands/SKILL.md +2 -2
  32. package/claude/skills/review-branch/SKILL.md +3 -3
  33. package/claude/skills/review-pr/SKILL.md +2 -2
  34. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  35. package/claude/skills/role-planner/SKILL.md +1 -1
  36. package/claude/skills/session-relay/REQUIREMENT.md +1 -1
  37. package/claude/skills/session-resume/SKILL.md +1 -1
  38. package/claude/skills/setup-gov/SKILL.md +1 -1
  39. package/claude/skills/setup-init/SKILL.md +1 -1
  40. package/claude/skills/teach-workspace/references/promotion.md +5 -5
  41. package/claude/skills/ux-audit/SKILL.md +5 -5
  42. package/claude/skills/ux-measure/SKILL.md +1 -1
  43. package/docs/agents/audits.md +2 -2
  44. package/docs/agents/commands.md +7 -2
  45. package/docs/agents/context-audit-checks.md +3 -3
  46. package/docs/agents/context-audit.md +2 -2
  47. package/docs/agents/counts.md +3 -3
  48. package/docs/agents/design-board.md +3 -3
  49. package/docs/agents/docs.md +3 -3
  50. package/docs/agents/indexes.md +1 -1
  51. package/docs/agents/install-and-sync.md +4 -4
  52. package/docs/agents/label-coverage.md +1 -1
  53. package/docs/agents/review-classification.md +1 -1
  54. package/docs/agents/routing.md +2 -2
  55. package/docs/agents/rule-citations.md +1 -1
  56. package/docs/agents/sandbox.md +1 -1
  57. package/docs/agents/scripting.md +1 -1
  58. package/docs/agents/skills-audit.md +1 -1
  59. package/docs/agents/skills-reach.md +4 -4
  60. package/docs/target-projects.md +11 -11
  61. package/docs/workflow/ai-workflow.md +15 -13
  62. package/docs/workflow/operating-model.md +2 -2
  63. package/docs/workflow/visual-design-workflow.md +8 -8
  64. package/governance/rules/claude/510-context.md +3 -2
  65. package/governance/rules/claude/520-wireframes.md +3 -2
  66. package/governance/rules/claude/530-requirements.md +2 -1
  67. package/governance/rules/claude/540-architecture.md +2 -1
  68. package/governance/rules/claude/550-design.md +2 -1
  69. package/governance/rules/core/025-indexes.md +1 -1
  70. package/governance/rules/core/045-memory.md +1 -1
  71. package/package.json +1 -1
  72. package/scripts/core/check-ignore-parity.sh +1 -1
  73. package/scripts/core/check-seed-independence.sh +5 -2
  74. package/scripts/core/install-check.sh +1 -1
  75. package/scripts/core/list-seed-roots.sh +3 -3
  76. package/scripts/core/regen-hero.sh +1 -1
  77. package/scripts/docs/list.sh +3 -3
  78. package/scripts/lib/sandbox-git.sh +1 -1
  79. package/scripts/lib/tooling.sh +6 -4
  80. package/snippets/claude/figma-steps.md +1 -1
  81. package/src/audits/baseline.ts +17 -5
  82. package/src/audits/catalog.ts +2 -2
  83. package/src/autoship/paths.ts +1 -1
  84. package/src/claude/cases/authoring.ts +2 -2
  85. package/src/claude/cases/setup.ts +1 -1
  86. package/src/claude/routing.ts +1 -1
  87. package/src/claude/seeds-list.ts +1 -1
  88. package/src/claude/seeds.ts +62 -28
  89. package/src/claude/skills-reach.ts +3 -3
  90. package/src/commands/claude.ts +1 -1
  91. package/src/commands/context.ts +8 -8
  92. package/src/commands/design.ts +1 -1
  93. package/src/commands/gov.ts +1 -1
  94. package/src/commands/migrate.ts +182 -1
  95. package/src/commands/sync.ts +1 -1
  96. package/src/context/audit.ts +1 -1
  97. package/src/context/citations.ts +7 -5
  98. package/src/context/folders.ts +2 -2
  99. package/src/counts/scan.ts +4 -4
  100. package/src/design/board.ts +5 -5
  101. package/src/design/components.ts +2 -2
  102. package/src/design/css.ts +1 -1
  103. package/src/design/document.ts +1 -1
  104. package/src/design/regen.ts +1 -1
  105. package/src/design/render.ts +1 -1
  106. package/src/design/tokens.ts +2 -2
  107. package/src/docs/read.ts +9 -3
  108. package/src/gate/measures.ts +14 -11
  109. package/src/gate/stages.ts +4 -4
  110. package/src/labels/map.ts +19 -5
  111. package/src/legacy-path.ts +15 -0
  112. package/src/markdown/scan.ts +1 -1
  113. package/src/migrate/plan.ts +1 -0
  114. package/src/migrate/surface-roots.ts +457 -0
  115. package/src/record-root.ts +6 -4
  116. package/src/sandbox/census.ts +1 -1
  117. package/src/shipped/references.ts +3 -3
  118. package/src/surface-root.ts +33 -8
  119. package/src/sync/layout.ts +10 -6
  120. package/src/sync/reverse.ts +1 -1
  121. package/src/sync/stamp.ts +22 -17
  122. package/src/targets/sweep.ts +6 -2
  123. package/src/tooling/inject.ts +5 -3
  124. package/src/tooling/scan.ts +4 -1
  125. package/standards/architecture.md +4 -4
  126. package/standards/context.md +7 -7
  127. package/standards/design.md +3 -3
  128. package/standards/diagrams.md +6 -6
  129. package/standards/index.md +5 -5
  130. package/standards/markdown.md +2 -2
  131. package/standards/publish.md +3 -3
  132. package/standards/readme.md +1 -1
  133. package/standards/requirements.md +4 -4
  134. package/standards/skill.md +2 -2
  135. package/standards/tasks.md +1 -1
  136. package/standards/wiki.md +1 -1
  137. package/standards/wireframes.md +8 -8
  138. package/tooling/astro/reference.md +3 -3
  139. package/tooling/base/reference.md +3 -3
  140. package/tooling/claude/reference.md +7 -3
  141. package/tooling/claude/seeds/CLAUDE.md +9 -9
  142. package/tooling/nextjs/reference.md +2 -2
  143. package/tooling/python/reference.md +1 -1
  144. package/tooling/vite-react/reference.md +2 -2
  145. package/tooling/web/reference.md +2 -2
  146. /package/tooling/base/seeds/{.claude → canon}/context/ci.md +0 -0
  147. /package/tooling/base/seeds/{.claude → canon}/context/development.md +0 -0
  148. /package/tooling/base/seeds/{.claude → canon}/context/index.md +0 -0
  149. /package/tooling/claude/seeds/{.claude → canon}/ARCHITECTURE.md +0 -0
  150. /package/tooling/claude/seeds/{.claude → canon}/DESIGN.md +0 -0
  151. /package/tooling/claude/seeds/{.claude → canon}/REQUIREMENTS.md +0 -0
  152. /package/tooling/claude/seeds/{.claude → canon}/wireframes/index.md +0 -0
@@ -0,0 +1,457 @@
1
+ /**
2
+ * The move of the tracked toolkit surfaces from `.claude/` to `canon/`.
3
+ *
4
+ * Unlike the records move, every entry here is tracked, so a folder relocating
5
+ * is itself a citation-shaped event: the file's own path is a string every
6
+ * other file may quote. One planner therefore judges both halves per source
7
+ * file rather than moving folders and rewriting citations as two passes, so a
8
+ * file that both moves and cites another moved entry is decided once.
9
+ */
10
+
11
+ import { join } from 'node:path'
12
+ import { SURFACE_ENTRIES, spell, type SurfaceRoot } from '@/surface-root'
13
+
14
+ /** The root the entries below leave. */
15
+ export const FROM_ROOT: SurfaceRoot = '.claude'
16
+
17
+ /** The root they arrive at. */
18
+ const TO_ROOT: SurfaceRoot = 'canon'
19
+
20
+ /**
21
+ * Every entry this move relocates, at the name `.claude/` gives it.
22
+ *
23
+ * Read from `SURFACE_ENTRIES` rather than restated. `canon` names the install
24
+ * stamp folder and is the one entry whose name differs by root, which
25
+ * `sourcePrefix` and `destinationPrefix` already resolve through `spell`
26
+ * rather than restating the variant here.
27
+ */
28
+ export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES
29
+
30
+ function sourcePrefix(entry: string): string {
31
+ return join(FROM_ROOT, spell(FROM_ROOT, entry))
32
+ }
33
+
34
+ function destinationPrefix(entry: string): string {
35
+ return join(TO_ROOT, spell(TO_ROOT, entry))
36
+ }
37
+
38
+ /**
39
+ * Where a moved entry's own path lands, or `undefined` when `path` sits
40
+ * outside every moved entry.
41
+ *
42
+ * Tested against the file's own path rather than its content, so a context
43
+ * entry moves regardless of what it cites.
44
+ */
45
+ export function movedPath(path: string): string | undefined {
46
+ for (const entry of MOVED_ENTRIES) {
47
+ const prefix = sourcePrefix(entry)
48
+ if (path === prefix) return destinationPrefix(entry)
49
+ if (path.startsWith(`${prefix}/`)) {
50
+ return destinationPrefix(entry) + path.slice(prefix.length)
51
+ }
52
+ }
53
+
54
+ return undefined
55
+ }
56
+
57
+ /**
58
+ * A citation into a moved entry.
59
+ *
60
+ * The tail rejects a following name character rather than asking for a word
61
+ * boundary, which is what keeps `.claude/context` bare and `.claude/context/`
62
+ * both matching while `.claude/contexts/` does not. The alternation is ordered
63
+ * longest first so `ARCHITECTURE.md` is decided before any shorter entry could
64
+ * claim its prefix, and each entry is escaped because two of them carry a dot.
65
+ */
66
+ const CITATION = new RegExp(
67
+ `${escape(FROM_ROOT)}/(${[...MOVED_ENTRIES]
68
+ .sort((left, right) => right.length - left.length)
69
+ .map(escape)
70
+ .join('|')})(?![A-Za-z0-9._-])`,
71
+ 'g',
72
+ )
73
+
74
+ function escape(value: string): string {
75
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
76
+ }
77
+
78
+ /**
79
+ * Files whose content is left alone entirely.
80
+ *
81
+ * The changelog is release history, and an eval result is a transcript of
82
+ * paths a session actually opened; rewriting either makes it testify to
83
+ * something that never happened. `src/surface-root.ts` and
84
+ * `src/record-root.ts` are the two sources that spell the old root as data on
85
+ * purpose, so sweeping either would turn the very literals this verb reads
86
+ * into their own replacement. A hook is the third such source, guarding on a
87
+ * `case` carrying an arm per root, so `.claude/hooks/` and the seed's copy of
88
+ * it are excluded for the same reason `records.ts` excludes them.
89
+ *
90
+ * No suffix-wide test exclusion. Most of the `src/` test files naming these
91
+ * paths use them as fixture data proving the fallback still resolves, not to
92
+ * pin one particular assertion, so a wholesale ban would leave the majority
93
+ * silently unswept. The two files that do pin a fallback carry `KEEP_MARKER`
94
+ * on the line that needs it instead.
95
+ */
96
+ const EXCLUDED_PREFIXES: readonly string[] = [
97
+ 'src/migrate/',
98
+ 'scripts/eval/result-',
99
+ '.claude/hooks/',
100
+ 'tooling/claude/seeds/.claude/hooks/',
101
+ ]
102
+
103
+ const EXCLUDED_PATHS: readonly string[] = [
104
+ 'CHANGELOG.md',
105
+ 'src/surface-root.ts',
106
+ 'src/record-root.ts',
107
+ ]
108
+
109
+ export function isExcludedPath(path: string): boolean {
110
+ if (EXCLUDED_PATHS.includes(path)) return true
111
+ return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))
112
+ }
113
+
114
+ /**
115
+ * Whether `text` names an `EXCLUDED_PREFIXES` or `EXCLUDED_PATHS` entry as
116
+ * literal substring text, which is how a rewritten file can couple to one
117
+ * this module leaves alone.
118
+ */
119
+ export function referencesExcluded(text: string): boolean {
120
+ return (
121
+ EXCLUDED_PATHS.some((path) => text.includes(path)) ||
122
+ EXCLUDED_PREFIXES.some((prefix) => text.includes(prefix))
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Marks a line naming the old root on purpose.
128
+ *
129
+ * The marker sits on the line itself or on the nearest non-blank line above
130
+ * it, walking past a run of blank markdown lines to reach a marker written on
131
+ * its own line above the paragraph it protects.
132
+ */
133
+ const KEEP_MARKER = 'canon-keep-surface-root'
134
+
135
+ function isKept(lines: readonly string[], index: number): boolean {
136
+ if (lines[index]?.includes(KEEP_MARKER)) return true
137
+
138
+ let above = index - 1
139
+ while (above >= 0 && lines[above]?.trim() === '') above -= 1
140
+
141
+ return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
142
+ }
143
+
144
+ /**
145
+ * The line index where a leading YAML frontmatter block closes, or
146
+ * `undefined` when the file does not open with a bare `---` on line one.
147
+ */
148
+ function frontmatterEnd(lines: readonly string[]): number | undefined {
149
+ if (lines[0] !== '---') return undefined
150
+ const end = lines.indexOf('---', 1)
151
+ return end === -1 ? undefined : end
152
+ }
153
+
154
+ /**
155
+ * Marks a line inside a rule's frontmatter `paths:` key: the key line itself,
156
+ * or an indented `- 'glob'` list item under it.
157
+ */
158
+ function isFrontmatterPathsLine(
159
+ lines: readonly string[],
160
+ index: number,
161
+ frontmatterEndIndex: number | undefined,
162
+ ): boolean {
163
+ if (frontmatterEndIndex === undefined) return false
164
+ if (index <= 0 || index >= frontmatterEndIndex) return false
165
+
166
+ let top = index
167
+ while (top > 0 && /^\s/.test(lines[top] ?? '')) top -= 1
168
+
169
+ return lines[top]?.trim().startsWith('paths:') ?? false
170
+ }
171
+
172
+ /**
173
+ * A path segment immediately before a citation match, shaped like another
174
+ * repository's own name: it carries a dot or it is itself preceded by a
175
+ * further `/`.
176
+ */
177
+ const CROSS_REPO_PREFIX = /(?:[\w.-]+\.[\w.-]+|[\w.-]+\/[\w.-]+)\/$/
178
+
179
+ function isCrossRepoPrefix(lines: readonly string[], index: number): boolean {
180
+ const line = lines[index] ?? ''
181
+ const match = [...line.matchAll(CITATION)][0]
182
+ if (match === undefined) return false
183
+
184
+ return CROSS_REPO_PREFIX.test(line.slice(0, match.index ?? 0))
185
+ }
186
+
187
+ /** An ISO date, marking a paragraph as recording what was true on that day. */
188
+ const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/
189
+
190
+ /**
191
+ * Whether the line's citation sits inside a blank-line-delimited paragraph
192
+ * that also carries an ISO date, read as dated prose rather than a live path.
193
+ *
194
+ * `.claude/ARCHITECTURE.md` is itself a moved entry and carries dozens of
195
+ * `Measured at <sha> on <date>` paragraphs naming the old root as what was
196
+ * true that day. Those stay as written rather than being rewritten into a
197
+ * root that did not exist yet on the date being recorded.
198
+ */
199
+ function isDatedParagraph(lines: readonly string[], index: number): boolean {
200
+ let start = index
201
+ while (start > 0 && lines[start - 1]?.trim() !== '') start -= 1
202
+
203
+ let end = index
204
+ while (end < lines.length - 1 && lines[end + 1]?.trim() !== '') end += 1
205
+
206
+ for (let cursor = start; cursor <= end; cursor += 1) {
207
+ if (ISO_DATE.test(lines[cursor] ?? '')) return true
208
+ }
209
+
210
+ return false
211
+ }
212
+
213
+ type LineClass = 'live' | 'kept' | 'glob' | 'crossRepo' | 'dated'
214
+
215
+ function classifyLine(
216
+ lines: readonly string[],
217
+ index: number,
218
+ frontmatterEndIndex: number | undefined,
219
+ ): LineClass {
220
+ if (isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) return 'glob'
221
+ if (isKept(lines, index)) return 'kept'
222
+ if (isCrossRepoPrefix(lines, index)) return 'crossRepo'
223
+ if (isDatedParagraph(lines, index)) return 'dated'
224
+ return 'live'
225
+ }
226
+
227
+ /** Rewrites every unmarked, non-glob citation into a moved entry. */
228
+ export function rewriteText(text: string): string {
229
+ const lines = text.split('\n')
230
+ const frontmatterEndIndex = frontmatterEnd(lines)
231
+
232
+ return lines
233
+ .map((line, index) =>
234
+ classifyLine(lines, index, frontmatterEndIndex) === 'live'
235
+ ? line.replace(CITATION, (_match, entry: string) =>
236
+ destinationPrefix(entry),
237
+ )
238
+ : line,
239
+ )
240
+ .join('\n')
241
+ }
242
+
243
+ /**
244
+ * How many citations `rewriteText` would rewrite, how many marked lines it
245
+ * left alone, how many sat inside a frontmatter `paths:` glob, how many sat
246
+ * behind a cross-repository-shaped prefix, and how many sat inside a dated
247
+ * paragraph.
248
+ */
249
+ export function scanText(text: string): {
250
+ readonly rewritten: number
251
+ readonly kept: number
252
+ readonly globs: number
253
+ readonly crossRepo: number
254
+ readonly dated: number
255
+ } {
256
+ const lines = text.split('\n')
257
+ const frontmatterEndIndex = frontmatterEnd(lines)
258
+ let rewritten = 0
259
+ let kept = 0
260
+ let globs = 0
261
+ let crossRepo = 0
262
+ let dated = 0
263
+
264
+ for (const [index, line] of lines.entries()) {
265
+ const matches = [...line.matchAll(CITATION)].length
266
+ if (matches === 0) continue
267
+
268
+ const kind = classifyLine(lines, index, frontmatterEndIndex)
269
+ if (kind === 'glob') globs += matches
270
+ else if (kind === 'kept') kept += matches
271
+ else if (kind === 'crossRepo') crossRepo += matches
272
+ else if (kind === 'dated') dated += matches
273
+ else rewritten += matches
274
+ }
275
+
276
+ return { rewritten, kept, globs, crossRepo, dated }
277
+ }
278
+
279
+ /** Where a citation sits, so a reader can judge it before `--write` runs. */
280
+ export interface CitationLine {
281
+ readonly line: number
282
+ readonly text: string
283
+ }
284
+
285
+ function frontmatterGlobLines(text: string): CitationLine[] {
286
+ const lines = text.split('\n')
287
+ const frontmatterEndIndex = frontmatterEnd(lines)
288
+ const held: CitationLine[] = []
289
+
290
+ for (const [index, line] of lines.entries()) {
291
+ if (!isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) continue
292
+ if ([...line.matchAll(CITATION)].length === 0) continue
293
+
294
+ held.push({ line: index + 1, text: line.trim() })
295
+ }
296
+
297
+ return held
298
+ }
299
+
300
+ function crossRepoLines(text: string): CitationLine[] {
301
+ const lines = text.split('\n')
302
+ const frontmatterEndIndex = frontmatterEnd(lines)
303
+ const held: CitationLine[] = []
304
+
305
+ for (const [index, line] of lines.entries()) {
306
+ if ([...line.matchAll(CITATION)].length === 0) continue
307
+ if (classifyLine(lines, index, frontmatterEndIndex) !== 'crossRepo')
308
+ continue
309
+
310
+ held.push({ line: index + 1, text: line.trim() })
311
+ }
312
+
313
+ return held
314
+ }
315
+
316
+ function datedLines(text: string): CitationLine[] {
317
+ const lines = text.split('\n')
318
+ const frontmatterEndIndex = frontmatterEnd(lines)
319
+ const held: CitationLine[] = []
320
+
321
+ for (const [index, line] of lines.entries()) {
322
+ if ([...line.matchAll(CITATION)].length === 0) continue
323
+ if (classifyLine(lines, index, frontmatterEndIndex) !== 'dated') continue
324
+
325
+ held.push({ line: index + 1, text: line.trim() })
326
+ }
327
+
328
+ return held
329
+ }
330
+
331
+ /** One tracked file, as the planner reads it. */
332
+ export interface SurfaceRootsSource {
333
+ readonly path: string
334
+ readonly text: string
335
+ }
336
+
337
+ /** One file the sweep touches, whether by moving, rewriting, or both. */
338
+ export interface SurfaceRootsEntry {
339
+ readonly path: string
340
+ readonly movesTo?: string
341
+ readonly text?: string
342
+ readonly rewritten: number
343
+ readonly kept: number
344
+ }
345
+
346
+ export interface FrontmatterGlobEntry {
347
+ readonly path: string
348
+ readonly lines: readonly CitationLine[]
349
+ }
350
+
351
+ export interface CrossRepoCitationEntry {
352
+ readonly path: string
353
+ readonly lines: readonly CitationLine[]
354
+ }
355
+
356
+ export interface DatedCitationEntry {
357
+ readonly path: string
358
+ readonly lines: readonly CitationLine[]
359
+ }
360
+
361
+ export interface SurfaceRootsPlan {
362
+ readonly entries: readonly SurfaceRootsEntry[]
363
+ readonly excluded: readonly string[]
364
+ readonly coupled: readonly string[]
365
+ readonly frontmatterGlobs: readonly FrontmatterGlobEntry[]
366
+ readonly crossRepoCitations: readonly CrossRepoCitationEntry[]
367
+ readonly datedCitations: readonly DatedCitationEntry[]
368
+ readonly rewritten: number
369
+ readonly kept: number
370
+ readonly globs: number
371
+ readonly crossRepo: number
372
+ readonly dated: number
373
+ readonly moves: number
374
+ }
375
+
376
+ /**
377
+ * What the move would do, without doing it.
378
+ *
379
+ * A file is judged once for both halves: whether its own path sits under a
380
+ * moved entry, and whether its content cites one. Either alone is enough to
381
+ * produce an entry, and a file excluded from the citation rewrite still moves
382
+ * when its path itself is inside a moved entry, since the two entries excluded
383
+ * today, `src/surface-root.ts` and `src/record-root.ts`, both sit outside
384
+ * every moved entry and never take that branch in practice.
385
+ */
386
+ export function planSurfaceRootsMove(
387
+ sources: readonly SurfaceRootsSource[],
388
+ ): SurfaceRootsPlan {
389
+ const entries: SurfaceRootsEntry[] = []
390
+ const excluded: string[] = []
391
+ const coupled: string[] = []
392
+ const frontmatterGlobs: FrontmatterGlobEntry[] = []
393
+ const crossRepoCitations: CrossRepoCitationEntry[] = []
394
+ const datedCitations: DatedCitationEntry[] = []
395
+ let kept = 0
396
+ let globs = 0
397
+ let crossRepo = 0
398
+ let dated = 0
399
+
400
+ for (const source of sources) {
401
+ const movesTo = movedPath(source.path)
402
+
403
+ if (isExcludedPath(source.path)) {
404
+ if (scanText(source.text).rewritten > 0) excluded.push(source.path)
405
+ if (movesTo !== undefined) {
406
+ entries.push({ path: source.path, movesTo, rewritten: 0, kept: 0 })
407
+ }
408
+ continue
409
+ }
410
+
411
+ const counts = scanText(source.text)
412
+ kept += counts.kept
413
+ globs += counts.globs
414
+ crossRepo += counts.crossRepo
415
+ dated += counts.dated
416
+
417
+ const held = frontmatterGlobLines(source.text)
418
+ if (held.length > 0)
419
+ frontmatterGlobs.push({ path: source.path, lines: held })
420
+
421
+ const foreign = crossRepoLines(source.text)
422
+ if (foreign.length > 0)
423
+ crossRepoCitations.push({ path: source.path, lines: foreign })
424
+
425
+ const dates = datedLines(source.text)
426
+ if (dates.length > 0)
427
+ datedCitations.push({ path: source.path, lines: dates })
428
+
429
+ const changed = counts.rewritten > 0
430
+ if (!changed && movesTo === undefined) continue
431
+
432
+ if (changed && referencesExcluded(source.text)) coupled.push(source.path)
433
+
434
+ entries.push({
435
+ path: source.path,
436
+ ...(movesTo !== undefined ? { movesTo } : {}),
437
+ ...(changed ? { text: rewriteText(source.text) } : {}),
438
+ rewritten: counts.rewritten,
439
+ kept: counts.kept,
440
+ })
441
+ }
442
+
443
+ return {
444
+ entries,
445
+ excluded,
446
+ coupled,
447
+ frontmatterGlobs,
448
+ crossRepoCitations,
449
+ datedCitations,
450
+ rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
451
+ kept,
452
+ globs,
453
+ crossRepo,
454
+ dated,
455
+ moves: entries.filter((entry) => entry.movesTo !== undefined).length,
456
+ }
457
+ }
@@ -52,10 +52,12 @@ const CANON_SCRATCH = 'tmp'
52
52
  * writes back. `worktrees` is absent because the harness creates a worktree
53
53
  * under `.claude/` and requires its target to sit there.
54
54
  *
55
- * Everything absent from this list is committed and stays where it is, which is
56
- * the rule the move ran on. `context`, `rules`, `skills`, `hooks`, `wireframes`,
57
- * and the loose documents at the root are all in that set, which is why a seed
58
- * and a superseded-layout report each ask this rather than assuming a root.
55
+ * Everything absent from this list is committed and never lands under the
56
+ * record root, which is the rule the move ran on. `rules`, `skills`, and
57
+ * `hooks` stay under `.claude/` because the vendor reads them there, while
58
+ * `context`, `wireframes`, and the loose documents are tracked surfaces that
59
+ * `surface-root.ts` resolves under `canon/` instead. A seed and a
60
+ * superseded-layout report each ask this rather than assuming a root.
59
61
  */
60
62
  export const RECORD_ENTRIES: readonly string[] = [
61
63
  '.records.git',
@@ -69,7 +69,7 @@ export function listSkills(root: string): string[] {
69
69
  /**
70
70
  * Maps a scenario to the skill it drives, trying two spellings in order.
71
71
  *
72
- * `<category>-<command>` is the rule `.claude/context/sandbox/coverage.md` states, and it
72
+ * `<category>-<command>` is the rule `canon/context/sandbox/coverage.md` states, and it
73
73
  * alone pairs 29 of 54 skills. The bare `<command>` fallback is what reaches the
74
74
  * rest: `claude/setup-init.sh` drives the `setup-init` skill, not a
75
75
  * `claude-setup-init` that does not exist. Stating one spelling and shipping two
@@ -139,10 +139,10 @@ const SAME_REPOSITORY =
139
139
  * named a target's own tree, measured on 2026-09-03. Resolving against `root`
140
140
  * is what turns that 124-hit pattern into the four-hit gate.
141
141
  *
142
- * `.claude/context/` carries no equivalent pattern. The same resolution test
142
+ * `canon/context/` carries no equivalent pattern. The same resolution test
143
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
144
+ * `canon/context/index.md`, from this repository's own domain entry, such
145
+ * as `canon/context/indexes.md`, since both resolve here and only the
146
146
  * second is a defect. That is a semantic read no pattern makes, so it stays
147
147
  * with the rule and the review checklist rather than joining this reader.
148
148
  */
@@ -23,13 +23,13 @@ export type SurfaceRoot = (typeof SURFACE_ROOTS)[number]
23
23
  /**
24
24
  * The root a surface is created at when neither root carries it yet.
25
25
  *
26
- * Disagreeing with the head of the read order for exactly one release: read
27
- * precedence is new-first so a tree that has moved is never answered from the
28
- * copy left behind, while creation stays at the old root so nothing writes a
29
- * fresh tracked file under a root a target's installed binary may not resolve
30
- * yet. A later batch flips this once a release carries the read side.
26
+ * It agrees with the head of the read precedence. While the two disagreed,
27
+ * creation stayed at `.claude/` so nothing wrote a fresh tracked file under a
28
+ * root a target's installed binary could not resolve. A release carries the
29
+ * read side now, so a fresh project scaffolds one root and an unmoved one keeps
30
+ * resolving its own surfaces through the fallback above.
31
31
  */
32
- export const CREATION_ROOT: SurfaceRoot = '.claude'
32
+ export const CREATION_ROOT: SurfaceRoot = 'canon'
33
33
 
34
34
  /**
35
35
  * Every tracked surface this module resolves, at the name `.claude/` gives it.
@@ -56,8 +56,13 @@ export function spell(root: SurfaceRoot, entry: string): string {
56
56
  }
57
57
 
58
58
  /**
59
- * The root a surface resolves at: the first that carries it, and the creation
60
- * default when neither does.
59
+ * The root a surface resolves at: the first that carries it, and
60
+ * `CREATION_ROOT` when neither does.
61
+ *
62
+ * The stamp folder used to take a carve-out here, staying at `.claude/` while
63
+ * the install stamp and the audits baseline still wrote there by a fixed path
64
+ * of their own. Both moved to `canon/config/` by a fixed path of their own, so
65
+ * every entry now shares the one default.
61
66
  */
62
67
  function rootOf(root: string, entry: string): SurfaceRoot {
63
68
  return (
@@ -82,6 +87,26 @@ export function surfaceDir(
82
87
  return join(root, at, spell(at, entry), ...rest)
83
88
  }
84
89
 
90
+ /**
91
+ * Where a path authored at the `canon/` spelling lands under `root`.
92
+ *
93
+ * A seed tree authors every surface once, at the new root, while a target may
94
+ * still hold that surface under `.claude/`. Writing the literal path there
95
+ * would open a `canon/` copy that wins read precedence and hides every entry
96
+ * the target already wrote, so the entry resolves the way a read does. A path
97
+ * naming no surface entry is joined as written.
98
+ */
99
+ export function resolveSurfacePath(root: string, rel: string): string {
100
+ const [head, entry, ...rest] = rel.split(/[\\/]/)
101
+
102
+ if (head !== 'canon' || entry === undefined || entry === 'canon') {
103
+ return join(root, rel)
104
+ }
105
+ if (!SURFACE_ENTRIES.includes(entry)) return join(root, rel)
106
+
107
+ return surfaceDir(root, entry, ...rest)
108
+ }
109
+
85
110
  /**
86
111
  * Every root a surface would be read at, in precedence order, whether or not
87
112
  * it is on disk.
@@ -1,7 +1,8 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
- import { basename, join } from 'node:path'
2
+ import { basename, join, relative } from 'node:path'
3
3
  import { SUBDIRS } from '@/claude/seeds'
4
4
  import { creationRel, isRecordEntry } from '@/record-root'
5
+ import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
5
6
  import type { StampDomain } from '@/sync/stamp'
6
7
 
7
8
  const CLAUDE_DIR = '.claude'
@@ -70,13 +71,16 @@ export function collectSuperseded(target: string): SupersededEntry[] {
70
71
  const rel = join(CLAUDE_DIR, `${subdir.toUpperCase()}.md`)
71
72
  if (!isFile(join(target, rel))) continue
72
73
 
73
- // A record subdir now lives under the record root, so the replacement this
74
- // names is resolved against the target rather than fixed at `.claude/`.
75
- // Naming a folder the target does not have sends a person to migrate their
76
- // legacy file into a path nothing reads.
74
+ // A record subdir lives under the record root and a tracked surface under
75
+ // the surface root, so the replacement this names is resolved against the
76
+ // target rather than fixed at `.claude/`. Naming a folder the target does
77
+ // not have sends a person to migrate their legacy file into a path nothing
78
+ // reads.
77
79
  const replacedBy = isRecordEntry(subdir)
78
80
  ? creationRel(target, subdir)
79
- : join(CLAUDE_DIR, subdir)
81
+ : SURFACE_ENTRIES.includes(subdir)
82
+ ? relative(target, surfaceDir(target, subdir))
83
+ : join(CLAUDE_DIR, subdir)
80
84
 
81
85
  entries.push({ rel, replacedBy })
82
86
  }
@@ -234,7 +234,7 @@ export function detectMigrations(target: string): MigrationCandidate[] {
234
234
  if (docs > 0 && context === 0) {
235
235
  found.push({
236
236
  skill: 'migration-context',
237
- reason: `${docs} markdown files under docs/ with no .claude/context/ tier`,
237
+ reason: `${docs} markdown files under docs/ with no canon/context/ tier`,
238
238
  })
239
239
  }
240
240
 
package/src/sync/stamp.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { mkdir, writeFile } from 'node:fs/promises'
4
4
  import { dirname, join, sep } from 'node:path'
5
5
  import { execa } from 'execa'
6
+ import { resolveExisting } from '@/legacy-path'
6
7
  import { recordTarget } from '@/targets/registry'
7
8
 
8
9
  /**
@@ -56,7 +57,21 @@ export interface Stamp {
56
57
  readonly domains: Readonly<Partial<Record<StampDomain, DomainStamp>>>
57
58
  }
58
59
 
60
+ /**
61
+ * Where a stamp is written today, `canon/config/config.json`. Named
62
+ * `stampPath` rather than `surfaceStampPath` because the write side has only
63
+ * one current path, unlike the read side's fallback chain below.
64
+ */
59
65
  export function stampPath(target: string): string {
66
+ return join(target, 'canon', 'config', 'config.json')
67
+ }
68
+
69
+ /**
70
+ * Where the stamp wrote before this move, `.claude/canon/config.json`. Kept
71
+ * for the same reason as the two spellings below: a target stamped under it
72
+ * still carries its config here, and `readStamp` falls back to it.
73
+ */
74
+ export function claudeCanonStampPath(target: string): string {
60
75
  return join(target, '.claude', 'canon', 'config.json')
61
76
  }
62
77
 
@@ -84,18 +99,6 @@ export function retiredNameStampPath(target: string): string {
84
99
  return join(target, '.claude', 'aitk', 'config.json')
85
100
  }
86
101
 
87
- /**
88
- * The stamp path under the new surface root, `canon/config/config.json`.
89
- *
90
- * Read ahead of `stampPath`, extending the same mechanism rather than adding a
91
- * new one: a target that has moved reads its config from the root it moved
92
- * to, and one that has not falls through to the spellings below unchanged.
93
- * The write destination does not move to it in this batch.
94
- */
95
- function surfaceStampPath(target: string): string {
96
- return join(target, 'canon', 'config', 'config.json')
97
- }
98
-
99
102
  /**
100
103
  * Every spelling a stamp has been written under, current first. The order is
101
104
  * the read order, so a target carrying more than one resolves to the newest.
@@ -105,10 +108,10 @@ function surfaceStampPath(target: string): string {
105
108
  * second breaking change aimed at exactly the targets that were slowest to
106
109
  * migrate the first time.
107
110
  */
108
- export function stampPaths(target: string): readonly string[] {
111
+ export function stampPaths(target: string): readonly [string, ...string[]] {
109
112
  return [
110
- surfaceStampPath(target),
111
113
  stampPath(target),
114
+ claudeCanonStampPath(target),
112
115
  retiredNameStampPath(target),
113
116
  legacyStampPath(target),
114
117
  ]
@@ -120,7 +123,10 @@ export function stampPaths(target: string): readonly string[] {
120
123
  * there is nothing to migrate off of.
121
124
  */
122
125
  export function isLegacyStamped(target: string): boolean {
123
- if (existsSync(surfaceStampPath(target)) || existsSync(stampPath(target))) {
126
+ if (
127
+ existsSync(stampPath(target)) ||
128
+ existsSync(claudeCanonStampPath(target))
129
+ ) {
124
130
  return false
125
131
  }
126
132
  return (
@@ -158,8 +164,7 @@ export function toStampKey(rel: string): string {
158
164
  * was read from.
159
165
  */
160
166
  export function readStamp(target: string): Stamp | undefined {
161
- const found = stampPaths(target).find((path) => existsSync(path))
162
- return found === undefined ? undefined : readStampFile(found)
167
+ return readStampFile(resolveExisting(stampPaths(target)))
163
168
  }
164
169
 
165
170
  function readStampFile(path: string): Stamp | undefined {