@erclx/canon 5.9.0 → 5.11.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 (48) 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/role-orchestrator/SKILL.md +2 -2
  12. package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
  13. package/claude/skills/systematic-debugging/SKILL.md +45 -17
  14. package/claude/skills/systematic-debugging/references/adopted.md +44 -0
  15. package/docs/agents/capture.md +2 -2
  16. package/docs/agents/commands.md +1 -1
  17. package/docs/agents/index.md +2 -2
  18. package/docs/agents/pr-evidence.md +48 -1
  19. package/docs/agents/scripting.md +0 -3
  20. package/docs/agents/tasks-plans.md +8 -5
  21. package/docs/agents/worktrees.md +16 -1
  22. package/docs/workflow/skill-map.md +2 -1
  23. package/package.json +1 -1
  24. package/scripts/core/regen-agent-fixture.sh +3 -3
  25. package/scripts/core/regen-hero.ts +119 -0
  26. package/scripts/core/regen-web-favicon.ts +1 -1
  27. package/src/capture/frame-values.ts +359 -0
  28. package/src/claude/cases/misc.ts +5 -0
  29. package/src/claude/skills-audit.ts +2 -0
  30. package/src/commands/gate.ts +3 -2
  31. package/src/commands/pr.ts +78 -6
  32. package/src/commands/tasks.ts +12 -2
  33. package/src/commands/tooling.ts +2 -2
  34. package/src/commands/worktrees.ts +37 -0
  35. package/src/counts/catalogs.ts +3 -3
  36. package/src/design/tokens.ts +1 -1
  37. package/src/gate/sequencer.ts +0 -20
  38. package/src/gate/stages.ts +1 -2
  39. package/src/help.ts +0 -1
  40. package/src/pr/evidence.ts +35 -0
  41. package/src/tasks/archive.ts +27 -0
  42. package/src/tasks/record.ts +25 -2
  43. package/src/worktrees/bare-flag.ts +83 -0
  44. package/standards/tasks.md +2 -2
  45. package/scripts/core/regen-hero.sh +0 -419
  46. package/scripts/core/repair-bare-flag.sh +0 -19
  47. package/scripts/lib/worktree.sh +0 -20
  48. package/scripts/tooling/create.sh +0 -109
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: The pull request evidence comparison
3
- description: What canon pr evidence compares, the marker that lets it edit its own comment rather than duplicate it, the refusal reasons it names, and why the comparison anchors at the merge base rather than the previous push
3
+ description: What canon pr evidence compares, the marker that lets it edit its own comment rather than duplicate it, the refusal reasons it names, what the --check read reports as owed, and why the comparison anchors at the merge base rather than the previous push
4
4
  ---
5
5
 
6
6
  # The pull request evidence comparison
@@ -58,6 +58,7 @@ image or marks the case new.
58
58
  | `no-base` | GitHub reported no merge base for the pull request. |
59
59
  | `unreadable-changes` | GitHub could not list what the pull request changed. |
60
60
  | `would-empty` | The render holds no cases and the marked comment holds some. No body. |
61
+ | `check-writes` | `--check` was passed with `--preview`, `--local`, or `--checklist`. |
61
62
 
62
63
  `no-evidence` is not a refusal a caller reports. A project on a stack that
63
64
  carries no evidence path, such as `base` or `python`, hits this reason on
@@ -179,6 +180,52 @@ the checklist land in one comment rather than the checklist going out alone.
179
180
  `canon docs pr-local` covers where the address comes from and how the line is
180
181
  removed at close.
181
182
 
