@erclx/canon 4.84.0 → 4.86.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 (35) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/docs-fold/SKILL.md +1 -1
  4. package/claude/skills/draft-and-pick/REQUIREMENT.md +2 -1
  5. package/claude/skills/draft-and-pick/SKILL.md +3 -2
  6. package/claude/skills/draft-and-pick/references/live-arms.md +19 -0
  7. package/claude/skills/git-ship/SKILL.md +2 -2
  8. package/claude/skills/memory-capture/SKILL.md +1 -1
  9. package/claude/skills/memory-review/REQUIREMENT.md +1 -1
  10. package/claude/skills/memory-review/SKILL.md +25 -22
  11. package/claude/skills/memory-review/references/receipt-format.md +2 -2
  12. package/claude/skills/role-worker/SKILL.md +2 -1
  13. package/claude/skills/ux-walkthrough/SKILL.md +2 -2
  14. package/claude/skills/ux-walkthrough/references/candidate-pages.md +1 -16
  15. package/docs/agents/commands.md +4 -1
  16. package/docs/agents/records.md +6 -6
  17. package/docs/workflow/ai-workflow.md +2 -2
  18. package/governance/rules/claude/563-ready.md +11 -0
  19. package/governance/rules/core/045-memory.md +1 -1
  20. package/package.json +1 -1
  21. package/src/commands/migrate.ts +142 -0
  22. package/src/commands/records.ts +12 -6
  23. package/src/design/tokens.ts +7 -4
  24. package/src/migrate/record-layout.ts +346 -0
  25. package/src/migrate/record-tree.ts +1 -1
  26. package/src/migrate/scratch-evidence.ts +3 -3
  27. package/src/record-root.ts +2 -0
  28. package/src/records/backup.ts +132 -37
  29. package/src/records/size.ts +16 -12
  30. package/standards/index.md +1 -0
  31. package/standards/memory.md +1 -1
  32. package/standards/plan.md +2 -0
  33. package/standards/ready.md +105 -0
  34. package/standards/skill.md +1 -1
  35. package/tooling/claude/seeds/.claude/hooks/memory-index.sh +10 -0
@@ -30,6 +30,13 @@ import {
30
30
  planSurfaceRootsMove,
31
31
  type SurfaceRootsPlan,
32
32
  } from '@/migrate/surface-roots'
33
+ import {
34
+ applyRecordLayout,
35
+ planRecordLayout,
36
+ readRecordLayoutCorpus,
37
+ type RecordLayoutPlan,
38
+ walkRecordLayoutCorpus,
39
+ } from '@/migrate/record-layout'
33
40
  import {
34
41
  applyScratchEvidence,
35
42
  planScratchEvidence,
@@ -746,6 +753,102 @@ function toScratchEvidenceRecord(
746
753
  }
747
754
  }
748
755
 
