@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,6 +1,6 @@
1
1
  /**
2
2
  * The promotion of nine cited measurement folders out of `.canon/tmp/` into
3
- * `.canon/review/evidence/`, which `canon records push` already backs.
3
+ * `.canon/evidence/<nn>-<folder>/`, which `canon records push` already backs.
4
4
  *
5
5
  * The scratch root is the one record root a disk loss takes with it, and a
6
6
  * durable record naming a folder under it as its evidence is a citation into
@@ -26,6 +26,13 @@
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 {
30
+ byFirstAppearance,
31
+ folderNames,
32
+ nextOrdinal,
33
+ numberedName,
34
+ slugOf,
35
+ } from '@/migrate/evidence-ordinal'
29
36
  import { presentFolders } from '@/records/backup'
30
37
  import { recordDir, SCRATCH } from '@/record-root'
31
38
 
@@ -41,11 +48,9 @@ import { recordDir, SCRATCH } from '@/record-root'
41
48
  * `verify-astro` and `verify-vite-react` pass the same test and are excluded
42
49
  * by name: both are scaffolds a command generates rather than records a
43
50
  * session wrote, and each is 100+ MB, which the review remote is not sized
44
- * for. `ablation`, `eval-runs`, `sandbox-runs`, `memory-archive`,
45
- * `groundwork-fixtures`, `precompact-handoff`, `pr-poll`, `pr`,
46
- * `address-review`, and `memory-routing` fail the second clause: a script or
47
- * a skill body names each of those paths, so moving one needs a code change
48
- * first rather than a promotion.
51
+ * for. `ablation`, `runs`, `memory-archive`, `hooks`, `pr`, and `handoff`
52
+ * fail the second clause: a script or a skill body names each of those
53
+ * paths, so moving one needs a code change first rather than a promotion.
49
54
  */
