@erclx/canon 4.86.0 → 4.88.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 (52) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +6 -6
  3. package/claude/skills/canon-feedback-file/SKILL.md +2 -2
  4. package/claude/skills/design-extract/SKILL.md +3 -3
  5. package/claude/skills/docs-fold/SKILL.md +6 -6
  6. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  7. package/claude/skills/draft-diagram/SKILL.md +2 -2
  8. package/claude/skills/draft-slides/SKILL.md +1 -1
  9. package/claude/skills/git-followup/SKILL.md +1 -1
  10. package/claude/skills/git-pr/SKILL.md +8 -8
  11. package/claude/skills/git-split/SKILL.md +19 -19
  12. package/claude/skills/memory-capture/SKILL.md +2 -2
  13. package/claude/skills/memory-review/SKILL.md +2 -2
  14. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  15. package/claude/skills/review-address/SKILL.md +11 -11
  16. package/claude/skills/review-branch/SKILL.md +3 -3
  17. package/claude/skills/review-pr/SKILL.md +2 -2
  18. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  19. package/claude/skills/role-orchestrator/scripts/poll.sh +1 -1
  20. package/claude/skills/sketch-design/SKILL.md +4 -4
  21. package/claude/skills/teach-workspace/SKILL.md +2 -2
  22. package/claude/skills/ui-test/REQUIREMENT.md +1 -1
  23. package/claude/skills/ui-test/SKILL.md +2 -2
  24. package/docs/agents/commands.md +99 -96
  25. package/docs/agents/design-board.md +7 -7
  26. package/docs/agents/records.md +27 -0
  27. package/docs/agents/sandbox.md +1 -1
  28. package/docs/workflow/ai-workflow.md +5 -3
  29. package/docs/workflow/visual-design-workflow.md +1 -1
  30. package/governance/rules/core/055-scratch.md +1 -0
  31. package/package.json +1 -1
  32. package/scripts/tooling/verify.sh +2 -2
  33. package/src/claude/skills-headings.ts +1 -1
  34. package/src/cli.ts +1 -1
  35. package/src/commands/design.ts +3 -3
  36. package/src/commands/feedback.ts +12 -12
  37. package/src/commands/migrate.ts +36 -13
  38. package/src/commands/records.ts +159 -0
  39. package/src/commands/slides.ts +2 -2
  40. package/src/design/board.ts +17 -10
  41. package/src/migrate/evidence-ordinal.ts +79 -0
  42. package/src/migrate/record-layout.ts +328 -75
  43. package/src/migrate/scratch-evidence.ts +60 -35
  44. package/src/record-root.ts +3 -0
  45. package/src/records/prune.ts +488 -0
  46. package/src/records/size.ts +24 -1
  47. package/standards/publish.md +1 -1
  48. package/standards/skill.md +1 -1
  49. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -2
  50. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
  51. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +2 -2
  52. package/tooling/claude/seeds/CLAUDE.md +2 -6
@@ -1,62 +1,156 @@
1
1
  /**
2
- * The memory pen's review receipts and retired entries, folded under the pen
3
- * itself rather than sitting in three separate record folders.
2
+ * The record folder layout, moved one intake batch at a time.
4
3
  *
5
- * `.canon/review/memory/` and `.canon/tmp/memory-archive/` move to
6
- * `.canon/memory/review/` and `.canon/memory/archive/`, the second one backed
7
- * for the first time: `canon records push` carries every top-level `.canon/`
8
- * entry except `EXCLUDED_ENTRIES`, and `tmp/` is one of the three names that
9
- * set excludes. `canon/ARCHITECTURE.md`'s "A durable record is named for what
10
- * it is, not for how long it lives" already names the cost this closes: two
11
- * surfaces both named for memory archived to two different places.
4
+ * Batch 2 folded the memory pen's review receipts and retired entries under
5
+ * the pen itself. Batch 3 leaves `.canon/review/` holding only reviews: toolkit
6
+ * feedback moves to `.canon/feedback/`, option captures to `.canon/picks/`,
7
+ * claim-backing folders to a numbered `.canon/evidence/`, renders rebuilt from
8
+ * committed sources to `.canon/tmp/render/`, a branch report flattens to
9
+ * `review/branch-<slug>.md`, and a flat checklist joins the `tmp/handoff/ui-checklist/`
10
+ * handoff folder. `canon records push` carries every top-level `.canon/`
11
+ * entry except `EXCLUDED_ENTRIES`, so the new root folders are backed with no
12
+ * list edit, and `tmp/render/` is deliberately not.
12
13
  *
13
14
  * The move and the citation repoint follow `scratch-evidence.ts`'s shape: a
14
15
  * dry run by default, `--write` to apply, a collision refusal, the
15
16
  * `canon-keep-record-root` marker honored, and archives swept on purpose,
16
17
  * since an archived receipt still cites the row it retired.
17
18
  *
18
- * `moves` is a data table rather than one function per pair, so a later
19
- * intake batch in the same folder-layout group appends an entry instead of
20
- * restructuring the module.
19
+ * `RECORD_LAYOUT_MOVES` is a data table rather than one function per pair, so
20
+ * a later batch appends a row instead of restructuring the module. A row that
21
+ * cannot name its destination ahead of time, being the pick-or-evidence split,
22
+ * names the rule that derives it from what is on disk, never a list of slugs,
23
+ * since every target holds folders of its own.
21
24
  */
