@erclx/canon 4.85.0 → 4.87.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 (44) 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 +3 -3
  6. package/claude/skills/draft-and-pick/REQUIREMENT.md +2 -1
  7. package/claude/skills/draft-and-pick/SKILL.md +4 -3
  8. package/claude/skills/draft-and-pick/references/live-arms.md +19 -0
  9. package/claude/skills/draft-diagram/SKILL.md +2 -2
  10. package/claude/skills/draft-slides/SKILL.md +1 -1
  11. package/claude/skills/git-ship/SKILL.md +1 -1
  12. package/claude/skills/memory-capture/SKILL.md +1 -1
  13. package/claude/skills/memory-review/SKILL.md +13 -13
  14. package/claude/skills/memory-review/references/receipt-format.md +1 -1
  15. package/claude/skills/review-branch/SKILL.md +3 -3
  16. package/claude/skills/role-worker/SKILL.md +2 -1
  17. package/claude/skills/sketch-design/SKILL.md +4 -4
  18. package/claude/skills/ux-walkthrough/SKILL.md +2 -2
  19. package/claude/skills/ux-walkthrough/references/candidate-pages.md +1 -16
  20. package/docs/agents/commands.md +100 -95
  21. package/docs/agents/design-board.md +7 -7
  22. package/docs/workflow/ai-workflow.md +3 -3
  23. package/docs/workflow/visual-design-workflow.md +1 -1
  24. package/governance/rules/claude/563-ready.md +11 -0
  25. package/governance/rules/core/045-memory.md +1 -1
  26. package/package.json +1 -1
  27. package/src/cli.ts +1 -1
  28. package/src/commands/design.ts +3 -3
  29. package/src/commands/feedback.ts +12 -12
  30. package/src/commands/migrate.ts +168 -3
  31. package/src/commands/slides.ts +2 -2
  32. package/src/design/board.ts +17 -10
  33. package/src/migrate/evidence-ordinal.ts +79 -0
  34. package/src/migrate/record-layout.ts +599 -0
  35. package/src/migrate/record-tree.ts +1 -1
  36. package/src/migrate/scratch-evidence.ts +57 -30
  37. package/src/record-root.ts +4 -0
  38. package/standards/index.md +1 -0
  39. package/standards/memory.md +1 -1
  40. package/standards/plan.md +2 -0
  41. package/standards/publish.md +1 -1
  42. package/standards/ready.md +105 -0
  43. package/standards/skill.md +1 -1
  44. package/tooling/claude/seeds/.claude/hooks/memory-index.sh +10 -0