50
55
  export const PROMOTED_FOLDERS: readonly string[] = [
51
56
  'hero-probe',
@@ -59,16 +64,14 @@ export const PROMOTED_FOLDERS: readonly string[] = [
59
64
  'target-survey',
60
65
  ]
61
66
 
62
- const EVIDENCE_ROOT = ['review', 'evidence'] as const
63
-
64
67
  /** Where a promoted folder sits before the move. */
65
68
  export function sourcePath(root: string, folder: string): string {
66
69
  return recordDir(root, SCRATCH, folder)
67
70
  }
68
71
 
69
- /** Where it lands after, always under whichever root `review/` resolves at. */
70
- export function destinationPath(root: string, folder: string): string {
71
- return recordDir(root, ...EVIDENCE_ROOT, folder)
72
+ /** The folder a promoted one lands inside, under whichever root carries it. */
73
+ function evidenceDir(root: string): string {
74
+ return recordDir(root, 'evidence')
72
75
  }
73
76
 
74
77
  function escape(value: string): string {
@@ -100,14 +103,14 @@ function citationPattern(folder: string): RegExp {
100
103
 
101
104
  interface Rewrite {
102
105
  readonly pattern: RegExp
103
- readonly folder: string
106
+ readonly name: string
104
107
  }
105
108
 
106
- /** One rewrite per folder, paired with its citation pattern. */
107
- function buildRewrites(folders: readonly string[]): readonly Rewrite[] {
108
- return folders.map((folder) => ({
109
+ /** One rewrite per folder, from its scratch name to its numbered one. */
110
+ function buildRewrites(names: ReadonlyMap<string, string>): readonly Rewrite[] {
111
+ return [...names].map(([folder, name]) => ({
109
112
  pattern: citationPattern(folder),
110
- folder,
113
+ name,
111
114
  }))
112
115
  }
113
116
 
@@ -131,8 +134,8 @@ function isKept(lines: readonly string[], index: number): boolean {
131
134
 
132
135
  function rewriteLine(line: string, rewrites: readonly Rewrite[]): string {
133
136
  return rewrites.reduce(
134
- (current, { pattern, folder }) =>
135
- current.replace(pattern, `.canon/review/evidence/${folder}`),
137
+ (current, { pattern, name }) =>
138
+ current.replace(pattern, `.canon/evidence/${name}`),
136
139
  line,
137
140
  )
138
141
  }
@@ -144,7 +147,7 @@ interface RewriteOutcome {
144
147
 
145
148
  /**
146
149
  * Rewrites every unmarked citation of a folder `rewrites` covers into its
147
- * destination under `.canon/review/evidence/`, absolute regardless of how the
150
+ * destination under `.canon/evidence/`, absolute regardless of how the
148
151
  * source citation was spelled, counting each as it goes. A file naming no
149
152
  * such folder returns byte-identical with a count of zero, and a marked line
150
153
  * is returned unchanged and uncounted.
@@ -232,30 +235,55 @@ export interface ScratchEvidencePlan {
232
235
  }
233
236
 
234
237
  /**
235
- * Every promoted folder found on disk, with its destination, refusing a
236
- * folder whose destination is already occupied rather than merging into it.
238
+ * Every promoted folder found on disk, with its numbered destination, and the
239
+ * name each promoted folder's citations rewrite to.
240
+ *
241
+ * Folders on disk take ordinals in order of first appearance, continuing past
242
+ * the highest ordinal `evidence/` already holds. One whose slug `evidence/`
243
+ * already carries refuses rather than taking a second number, and its
244
+ * citations stay as they are. One already moved is not on disk under scratch,
245
+ * so its citations rewrite to the numbered name it landed at.
237
246
  */
238
247
  export function planFolderMoves(root: string): {
239
248
  moves: FolderMove[]
240
249
  collisions: string[]
250
+ names: Map<string, string>
241
251
  } {
252
+ const dir = evidenceDir(root)
253
+ const existing = folderNames(dir)
242
254
  const moves: FolderMove[] = []
243
255
  const collisions: string[] = []
256
+ const names = new Map<string, string>()
257
+ let next = nextOrdinal(existing)
258
+
259
+ const present = byFirstAppearance(
260
+ PROMOTED_FOLDERS.map((folder) => ({
261
+ name: folder,
262
+ dir: sourcePath(root, folder),
263
+ })).filter((folder) => existsSync(folder.dir)),
264
+ )
244
265
 
245
- for (const folder of PROMOTED_FOLDERS) {
246
- const from = sourcePath(root, folder)
247
- if (!existsSync(from)) continue
248
-
249
- const to = destinationPath(root, folder)
250
- if (existsSync(to)) {
251
- collisions.push(to)
266
+ for (const { name: folder, dir: from } of present) {
267
+ const landed = existing.find((entry) => slugOf(entry) === folder)
268
+ if (landed !== undefined) {
269
+ collisions.push(join(dir, landed))
252
270
  continue
253
271
  }
254
272
 
255
- moves.push({ folder, from, to })
273
+ const name = numberedName(next, folder)
274
+ next += 1
275
+ names.set(folder, name)
276
+ moves.push({ folder, from, to: join(dir, name) })
256
277
  }
257
278
 
258
- return { moves, collisions }
279
+ for (const folder of PROMOTED_FOLDERS) {
280
+ if (existsSync(sourcePath(root, folder))) continue
281
+
282
+ const landed = existing.find((entry) => slugOf(entry) === folder)
283
+ if (landed !== undefined) names.set(folder, landed)
284
+ }
285
+
286
+ return { moves, collisions, names }
259
287
  }
260
288
 
261
289
  /**
@@ -267,11 +295,8 @@ export function planScratchEvidence(
267
295
  root: string,
268
296
  sources: readonly ScratchEvidenceSource[],
269
297
  ): ScratchEvidencePlan {
270
- const { moves, collisions } = planFolderMoves(root)
271
- const folders = PROMOTED_FOLDERS.filter(
272
- (folder) => !collisions.includes(destinationPath(root, folder)),
273
- )
274
- const rewrites = buildRewrites(folders)
298
+ const { moves, collisions, names } = planFolderMoves(root)
299
+ const rewrites = buildRewrites(names)
275
300
  const entries: CitationEntry[] = []
276
301
 
277
302
  for (const source of sources) {
@@ -65,9 +65,12 @@ export const RECORD_ENTRIES: readonly string[] = [
65
65
  SCRATCH,
66
66
  'README.md',
67
67
  'diagrams',
68
+ 'evidence',
69
+ 'feedback',
68
70
  'groundwork',
69
71
  'intake',
70
72
  'memory',
73
+ 'picks',
71
74
  'plans',
72
75
  'proposals',
73
76
  'ready',
@@ -0,0 +1,488 @@
1
+ import { existsSync, type Dirent } from 'node:fs'
2
+ import { readdir, rm, stat } from 'node:fs/promises'
3
+ import { join, relative } from 'node:path'
4
+ import { RECORD_LAYOUT_MOVES } from '@/migrate/record-layout'
5
+ import { PROMOTED_FOLDERS } from '@/migrate/scratch-evidence'
6
+ import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record-root'
7
+ import { day, newestMtime } from '@/records/size'
8
+
9
+ const DAY_MS = 24 * 60 * 60 * 1000
10
+
11
+ /** The default age a unit must clear before it is offered, in days. */
12
+ export const DEFAULT_OLDER_THAN_DAYS = 14
13
+
14
+ /**
15
+ * Reserved subfolders under scratch. The scratch standard bars a session slug
16
+ * from taking one of these five names, so a top-level entry carrying one is
17
+ * always the reserved folder rather than an ordinary candidate.
18
+ */
19
+ const RESERVED = ['runs', 'hooks', 'handoff', 'pr', 'render'] as const
20
+
21
+ /**
22
+ * Pre-split names `#1695` moved into a reserved subfolder, still sitting at
23
+ * the scratch root on a tree the rename never touched. Reading them as
24
+ * ordinary slugs would offer an unread handoff or the live poll baseline for
25
+ * deletion, the one thing a wrong delete here loses for good.
26
+ */
27
+ const LEGACY_MOVES: ReadonlyMap<string, string> = new Map([
28
+ ['memory-routing', 'handoff/memory-routing'],
29
+ ['teach-promotion', 'handoff/teach-promotion'],
30
+ ['ui-checklist', 'handoff/ui-checklist'],
31
+ ['pr-poll', 'pr/poll'],
32
+ ])
33
+
34
+ /**
35
+ * Scratch-root names a different migration moves out of scratch for good,
36
+ * derived from that migration's own table rather than duplicated in a second
37
+ * hand-kept list. `RECORD_LAYOUT_MOVES` names `memory-archive`, the
38
+ * retired-entry archive `045-memory` says never to delete, and `PROMOTED_FOLDERS`
39
+ * names an evidence folder a durable record cites by name. A project that has
40
+ * not run either migration still carries these at the scratch root, where an
41
+ * ordinary slug's own age test would eventually offer them.
42
+ */
43
+ function migratedScratchNames(): ReadonlyMap<string, string> {
44
+ const names = new Map<string, string>()
45
+
46
+ for (const move of RECORD_LAYOUT_MOVES) {
47
+ if (move.from[0] === SCRATCH && move.from.length >= 2) {
48
+ names.set(move.from[1], 'canon migrate record-layout')
49
+ }
50
+ }
51
+
52
+ for (const folder of PROMOTED_FOLDERS) {
53
+ names.set(folder, 'canon migrate scratch-evidence')
54
+ }
55
+
56
+ return names
57
+ }
58
+
59
+ const MIGRATED_SCRATCH_NAMES = migratedScratchNames()
60
+
61
+ export interface PruneUnit {
62
+ /** Relative to the project root, at the scratch spelling the project carries. */
63
+ readonly path: string
64
+ readonly files: number
65
+ readonly bytes: number
66
+ /** `YYYY-MM-DD` of the most recently modified file, absent when the unit holds none. */
67
+ readonly newest?: string
68
+ }
69
+
70
+ export interface PruneSkip {
71
+ readonly path: string
72
+ readonly reason: string
73
+ }
74
+
75
+ export interface PruneFailure {
76
+ readonly path: string
77
+ readonly message: string
78
+ }
79
+
80
+ export interface PruneReport {
81
+ readonly ok: true
82
+ readonly root: string
83
+ readonly olderThan: number
84
+ readonly candidates: readonly PruneUnit[]
85
+ readonly kept: readonly PruneUnit[]
86
+ readonly skipped: readonly PruneSkip[]
87
+ readonly deleted: readonly string[]
88
+ readonly failed: readonly PruneFailure[]
89
+ }
90
+
91
+ export const PRUNE_REFUSALS = ['no-folder'] as const
92
+
93
+ export type PruneRefusal = (typeof PRUNE_REFUSALS)[number]
94
+
95
+ export interface PruneRefused {
96
+ readonly ok: false
97
+ readonly reason: PruneRefusal
98
+ readonly message: string
99
+ }
100
+
101
+ export type PruneOutcome = PruneReport | PruneRefused
102
+
103
+ /** A unit still carrying the filesystem paths a write deletes, ahead of the public shape. */
104
+ interface RawUnit {
105
+ readonly path: string
106
+ readonly files: number
107
+ readonly bytes: number
108
+ readonly newestMs?: number
109
+ readonly targets: readonly string[]
110
+ }
111
+
112
+ function describeFailure(reason: unknown): string {
113
+ return reason instanceof Error ? reason.message : String(reason)
114
+ }
115
+
116
+ function toPublic(unit: RawUnit): PruneUnit {
117
+ return {
118
+ path: unit.path,
119
+ files: unit.files,
120
+ bytes: unit.bytes,
121
+ newest: unit.newestMs === undefined ? undefined : day(unit.newestMs),
122
+ }
123
+ }
124
+
125
+ /** A unit with no files, empty subfolders included, is offered whatever its age. */
126
+ function isPrunable(unit: RawUnit, thresholdMs: number, now: number): boolean {
127
+ if (unit.files === 0) return true
128
+ return unit.newestMs !== undefined && now - unit.newestMs > thresholdMs
129
+ }
130
+
131
+ async function listDir(dir: string): Promise<Dirent[]> {
132
+ return readdir(dir, { withFileTypes: true }).catch(
133
+ (error: NodeJS.ErrnoException) => {
134
+ if (error.code === 'ENOENT') return []
135
+ throw error
136
+ },
137
+ )
138
+ }
139
+
140
+ async function statFile(
141
+ path: string,
142
+ ): Promise<{ bytes: number; newest: number } | undefined> {
143
+ try {
144
+ const info = await stat(path)
145
+ return { bytes: info.size, newest: info.mtimeMs }
146
+ } catch (error) {
147
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
148
+ throw error
149
+ }
150
+ }
151
+
152
+ async function unitFromFolder(
153
+ displayPath: string,
154
+ absPath: string,
155
+ ): Promise<RawUnit> {
156
+ const info = await newestMtime(absPath)
157
+ return {
158
+ path: displayPath,
159
+ files: info?.files ?? 0,
160
+ bytes: info?.bytes ?? 0,
161
+ newestMs: info?.newest,
162
+ targets: [absPath],
163
+ }
164
+ }
165
+
166
+ async function unitFromFile(
167
+ displayPath: string,
168
+ absPath: string,
169
+ ): Promise<RawUnit | undefined> {
170
+ const info = await statFile(absPath)
171
+ if (!info) return undefined
172
+
173
+ return {
174
+ path: displayPath,
175
+ files: 1,
176
+ bytes: info.bytes,
177
+ newestMs: info.newest,
178
+ targets: [absPath],
179
+ }
180
+ }
181
+
182
+ /** One unit spanning several files, for `pr/review/`'s per-pull-request grouping. */
183
+ async function groupedUnit(
184
+ displayPath: string,
185
+ files: readonly string[],
186
+ ): Promise<RawUnit> {
187
+ const infos = await Promise.all(files.map((path) => statFile(path)))
188
+
189
+ let bytes = 0
190
+ let newestMs: number | undefined
191
+ let count = 0
192
+
193
+ for (const info of infos) {
194
+ if (!info) continue
195
+ count += 1
196
+ bytes += info.bytes
197
+ newestMs =
198
+ newestMs === undefined ? info.newest : Math.max(newestMs, info.newest)
199
+ }
200
+
201
+ return { path: displayPath, files: count, bytes, newestMs, targets: files }
202
+ }
203
+
204
+ /**
205
+ * `pr/review/` groups by pull request rather than reporting as one folder
206
+ * unit, per the prune-tmp plan's Question 3: a review pass leaves one body
207
+ * file per pass, and a folder holding thousands of them would otherwise
208
+ * report as a single row with no way to prune the finished pulls apart from
209
+ * one still open.
210
+ */
211
+ async function reviewUnits(
212
+ displayPath: string,
213
+ reviewDir: string,
214
+ ): Promise<RawUnit[]> {
215
+ const files = await listDir(reviewDir)
216
+ const groups = new Map<string, string[]>()
217
+
218
+ for (const file of files) {
219
+ if (!file.isFile()) continue
220
+ const match = /^body-(\d+)-/.exec(file.name)
221
+ const key = match ? match[1] : 'other'
222
+ const list = groups.get(key) ?? []
223
+ list.push(join(reviewDir, file.name))
224
+ groups.set(key, list)
225
+ }
226
+
227
+ return Promise.all(
228
+ Array.from(groups.entries(), ([number, paths]) =>
229
+ groupedUnit(`${displayPath}/${number}`, paths),
230
+ ),
231
+ )
232
+ }
233
+
234
+ async function oneHookUnits(
235
+ displayPath: string,
236
+ hookDir: string,
237
+ ): Promise<RawUnit[]> {
238
+ const markers = await listDir(hookDir)
239
+ const units = await Promise.all(
240
+ markers
241
+ .filter((marker) => marker.isFile())
242
+ .map((marker) =>
243
+ unitFromFile(
244
+ `${displayPath}/${marker.name}`,
245
+ join(hookDir, marker.name),
246
+ ),
247
+ ),
248
+ )
249
+
250
+ return units.filter((unit): unit is RawUnit => unit !== undefined)
251
+ }
252
+
253
+ async function hookUnits(
254
+ displayPath: string,
255
+ hooksDir: string,
256
+ ): Promise<RawUnit[]> {
257
+ const hooks = await listDir(hooksDir)
258
+ const perHook = await Promise.all(
259
+ hooks
260
+ .filter((hook) => hook.isDirectory())
261
+ .map((hook) =>
262
+ oneHookUnits(`${displayPath}/${hook.name}`, join(hooksDir, hook.name)),
263
+ ),
264
+ )
265
+
266
+ return perHook.flat()
267
+ }
268
+
269
+ async function onePrSub(
270
+ displayPath: string,
271
+ subDir: string,
272
+ name: string,
273
+ ): Promise<{ units: RawUnit[]; skipped: PruneSkip[] }> {
274
+ if (name === 'poll') {
275
+ return {
276
+ units: [],
277
+ skipped: [
278
+ { path: displayPath, reason: 'a live poll baseline, never offered' },
279
+ ],
280
+ }
281
+ }
282
+
283
+ if (name === 'review') {
284
+ return { units: await reviewUnits(displayPath, subDir), skipped: [] }
285
+ }
286
+
287
+ return { units: [await unitFromFolder(displayPath, subDir)], skipped: [] }
288
+ }
289
+
290
+ async function prUnits(
291
+ displayPath: string,
292
+ prDir: string,
293
+ ): Promise<{
294
+ units: RawUnit[]
295
+ skipped: PruneSkip[]
296
+ }> {
297
+ const subs = await listDir(prDir)
298
+ const results = await Promise.all(
299
+ subs
300
+ .filter((sub) => sub.isDirectory())
301
+ .map((sub) =>
302
+ onePrSub(`${displayPath}/${sub.name}`, join(prDir, sub.name), sub.name),
303
+ ),
304
+ )
305
+
306
+ return {
307
+ units: results.flatMap((result) => result.units),
308
+ skipped: results.flatMap((result) => result.skipped),
309
+ }
310
+ }
311
+
312
+ async function reservedUnits(
313
+ name: (typeof RESERVED)[number],
314
+ displayPath: string,
315
+ absPath: string,
316
+ ): Promise<{ units: RawUnit[]; skipped: PruneSkip[] }> {
317
+ if (name === 'handoff') {
318
+ return {
319
+ units: [],
320
+ skipped: [
321
+ {
322
+ path: displayPath,
323
+ reason:
324
+ 'a reader deletes a handoff itself, so it is never offered here',
325
+ },
326
+ ],
327
+ }
328
+ }
329
+
330
+ if (name === 'hooks') {
331
+ return { units: await hookUnits(displayPath, absPath), skipped: [] }
332
+ }
333
+
334
+ if (name === 'pr') {
335
+ return prUnits(displayPath, absPath)
336
+ }
337
+
338
+ // 'runs' and 'render': every direct subfolder is one unit.
339
+ const subs = await listDir(absPath)
340
+ const units = await Promise.all(
341
+ subs
342
+ .filter((sub) => sub.isDirectory())
343
+ .map((sub) =>
344
+ unitFromFolder(`${displayPath}/${sub.name}`, join(absPath, sub.name)),
345
+ ),
346
+ )
347
+ return { units, skipped: [] }
348
+ }
349
+
350
+ async function processEntry(
351
+ scratchDisplay: string,
352
+ scratchDir: string,
353
+ name: string,
354
+ ): Promise<{ units: RawUnit[]; skipped: PruneSkip[] }> {
355
+ const abs = join(scratchDir, name)
356
+ const displayPath = `${scratchDisplay}/${name}`
357
+
358
+ const legacyTarget = LEGACY_MOVES.get(name)
359
+ if (legacyTarget !== undefined) {
360
+ return {
361
+ units: [],
362
+ skipped: [
363
+ {
364
+ path: displayPath,
365
+ reason: `moved to ${scratchDisplay}/${legacyTarget}, move or clear it by hand`,
366
+ },
367
+ ],
368
+ }
369
+ }
370
+
371
+ const migrationVerb = MIGRATED_SCRATCH_NAMES.get(name)
372
+ if (migrationVerb !== undefined) {
373
+ return {
374
+ units: [],
375
+ skipped: [
376
+ {
377
+ path: displayPath,
378
+ reason: `moves out under a different migration, run ${migrationVerb} to clear it`,
379
+ },
380
+ ],
381
+ }
382
+ }
383
+
384
+ if ((RESERVED as readonly string[]).includes(name)) {
385
+ return reservedUnits(name as (typeof RESERVED)[number], displayPath, abs)
386
+ }
387
+
388
+ return { units: [await unitFromFolder(displayPath, abs)], skipped: [] }
389
+ }
390
+
391
+ /**
392
+ * Every top-level entry is independent, per the concurrency standard's rule
393
+ * against running independent async work sequentially, so a scratch folder
394
+ * carrying dozens of slugs is walked concurrently rather than one at a time.
395
+ */
396
+ async function collectUnits(
397
+ scratchDisplay: string,
398
+ scratchDir: string,
399
+ ): Promise<{ units: RawUnit[]; skipped: PruneSkip[] }> {
400
+ const entries = await listDir(scratchDir)
401
+ const results = await Promise.all(
402
+ entries
403
+ .filter((entry) => entry.isDirectory())
404
+ .map((entry) => processEntry(scratchDisplay, scratchDir, entry.name)),
405
+ )
406
+
407
+ return {
408
+ units: results.flatMap((result) => result.units),
409
+ skipped: results.flatMap((result) => result.skipped),
410
+ }
411
+ }
412
+
413
+ async function deleteUnits(
414
+ units: readonly RawUnit[],
415
+ ): Promise<{ deleted: string[]; failed: PruneFailure[] }> {
416
+ const settled = await Promise.allSettled(
417
+ units.map(async (unit) => {
418
+ await Promise.all(
419
+ unit.targets.map((target) =>
420
+ rm(target, { recursive: true, force: true }),
421
+ ),
422
+ )
423
+ return unit.path
424
+ }),
425
+ )
426
+
427
+ const deleted: string[] = []
428
+ const failed: PruneFailure[] = []
429
+
430
+ settled.forEach((result, index) => {
431
+ if (result.status === 'fulfilled') {
432
+ deleted.push(result.value)
433
+ } else {
434
+ failed.push({
435
+ path: units[index].path,
436
+ message: describeFailure(result.reason),
437
+ })
438
+ }
439
+ })
440
+
441
+ return { deleted, failed }
442
+ }
443
+
444
+ /**
445
+ * Reports scratch nobody has touched inside the age window, and deletes it
446
+ * only when asked.
447
+ *
448
+ * Reads mtime the way `sizeRecords` does, so a machine restored by `canon
449
+ * records pull` reads every file as new and fails safe by offering nothing.
450
+ */
451
+ export async function pruneScratch(
452
+ root: string,
453
+ olderThanDays: number,
454
+ write: boolean,
455
+ now: number = Date.now(),
456
+ ): Promise<PruneOutcome> {
457
+ if (!RECORD_ROOTS.some((name) => existsSync(join(root, name)))) {
458
+ return {
459
+ ok: false,
460
+ reason: 'no-folder',
461
+ message: `No ${RECORD_ROOTS.join(' or ')} directory at ${root}, so there is no scratch folder to prune.`,
462
+ }
463
+ }
464
+
465
+ const scratchDir = recordDir(root, SCRATCH)
466
+ const scratchDisplay = relative(root, scratchDir)
467
+
468
+ const { units, skipped } = await collectUnits(scratchDisplay, scratchDir)
469
+
470
+ const thresholdMs = olderThanDays * DAY_MS
471
+ const prunable = units.filter((unit) => isPrunable(unit, thresholdMs, now))
472
+ const fresh = units.filter((unit) => !isPrunable(unit, thresholdMs, now))
473
+
474
+ const { deleted, failed } = write
475
+ ? await deleteUnits(prunable)
476
+ : { deleted: [], failed: [] }
477
+
478
+ return {
479
+ ok: true,
480
+ root,
481
+ olderThan: olderThanDays,
482
+ candidates: prunable.map(toPublic),
483
+ kept: fresh.map(toPublic),
484
+ skipped,
485
+ deleted,
486
+ failed,
487
+ }
488
+ }