@erclx/canon 4.6.1 → 4.8.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 (145) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-feedback-file/SKILL.md +3 -3
  3. package/claude/skills/canon-feedback-triage/SKILL.md +1 -1
  4. package/claude/skills/canon-rollout/SKILL.md +1 -1
  5. package/claude/skills/canon-screencast/SKILL.md +5 -5
  6. package/claude/skills/canon-slides-draft/SKILL.md +1 -1
  7. package/claude/skills/claude-address-review/SKILL.md +4 -4
  8. package/claude/skills/claude-autoship/SKILL.md +17 -17
  9. package/claude/skills/claude-design-extract/SKILL.md +2 -2
  10. package/claude/skills/claude-diagram/SKILL.md +13 -13
  11. package/claude/skills/claude-docs/SKILL.md +23 -23
  12. package/claude/skills/claude-feature/SKILL.md +6 -6
  13. package/claude/skills/claude-groundwork/SKILL.md +16 -16
  14. package/claude/skills/claude-intake/SKILL.md +8 -8
  15. package/claude/skills/claude-intake-answer/SKILL.md +1 -1
  16. package/claude/skills/claude-markdown-propose/REQUIREMENT.md +2 -2
  17. package/claude/skills/claude-markdown-propose/SKILL.md +8 -8
  18. package/claude/skills/claude-markdown-propose/references/format.md +3 -3
  19. package/claude/skills/claude-memory-capture/SKILL.md +13 -13
  20. package/claude/skills/claude-memory-review/SKILL.md +28 -28
  21. package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
  22. package/claude/skills/claude-orchestrate/SKILL.md +9 -9
  23. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +1 -1
  24. package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +1 -1
  25. package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +1 -1
  26. package/claude/skills/claude-orchestrate/references/orchestrator-resume.md +1 -1
  27. package/claude/skills/claude-orchestrate/scripts/poll.sh +1 -1
  28. package/claude/skills/claude-planner/REQUIREMENT.md +1 -1
  29. package/claude/skills/claude-planner/SKILL.md +6 -6
  30. package/claude/skills/claude-pr-review/SKILL.md +5 -5
  31. package/claude/skills/claude-review/SKILL.md +4 -4
  32. package/claude/skills/claude-seed-sync/SKILL.md +2 -2
  33. package/claude/skills/claude-tasks/SKILL.md +16 -16
  34. package/claude/skills/claude-teach/SKILL.md +9 -9
  35. package/claude/skills/claude-ui-test/SKILL.md +3 -3
  36. package/claude/skills/claude-ux-audit/SKILL.md +5 -5
  37. package/claude/skills/claude-ux-measure/SKILL.md +3 -3
  38. package/claude/skills/claude-worker/SKILL.md +2 -2
  39. package/claude/skills/claude-worktree/SKILL.md +3 -3
  40. package/claude/skills/git-issue/SKILL.md +4 -4
  41. package/claude/skills/git-pr/SKILL.md +6 -6
  42. package/claude/skills/git-ship/SKILL.md +3 -3
  43. package/claude/skills/git-split/REQUIREMENT.md +1 -1
  44. package/claude/skills/git-split/SKILL.md +20 -20
  45. package/claude/skills/migration-superseded/SKILL.md +4 -4
  46. package/claude/skills/session-map/SKILL.md +2 -2
  47. package/claude/skills/session-resume/SKILL.md +8 -8
  48. package/docs/agents/capture.md +17 -11
  49. package/docs/agents/commands.md +8 -5
  50. package/docs/agents/demo.md +2 -2
  51. package/docs/agents/driver.md +2 -2
  52. package/docs/agents/index.md +1 -1
  53. package/docs/agents/indexes.md +1 -1
  54. package/docs/agents/install-and-sync.md +1 -1
  55. package/docs/agents/intake.md +1 -1
  56. package/docs/agents/key-changes.md +1 -1
  57. package/docs/agents/records.md +12 -9
  58. package/docs/agents/restated.md +2 -2
  59. package/docs/agents/rule-citations.md +1 -1
  60. package/docs/agents/sandbox.md +1 -1
  61. package/docs/agents/state-scoped-risk.md +1 -1
  62. package/docs/agents/tasks.md +6 -6
  63. package/docs/agents/teach.md +4 -4
  64. package/docs/ai-workflow.md +26 -19
  65. package/docs/operating-model.md +3 -3
  66. package/docs/target-projects.md +22 -2
  67. package/docs/visual-design-workflow.md +1 -1
  68. package/governance/rules/claude/555-tasks.md +3 -3
  69. package/governance/rules/claude/556-groundwork.md +2 -2
  70. package/governance/rules/claude/557-intake.md +2 -2
  71. package/governance/rules/claude/558-plan.md +3 -3
  72. package/governance/rules/claude/559-memory.md +2 -2
  73. package/governance/rules/claude/560-diagrams.md +3 -3
  74. package/governance/rules/claude/561-teach.md +2 -2
  75. package/governance/rules/claude/562-session.md +2 -2
  76. package/governance/rules/core/035-tasks.md +2 -2
  77. package/governance/rules/core/045-memory.md +3 -3
  78. package/governance/rules/core/055-scratch.md +2 -1
  79. package/governance/rules/core/085-worktrees.md +1 -1
  80. package/package.json +1 -2
  81. package/scripts/core/check-ignore-parity.sh +24 -51
  82. package/scripts/core/install-check.sh +4 -4
  83. package/scripts/core/regen-hero.sh +6 -3
  84. package/scripts/core/snapshot.sh +1 -1
  85. package/scripts/manage-sandbox.sh +1 -1
  86. package/scripts/tooling/verify.sh +2 -2
  87. package/snippets/claude/decision-memo.md +1 -1
  88. package/snippets/claude/feature-recap.md +1 -1
  89. package/src/audits/catalog.ts +1 -1
  90. package/src/capture/render.ts +166 -0
  91. package/src/capture/sources.ts +42 -0
  92. package/src/capture/stamp.ts +52 -0
  93. package/src/claude/cases/all.ts +1 -1
  94. package/src/claude/seeds.ts +13 -2
  95. package/src/claude/skills-rank.ts +1 -1
  96. package/src/cli.ts +5 -5
  97. package/src/commands/capture.ts +52 -41
  98. package/src/commands/demo.ts +1 -1
  99. package/src/commands/design.ts +1 -1
  100. package/src/commands/feedback.ts +5 -5
  101. package/src/commands/gov.ts +17 -6
  102. package/src/commands/intake.ts +1 -1
  103. package/src/commands/migrate.ts +173 -2
  104. package/src/commands/pr.ts +1 -1
  105. package/src/commands/records.ts +1 -1
  106. package/src/commands/serve.ts +2 -2
  107. package/src/commands/slides.ts +1 -1
  108. package/src/commands/tasks.ts +5 -5
  109. package/src/commands/teach.ts +1 -1
  110. package/src/context/audit.ts +1 -1
  111. package/src/demo/compile.ts +1 -1
  112. package/src/demo/drive.ts +5 -8
  113. package/src/driver/drive.ts +7 -8
  114. package/src/gate/measures.ts +1 -1
  115. package/src/gate/stages.ts +1 -1
  116. package/src/gov/citations.ts +1 -1
  117. package/src/gov/restated.ts +3 -3
  118. package/src/inventory/walk.ts +3 -3
  119. package/src/migrate/apply.ts +67 -1
  120. package/src/migrate/records.ts +276 -0
  121. package/src/paths.ts +1 -1
  122. package/src/pr/paths.ts +1 -1
  123. package/src/record-root.ts +77 -27
  124. package/src/records/backup.ts +71 -17
  125. package/src/records/validate.ts +1 -1
  126. package/src/sync/layout.ts +11 -2
  127. package/src/tasks/answers.ts +1 -1
  128. package/src/tasks/archive.ts +20 -3
  129. package/standards/diagrams.md +4 -4
  130. package/standards/glossary.md +1 -1
  131. package/standards/groundwork.md +3 -3
  132. package/standards/index.md +2 -2
  133. package/standards/intake.md +3 -3
  134. package/standards/memory.md +2 -2
  135. package/standards/plan.md +3 -3
  136. package/standards/session.md +2 -2
  137. package/standards/standard.md +1 -1
  138. package/standards/tasks.md +9 -9
  139. package/standards/teach.md +2 -2
  140. package/standards/versioning.md +1 -1
  141. package/tooling/base/configs/.husky/post-merge +1 -1
  142. package/tooling/base/reference.md +1 -1
  143. package/tooling/claude/manifest.toml +13 -3
  144. package/tooling/claude/reference.md +28 -15
  145. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +8 -8
