@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.
Files changed (34) 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 +4 -4
  4. package/claude/skills/git-followup/SKILL.md +1 -1
  5. package/claude/skills/git-pr/SKILL.md +8 -8
  6. package/claude/skills/git-split/SKILL.md +19 -19
  7. package/claude/skills/memory-capture/SKILL.md +2 -2
  8. package/claude/skills/memory-review/SKILL.md +2 -2
  9. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  10. package/claude/skills/review-address/SKILL.md +11 -11
  11. package/claude/skills/review-pr/SKILL.md +2 -2
  12. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  13. package/claude/skills/role-orchestrator/scripts/poll.sh +1 -1
  14. package/claude/skills/teach-workspace/SKILL.md +2 -2
  15. package/claude/skills/ui-test/REQUIREMENT.md +1 -1
  16. package/claude/skills/ui-test/SKILL.md +2 -2
  17. package/docs/agents/commands.md +2 -1
  18. package/docs/agents/records.md +27 -0
  19. package/docs/agents/sandbox.md +1 -1
  20. package/docs/workflow/ai-workflow.md +4 -2
  21. package/governance/rules/core/055-scratch.md +1 -0
  22. package/package.json +1 -1
  23. package/scripts/tooling/verify.sh +2 -2
  24. package/src/claude/skills-headings.ts +1 -1
  25. package/src/commands/migrate.ts +1 -1
  26. package/src/commands/records.ts +159 -0
  27. package/src/migrate/record-layout.ts +2 -2
  28. package/src/migrate/scratch-evidence.ts +3 -5
  29. package/src/records/prune.ts +488 -0
  30. package/src/records/size.ts +24 -1
  31. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -2
  32. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
  33. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +2 -2
  34. package/tooling/claude/seeds/CLAUDE.md +2 -6
