@erclx/canon 4.87.0 → 4.88.1

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 (36) 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/draft-and-pick/REQUIREMENT.md +10 -1
  5. package/claude/skills/draft-and-pick/SKILL.md +7 -3
  6. package/claude/skills/git-followup/SKILL.md +1 -1
  7. package/claude/skills/git-pr/SKILL.md +8 -8
  8. package/claude/skills/git-split/SKILL.md +19 -19
  9. package/claude/skills/memory-capture/SKILL.md +2 -2
  10. package/claude/skills/memory-review/SKILL.md +2 -2
  11. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  12. package/claude/skills/review-address/SKILL.md +11 -11
  13. package/claude/skills/review-pr/SKILL.md +2 -2
  14. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  15. package/claude/skills/role-orchestrator/scripts/poll.sh +1 -1
  16. package/claude/skills/teach-workspace/SKILL.md +2 -2
  17. package/claude/skills/ui-test/REQUIREMENT.md +1 -1
  18. package/claude/skills/ui-test/SKILL.md +2 -2
  19. package/docs/agents/commands.md +2 -1
  20. package/docs/agents/records.md +27 -0
  21. package/docs/agents/sandbox.md +1 -1
  22. package/docs/workflow/ai-workflow.md +4 -2
  23. package/governance/rules/core/055-scratch.md +1 -0
  24. package/package.json +1 -1
  25. package/scripts/tooling/verify.sh +2 -2
  26. package/src/claude/skills-headings.ts +1 -1
  27. package/src/commands/migrate.ts +1 -1
  28. package/src/commands/records.ts +159 -0
  29. package/src/migrate/record-layout.ts +2 -2
  30. package/src/migrate/scratch-evidence.ts +3 -5
  31. package/src/records/prune.ts +488 -0
  32. package/src/records/size.ts +24 -1
  33. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -2
  34. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
  35. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +2 -2
  36. package/tooling/claude/seeds/CLAUDE.md +2 -6
@@ -63,7 +63,7 @@ Work in Claude Code directly. It reads `CLAUDE.md` automatically and has full fi
63
63
 
64
64
  The routing test is whether the repository can answer an item today. A session grepping handles the yes, and a groundwork track handles the no.
65
65
 
66
- A groundwork track may run experiments to settle a question, writing a fixture it reads itself under `.canon/tmp/groundwork-fixtures/<slug>/` and spawning up to three billed headless runs before it asks. A fixture a headless run is pointed at sits outside the repository, since a session started under the project root inherits that project's `CLAUDE.md` and rules and would measure them instead of the arm.
66
+ A groundwork track may run experiments to settle a question, writing a fixture it reads itself under `.canon/tmp/runs/groundwork-fixtures/<slug>/` and spawning up to three billed headless runs before it asks. A fixture a headless run is pointed at sits outside the repository, since a session started under the project root inherits that project's `CLAUDE.md` and rules and would measure them instead of the arm.
67
67
 
68
68
  What a spike produces goes somewhere else again. An input the run reads is re-runnable and cited by nothing, so the scratch path above is the right lifetime for it, while a recording or a render the track cites as evidence for a finding is what a later reader opens to check the claim. Evidence therefore lives in `evidence/` inside the track beside the file citing it, since the scratch tree holds only what can be deleted without loss.
69
69
 
@@ -81,7 +81,7 @@ When features are independent, run them in parallel instead of sequentially. Use
81
81
 
82
82
  - Create a worktree per feature, then start a Claude Code session in each
83
83
  - Invoke `canon:plan-feature` in each session. Plans land at the main worktree root as `.canon/plans/feature-<slug>.md`, one per feature, no collisions. Small features stay in chat and skip the file.
84
- - Implement, verify, and review each feature independently. `review-branch` writes a per-branch report at the main worktree root (`review/branch-<slug>.md`), and `ui-test` writes a per-branch checklist handoff there too (`tmp/ui-checklist/<slug>.md`) that `git-pr` posts to the pull request and removes, so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
84
+ - Implement, verify, and review each feature independently. `review-branch` writes a per-branch report at the main worktree root (`review/branch-<slug>.md`), and `ui-test` writes a per-branch checklist handoff there too (`tmp/handoff/ui-checklist/<slug>.md`) that `git-pr` posts to the pull request and removes, so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
85
85
  - Ship each worktree separately with `canon:git-ship`
86
86
  - For full autonomy per worktree, invoke `canon:auto-ship` instead of the manual chain. Approve the plan, walk away, come back to a pull request the chain marked as a draft and then read the flag back on. The mark says the work has had no review yet, and it holds no window, since readying a pull request to merge lifts it directly, an act reserved to the operator or to the controlling session that closed the review.
87
87
 