183
+ ## What a pull request is still owed
184
+
185
+ `--check` reads the same changed set and the same thread, renders no body, and
186
+ reports which steps of the ship chain that run after the pull request opens
187
+ never landed on it. It reads the pull request rather than any session's account
188
+ of what it ran, so a session that stopped after opening the pull request leaves
189
+ a branch the check can tell apart from one that needed neither step.
190
+
191
+ ```bash
192
+ canon pr evidence 1341 --check --json
193
+ ```
194
+
195
+ | Reason | What it means |
196
+ | --------- | ------------------------------------------------------------------ |
197
+ | `settled` | Nothing is owed. `owed` is empty. |
198
+ | `owed` | `owed` lists `evidence`, `preview`, or both, always in that order. |
199
+
200
+ - `evidence` is owed when the changed set carries an evidence image and no
201
+ comment carries the marker.
202
+ - `preview` is owed when the pull request has a marked comment or an evidence
203
+ change, a deploy workflow resolves in the checkout the verb runs from, and
204
+ the marked comment's first line is no `**Preview:**` line. A
205
+ `**Local preview:**` line does not satisfy it. A project deploying nothing is
206
+ never owed a preview.
207
+
208
+ The record carries `owed`, `commentId` when a marked comment exists, and the
209
+ same `preview`, `local`, and `checklist` fields the other reasons carry, so a
210
+ caller reads one shape. It never carries `body`, so a writer branching on `ok`
211
+ and posting `body` cannot act on a check record. Passing `--preview`, `--local`,
212
+ or `--checklist` with it refuses as `check-writes` before any `gh` call, since
213
+ those ask a read to write.
214
+
215
+ The thread cannot separate a skipped preview step from a failed one. A pull
216
+ request whose deploy run failed or timed out reads as owing a preview, and the
217
+ session that ran it holds the cause. A checklist posted on its own carries no
218
+ marker, so a branch with a checklist and no evidence image never reads as
219
+ owing a preview, though `git-pr` runs the preview step for it. The deploy
220
+ workflow is read from the checkout rather than the pull request's head, so a
221
+ pull request that adds or removes its own deploy workflow reads against trunk's.
222
+
223
+ An unreadable thread refuses as `gh-failed` rather than reporting `settled` off
224
+ a thread it never read. The check skips the merge base read, which only the
225
+ render needs, so it never refuses `no-base`. The orchestrator's draft lift is
226
+ the caller, and lifts only on `settled`, so a refusal holds it the same as
227
+ `owed` does.
228
+
182
229
  ## What a collapsed comment still leaves to GitHub
183
230
 
184
231
  GitHub already draws its own before-and-after comparison on the Files Changed
@@ -79,9 +79,6 @@ surface is in `skills-audit.md`.
79
79
  ## Non-interactive examples
80
80
 
81
81
  ```bash
82
- # Create a new tooling stack
83
- CANON_NON_INTERACTIVE=1 canon tooling create astro
84
-
85
82
  # Report what a stack would change, writing nothing
86
83
  CANON_NON_INTERACTIVE=1 canon tooling diff astro /path/to/project
87
84
 
@@ -135,15 +135,18 @@ canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate # canon-allo
135
135
  canon tasks plan-link v28.1-trigger-escalation .canon/plans/feature-dispatch-answer-gate.md --json
136
136
  ```
137
137
 
138
- | Option | Behavior |
139
- | --------------- | ------------------------------------------- |
140
- | `--json` | Emit a machine-readable record on stdout |
141
- | `--root <path>` | Board root, defaulting to the main worktree |
138
+ | Option | Behavior |
139
+ | --------------- | --------------------------------------------------------- |
140
+ | `--add` | Append the plan to the line instead of replacing the link |
141
+ | `--json` | Emit a machine-readable record on stdout |
142
+ | `--root <path>` | Board root, defaulting to the main worktree |
142
143
 
143
144
  The plan resolves the same way `canon tasks plan-answers` resolves one, against the project root first and `.canon/tasks/` second, so a bare slug and a board-relative path both work. A reference resolving to no file refuses as `no-plan`, naming every base it looked under.
144
145
 
145
- The write adds the line when it is absent and corrects the target in place when it exists, anchored on the H1 rather than on the last origin line, since `Plan:` is the first origin line a task carries rather than the last. A `Plan:` line inside a fenced sample is skipped. The `action` field reports `added`, `corrected`, or `unchanged`, which makes a rerun against the same plan safe, and `replaced` names the target the line carried before when it differed.
146
+ The write adds the line when it is absent and corrects the target in place when it exists, anchored on the H1 rather than on the last origin line, since `Plan:` is the first origin line a task carries rather than the last. A `Plan:` line inside a fenced sample is skipped. The `action` field reports `added`, `appended`, `corrected`, or `unchanged`, which makes a rerun against the same plan safe, and `replaced` names the target the line carried before when it differed.
146
147
 
