@erclx/canon 5.8.0 → 5.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +51 -0
  3. package/claude/skills/api-design/SKILL.md +102 -0
  4. package/claude/skills/api-design/references/adopted.md +40 -0
  5. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  6. package/claude/skills/codebase-layout/SKILL.md +2 -1
  7. package/claude/skills/git-followup/SKILL.md +1 -1
  8. package/claude/skills/git-pr/references/evidence.md +1 -1
  9. package/claude/skills/review-craft/REQUIREMENT.md +8 -2
  10. package/claude/skills/review-craft/SKILL.md +63 -7
  11. package/claude/skills/review-craft/references/adopted.md +49 -0
  12. package/claude/skills/review-ui/REQUIREMENT.md +3 -0
  13. package/claude/skills/review-ui/SKILL.md +15 -1
  14. package/claude/skills/review-ui/references/driving.md +3 -3
  15. package/claude/skills/review-ui/references/post.md +16 -3
  16. package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
  17. package/claude/skills/systematic-debugging/SKILL.md +45 -17
  18. package/claude/skills/systematic-debugging/references/adopted.md +44 -0
  19. package/docs/agents/capture.md +2 -2
  20. package/docs/agents/commands.md +3 -1
  21. package/docs/agents/hooks.md +56 -0
  22. package/docs/agents/index.md +1 -0
  23. package/docs/agents/install-and-sync.md +1 -1
  24. package/docs/agents/markdown-audit.md +1 -1
  25. package/docs/agents/pr-evidence.md +33 -17
  26. package/docs/agents/records-push.md +1 -1
  27. package/docs/agents/records-validate.md +1 -1
  28. package/docs/agents/scripting.md +6 -3
  29. package/docs/agents/tasks-archive.md +4 -2
  30. package/docs/agents/tasks-plans.md +8 -5
  31. package/docs/agents/tasks.md +1 -1
  32. package/docs/agents/worktrees.md +2 -2
  33. package/docs/target/scenarios.md +2 -2
  34. package/docs/workflow/skill-map.md +1 -0
  35. package/package.json +1 -1
  36. package/scripts/core/regen-agent-fixture.sh +3 -3
  37. package/scripts/core/regen-hero.ts +119 -0
  38. package/scripts/core/regen-web-favicon.ts +1 -1
  39. package/src/capture/frame-values.ts +359 -0
  40. package/src/claude/cases/misc.ts +5 -0
  41. package/src/claude/skills-audit.ts +3 -0
  42. package/src/cli.ts +2 -0
  43. package/src/commands/claude.ts +1 -1
  44. package/src/commands/docs.ts +51 -11
  45. package/src/commands/hooks.ts +67 -0
  46. package/src/commands/pr.ts +146 -76
  47. package/src/commands/standards.ts +33 -2
  48. package/src/commands/tasks.ts +57 -2
  49. package/src/commands/tooling.ts +5 -5
  50. package/src/commands/upgrade.ts +3 -2
  51. package/src/commands/worktrees.ts +4 -3
  52. package/src/counts/catalogs.ts +3 -3
  53. package/src/design/tokens.ts +1 -1
  54. package/src/docs/list.ts +103 -0
  55. package/src/docs/read.ts +63 -19
  56. package/src/gate/stages.ts +1 -1
  57. package/src/gov/stacks.ts +1 -1
  58. package/src/help.ts +2 -1
  59. package/src/hooks/post-merge.ts +331 -0
  60. package/src/pr/evidence.ts +21 -0
  61. package/src/records/validate.ts +1 -38
  62. package/src/standards/list.ts +33 -0
  63. package/src/standards/read.ts +13 -0
  64. package/src/standards/scope.ts +56 -0
  65. package/src/sync/check.ts +38 -4
  66. package/src/tasks/archive.ts +195 -3
  67. package/src/tasks/record.ts +25 -2
  68. package/src/tooling/inject.ts +4 -3
  69. package/src/tooling/manifest.ts +62 -8
  70. package/src/tooling/scan.ts +4 -3
  71. package/src/tooling/stamp.ts +11 -2
  72. package/src/tooling/subfolder.ts +7 -3
  73. package/standards/tasks.md +2 -2
  74. package/tooling/astro/reference.md +1 -1
  75. package/tooling/base/configs/scripts/verify.json +3 -0
  76. package/tooling/base/configs/scripts/verify.sh +1 -110
  77. package/tooling/base/configs/scripts/verify.ts +378 -0
  78. package/tooling/base/manifest.toml +1 -0
  79. package/tooling/base/reference.md +5 -3
  80. package/tooling/go/configs/scripts/verify.json +10 -0
  81. package/tooling/go/reference.md +5 -4
  82. package/tooling/php/configs/scripts/verify.json +7 -0
  83. package/tooling/php/reference.md +5 -4
  84. package/tooling/python/configs/scripts/verify.json +4 -0
  85. package/tooling/python/reference.md +3 -3
  86. package/tooling/web/configs/scripts/verify.json +3 -0
  87. package/tooling/web/reference.md +3 -3
  88. package/scripts/core/regen-hero.sh +0 -419
  89. package/scripts/docs/list.sh +0 -184
  90. package/scripts/lib/frontmatter.sh +0 -30
  91. package/scripts/standards/list.sh +0 -115
  92. package/scripts/tooling/create.sh +0 -109
  93. package/src/commands/pass-through.ts +0 -39
  94. package/tooling/go/configs/scripts/verify.sh +0 -142
  95. package/tooling/php/configs/scripts/verify.sh +0 -147
  96. package/tooling/python/configs/scripts/verify.sh +0 -141
  97. package/tooling/web/configs/scripts/verify.sh +0 -153
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Overview
6
6
 