@@ -43,7 +43,7 @@ const MIRRORS: readonly (readonly [string, string])[] = [
43
43
  *
44
44
  * Under one percent of the 2750 statements this repository offers. Tuned
45
45
  * against that corpus rather than reasoned to, which is what the plan asked of
46
- * the first run: `.claude/plans/` sits at 14 and is the anchor the motivating
46
+ * the first run: `.canon/plans/` sits at 14 and is the anchor the motivating
47
47
  * case turns on, while `file` sits at 371 and matches most of the tree.
48
48
  */
49
49
  export const COMMON_CEILING = 20
@@ -55,7 +55,7 @@ export const ANCHOR_FLOOR = 3
55
55
  * What a backticked token is worth against a plain word.
56
56
  *
57
57
  * An author marking a span as code named an identifier rather than describing
58
- * one, so `.claude/plans/archive/` says more about what a statement governs
58
+ * one, so `.canon/plans/archive/` says more about what a statement governs
59
59
  * than any two prose words do. Weighting it is what lets the floor rise high
60
60
  * enough to drop a coincidental word pair without losing a rule two surfaces
61
61
  * spelled entirely differently around one shared path.
@@ -267,7 +267,7 @@ interface Indexed<T extends Statement> {
267
267
  /**
268
268
  * Splits text into the tokens an anchor can be drawn from.
269
269
  *
270
- * A code span keeps its inner text whole, since `.claude/plans/archive/` is the
270
+ * A code span keeps its inner text whole, since `.canon/plans/archive/` is the
271
271
  * strongest anchor this corpus offers and splitting it on the punctuation would
272
272
  * leave three words every second bullet also carries.
273
273
  */
@@ -15,9 +15,9 @@ import type { Subject } from '@/inventory/subjects'
15
15
  * and `src/commands/inventory.ts` reaches it through a dynamic import so no
16
16
  * other command resolves the engine at startup.
17
17
  *
18
- * Like `@/demo/drive` and unlike `@/capture/render`, this module ships, because
19
- * a command whose whole purpose is running inside someone else's project cannot
20
- * stay toolkit-only.
18
+ * Like `@/demo/drive`, `@/driver/drive`, and `@/capture/render`, this module
19
+ * ships, because a command whose whole purpose is running inside someone else's
20
+ * project cannot stay toolkit-only.
21
21
  */
22
22
 
23
23
  /**
@@ -1,8 +1,9 @@
1
1
  import { $ } from 'bun'
2
- import { readFile, mkdir, rmdir, writeFile } from 'node:fs/promises'
2
+ import { readFile, mkdir, rename, rmdir, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative } from 'node:path'
4
4
  import { gitEnv } from '@/git-env'
5
5
  import type { RenamePlan, RenameSource } from '@/migrate/plan'
6
+ import { FROM_ROOT as OLD_ROOT, type RecordsPlan } from '@/migrate/records'
6
7
 
7
8
  /**
8
9
  * A file whose bytes carry a NUL is read as binary and its content is left
@@ -33,6 +34,71 @@ export interface ApplyResult {
33
34
  readonly failed: readonly string[]
34
35
  }
35
36
 
37
+ /**
38
+ * Writes the records move: the citations first, then the folders.
39
+ *
40
+ * The order is the reverse of `applyRename`'s reasoning and lands in the same
41
+ * place. Every folder here is untracked, so a failed move leaves the records
42
+ * where they were and the rewritten citations point at a root nothing has
43
+ * reached yet, which reads as pending. Moving first and failing the rewrite
44
+ * strands the records at a root nothing points at, which reads as lost.
45
+ *
46
+ * A plain rename rather than `git mv`, since none of these paths is tracked and
47
+ * git has no history to carry. The destination root is created once; a rename
48
+ * across filesystems is not handled, because both paths sit inside one project.
49
+ *
50
+ * The folder loop stops on the first failure rather than carrying on. Each move
51
+ * that lands makes the tree more split, and a split tree is what `pushRecords`
52
+ * refuses on, so continuing turns one unmovable folder into a longer list of
53
+ * folders to put back by hand. Stopping does not undo what already moved,
54
+ * which is why the refusal on the push side exists as well.
55
+ */
56
+ export async function applyRecordsMove(
57
+ root: string,
58
+ plan: RecordsPlan,
59
+ ): Promise<ApplyResult> {
60
+ let written = 0
61
+ let moved = 0
62
+ const failed: string[] = []
63
+
64
+ for (const entry of plan.entries) {
65
+ // A rejected write leaves the tree half-rewritten with nothing said, since
66
+ // this runs inside a commander action that would unwind past the report.
67
+ // Reads still resolve through the root fallback, so the recorded failure is
68
+ // what a caller needs rather than a stop.
69
+ const done = await writeFile(join(root, entry.path), entry.text)
70
+ .then(() => true)
71
+ .catch(() => false)
72
+
73
+ if (done) written += 1
74
+ else failed.push(entry.path)
75
+ }
76
+
77
+ for (const move of plan.moves) {
78
+ await mkdir(dirname(join(root, move.to)), { recursive: true })
79
+ const done = await rename(join(root, move.from), join(root, move.to))
80
+ .then(() => true)
81
+ .catch(() => false)
82
+
83
+ if (!done) {
84
+ failed.push(move.from)
85
+ break
86
+ }
87
+
88
+ moved += 1
89
+ }
90
+
91
+ // The old root survives the moves as an empty directory when it held records
92
+ // and nothing else. `rmdir` refuses a directory that still holds anything, so
93
+ // a project keeping its rules, skills, and settings there is untouched and the
94
+ // refusal is ignored rather than reported.
95
+ if (moved > 0) {
96
+ await rmdir(join(root, OLD_ROOT)).catch(() => undefined)
97
+ }
98
+
99
+ return { written, moved, failed }
100
+ }
101
+
36
102
  /**
37
103
  * Writes the plan. Content lands first and the move follows, so a failed move
38
104
  * leaves the rewrite on a path that still exists rather than stranding content
@@ -0,0 +1,276 @@
1
+ /**
2
+ * The move of the gitignored session records from `.claude/` to `.canon/`.
3
+ *
4
+ * Two halves that share one list. The folders themselves are untracked, so
5
+ * relocating them is a filesystem act no commit records, while every tracked
6
+ * file naming one of their paths is a citation that goes stale the moment they
7
+ * land. Running one half without the other leaves a tree whose records are at a
8
+ * root nothing points at, which is why the same verb performs both.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { type RecordRoot, RECORD_ENTRIES, spell } from '@/record-root'
14
+
15
+ /** The root the entries below leave, exported so the writer can prune it. */
16
+ export const FROM_ROOT: RecordRoot = '.claude'
17
+
18
+ /** The root they arrive at. */
19
+ const TO_ROOT: RecordRoot = '.canon'
20
+
21
+ /**
22
+ * Every entry the move relocates, at the name `.claude/` gave it.
23
+ *
24
+ * The list is `RECORD_ENTRIES`, which `record-root.ts` owns because a seed
25
+ * destination and a superseded-layout report ask the same question. Restating
26
+ * it here would let the sweep and the resolver disagree about what moved.
27
+ */
28
+ export const MOVED_ENTRIES = RECORD_ENTRIES
29
+
30
+ /** Where an entry sits before the move, relative to the project root. */
31
+ export function sourcePath(entry: string): string {
32
+ return join(FROM_ROOT, entry)
33
+ }
34
+
35
+ /** Where it sits after, which is the only place the scratch folder is renamed. */
36
+ export function destinationPath(entry: string): string {
37
+ return join(TO_ROOT, spell(TO_ROOT, entry))
38
+ }
39
+
40
+ /**
41
+ * A citation into a moved entry.
42
+ *
43
+ * The tail rejects a following name character rather than asking for a word
44
+ * boundary, and that is what protects the three retired flat archives without
45
+ * an exception list: `.claude/plans-archive` continues into a `-` and never
46
+ * matches, where `.claude/plans/` and a bare `.claude/plans` both do. A
47
+ * boundary would treat the hyphen as a break and rewrite the archive to a root
48
+ * it never sat under.
49
+ *
50
+ * The alternation is ordered longest first so `README.md` is decided before any
51
+ * shorter entry can claim its prefix, and each entry is escaped because two of
52
+ * them carry a dot.
53
+ */
54
+ const CITATION = new RegExp(
55
+ `${escape(FROM_ROOT)}/(${[...MOVED_ENTRIES]
56
+ .sort((left, right) => right.length - left.length)
57
+ .map(escape)
58
+ .join('|')})(?![A-Za-z0-9._-])`,
59
+ 'g',
60
+ )
61
+
62
+ function escape(value: string): string {
63
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
64
+ }
65
+
66
+ /**
67
+ * Files whose content is left alone entirely.
68
+ *
69
+ * The changelog is release history, whose entries record what shipped while the
70
+ * records were still under the old root. An eval result is a transcript of the
71
+ * paths a session actually opened. Rewriting either makes it testify to
72
+ * something that never happened.
73
+ *
74
+ * This module and `src/record-root.ts` are the two sources that state the old
75
+ * root on purpose. Sweeping them turns every citation this expression is built
76
+ * from into its own replacement, leaving a rewriter that maps `.canon/` to
77
+ * `.canon/` and matches nothing.
78
+ *
79
+ * A test file is excluded because the fixtures that prove the old root still
80
+ * resolves have to keep building it. Rewriting one is worse than a failing
81
+ * test: the old-root case collapses into a copy of the new-root case beside it
82
+ * and keeps passing, reporting coverage for a fallback nothing exercises.
83
+ *
84
+ * A hook is the third source that states both roots on purpose. Each one guards
85
+ * on a `case` carrying an arm per root, so rewriting the old arm collapses the
86
+ * pair into two copies of the new one and shellcheck reports a pattern that can
87
+ * never match. The guard then stops firing in a project the move has not
88
+ * reached, which is silent: the index goes stale while every save succeeds.
89
+ */
90
+ const EXCLUDED_PREFIXES: readonly string[] = [
91
+ 'src/migrate/',
92
+ 'scripts/eval/result-',
93
+ '.claude/hooks/',
94
+ 'tooling/claude/seeds/.claude/hooks/',
95
+ ]
96
+
97
+ const EXCLUDED_PATHS: readonly string[] = ['CHANGELOG.md', 'src/record-root.ts']
98
+
99
+ const EXCLUDED_SUFFIXES: readonly string[] = ['.test.ts']
100
+
101
+ export function isExcludedPath(path: string): boolean {
102
+ if (EXCLUDED_PATHS.includes(path)) return true
103
+ if (EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))) return true
104
+ return EXCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix))
105
+ }
106
+
107
+ /**
108
+ * Marks a line naming the old root on purpose.
109
+ *
110
+ * Prose that dates a decision, records where a defect landed, or names the
111
+ * fallback a target still resolves through all have to keep saying `.claude/`,
112
+ * and a sweep cannot tell those from a live path. The marker sits on the line
113
+ * itself or on the one above it, which is the placement `canon-keep-retired`
114
+ * and `canon-allow-superseded` already use here.
115
+ */
116
+ const KEEP_MARKER = 'canon-keep-record-root'
117
+
118
+ function isKept(lines: readonly string[], index: number): boolean {
119
+ if (lines[index]?.includes(KEEP_MARKER)) return true
120
+ return index > 0 && (lines[index - 1]?.includes(KEEP_MARKER) ?? false)
121
+ }
122
+
123
+ /** Rewrites every unmarked citation into a moved entry. */
124
+ export function rewriteText(text: string): string {
125
+ const lines = text.split('\n')
126
+
127
+ return lines
128
+ .map((line, index) =>
129
+ isKept(lines, index)
130
+ ? line
131
+ : line.replace(CITATION, (_match, entry: string) =>
132
+ destinationPath(entry),
133
+ ),
134
+ )
135
+ .join('\n')
136
+ }
137
+
138
+ /**
139
+ * How many citations `rewriteText` would rewrite, and how many marked lines it
140
+ * left alone. The second number is what says the markers fired at all, which a
141
+ * diff cannot show because a protected line does not appear in one.
142
+ */
143
+ export function scanText(text: string): {
144
+ readonly rewritten: number
145
+ readonly kept: number
146
+ } {
147
+ const lines = text.split('\n')
148
+ let rewritten = 0
149
+ let kept = 0
150
+
151
+ for (const [index, line] of lines.entries()) {
152
+ const matches = [...line.matchAll(CITATION)].length
153
+ if (matches === 0) continue
154
+
155
+ if (isKept(lines, index)) kept += matches
156
+ else rewritten += matches
157
+ }
158
+
159
+ return { rewritten, kept }
160
+ }
161
+
162
+ export interface FolderMove {
163
+ readonly from: string
164
+ readonly to: string
165
+ }
166
+
167
+ /**
168
+ * The entries actually on disk under the old root, with where each lands.
169
+ *
170
+ * An entry already present at the destination is reported as a collision by the
171
+ * caller rather than filtered out here, since merging two record folders is a
172
+ * judgment no verb should take on a memory pen.
173
+ */
174
+ export function planFolderMoves(root: string): FolderMove[] {
175
+ return MOVED_ENTRIES.filter((entry) =>
176
+ existsSync(join(root, sourcePath(entry))),
177
+ ).map((entry) => ({ from: sourcePath(entry), to: destinationPath(entry) }))
178
+ }
179
+
180
+ /** Moves whose destination is already taken, which the verb refuses on. */
181
+ export function collisions(
182
+ root: string,
183
+ moves: readonly FolderMove[],
184
+ ): string[] {
185
+ return moves
186
+ .filter((move) => existsSync(join(root, move.to)))
187
+ .map((move) => move.to)
188
+ }
189
+
190
+ /**
191
+ * Whether a project's ignore rules already cover the new root.
192
+ *
193
+ * The gate exists because every entry being moved is ignored where it stands.
194
+ * Landing them under a root the project does not ignore publishes the memory
195
+ * pen and the groundwork trails into the next commit, which is the same harm
196
+ * the records remote gate refuses for the same payload. Reading the file rather
197
+ * than asking git keeps the answer available in a project with no commits yet.
198
+ */
199
+ export function ignoresDestination(gitignore: string): boolean {
200
+ return gitignore
201
+ .split('\n')
202
+ .map((line) => line.trim())
203
+ .some((line) => line === TO_ROOT || line === `${TO_ROOT}/`)
204
+ }
205
+
206
+ /** One tracked file, as the planner reads it. */
207
+ export interface RecordsSource {
208
+ readonly path: string
209
+ readonly text: string
210
+ }
211
+
212
+ /** One file whose citations move, carried with the text to write back. */
213
+ export interface CitationEntry {
214
+ readonly path: string
215
+ readonly text: string
216
+ readonly rewritten: number
217
+ readonly kept: number
218
+ }
219
+
220
+ export interface RecordsPlan {
221
+ readonly moves: readonly FolderMove[]
222
+ readonly collisions: readonly string[]
223
+ readonly entries: readonly CitationEntry[]
224
+ readonly excluded: readonly string[]
225
+ readonly rewritten: number
226
+ readonly kept: number
227
+ }
228
+
229
+ /**
230
+ * What the move would do, without doing it.
231
+ *
232
+ * The folder half reads disk and the citation half is pure over the sources it
233
+ * is handed, so a caller can report the whole plan and apply it from the same
234
+ * value. A file whose text does not change is dropped rather than carried as a
235
+ * no-op, which keeps the reported file count equal to what the sweep writes.
236
+ */
237
+ export function planRecordsMove(
238
+ root: string,
239
+ sources: readonly RecordsSource[],
240
+ ): RecordsPlan {
241
+ const moves = planFolderMoves(root)
242
+ const entries: CitationEntry[] = []
243
+ const excluded: string[] = []
244
+ let kept = 0
245
+
246
+ for (const source of sources) {
247
+ if (isExcludedPath(source.path)) {
248
+ // Only an excluded file that actually carries a citation is reported. The
249
+ // predicate covers every test file in the tree, so counting them all would
250
+ // report hundreds of exclusions the sweep was never going to touch and
251
+ // bury the handful a reader has to go and check by hand.
252
+ if (scanText(source.text).rewritten > 0) excluded.push(source.path)
253
+ continue
254
+ }
255
+
256
+ const counts = scanText(source.text)
257
+ kept += counts.kept
258
+ if (counts.rewritten === 0) continue
259
+
260
+ entries.push({
261
+ path: source.path,
262
+ text: rewriteText(source.text),
263
+ rewritten: counts.rewritten,
264
+ kept: counts.kept,
265
+ })
266
+ }
267
+
268
+ return {
269
+ moves,
270
+ collisions: collisions(root, moves),
271
+ entries,
272
+ excluded,
273
+ rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
274
+ kept,
275
+ }
276
+ }
package/src/paths.ts CHANGED
@@ -3,7 +3,7 @@ import { sep } from 'node:path'
3
3
  /**
4
4
  * Whether a resolved path is the directory itself or sits inside it. The
5
5
  * separator guard is the whole of it: a bare prefix test reads
6
- * `.claude/plans-archive` as living under `.claude/plans`, which is a sibling
6
+ * `.claude/plans-archive` as living under `.canon/plans`, which is a sibling
7
7
  * rather than a child and is exactly the pair the plan folders spell.
8
8
  *
9
9
  * It lives at the root rather than beside either caller because both resolve
package/src/pr/paths.ts CHANGED
@@ -66,7 +66,7 @@ const LINE_SUFFIX = /:\d+(?:-\d+)?$/
66
66
  * Whitespace separates a backticked command from a backticked path, and it is
67
67
  * the whole answer to one of the four observed false-positive classes:
68
68
  * `canon markdown audit .claude/rules --json` carries a slash and names no file.
69
- * Angle brackets answer a second, since `.claude/plans/feature-<slug>.md`
69
+ * Angle brackets answer a second, since `.canon/plans/feature-<slug>.md`
70
70
  * describes a shape rather than naming a file. A glob and a caret describe a
71
71
  * shape too, `^src/` being a grep pattern one body spelled in Key Changes, and
72
72
  * a leading anchor names something outside this repository.
@@ -5,44 +5,86 @@ import { join } from 'node:path'
5
5
  * The roots a session record folder is read at, in precedence order.
6
6
  *
7
7
  * `.canon/` wins because a tree that carries it has been migrated, and reading
8
- * `.claude/` there would answer from the copy the move left behind. A tree that
9
- * carries neither is every tree today, which is what keeps this branch a no-op
10
- * until the move lands.
8
+ * `.claude/` there would answer from the copy the move left behind. The second
9
+ * spelling is what an unmigrated project still resolves through, so it stays
10
+ * until no target reaches one, which nothing measures.
11
11
  *
12
12
  * The shape is `readStamp`'s: order the spellings, take the first that exists,
13
13
  * and stand the creation default in when none does.
14
14
  */