22
25
 
23
26
  import { existsSync, readdirSync } from 'node:fs'
24
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
27
+ import {
28
+ mkdir,
29
+ readdir,
30
+ readFile,
31
+ rename,
32
+ rmdir,
33
+ writeFile,
34
+ } from 'node:fs/promises'
25
35
  import { dirname, join } from 'node:path'
36
+ import {
37
+ byFirstAppearance,
38
+ nextOrdinal,
39
+ numberedName,
40
+ slugOf,
41
+ } from '@/migrate/evidence-ordinal'
26
42
  import { presentFolders } from '@/records/backup'
27
43
  import { recordDir, SCRATCH, spell, type RecordRoot } from '@/record-root'
28
44
 
29
45
  const CANON: RecordRoot = '.canon'
30
46
  const CLAUDE: RecordRoot = '.claude'
31
47
 
32
- export interface RecordLayoutMove {
48
+ /** A whole folder moving to a fixed destination. */
49
+ export interface FolderLayoutMove {
50
+ readonly kind: 'folder'
33
51
  readonly from: readonly string[]
34
52
  readonly to: readonly string[]
35
53
  }
36
54
 
37
55
  /**
38
- * Every folder this migration moves, as a record-relative path on each side.
56
+ * Every file directly inside `from` whose name starts with `prefix`, each
57
+ * landing in `to` with that prefix swapped for `renamed`. `prune` removes
58
+ * `from` once the moves leave it empty.
59
+ */
60
+ export interface FilesLayoutMove {
61
+ readonly kind: 'files'
62
+ readonly from: readonly string[]
63
+ readonly prefix: string
64
+ readonly to: readonly string[]
65
+ readonly renamed: string
66
+ readonly prune: boolean
67
+ }
68
+
69
+ /**
70
+ * Every folder directly inside `from`, sent to `picks` when it holds what a
71
+ * pick writes and to `evidence` under the next ordinal otherwise.
72
+ */
73
+ export interface SplitLayoutMove {
74
+ readonly kind: 'split'
75
+ readonly from: readonly string[]
76
+ readonly picks: readonly string[]
77
+ readonly evidence: readonly string[]
78
+ }
79
+
80
+ export type RecordLayoutMove =
81
+ | FolderLayoutMove
82
+ | FilesLayoutMove
83
+ | SplitLayoutMove
84
+
85
+ const RENDER = [SCRATCH, 'render'] as const
86
+
87
+ /**
88
+ * Every move this migration makes, as record-relative paths.
39
89
  *
40
- * Derived from the plan's own mapping: the review folder's memory receipts
41
- * fold into the pen at `memory/review/`, and the scratch archive folds in at
42
- * `memory/archive/`. A later batch in the same intake group appends here
43
- * rather than adding a second table.
90
+ * The first two rows are batch 2's. The rest are batch 3's, in the order the
91
+ * intake's review-folder pass listed them. A later batch appends here rather
92
+ * than adding a second table.
44
93
  */
45
94
  export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
46
- { from: ['review', 'memory'], to: ['memory', 'review'] },
47
- { from: [SCRATCH, 'memory-archive'], to: ['memory', 'archive'] },
95
+ { kind: 'folder', from: ['review', 'memory'], to: ['memory', 'review'] },
96
+ {
97
+ kind: 'folder',
98
+ from: [SCRATCH, 'memory-archive'],
99
+ to: ['memory', 'archive'],
100
+ },
101
+ { kind: 'folder', from: ['review', 'feedback'], to: ['feedback'] },
102
+ { kind: 'folder', from: ['review', 'design'], to: [...RENDER, 'design'] },
103
+ { kind: 'folder', from: ['review', 'board'], to: [...RENDER, 'board'] },
104
+ { kind: 'folder', from: ['review', 'slides'], to: [...RENDER, 'slides'] },
105
+ { kind: 'folder', from: ['review', 'diagrams'], to: [...RENDER, 'diagrams'] },
106
+ {
107
+ kind: 'folder',
108
+ from: ['review', 'references'],
109
+ to: ['picks', 'references'],
110
+ },
111
+ {
112
+ kind: 'files',
113
+ from: ['review', 'branch'],
114
+ prefix: 'review-',
115
+ to: ['review'],
116
+ renamed: 'branch-',
117
+ prune: true,
118
+ },
119
+ {
120
+ kind: 'files',
121
+ from: ['review'],
122
+ prefix: 'ui-checklist-',
123
+ to: [SCRATCH, 'handoff', 'ui-checklist'],
124
+ renamed: '',
125
+ prune: false,
126
+ },
127
+ {
128
+ kind: 'split',
129
+ from: ['review', 'evidence'],
130
+ picks: ['picks'],
131
+ evidence: ['evidence'],
132
+ },
48
133
  ]
