@erclx/canon 4.80.0 → 4.82.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 (39) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/design-extract/SKILL.md +5 -1
  3. package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
  4. package/claude/skills/sketch-design/SKILL.md +92 -0
  5. package/claude/skills/teach-workspace/SKILL.md +23 -1
  6. package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
  7. package/docs/agents/commands.md +11 -5
  8. package/docs/agents/context-audit.md +1 -1
  9. package/docs/agents/context-classify.md +99 -0
  10. package/docs/agents/design-board.md +13 -11
  11. package/docs/agents/index.md +2 -1
  12. package/docs/agents/teach.md +16 -0
  13. package/docs/target-projects.md +15 -0
  14. package/docs/workflow/ai-workflow.md +4 -2
  15. package/docs/workflow/visual-design-workflow.md +1 -0
  16. package/governance/rules/claude/545-decisions.md +12 -0
  17. package/package.json +1 -1
  18. package/src/claude/cases/misc.ts +5 -0
  19. package/src/claude/seeds.ts +1 -0
  20. package/src/commands/claude.ts +26 -5
  21. package/src/commands/context.ts +425 -0
  22. package/src/commands/design.ts +24 -8
  23. package/src/commands/teach.ts +118 -0
  24. package/src/context/classify/extract.ts +450 -0
  25. package/src/context/classify/ollama.ts +172 -0
  26. package/src/context/classify/patterns.ts +114 -0
  27. package/src/context/classify/prompts.ts +73 -0
  28. package/src/context/classify/run.ts +348 -0
  29. package/src/context/classify/settings.ts +196 -0
  30. package/src/context/folders.ts +1 -0
  31. package/src/design/board.ts +130 -47
  32. package/src/project-root.ts +16 -0
  33. package/src/surface-root.ts +1 -0
  34. package/src/teach/render.ts +126 -0
  35. package/standards/decisions.md +100 -0
  36. package/standards/index.md +1 -0
  37. package/tooling/claude/reference.md +1 -0
  38. package/tooling/claude/seeds/CLAUDE.md +1 -0
  39. package/tooling/claude/seeds/canon/decisions/index.md +8 -0
@@ -11,13 +11,17 @@ import { join, relative, sep } from 'node:path'
11
11
  import { DESIGN_DOCUMENT } from '@/design/regen'
12
12
  import { renderDesignDoc } from '@/design/render'
13
13
  import { colorValue } from '@/design/tokens'
14
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
14
15
  import { recordDir } from '@/record-root'
16
+ import { surfaceDir } from '@/surface-root'
15
17
 
16
18
  /**
17
- * The one named site for this repository's wireframe corpus, so the surface
18
- * move retargets a single constant rather than a literal repeated per panel.
19
- * The board reads this checkout alone, which has moved, so the new root is
20
- * spelled directly rather than resolved.
19
+ * The wireframes panel's creation-root spelling, used in its own empty-state
20
+ * message and by this module's tests to seed a fixture. The panel itself
21
+ * resolves its actual read path through `surfaceDir`, which also reads
22
+ * `.claude/wireframes/` for a target that has not run the surface-roots
23
+ * migration, so this constant names where a fresh file lands rather than
24
+ * where every root's corpus necessarily sits.
21
25
  */
22
26
  export const WIREFRAME_DIR = join('canon', 'wireframes')
23
27
 
@@ -34,26 +38,6 @@ const WEB_DIST_ENTRY = 'index.html'
34
38
  const WEB_GALLERY_DIST = join('web', 'gallery-dist')
35
39
  const WEB_GALLERY_DIST_ENTRY = 'index.html'
36
40
 
37
- interface WireframeEntry {
38
- readonly path: string
39
- readonly describes: string
40
- }
41
-
42
- /**
43
- * The six files the wireframes panel renders, each beside the surface it
44
- * describes. `index.md` at either level is a catalog rather than a wireframe
45
- * and is excluded, which is why this list holds six rather than the eight
46
- * files the corpus carries today.
47
- */
48
- const WIREFRAMES: readonly WireframeEntry[] = [
49
- { path: 'landing-page.md', describes: 'The canon.erclx.dev landing page' },
50
- { path: 'slides.md', describes: 'The SLIDES.md render' },
51
- { path: 'teach/root.md', describes: 'A teach workspace root listing' },
52
- { path: 'teach/contents.md', describes: 'A workspace contents page' },
53
- { path: 'teach/lesson.md', describes: 'A lesson page and quiz stepper' },
54
- { path: 'teach/chrome.md', describes: 'The shared teach chrome' },
55
- ]
56
-
57
41
  const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.webp']