15
+ // canon-keep-record-root
15
16
  export const RECORD_ROOTS = ['.canon', '.claude'] as const
16
17
 
17
18
  export type RecordRoot = (typeof RECORD_ROOTS)[number]
18
19
 
19
20
  /**
20
- * The root a record folder is created at.
21
+ * The root a record folder is created at when no root carries it yet.
21
22
  *
22
- * It disagrees with the read precedence above on purpose, and the disagreement
23
- * is the whole of this branch. Creating under `.canon/` before the move would
24
- * write records to a root whose ignore line may not have reached a target yet,
25
- * and it would split one project's records across two roots with no verb able
26
- * to reconcile them. The move flips this line and nothing else.
23
+ * It agrees with the head of the read precedence, which is what the move
24
+ * changed. While the two disagreed, creation stayed at `.claude/` so a record
25
+ * could not land under a root whose ignore line had yet to reach the project.
26
+ * The ignore line ships now, so a fresh project scaffolds one root and an
27
+ * unmigrated one keeps resolving its own through the fallback above.
27
28
  */
28
- export const CREATION_ROOT: RecordRoot = '.claude'
29
+ export const CREATION_ROOT: RecordRoot = '.canon'
29
30
 
30
31
  /**
31
32
  * The deletable scratch folder, named at the spelling `.claude/` gives it.
32
33
  *
33
- * It is the one folder whose name differs by root. Inside a dotted root the
34
- * leading dot hides nothing already hidden and costs a bare `ls` that omits the
35
- * folder, so the move drops it. Every other record folder keeps its name,
36
- * `.records.git` included, where the dot marks the mechanism apart from a
37
- * payload rather than hiding it.
34
+ * It is the one folder whose name differs by root, and callers name it at the
35
+ * old spelling because `spell` is the only place the variant is decided. Inside
36
+ * a dotted root the leading dot hides nothing already hidden and costs a bare
37
+ * `ls` that omits the folder, so the move dropped it. Every other record folder
38
+ * keeps its name, `.records.git` included, where the dot marks the mechanism
39
+ * apart from a payload rather than hiding it.
38
40
  */