49
134
 
135
+ function resolveRecord(root: string, segments: readonly string[]): string {
136
+ const [folder, ...rest] = segments
137
+ return recordDir(root, folder, ...rest)
138
+ }
139
+
50
140
  /** Where a move's source sits today. */
51
141
  export function sourcePath(root: string, move: RecordLayoutMove): string {
52
- const [folder, ...rest] = move.from
53
- return recordDir(root, folder, ...rest)
142
+ return resolveRecord(root, move.from)
54
143
  }
55
144
 
56
- /** Where it lands after, always under whichever root `memory/` resolves at. */
57
- export function destinationPath(root: string, move: RecordLayoutMove): string {
58
- const [folder, ...rest] = move.to
59
- return recordDir(root, folder, ...rest)
145
+ /** Where a folder row lands, under whichever root its head folder resolves at. */
146
+ export function destinationPath(root: string, move: FolderLayoutMove): string {
147
+ return resolveRecord(root, move.to)
148
+ }
149
+
150
+ /** A record-relative path as a citation spells it at `.canon/`, no trailing slash. */
151
+ function canonCitation(segments: readonly string[]): string {
152
+ const [head, ...rest] = segments
153
+ return ['.canon', spell(CANON, head), ...rest].join('/')
60
154
  }
61
155
 
62
156
  function escape(value: string): string {
@@ -64,10 +158,11 @@ function escape(value: string): string {
64
158
  }
65
159
 
66
160
  /**
67
- * The prefixes a citation of a move's source is spelled with: absolute at
68
- * either record root, spelled the way each root spells the leading segment,
69
- * and relative one and two directories up, in both spellings, since a
70
- * relative citation carries no root of its own to read the spelling from.
161
+ * The prefixes a citation of a source is spelled with, each ending in a
162
+ * slash: absolute at either record root, spelled the way each root spells the
163
+ * leading segment, and relative one and two directories up, in both
164
+ * spellings, since a relative citation carries no root of its own to read the
165
+ * spelling from.
71
166
  */
72
167
  function oldPrefixes(from: readonly string[]): readonly string[] {
73
168
  const [head, ...rest] = from
@@ -86,24 +181,20 @@ function oldPrefixes(from: readonly string[]): readonly string[] {
86
181
  return [...prefixes]
87
182
  }
88
183
 
89
- function citationPattern(move: RecordLayoutMove): RegExp {
90
- const alternation = oldPrefixes(move.from).map(escape).join('|')
91
- return new RegExp(`(?:${alternation})`, 'g')
184
+ /** Matches a citation of `from` followed by `tail`, a pattern source. */
185
+ function citationPattern(from: readonly string[], tail: string): RegExp {
186
+ const alternation = oldPrefixes(from).map(escape).join('|')
187
+ return new RegExp(`(?:${alternation})${tail}`, 'g')
92
188
  }
93
189
 
190
+ /** Rejects a following name character, so one slug never swallows a sibling. */
191
+ const NAME_BOUNDARY = '(?![A-Za-z0-9._-])'
192
+
94
193
  interface Rewrite {
95
194
  readonly pattern: RegExp
96
195
  readonly destination: string
97
196
  }
98
197
 
99
- /** One rewrite per move, paired with its citation pattern. */
100
- function buildRewrites(moves: readonly RecordLayoutMove[]): readonly Rewrite[] {
101
- return moves.map((move) => ({
102
- pattern: citationPattern(move),
103
- destination: `.canon/${move.to.join('/')}/`,
104
- }))
105
- }
106
-
107
198
  /**
108
199
  * Marks a line naming a moved path on purpose, the same marker
109
200
  * `scratch-evidence.ts` and `records.ts` read: on the line itself or on the
@@ -200,10 +291,13 @@ export async function readRecordLayoutCorpus(
200
291
  return sources
201
292
  }
202
293
 
203
- export interface FolderMove {
294
+ export type Classification = 'pick' | 'evidence'
295
+
296
+ export interface PathMove {
204
297
  readonly move: RecordLayoutMove
205
298
  readonly from: string
206
299
  readonly to: string
300
+ readonly classified?: Classification
207
301
  }
208
302
 
209
303
  export interface CitationEntry {
@@ -213,11 +307,12 @@ export interface CitationEntry {
213
307
  }
214
308
 
215
309
  export interface RecordLayoutPlan {
216
- readonly moves: readonly FolderMove[]
310
+ readonly moves: readonly PathMove[]
217
311
  readonly collisions: readonly string[]
218
312
  readonly entries: readonly CitationEntry[]
219
313
  readonly rewritten: number
220
314
  readonly strays: readonly string[]
315
+ readonly prune: readonly string[]
221
316
  }
222
317
 
223
318
  const STRAY_RECEIPT_PATTERN = /^memory-review-.*\.md$/
@@ -241,48 +336,195 @@ export function strayReceipts(root: string): string[] {
241
336
  .sort()
242
337
  }
243
338
 
339
+ function entriesOf(dir: string, isDirectory: boolean): string[] {
340
+ if (!existsSync(dir)) return []
341
+
342
+ return readdirSync(dir, { withFileTypes: true })
343
+ .filter((entry) => entry.isDirectory() === isDirectory)
344
+ .map((entry) => entry.name)
345
+ .sort()
346
+ }
347
+
348
+ /**
349
+ * What `draft-and-pick` Step 6 and `sketch-design` write into the folder they
350
+ * archive a choice to: a capture per arm, named `arm-<id>`, and a design
351
+ * handoff. A folder carrying neither is evidence.
352
+ */
353
+ const PICK_FILE = /^(?:arm-[^.]+\..+|design-handoff\.md)$/
354
+
355
+ function classify(dir: string): Classification {
356
+ return entriesOf(dir, false).some((name) => PICK_FILE.test(name))
357
+ ? 'pick'
358
+ : 'evidence'
359
+ }
360
+
361
+ interface RowPlan {
362
+ readonly moves: PathMove[]
363
+ readonly collisions: string[]
364
+ readonly rewrites: Rewrite[]
365
+ readonly prune: string[]
366
+ }
367
+
368
+ function planFolderRow(
369
+ root: string,
370
+ move: FolderLayoutMove,
371
+ claimed: Set<string>,
372
+ ): RowPlan {
373
+ const from = sourcePath(root, move)
374
+ const to = destinationPath(root, move)
375
+
376
+ if (existsSync(from) && (existsSync(to) || claimed.has(to))) {
377
+ return { moves: [], collisions: [to], rewrites: [], prune: [] }
378
+ }
379
+
380
+ const rewrites = [
381
+ {
382
+ pattern: citationPattern(move.from, ''),
383
+ destination: `${canonCitation(move.to)}/`,
384
+ },
385
+ ]
386
+ if (!existsSync(from))
387
+ return { moves: [], collisions: [], rewrites, prune: [] }
388
+
389
+ claimed.add(to)
390
+ return { moves: [{ move, from, to }], collisions: [], rewrites, prune: [] }
391
+ }
392
+
393
+ /**
394
+ * A files row moves whole or not at all: one occupied destination refuses
395
+ * every file in the row, since the citation rewrite matches the shared prefix
396
+ * rather than each name.
397
+ */
398
+ function planFilesRow(
399
+ root: string,
400
+ move: FilesLayoutMove,
401
+ claimed: Set<string>,
402
+ ): RowPlan {
403
+ const from = sourcePath(root, move)
404
+ const toDir = resolveRecord(root, move.to)
405
+ const names = entriesOf(from, false).filter((name) =>
406
+ name.startsWith(move.prefix),
407
+ )
408
+
409
+ const moves = names.map((name) => ({
410
+ move,
411
+ from: join(from, name),
412
+ to: join(toDir, `${move.renamed}${name.slice(move.prefix.length)}`),
413
+ }))
414
+ const collisions = moves
415
+ .map((candidate) => candidate.to)
416
+ .filter((to) => existsSync(to) || claimed.has(to))
417
+
418
+ if (collisions.length > 0) {
419
+ return { moves: [], collisions, rewrites: [], prune: [] }
420
+ }
421
+
422
+ for (const candidate of moves) claimed.add(candidate.to)
423
+
424
+ return {
425
+ moves,
426
+ collisions: [],
427
+ rewrites: [
428
+ {
429
+ pattern: citationPattern(move.from, escape(move.prefix)),
430
+ destination: `${canonCitation(move.to)}/${move.renamed}`,
431
+ },
432
+ ],
433
+ prune: move.prune && moves.length > 0 ? [from] : [],
434
+ }
435
+ }
436
+
244
437
  /**
245
- * Every mapped move found on disk, with its destination, refusing a move
246
- * whose destination is already occupied rather than merging into it.
438
+ * Sends each folder to its derived destination. Picks keep their slug.
439
+ * Evidence folders take ordinals in order of first appearance, continuing past
440
+ * the highest ordinal `evidence/` already holds, and a slug already numbered
441
+ * there refuses rather than taking a second number.
247
442
  */
248
- export function planFolderMoves(root: string): {
249
- moves: FolderMove[]
250
- collisions: string[]
251
- } {
252
- const moves: FolderMove[] = []
253
- const collisions: string[] = []
254
-
255
- for (const move of RECORD_LAYOUT_MOVES) {
256
- const from = sourcePath(root, move)
257
- if (!existsSync(from)) continue
258
-
259
- const to = destinationPath(root, move)
260
- if (existsSync(to)) {
261
- collisions.push(to)
443
+ function planSplitRow(
444
+ root: string,
445
+ move: SplitLayoutMove,
446
+ claimed: Set<string>,
447
+ ): RowPlan {
448
+ const from = sourcePath(root, move)
449
+ const evidenceDir = resolveRecord(root, move.evidence)
450
+ const existing = entriesOf(evidenceDir, true)
451
+ let next = nextOrdinal(existing)
452
+
453
+ const folders = entriesOf(from, true).map((name) => ({
454
+ name,
455
+ dir: join(from, name),
456
+ }))
457
+ const picks = folders.filter(({ dir }) => classify(dir) === 'pick')
458
+ const evidence = byFirstAppearance(
459
+ folders.filter(({ dir }) => classify(dir) === 'evidence'),
460
+ )
461
+
462
+ const plan: RowPlan = { moves: [], collisions: [], rewrites: [], prune: [] }
463
+
464
+ const accept = (
465
+ name: string,
466
+ dir: string,
467
+ segments: readonly string[],
468
+ classified: Classification,
469
+ ): void => {
470
+ const to = resolveRecord(root, segments)
471
+ claimed.add(to)
472
+ plan.moves.push({ move, from: dir, to, classified })
473
+ plan.rewrites.push({
474
+ pattern: citationPattern(move.from, `${escape(name)}${NAME_BOUNDARY}`),
475
+ destination: canonCitation(segments),
476
+ })
477
+ }
478
+
479
+ for (const { name, dir } of picks) {
480
+ const segments = [...move.picks, name]
481
+ const to = resolveRecord(root, segments)
482
+ if (existsSync(to) || claimed.has(to)) plan.collisions.push(to)
483
+ else accept(name, dir, segments, 'pick')
484
+ }
485
+
486
+ for (const { name, dir } of evidence) {
487
+ const numbered = existing.find((entry) => slugOf(entry) === name)
488
+ if (numbered !== undefined) {
489
+ plan.collisions.push(join(evidenceDir, numbered))
262
490
  continue
263
491
  }
264
492
 
265
- moves.push({ move, from, to })
493
+ accept(name, dir, [...move.evidence, numberedName(next, name)], 'evidence')
494
+ next += 1
266
495
  }
267
496
 
268
- return { moves, collisions }
497
+ return plan
498
+ }
499
+
500
+ function planRow(
501
+ root: string,
502
+ move: RecordLayoutMove,
503
+ claimed: Set<string>,
504
+ ): RowPlan {
505
+ switch (move.kind) {
506
+ case 'folder':
507
+ return planFolderRow(root, move, claimed)
508
+ case 'files':
509
+ return planFilesRow(root, move, claimed)
510
+ case 'split':
511
+ return planSplitRow(root, move, claimed)
512
+ }
269
513
  }
270
514
 
271
515
  /**
272
516
  * What the migration would do, without doing it. Pure over the sources it is
273
- * handed, so a caller reports and applies from the same value. A file whose
274
- * text does not change is dropped.
517
+ * handed apart from reading the tree it plans against, so a caller reports
518
+ * and applies from the same value. A file whose text does not change is
519
+ * dropped.
275
520
  */
276
521
  export function planRecordLayout(
277
522
  root: string,
278
523
  sources: readonly RecordLayoutSource[],
279
524
  ): RecordLayoutPlan {
280
- const { moves, collisions } = planFolderMoves(root)
281
- const collidedDestinations = new Set(collisions)
282
- const covered = RECORD_LAYOUT_MOVES.filter(
283
- (move) => !collidedDestinations.has(destinationPath(root, move)),
284
- )
285
- const rewrites = buildRewrites(covered)
525
+ const claimed = new Set<string>()
526
+ const rows = RECORD_LAYOUT_MOVES.map((move) => planRow(root, move, claimed))
527
+ const rewrites = rows.flatMap((row) => row.rewrites)
286
528
  const entries: CitationEntry[] = []
287
529
 
288
530
  for (const source of sources) {
@@ -293,11 +535,12 @@ export function planRecordLayout(
293
535
  }
294
536
 
295
537
  return {
296
- moves,
297
- collisions,
538
+ moves: rows.flatMap((row) => row.moves),
539
+ collisions: rows.flatMap((row) => row.collisions),
298
540
  entries,
299
541
  rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
300
542
  strays: strayReceipts(root),
543
+ prune: rows.flatMap((row) => row.prune),
301
544
  }
302
545
  }
303
546
 
@@ -307,14 +550,22 @@ export interface RecordLayoutResult {
307
550
  readonly failed: readonly string[]
308
551
  }
309
552
 
553
+ async function removeIfEmpty(dir: string): Promise<void> {
554
+ const left = await readdir(dir).catch(() => undefined)
555
+ if (left === undefined || left.length > 0) return
556
+
557
+ await rmdir(dir).catch(() => undefined)
558
+ }
559
+
310
560
  /**
311
- * Writes the plan: every citation rewrite first, then every folder move.
561
+ * Writes the plan: every citation rewrite first, then every move, then the
562
+ * folders a files row emptied.
312
563
  *
313
564
  * A citation can sit inside a file the plan is about to move, since the walk
314
565
  * carries archives on purpose and a receipt can cite the row it retired.
315
566
  * Rewriting first is what keeps that write landing on a path that still
316
- * exists: renaming the folder first would send `writeFile` at the pre-move
317
- * path into a directory `rename` already cleared.
567
+ * exists: moving first would send `writeFile` at the pre-move path into a
568
+ * directory `rename` already cleared.
318
569
  */
319
570
  export async function applyRecordLayout(
320
571
  plan: RecordLayoutPlan,
@@ -342,5 +593,7 @@ export async function applyRecordLayout(
342
593
  else failed.push(move.from)
343
594
  }
344
595
 
596
+ for (const dir of plan.prune) await removeIfEmpty(dir)
597
+
345
598
  return { moved, written, failed }
346
599
  }