@@ -0,0 +1,599 @@
1
+ /**
2
+ * The record folder layout, moved one intake batch at a time.
3
+ *
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/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.
13
+ *
14
+ * The move and the citation repoint follow `scratch-evidence.ts`'s shape: a
15
+ * dry run by default, `--write` to apply, a collision refusal, the
16
+ * `canon-keep-record-root` marker honored, and archives swept on purpose,
17
+ * since an archived receipt still cites the row it retired.
18
+ *
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.
24
+ */
25
+
26
+ import { existsSync, readdirSync } from 'node:fs'
27
+ import {
28
+ mkdir,
29
+ readdir,
30
+ readFile,
31
+ rename,
32
+ rmdir,
33
+ writeFile,
34
+ } from 'node:fs/promises'
35
+ import { dirname, join } from 'node:path'
36
+ import {
37
+ byFirstAppearance,
38
+ nextOrdinal,
39
+ numberedName,
40
+ slugOf,
41
+ } from '@/migrate/evidence-ordinal'
42
+ import { presentFolders } from '@/records/backup'
43
+ import { recordDir, SCRATCH, spell, type RecordRoot } from '@/record-root'
44
+
45
+ const CANON: RecordRoot = '.canon'
46
+ const CLAUDE: RecordRoot = '.claude'
47
+
48
+ /** A whole folder moving to a fixed destination. */
49
+ export interface FolderLayoutMove {
50
+ readonly kind: 'folder'
51
+ readonly from: readonly string[]
52
+ readonly to: readonly string[]
53
+ }
54
+
55
+ /**
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.
89
+ *
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.
93
+ */
94
+ export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
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, 'ui-checklist'],
124
+ renamed: '',
125
+ prune: false,
126
+ },
127
+ {
128
+ kind: 'split',
129
+ from: ['review', 'evidence'],
130
+ picks: ['picks'],
131
+ evidence: ['evidence'],
132
+ },
133
+ ]
134
+
135
+ function resolveRecord(root: string, segments: readonly string[]): string {
136
+ const [folder, ...rest] = segments
137
+ return recordDir(root, folder, ...rest)
138
+ }
139
+
140
+ /** Where a move's source sits today. */
141
+ export function sourcePath(root: string, move: RecordLayoutMove): string {
142
+ return resolveRecord(root, move.from)
143
+ }
144
+
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('/')
154
+ }
155
+
156
+ function escape(value: string): string {
157
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
158
+ }
159
+
160
+ /**
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.
166
+ */
167
+ function oldPrefixes(from: readonly string[]): readonly string[] {
168
+ const [head, ...rest] = from
169
+ const canonSuffix = [spell(CANON, head), ...rest].join('/')
170
+ const claudeSuffix = [spell(CLAUDE, head), ...rest].join('/')
171
+
172
+ const prefixes = new Set<string>([
173
+ `.canon/${canonSuffix}/`,
174
+ `.claude/${claudeSuffix}/`,
175
+ ])
176
+ for (const suffix of [canonSuffix, claudeSuffix]) {
177
+ prefixes.add(`../${suffix}/`)
178
+ prefixes.add(`../../${suffix}/`)
179
+ }
180
+
181
+ return [...prefixes]
182
+ }
183
+
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')
188
+ }
189
+
190
+ /** Rejects a following name character, so one slug never swallows a sibling. */
191
+ const NAME_BOUNDARY = '(?![A-Za-z0-9._-])'
192
+
193
+ interface Rewrite {
194
+ readonly pattern: RegExp
195
+ readonly destination: string
196
+ }
197
+
198
+ /**
199
+ * Marks a line naming a moved path on purpose, the same marker
200
+ * `scratch-evidence.ts` and `records.ts` read: on the line itself or on the
201
+ * nearest non-blank line above it.
202
+ */
203
+ const KEEP_MARKER = 'canon-keep-record-root'
204
+
205
+ function isKept(lines: readonly string[], index: number): boolean {
206
+ if (lines[index]?.includes(KEEP_MARKER)) return true
207
+
208
+ let above = index - 1
209
+ while (above >= 0 && lines[above]?.trim() === '') above -= 1
210
+
211
+ return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
212
+ }
213
+
214
+ function rewriteLine(line: string, rewrites: readonly Rewrite[]): string {
215
+ return rewrites.reduce(
216
+ (current, { pattern, destination }) =>
217
+ current.replace(pattern, destination),
218
+ line,
219
+ )
220
+ }
221
+
222
+ interface RewriteOutcome {
223
+ readonly text: string
224
+ readonly count: number
225
+ }
226
+
227
+ /**
228
+ * Rewrites every unmarked citation a move covers into its destination,
229
+ * counting each as it goes. A file naming no such path returns
230
+ * byte-identical with a count of zero, and a marked line is returned
231
+ * unchanged and uncounted.
232
+ */
233
+ function applyRewrites(
234
+ text: string,
235
+ rewrites: readonly Rewrite[],
236
+ ): RewriteOutcome {
237
+ const lines = text.split('\n')
238
+ let count = 0
239
+
240
+ const rewritten = lines.map((line, index) => {
241
+ if (isKept(lines, index)) return line
242
+
243
+ for (const { pattern } of rewrites) {
244
+ count += [...line.matchAll(pattern)].length
245
+ }
246
+ return rewriteLine(line, rewrites)
247
+ })
248
+
249
+ return { text: rewritten.join('\n'), count }
250
+ }
251
+
252
+ /** The files under every present backed folder at `root`, archives included. */
253
+ export async function walkRecordLayoutCorpus(root: string): Promise<string[]> {
254
+ const files: string[] = []
255
+
256
+ for (const folder of presentFolders(root)) {
257
+ const dir = recordDir(root, folder)
258
+ if (!existsSync(dir)) continue
259
+
260
+ const glob = new Bun.Glob('**/*')
261
+ for await (const path of glob.scan({
262
+ cwd: dir,
263
+ onlyFiles: true,
264
+ dot: true,
265
+ })) {
266
+ files.push(join(dir, path))
267
+ }
268
+ }
269
+
270
+ return files.sort()
271
+ }
272
+
273
+ export interface RecordLayoutSource {
274
+ readonly path: string
275
+ readonly text: string
276
+ }
277
+
278
+ /** Reads every file the walk found, skipping one that carries a NUL byte. */
279
+ export async function readRecordLayoutCorpus(
280
+ paths: readonly string[],
281
+ ): Promise<RecordLayoutSource[]> {
282
+ const sources: RecordLayoutSource[] = []
283
+
284
+ for (const path of paths) {
285
+ const bytes = await readFile(path).catch(() => undefined)
286
+ if (bytes === undefined || bytes.includes(0)) continue
287
+
288
+ sources.push({ path, text: bytes.toString('utf8') })
289
+ }
290
+
291
+ return sources
292
+ }
293
+
294
+ export type Classification = 'pick' | 'evidence'
295
+
296
+ export interface PathMove {
297
+ readonly move: RecordLayoutMove
298
+ readonly from: string
299
+ readonly to: string
300
+ readonly classified?: Classification
301
+ }
302
+
303
+ export interface CitationEntry {
304
+ readonly path: string
305
+ readonly text: string
306
+ readonly rewritten: number
307
+ }
308
+
309
+ export interface RecordLayoutPlan {
310
+ readonly moves: readonly PathMove[]
311
+ readonly collisions: readonly string[]
312
+ readonly entries: readonly CitationEntry[]
313
+ readonly rewritten: number
314
+ readonly strays: readonly string[]
315
+ readonly prune: readonly string[]
316
+ }
317
+
318
+ const STRAY_RECEIPT_PATTERN = /^memory-review-.*\.md$/
319
+
320
+ /**
321
+ * A receipt sitting at the flat `review/` root, the shape `memory-review`
322
+ * wrote before `.canon/review/memory/` existed. Neither mapped move covers
323
+ * it, since it sits one level above the folder either move reads, so it is
324
+ * reported rather than moved: widening the table for one stray file trades a
325
+ * data-shaped mapping for a special case.
326
+ */
327
+ export function strayReceipts(root: string): string[] {
328
+ const dir = recordDir(root, 'review')
329
+ const entries = existsSync(dir)
330
+ ? readdirSync(dir, { withFileTypes: true })
331
+ : []
332
+
333
+ return entries
334
+ .filter((entry) => entry.isFile() && STRAY_RECEIPT_PATTERN.test(entry.name))
335
+ .map((entry) => join(dir, entry.name))
336
+ .sort()
337
+ }
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
+
437
+ /**
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.
442
+ */
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))
490
+ continue
491
+ }
492
+
493
+ accept(name, dir, [...move.evidence, numberedName(next, name)], 'evidence')
494
+ next += 1
495
+ }
496
+
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
+ }
513
+ }
514
+
515
+ /**
516
+ * What the migration would do, without doing it. Pure over the sources it is
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.
520
+ */
521
+ export function planRecordLayout(
522
+ root: string,
523
+ sources: readonly RecordLayoutSource[],
524
+ ): RecordLayoutPlan {
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)
528
+ const entries: CitationEntry[] = []
529
+
530
+ for (const source of sources) {
531
+ const { text, count } = applyRewrites(source.text, rewrites)
532
+ if (count === 0) continue
533
+
534
+ entries.push({ path: source.path, text, rewritten: count })
535
+ }
536
+
537
+ return {
538
+ moves: rows.flatMap((row) => row.moves),
539
+ collisions: rows.flatMap((row) => row.collisions),
540
+ entries,
541
+ rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
542
+ strays: strayReceipts(root),
543
+ prune: rows.flatMap((row) => row.prune),
544
+ }
545
+ }
546
+
547
+ export interface RecordLayoutResult {
548
+ readonly moved: number
549
+ readonly written: number
550
+ readonly failed: readonly string[]
551
+ }
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
+
560
+ /**
561
+ * Writes the plan: every citation rewrite first, then every move, then the
562
+ * folders a files row emptied.
563
+ *
564
+ * A citation can sit inside a file the plan is about to move, since the walk
565
+ * carries archives on purpose and a receipt can cite the row it retired.
566
+ * Rewriting first is what keeps that write landing on a path that still
567
+ * exists: moving first would send `writeFile` at the pre-move path into a
568
+ * directory `rename` already cleared.
569
+ */
570
+ export async function applyRecordLayout(
571
+ plan: RecordLayoutPlan,
572
+ ): Promise<RecordLayoutResult> {
573
+ let moved = 0
574
+ let written = 0
575
+ const failed: string[] = []
576
+
577
+ for (const entry of plan.entries) {
578
+ const done = await writeFile(entry.path, entry.text)
579
+ .then(() => true)
580
+ .catch(() => false)
581
+
582
+ if (done) written += 1
583
+ else failed.push(entry.path)
584
+ }
585
+
586
+ for (const move of plan.moves) {
587
+ await mkdir(dirname(move.to), { recursive: true })
588
+ const done = await rename(move.from, move.to)
589
+ .then(() => true)
590
+ .catch(() => false)
591
+
592
+ if (done) moved += 1
593
+ else failed.push(move.from)
594
+ }
595
+
596
+ for (const dir of plan.prune) await removeIfEmpty(dir)
597
+
598
+ return { moved, written, failed }
599
+ }
@@ -60,7 +60,7 @@ export const OBJECT_STORE = '.records.git'
60
60
  * `archive` is the substantive one: an archived plan or a retired memory entry
61
61
  * describes work that closed, and a path inside that sentence is history rather
62
62
  * than a pointer. It is pruned at any depth because the archives do not all sit
63
- * at the same one, `review/memory/archive/` being two levels down.
63
+ * at the same one, `memory/review/archive/` being two levels down.
64
64
  */
65
65
  export const PRUNED_SEGMENTS: readonly string[] = [
66
66
  'archive',