@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
@@ -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
+ }
@@ -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-create-log"
46
+ log_dir="$root/.canon/tmp/pr/log"
47
47
  else
48
- log_dir="$root/.claude/.tmp/pr-create-log"
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-line description]
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
- - These conventions came from a toolkit with its own CLI. A rule or standard naming a command is naming that CLI, present only where this project installed it.
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