39
41
  export const SCRATCH = '.tmp'
40
42
 
41
43
  /** The scratch folder's name under `.canon/`. */
42
44
  const CANON_SCRATCH = 'tmp'
43
45
 
44
- /** How a root spells a folder name. Only the scratch folder differs. */
45
- function spell(root: RecordRoot, folder: string): string {
46
+ /**
47
+ * Every entry that lives under the record root, at the name `.claude/` gave it.
48
+ *
49
+ * These are the twelve ignore patterns the move to `.canon/` collapsed into one,
50
+ * so the list counts entries rather than record folders: `.records.git` is the
51
+ * backup history rather than a record, and `README.md` is a file a records pull
52
+ * writes back. `worktrees` is absent because the harness creates a worktree
53
+ * under `.claude/` and requires its target to sit there.
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.
59
+ */
60
+ export const RECORD_ENTRIES: readonly string[] = [
61
+ '.records.git',
62
+ SCRATCH,
63
+ 'README.md',
64
+ 'diagrams',
65
+ 'groundwork',
66
+ 'intake',
67
+ 'memory',
68
+ 'plans',
69
+ 'proposals',
70
+ 'review',
71
+ 'tasks',
72
+ 'teach',
73
+ ]
74
+
75
+ /** Whether a name under `.claude/` is one the record root owns. */
76
+ export function isRecordEntry(name: string): boolean {
77
+ return RECORD_ENTRIES.includes(name)
78
+ }
79
+
80
+ /**
81
+ * How a root spells a folder name. Only the scratch folder differs.
82
+ *
83
+ * Exported because the migration verb has to name a folder's destination and
84
+ * deriving it there would state the one naming variant in a second place, where
85
+ * a tree half-moved by one rule and read by the other resolves nothing.
86
+ */
87
+ export function spell(root: RecordRoot, folder: string): string {
46
88
  return root === '.canon' && folder === SCRATCH ? CANON_SCRATCH : folder
47
89
  }
48
90
 
@@ -98,21 +140,29 @@ export function recordDirs(
98
140
  )
99
141
  }