58
42
 
59
43
  export interface BoardPanel {
@@ -233,7 +217,8 @@ iframe { width: 100%; height: 480px; border: 1px solid var(--board-border); bord
233
217
  <body>
234
218
  <header>
235
219
  <h1>Design board</h1>
236
- <p class="meta">Generated by <code>canon design board</code>. Repository-local, never installed into a target.</p>
220
+ <p class="meta">Generated by <code>canon design board</code>. Reads from a caller-resolved root, never installed or synced.</p>
221
+ <p class="meta">Drafting a new candidate: <code>canon:draft-and-pick</code>. Auditing a rendered surface: <code>canon:ux-audit</code>.</p>
237
222
  </header>
238
223
  <nav>${navLinks}${THEME_TOGGLE_BUTTON}</nav>
239
224
  <main>
@@ -282,7 +267,7 @@ ${body}
282
267
  }
283
268
 
284
269
  function writeTokensPanel(root: string, outDir: string): void {
285
- const sourcePath = join(root, DESIGN_DOCUMENT)
270
+ const sourcePath = surfaceDir(root, 'DESIGN.md')
286
271
  const dir = join(outDir, 'tokens')
287
272
 
288
273
  if (!existsSync(sourcePath)) {
@@ -300,18 +285,56 @@ function writeTokensPanel(root: string, outDir: string): void {
300
285
  renderDesignDoc(sourcePath, dir)
301
286
  }
302
287
 
288
+ interface WireframeFile {
289
+ readonly relPath: string
290
+ readonly text: string
291
+ readonly description: string | undefined
292
+ }
293
+
294
+ /**
295
+ * Every wireframe under whichever root the corpus resolves at, sorted for a
296
+ * stable render order across runs. `surfaceDir` reads `.claude/wireframes/`
297
+ * for a target that has not run the surface-roots migration, the same
298
+ * fallback `recordDir` already gives the surfaces panel's teach half.
299
+ * `index.md` at any depth is a catalog rather than a wireframe and is
300
+ * excluded. A glob emits OS-native separators, and the corpus nests one
301
+ * level deep, so the label always reads with forward slashes regardless of
302
+ * platform.
303
+ */
304
+ function listWireframes(root: string): readonly WireframeFile[] {
305
+ const dir = surfaceDir(root, 'wireframes')
306
+ if (!existsSync(dir)) return []
307
+
308
+ const glob = new Bun.Glob('**/*.md')
309
+ const relPaths = [...glob.scanSync({ cwd: dir })]
310
+ .filter((relPath) => relPath.split(sep).pop() !== 'index.md')
311
+ .map((relPath) => relPath.split(sep).join('/'))
312
+ .sort()
313
+
314
+ return relPaths.map((relPath) => {
315
+ const text = readFileSync(join(dir, relPath), 'utf8')
316
+ return {
317
+ relPath,
318
+ text,
319
+ description: readField(parseFrontmatter(text), 'description'),
320
+ }
321
+ })
322
+ }
323
+
303
324
  function writeWireframesPanel(root: string, outDir: string): void {
304
325
  const dir = join(outDir, 'wireframes')
305
326
  mkdirSync(dir, { recursive: true })
306
327
 
307
- const sections = WIREFRAMES.map((entry) => {
308
- const sourcePath = join(root, WIREFRAME_DIR, entry.path)
309
- if (!existsSync(sourcePath)) {
310
- return `<h2>${escapeHtml(entry.path)}</h2>\n<p class="empty">Missing from ${WIREFRAME_DIR}/.</p>`
311
- }
312
- const text = readFileSync(sourcePath, 'utf8')
313
- return `<h2>${escapeHtml(entry.path)}</h2>\n<p>${escapeHtml(entry.describes)}</p>\n<pre>${escapeHtml(text)}</pre>`
314
- }).join('\n')
328
+ const files = listWireframes(root)
329
+ const sections =
330
+ files.length === 0
331
+ ? `<p class="empty">No wireframe files under ${WIREFRAME_DIR}/.</p>`
332
+ : files
333
+ .map((file) => {
334
+ const description = file.description ?? file.relPath
335
+ return `<h2>${escapeHtml(file.relPath)}</h2>\n<p>${escapeHtml(description)}</p>\n<pre>${escapeHtml(file.text)}</pre>`
336
+ })
337
+ .join('\n')
315
338
 
316
339
  writeFileSync(join(dir, 'index.html'), panelPage('Wireframes', sections))
317
340
  }
@@ -322,15 +345,21 @@ function copyBuilt(source: string, dest: string): void {
322
345
  cpSync(source, dest, { recursive: true })
323
346
  }
324
347
 
325
- function writeSurfacesPanel(root: string, outDir: string): void {
348
+ function writeSurfacesPanel(
349
+ root: string,
350
+ outDir: string,
351
+ isToolkitCheckout: boolean,
352
+ ): void {
326
353
  const dir = join(outDir, 'surfaces')
327
354
  mkdirSync(dir, { recursive: true })
328
355
 
329
356
  const distSource = join(root, WEB_DIST)
330
- const landingBody = existsSync(join(distSource, WEB_DIST_ENTRY))
331
- ? (copyBuilt(distSource, join(dir, 'landing')),
332
- '<iframe src="landing/index.html" loading="lazy"></iframe>')
333
- : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
357
+ const landingBody = !isToolkitCheckout
358
+ ? `<p class="empty">The landing page panel only renders in this toolkit's own checkout.</p>`
359
+ : existsSync(join(distSource, WEB_DIST_ENTRY))
360
+ ? (copyBuilt(distSource, join(dir, 'landing')),
361
+ '<iframe src="landing/index.html" loading="lazy"></iframe>')
362
+ : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
334
363
 
335
364
  const teachSource = recordDir(root, 'teach')
336
365
  const teachBody = existsSync(join(teachSource, 'index.html'))
@@ -347,15 +376,21 @@ function writeSurfacesPanel(root: string, outDir: string): void {
347
376
  )
348
377
  }
349
378
 
350
- function writeComponentsPanel(root: string, outDir: string): void {
379
+ function writeComponentsPanel(
380
+ root: string,
381
+ outDir: string,
382
+ isToolkitCheckout: boolean,
383
+ ): void {
351
384
  const dir = join(outDir, 'components')
352
385
  mkdirSync(dir, { recursive: true })
353
386
 
354
387
  const gallerySource = join(root, WEB_GALLERY_DIST)
355
- const body = existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
356
- ? (copyBuilt(gallerySource, join(dir, 'gallery')),
357
- '<iframe src="gallery/index.html" loading="lazy"></iframe>')
358
- : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
388
+ const body = !isToolkitCheckout
389
+ ? `<p class="empty">The components panel only renders in this toolkit's own checkout.</p>`
390
+ : existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
391
+ ? (copyBuilt(gallerySource, join(dir, 'gallery')),
392
+ '<iframe src="gallery/index.html" loading="lazy"></iframe>')
393
+ : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
359
394
 
360
395
  writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
361
396
  }
@@ -416,13 +451,54 @@ function writeCandidatesPanel(root: string, outDir: string): void {
416
451
  const sections = found
417
452
  .map(
418
453
  ({ folder, images }) =>
419
- `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${folder}/${image}" alt="${escapeHtml(image)}">`).join('\n')}`,
454
+ `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${escapeHtml(`${folder}/${image}`)}" alt="${escapeHtml(image)}">`).join('\n')}`,
420
455
  )
421
456
  .join('\n')
422
457
 
423
458
  writeFileSync(join(dir, 'index.html'), panelPage('Past candidates', sections))
424
459
  }
425
460
 
461
+ function writeReferencesPanel(root: string, outDir: string): void {
462
+ const dir = join(outDir, 'references')
463
+ mkdirSync(dir, { recursive: true })
464
+
465
+ const referencesDir = recordDir(root, 'review', 'references')
466
+ if (!existsSync(referencesDir)) {
467
+ writeFileSync(
468
+ join(dir, 'index.html'),
469
+ panelPage(
470
+ 'References',
471
+ `<p class="empty">No ${relative(root, referencesDir)} folder yet.</p>`,
472
+ ),
473
+ )
474
+ return
475
+ }
476
+
477
+ const images = imagesIn(referencesDir)
478
+ if (images.length === 0) {
479
+ writeFileSync(
480
+ join(dir, 'index.html'),
481
+ panelPage(
482
+ 'References',
483
+ `<p class="empty">${relative(root, referencesDir)} exists but carries no image yet.</p>`,
484
+ ),
485
+ )
486
+ return
487
+ }
488
+
489
+ for (const image of images) {
490
+ cpSync(join(referencesDir, image), join(dir, image))
491
+ }
492
+
493
+ const sections = images
494
+ .map(
495
+ (image) => `<img src="${escapeHtml(image)}" alt="${escapeHtml(image)}">`,
496
+ )
497
+ .join('\n')
498
+
499
+ writeFileSync(join(dir, 'index.html'), panelPage('References', sections))
500
+ }
501
+
426
502
  /**
427
503
  * Generates the board's page set into `outDir`, clearing whatever was there.
428
504
  *
@@ -433,11 +509,16 @@ function writeCandidatesPanel(root: string, outDir: string): void {
433
509
  * `cwd` is the caller's own working directory, resolved and passed in
434
510
  * explicitly rather than read here, so a test can exercise the checkout-
435
511
  * mismatch case without touching the process's real cwd.
512
+ *
513
+ * `isToolkitCheckout` gates the surfaces panel's landing-page half and the
514
+ * whole components panel, both of which read this repository's own build
515
+ * output rather than anything a target project produces.
436
516
  */
437
517
  export function generateBoard(
438
518
  root: string,
439
519
  outDir: string,
440
520
  cwd: string,
521
+ isToolkitCheckout: boolean,
441
522
  ): BoardOutcome {
442
523
  if (wouldDeleteRoot([root, cwd], outDir)) {
443
524
  return {
@@ -460,13 +541,15 @@ export function generateBoard(
460
541
  path: 'candidates/index.html',
461
542
  },
462
543
  { id: 'components', title: 'Components', path: 'components/index.html' },
544
+ { id: 'references', title: 'References', path: 'references/index.html' },
463
545
  ]
464
546
 
465
547
  writeTokensPanel(root, outDir)
466
- writeSurfacesPanel(root, outDir)
548
+ writeSurfacesPanel(root, outDir, isToolkitCheckout)
467
549
  writeWireframesPanel(root, outDir)
468
550
  writeCandidatesPanel(root, outDir)
469
- writeComponentsPanel(root, outDir)
551
+ writeComponentsPanel(root, outDir, isToolkitCheckout)
552
+ writeReferencesPanel(root, outDir)
470
553
 
471
554
  const indexPath = join(outDir, 'index.html')
472
555
  writeFileSync(indexPath, shellHtml(panels))
@@ -28,6 +28,22 @@ const readPackageName = (root: string): string | undefined => {
28
28
  }
29
29
  }
30
30
 
31
+ /**
32
+ * Whether `root` is a checkout of this same package, judged by package name
33
+ * rather than by path equality against `PROJECT_ROOT`.
34
+ *
35
+ * A linked worktree tracks the identical `package.json` the main worktree
36
+ * does, so this reads true for either. Comparing `root` against `PROJECT_ROOT`
37
+ * directly reads true only for the one directory this process happened to
38
+ * load its own source from, which is wrong for a caller whose CLI runs out of
39
+ * a linked worktree while `root` correctly points at the main one, or at
40
+ * another worktree of the same repository.
41
+ */
42
+ export function isOwnCheckout(root: string): boolean {
43
+ const ownName = readPackageName(PROJECT_ROOT)
44
+ return ownName !== undefined && readPackageName(root) === ownName
45
+ }
46
+
31
47
  /**
32
48
  * Walks upward from `startDir` for the nearest ancestor `package.json`
33
49
  * sharing this package's own `name`, and reports its path when that ancestor
@@ -43,6 +43,7 @@ export const SURFACE_ENTRIES: readonly string[] = [
43
43
  'DESIGN.md',
44
44
  'context',
45
45
  'wireframes',
46
+ 'decisions',
46
47
  'canon',
47
48
  ]
48
49
 
@@ -0,0 +1,126 @@
1
+ import { Heading } from '@/teach/components/heading'
2
+ import { List } from '@/teach/components/list'
3
+ import { Paragraph } from '@/teach/components/paragraph'
4
+ import { render } from '@/teach/html/jsx-runtime'
5
+ import type { TeachRefused } from '@/teach/workspace'
6
+
7
+ export type LessonBlock =
8
+ | { readonly type: 'heading'; readonly level: 1 | 2; readonly text: string }
9
+ | {
10
+ readonly type: 'paragraph'
11
+ readonly text: string
12
+ readonly lede?: boolean
13
+ }
14
+ | {
15
+ readonly type: 'list'
16
+ readonly items: readonly string[]
17
+ readonly ordered?: boolean
18
+ }
19
+ | { readonly type: 'raw'; readonly html: string }
20
+
21
+ export interface RenderRendered {
22
+ readonly ok: true
23
+ readonly html: string
24
+ }
25
+
26
+ export type RenderOutcome = RenderRendered | TeachRefused
27
+
28
+ const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'raw'] as const
29
+
30
+ function isBlockType(value: unknown): value is LessonBlock['type'] {
31
+ return (BLOCK_TYPES as readonly string[]).includes(value as string)
32
+ }
33
+
34
+ function badBlock(index: number, message: string): TeachRefused {
35
+ return {
36
+ ok: false,
37
+ reason: 'bad-input',
38
+ message: `Block ${index}: ${message}`,
39
+ detail: [],
40
+ }
41
+ }
42
+
43
+ function isString(value: unknown): value is string {
44
+ return typeof value === 'string'
45
+ }
46
+
47
+ function isStringArray(value: unknown): value is readonly string[] {
48
+ return Array.isArray(value) && value.every(isString)
49
+ }
50
+
51
+ function renderBlock(
52
+ block: unknown,
53
+ index: number,
54
+ ): { readonly html: string } | TeachRefused {
55
+ if (typeof block !== 'object' || block === null || !('type' in block)) {
56
+ return badBlock(index, 'not an object carrying a type')
57
+ }
58
+
59
+ const type = (block as { readonly type: unknown }).type
60
+
61
+ if (!isBlockType(type)) {
62
+ return badBlock(index, `unrecognized type ${JSON.stringify(type)}`)
63
+ }
64
+
65
+ const fields = block as Record<string, unknown>
66
+
67
+ switch (type) {
68
+ case 'heading': {
69
+ if (fields.level !== 1 && fields.level !== 2) {
70
+ return badBlock(index, 'heading needs level 1 or 2')
71
+ }
72
+ if (!isString(fields.text)) {
73
+ return badBlock(index, 'heading needs a string text')
74
+ }
75
+ return {
76
+ html: render(Heading({ level: fields.level, children: fields.text })),
77
+ }
78
+ }
79
+ case 'paragraph': {
80
+ if (!isString(fields.text)) {
81
+ return badBlock(index, 'paragraph needs a string text')
82
+ }
83
+ if (fields.lede !== undefined && typeof fields.lede !== 'boolean') {
84
+ return badBlock(index, 'paragraph lede must be a boolean')
85
+ }
86
+ return {
87
+ html: render(Paragraph({ lede: fields.lede, children: fields.text })),
88
+ }
89
+ }
90
+ case 'list': {
91
+ if (!isStringArray(fields.items)) {
92
+ return badBlock(index, 'list needs an items array of strings')
93
+ }
94
+ if (fields.ordered !== undefined && typeof fields.ordered !== 'boolean') {
95
+ return badBlock(index, 'list ordered must be a boolean')
96
+ }
97
+ return {
98
+ html: render(List({ ordered: fields.ordered, items: fields.items })),
99
+ }
100
+ }
101
+ case 'raw': {
102
+ if (!isString(fields.html)) {
103
+ return badBlock(index, 'raw needs a string html')
104
+ }
105
+ return { html: fields.html }
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Renders a lesson body's structural blocks through the same components
112
+ * `render-fixture.tsx` composes by hand, so a real lesson and the committed
113
+ * fixture share one rendering mechanism. Every block past the first refusal
114
+ * goes unread, matching how a malformed JSON parse refuses the whole call.
115
+ */
116
+ export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
117
+ const rendered: string[] = []
118
+
119
+ for (const [index, block] of blocks.entries()) {
120
+ const result = renderBlock(block, index)
121
+ if ('ok' in result) return result
122
+ rendered.push(result.html)
123
+ }
124
+
125
+ return { ok: true, html: rendered.join('\n') }
126
+ }
@@ -0,0 +1,100 @@
1
+ ---
2
+ title: Decisions reference
3
+ description: Folder layout, ordinal filename, frontmatter, record sections, and the append-only lifecycle for canon/decisions/
4
+ ---
5
+
6
+ # Decisions reference
7
+
8
+ Applies to `canon/decisions/`. Holds the history a canonical doc used to carry itself: pick rounds, superseded figures, and a decision's rejected alternatives, in a tracked folder nothing loads eagerly. A canonical doc points at a record from its own history section rather than restating it.
9
+
10
+ ## Scope
11
+
12
+ Governs `canon/decisions/`: folder layout, the ordinal filename, frontmatter, record sections, and the append-only lifecycle.
13
+
14
+ Does not govern:
15
+
16
+ - Which canonical doc points here and when, and what stays behind in that doc's own body: `architecture.md`, `context.md`, `wireframes.md`, `design.md`, `requirements.md`, once each states its own retirement rule
17
+ - Voice, rhythm, and sentence construction: the `write-human` skill
18
+ - Headings, punctuation, word choice, and file references: `markdown.md`
19
+
20
+ ## What a working record looks like
21
+
22
+ A record works when a reader who has never opened the project can follow it from the file alone:
23
+
24
+ - What was decided, stated once, without needing the canonical doc that points here
25
+ - What else was considered, and why each alternative lost
26
+ - Which claim rests on a measurement, and what commit that measurement was read against
27
+
28
+ A record failing these is non-conforming even when it satisfies every shape rule below.
29
+
30
+ ## Folder name
31
+
32
+ - `canon/decisions/`, resolved the way every tracked surface is: at `canon/decisions/` in a project that has moved, at `.claude/decisions/` in one that has not.
33
+ - Never add `canon/decisions/index.md` to a `CLAUDE.md` `@` import. A log that loads eagerly rebuilds the bloat it exists to absorb. A canonical doc's own pointer is how a reader reaches a record, one file at a time.
34
+
35
+ ## Record filename
36
+
37
+ - Name each record `<nn>-<slug>.md`, a two-digit zero-padded ordinal followed by a kebab-case slug, the same shape a groundwork track's folder takes.
38
+ - The ordinal is the order the record was written in, which is what lets a listing sort by when a decision landed rather than alphabetically by subject.
39
+ - Never renumber an existing record. A later reader cites it by that name, and a record whose number moved is a record a stale citation can no longer find.
40
+
41
+ ## Frontmatter
42
+
43
+ - `title` (required): the decision in sentence case
44
+ - `description` (required): one line naming what was decided
45
+
46
+ ## Sections
47
+
48
+ Use `## Context`, `## Decision`, `## Alternatives`, and `## Measurements`.
49
+
50
+ - `## Context`: the problem as it stood, stated so a reader needs nothing else open. Restate a fact rather than pointing at where it was found.
51
+ - `## Decision`: what was chosen, and why, in enough detail that a reader can tell it apart from an alternative that sounds similar.
52
+ - `## Alternatives`: each one considered and dropped, with the reason it lost. An alternative with no stated reason reads as a claim nobody checked.
53
+ - `## Measurements`: skip when the decision cites no number. When it does, state the number and close with the commit it was read against, per Verification anchors below.
54
+
55
+ ## Verification anchors
56
+
57
+ A record's reasoning stays correct while the numbers it cites move. The anchor records what a measured claim was read against, so a reader can tell a number that was checked and held from one nobody has looked at since.
58
+
59
+ - Close a `## Measurements` section with a trailing sentence naming the short commit SHA and the ISO date that number was read: `Measured at <short-sha> on <YYYY-MM-DD>.`
60
+ - Anchor on the number alone. A record citing none carries no `## Measurements` section at all.
61
+ - Read an absent section as unchecked rather than as current. A record with no measurements has nothing due a re-read.
62
+
63
+ ## Lifecycle
64
+
65
+ - Append-only. A written record is never edited to reflect a later reversal.
66
+ - Write a new record when a later decision supersedes an earlier one, naming the record it supersedes. The old record stays as it was written, since it is history rather than a live statement of the current shape.
67
+ - Never auto-loaded. A canonical doc's own history section links to a record by relative path, and a reader reaches it by following that link, not by the folder loading with the session.
68
+
69
+ ## Citation
70
+
71
+ - Never cite `.canon/`. A record restates what it needs, since a clone without the gitignored records folder resolves nothing there.
72
+ - Cite a same-repository pull request or commit the way `publish.md` fixes for any tracked document.
73
+
74
+ ## Template
75
+
76
+ ```markdown
77
+ ---
78
+ title: <Decision, in sentence case>
79
+ description: <one line naming what was decided>
80
+ ---
81
+
82
+ # <Decision title>
83
+
84
+ ## Context
85
+
86
+ <The problem as it stood, self-contained.>
87
+
88
+ ## Decision
89
+
90
+ <What was chosen, and why.>
91
+
92
+ ## Alternatives
93
+
94
+ - **<Alternative>.** <Why it lost.>
95
+ - **<Alternative>.** <Why it lost.>
96
+
97
+ ## Measurements
98
+
99
+ <The number the decision rests on.> Measured at <short-sha> on <YYYY-MM-DD>.
100
+ ```
@@ -11,6 +11,7 @@ Reference docs for consistent authoring across the toolkit and target projects.
11
11
  - [Branch reference](branch.md): Branch naming format and type conventions
12
12
  - [Commit reference](commit.md): Commit message format and type conventions
13
13
  - [Context entry reference](context.md): Shape and content rules for canon/context/<domain>.md entries
14
+ - [Decisions reference](decisions.md): Folder layout, ordinal filename, frontmatter, record sections, and the append-only lifecycle for canon/decisions/
14
15
  - [Design reference](design.md): Shape and content rules for canon/DESIGN.md
15
16
  - [Diagram reference](diagrams.md): Shape and content rules for .canon/diagrams/<kind>.md files
16
17
  - [Docs reference](docs.md): Reader and jurisdiction, frontmatter, page structure, what a page links out to, the diagram permission, and when a category earns a subfolder
@@ -14,6 +14,7 @@ canon/
14
14
  ├── ARCHITECTURE.md ← seeded. Technical design decisions and open questions
15
15
  ├── DESIGN.md ← seeded. Visual intent and the decisions behind it
16
16
  ├── context/ ← seeded. Per-domain narrative. `index.md` is the discovery anchor.
17
+ ├── decisions/ ← seeded. Decision history a canonical doc points at, never loaded eagerly. `<nn>-<slug>.md` records.
17
18
  └── wireframes/ ← seeded. Per-surface ASCII layouts. `index.md` is the discovery anchor; `<surface>.md` files hold the sketches and behavior bullets.
18
19
 
19
20
  .claude/
@@ -22,3 +22,4 @@
22
22
  - `canon/DESIGN.md`: design tokens and the visual system
23
23
  - `canon/context/`: per-domain narrative (how a domain is structured, decisions, gotchas), indexed via `canon/context/index.md`
24
24
  - `canon/wireframes/`: per-surface ASCII layouts loaded on demand, indexed via `canon/wireframes/index.md`
25
+ - `canon/decisions/`: decision history a project doc points at, never loaded eagerly
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Decisions
3
+ subtitle: Decision history a project doc points at, never loaded eagerly
4
+ ---
5
+
6
+ # Decisions
7
+
8
+ Decision history a project doc points at, never loaded eagerly