147
148
  A line already linking several plans is refused as `several-plans` and left unchanged, with `detail` listing each link, even when one of them is the plan named. The verb cannot tell which link to drop, and reporting `unchanged` would hide a line that breaks the one-plan rule.
148
149
 
150
+ A sliced task ships one plan per slice, so `--add` appends the plan to the line rather than replacing the link. It keeps every link in its order, writes a bare-path target as a link so it stays readable, leaves trailing prose and the line ending where they were, adds the line when the task carries none, reports `unchanged` and writes nothing when the plan is already listed, and is allowed on a line already holding several. Without the flag the replace behavior and the `several-plans` refusal above hold.
151
+
149
152
  Exit codes: `0` recorded, `1` refused. The `reason` field carries `no-board`, `no-match`, `no-plan`, `several-plans`, or `bad-input`.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Worktrees
3
- description: Reporting which worktrees are reclaimable, removing the ones that are, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
3
+ description: Reporting which worktrees are reclaimable, removing the ones that are, the hidden verb that clears a stranded bare flag, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
4
4
  ---
5
5
 
6
6
  # Worktrees
@@ -24,6 +24,21 @@ Exit codes: `0` every worktree was read, `1` refused. The refusal carries a `rea
24
24
 
25
25
  An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `reason` rather than the exit when a skill consumes this.
26
26
 
27
+ ## Repair the bare flag
28
+
29
+ `canon worktrees repair-bare-flag` clears a `core.bare` flag that Claude Code's worktree entry left set in the shared config. It is hidden, so no help listing shows it, because its one caller is a hook rather than a person.
30
+
31
+ ```bash
32
+ canon worktrees repair-bare-flag --root <path> --json
33
+ ```
34
+
35
+ | Option | Behavior |
36
+ | --------------- | ------------------------------------- |
37
+ | `--root <path>` | Repository to repair, default the cwd |
38
+ | `--json` | Print the record on stdout |
39
+
40
+ `--json` writes `{repaired, reason, message}` on stdout, once, whether or not anything was written. `reason` is `flag-unset`, `common-dir-unreadable`, `genuinely-bare`, or `write-failed` when `repaired` is false. `message` carries text when the flag is repaired and when the write failed, and is null on the three quiet skips. A `write-failed` record means the flag is still set, which a lock file or a read-only config causes. A repository whose common dir is not named `.git` keeps its flag. The exit code is always `0`, so a hook never fails a tool call. Without `--json`, a record carrying a message prints it on stderr and any other prints nothing.
41
+
27
42
  ## Reclaim
28
43
 
29
44
  `canon worktrees reclaim` removes every worktree the reading above called reclaimable, and the branch behind each one.
@@ -8,7 +8,7 @@ category: Workflow
8
8
 
9
9
  Groups run in the order a project meets them, so a reader at a known point scans to that group and reads across. The set reconciles the scenarios in [AI workflow](ai-workflow.md) with the lifecycle [target projects](../target/projects.md) describes, rather than inventing a third vocabulary beside those two, so a group name matches neither source exactly and every moment either one names has a group. Each row says when to reach for the skill. What it does is the skill's own description.
10
10
 
11
- This page is the corpus the coverage claim is measured against: every name `canon claude skills list --names` reports takes exactly one row here. A skill serving two moments sits at the earlier one, and a mention on any other page is prose rather than routing.
11
+ This page is the corpus the coverage claim is measured against: every name `canon claude skills list --names` reports takes exactly one row here. A skill serving two moments sits at the earlier one, and a mention on any other page is prose rather than routing. The landing page's skills field renders from this page, so its build refuses a skill with no row and a row naming no skill.
12
12
 
13
13
  ## Set up a project
14
14
 
@@ -45,6 +45,7 @@ This page is the corpus the coverage claim is measured against: every name `cano
45
45
  | `canon:project-commands` | When the project's own command needs running |
46
46
  | `canon:test-first` | Before implementing a planned change, to run its test red, green, then refactor |
47
47
  | `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
48
+ | `canon:api-design` | When changing what a caller sees, being an export, a flag, an output shape, or a retried write |
48
49
  | `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
49
50
  | `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
50
51
  | `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.9.0",
4
+ "version": "5.11.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -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
+ }