756
+ interface RecordLayoutOptions {
757
+ readonly json?: boolean
758
+ readonly write?: boolean
759
+ readonly root?: string
760
+ }
761
+
762
+ /**
763
+ * Moves review receipts from `.canon/review/memory/` to `.canon/memory/review/`
764
+ * and retired entries from `.canon/tmp/memory-archive/` to
765
+ * `.canon/memory/archive/`, and repoints the citations that name either.
766
+ */
767
+ async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
768
+ const root = opts.root ?? process.cwd()
769
+
770
+ const files = await walkRecordLayoutCorpus(root)
771
+ const sources = await readRecordLayoutCorpus(files)
772
+ const plan = planRecordLayout(root, sources)
773
+
774
+ if (opts.json) {
775
+ process.stdout.write(
776
+ `${JSON.stringify(toRecordLayoutRecord(plan, opts.write))}\n`,
777
+ )
778
+ }
779
+
780
+ reportRecordLayout(plan)
781
+
782
+ if (plan.collisions.length > 0) {
783
+ logError(
784
+ `${plural(plan.collisions.length, 'destination')} already occupied. Neither side moved.`,
785
+ )
786
+ for (const collision of plan.collisions) logError(` ${collision}`)
787
+ }
788
+
789
+ if (plan.moves.length === 0 && plan.entries.length === 0) {
790
+ return plan.collisions.length > 0 ? 1 : 0
791
+ }
792
+
793
+ if (!opts.write) {
794
+ logWarn('Nothing was written. Pass --write to apply this plan.')
795
+ return 2
796
+ }
797
+
798
+ const applied = await applyRecordLayout(plan)
799
+ logStep(
800
+ `Moved ${plural(applied.moved, 'folder')} and rewrote ${plural(applied.written, 'file')}.`,
801
+ )
802
+
803
+ if (applied.failed.length > 0) {
804
+ logError(`Could not write ${plural(applied.failed.length, 'file')}.`)
805
+ for (const path of applied.failed) logError(` ${path}`)
806
+ return 1
807
+ }
808
+
809
+ return plan.collisions.length > 0 ? 1 : 0
810
+ }
811
+
812
+ function reportRecordLayout(plan: RecordLayoutPlan): void {
813
+ logInfo(`${plural(plan.moves.length, 'folder')} to move.`)
814
+ for (const move of plan.moves) {
815
+ logInfo(` ${move.from} -> ${move.to}`)
816
+ }
817
+
818
+ logInfo(
819
+ `${plural(plan.entries.length, 'file')} to change, ${plural(plan.rewritten, 'citation')} to rewrite.`,
820
+ )
821
+ for (const entry of plan.entries) {
822
+ logInfo(` ${entry.path}: ${plural(entry.rewritten, 'citation')}`)
823
+ }
824
+
825
+ if (plan.strays.length > 0) {
826
+ logWarn(
827
+ `${plural(plan.strays.length, 'stray receipt')} at the flat review/ root. Not moved, move by hand into memory/review/.`,
828
+ )
829
+ for (const stray of plan.strays) logWarn(` ${stray}`)
830
+ }
831
+ }
832
+
833
+ function toRecordLayoutRecord(
834
+ plan: RecordLayoutPlan,
835
+ wrote: boolean | undefined,
836
+ ): unknown {
837
+ return {
838
+ ok: true,
839
+ wrote: wrote === true,
840
+ moves: plan.moves.map((move) => ({ from: move.from, to: move.to })),
841
+ collisions: plan.collisions,
842
+ files: plan.entries.length,
843
+ rewritten: plan.rewritten,
844
+ strays: plan.strays,
845
+ paths: plan.entries.map((entry) => ({
846
+ path: entry.path,
847
+ rewritten: entry.rewritten,
848
+ })),
849
+ }
850
+ }
851
+
749
852
  interface RuleLayoutOptions {
750
853
  readonly json?: boolean
751
854
  readonly write?: boolean
@@ -1031,6 +1134,45 @@ export function register(program: Command): void {
1031
1134
  process.exitCode = await runScratchEvidence(opts)
1032
1135
  })
1033
1136
 
1137
+ migrate
1138
+ .command('record-layout')
1139
+ .description('Fold memory review receipts and archive under memory/')
1140
+ .helpOption('-h, --help', 'Show this help message')
1141
+ .option('--json', 'Add a machine-readable record on stdout')
1142
+ .option('--write', 'Apply the plan rather than reporting it')
1143
+ .option(
1144
+ '--root <path>',
1145
+ 'Project root, defaulting to the working directory',
1146
+ )
1147
+ .addHelpText(
1148
+ 'after',
1149
+ [
1150
+ '',
1151
+ 'Moves review receipts from .canon/review/memory/ to',
1152
+ '.canon/memory/review/, and retired entries from',
1153
+ '.canon/tmp/memory-archive/ to .canon/memory/archive/, backed for',
1154
+ 'the first time, and repoints every citation that names either,',
1155
+ 'live or archived.',
1156
+ '',
1157
+ 'A receipt sitting at the flat review/ root, the shape memory-review',
1158
+ 'wrote before review/memory/ existed, is reported rather than moved.',
1159
+ '',
1160
+ 'Exit codes:',
1161
+ ' 0 nothing to move, or --write applied the whole plan',
1162
+ ' 1 a write failed, or an unresolved collision remains',
1163
+ ' 2 a plan exists and --write was not passed',
1164
+ '',
1165
+ 'Examples:',
1166
+ ' canon migrate record-layout',
1167
+ ' canon migrate record-layout --write',
1168
+ ' canon migrate record-layout --json',
1169
+ '',
1170
+ ].join('\n'),
1171
+ )
1172
+ .action(async (opts: RecordLayoutOptions) => {
1173
+ process.exitCode = await runRecordLayout(opts)
1174
+ })
1175
+
1034
1176
  migrate