7
- The php stack covers Composer projects on PHP 8.3 or later, formatted by PHP CS Fixer, analysed by PHPStan, and tested by PHPUnit. It ships the three tools' configs, a PHP-aware `verify.sh`, and a copy-once smoke test.
7
+ The php stack covers Composer projects on PHP 8.3 or later, formatted by PHP CS Fixer, analysed by PHPStan, and tested by PHPUnit. It ships the three tools' configs, a php phase list for base's verify runner, and a copy-once smoke test.
8
8
 
9
9
  ## Scaffold checklist
10
10
 
@@ -39,10 +39,11 @@ The php stack also ships `tests/SmokeTest.php` as a copy-once seed, so `bun run
39
39
  - Set `colors`, `failOnWarning`, and `failOnRisky`.
40
40
  - Run one `unit` suite over `tests`, with `src` as the source.
41
41
 
42
- ## verify.sh
42
+ ## verify.json
43
43
 
44
- - Stop and name `composer install` when `vendor/bin/phpunit` is missing.
45
- - Run Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`), plus the four base phases wherever the folder declares their scripts, then the markdown bans stage base ships.
44
+ - Probe `php` and `composer` first.
45
+ - Hold a precondition on `vendor/bin/phpunit`, so the run stops and names `composer install` when it is missing.
46
+ - Require Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`). Base's runner adds the four base phases wherever the folder declares their scripts, then the markdown bans stage.
46
47
 
47
48
  ## Gitignore
48
49
 
@@ -0,0 +1,4 @@
1
+ {
2
+ "require": ["typecheck", "lint", "test:run"],
3
+ "tools": [{ "name": "uv" }]
4
+ }
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Overview
6
6
 
7
- The python stack covers Python 3.13+ projects managed with `uv`. It ships golden sidecar configs for `ruff`, `mypy`, `pytest`, and `coverage`, plus a `.python-version` pin and a python-aware `verify.sh`. Framework adapters layer on top with their own deps and configs.
7
+ The python stack covers Python 3.13+ projects managed with `uv`. It ships golden sidecar configs for `ruff`, `mypy`, `pytest`, and `coverage`, plus a `.python-version` pin and a python phase list for base's verify runner. Framework adapters layer on top with their own deps and configs.
8
8
 
9
9
  Configs ship as sidecar files (`ruff.toml`, `mypy.ini`, `pytest.ini`, `.coveragerc`) rather than `[tool.*]` sections in `pyproject.toml`. Sync overwrites configs on every run, so keeping them sidecar avoids stomping the user-owned `[project]` block in `pyproject.toml`.
10
10
 
@@ -27,7 +27,7 @@ The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest`
27
27
  - `pytest.ini`: tests live under `tests/`, source under `src/`.
28
28
  - `.coveragerc`: branch coverage on `src/`, html report under `.coverage_cache/html`.
