@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
@@ -417,6 +417,33 @@ export function retargetPlanLine(
417
417
  return lines.join('\n')
418
418
  }
419
419
 
420
+ /**
421
+ * Appends a link to a located `Plan:` line right after its last target, so
422
+ * trailing prose and a line ending stay where they were. A bare target is
423
+ * rewritten as a link on the way, since the reader takes bare paths only when
424
+ * no link sits on the line and the appended one would otherwise hide them.
425
+ */
426
+ export function appendPlanLink(line: string, target: string): string {
427
+ const tokens = planTokens(line)
428
+ if (tokens.length === 0) {
429
+ const trailing = /\s*$/.exec(line)?.[0] ?? ''
430
+ return `${line.slice(0, line.length - trailing.length)} ${planLink(target)}${trailing}`
431
+ }
432
+
433
+ let rewritten = ''
434
+ let at = 0
435
+ for (const token of tokens) {
436
+ const isLink = line[token.start] === '['
437
+ rewritten += line.slice(at, token.start)
438
+ rewritten += isLink
439
+ ? line.slice(token.start, token.end)
440
+ : planLink(token.target)
441
+ at = token.end
442
+ }
443
+
444
+ return `${rewritten}, ${planLink(target)}${line.slice(at)}`
445
+ }
446
+
420
447
  /** The `Ready:` line in either form, the link's target captured ahead of the bare path. */
421
448
  const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
422
449
 
@@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, join, relative } from 'node:path'
4
4
  import { planCandidates } from '@/tasks/answers'
