@erclx/canon 5.74.0 → 5.76.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 (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-rule/SKILL.md +5 -1
  4. package/claude/skills/create-rule/references/ledger.md +73 -0
  5. package/claude/skills/create-rule/references/rule.md +1 -1
  6. package/claude/skills/create-skill/references/skill-practice.md +1 -1
  7. package/claude/skills/design-identity/REQUIREMENT.md +1 -1
  8. package/claude/skills/design-identity/SKILL.md +13 -4
  9. package/claude/skills/design-identity/references/card-route.md +11 -4
  10. package/claude/skills/git-issue/REQUIREMENT.md +4 -2
  11. package/claude/skills/git-issue/SKILL.md +32 -3
  12. package/claude/skills/git-issue/references/issue.md +7 -0
  13. package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
  14. package/claude/skills/issue-triage/SKILL.md +130 -0
  15. package/claude/skills/pr-evidence/references/reel.md +9 -9
  16. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  17. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
  20. package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
  21. package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
  22. package/claude/skills/ui-film/SKILL.md +8 -7
  23. package/docs/agents/audits.md +24 -24
  24. package/docs/agents/migrate.md +10 -10
  25. package/docs/migrations/by-verb.md +10 -4
  26. package/docs/workflow/ai-workflow.md +1 -1
  27. package/docs/workflow/skill-map.md +1 -0
  28. package/governance/detect.toml +4 -0
  29. package/governance/ledger/claude/rule.md +16 -0
  30. package/governance/rules/claude/rule.md +13 -0
  31. package/package.json +1 -1
  32. package/scripts/ci/refresh-frames-pr.sh +11 -12
  33. package/src/audits/catalog.ts +7 -2
  34. package/src/capture/render.ts +30 -5
  35. package/src/capture/stamp.ts +3 -5
  36. package/src/claude/cases/git.ts +5 -0
  37. package/src/claude/cases/workflow.ts +4 -0
  38. package/src/claude/skills-audit.ts +65 -0
  39. package/src/cli/help.ts +1 -1
  40. package/src/commands/capture.ts +38 -1
  41. package/src/commands/claude.ts +10 -2
  42. package/src/commands/gov.ts +12 -7
  43. package/src/commands/migrate.ts +6 -2
  44. package/src/commands/take.ts +36 -7
  45. package/src/counts/catalogs.ts +3 -5
  46. package/src/gate/measures.ts +1 -144
  47. package/src/gate/stages.ts +0 -23
  48. package/src/gov/audit.ts +59 -13
  49. package/src/markdown/scope.ts +4 -0
  50. package/src/migrate/session-defaults.ts +146 -8
  51. package/src/take/encode.ts +9 -1
  52. package/src/take/film.ts +17 -2
  53. package/src/take/fixtures/busy.html +107 -0
  54. package/src/take/perform.ts +99 -25
  55. package/src/take/screencast.ts +4 -3
  56. package/src/take/stepper.ts +33 -20
  57. package/tooling/claude/reference.md +4 -2
  58. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  59. package/tooling/claude/seeds/.claude/settings.json +12 -0
  60. package/scripts/core/regen-hero.ts +0 -113
  61. package/src/capture/frame-values.ts +0 -326
@@ -5,7 +5,7 @@ import {
5
5
  type KeyInput,
6
6
  type PointerInput,
7
7
  type Stage,
8
- TargetMissing,
8
+ TargetRefused,
9
9
  performSteps,
10
10
  } from '@/take/perform'
11
11
  import type { Beat } from '@/take/plan'
@@ -25,7 +25,10 @@ import { type ShellPage, openShell } from '@/take/shell'
25
25
  * - Frame time continues Chromium's monotonic clock, since a frame stamped on
26
26
  * any other base leaves every CSS animation at its first frame
27
27
  * - No frame is begun before the load event, since one sent mid-navigation can
28
- * go unanswered, and each loading frame is capped so a lost one cannot hang
28
+ * go unanswered
29
+ * - A frame is begun only once the one before it settles, since Chromium
30
+ * refuses a second while one is pending, and a frame left pending past the
31
+ * stall limit falls the beat back rather than hanging it
29
32
  */
30
33
 
31
34
  const FRAME_MS = 1000 / FPS
@@ -41,7 +44,6 @@ export const HOLD_MS = 600
41
44
 
42
45
  const LOAD_CAP_MS = 15_000
43
46
  const SETTLE_MS = 2500
44
- const LOADING_FRAME_CAP_MS = 250
45
47
 
46
48
  /** How long a refusal waits on the frame it shows, since the page may be wedged. */
47
49
  const STILL_CAP_MS = 2000
@@ -81,19 +83,25 @@ export async function filmStepped(
81
83
  let still: (() => Promise<Buffer | undefined>) | undefined
82
84
  try {
83
85
  let ticks = await load(page, request.url)
84
- const beginFrame = (screenshot: boolean) =>
85
- page.send<{ screenshotData?: string }>(
86
- 'HeadlessExperimental.beginFrame',
87
- {
88
- frameTimeTicks: (ticks += FRAME_MS),
89
- interval: FRAME_MS,
90
- ...(screenshot
91
- ? { screenshot: { format: 'jpeg', quality: 95 } }
92
- : {}),
93
- },
86
+ let outstanding: Promise<unknown> = Promise.resolve()
87
+ const beginFrame = (screenshot: boolean) => {
88
+ const reply = outstanding.then(() =>
89
+ page.send<{ screenshotData?: string }>(
90
+ 'HeadlessExperimental.beginFrame',
91
+ {
92
+ frameTimeTicks: (ticks += FRAME_MS),
93
+ interval: FRAME_MS,
94
+ ...(screenshot
95
+ ? { screenshot: { format: 'jpeg', quality: 95 } }
96
+ : {}),
97
+ },
98
+ ),
94
99
  )
100
+ outstanding = reply.catch(() => {})
101
+ return reply
102
+ }
95
103
  still = () => frameStill(beginFrame)
96
- await pump(beginFrame, SETTLE_MS)
104
+ await pump(beginFrame, SETTLE_MS, stallMs)
97
105
 
98
106
  const live = liveStage(page, beginFrame, stallMs)
99
107
  await performSteps(live, request.beat.setup, { pointer: START })
@@ -163,7 +171,7 @@ export async function filmStepped(
163
171
  return { frames, events, still: last }
164
172
  } catch (error) {
165
173
  encoder.abort()
166
- if (error instanceof TargetMissing && still) error.still = await still()
174
+ if (error instanceof TargetRefused && still) error.still = await still()
167
175
  throw error
168
176
  } finally {
169
177
  page.close()
@@ -215,17 +223,22 @@ async function load(page: ShellPage, url: string): Promise<number> {
215
223
  return timestamp
216
224
  }
217
225
 
218
- /** Begins frames in real time so loading, fonts, and layout settle. */
226
+ /**
227
+ * Begins frames in real time so loading, fonts, and layout settle. Each frame
228
+ * is awaited, so a slow page stretches the settle rather than overlapping it.
229
+ */
219
230
  async function pump(
220
231
  beginFrame: (screenshot: boolean) => Promise<unknown>,
221
232
  ms: number,
233
+ stallMs: number,
222
234
  ): Promise<void> {
223
235
  const end = Date.now() + ms
224
236
  while (Date.now() < end) {
225
- await Promise.race([
237
+ await withinStall(
226
238
  beginFrame(false).catch(() => {}),
227
- sleep(LOADING_FRAME_CAP_MS),
228
- ])
239
+ 0,
240
+ stallMs,
241
+ )
229
242
  await sleep(12)
230
243
  }
231
244
  }
@@ -242,7 +255,7 @@ function liveStage(
242
255
  const started = Date.now()
243
256
  return {
244
257
  now: () => Date.now() - started,
245
- hold: (ms) => pump(beginFrame, ms),
258
+ hold: (ms) => pump(beginFrame, ms, stallMs),
246
259
  pointer: (input) => {
247
260
  const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
248
261
  return withinStall(
@@ -68,14 +68,16 @@ A project installed before the board became a folder still holds `.claude/TASKS.
68
68
 
69
69
  ## settings.json
70
70
 
71
- - Pair the seed's three PreToolUse hooks with `index-reminder.sh`, `search-reminder.sh`, and `scratch-guard.sh`, and its PostToolUse hook with `standards-audit.sh`, all under `.claude/hooks/`
71
+ - Pair the seed's PreToolUse hooks with `index-reminder.sh`, `search-reminder.sh`, `scratch-guard.sh`, `review-post-scan.sh`, and `plan-guard.sh`, and its PostToolUse hook with `standards-audit.sh`
72
72
  - Keep only what is project-specific in the project file, since project settings layer over user settings
73
73
  - Expect `permissions.deny` to hold `Agent` and `Task`, so no session spawns a subagent and parallel work runs as separate background sessions
74
74
  - Expect `autoMemoryEnabled` to be `false`. A person turns it back on for themselves in `settings.local.json`.
75
- - Run `canon migrate session-defaults` on a target seeded before those two defaults or before the merge entries below, since sync never rewrites an existing settings file
75
+ - Run `canon migrate session-defaults` on a target seeded before those defaults, the merge entries, or the plan guard below, since sync never rewrites an existing settings file
76
76
 
77
77
  The seed's deny list also holds `Bash(canon pr merge:*)` and `Bash(gh pr merge:*)`, so the operator alone merges. An existing target gets both from `canon migrate session-defaults --write`, and `canon claude sync` warns while either is absent. Neither covers more than the form an agent writes by default.
78
78
 
79
+ `plan-guard.sh` blocks a new plan under `.canon/plans/` until `plan-feature` is invoked for it, since a heredoc never loads the plan rule. It passes when the transcript holds no assistant tool call it can parse, though a format change confined to the Skill call still blocks. It logs each decision under `.canon/tmp/hooks/plan-guard/`.
80
+
79
81
  ## User settings
80
82
 
81
83
  - `canon claude setup` installs `tooling/claude/user/settings.template.json` once per machine
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # A path-scoped rule loads on a Read, Write, or Edit of a matching file, and a
4
+ # heredoc onto `.canon/plans/` is none of them, so the plan rule never reaches
5
+ # the session writing most plans. This guard is the check behind that rule: a
6
+ # new plan passes only when the transcript shows a `canon:plan-feature`
7
+ # invocation this session has not already spent on an earlier new plan, or an
8
+ # `auto-ship` invocation naming that plan's path. It
9
+ # backs the rule up rather than replacing it, since a hook fails open on a
10
+ # timeout and on anything below it cannot read.
11
+ IFS= read -r -d '' -t 2 input
12
+ [ -n "$input" ] || {
13
+ printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
14
+ exit 1
15
+ }
16
+
17
+ # Bash is the highest-frequency tool, so the one field a write names is lifted
18
+ # out of the raw payload and tested with a shell pattern before jq starts.
19
+ field_re='"(command|file_path)":"(([^"\\]|\\.)*)"'
20
+ [[ $input =~ $field_re ]] || exit 0
21
+ case "${BASH_REMATCH[2]}" in
22
+ *.canon/plans/*) ;;
23
+ *) exit 0 ;;
24
+ esac
25
+
26
+ target=$(printf '%s' "$input" | jq -r '
27
+ if .tool_name == "Write" then "F" + .tool_input.file_path
28
+ elif .tool_name == "Bash" then "B" + .tool_input.command
29
+ else empty end // empty')
30
+ [ -n "$target" ] || exit 0
31
+ kind=${target:0:1}
32
+ target=${target:1}
33
+
34
+ # A command that only mentions the folder is not a write to it, as with
35
+ # `canon tasks plan-link` or a heredoc body quoting a plan path. The awk pass
36
+ # reads the command as words, drops heredoc bodies, and prints only the words a
37
+ # write lands on: the word after a `>` or `>>`, and every argument of `tee` or
38
+ # `cp`, whose source is an existing file and so never reads as a new plan. A
39
+ # quoted span is one word, so a redirect inside quotes is text. This is a
40
+ # scanner and not a shell parser, so a path built in a variable or written by
41
+ # `printf` into an interpreter passes unseen.
42
+ if [ "$kind" = B ]; then
43
+ IFS= read -r -d '' scanner <<'AWK'
44
+ { s = s (NR > 1 ? "\n" : "") $0 }
45
+ function flush() {
46
+ if (!inword) return
47
+ if (redirect) print word
48
+ else if (tag) { nt++; tags[nt] = word; dashes[nt] = dash }
49
+ else if (cmd == "") {
50
+ w = word; sub(/.*\//, "", w)
51
+ if (w !~ /^[A-Za-z_][A-Za-z0-9_]*=/ && w !~ /^(sudo|env|command|exec|time|nice|xargs)$/) cmd = w
52
+ }
53
+ else if ((cmd == "tee" || cmd == "cp") && word !~ /^-/) print word
54
+ word = ""; inword = 0; redirect = 0; tag = 0
55
+ }
56
+ END {
57
+ n = length(s); i = 1; q = ""; cmd = ""; nt = 0
58
+ while (i <= n) {
59
+ c = substr(s, i, 1)
60
+ if (q != "") {
61
+ if (c == q) q = ""
62
+ else if (q == "\"" && c == "\\") { word = word substr(s, i + 1, 1); i += 2; continue }
63
+ else word = word c
64
+ i++; continue
65
+ }
66
+ if (c == "'" || c == "\"") { q = c; inword = 1; i++; continue }
67
+ if (c == "\\") { word = word substr(s, i + 1, 1); inword = 1; i += 2; continue }
68
+ if (c == " " || c == "\t") { flush(); i++; continue }
69
+ if (c == ">") {
70
+ flush(); i++
71
+ if (substr(s, i, 1) == ">" || substr(s, i, 1) == "|") i++
72
+ if (substr(s, i, 1) == "&") { i++; continue }
73
+ redirect = 1; continue
74
+ }
75
+ if (c == "<" && substr(s, i, 2) == "<<" && substr(s, i, 3) != "<<<") {
76
+ flush(); i += 2; dash = 0
77
+ if (substr(s, i, 1) == "-") { dash = 1; i++ }
78
+ tag = 1; continue
79
+ }
80
+ if (c ~ /[;|&()<`]/) { flush(); if (c != "<") cmd = ""; i++; continue }
81
+ if (c == "\n") {
82
+ flush(); cmd = ""; i++
83
+ for (t = 1; t <= nt; t++) {
84
+ while (i <= n) {
85
+ e = index(substr(s, i), "\n")
86
+ if (e == 0) { line = substr(s, i); i = n + 1 } else { line = substr(s, i, e - 1); i += e }
87
+ if (dashes[t]) sub(/^\t+/, "", line)
88
+ if (line == tags[t]) break
89
+ }
90
+ }
91
+ nt = 0; continue
92
+ }
93
+ word = word c; inword = 1; i++
94
+ }
95
+ flush()
96
+ }
97
+ AWK
98
+ targets=$(printf '%s' "$target" | awk "$scanner")
99
+ else
100
+ targets=$target
101
+ fi
102
+ [ -n "$targets" ] || exit 0
103
+
104
+ payload_cwd=$(printf '%s' "$input" | jq -r '.cwd // empty')
105
+ cwd=${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}
106
+ # Plans live at the main worktree root, which a linked worktree's cwd is not.
107
+ root=$(git -C "$cwd" worktree list --porcelain 2>/dev/null | awk '/^worktree /{sub(/^worktree /, ""); print; exit}')
108
+ root=${root:-$cwd}
109
+
110
+ # A write onto an existing plan is an amendment, such as a worker rewriting a
111
+ # `- Suggested:` line, and an archive path holds a shipped plan rather than a
112
+ # new one, so only a path naming no file yet asks for the skill.
113
+ new=""
114
+ while IFS= read -r path; do
115
+ case "$path" in
116
+ *.canon/plans/*) ;;
117
+ *) continue ;;
118
+ esac
119
+ rest=${path##*.canon/plans/}
120
+ case "$rest" in
121
+ '' | archive/* | *'$'* | *'*'*) continue ;;
122
+ *.md) ;;
123
+ *) continue ;;
124
+ esac
125
+ [ -e "$root/.canon/plans/$rest" ] && continue
126
+ case "$path" in
127
+ /*) [ -e "$path" ] && continue ;;
128
+ esac
129
+ new=".canon/plans/$rest"
130
+ break
131
+ done <<EOF
132
+ $targets
133
+ EOF
134
+ [ -n "$new" ] || exit 0
135
+
136
+ # Without a session id or a readable transcript there is nothing to read the
137
+ # invocation from, and blocking on that would stop every plan, so pass.
138
+ meta=$(printf '%s' "$input" | jq -r '[.session_id // "", .transcript_path // ""] | @tsv')
139
+ session=${meta%%$'\t'*}
140
+ transcript=${meta#*$'\t'}
141
+ [ -n "$session" ] && [ -n "$transcript" ] && [ -r "$transcript" ] || exit 0
142
+
143
+ # An invocation is a Skill tool call naming the skill, or the operator typing
144
+ # its slash command, whose user line carries the command-name tag. Each one
145
+ # prints as `P <line>`, the line ordering it against the invocation the last
146
+ # new plan spent. An `auto-ship` invocation prints as `A <its arguments>`,
147
+ # since a cloud worker writes the approved plan that launch names verbatim and
148
+ # never plans it, so a write onto the path those arguments name passes.
149
+ invocations=$(grep -n -e 'plan-feature' -e 'auto-ship' "$transcript" 2>/dev/null | jq -Rr '
150
+ def text: if type == "string" then . else (map(select(.type == "text") | .text) | join("\n")) end;
151
+ def skill($name): .type == "tool_use" and .name == "Skill"
152
+ and ((.input.skill // "") | test("^(canon:)?" + $name + "$"));
153
+ def typed($name): test("<command-name>/?(canon:)?" + $name + "</command-name>");
154
+ capture("^(?<n>[0-9]+):(?<j>.*)$") as $l
155
+ | ($l.j | fromjson? // empty) as $o
156
+ | if $o.type == "assistant" then
157
+ ($o.message.content[]? | select(type == "object")
158
+ | if skill("plan-feature") then "P \($l.n)"
159
+ elif skill("auto-ship") then "A \(.input.args // "" | tostring)"
160
+ else empty end)
161
+ elif $o.type == "user" then
162
+ (($o.message.content // "") | text)
163
+ | if typed("plan-feature") then "P \($l.n)"
164
+ elif typed("auto-ship") then "A \(gsub("\n"; " "))"
165
+ else empty end
166
+ else empty end' 2>/dev/null)
167
+
168
+ rest=${new#.canon/plans/}
169
+ key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9' '_')
170
+ marker_dir="$root/.canon/tmp/hooks/plan-guard"
171
+
172
+ # One line per new plan judged, so a wrong block in any project shows up beside
173
+ # the passes and the unread ones. A failed write never changes the decision.
174
+ log_decision() {
175
+ # The group takes the redirect's own error, which a `2>/dev/null` after the
176
+ # `>>` would miss because the shell opens the file first.
177
+ mkdir -p "$marker_dir" 2>/dev/null && {
178
+ printf '%s %s %s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$key" "$rest" \
179
+ >>"$marker_dir/decisions.log"
180
+ } 2>/dev/null
181
+ return 0
182
+ }
183
+
184
+ while IFS= read -r line; do
185
+ case "$line" in
186
+ "A "*".canon/plans/$rest"*)
187
+ log_decision pass
188
+ exit 0
189
+ ;;
190
+ esac
191
+ done <<EOF
192
+ $invocations
193
+ EOF
194
+ latest=$(printf '%s\n' "$invocations" | awk '/^P /{n = $2} END {if (n != "") print n}')
195
+
196
+ # The marker holds the line of the spent invocation and the plan it licensed.
197
+ # A PreToolUse pass is not a landed write, so a retry after a refused heredoc,
198
+ # or the `cp` that follows a worktree `Write`, lands on the same plan and
199
+ # passes on the invocation it already spent.
200
+ marker="$marker_dir/$key"
201
+ spent=0
202
+ spent_plan=""
203
+ if [ -f "$marker" ]; then
204
+ IFS=' ' read -r spent spent_plan <"$marker" 2>/dev/null
205
+ fi
206
+ case "$spent" in '' | *[!0-9]*) spent=0 ;; esac
207
+
208
+ if [ "$spent" -gt 0 ] && [ "$spent_plan" = "$rest" ]; then
209
+ log_decision pass
210
+ exit 0
211
+ fi
212
+ if [ -n "$latest" ] && [ "$latest" -gt "$spent" ]; then
213
+ mkdir -p "$marker_dir" 2>/dev/null && printf '%s %s\n' "$latest" "$rest" >"$marker" 2>/dev/null
214
+ log_decision pass
215
+ exit 0
216
+ fi
217
+
218
+ # A transcript holding no assistant `tool_use` line means Claude Code changed
219
+ # its message format rather than that the session skipped the skill, and
220
+ # blocking on that would stop every plan, so pass and log it apart. A change
221
+ # confined to the Skill call's input or the typed command tag leaves other
222
+ # tool calls readable, so this does not catch it and the guard still blocks.
223
+ calls=$(grep -F '"tool_use"' "$transcript" 2>/dev/null | jq -Rrn '
224
+ first(inputs | fromjson? | select(type == "object" and .type == "assistant")
225
+ | .message.content[]? | select(type == "object" and .type == "tool_use"
226
+ and (.name | type) == "string") | "read") // empty' 2>/dev/null)
227
+ if [ -z "$calls" ]; then
228
+ log_decision unread
229
+ exit 0
230
+ fi
231
+
232
+ if [ "$spent" -gt 0 ]; then
233
+ why='The last canon:plan-feature invocation in this session was spent on an earlier new plan'
234
+ else
235
+ why='This session has not invoked canon:plan-feature'
236
+ fi
237
+ log_decision block
238
+ printf '%s, so %s would be written outside the skill that owns its shape. Invoke the canon:plan-feature skill for this plan, then resend the write.\n' "$why" "$new" >&2
239
+ exit 2
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "autoMemoryEnabled": false,
3
3
  "permissions": {
4
+ "allow": [
5
+ "Bash(git push --force-with-lease)",
6
+ "Bash(git push --force-with-lease origin HEAD)"
7
+ ],
4
8
  "deny": ["Agent", "Task", "Bash(canon pr merge:*)", "Bash(gh pr merge:*)"]
5
9
  },
6
10
  "hooks": {
@@ -29,6 +33,10 @@
29
33
  {
30
34
  "type": "command",
31
35
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scratch-guard.sh"
36
+ },
37
+ {
38
+ "type": "command",
39
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-guard.sh"
32
40
  }
33
41
  ]
34
42
  },
@@ -38,6 +46,10 @@
38
46
  {
39
47
  "type": "command",
40
48
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/review-post-scan.sh"
49
+ },
50
+ {
51
+ "type": "command",
52
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-guard.sh"
41
53
  }
42
54
  ]
43
55
  }
@@ -1,113 +0,0 @@
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/frames`
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 { buildStackSummaries } from '../../src/tooling/list'
54
-
55
- const root = new URL('../..', import.meta.url).pathname
56
- const assetDir = join(root, 'assets', 'captures')
57
-
58
- const [flag, ...extra] = process.argv.slice(2)
59
- if (flag !== undefined && flag !== '--check') {
60
- console.error(`regen-hero: unknown argument ${flag}`)
61
- process.exit(1)
62
- }
63
- if (extra.length > 0) {
64
- console.error(`regen-hero: unknown argument ${extra[0]}`)
65
- process.exit(1)
66
- }
67
-
68
- const outDir =
69
- flag === '--check' ? mkdtempSync(join(tmpdir(), 'regen-hero-')) : assetDir
70
-
71
- function run(): void {
72
- const templates = readdirSync(assetDir)
73
- .filter((name) => name.endsWith('.html.tmpl'))
74
- .sort()
75
- if (templates.length === 0) {
76
- throw new FrameError(`no templates under ${assetDir}`)
77
- }
78
-
79
- const tokenCss = buildDesignCss(undefined, { components: false })
80
- if (!tokenCss.trim()) {
81
- throw new FrameError(
82
- 'the design source emitted nothing, refusing to write an unstyled frame',
83
- )
84
- }
85
-
86
- const { values, frames } = buildFrameValues({
87
- skills: listSkills(root).map((entry) => entry.name),
88
- gov: buildGovCatalog(root),
89
- toolingStacks: buildStackSummaries(root),
90
- commandCount: countCommands(root) ?? 0,
91
- tokenCss,
92
- markSvg: readFileSync(join(root, 'assets', 'brand', 'mark.svg'), 'utf8'),
93
- })
94
-
95
- mkdirSync(outDir, { recursive: true })
96
- for (const { template, out, tokens } of frames(templates)) {
97
- const source = readFileSync(join(assetDir, template), 'utf8')
98
- writeFileSync(
99
- join(outDir, out),
100
- fillTemplate(template, source, values, tokens),
101
- )
102
- }
103
- }
104
-
105
- try {
106
- run()
107
- } catch (error) {
108
- if (!(error instanceof FrameError)) throw error
109
- console.error(`regen-hero: ${error.message}`)
110
- process.exitCode = 1
111
- } finally {
112
- if (outDir !== assetDir) rmSync(outDir, { recursive: true, force: true })
113
- }