@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +6 -6
- package/claude/skills/canon-feedback-file/SKILL.md +2 -2
- package/claude/skills/design-extract/SKILL.md +3 -3
- package/claude/skills/docs-fold/SKILL.md +6 -6
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/draft-diagram/SKILL.md +2 -2
- package/claude/skills/draft-slides/SKILL.md +1 -1
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +8 -8
- package/claude/skills/git-split/SKILL.md +19 -19
- package/claude/skills/memory-capture/SKILL.md +2 -2
- package/claude/skills/memory-review/SKILL.md +2 -2
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +11 -11
- package/claude/skills/review-branch/SKILL.md +3 -3
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +1 -1
- package/claude/skills/sketch-design/SKILL.md +4 -4
- package/claude/skills/teach-workspace/SKILL.md +2 -2
- package/claude/skills/ui-test/REQUIREMENT.md +1 -1
- package/claude/skills/ui-test/SKILL.md +2 -2
- package/docs/agents/commands.md +99 -96
- package/docs/agents/design-board.md +7 -7
- package/docs/agents/records.md +27 -0
- package/docs/agents/sandbox.md +1 -1
- package/docs/workflow/ai-workflow.md +5 -3
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/core/055-scratch.md +1 -0
- package/package.json +1 -1
- package/scripts/tooling/verify.sh +2 -2
- package/src/claude/skills-headings.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/design.ts +3 -3
- package/src/commands/feedback.ts +12 -12
- package/src/commands/migrate.ts +36 -13
- package/src/commands/records.ts +159 -0
- package/src/commands/slides.ts +2 -2
- package/src/design/board.ts +17 -10
- package/src/migrate/evidence-ordinal.ts +79 -0
- package/src/migrate/record-layout.ts +328 -75
- package/src/migrate/scratch-evidence.ts +60 -35
- package/src/record-root.ts +3 -0
- package/src/records/prune.ts +488 -0
- package/src/records/size.ts +24 -1
- package/standards/publish.md +1 -1
- package/standards/skill.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +2 -2
- 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/
|
|
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`, `
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
/**
|
|
70
|
-
|
|
71
|
-
return recordDir(root,
|
|
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
|
|
106
|
+
readonly name: string
|
|
104
107
|
}
|
|
105
108
|
|
|
106
|
-
/** One rewrite per folder,
|
|
107
|
-
function buildRewrites(
|
|
108
|
-
return
|
|
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
|
-
|
|
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,
|
|
135
|
-
current.replace(pattern, `.canon/
|
|
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/
|
|
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,
|
|
236
|
-
*
|
|
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
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
package/src/record-root.ts
CHANGED
|
@@ -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
|
+
}
|