@erclx/canon 4.6.1 → 4.7.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 (132) 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 +1 -1
  49. package/docs/agents/commands.md +5 -2
  50. package/docs/agents/demo.md +1 -1
  51. package/docs/agents/indexes.md +1 -1
  52. package/docs/agents/install-and-sync.md +1 -1
  53. package/docs/agents/intake.md +1 -1
  54. package/docs/agents/key-changes.md +1 -1
  55. package/docs/agents/records.md +12 -9
  56. package/docs/agents/restated.md +2 -2
  57. package/docs/agents/rule-citations.md +1 -1
  58. package/docs/agents/sandbox.md +1 -1
  59. package/docs/agents/tasks.md +6 -6
  60. package/docs/agents/teach.md +4 -4
  61. package/docs/ai-workflow.md +26 -19
  62. package/docs/operating-model.md +3 -3
  63. package/docs/target-projects.md +22 -2
  64. package/docs/visual-design-workflow.md +1 -1
  65. package/governance/rules/claude/555-tasks.md +3 -3
  66. package/governance/rules/claude/556-groundwork.md +2 -2
  67. package/governance/rules/claude/557-intake.md +2 -2
  68. package/governance/rules/claude/558-plan.md +3 -3
  69. package/governance/rules/claude/559-memory.md +2 -2
  70. package/governance/rules/claude/560-diagrams.md +3 -3
  71. package/governance/rules/claude/561-teach.md +2 -2
  72. package/governance/rules/claude/562-session.md +2 -2
  73. package/governance/rules/core/035-tasks.md +2 -2
  74. package/governance/rules/core/045-memory.md +3 -3
  75. package/governance/rules/core/055-scratch.md +2 -1
  76. package/governance/rules/core/085-worktrees.md +1 -1
  77. package/package.json +1 -1
  78. package/scripts/core/check-ignore-parity.sh +24 -51
  79. package/scripts/core/install-check.sh +4 -4
  80. package/scripts/core/snapshot.sh +1 -1
  81. package/scripts/manage-sandbox.sh +1 -1
  82. package/scripts/tooling/verify.sh +2 -2
  83. package/snippets/claude/decision-memo.md +1 -1
  84. package/snippets/claude/feature-recap.md +1 -1
  85. package/src/audits/catalog.ts +1 -1
  86. package/src/claude/cases/all.ts +1 -1
  87. package/src/claude/seeds.ts +13 -2
  88. package/src/claude/skills-rank.ts +1 -1
  89. package/src/cli.ts +4 -4
  90. package/src/commands/demo.ts +1 -1
  91. package/src/commands/design.ts +1 -1
  92. package/src/commands/feedback.ts +5 -5
  93. package/src/commands/gov.ts +17 -6
  94. package/src/commands/intake.ts +1 -1
  95. package/src/commands/migrate.ts +173 -2
  96. package/src/commands/pr.ts +1 -1
  97. package/src/commands/records.ts +1 -1
  98. package/src/commands/serve.ts +2 -2
  99. package/src/commands/slides.ts +1 -1
  100. package/src/commands/tasks.ts +5 -5
  101. package/src/commands/teach.ts +1 -1
  102. package/src/context/audit.ts +1 -1
  103. package/src/demo/compile.ts +1 -1
  104. package/src/gov/citations.ts +1 -1
  105. package/src/gov/restated.ts +3 -3
  106. package/src/migrate/apply.ts +67 -1
  107. package/src/migrate/records.ts +276 -0
  108. package/src/paths.ts +1 -1
  109. package/src/pr/paths.ts +1 -1
  110. package/src/record-root.ts +77 -27
  111. package/src/records/backup.ts +71 -17
  112. package/src/records/validate.ts +1 -1
  113. package/src/sync/layout.ts +11 -2
  114. package/src/tasks/answers.ts +1 -1
  115. package/src/tasks/archive.ts +20 -3
  116. package/standards/diagrams.md +4 -4
  117. package/standards/glossary.md +1 -1
  118. package/standards/groundwork.md +3 -3
  119. package/standards/index.md +2 -2
  120. package/standards/intake.md +3 -3
  121. package/standards/memory.md +2 -2
  122. package/standards/plan.md +3 -3
  123. package/standards/session.md +2 -2
  124. package/standards/standard.md +1 -1
  125. package/standards/tasks.md +9 -9
  126. package/standards/teach.md +2 -2
  127. package/standards/versioning.md +1 -1
  128. package/tooling/base/configs/.husky/post-merge +1 -1
  129. package/tooling/base/reference.md +1 -1
  130. package/tooling/claude/manifest.toml +13 -3
  131. package/tooling/claude/reference.md +28 -15
  132. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +8 -8