@@ -111,6 +111,8 @@ A person points it at a private repository once and both verbs refuse until they
111
111
 
112
112
  `canon records size` reports what each of these folders holds, heaviest first, along with `.canon/tmp`. Each row carries the file count, the bytes, how many files were written in the last 7 and 30 days, and the dates of the least and most recently written one. Nothing fails on a number, because a record folder has no correct size. What the verb replaces is a reading somebody had to remember to take: the memory pen went from 44 entries to 236 between two counts made by hand a fortnight apart, and nothing reported the rate in between.
113
113
 
114
+ `canon records prune-tmp` reports scratch under `.canon/tmp` nobody has touched inside an age window, and deletes it only with `--write`. A `tmp/handoff/` folder and the live `tmp/pr/poll/` baseline are never offered, since a reader deletes a handoff once it is read and a poll baseline is state a session still needs. See [records](../agents/records.md) for the candidate shapes and the exit codes.
115
+
114
116
  A plan that ships is archived, never deleted. `canon tasks archive` moves it to `.canon/plans/archive/` alongside the task it belonged to and retargets that task's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. An archive sits inside the record folder it archives rather than beside it, so one ignore entry and one backed-folder entry cover a record and everything it has retired. The folder is gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
115
117
 
116
118
  A branch review report takes the other route and is swept rather than archived. `review-branch` writes it flat into `.canon/review/` as `branch-<slug>.md`, the session addressing it reads it once, and the durable record of what a review found is the comment `review-pr` posts on the pull request, so `docs-fold` deletes any report whose branch is gone. The body that writes a report owns how long it lives, which leaves the shipping branch's own report on disk through the run that cites it and collects it a branch later. What that loses is a local-only review on a branch that never opened a pull request, which is why the report says so where a reader meets it.
@@ -9,3 +9,4 @@ description: Write temporary files to the scratch folder structure
9
9
  - Write temporary files to `.canon/tmp/<slug>/<file>.md` in the project root, a nested `<slug>/` folder with a kebab-slug tied to the topic, not a flat `<slug>-<file>.md`. The scratch-guard hook enforces the location.
10
10
  - Write to `.claude/.tmp/<slug>/` instead in a project that carries no `.canon/` root, which is one the record move has not reached. The hook accepts either, but the ignore file is the source: `.gitignore` and its manifest counterpart are what keep the fallback spelling out of `git status`, and the hook and this rule both defer to it. `canon migrate records` is what moves a project from this second spelling to the first. <!-- canon-keep-record-root -->
11
11
  - "The project root" above means the current worktree by default. `085-worktrees.md` states the same mixed default for `.canon/tmp/` and defers to each skill's own body for which write resolves at the main root instead.
12
+ - `runs/`, `hooks/`, `handoff/`, `pr/`, and `render/` are reserved under `tmp/` for build and command output that can be regenerated, hook state, skill-to-skill handoff, pull request scratch, and render output. A session slug must not take one of these five names.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.87.0",
4
+ "version": "4.88.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -16,7 +16,7 @@ show_help() {
16
16
  echo -e "${GREY}│${NC} --keep ${GREY}# Keep tmp dir after run for inspection${NC}"
17
17
  echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
18
18
  echo -e "${GREY}│${NC}"
19
- echo -e "${GREY}│${NC} Scaffolds <stack> into .canon/tmp/verify-<stack>/, syncs the full"
19
+ echo -e "${GREY}│${NC} Scaffolds <stack> into .canon/tmp/runs/verify-<stack>/, syncs the full"
20
20
  echo -e "${GREY}│${NC} layer chain, and runs check + test:e2e + screenshot."
21
21
  echo -e "${GREY}└${NC}"
22
22
  exit 0
@@ -102,7 +102,7 @@ main() {
102
102
  open_timeline "canon tooling verify $stack"
103
103
  trap close_timeline EXIT
104
104
 
105
- local tmp_root="$PROJECT_ROOT/.canon/tmp"
105
+ local tmp_root="$PROJECT_ROOT/.canon/tmp/runs"
106
106
  local tmp_dir="$tmp_root/verify-$stack"
107
107
 
108
108
  mkdir -p "$tmp_root"
@@ -21,7 +21,7 @@
21
21
  * `markdown.md` has a body backtick a named thing, so the natural spelling of
22
22
  * the citation is `` See the `Parallel sessions` heading in `CLAUDE.md` `` and
23
23
  * a class stopping at the first backtick never reaches the file. That heading
24
- * is real in this repository's own root file. Both classes returned the same
24
+ * was real in this repository's own root file when the check was written. Both classes returned the same
25
25
  * twenty lines over `claude/skills/` at the stamp, so the backtick exclusion
26
26
  * was carrying nothing and hiding the spelling an author would reach for.
27
27
  *
@@ -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',