1035
1177
  .command('rename')
1036
1178
  .description('Rewrite every unprotected aitk token to canon')
@@ -1,7 +1,7 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { BACKED_FOLDERS, pullRecords, pushRecords } from '@/records/backup'
4
+ import { pullRecords, pushRecords } from '@/records/backup'
5
5
  import { migrateRecord } from '@/records/migrate'
6
6
  import {
7
7
  type ClaimOutcome,
@@ -14,7 +14,7 @@ import {
14
14
  type FolderSize,
15
15
  formatBytes,
16
16
  GROWTH_WINDOWS,
17
- SIZED_FOLDERS,
17
+ sizedFolders,
18
18
  type SizeOutcome,
19
19
  sizeRecords,
20
20
  } from '@/records/size'
@@ -207,8 +207,9 @@ export function register(program: Command): void {
207
207
  'after',
208
208
  [
209
209
  '',
210
- 'Folders read under .claude/:',
211
- ` ${SIZED_FOLDERS.join(', ')}`,
210
+ 'Folders read, at whichever record root the project carries:',
211
+ ' every folder canon records push carries, plus the scratch folder',
212
+ ' (deletable without loss, so a backup skips it, but a reading does not)',
212
213
  '',
213
214
  'Exit codes:',
214
215
  ' 0 the reading completed',
@@ -274,8 +275,10 @@ function backupHelp(verb: 'push' | 'pull'): string {
274
275
 
275
276
  return [
276
277
  '',
277
- 'Backed folders under .claude/:',
278
- ` ${BACKED_FOLDERS.join(', ')}`,
278
+ 'Backed folders, at whichever record root the project carries:',
279
+ ' every top-level entry, less tmp, ordinal-locks, and .records.git',
280
+ ' (a legacy .claude root instead uses a fixed list; run this with --json',
281
+ ' to see what actually resolved, including any folder seen for the first time)',
279
282
  '',
280
283
  'Exit codes:',
281
284
  ' 0 the records remote and this machine agree',
@@ -404,6 +407,9 @@ async function runPush(opts: BackupCommandOptions): Promise<number> {
404
407
  logInfo(
405
408
  `${outcome.folders.length} folder(s), ${outcome.changed} path(s) changed`,
406
409
  )
410
+ if (outcome.firstSeen.length > 0) {
411
+ logWarn(`first seen: ${outcome.firstSeen.join(', ')}`)
412
+ }
407
413
  logStep(outcome.pushed ? 'Pushed' : 'Nothing to push')
408
414
  logInfo(
409
415
  outcome.commit
@@ -104,7 +104,7 @@ export const TOKENS: DesignTokens = {
104
104
  ].join('\n'),
105
105
 
106
106
  colorNote: [
107
- 'Every role clears WCAG AA at 4.5:1 against each ground it declares, asserted in `src/design/contrast.test.ts`. Two corrections landed with this record becoming the source. The light `muted` step moved from `#7A736A`, which read 4.38 and 4.09 against the two light grounds, and the dark `accent` moved off the `#C8602E` the slide theme carried, which read 4.36 and 3.99 against the two dark ones. Both now sit on the values below.',
107
+ 'Every role clears WCAG AA at 4.5:1 against each ground it declares, asserted in `src/design/contrast.test.ts`.',
108
108
  '',
109
109
  'Warning and error hold ANSI codes because that is what `scripts/lib/ui.sh` writes and no rendered surface implements an equivalent. Giving either a hex value would invent a mapping no file has, so they carry no contrast reading either.',
110
110
  '',
@@ -232,7 +232,7 @@ export const TOKENS: DesignTokens = {
232
232
  typographyNote: [
233
233
  'One family covers every role but `page-display`, which is the landing page hero and takes the proportional sibling of the same superfamily. The size scale runs from 11.5 to 52 pixels, and six values map onto a role. Five further values are adjustments inside a single component and get no role here, since a scale with five invented steps reads as a system the surfaces do not implement. They are 11.5, 12.5, 13, 14, and 15 pixels.',
234
234
  '',
235
- 'The 52 pixel step sits above the 34 the rest of the scale tops out at, and it is the one size no other surface reaches. A hero headline set at the display cap reads as a section heading rather than an opening, which four rendered arms measured on 2026-09-04 before the step was added.',
235
+ 'The 52 pixel step sits above the 34 the rest of the scale tops out at, and it is the one size no other surface reaches, since a hero headline set at the display cap reads as an opening rather than as a section heading.',
236
236
  '',
237
237
  'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
238
238
  '',
@@ -353,8 +353,11 @@ export const TOKENS: DesignTokens = {
353
353
  motion:
354
354
  'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
355
355
 
356
- iconography:
357
- "No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing. The same mark also ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift: `regen-hero.sh` derives one from the live SVG colored with whatever `--color-accent` (`#e0724b`) the fetched token CSS carries, `src/design/render.ts` carries the path data as a hardcoded literal colored via `colorValue('light-accent')` (`#a4471c`), since a data URI has no CSS context and that page renders on light chrome, and `teach-workspace`'s `SKILL.md` names one in prose colored `rgb(224,114,75)`, the same value as the dark accent written as decimal rather than hex to clear the shipped-references gate's commit-sha check. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for.",
356
+ iconography: [
357
+ 'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
358
+ '',
359
+ 'The same mark ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift, colored to fit the chrome each renders on: the dark accent (`#e0724b`) for a dark-chrome surface and the light accent (`#a4471c`) for a light-chrome one. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
360
+ ].join('\n'),
358
361
  }
359
362
 
360
363
  /** A role's value, or `undefined` where the record declares no such role. */
@@ -0,0 +1,346 @@
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.
4
+ *
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.
12
+ *
13
+ * The move and the citation repoint follow `scratch-evidence.ts`'s shape: a
14
+ * dry run by default, `--write` to apply, a collision refusal, the
15
+ * `canon-keep-record-root` marker honored, and archives swept on purpose,
16
+ * since an archived receipt still cites the row it retired.
17
+ *
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.
21
+ */
22
+
23
+ import { existsSync, readdirSync } from 'node:fs'
24
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
25
+ import { dirname, join } from 'node:path'
26
+ import { presentFolders } from '@/records/backup'
27
+ import { recordDir, SCRATCH, spell, type RecordRoot } from '@/record-root'
28
+
29
+ const CANON: RecordRoot = '.canon'
30
+ const CLAUDE: RecordRoot = '.claude'
31
+
32
+ export interface RecordLayoutMove {
33
+ readonly from: readonly string[]
34
+ readonly to: readonly string[]
35
+ }
36
+
37
+ /**
38
+ * Every folder this migration moves, as a record-relative path on each side.
39
+ *
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.
44
+ */
45
+ export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
46
+ { from: ['review', 'memory'], to: ['memory', 'review'] },
47
+ { from: [SCRATCH, 'memory-archive'], to: ['memory', 'archive'] },
48
+ ]
49
+
50
+ /** Where a move's source sits today. */
51
+ export function sourcePath(root: string, move: RecordLayoutMove): string {
52
+ const [folder, ...rest] = move.from
53
+ return recordDir(root, folder, ...rest)
54
+ }
55
+
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)
60
+ }
61
+
62
+ function escape(value: string): string {
63
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
64
+ }
65
+
66
+ /**
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.
71
+ */
72
+ function oldPrefixes(from: readonly string[]): readonly string[] {
73
+ const [head, ...rest] = from
74
+ const canonSuffix = [spell(CANON, head), ...rest].join('/')
75
+ const claudeSuffix = [spell(CLAUDE, head), ...rest].join('/')
76
+
77
+ const prefixes = new Set<string>([
78
+ `.canon/${canonSuffix}/`,
79
+ `.claude/${claudeSuffix}/`,
80
+ ])
81
+ for (const suffix of [canonSuffix, claudeSuffix]) {
82
+ prefixes.add(`../${suffix}/`)
83
+ prefixes.add(`../../${suffix}/`)
84
+ }
85
+
86
+ return [...prefixes]
87
+ }
88
+
89
+ function citationPattern(move: RecordLayoutMove): RegExp {
90
+ const alternation = oldPrefixes(move.from).map(escape).join('|')
91
+ return new RegExp(`(?:${alternation})`, 'g')
92
+ }
93
+
94
+ interface Rewrite {
95
+ readonly pattern: RegExp
96
+ readonly destination: string
97
+ }
98
+
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
+ /**
108
+ * Marks a line naming a moved path on purpose, the same marker
109
+ * `scratch-evidence.ts` and `records.ts` read: on the line itself or on the
110
+ * nearest non-blank line above it.
111
+ */
112
+ const KEEP_MARKER = 'canon-keep-record-root'
113
+
114
+ function isKept(lines: readonly string[], index: number): boolean {
115
+ if (lines[index]?.includes(KEEP_MARKER)) return true
116
+
117
+ let above = index - 1
118
+ while (above >= 0 && lines[above]?.trim() === '') above -= 1
119
+
120
+ return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
121
+ }
122
+
123
+ function rewriteLine(line: string, rewrites: readonly Rewrite[]): string {
124
+ return rewrites.reduce(
125
+ (current, { pattern, destination }) =>
126
+ current.replace(pattern, destination),
127
+ line,
128
+ )
129
+ }
130
+
131
+ interface RewriteOutcome {
132
+ readonly text: string
133
+ readonly count: number
134
+ }
135
+
136
+ /**
137
+ * Rewrites every unmarked citation a move covers into its destination,
138
+ * counting each as it goes. A file naming no such path returns
139
+ * byte-identical with a count of zero, and a marked line is returned
140
+ * unchanged and uncounted.
141
+ */
142
+ function applyRewrites(
143
+ text: string,
144
+ rewrites: readonly Rewrite[],
145
+ ): RewriteOutcome {
146
+ const lines = text.split('\n')
147
+ let count = 0
148
+
149
+ const rewritten = lines.map((line, index) => {
150
+ if (isKept(lines, index)) return line
151
+
152
+ for (const { pattern } of rewrites) {
153
+ count += [...line.matchAll(pattern)].length
154
+ }
155
+ return rewriteLine(line, rewrites)
156
+ })
157
+
158
+ return { text: rewritten.join('\n'), count }
159
+ }
160
+
161
+ /** The files under every present backed folder at `root`, archives included. */
162
+ export async function walkRecordLayoutCorpus(root: string): Promise<string[]> {
163
+ const files: string[] = []
164
+
165
+ for (const folder of presentFolders(root)) {
166
+ const dir = recordDir(root, folder)
167
+ if (!existsSync(dir)) continue
168
+
169
+ const glob = new Bun.Glob('**/*')
170
+ for await (const path of glob.scan({
171
+ cwd: dir,
172
+ onlyFiles: true,
173
+ dot: true,
174
+ })) {
175
+ files.push(join(dir, path))
176
+ }
177
+ }
178
+
179
+ return files.sort()
180
+ }
181
+
182
+ export interface RecordLayoutSource {
183
+ readonly path: string
184
+ readonly text: string
185
+ }
186
+
187
+ /** Reads every file the walk found, skipping one that carries a NUL byte. */
188
+ export async function readRecordLayoutCorpus(
189
+ paths: readonly string[],
190
+ ): Promise<RecordLayoutSource[]> {
191
+ const sources: RecordLayoutSource[] = []
192
+
193
+ for (const path of paths) {
194
+ const bytes = await readFile(path).catch(() => undefined)
195
+ if (bytes === undefined || bytes.includes(0)) continue
196
+
197
+ sources.push({ path, text: bytes.toString('utf8') })
198
+ }
199
+
200
+ return sources
201
+ }
202
+
203
+ export interface FolderMove {
204
+ readonly move: RecordLayoutMove
205
+ readonly from: string
206
+ readonly to: string
207
+ }
208
+
209
+ export interface CitationEntry {
210
+ readonly path: string
211
+ readonly text: string
212
+ readonly rewritten: number
213
+ }
214
+
215
+ export interface RecordLayoutPlan {
216
+ readonly moves: readonly FolderMove[]
217
+ readonly collisions: readonly string[]
218
+ readonly entries: readonly CitationEntry[]
219
+ readonly rewritten: number
220
+ readonly strays: readonly string[]
221
+ }
222
+
223
+ const STRAY_RECEIPT_PATTERN = /^memory-review-.*\.md$/
224
+
225
+ /**
226
+ * A receipt sitting at the flat `review/` root, the shape `memory-review`
227
+ * wrote before `.canon/review/memory/` existed. Neither mapped move covers
228
+ * it, since it sits one level above the folder either move reads, so it is
229
+ * reported rather than moved: widening the table for one stray file trades a
230
+ * data-shaped mapping for a special case.
231
+ */
232
+ export function strayReceipts(root: string): string[] {
233
+ const dir = recordDir(root, 'review')
234
+ const entries = existsSync(dir)
235
+ ? readdirSync(dir, { withFileTypes: true })
236
+ : []
237
+
238
+ return entries
239
+ .filter((entry) => entry.isFile() && STRAY_RECEIPT_PATTERN.test(entry.name))
240
+ .map((entry) => join(dir, entry.name))
241
+ .sort()
242
+ }
243
+
244
+ /**
245
+ * Every mapped move found on disk, with its destination, refusing a move
246
+ * whose destination is already occupied rather than merging into it.
247
+ */
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)
262
+ continue
263
+ }
264
+
265
+ moves.push({ move, from, to })
266
+ }
267
+
268
+ return { moves, collisions }
269
+ }
270
+
271
+ /**
272
+ * 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.
275
+ */
276
+ export function planRecordLayout(
277
+ root: string,
278
+ sources: readonly RecordLayoutSource[],
279
+ ): 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)
286
+ const entries: CitationEntry[] = []
287
+
288
+ for (const source of sources) {
289
+ const { text, count } = applyRewrites(source.text, rewrites)
290
+ if (count === 0) continue
291
+
292
+ entries.push({ path: source.path, text, rewritten: count })
293
+ }
294
+
295
+ return {
296
+ moves,
297
+ collisions,
298
+ entries,
299
+ rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
300
+ strays: strayReceipts(root),
301
+ }
302
+ }
303
+
304
+ export interface RecordLayoutResult {
305
+ readonly moved: number
306
+ readonly written: number
307
+ readonly failed: readonly string[]
308
+ }
309
+
310
+ /**
311
+ * Writes the plan: every citation rewrite first, then every folder move.
312
+ *
313
+ * A citation can sit inside a file the plan is about to move, since the walk
314
+ * carries archives on purpose and a receipt can cite the row it retired.
315
+ * 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.
318
+ */
319
+ export async function applyRecordLayout(
320
+ plan: RecordLayoutPlan,
321
+ ): Promise<RecordLayoutResult> {
322
+ let moved = 0
323
+ let written = 0
324
+ const failed: string[] = []
325
+
326
+ for (const entry of plan.entries) {
327
+ const done = await writeFile(entry.path, entry.text)
328
+ .then(() => true)
329
+ .catch(() => false)
330
+
331
+ if (done) written += 1
332
+ else failed.push(entry.path)
333
+ }
334
+
335
+ for (const move of plan.moves) {
336
+ await mkdir(dirname(move.to), { recursive: true })
337
+ const done = await rename(move.from, move.to)
338
+ .then(() => true)
339
+ .catch(() => false)
340
+
341
+ if (done) moved += 1
342
+ else failed.push(move.from)
343
+ }
344
+
345
+ return { moved, written, failed }
346
+ }
@@ -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',
@@ -26,7 +26,7 @@
26
26
  import { existsSync } from 'node:fs'
27
27
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
28
28
  import { dirname, join } from 'node:path'
29
- import { BACKED_FOLDERS } from '@/records/backup'
29
+ import { presentFolders } from '@/records/backup'
30
30
  import { recordDir, SCRATCH } from '@/record-root'
31
31
 
32
32
  /**
@@ -168,13 +168,13 @@ function applyRewrites(
168
168
  return { text: rewritten.join('\n'), count }
169
169
  }
170
170
 
171
- /** The files under every `BACKED_FOLDERS` entry, archives included. */
171
+ /** The files under every present backed folder at `root`, archives included. */
172
172
  export async function walkScratchEvidenceCorpus(
173
173
  root: string,
174
174
  ): Promise<string[]> {
175
175
  const files: string[] = []
176
176
 
177
- for (const folder of BACKED_FOLDERS) {
177
+ for (const folder of presentFolders(root)) {
178
178
  const dir = recordDir(root, folder)
179
179
  if (!existsSync(dir)) continue
180
180
 
@@ -70,9 +70,11 @@ export const RECORD_ENTRIES: readonly string[] = [
70
70
  'memory',
71
71
  'plans',
72
72
  'proposals',
73
+ 'ready',
73
74
  'review',
74
75
  'tasks',
75
76
  'teach',
77
+ 'transcripts',
76
78
  'walkthroughs',
77
79
  ]
78
80