@@ -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
  /**
@@ -2,28 +2,29 @@ import { existsSync } from 'node:fs'
2
2
  import { join, relative, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
- import { recordRoot } from '@/record-root'
5
+ import { RECORD_ROOTS, recordRoot } from '@/record-root'
6
6
 
7
7
  /**
8
8
  * The folders a backup carries, relative to the record root `workTree` resolves
9
- * rather than to `.claude/` specifically, since the same nine names sit under
10
- * whichever root a tree holds. Most of them are the `# Claude` group the claude
11
- * manifest ships, minus three: the scratch folder, which is defined as deletable
12
- * without loss, `worktrees/`, whose contents belong to the enclosing repository
13
- * already, and `.records.git/`, which is the history the rest are pushed into.
14
- * The list is spelled out rather than read off that group so adding an ignore
15
- * entry cannot silently enlarge the payload.
9
+ * rather than to either root specifically, since the same nine names sit under
10
+ * whichever one a tree holds.
16
11
  *
17
- * `diagrams` is the one name the manifest group does not carry, so a target
18
- * tracks it where this repository ignores it. That is the second reason to
19
- * spell the list out: the manifest bounds it rather than producing it, and no
20
- * count taken off that group matches this one. `scripts/core/check-ignore-parity.sh`
21
- * compares the two lists and holds the reason `diagrams` stays apart, so the
22
- * one difference is enforced rather than assumed.
12
+ * Nothing bounds this list any more, and the move is what took the bound away.
13
+ * The claude manifest used to ship a folder apiece, so the `# Claude` group
14
+ * named a superset this could be read against; it ships one `.canon/` root
15
+ * entry now and names no folder at all. Spelling the list out is therefore the
16
+ * whole of the protection rather than half of it, since a record folder added
17
+ * under `.canon/` is ignored the moment it exists and enters no payload until
18
+ * a name is written here.
23
19
  *
24
- * The manifest group is the one this reads rather than the enclosing
25
- * repository's own `.gitignore`, which spreads the same entries across two
26
- * headers and carries `.claude/README.md` that no target receives.
20
+ * Three counts describe this surface and each is right about a different
21
+ * question, so they are stated apart rather than reconciled. Nine is what a
22
+ * disk loss would take, which is this list. Eleven is what sat under `.claude/`
23
+ * as an ignored folder before the move, which adds the scratch folder that is
24
+ * deletable without loss and `worktrees/`, whose contents belong to the
25
+ * enclosing repository already. Twelve is what the move relocated, which counts
26
+ * ignore entries rather than folders: the eleven less `worktrees/`, which stayed,
27
+ * plus `.records.git/` and the `README.md` a records pull writes back.
27
28
  *
28
29
  * Each entry is a top-level record folder and every archive sits inside the one
29
30
  * it archives, so the three former archive entries are covered by their parents
@@ -103,6 +104,7 @@ const COMMIT_IDENTITY = [
103
104
  ]
104
105
 
105
106
  export const BACKUP_REFUSALS = [
107
+ 'split-roots',
106
108
  'no-repository',
107
109
  'no-remote',
108
110
  'remote-unreadable',
@@ -297,6 +299,52 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
297
299
  return url
298
300
  }
299
301
 
302
+ /**
303
+ * Backed folders sitting at a record root other than the one `workTree` chose.
304
+ *
305
+ * A half-migrated tree is the case this reads. `recordRoot` answers for the
306
+ * whole tree on the first root that exists, so one folder left behind by a move
307
+ * that failed partway is absent from the work tree while the records index
308
+ * still names it. `scopedFolders` puts it in the pathspec on the index side,
309
+ * `add -A` stages its deletion, and the push drops it from the remote, which is
310
+ * the harm `RETIRED_FOLDERS` documents reached by a different route.
311
+ *
312
+ * Both verbs refuse on it rather than only `push`. A `pull` resets the resolved
313
+ * work tree hard and leaves the stranded copy beside it, which is not a loss but
314
+ * is a tree where two roots disagree and neither is wrong.
315
+ *
316
+ * Only `BACKED_FOLDERS` is read. The scratch folder is deletable by definition
317
+ * and `worktrees/` belongs to the enclosing repository, so neither stranded
318
+ * anywhere costs a record.
319
+ */
320
+ function strandedFolders(root: string): string[] {
321
+ const resolved = resolve(workTree(root))
322
+
323
+ return RECORD_ROOTS.flatMap((candidate) => {
324
+ const dir = join(root, candidate)
325
+ if (resolve(dir) === resolved) return []
326
+
327
+ return BACKED_FOLDERS.filter((folder) => existsSync(join(dir, folder))).map(
328
+ (folder) => join(candidate, folder),
329
+ )
330
+ })
331
+ }
332
+
333
+ /** Refuses a tree whose records sit under both roots, naming what to move. */
334
+ function refuseSplitRoots(root: string): BackupRefused | undefined {
335
+ const stranded = strandedFolders(root)
336
+ if (stranded.length === 0) return undefined
337
+
338
+ return refuse(
339
+ 'split-roots',
340
+ [
341
+ `Records sit under both roots, so ${relative(root, workTree(root))} is not the whole set and a push would stage the rest as deleted:`,
342
+ ...stranded.map((path) => ` ${path}`),
343
+ 'Finish the move with canon migrate records --write, or put these back beside the others by hand.',
344
+ ].join('\n'),
345
+ )
346
+ }
347
+
300
348
  /**
301
349
  * The subset of the backed and retired names a pathspec can name: on disk, or
302
350
  * already in the records index.
@@ -345,6 +393,9 @@ function countLines(text: string): number {
345
393
  * that commit on one disk, which is the state the whole verb exists to end.
346
394
  */
