@erclx/canon 5.9.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 (37) 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/review-ui/REQUIREMENT.md +3 -0
  8. package/claude/skills/review-ui/SKILL.md +15 -1
  9. package/claude/skills/review-ui/references/driving.md +3 -3
  10. package/claude/skills/review-ui/references/post.md +16 -3
  11. package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
  12. package/claude/skills/systematic-debugging/SKILL.md +45 -17
  13. package/claude/skills/systematic-debugging/references/adopted.md +44 -0
  14. package/docs/agents/capture.md +2 -2
  15. package/docs/agents/commands.md +1 -1
  16. package/docs/agents/scripting.md +0 -3
  17. package/docs/agents/tasks-plans.md +8 -5
  18. package/docs/workflow/skill-map.md +1 -0
  19. package/package.json +1 -1
  20. package/scripts/core/regen-agent-fixture.sh +3 -3
  21. package/scripts/core/regen-hero.ts +119 -0
  22. package/scripts/core/regen-web-favicon.ts +1 -1
  23. package/src/capture/frame-values.ts +359 -0
  24. package/src/claude/cases/misc.ts +5 -0
  25. package/src/claude/skills-audit.ts +2 -0
  26. package/src/commands/pr.ts +1 -1
  27. package/src/commands/tasks.ts +12 -2
  28. package/src/commands/tooling.ts +2 -2
  29. package/src/counts/catalogs.ts +3 -3
  30. package/src/design/tokens.ts +1 -1
  31. package/src/gate/stages.ts +1 -1
  32. package/src/help.ts +0 -1
  33. package/src/tasks/archive.ts +27 -0
  34. package/src/tasks/record.ts +25 -2
  35. package/standards/tasks.md +2 -2
  36. package/scripts/core/regen-hero.sh +0 -419
  37. package/scripts/tooling/create.sh +0 -109
@@ -91,9 +91,9 @@ if [ -z "$SESSIONS_JSON" ]; then
91
91
  fi
92
92
 
93
93
  # The listing crosses into the eval as a file rather than an environment entry,
94
- # for the reason regen-hero.sh records: Linux caps a single env string at 128KB
95
- # and a machine running many sessions can cross it, which fails the exec with
96
- # E2BIG before any check can report a thing.
94
+ # since Linux caps a single env string at 128KB and a machine running many
95
+ # sessions can cross it, which fails the exec with E2BIG before any check can
96
+ # report a thing.
97
97
  SESSIONS_FILE="$(mktemp)"
98
98
  trap 'rm -f "$SESSIONS_FILE"' EXIT
99
99
  printf '%s' "$SESSIONS_JSON" >"$SESSIONS_FILE"
@@ -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',
@@ -35,6 +35,8 @@ export const PRACTICE_SKILLS: readonly string[] = [
35
35
  join(SHIPPED_CORPUS, 'codebase-layout'),
36
36
  join(SHIPPED_CORPUS, 'test-craft'),
37
37
  join(SHIPPED_CORPUS, 'review-craft'),
38
+ join(SHIPPED_CORPUS, 'systematic-debugging'),
39
+ join(SHIPPED_CORPUS, 'api-design'),
38
40
  ]
39
41
 
40
42
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
@@ -1368,7 +1368,7 @@ async function listPullFiles(
1368
1368
  '--paginate',
1369
1369
  `repos/{owner}/{repo}/pulls/${number}/files`,
1370
1370
  '--jq',
1371
- '.[] | "\(.status)\t\(.filename)"',
1371
+ '.[] | [.status, .filename] | @tsv',
1372
1372
  ])
1373
1373
  if (stdout === null) return undefined
1374
1374
  const files = new Map<string, boolean>()
@@ -114,6 +114,7 @@ interface PullRequestCommandOptions {
114
114
  }
115
115
 
116
116
  interface PlanLinkCommandOptions {
117
+ readonly add?: boolean
117
118
  readonly json?: boolean
118
119
  readonly root?: string
119
120
  }