@@ -1164,7 +1164,7 @@ export function register(program: Command): void {
1164
1164
  ' review/{design,board,slides,diagrams}/ -> tmp/render/<kind>/',
1165
1165
  ' review/references/ -> picks/references/',
1166
1166
  ' review/branch/review-<slug>.md -> review/branch-<slug>.md',
1167
- ' review/ui-checklist-<slug>.md -> tmp/ui-checklist/<slug>.md',
1167
+ ' review/ui-checklist-<slug>.md -> tmp/handoff/ui-checklist/<slug>.md',
1168
1168
  ' review/evidence/<slug>/ -> picks/<slug>/ or evidence/<nn>-<slug>/',
1169
1169
  '',
1170
1170
  'An evidence folder is a pick when it directly holds an arm-<id>',
@@ -10,6 +10,12 @@ import {
10
10
  isOrdinalKind,
11
11
  ORDINAL_KINDS,
12
12
  } from '@/records/ordinal'
13
+ import {
14
+ DEFAULT_OLDER_THAN_DAYS,
15
+ type PruneReport,
16
+ type PruneUnit,
17
+ pruneScratch,
18
+ } from '@/records/prune'
13
19
  import {
14
20
  type FolderSize,
15
21
  formatBytes,
@@ -50,6 +56,9 @@ const EXIT_MIGRATABLE = 2
50
56
  /** Returned when `--claim` loses every retry to a collision. */
51
57
  const EXIT_CONTENDED = 2
52
58
 
59
+ /** Returned when candidates exist and `--write` was not passed. */
60
+ const EXIT_PRUNABLE = 2
61
+
53
62
  interface ValidateCommandOptions {
54
63
  readonly json?: boolean
55
64
  readonly root?: string
@@ -61,6 +70,11 @@ interface MigrateCommandOptions extends ValidateCommandOptions {
61
70
  readonly write?: boolean
62
71
  }
63
72
 
73
+ interface PruneCommandOptions extends ValidateCommandOptions {
74
+ readonly write?: boolean
75
+ readonly olderThan?: string
76
+ }
77
+
64
78
  interface OrdinalCommandOptions extends ValidateCommandOptions {
65
79
  readonly claim?: boolean
66
80
  }
@@ -233,6 +247,55 @@ export function register(program: Command): void {
233
247
  process.exitCode = await runSize(opts)
234
248
  })
235
249
 
250
+ records
251
+ .command('prune-tmp')
252
+ .description('Report scratch nobody has touched in a while, and delete it')
253
+ .helpOption('-h, --help', 'Show this help message')
254
+ .option('--json', 'Add a machine-readable record on stdout')
255
+ .option('--write', 'Delete every candidate the report lists')
256
+ .option(
257
+ '--older-than <days>',
258
+ `Age in days a unit's newest file must clear to be offered (default ${DEFAULT_OLDER_THAN_DAYS})`,
259
+ )
260
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
261
+ .addHelpText(
262
+ 'after',
263
+ [
264
+ '',
265
+ 'A candidate is a unit whose newest file is older than --older-than:',
266
+ ' a tmp/<slug>/ folder, a folder one level inside runs/, render/, or',
267
+ ' pr/, or a single marker file inside hooks/<hook>/. A unit with no',
268
+ ' files is offered whatever its age.',
269
+ '',
270
+ 'tmp/handoff/ and tmp/pr/poll/ are never offered, since a reader deletes',
271
+ 'a handoff itself and a poll baseline is live state. The pre-split names',
272
+ 'the same two folders replaced (memory-routing/, teach-promotion/,',
273
+ 'ui-checklist/, pr-poll/) are skipped the same way rather than offered',
274
+ 'as ordinary slugs.',
275
+ '',
276
+ 'Exit codes:',
277
+ ' 0 nothing to prune, or --write deleted every candidate',
278
+ ' 1 refused, with the reason on stderr or in the JSON record, or a',
279
+ ' delete failed',
280
+ ' 2 candidates exist and --write was not passed',
281
+ '',
282
+ 'It writes nothing until --write is passed, matching canon records',
283
+ 'migrate: a session record has no history to undo a wrong delete from.',
284
+ 'It reads mtime like canon records size, so a machine restored by canon',
285
+ 'records pull reads everything as new, which fails safe by offering',
286
+ 'nothing.',
287
+ '',
288
+ 'Examples:',
289
+ ' canon records prune-tmp',
290
+ ' canon records prune-tmp --write',
291
+ ' canon records prune-tmp --older-than 30 --json',
292
+ '',
293
+ ].join('\n'),
294
+ )
295
+ .action(async (opts: PruneCommandOptions) => {
296
+ process.exitCode = await runPruneTmp(opts)
297
+ })
298
+
236
299
  records
237
300
  .command('push')
238
301
  .description(
@@ -390,6 +453,102 @@ function reportSize(outcome: Extract<SizeOutcome, { ok: true }>): void {
390
453
  outro()
391
454
  }
392
455
 
456
+ async function runPruneTmp(opts: PruneCommandOptions): Promise<number> {
457
+ const emitJson = opts.json ?? false
458
+ const olderThan = Number(opts.olderThan ?? DEFAULT_OLDER_THAN_DAYS)
459
+
460
+ if (!Number.isFinite(olderThan) || olderThan <= 0) {
461
+ return reportRefusal(
462
+ 'canon records prune-tmp',
463
+ {
464
+ reason: 'bad-older-than',
465
+ message: `--older-than must be a positive number of days, got ${opts.olderThan}.`,
466
+ },
467
+ emitJson,
468
+ )
469
+ }
470
+
471
+ const root = opts.root ?? (await mainWorktreeRoot())
472
+ const write = opts.write ?? false
473
+ const outcome = await pruneScratch(root, olderThan, write)
474
+
475
+ if (!outcome.ok)
476
+ return reportRefusal('canon records prune-tmp', outcome, emitJson)
477
+
478
+ return reportPrune(outcome, emitJson)
479
+ }
480
+
481
+ function pruneRow(entry: PruneUnit): string[] {
482
+ return [
483
+ entry.path,
484
+ String(entry.files),
485
+ formatBytes(entry.bytes),
486
+ entry.newest ?? '',
487
+ ]
488
+ }
489
+
490
+ function pruneExitCode(outcome: PruneReport): number {
491
+ if (outcome.failed.length > 0) return 1
492
+ if (outcome.candidates.length > 0 && outcome.deleted.length === 0)
493
+ return EXIT_PRUNABLE
494
+ return 0
495
+ }
496
+
497
+ function reportPrune(outcome: PruneReport, emitJson: boolean): number {
498
+ const exit = pruneExitCode(outcome)
499
+
500
+ if (emitJson) {
501
+ process.stdout.write(`${JSON.stringify(outcome)}\n`)
502
+ return exit
503
+ }
504
+
505
+ intro('canon records prune-tmp')
506
+
507
+ if (outcome.candidates.length === 0) {
508
+ logStep('Prunable')
509
+ logInfo(`none, older than ${plural(outcome.olderThan, 'day')}`)
510
+ } else {
511
+ logStep(outcome.deleted.length > 0 ? 'Deleted' : 'Prunable')
512
+
513
+ const headers = ['path', 'files', 'size', 'newest']
514
+ const rows = outcome.candidates.map(pruneRow)
515
+ const widths = headers.map((header, column) =>
516
+ columnWidth(
517
+ header,
518
+ rows.map((row) => row[column]),
519
+ ),
520
+ )
521
+ const render = (cells: readonly string[]): string =>
522
+ cells
523
+ .map((cell, column) =>
524
+ column === 0
525
+ ? cell.padEnd(widths[column])
526
+ : cell.padStart(widths[column]),
527
+ )
528
+ .join(' ')
529
+ .trimEnd()
530
+
531
+ pipeOutput([render(headers), ...rows.map(render)].join('\n'))
532
+
533
+ if (outcome.deleted.length === 0) logInfo('Re-run with --write to delete.')
534
+ }
535
+
536
+ if (outcome.skipped.length > 0) {
537
+ logStep('Skipped')
538
+ for (const entry of outcome.skipped)
539
+ logInfo(`${entry.path}: ${entry.reason}`)
540
+ }
541
+
542
+ if (outcome.failed.length > 0) {
543
+ logStep('Failed')
544
+ for (const entry of outcome.failed)
545
+ logWarn(`${entry.path}: ${entry.message}`)
546
+ }
547
+
548
+ outro()
549
+ return exit
550
+ }
551
+
393
552
  async function runPush(opts: BackupCommandOptions): Promise<number> {
394
553
  const root = opts.root ?? (await mainWorktreeRoot())
395
554
  const outcome = await pushRecords(root)
@@ -6,7 +6,7 @@
6
6
  * feedback moves to `.canon/feedback/`, option captures to `.canon/picks/`,
7
7
  * claim-backing folders to a numbered `.canon/evidence/`, renders rebuilt from
8
8
  * committed sources to `.canon/tmp/render/`, a branch report flattens to
9
- * `review/branch-<slug>.md`, and a flat checklist joins the `tmp/ui-checklist/`
9
+ * `review/branch-<slug>.md`, and a flat checklist joins the `tmp/handoff/ui-checklist/`
10
10
  * handoff folder. `canon records push` carries every top-level `.canon/`
11
11
  * entry except `EXCLUDED_ENTRIES`, so the new root folders are backed with no
12
12
  * list edit, and `tmp/render/` is deliberately not.
@@ -120,7 +120,7 @@ export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
120
120
  kind: 'files',
121
121
  from: ['review'],
122
122
  prefix: 'ui-checklist-',
123
- to: [SCRATCH, 'ui-checklist'],
123
+ to: [SCRATCH, 'handoff', 'ui-checklist'],
124
124
  renamed: '',
125
125
  prune: false,
126
126
  },
@@ -48,11 +48,9 @@ import { recordDir, SCRATCH } from '@/record-root'
48
48
  * `verify-astro` and `verify-vite-react` pass the same test and are excluded
49
49
  * by name: both are scaffolds a command generates rather than records a
50
50
  * session wrote, and each is 100+ MB, which the review remote is not sized
51
- * for. `ablation`, `eval-runs`, `sandbox-runs`, `memory-archive`,
52
- * `groundwork-fixtures`, `precompact-handoff`, `pr-poll`, `pr`,
53
- * `address-review`, and `memory-routing` fail the second clause: a script or
54
- * a skill body names each of those paths, so moving one needs a code change
55
- * 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.
56
54
  */
57
55
  export const PROMOTED_FOLDERS: readonly string[] = [
58
56
  'hero-probe',