100
142
 
101
- /** Where a record folder is created, which is the creation default always. */
102
- export function creationDir(
143
+ /**
144
+ * The creation destination relative to the project root, which is the form a
145
+ * message displays and an option default carries.
146
+ *
147
+ * It resolves the root the same way a read does rather than pinning the
148
+ * creation default, because the two stopped disagreeing when the move flipped
149
+ * `CREATION_ROOT`. Pinning it now would write a new record to `.canon/` in a
150
+ * project whose board is still `.claude/`, which splits one project's records
151
+ * across two roots and leaves every reader answering from the half the writer
152
+ * did not use.
153
+ *
154
+ * The root is a parameter rather than the working directory, because two of the
155
+ * callers spell a path they later join onto a target root. Resolving against
156
+ * the working directory there would read one project to answer about another.
157
+ */
158
+ export function creationRel(
103
159
  root: string,
104
160
  folder: string,
105
161
  ...rest: string[]
106
162
  ): string {
107
- return join(root, CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
108
- }
163
+ const at = rootOf(root, folder)
109
164
 
110
- /**
111
- * The creation destination relative to the project root, which is the form a
112
- * message displays and an option default carries.
113
- */
114
- export function creationRel(folder: string, ...rest: string[]): string {
115
- return join(CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
165
+ return join(at, spell(at, folder), ...rest)
116
166
  }
117
167
 
118
168
  /**