@@ -454,6 +455,10 @@ export function register(program: Command): void {
454
455
  .argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
455
456
  .argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
456
457
  .helpOption('-h, --help', 'Show this help message')
458
+ .option(
459
+ '--add',
460
+ 'Append the plan to the line, for a task sliced over plans',
461
+ )
457
462
  .option('--json', 'Emit a machine-readable record on stdout')
458
463
  .option('--root <path>', 'Board root, defaulting to the main worktree')
459
464
  .addHelpText(
@@ -461,7 +466,7 @@ export function register(program: Command): void {
461
466
  [
462
467
  '',
463
468
  'Exit codes:',
464
- ' 0 the line was added, corrected, or already correct',
469
+ ' 0 the line was added, appended to, corrected, or already correct',
465
470
  ' 1 refused, including several-plans for a line linking more than one',
466
471
  ' plan, with the reason on stderr or in the JSON record',
467
472
  '',
@@ -471,8 +476,13 @@ export function register(program: Command): void {
471
476
  'tasks plan-answers does. Safe from a linked worktree, since it',
472
477
  'resolves the board root in-process.',
473
478
  '',
479
+ 'With --add it keeps every link in order and appends the plan, so a',
480
+ 'sliced task links one plan per slice. It reports unchanged when the',
481
+ 'plan is already listed and is allowed on a line holding several.',
482
+ '',
474
483
  'Examples:',
475
484
  ' canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate',
485
+ ' canon tasks plan-link v28.1-trigger-escalation second-slice --add',
476
486
  ' canon tasks plan-link v28.1-trigger-escalation .canon/plans/feature-dispatch-answer-gate.md --json',
477
487
  '',
478
488
  ].join('\n'),
@@ -725,7 +735,7 @@ async function runPlanLink(
725
735
  ): Promise<number> {
726
736
  const emitJson = opts.json ?? false
727
737
  const root = opts.root ?? (await mainWorktreeRoot())
728
- const outcome = await recordPlan(root, task, plan)
738
+ const outcome = await recordPlan(root, task, plan, opts.add ?? false)
729
739
 
730
740
  return reportPlanLink(outcome, emitJson, root)
731
741
  }
@@ -34,7 +34,7 @@ import {
34
34
  select,
35
35
  } from '@/ui'
36
36
 
37
- const PASS_THROUGH_VERBS = ['create', 'verify'] as const
37
+ const PASS_THROUGH_VERBS = ['verify'] as const
38
38
 
39
39
  interface SyncOptions {
40
40
  readonly skip?: string
@@ -71,7 +71,7 @@ type Prepared =
71
71
  export function register(program: Command): void {
72
72
  const tooling = program
73
73
  .command('tooling')
74
- .description('Manage tooling stacks (sync, reference, create)')
74
+ .description('Manage tooling stacks (sync, reference)')
75
75
  .helpOption('-h, --help', 'Show this help message')
76
76
 
77
77
  tooling
@@ -25,14 +25,14 @@ const REGISTER_IMPORT = /^import \{ register as /gm
25
25
  /**
26
26
  * Every top-level `canon` command, read off the CLI entry point rather than a
27
27
  * catalog verb, because a command carries no `--json` listing of its own
28
- * siblings. `regen-hero.sh` reads its own `COMMAND_COUNT` through `gov counts`
28
+ * siblings. `regen-hero.ts` imports this reader for its `COMMAND_COUNT`
29
29
  * rather than a second regex on `src/cli.ts`, so this is the one place that
30
30
  * pattern is written.
31
31
  *
32
32
  * This is the one catalog with no meaning outside this repository, the way
33
- * `regen-hero.sh` documents itself as clone-only for the same reason.
33
+ * `regen-hero.ts` documents itself as clone-only for the same reason.
34
34
  */
35
- function countCommands(root: string): number | undefined {
35
+ export function countCommands(root: string): number | undefined {
36
36
  const cli = join(root, 'src', 'cli.ts')
37
37
  if (!existsSync(cli)) return undefined
38
38
  return [...readFileSync(cli, 'utf8').matchAll(REGISTER_IMPORT)].length
@@ -99,7 +99,7 @@ export const TOKENS: DesignTokens = {
99
99
  '',
100
100
  'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
101
101
  '',
102
- 'The two rendered captures read it as well. `scripts/core/regen-hero.sh` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
102
+ 'The two rendered captures read it as well. `scripts/core/regen-hero.ts` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
103
103
  '',
104
104
  'The terminal framing is the one surface left holding its own values, and that is a decision rather than a gap. `scripts/lib/ui.sh` and `src/ui.ts` each spell six escape constants, and `canon/context/scripts/framing.md` records one color source per language with a check behind each, so generating a third spelling from here would break the rule those two checks enforce. What the record is still incomplete about is the other half of those six: `WHITE` and `GREY` name no role below, so the terminal palette is described here in part rather than in whole.',
105
105
  ].join('\n'),
@@ -203,7 +203,7 @@ export const STAGES: readonly Stage[] = [
203
203
  checks: [
204
204
  {
205
205
  kind: 'command',
206
- argv: ['bash', 'scripts/core/regen-hero.sh', '--check'],
206
+ argv: ['bun', 'scripts/core/regen-hero.ts', '--check'],
207
207
  failure: 'Hero regen health check failed',
208
208
  },
209
209
  { kind: 'measure', measure: captureStamps },
package/src/help.ts CHANGED
@@ -95,7 +95,6 @@ const EXAMPLES = [
95
95
  'canon standards markdown',
96
96
  'canon init ../my-app',
97
97
  'canon tooling sync base',
98
- 'canon tooling create',
99
98
  'canon claude init',
100
99
  'canon indexes regen',
101
100
  'canon indexes regen --dry-run --json',