@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +51 -0
- package/claude/skills/api-design/SKILL.md +102 -0
- package/claude/skills/api-design/references/adopted.md +40 -0
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/codebase-layout/SKILL.md +2 -1
- package/claude/skills/review-ui/REQUIREMENT.md +3 -0
- package/claude/skills/review-ui/SKILL.md +15 -1
- package/claude/skills/review-ui/references/driving.md +3 -3
- package/claude/skills/review-ui/references/post.md +16 -3
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
- package/claude/skills/systematic-debugging/SKILL.md +45 -17
- package/claude/skills/systematic-debugging/references/adopted.md +44 -0
- package/docs/agents/capture.md +2 -2
- package/docs/agents/commands.md +1 -1
- package/docs/agents/index.md +2 -2
- package/docs/agents/pr-evidence.md +48 -1
- package/docs/agents/scripting.md +0 -3
- package/docs/agents/tasks-plans.md +8 -5
- package/docs/agents/worktrees.md +16 -1
- package/docs/workflow/skill-map.md +2 -1
- package/package.json +1 -1
- package/scripts/core/regen-agent-fixture.sh +3 -3
- package/scripts/core/regen-hero.ts +119 -0
- package/scripts/core/regen-web-favicon.ts +1 -1
- package/src/capture/frame-values.ts +359 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +2 -0
- package/src/commands/gate.ts +3 -2
- package/src/commands/pr.ts +78 -6
- package/src/commands/tasks.ts +12 -2
- package/src/commands/tooling.ts +2 -2
- package/src/commands/worktrees.ts +37 -0
- package/src/counts/catalogs.ts +3 -3
- package/src/design/tokens.ts +1 -1
- package/src/gate/sequencer.ts +0 -20
- package/src/gate/stages.ts +1 -2
- package/src/help.ts +0 -1
- package/src/pr/evidence.ts +35 -0
- package/src/tasks/archive.ts +27 -0
- package/src/tasks/record.ts +25 -2
- package/src/worktrees/bare-flag.ts +83 -0
- package/standards/tasks.md +2 -2
- package/scripts/core/regen-hero.sh +0 -419
- package/scripts/core/repair-bare-flag.sh +0 -19
- package/scripts/lib/worktree.sh +0 -20
- 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
|
package/docs/agents/scripting.md
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
|
-
| `--
|
|
141
|
-
| `--
|
|
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`.
|
package/docs/agents/worktrees.md
CHANGED
|
@@ -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
|
@@ -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
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
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.
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|