@erclx/canon 5.8.0 → 5.10.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 (97) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +51 -0
  3. package/claude/skills/api-design/SKILL.md +102 -0
  4. package/claude/skills/api-design/references/adopted.md +40 -0
  5. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  6. package/claude/skills/codebase-layout/SKILL.md +2 -1
  7. package/claude/skills/git-followup/SKILL.md +1 -1
  8. package/claude/skills/git-pr/references/evidence.md +1 -1
  9. package/claude/skills/review-craft/REQUIREMENT.md +8 -2
  10. package/claude/skills/review-craft/SKILL.md +63 -7
  11. package/claude/skills/review-craft/references/adopted.md +49 -0
  12. package/claude/skills/review-ui/REQUIREMENT.md +3 -0
  13. package/claude/skills/review-ui/SKILL.md +15 -1
  14. package/claude/skills/review-ui/references/driving.md +3 -3
  15. package/claude/skills/review-ui/references/post.md +16 -3
  16. package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
  17. package/claude/skills/systematic-debugging/SKILL.md +45 -17
  18. package/claude/skills/systematic-debugging/references/adopted.md +44 -0
  19. package/docs/agents/capture.md +2 -2
  20. package/docs/agents/commands.md +3 -1
  21. package/docs/agents/hooks.md +56 -0
  22. package/docs/agents/index.md +1 -0
  23. package/docs/agents/install-and-sync.md +1 -1
  24. package/docs/agents/markdown-audit.md +1 -1
  25. package/docs/agents/pr-evidence.md +33 -17
  26. package/docs/agents/records-push.md +1 -1
  27. package/docs/agents/records-validate.md +1 -1
  28. package/docs/agents/scripting.md +6 -3
  29. package/docs/agents/tasks-archive.md +4 -2
  30. package/docs/agents/tasks-plans.md +8 -5
  31. package/docs/agents/tasks.md +1 -1
  32. package/docs/agents/worktrees.md +2 -2
  33. package/docs/target/scenarios.md +2 -2
  34. package/docs/workflow/skill-map.md +1 -0
  35. package/package.json +1 -1
  36. package/scripts/core/regen-agent-fixture.sh +3 -3
  37. package/scripts/core/regen-hero.ts +119 -0
  38. package/scripts/core/regen-web-favicon.ts +1 -1
  39. package/src/capture/frame-values.ts +359 -0
  40. package/src/claude/cases/misc.ts +5 -0
  41. package/src/claude/skills-audit.ts +3 -0
  42. package/src/cli.ts +2 -0
  43. package/src/commands/claude.ts +1 -1
  44. package/src/commands/docs.ts +51 -11
  45. package/src/commands/hooks.ts +67 -0
  46. package/src/commands/pr.ts +146 -76
  47. package/src/commands/standards.ts +33 -2
  48. package/src/commands/tasks.ts +57 -2
  49. package/src/commands/tooling.ts +5 -5
  50. package/src/commands/upgrade.ts +3 -2
  51. package/src/commands/worktrees.ts +4 -3
  52. package/src/counts/catalogs.ts +3 -3
  53. package/src/design/tokens.ts +1 -1
  54. package/src/docs/list.ts +103 -0
  55. package/src/docs/read.ts +63 -19
  56. package/src/gate/stages.ts +1 -1
  57. package/src/gov/stacks.ts +1 -1
  58. package/src/help.ts +2 -1
  59. package/src/hooks/post-merge.ts +331 -0
  60. package/src/pr/evidence.ts +21 -0
  61. package/src/records/validate.ts +1 -38
  62. package/src/standards/list.ts +33 -0
  63. package/src/standards/read.ts +13 -0
  64. package/src/standards/scope.ts +56 -0
  65. package/src/sync/check.ts +38 -4
  66. package/src/tasks/archive.ts +195 -3
  67. package/src/tasks/record.ts +25 -2
  68. package/src/tooling/inject.ts +4 -3
  69. package/src/tooling/manifest.ts +62 -8
  70. package/src/tooling/scan.ts +4 -3
  71. package/src/tooling/stamp.ts +11 -2
  72. package/src/tooling/subfolder.ts +7 -3
  73. package/standards/tasks.md +2 -2
  74. package/tooling/astro/reference.md +1 -1
  75. package/tooling/base/configs/scripts/verify.json +3 -0
  76. package/tooling/base/configs/scripts/verify.sh +1 -110
  77. package/tooling/base/configs/scripts/verify.ts +378 -0
  78. package/tooling/base/manifest.toml +1 -0
  79. package/tooling/base/reference.md +5 -3
  80. package/tooling/go/configs/scripts/verify.json +10 -0
  81. package/tooling/go/reference.md +5 -4
  82. package/tooling/php/configs/scripts/verify.json +7 -0
  83. package/tooling/php/reference.md +5 -4
  84. package/tooling/python/configs/scripts/verify.json +4 -0
  85. package/tooling/python/reference.md +3 -3
  86. package/tooling/web/configs/scripts/verify.json +3 -0
  87. package/tooling/web/reference.md +3 -3
  88. package/scripts/core/regen-hero.sh +0 -419
  89. package/scripts/docs/list.sh +0 -184
  90. package/scripts/lib/frontmatter.sh +0 -30
  91. package/scripts/standards/list.sh +0 -115
  92. package/scripts/tooling/create.sh +0 -109
  93. package/src/commands/pass-through.ts +0 -39
  94. package/tooling/go/configs/scripts/verify.sh +0 -142
  95. package/tooling/php/configs/scripts/verify.sh +0 -147
  96. package/tooling/python/configs/scripts/verify.sh +0 -141
  97. package/tooling/web/configs/scripts/verify.sh +0 -153
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Fills every assets/captures/*.html.tmpl from the catalogs and the design
3
+ * source, writing the .html beside each one.
4
+ *
5
+ * The name says hero because the hero was the only template when it was written
6
+ * and every citation of it across docs/, canon/context/, and src/ spells that
7
+ * name. The loop below covers whatever templates the folder holds.
8
+ *
9
+ * Each catalog is read through the module its `canon` verb calls, so no child
10
+ * process runs and no stderr is discarded. The palette arrives from
11
+ * `buildDesignCss` without the component half, which is a scrollbar and a
12
+ * status marker a static capture frame renders neither of. The value builder
13
+ * and every guard live in `src/capture/frame-values.ts`, where tsc and vitest
14
+ * reach them.
15
+ *
16
+ * `--check` fills every template into a temp folder and discards it, so a
17
+ * renamed featured skill, an empty catalog, or an unresolved placeholder still
18
+ * fails a branch while no frame file changes. Frames are refreshed after a
19
+ * merge rather than committed by each branch.
20
+ *
21
+ * Only the HTML regenerates here. The PNG beside it is a chromium render whose
22
+ * bytes move with the browser version. Rebuild the images with
23
+ * `canon capture assets/captures --selector .window --out assets/evidence`
24
+ * after this script reports a change. That capture writes a .stamp beside each
25
+ * PNG, which records the digest of the markup it rendered and is what the Hero
26
+ * stage compares, so a frame's three files commit together. The frame carries
27
+ * no version, since `package.json` is bumped on main by the release tooling and
28
+ * embedding it would drift every open branch on the next release.
29
+ *
30
+ * Clone-only. This script reads the repository's own catalogs and
31
+ * `assets/captures`, which a registry install does not carry.
32
+ */
33
+ import {
34
+ mkdirSync,
35
+ mkdtempSync,
36
+ readdirSync,
37
+ readFileSync,
38
+ rmSync,
39
+ writeFileSync,
40
+ } from 'node:fs'
41
+ import { tmpdir } from 'node:os'
42
+ import { join } from 'node:path'
43
+
44
+ import {
45
+ buildFrameValues,
46
+ FrameError,
47
+ fillTemplate,
48
+ } from '../../src/capture/frame-values'
49
+ import { listSkills } from '../../src/claude/skills-list'
50
+ import { countCommands } from '../../src/counts/catalogs'
51
+ import { buildDesignCss } from '../../src/design/css'
52
+ import { buildGovCatalog } from '../../src/gov/list'
53
+ import { listStandardEntries } from '../../src/standards/list'
54
+ import { buildStackSummaries } from '../../src/tooling/list'
55
+
56
+ const root = new URL('../..', import.meta.url).pathname
57
+ const assetDir = join(root, 'assets', 'captures')
58
+
59
+ const [flag, ...extra] = process.argv.slice(2)
60
+ if (flag !== undefined && flag !== '--check') {
61
+ console.error(`regen-hero: unknown argument ${flag}`)
62
+ process.exit(1)
63
+ }
64
+ if (extra.length > 0) {
65
+ console.error(`regen-hero: unknown argument ${extra[0]}`)
66
+ process.exit(1)
67
+ }
68
+
69
+ const outDir =
70
+ flag === '--check' ? mkdtempSync(join(tmpdir(), 'regen-hero-')) : assetDir
71
+
72
+ function run(): void {
73
+ const templates = readdirSync(assetDir)
74
+ .filter((name) => name.endsWith('.html.tmpl'))
75
+ .sort()
76
+ if (templates.length === 0) {
77
+ throw new FrameError(`no templates under ${assetDir}`)
78
+ }
79
+
80
+ const tokenCss = buildDesignCss(undefined, { components: false })
81
+ if (!tokenCss.trim()) {
82
+ throw new FrameError(
83
+ 'the design source emitted nothing, refusing to write an unstyled frame',
84
+ )
85
+ }
86
+
87
+ const { values, frames } = buildFrameValues({
88
+ skills: listSkills(root).map((entry) => entry.name),
89
+ gov: buildGovCatalog(root),
90
+ // The toolkit root only: a project-local standard would put a row in the
91
+ // hero the toolkit does not ship.
92
+ standards: listStandardEntries(root).filter((entry) =>
93
+ entry.source.startsWith('standards/'),
94
+ ),
95
+ toolingStacks: buildStackSummaries(root),
96
+ commandCount: countCommands(root) ?? 0,
97
+ tokenCss,
98
+ markSvg: readFileSync(join(root, 'assets', 'brand', 'mark.svg'), 'utf8'),
99
+ })
100
+
101
+ mkdirSync(outDir, { recursive: true })
102
+ for (const { template, out, tokens } of frames(templates)) {
103
+ const source = readFileSync(join(assetDir, template), 'utf8')
104
+ writeFileSync(
105
+ join(outDir, out),
106
+ fillTemplate(template, source, values, tokens),
107
+ )
108
+ }
109
+ }
110
+
111
+ try {
112
+ run()
113
+ } catch (error) {
114
+ if (!(error instanceof FrameError)) throw error
115
+ console.error(`regen-hero: ${error.message}`)
116
+ process.exitCode = 1
117
+ } finally {
118
+ if (outDir !== assetDir) rmSync(outDir, { recursive: true, force: true })
119
+ }
@@ -3,7 +3,7 @@
3
3
  * own colors in src/design/favicon.ts, apart from the page accent tokens.