5
5
  import {
6
+ appendPlanLink,
6
7
  describeUnmatchedStem,
7
8
  fenceMask,
8
9
  linkTo,
@@ -240,12 +241,33 @@ export type PlanLineWrite =
240
241
  * A line already linking several plans is refused whole, even when one of them
241
242
  * is the target, since overwriting it drops links the caller never named and
242
243
  * reporting it unchanged hides a line that breaks the one-plan rule.
244
+ *
245
+ * `add` is the sliced task's writer, which links one plan per slice. It keeps
246
+ * every link in order and appends the target, reports `unchanged` when the
247
+ * target is already listed, and so never refuses a line holding several.
243
248
  */
244
- export function writePlanLine(text: string, target: string): PlanLineWrite {
249
+ export function writePlanLine(
250
+ text: string,
251
+ target: string,
252
+ add = false,
253
+ ): PlanLineWrite {
245
254
  const line = planLine(target)
246
255
  const lines = text.split('\n')
247
256
  const existing = planLineIndex(lines)
248
257
 
258
+ if (existing !== -1 && add) {
259
+ if (readPlanTargets(lines[existing]).includes(target)) {
260
+ return { ok: true, text, action: 'unchanged', replaced: undefined }
261
+ }
262
+ lines[existing] = appendPlanLink(lines[existing], target)
263
+ return {
264
+ ok: true,
265
+ text: lines.join('\n'),
266
+ action: 'appended',
267
+ replaced: undefined,
268
+ }
269
+ }
270
+
249
271
  if (existing !== -1) {
250
272
  const targets = readPlanTargets(lines[existing])
251
273
  if (targets.length > 1) return { ok: false, targets }
@@ -449,6 +471,7 @@ export async function recordPlan(
449
471
  root: string,
450
472
  stem: string,
451
473
  reference: string,
474
+ add = false,
452
475
  ): Promise<PlanOutcome> {
453
476
  const opened = await openTask(root, { kind: 'stem', stem })
454
477
  if ('ok' in opened) return opened
@@ -464,7 +487,7 @@ export async function recordPlan(
464
487
  }
465
488
 
466
489
  const target = linkTo(dir, plan)
467
- const written = writePlanLine(await readFile(path, 'utf8'), target)
490
+ const written = writePlanLine(await readFile(path, 'utf8'), target, add)
468
491
 
469
492
  if (!written.ok) {
470
493
  return refuse(
@@ -124,9 +124,9 @@ An intake folder answers that direction at folder scope rather than item scope.
124
124
 
125
125
  A project that archived plans before the folder nested under `.canon/plans/` holds closed tasks pointing at `../plans-archive/` or `../.tmp/plans-archive/`. Leave those pointers where they are, since each resolves against the files it names and a task retargeted without its plan moving leads nowhere.
126
126
 
127
- One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for.
127
+ One plan per task, or one per slice on a sliced task. A plan cited by two tasks is a misfile rather than a shape to design for.
128
128
 
129
- `canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, so the line is a mechanical write rather than hand-edited markdown.
129
+ `canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, and `--add` appends a slice's plan, so the line is never hand-edited.
130
130
 
131
131
  `Groundwork:` points at `../groundwork/<slug>/`, the folder `plan-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
132
132
 
@@ -1,419 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Fills every assets/captures/*.html.tmpl from the CLI 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. Renaming the file is a sweep across roughly twenty surfaces for a
8
- # cosmetic gain, so the file keeps its name and the loop below covers whatever
9
- # templates the folder holds.
10
- #
11
- # The palette arrives the same way the counts do. `canon design css
12
- # --no-components` emits the custom properties from src/design/tokens.ts and
13
- # they land on the {{TOKENS}} placeholder, so neither frame carries its own copy
14
- # of a hex value and a token moved at the source moves both captures. The
15
- # component half is left out on purpose: it is a scrollbar and a status marker,
16
- # and a static capture frame renders neither.
17
- #
18
- # A frame a README shows in both themes fills once more against the light roles
19
- # and writes `<name>-light.html` beside the dark one, so each theme is its own
20
- # capture set with its own stamp.
21
- #
22
- # Only the HTML regenerates here. The PNG beside it is a chromium render whose
23
- # bytes move with the browser version, so asserting it in verify.sh would fail
24
- # on a machine whose chromium differs rather than on a stale count. Rebuild the
25
- # images with `canon capture assets/captures --selector .window --out assets/evidence`
26
- # after this script reports a change. The markup and the image sit in two
27
- # folders, so that run reads the sources here and sends every PNG and stamp to
28
- # assets/evidence/, where the documents point and where `canon pr evidence`
29
- # compares a changed image. The selector has no default, since
30
- # the element a capture crops to belongs to the page rather than to the command,
31
- # and `.window` is the class this repository's own sources declare.
32
- # That capture also writes a .stamp beside each PNG, which records the digest of
33
- # the markup it rendered and is what the Hero stage compares, so a frame's three
34
- # files commit together. The frame carries no version. `package.json` is bumped on main by the release
35
- # tooling, so embedding it drifts every open branch on the next release and the
36
- # stage then fails for work that touched nothing.
37
- #
38
- # Clone-only. `canon capture` ships now, but this script reads the repository's
39
- # own catalogs, which a registry install does not carry.
40
- set -e
41
- set -o pipefail
42
-
43
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44
- PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
45
-
46
- ASSET_DIR="$PROJECT_ROOT/assets/captures"
47
- LISTED=10
48
-
49
- # `--check` fills every template into a temp folder and discards it, so a renamed
50
- # featured skill, an empty catalog, or an unresolved placeholder still fails a
51
- # branch while no frame file changes. Frames are refreshed after a merge rather
52
- # than committed by each branch, so a branch asks whether the regen can run and
53
- # never whether its output is current.
54
- OUT_DIR="$ASSET_DIR"
55
- case "${1:-}" in
56
- '') ;;
57
- --check) OUT_DIR="$(mktemp -d)" ;;
58
- *)
59
- echo "regen-hero: unknown argument $1" >&2
60
- exit 1
61
- ;;
62
- esac
63
-
64
- # `bun src/cli.ts` rather than `canon`, since a globally linked binary resolves to
65
- # the main checkout no matter which worktree is running.
66
- catalog() {
67
- (cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts "$@" --json 2>/dev/null)
68
- }
69
-
70
- TEMPLATES=("$ASSET_DIR"/*.html.tmpl)
71
- if [ ! -f "${TEMPLATES[0]}" ]; then
72
- echo "regen-hero: no templates under $ASSET_DIR" >&2
73
- exit 1
74
- fi
75
-
76
- # Not routed through `catalog`, since the design emitter writes CSS on stdout
77
- # rather than a `--json` record.
78
- TOKEN_CSS="$(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts design css --no-components)"
79
- if [ -z "$TOKEN_CSS" ]; then
80
- echo "regen-hero: the design source emitted nothing, refusing to write an unstyled frame" >&2
81
- exit 1
82
- fi
83
-
84
- # The commands have no `--json` catalog of their own, so the count comes from
85
- # `canon gov counts`, which already reads the registration block in the CLI
86
- # entry point to judge a document stating its own command count. Reading that
87
- # figure here rather than re-deriving it with a second regex is what keeps the
88
- # two readings of `src/cli.ts` from drifting apart unwatched, which is the
89
- # defect `canon gov counts` itself exists to catch.
90
- # `gov counts` exits non-zero on an ordinary finding, unlike every `list` verb
91
- # below, so its output is read past that under `set -e` rather than aborting
92
- # a hero rebuild for a stale count somewhere unrelated in the tree.
93
- COUNTS_JSON="$(catalog gov counts || true)"
94
- SKILLS_JSON="$(catalog claude skills list)"
95
- GOV_JSON="$(catalog gov list)"
96
- STANDARDS_JSON="$(catalog standards list)"
97
- TOOLING_JSON="$(catalog tooling list)"
98
-
99
- for payload in "$COUNTS_JSON" "$SKILLS_JSON" "$GOV_JSON" "$STANDARDS_JSON" "$TOOLING_JSON"; do
100
- if [ -z "$payload" ]; then
101
- echo "regen-hero: a catalog returned nothing, refusing to write a zeroed hero" >&2
102
- exit 1
103
- fi
104
- done
105
-
106
- # The catalogs reach the eval as files rather than as environment entries. Linux
107
- # caps a single env string at 128KB, and the standards payload crossed it, which
108
- # fails the exec with E2BIG before any stage can report a stale count. A file
109
- # path is bounded whatever the catalogs grow to.
110
- PAYLOAD_DIR="$(mktemp -d)"
111
- trap 'rm -rf "$PAYLOAD_DIR"; [ "$OUT_DIR" = "$ASSET_DIR" ] || rm -rf "$OUT_DIR"' EXIT
112
-
113
- printf '%s' "$COUNTS_JSON" >"$PAYLOAD_DIR/counts.json"
114
- printf '%s' "$SKILLS_JSON" >"$PAYLOAD_DIR/skills.json"
115
- printf '%s' "$GOV_JSON" >"$PAYLOAD_DIR/gov.json"
116
- printf '%s' "$STANDARDS_JSON" >"$PAYLOAD_DIR/standards.json"
117
- printf '%s' "$TOOLING_JSON" >"$PAYLOAD_DIR/tooling.json"
118
- printf '%s' "$TOKEN_CSS" >"$PAYLOAD_DIR/tokens.css"
119
-
120
- export PAYLOAD_DIR
121
- export ASSET_DIR OUT_DIR LISTED PROJECT_ROOT
122
-
123
- bun --eval '
124
- const { readFileSync, readdirSync } = require("node:fs")
125
-
126
- const { PAYLOAD_DIR, ASSET_DIR, OUT_DIR, LISTED, PROJECT_ROOT } = process.env
127
-
128
- const payload = (name) => readFileSync(PAYLOAD_DIR + "/" + name + ".json", "utf8")
129
-
130
- // Indented to the depth a rule inside the `<style>` block sits at, so the
131
- // emitted file passes the same formatter every other committed asset does.
132
- const tokenCss = readFileSync(PAYLOAD_DIR + "/tokens.css", "utf8")
133
- .trimEnd()
134
- .split("\n")
135
- .map((line) => (line === "" ? "" : " " + line))
136
- .join("\n")
137
-
138
- const COUNTS_JSON = payload("counts")
139
- const SKILLS_JSON = payload("skills")
140
- const GOV_JSON = payload("gov")
141
- const STANDARDS_JSON = payload("standards")
142
- const TOOLING_JSON = payload("tooling")
143
-
144
- const listed = Number(LISTED)
145
- const skills = JSON.parse(SKILLS_JSON).skills.map((entry) => entry.name)
146
- const gov = JSON.parse(GOV_JSON)
147
- // Rule names carry a numeric prefix that orders the load, not the identity a
148
- // reader knows them by, so the frame shows the slug alone.
149
- const slug = (entry) => entry.name.replace(/^\d+-/, "")
150
- const rules = gov.rules.map(slug)
151
- // A rule no stack names is opt-in behind `--add`, so featuring one advertises
152
- // an entry no ordinary `canon gov install` delivers. The column samples the
153
- // stack-reached subset while its count and remainder describe the whole catalog.
154
- const stacked = new Set(gov.stacks.flatMap((stack) => stack.rules))
155
- const deliveredRules = gov.rules.filter((entry) => stacked.has(entry.name)).map(slug)
156
- const standards = JSON.parse(STANDARDS_JSON).standards.map((entry) => entry.name)
157
- const toolingStacks = JSON.parse(TOOLING_JSON).stacks
158
- const commandCount = JSON.parse(COUNTS_JSON).catalogs.commands
159
- if (!commandCount) {
160
- console.error("regen-hero: gov counts read no commands catalog, refusing to write a zeroed hero")
161
- process.exit(1)
162
- }
163
-
164
- const MARK_SVG = (await Bun.file(`${PROJECT_ROOT}/assets/brand/mark.svg`).text()).trim()
165
- if (!MARK_SVG) {
166
- console.error("regen-hero: assets/brand/mark.svg read empty, refusing to write a hero with no mark")
167
- process.exit(1)
168
- }
169
-
170
- const accentMatch = tokenCss.match(/--color-accent:\s*(#[0-9a-fA-F]{3,8})/)
171
- if (!accentMatch) {
172
- console.error("regen-hero: token css carries no --color-accent value, refusing to write a colorless favicon")
173
- process.exit(1)
174
- }
175
- const faviconSvg = MARK_SVG.replace(/<!--[\s\S]*?-->/, "").trim().replaceAll("currentColor", accentMatch[1])
176
- const FAVICON = `data:image/svg+xml,${encodeURIComponent(faviconSvg)}`
177
-
178
- const escape = (value) =>
179
- value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
180
-
181
- // Even spacing across the sorted catalog rather than its first N, so a column
182
- // whose names share a prefix does not read as a narrower catalog than the one
183
- // that ships. Rules and standards sample. Skills do not, because the even step
184
- // lands on the entries closest to commodity and on none of the workflow the
185
- // toolkit exists to carry.
186
- const sample = (names) => {
187
- if (names.length <= listed) return names
188
- const step = names.length / listed
189
- return Array.from({ length: listed }, (_, i) => names[Math.floor(i * step)])
190
- }
191
-
192
- // The first seven are the method and exist nowhere else. The last three are
193
- // recognizable on sight and are what keeps a column of chosen names from
194
- // reading as an all-`claude-` catalog, which is the failure the sampler above
195
- // was written against.
196
- const FEATURED_SKILLS = [
197
- "plan-feature",
198
- "plan-groundwork",
199
- "plan-intake",
200
- "role-orchestrator",
201
- "auto-ship",
202
- "review-pr",
203
- "task-board",
204
- "git-ship",
205
- "target-setup",
206
- "systematic-debugging",
207
- ]
208
-
209
- // A chosen name is maintained by hand where a sample never goes stale, so a
210
- // rename has to fail the run rather than quietly shrink the column. This is
211
- // what makes the trade acceptable.
212
- //
213
- // The length is asserted because the `+N more` figure below counts down from
214
- // `listed` rather than from what this returns, so a list of any other length
215
- // renders a remainder that is wrong by the difference.
216
- const featured = (names, chosen) => {
217
- if (chosen.length !== listed) {
218
- console.error(`regen-hero: ${chosen.length} featured skills, expected ${listed}`)
219
- process.exit(1)
220
- }
221
- const catalog = new Set(names)
222
- const missing = chosen.filter((name) => !catalog.has(name))
223
- if (missing.length > 0) {
224
- console.error(`regen-hero: featured skills missing from the catalog: ${missing.join(", ")}`)
225
- process.exit(1)
226
- }
227
- return chosen
228
- }
229
-
230
- const markup = (names) =>
231
- names
232
- .map((name) => ` <div class="entry">${escape(name)}</div>`)
233
- .join("\n")
234
-
235
- const entries = (names) => markup(sample(names))
236
-
237
- const remaining = (names) => String(Math.max(0, names.length - listed))
238
-
239
- // An empty array is well-formed JSON, so the shell guard on an empty payload
240
- // does not catch a catalog whose tree went missing. A zeroed count would ship a
241
- // frame claiming the domain has nothing in it.
242
- for (const [label, list] of [
243
- ["skills", skills], ["rules", rules], ["standards", standards],
244
- ["tooling stacks", toolingStacks], ["gov stacks", gov.stacks],
245
- ["stack-delivered rules", deliveredRules],
246
- ]) {
247
- if (list.length === 0) {
248
- console.error(`regen-hero: the ${label} catalog is empty, refusing to write a zeroed hero`)
249
- process.exit(1)
250
- }
251
- }
252
-
253
- // A terminal frame renders lines into a `<pre>` where the hero renders names
254
- // into divs, so the two cannot share a value however similar the catalog read
255
- // behind them looks. Padding happens before escaping, since an entity is longer
256
- // than the character it replaces and would push a column out of line.
257
- const pad = (value, width) => value + " ".repeat(Math.max(0, width - value.length))
258
- const frameRow = (cells) => `<span class="frame">│</span> <span class="ok">✓</span> ${cells}`
259
-
260
- // The frame sets `white-space: pre` at a fixed window width, so an overlong
261
- // cell is clipped by the window edge rather than wrapped. Truncating here is
262
- // what keeps the longest row inside the capture, and the ellipsis is what stops
263
- // a clipped value from reading as the whole value.
264
- const GLOB_WIDTH = 44
265
- const clip = (value, width) =>
266
- value.length <= width ? value : `${value.slice(0, width - 1)}…`
267
-
268
- const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`
269
-
270
- const govStackRows = gov.stacks
271
- .map((stack) =>
272
- frameRow(
273
- `<span class="name">${escape(pad(stack.name, 17))}</span>` +
274
- `<span class="muted">${escape(pad(plural(stack.rules.length, "rule"), 11))}</span>` +
275
- `<span class="muted">${escape(stack.extends ? `extends ${stack.extends}` : "")}</span>`,
276
- ),
277
- )
278
- .join("\n")
279
-
280
- // Sampled from the stack-reached subset for the reason the hero column is, and
281
- // showing each rule beside the glob that loads it rather than its description,
282
- // since the glob is the mechanism a reader cannot otherwise see.
283
- const deliveredEntries = gov.rules.filter((entry) => stacked.has(entry.name))
284
- const govRuleRows = sample(deliveredEntries)
285
- .map((entry) => {
286
- const globs = [entry.paths ?? []].flat()
287
- // A rule carrying no glob is not unscoped, it loads every session, and
288
- // saying so is the contrast that makes the column mean anything.
289
- const scope = globs.length > 0 ? clip(globs.join(" "), GLOB_WIDTH) : "every session"
290
- return frameRow(
291
- `<span class="name">${escape(pad(slug(entry), 24))}</span>` +
292
- `<span class="domain">${escape(pad(`[${entry.domain}]`, 12))}</span>` +
293
- `<span class="muted">${escape(scope)}</span>`,
294
- )
295
- })
296
- .join("\n")
297
-
298
- // A standard either governs an artifact by path or is opened by name when a
299
- // session decides it needs it. Rendering the first and naming the second is the
300
- // contrast, since nothing else in the catalog tells a reader that the corpus
301
- // installs into no project and is read through a verb instead.
302
- const standardEntries = JSON.parse(STANDARDS_JSON).standards
303
- const standardRows = sample(standardEntries)
304
- .map((entry) => {
305
- const applies = [entry.appliesTo ?? []].flat()
306
- const governs =
307
- applies.length > 0 ? clip(applies.join(" "), GLOB_WIDTH) : "read by name"
308
- return frameRow(
309
- `<span class="name">${escape(pad(entry.name, 18))}</span>` +
310
- `<span class="muted">${escape(governs)}</span>`,
311
- )
312
- })
313
- .join("\n")
314
-
315
- // A tooling stack is counted rather than listed, because what it lays down is
316
- // dev dependencies, run scripts, and ignore groups rather than named entries a
317
- // reader would recognize. The inheritance is the part worth showing, since it
318
- // is why a stack carrying two of its own arrives with far more than two.
319
- const toolingStackRows = toolingStacks
320
- .map((stack) =>
321
- frameRow(
322
- `<span class="name">${escape(pad(stack.name, 14))}</span>` +
323
- `<span class="muted">${escape(pad(plural(stack.devDeps, "dep"), 10))}</span>` +
324
- `<span class="muted">${escape(pad(plural(stack.scripts, "script"), 12))}</span>` +
325
- `<span class="muted">${escape(stack.extends ? `extends ${stack.extends}` : "")}</span>`,
326
- ),
327
- )
328
- .join("\n")
329
-
330
- for (const [label, rows] of [
331
- ["governance stack rows", govStackRows],
332
- ["governance rule rows", govRuleRows],
333
- ["standard rows", standardRows],
334
- ["tooling stack rows", toolingStackRows],
335
- ]) {
336
- if (rows === "") {
337
- console.error(`regen-hero: the ${label} rendered empty, refusing to write a blank frame`)
338
- process.exit(1)
339
- }
340
- }
341
-
342
- const values = {
343
- TOKENS: tokenCss,
344
- GOV_STACK_ROWS: govStackRows,
345
- GOV_RULE_ROWS: govRuleRows,
346
- STANDARD_ROWS: standardRows,
347
- TOOLING_STACK_ROWS: toolingStackRows,
348
- SKILL_COUNT: String(skills.length),
349
- RULE_COUNT: String(rules.length),
350
- STANDARD_COUNT: String(standards.length),
351
- GOV_STACK_COUNT: String(gov.stacks.length),
352
- TOOLING_STACK_COUNT: String(toolingStacks.length),
353
- COMMAND_COUNT: String(commandCount),
354
- MARK_SVG,
355
- FAVICON,
356
- SKILL_ENTRIES: markup(featured(skills, FEATURED_SKILLS)),
357
- RULE_ENTRIES: entries(deliveredRules),
358
- STANDARD_ENTRIES: entries(standards),
359
- SKILL_MORE: remaining(skills),
360
- RULE_MORE: remaining(rules),
361
- STANDARD_MORE: remaining(standards),
362
- }
363
-
364
- // Every template takes the same value map, so one carrying no count placeholder
365
- // simply resolves none of them. What a template must not do is name a
366
- // placeholder nobody fills, which the unresolved check below catches per file.
367
- const templates = readdirSync(ASSET_DIR)
368
- .filter((name) => name.endsWith(".html.tmpl"))
369
- .sort()
370
-
371
- if (templates.length === 0) {
372
- console.error(`regen-hero: no templates under ${ASSET_DIR}`)
373
- process.exit(1)
374
- }
375
-
376
- // The frames a README serves in both themes. The emitter already carries a
377
- // `--color-light-*` role beside each `--color-*` one, so the light frame is the
378
- // same template with the plain roles pointed at the light ones rather than a
379
- // second copy of any hex value. The hero is a landing-page frame no README
380
- // shows, so it stays dark.
381
- const LIGHT_TEMPLATES = new Set(["install.html.tmpl"])
382
-
383
- const lightRoles = [...tokenCss.matchAll(/--color-light-([a-z-]+):/g)].map((match) => match[1])
384
- if (lightRoles.length === 0) {
385
- console.error("regen-hero: token css carries no --color-light-* roles, refusing to write a light frame")
386
- process.exit(1)
387
- }
388
- const lightTokenCss = [
389
- tokenCss,
390
- "",
391
- " :root {",
392
- ...lightRoles.map((role) => ` --color-${role}: var(--color-light-${role});`),
393
- " --frame-shadow: rgb(0 0 0 / 16%);",
394
- " }",
395
- ].join("\n")
396
-
397
- const frames = templates.flatMap((template) => {
398
- const base = template.replace(/\.html\.tmpl$/, "")
399
- const dark = { template, out: `${base}.html`, tokens: tokenCss }
400
- return LIGHT_TEMPLATES.has(template)
401
- ? [dark, { template, out: `${base}-light.html`, tokens: lightTokenCss }]
402
- : [dark]
403
- })
404
-
405
- for (const { template, out, tokens } of frames) {
406
- let html = await Bun.file(ASSET_DIR + "/" + template).text()
407
- for (const [key, value] of Object.entries({ ...values, TOKENS: tokens })) {
408
- html = html.replaceAll(`{{${key}}}`, value)
409
- }
410
-
411
- const unresolved = html.match(/{{[A-Z_]+}}/g)
412
- if (unresolved) {
413
- console.error(`regen-hero: ${template} carries unresolved placeholders ${[...new Set(unresolved)].join(", ")}`)
414
- process.exit(1)
415
- }
416
-
417
- await Bun.write(OUT_DIR + "/" + out, html)
418
- }
419
- '
@@ -1,109 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(dirname "$(dirname "$SCRIPT_DIR")")}"
7
-
8
- source "$PROJECT_ROOT/scripts/lib/ui.sh"
9
- source "$PROJECT_ROOT/scripts/lib/tooling.sh"
10
- trap close_timeline EXIT
11
-
12
- show_help() {
13
- echo -e "${GREY}┌${NC}"
14
- log_step "Tooling create usage"
15
- echo -e "${GREY}│${NC} ${WHITE}Usage:${NC} canon tooling create [stack]"
16
- echo -e "${GREY}│${NC}"
17
- echo -e "${GREY}│${NC} Creates a new stack with stub manifest, reference, configs, and seeds."
18
- echo -e "${GREY}│${NC}"
19
- echo -e "${GREY}│${NC} ${WHITE}Arguments:${NC}"
20
- echo -e "${GREY}│${NC} stack Name of the new stack to create"
21
- echo -e "${GREY}│${NC}"
22
- echo -e "${GREY}│${NC} ${WHITE}Options:${NC}"
23
- echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
24
- echo -e "${GREY}└${NC}"
25
- exit 0
26
- }
27
-
28
- main() {
29
- if [[ "$1" == "-h" || "$1" == "--help" ]]; then
30
- show_help
31
- fi
32
-
33
- local stack="$1"
34
-
35
- echo -e "${GREY}┌${NC}"
36
- echo -e "${GREY}│${NC} ${WHITE}canon tooling create${NC}"
37
-
38
- if [ -z "$stack" ]; then
39
- ask "Stack name?" "stack"
40
- fi
41
-
42
- if [ -z "$stack" ]; then
43
- log_error "Stack name is required"
44
- fi
45
-
46
- if is_tooling_stack_excluded "$stack"; then
47
- log_error "Reserved name: $stack. Managed by \`canon claude\`, not \`canon tooling\`."
48
- fi
49
-
50
- local dest="$PROJECT_ROOT/tooling/$stack"
51
-
52
- if [ -d "$dest" ]; then
53
- log_error "Stack already exists: $stack"
54
- fi
55
-
56
- log_step "Creating"
57
- log_add "tooling/$stack/configs/"
58
- log_add "tooling/$stack/seeds/"
59
- log_add "tooling/$stack/manifest.toml"
60
- log_add "tooling/$stack/reference.md"
61
-
62
- select_option "Create stack at tooling/$stack?" "Yes" "No"
63
-
64
- if [ "$SELECTED_OPTION" = "No" ]; then
65
- log_warn "Cancelled"
66
- exit 0
67
- fi
68
-
69
- log_step "Applying"
70
-
71
- mkdir -p "$dest/configs"
72
- log_add "tooling/$stack/configs/"
73
-
74
- mkdir -p "$dest/seeds"
75
- log_add "tooling/$stack/seeds/"
76
-
77
- cat >"$dest/manifest.toml" <<EOF
78
- [stack]
79
- name = "$stack"
80
- extends = ""
81
- runtime = ""
82
- scaffold = ""
83
-
84
- [dependencies.dev]
85
- packages = []
86
-
87
- [scripts]
88
- "script-key" = "command --flag"
89
-
90
- [gitignore]
91
- "# $stack" = ["pattern/", ".file"]
92
- EOF
93
- log_add "tooling/$stack/manifest.toml"
94
- cat >"$dest/reference.md" <<EOF
95
- # Tooling $stack reference
96
-
97
- ## Overview
98
-
99
- [One or two sentences: what this stack provides and its purpose.]
100
-
101
- EOF
102
- log_add "tooling/$stack/reference.md"
103
-
104
- trap - EXIT
105
- echo -e "${GREY}└${NC}\n"
106
- echo -e "${GREEN}✓ Stack created${NC}"
107
- }
108
-
109
- main "$@"