29
29
  - `.python-version`: pinned to `3.14` to match `uv init` defaults.
30
- - `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases, then the same markdown bans phase base ships.
30
+ - `scripts/verify.json`: requires Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) on top of base's runner, and stops naming `uv` when it is not on PATH. The markdown bans phase runs as it does everywhere.
31
31
 
32
32
  ## Hybrid project shape
33
33
 
@@ -39,7 +39,7 @@ The manifest declares no `[dependencies.dev]`. Manifest injection currently call
39
39
 
40
40
  ## Verify command
41
41
 
42
- `canon tooling verify <stack>` currently runs `bun run lint:fix` and `bun run check` against any stack with a `package.json`. Since base ships a `package.json`, those run for python too. Python's verify.sh wraps the package.json `lint`/`typecheck`/`test:run` scripts that delegate to `uv run`, so the verify path works end-to-end as long as `uv` is installed in the verify environment. The end-to-end test and screenshot phases are gated on `package.json` script keys that python does not declare, so they cleanly skip.
42
+ `canon tooling verify <stack>` currently runs `bun run lint:fix` and `bun run check` against any stack with a `package.json`. Since base ships a `package.json`, those run for python too. Python's `verify.json` requires the package.json `lint`/`typecheck`/`test:run` scripts that delegate to `uv run`, so the verify path works end-to-end as long as `uv` is installed in the verify environment. The end-to-end test and screenshot phases are gated on `package.json` script keys that python does not declare, so they cleanly skip.
43
43
 
44
44
  ## Anti-patterns
45
45
 
@@ -0,0 +1,3 @@
1
+ {
2
+ "require": ["typecheck", "lint", "test:run", "build"]
3
+ }
@@ -19,7 +19,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
19
19
  - `.github/workflows/readme-screenshot.yml`: refreshes that frame on a pull request and commits it back to the branch. The path list ships as `REPLACE_WITH_PROJECT_PATHS`, and the job's first step after checkout exits 1 while it stays, since a job that never fires leaves no run and no diff to notice. The workflow's own path is in the list so the installing pull request reaches that step. When a commit-back is right and when a report is, `governance/rules/tooling/700-ci-workflow.md` states the test.
20
20
  - `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
21
21
  - `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
22
- - `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order. Its last stage runs `scripts/check-card-exclusion.sh` whenever that file is installed, which puts the social card route's build exclusion on the default path rather than behind a flag nobody passes. The stage tests for the script rather than for a framework, so this layer owns when the check runs and the stack shipping a card route owns what it reads. The astro reference carries the route itself.
22
+ - `scripts/verify.json`: the phases base's verify runner requires here, being typecheck, lint, unit tests, and build. `## Verify script` covers the order and the card exclusion stage.
23
23
  - `scripts/worktree-port.sh`: prints a base port plus this working directory's offset. Called with no argument it prints the offset alone. It refuses a folder left under the worktrees directory after its worktree was removed, rather than printing a port for it.
24
24
 
25
25
  ## What stays in per-stack adapters
@@ -128,6 +128,6 @@ The sweep under `screenshots/` is ignored again, and only a flagged case's `evid
128
128
 
129
129
  ## Verify script
130
130
 
131
- The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build, markdown bans, then the card exclusion check where one is installed. Stack adapters may override if their typecheck or build differs.
131
+ The web layer's `scripts/verify.json` requires `typecheck`, `lint`, `test:run`, and `build`, and base's `scripts/verify.ts` runs them in its fixed order: typecheck, lint, format, spelling, shell, unit tests, build, markdown bans, then the card exclusion check where one is installed. astro, vite-react, and nextjs inherit the list, since the nearest stack's copy wins. An adapter whose phases differ ships its own `verify.json` rather than a script.
132
132
 
133
- The card stage is conditional on `scripts/check-card-exclusion.sh` existing rather than on the stack, so a project with no card route runs one less stage and needs no override to skip it. A stack that ships a card route ships that script, and the check reads the build the stage before it produced.
133
+ The card stage runs `scripts/check-card-exclusion.sh` whenever that file is installed, which keeps the social card route's build exclusion on the default path rather than behind a flag. It tests for the script rather than the stack, so a project with no card route runs one less stage with no override, and the stack shipping a card route owns what the check reads, being the build the stage before it produced. The astro reference carries the route itself.
@@ -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
- '