4
4
  *
5
5
  * The page is the fourth surface to carry the mark as a favicon and the only
6
- * one that can answer for itself. `regen-hero.sh` and `src/design/render.ts`
6
+ * one that can answer for itself. `regen-hero.ts` and `src/design/render.ts`
7
7
  * each bake a single literal, because both embed the mark as a data URI and a
8
8
  * data URI has no CSS context. A file served at its own URL does have one, so
9
9
  * this copy carries a `prefers-color-scheme` branch and tracks the reader's
@@ -0,0 +1,359 @@
1
+ import type { GovCatalog } from '@/gov/list'
2
+ import type { StackSummary } from '@/tooling/list'
3
+
4
+ export const LISTED = 10
5
+
6
+ /**
7
+ * The first seven are the method and exist nowhere else. The last three are
8
+ * recognizable on sight and are what keeps a column of chosen names from
9
+ * reading as an all-`claude-` catalog, which is the failure the sampler was
10
+ * written against.
11
+ */
12
+ export const FEATURED_SKILLS = [
13
+ 'plan-feature',
14
+ 'plan-groundwork',
15
+ 'plan-intake',
16
+ 'role-orchestrator',
17
+ 'auto-ship',
18
+ 'review-pr',
19
+ 'task-board',
20
+ 'git-ship',
21
+ 'target-setup',
22
+ 'systematic-debugging',
23
+ ]
24
+
25
+ /**
26
+ * A terminal frame sets `white-space: pre` at a fixed window width, so an
27
+ * overlong cell is clipped by the window edge rather than wrapped.
28
+ */
29
+ const GLOB_WIDTH = 44
30
+
31
+ /** A refusal the script prints as `regen-hero: <message>` and exits 1 on. */
32
+ export class FrameError extends Error {}
33
+
34
+ export interface StandardRow {
35
+ readonly name: string
36
+ readonly appliesTo?: readonly string[]
37
+ }
38
+
39
+ export interface FrameCatalogs {
40
+ readonly skills: readonly string[]
41
+ readonly gov: GovCatalog
42
+ readonly standards: readonly StandardRow[]
43
+ readonly toolingStacks: readonly StackSummary[]
44
+ readonly commandCount: number
45
+ readonly tokenCss: string
46
+ readonly markSvg: string
47
+ }
48
+
49
+ export interface Frame {
50
+ readonly template: string
51
+ readonly out: string
52
+ readonly tokens: string
53
+ }
54
+
55
+ export interface FrameValues {
56
+ readonly values: Readonly<Record<string, string>>
57
+ readonly frames: (templates: readonly string[]) => Frame[]
58
+ }
59
+
60
+ const escape = (value: string): string =>
61
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
62
+
63
+ /**
64
+ * Even spacing across the sorted catalog rather than its first N, so a column
65
+ * whose names share a prefix does not read as a narrower catalog than the one
66
+ * that ships. Rules and standards sample. Skills do not, because the even step
67
+ * lands on the entries closest to commodity and on none of the workflow the
68
+ * toolkit exists to carry.
69
+ */
70
+ export function sample<T>(items: readonly T[], listed = LISTED): T[] {
71
+ if (items.length <= listed) return [...items]
72
+ const step = items.length / listed
73
+ return Array.from({ length: listed }, (_, i) => items[Math.floor(i * step)])
74
+ }
75
+
76
+ /**
77
+ * A chosen name is maintained by hand where a sample never goes stale, so a
78
+ * rename has to fail the run rather than quietly shrink the column. The length
79
+ * is asserted because the `+N more` figure counts down from `LISTED` rather
80
+ * than from what this returns.
81
+ */
82
+ export function featured(
83
+ names: readonly string[],
84
+ chosen: readonly string[],
85
+ listed = LISTED,
86
+ ): string[] {
87
+ if (chosen.length !== listed) {
88
+ throw new FrameError(`${chosen.length} featured skills, expected ${listed}`)
89
+ }
90
+ const catalog = new Set(names)
91
+ const missing = chosen.filter((name) => !catalog.has(name))
92
+ if (missing.length > 0) {
93
+ throw new FrameError(
94
+ `featured skills missing from the catalog: ${missing.join(', ')}`,
95
+ )
96
+ }
97
+ return [...chosen]
98
+ }
99
+
100
+ /**
101
+ * Truncating keeps the longest row inside the capture, and the ellipsis stops a
102
+ * clipped value from reading as the whole value.
103
+ */
104
+ export function clip(value: string, width: number): string {
105
+ return value.length <= width ? value : `${value.slice(0, width - 1)}…`
106
+ }
107
+
108
+ /**
109
+ * Padding happens before escaping, since an entity is longer than the
110
+ * character it replaces and would push a column out of line.
111
+ */
112
+ export function pad(value: string, width: number): string {
113
+ return value + ' '.repeat(Math.max(0, width - value.length))
114
+ }
115
+
116
+ const markup = (names: readonly string[]): string =>
117
+ names
118
+ .map((name) => ` <div class="entry">${escape(name)}</div>`)
119
+ .join('\n')
120
+
121
+ const remaining = (names: readonly unknown[], listed = LISTED): string =>
122
+ String(Math.max(0, names.length - listed))
123
+
124
+ const plural = (count: number, noun: string): string =>
125
+ `${count} ${noun}${count === 1 ? '' : 's'}`
126
+
127
+ const frameRow = (cells: string): string =>
128
+ `<span class="frame">│</span> <span class="ok">✓</span> ${cells}`
129
+
130
+ /** Rule names carry a numeric prefix that orders the load, not the identity. */
131
+ const slug = (name: string): string => name.replace(/^\d+-/, '')
132
+
133
+ /** Indented to the depth a rule inside the `<style>` block sits at. */
134
+ const indentTokens = (css: string): string =>
135
+ css
136
+ .trimEnd()
137
+ .split('\n')
138
+ .map((line) => (line === '' ? '' : ` ${line}`))
139
+ .join('\n')
140
+
141
+ /**
142
+ * The frames a README serves in both themes. The hero is a landing-page frame
143
+ * no README shows, so it stays dark.
144
+ */
145
+ const LIGHT_TEMPLATES = new Set(['install.html.tmpl'])
146
+
147
+ function lightTokens(tokenCss: string): string {
148
+ const roles = [...tokenCss.matchAll(/--color-light-([a-z-]+):/g)].map(
149
+ (match) => match[1],
150
+ )
151
+ if (roles.length === 0) {
152
+ throw new FrameError(
153
+ 'token css carries no --color-light-* roles, refusing to write a light frame',
154
+ )
155
+ }
156
+ return [
157
+ tokenCss,
158
+ '',
159
+ ' :root {',
160
+ ...roles.map(
161
+ (role) => ` --color-${role}: var(--color-light-${role});`,
162
+ ),
163
+ ' --frame-shadow: rgb(0 0 0 / 16%);',
164
+ ' }',
165
+ ].join('\n')
166
+ }
167
+
168
+ function faviconUri(markSvg: string, tokenCss: string): string {
169
+ const accent = tokenCss.match(/--color-accent:\s*(#[0-9a-fA-F]{3,8})/)
170
+ if (!accent) {
171
+ throw new FrameError(
172
+ 'token css carries no --color-accent value, refusing to write a colorless favicon',
173
+ )
174
+ }
175
+ const svg = markSvg
176
+ .replace(/<!--[\s\S]*?-->/, '')
177
+ .trim()
178
+ .replaceAll('currentColor', accent[1])
179
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`
180
+ }
181
+
182
+ /**
183
+ * Builds the value map every template takes and the frame list a template set
184
+ * expands to. An empty array is well-formed, and the in-process readers return
185
+ * one quietly for a missing tree, so every catalog is refused when empty
186
+ * rather than shipping a frame claiming the domain has nothing in it.
187
+ */
188
+ export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
189
+ const { gov, toolingStacks, standards, skills } = catalogs
190
+ const markSvg = catalogs.markSvg.trim()
191
+ if (!markSvg) {
192
+ throw new FrameError(
193
+ 'assets/brand/mark.svg read empty, refusing to write a hero with no mark',
194
+ )
195
+ }
196
+ if (!catalogs.commandCount) {
197
+ throw new FrameError(
198
+ 'the commands catalog read no commands, refusing to write a zeroed hero',
199
+ )
200
+ }
201
+
202
+ const tokenCss = indentTokens(catalogs.tokenCss)
203
+ const rules = gov.rules.map((entry) => slug(entry.name))
204
+ // A rule no stack names is opt-in behind `--add`, so featuring one advertises
205
+ // an entry no ordinary `canon gov install` delivers. The column samples the
206
+ // stack-reached subset while its count and remainder describe the whole.
207
+ const stacked = new Set(gov.stacks.flatMap((stack) => stack.rules))
208
+ const deliveredEntries = gov.rules.filter((entry) => stacked.has(entry.name))
209
+ const deliveredRules = deliveredEntries.map((entry) => slug(entry.name))
210
+ const standardNames = standards.map((entry) => entry.name)
211
+
212
+ for (const [label, list] of [
213
+ ['skills', skills],
214
+ ['rules', rules],
215
+ ['standards', standardNames],
216
+ ['tooling stacks', toolingStacks],
217
+ ['gov stacks', gov.stacks],
218
+ ['stack-delivered rules', deliveredRules],
219
+ ] as const) {
220
+ if (list.length === 0) {
221
+ throw new FrameError(
222
+ `the ${label} catalog is empty, refusing to write a zeroed hero`,
223
+ )
224
+ }
225
+ }
226
+
227
+ const govStackRows = gov.stacks
228
+ .map((stack) =>
229
+ frameRow(
230
+ `<span class="name">${escape(pad(stack.name, 17))}</span>` +
231
+ `<span class="muted">${escape(pad(plural(stack.rules.length, 'rule'), 11))}</span>` +
232
+ `<span class="muted">${escape(stack.extends ? `extends ${stack.extends}` : '')}</span>`,
233
+ ),
234
+ )
235
+ .join('\n')
236
+
237
+ // Each rule sits beside the glob that loads it rather than its description,
238
+ // since the glob is the mechanism a reader cannot otherwise see. A rule
239
+ // carrying no glob is not unscoped, it loads every session, and saying so is
240
+ // the contrast that makes the column mean anything.
241
+ const govRuleRows = sample(deliveredEntries)
242
+ .map((entry) => {
243
+ const globs = entry.paths ?? []
244
+ const scope =
245
+ globs.length > 0 ? clip(globs.join(' '), GLOB_WIDTH) : 'every session'
246
+ return frameRow(
247
+ `<span class="name">${escape(pad(slug(entry.name), 24))}</span>` +
248
+ `<span class="domain">${escape(pad(`[${entry.domain}]`, 12))}</span>` +
249
+ `<span class="muted">${escape(scope)}</span>`,
250
+ )
251
+ })
252
+ .join('\n')
253
+
254
+ // A standard either governs an artifact by path or is opened by name when a
255
+ // session decides it needs it. Naming the second is the contrast, since
256
+ // nothing else tells a reader the corpus installs into no project.
257
+ const standardRows = sample(standards)
258
+ .map((entry) => {
259
+ const applies = entry.appliesTo ?? []
260
+ const governs =
261
+ applies.length > 0
262
+ ? clip(applies.join(' '), GLOB_WIDTH)
263
+ : 'read by name'
264
+ return frameRow(
265
+ `<span class="name">${escape(pad(entry.name, 18))}</span>` +
266
+ `<span class="muted">${escape(governs)}</span>`,
267
+ )
268
+ })
269
+ .join('\n')
270
+
271
+ // A tooling stack is counted rather than listed, because what it lays down is
272
+ // dev dependencies, run scripts, and ignore groups rather than named entries.
273
+ // The inheritance is the part worth showing, since it is why a stack carrying
274
+ // two of its own arrives with far more than two.
275
+ const toolingStackRows = toolingStacks
276
+ .map((stack) =>
277
+ frameRow(
278
+ `<span class="name">${escape(pad(stack.name, 14))}</span>` +
279
+ `<span class="muted">${escape(pad(plural(stack.devDeps, 'dep'), 10))}</span>` +
280
+ `<span class="muted">${escape(pad(plural(stack.scripts, 'script'), 12))}</span>` +
281
+ `<span class="muted">${escape(stack.extends ? `extends ${stack.extends}` : '')}</span>`,
282
+ ),
283
+ )
284
+ .join('\n')
285
+
286
+ for (const [label, rows] of [
287
+ ['governance stack rows', govStackRows],
288
+ ['governance rule rows', govRuleRows],
289
+ ['standard rows', standardRows],
290
+ ['tooling stack rows', toolingStackRows],
291
+ ] as const) {
292
+ if (rows === '') {
293
+ throw new FrameError(
294
+ `the ${label} rendered empty, refusing to write a blank frame`,
295
+ )
296
+ }
297
+ }
298
+
299
+ const values: Record<string, string> = {
300
+ TOKENS: tokenCss,
301
+ GOV_STACK_ROWS: govStackRows,
302
+ GOV_RULE_ROWS: govRuleRows,
303
+ STANDARD_ROWS: standardRows,
304
+ TOOLING_STACK_ROWS: toolingStackRows,
305
+ SKILL_COUNT: String(skills.length),
306
+ RULE_COUNT: String(rules.length),
307
+ STANDARD_COUNT: String(standardNames.length),
308
+ GOV_STACK_COUNT: String(gov.stacks.length),
309
+ TOOLING_STACK_COUNT: String(toolingStacks.length),
310
+ COMMAND_COUNT: String(catalogs.commandCount),
311
+ MARK_SVG: markSvg,
312
+ FAVICON: faviconUri(markSvg, tokenCss),
313
+ SKILL_ENTRIES: markup(featured(skills, FEATURED_SKILLS)),
314
+ RULE_ENTRIES: markup(sample(deliveredRules)),
315
+ STANDARD_ENTRIES: markup(sample(standardNames)),
316
+ SKILL_MORE: remaining(skills),
317
+ RULE_MORE: remaining(rules),
318
+ STANDARD_MORE: remaining(standardNames),
319
+ }
320
+
321
+ const light = lightTokens(tokenCss)
322
+
323
+ return {
324
+ values,
325
+ frames: (templates) =>
326
+ templates.flatMap((template) => {
327
+ const base = template.replace(/\.html\.tmpl$/, '')
328
+ const dark = { template, out: `${base}.html`, tokens: tokenCss }
329
+ return LIGHT_TEMPLATES.has(template)
330
+ ? [dark, { template, out: `${base}-light.html`, tokens: light }]
331
+ : [dark]
332
+ }),
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Every template takes the same value map, so one carrying no count
338
+ * placeholder simply resolves none of them. What a template must not do is name
339
+ * a placeholder nothing fills.
340
+ */
341
+ export function fillTemplate(
342
+ template: string,
343
+ source: string,
344
+ values: Readonly<Record<string, string>>,
345
+ tokens: string,
346
+ ): string {
347
+ let html = source
348
+ for (const [key, value] of Object.entries({ ...values, TOKENS: tokens })) {
349
+ html = html.replaceAll(`{{${key}}}`, value)
350
+ }
351
+
352
+ const unresolved = html.match(/{{[A-Z_]+}}/g)
353
+ if (unresolved) {
354
+ throw new FrameError(
355
+ `${template} carries unresolved placeholders ${[...new Set(unresolved)].join(', ')}`,
356
+ )
357
+ }
358
+ return html
359
+ }
@@ -69,6 +69,11 @@ export const MISC_CASES: readonly SkillCase[] = [
69
69
  "The e2e folder has specs and helpers mixed together and I'm adding two more, where should they go?",
70
70
  expect: 'codebase-layout',
71
71
  },
72
+ {
73
+ prompt:
74
+ "I'm adding a field to the JSON this command prints and renaming one flag, anything to watch?",
75
+ expect: 'api-design',
76
+ },
72
77
  {
73
78
  prompt: 'Does our github about text still match what the readme says?',
74
79
  expect: 'repo-metadata',
@@ -34,6 +34,9 @@ export const CORPORA: readonly string[] = [
34
34
  export const PRACTICE_SKILLS: readonly string[] = [
35
35
  join(SHIPPED_CORPUS, 'codebase-layout'),
36
36
  join(SHIPPED_CORPUS, 'test-craft'),
37
+ join(SHIPPED_CORPUS, 'review-craft'),
38
+ join(SHIPPED_CORPUS, 'systematic-debugging'),
39
+ join(SHIPPED_CORPUS, 'api-design'),
37
40
  ]
38
41
 
39
42
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
package/src/cli.ts CHANGED
@@ -30,6 +30,7 @@ import { register as migrate } from '@/commands/migrate'
30
30
  import { register as records } from '@/commands/records'
31
31
  import { register as sessions } from '@/commands/sessions'
32
32
  import { register as worktrees } from '@/commands/worktrees'
33
+ import { register as hooks } from '@/commands/hooks'
33
34
  import { register as audits } from '@/commands/audits'
34
35
  import { register as gate } from '@/commands/gate'
35
36
  import { register as secrets } from '@/commands/secrets'
@@ -91,6 +92,7 @@ migrate(program)
91
92
  sessions(program)
92
93
  targets(program)
93
94
  worktrees(program)
95
+ hooks(program)
94
96
  secrets(program)
95
97
  deps(program)
96
98
  labels(program)
@@ -111,7 +111,7 @@ interface PluginUpdateOptions {
111
111
  /**
112
112
  * `no-claude` and `no-plugin` name permanent conditions on a machine that
113
113
  * never carries the marketplace plugin at all, matching the `gh-missing` and
114
- * `no-repository` reasons `.husky/post-merge`'s other two steps already stay
114
+ * `no-repository` reasons the post-merge hook's other two steps already stay
115
115
  * quiet on forever rather than nagging a project that will never fix them.
116
116
  * Every other reason is a real defect and prints.
117
117
  */
@@ -1,7 +1,6 @@
1
1
  import type { Command } from 'commander'
2
- import { registerPassThroughVerbs } from '@/commands/pass-through'
2
+ import { type DocEntry, listDocs } from '@/docs/list'
3
3
  import { listTopics, readTopic, resolveTopic } from '@/docs/read'
4
- import { execScript } from '@/exec'
5
4
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
6
5
  import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
7
6
 
@@ -22,19 +21,60 @@ export function register(program: Command): void {
22
21
  '',
23
22
  ].join('\n'),
24
23
  )
25
- .action(async (topic: string | undefined) => {
26
- if (topic === undefined) {
27
- intro('canon docs')
28
- await execScript('docs/list.sh', [])
29
- return
30
- }
31
-
32
- process.exitCode = get(topic)
24
+ .action((topic: string | undefined) => {
25
+ process.exitCode = topic === undefined ? list({}) : get(topic)
33
26
  })
34
27
 
35
- registerPassThroughVerbs(docs, 'docs', ['list'])
28
+ docs
29
+ .command('list')
30
+ .description('List the docs and domain context with their descriptions')
31
+ .helpOption('-h, --help', 'Show this help message')
32
+ .option('--json', 'Emit machine-readable JSON')
33
+ .action((opts: ListOptions) => {
34
+ process.exitCode = list(opts)
35
+ })
36
+ }
37
+
38
+ interface ListOptions {
39
+ readonly json?: boolean
36
40
  }
37
41
 
42
+ /**
43
+ * The text listing is frame lines on stderr and the `--json` record is the one
44
+ * thing on stdout, so a caller piping it through a wrapper reads JSON alone.
45
+ * The frame opens after the `--json` branch and never wraps it.
46
+ */
47
+ function list(opts: ListOptions): number {
48
+ const catalog = listDocs(PROJECT_ROOT)
49
+ const mismatch = checkoutMismatchWarning(process.cwd())
50
+
51
+ if (opts.json) {
52
+ if (mismatch !== undefined) logWarn(mismatch)
53
+ const docs: DocEntry[] = catalog.hasContext
54
+ ? [...catalog.docs, ...catalog.context]
55
+ : catalog.docs
56
+ process.stdout.write(`${JSON.stringify({ docs })}\n`)
57
+ return 0
58
+ }
59
+
60
+ intro('canon docs')
61
+ if (mismatch !== undefined) logWarn(mismatch)
62
+
63
+ logStep('Docs')
64
+ for (const entry of catalog.docs) logInfo(describeEntry(entry))
65
+
66
+ if (catalog.hasContext) {
67
+ logStep('Domain context')
68
+ for (const entry of catalog.context) logInfo(describeEntry(entry))
69
+ }
70
+
71
+ outro()
72
+ return 0
73
+ }
74
+
75
+ const describeEntry = (entry: DocEntry): string =>
76
+ `${entry.name} : ${entry.description}`
77
+
38
78
  /**
39
79
  * Writes the document body to stdout and every frame line to stderr, so a
40
80
  * caller capturing the output with `$(...)` receives the document alone.
@@ -0,0 +1,67 @@
1
+ import type { Command } from 'commander'
2
+ import {
3
+ readMergedSubjects,
4
+ runCanon,
5
+ runPostMerge,
6
+ STEP_VERBS,
7
+ } from '@/hooks/post-merge'
8
+ import { mainWorktreeRoot } from '@/worktree'
9
+
10
+ interface PostMergeOptions {
11
+ readonly root?: string
12
+ }
13
+
14
+ export function register(program: Command): void {
15
+ const hooks = program
16
+ .command('hooks')
17
+ .description('Run the steps a git hook drives')
18
+ .helpOption('-h, --help', 'Show this help message')
19
+
20
+ hooks
21
+ .command('post-merge')
22
+ .description('Run the post-merge steps in order and report each in a line')
23
+ .helpOption('-h, --help', 'Show this help message')
24
+ .option(
25
+ '--root <path>',
26
+ 'Main worktree root holding the board and the records',
27
+ )
28
+ .addHelpText(
29
+ 'after',
30
+ [
31
+ '',
32
+ 'Runs these steps in order, each a child call reading its --json record:',
33
+ ...Object.values(STEP_VERBS).map((verb) => ` canon ${verb.join(' ')}`),
34
+ '',
35
+ 'The archive runs once per pull request number the merge brought in.',
36
+ 'Lines go to stderr, and an ordinary merge prints nothing.',
37
+ '',
38
+ 'The reclaim runs from the working directory with no root, so a pull',
39
+ 'inside a linked worktree never removes the worktree it stands in.',
40
+ '',
41
+ 'Environment:',
42
+ ' CANON_SKIP_RECLAIM=1 skip the worktree reclaim',
43
+ ' CANON_SKIP_UPGRADE=1 skip the CLI reinstall',
44
+ ' CANON_SKIP_PLUGIN_UPDATE=1 skip the plugin update',
45
+ '',
46
+ 'Exit codes:',
47
+ ' 0 always, since a hook failing aborts nothing useful',
48
+ '',
49
+ 'Examples:',
50
+ ' canon hooks post-merge',
51
+ ' CANON_SKIP_UPGRADE=1 canon hooks post-merge --root "$root"',
52
+ '',
53
+ ].join('\n'),
54
+ )
55
+ .action(async (opts: PostMergeOptions) => {
56
+ const cwd = process.cwd()
57
+ await runPostMerge({
58
+ root: opts.root ?? (await mainWorktreeRoot()),
59
+ cwd,
60
+ subjects: await readMergedSubjects(cwd),
61
+ env: process.env,
62
+ run: runCanon,
63
+ write: (text) => process.stderr.write(text),
64
+ })
65
+ process.exitCode = 0
66
+ })
67
+ }