347
395
  export async function pushRecords(root: string): Promise<PushOutcome> {
396
+ const split = refuseSplitRoots(root)
397
+ if (split) return split
398
+
348
399
  const remote = await resolveRemote(root)
349
400
  if (typeof remote !== 'string') return remote
350
401
 
@@ -406,6 +457,9 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
406
457
  * resolves by pushing first or by moving the local folders aside.
407
458
  */
408
459
  export async function pullRecords(root: string): Promise<PullOutcome> {
460
+ const split = refuseSplitRoots(root)
461
+ if (split) return split
462
+
409
463
  const remote = await resolveRemote(root)
410
464
  if (typeof remote !== 'string') return remote
411
465
 
@@ -977,7 +977,7 @@ export function governedPaths(statement: string): string[] {
977
977
  /**
978
978
  * The words a governed path offers a filename. Each segment gives its own word
979
979
  * and, where it carries a prefix or a placeholder, the parts either side of a
980
- * hyphen, so `.claude/tasks/session-<slug>.md` offers `tasks` and `session`.
980
+ * hyphen, so `.canon/tasks/session-<slug>.md` offers `tasks` and `session`.
981
981
  *
982
982
  * A dotted segment gives nothing. It names the folder holding the artifact
983
983
  * rather than the artifact, and a standard named for it would pass this check