@erclx/canon 4.87.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 +1 -1
- package/claude/skills/docs-fold/SKILL.md +4 -4
- 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-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/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 +2 -1
- package/docs/agents/records.md +27 -0
- package/docs/agents/sandbox.md +1 -1
- package/docs/workflow/ai-workflow.md +4 -2
- 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/commands/migrate.ts +1 -1
- package/src/commands/records.ts +159 -0
- package/src/migrate/record-layout.ts +2 -2
- package/src/migrate/scratch-evidence.ts +3 -5
- package/src/records/prune.ts +488 -0
- package/src/records/size.ts +24 -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
|
@@ -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
|
+
}
|
package/src/records/size.ts
CHANGED
|
@@ -87,7 +87,7 @@ interface Walked {
|
|
|
87
87
|
* already, since these folders are gitignored and hold whatever that disk holds,
|
|
88
88
|
* so a local date is the answer consistent with the rest of the report.
|
|
89
89
|
*/
|
|
90
|
-
function day(ms: number): string {
|
|
90
|
+
export function day(ms: number): string {
|
|
91
91
|
const at = new Date(ms)
|
|
92
92
|
const month = String(at.getMonth() + 1).padStart(2, '0')
|
|
93
93
|
const date = String(at.getDate()).padStart(2, '0')
|
|
@@ -245,6 +245,29 @@ export async function sizeRecords(
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The file count, byte total, and newest `mtime` under one path, for a caller
|
|
250
|
+
* that wants a unit's freshness rather than the full per-window reading.
|
|
251
|
+
*
|
|
252
|
+
* It walks with the same `walk`/`absorb` pair `sizeRecords` uses, so the two
|
|
253
|
+
* verbs agree on what counts as a file and which mtime a rewritten entry
|
|
254
|
+
* carries, rather than each answering from a second walk of its own.
|
|
255
|
+
*/
|
|
256
|
+
export async function newestMtime(
|
|
257
|
+
path: string,
|
|
258
|
+
): Promise<{ files: number; bytes: number; newest?: number } | undefined> {
|
|
259
|
+
if (!existsSync(path)) return undefined
|
|
260
|
+
|
|
261
|
+
const walked: Walked = {
|
|
262
|
+
files: 0,
|
|
263
|
+
bytes: 0,
|
|
264
|
+
touched: GROWTH_WINDOWS.map(() => 0),
|
|
265
|
+
}
|
|
266
|
+
await walk(path, walked, Date.now())
|
|
267
|
+
|
|
268
|
+
return { files: walked.files, bytes: walked.bytes, newest: walked.newest }
|
|
269
|
+
}
|
|
270
|
+
|
|
248
271
|
const UNITS = ['B', 'K', 'M', 'G'] as const
|
|
249
272
|
|
|
250
273
|
/**
|
|
@@ -44,9 +44,9 @@ key=$(printf '%s__%s' "$session" "$index" | tr -c 'A-Za-z0-9' '_')
|
|
|
44
44
|
# root the project carries rather than creating a second one beside it.
|
|
45
45
|
project="${CLAUDE_PROJECT_DIR:-.}"
|
|
46
46
|
if [ -d "$project/.canon" ]; then
|
|
47
|
-
marker_dir="$project/.canon/tmp/index-reminder"
|
|
47
|
+
marker_dir="$project/.canon/tmp/hooks/index-reminder"
|
|
48
48
|
else
|
|
49
|
-
marker_dir="$project/.claude/.tmp/index-reminder"
|
|
49
|
+
marker_dir="$project/.claude/.tmp/hooks/index-reminder"
|
|
50
50
|
fi
|
|
51
51
|
marker="$marker_dir/$key"
|
|
52
52
|
[ -f "$marker" ] && exit 0
|
|
@@ -43,9 +43,9 @@ esac
|
|
|
43
43
|
# The log is scratch, so it follows the scratch folder to whichever record root
|
|
44
44
|
# the project carries rather than creating a second one beside it.
|
|
45
45
|
if [ -d "$root/.canon" ]; then
|
|
46
|
-
log_dir="$root/.canon/tmp/pr
|
|
46
|
+
log_dir="$root/.canon/tmp/pr/log"
|
|
47
47
|
else
|
|
48
|
-
log_dir="$root/.claude/.tmp/pr
|
|
48
|
+
log_dir="$root/.claude/.tmp/pr/log"
|
|
49
49
|
fi
|
|
50
50
|
mkdir -p "$log_dir"
|
|
51
51
|
session=$(printf '%s' "$input" | jq -r '.session_id // "unknown"')
|
|
@@ -48,9 +48,9 @@ session=$(printf '%s' "$input" | jq -r '.session_id // "none"')
|
|
|
48
48
|
key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9' '_')
|
|
49
49
|
project="${CLAUDE_PROJECT_DIR:-.}"
|
|
50
50
|
if [ -d "$project/.canon" ]; then
|
|
51
|
-
marker_dir="$project/.canon/tmp/scratch-guard"
|
|
51
|
+
marker_dir="$project/.canon/tmp/hooks/scratch-guard"
|
|
52
52
|
else
|
|
53
|
-
marker_dir="$project/.claude/.tmp/scratch-guard"
|
|
53
|
+
marker_dir="$project/.claude/.tmp/hooks/scratch-guard"
|
|
54
54
|
fi
|
|
55
55
|
marker="$marker_dir/$key"
|
|
56
56
|
[ -f "$marker" ] && exit 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Project
|
|
2
2
|
|
|
3
|
-
[One
|
|
3
|
+
[One or two line description]
|
|
4
4
|
|
|
5
5
|
## Context
|
|
6
6
|
|
|
@@ -13,13 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
## Commands
|
|
15
15
|
|
|
16
|
-
-
|
|
17
|
-
- [Command to run before committing]. Full script reference in the development entry under `canon/context/`.
|
|
16
|
+
- [Command to run before committing]
|
|
18
17
|
|
|
19
18
|
## Key paths
|
|
20
19
|
|
|
21
20
|
- `src/`: [description]
|
|
22
21
|
- `canon/DESIGN.md`: design tokens and the visual system
|
|
23
|
-
- `canon/context/`: per-domain narrative (how a domain is structured, decisions, gotchas), indexed via `canon/context/index.md`
|
|
24
|
-
- `canon/wireframes/`: per-surface regions and states loaded on demand, indexed via `canon/wireframes/index.md`
|
|
25
|
-
- `canon/decisions/`: decision history a project doc points at, never loaded eagerly
|