@erclx/canon 5.74.0 → 5.75.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/CHANGELOG.md +18 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-rule/SKILL.md +5 -1
- package/claude/skills/create-rule/references/ledger.md +73 -0
- package/claude/skills/create-rule/references/rule.md +1 -1
- package/claude/skills/create-skill/references/skill-practice.md +1 -1
- package/claude/skills/pr-evidence/references/reel.md +9 -9
- package/claude/skills/proposal-critique/references/adopted.md +44 -0
- package/claude/skills/ui-film/SKILL.md +8 -7
- package/docs/agents/audits.md +24 -24
- package/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +2 -0
- package/governance/detect.toml +4 -0
- package/governance/ledger/claude/rule.md +16 -0
- package/governance/rules/claude/rule.md +13 -0
- package/package.json +1 -1
- package/scripts/ci/refresh-frames-pr.sh +11 -12
- package/src/audits/catalog.ts +7 -2
- package/src/capture/stamp.ts +3 -5
- package/src/claude/skills-audit.ts +65 -0
- package/src/cli/help.ts +1 -1
- package/src/commands/claude.ts +6 -2
- package/src/commands/gov.ts +12 -7
- package/src/commands/migrate.ts +6 -2
- package/src/commands/take.ts +36 -7
- package/src/counts/catalogs.ts +3 -5
- package/src/gate/measures.ts +1 -144
- package/src/gate/stages.ts +0 -23
- package/src/gov/audit.ts +59 -13
- package/src/markdown/scope.ts +2 -0
- package/src/migrate/session-defaults.ts +114 -4
- package/src/take/encode.ts +9 -1
- package/src/take/film.ts +17 -2
- package/src/take/fixtures/busy.html +107 -0
- package/src/take/perform.ts +99 -25
- package/src/take/screencast.ts +4 -3
- package/src/take/stepper.ts +33 -20
- package/tooling/claude/reference.md +4 -2
- package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
- package/tooling/claude/seeds/.claude/settings.json +8 -0
- package/scripts/core/regen-hero.ts +0 -113
- package/src/capture/frame-values.ts +0 -326
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
import { FAVICON_COLORS, renderFavicon } from '@/design/favicon'
|
|
2
|
-
import type { GovCatalog } from '@/gov/list'
|
|
3
|
-
import type { StackSummary } from '@/tooling/list'
|
|
4
|
-
|
|
5
|
-
export const LISTED = 10
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The first seven are the method and exist nowhere else. The last three are
|
|
9
|
-
* recognizable on sight and are what keeps a column of chosen names from
|
|
10
|
-
* reading as an all-`claude-` catalog, which is the failure the sampler was
|
|
11
|
-
* written against.
|
|
12
|
-
*/
|
|
13
|
-
export const FEATURED_SKILLS = [
|
|
14
|
-
'plan-feature',
|
|
15
|
-
'plan-groundwork',
|
|
16
|
-
'plan-intake',
|
|
17
|
-
'role-orchestrator',
|
|
18
|
-
'auto-ship',
|
|
19
|
-
'review-pr',
|
|
20
|
-
'task-board',
|
|
21
|
-
'git-ship',
|
|
22
|
-
'target-setup',
|
|
23
|
-
'systematic-debugging',
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* A terminal frame sets `white-space: pre` at a fixed window width, so an
|
|
28
|
-
* overlong cell is clipped by the window edge rather than wrapped.
|
|
29
|
-
*/
|
|
30
|
-
const GLOB_WIDTH = 44
|
|
31
|
-
|
|
32
|
-
/** A refusal the script prints as `regen-hero: <message>` and exits 1 on. */
|
|
33
|
-
export class FrameError extends Error {}
|
|
34
|
-
|
|
35
|
-
export interface FrameCatalogs {
|
|
36
|
-
readonly skills: readonly string[]
|
|
37
|
-
readonly gov: GovCatalog
|
|
38
|
-
readonly toolingStacks: readonly StackSummary[]
|
|
39
|
-
readonly commandCount: number
|
|
40
|
-
readonly tokenCss: string
|
|
41
|
-
readonly markSvg: string
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface Frame {
|
|
45
|
-
readonly template: string
|
|
46
|
-
readonly out: string
|
|
47
|
-
readonly tokens: string
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface FrameValues {
|
|
51
|
-
readonly values: Readonly<Record<string, string>>
|
|
52
|
-
readonly frames: (templates: readonly string[]) => Frame[]
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const escape = (value: string): string =>
|
|
56
|
-
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Even spacing across the sorted catalog rather than its first N, so a column
|
|
60
|
-
* whose names share a prefix does not read as a narrower catalog than the one
|
|
61
|
-
* that ships. Rules sample. Skills do not, because the even step
|
|
62
|
-
* lands on the entries closest to commodity and on none of the workflow the
|
|
63
|
-
* toolkit exists to carry.
|
|
64
|
-
*/
|
|
65
|
-
export function sample<T>(items: readonly T[], listed = LISTED): T[] {
|
|
66
|
-
if (items.length <= listed) return [...items]
|
|
67
|
-
const step = items.length / listed
|
|
68
|
-
return Array.from({ length: listed }, (_, i) => items[Math.floor(i * step)])
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* A chosen name is maintained by hand where a sample never goes stale, so a
|
|
73
|
-
* rename has to fail the run rather than quietly shrink the column. The length
|
|
74
|
-
* is asserted because the `+N more` figure counts down from `LISTED` rather
|
|
75
|
-
* than from what this returns.
|
|
76
|
-
*/
|
|
77
|
-
export function featured(
|
|
78
|
-
names: readonly string[],
|
|
79
|
-
chosen: readonly string[],
|
|
80
|
-
listed = LISTED,
|
|
81
|
-
): string[] {
|
|
82
|
-
if (chosen.length !== listed) {
|
|
83
|
-
throw new FrameError(`${chosen.length} featured skills, expected ${listed}`)
|
|
84
|
-
}
|
|
85
|
-
const catalog = new Set(names)
|
|
86
|
-
const missing = chosen.filter((name) => !catalog.has(name))
|
|
87
|
-
if (missing.length > 0) {
|
|
88
|
-
throw new FrameError(
|
|
89
|
-
`featured skills missing from the catalog: ${missing.join(', ')}`,
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
return [...chosen]
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Truncating keeps the longest row inside the capture, and the ellipsis stops a
|
|
97
|
-
* clipped value from reading as the whole value.
|
|
98
|
-
*/
|
|
99
|
-
export function clip(value: string, width: number): string {
|
|
100
|
-
return value.length <= width ? value : `${value.slice(0, width - 1)}…`
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Padding happens before escaping, since an entity is longer than the
|
|
105
|
-
* character it replaces and would push a column out of line.
|
|
106
|
-
*/
|
|
107
|
-
export function pad(value: string, width: number): string {
|
|
108
|
-
return value + ' '.repeat(Math.max(0, width - value.length))
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const markup = (names: readonly string[]): string =>
|
|
112
|
-
names
|
|
113
|
-
.map((name) => ` <div class="entry">${escape(name)}</div>`)
|
|
114
|
-
.join('\n')
|
|
115
|
-
|
|
116
|
-
const remaining = (names: readonly unknown[], listed = LISTED): string =>
|
|
117
|
-
String(Math.max(0, names.length - listed))
|
|
118
|
-
|
|
119
|
-
const plural = (count: number, noun: string): string =>
|
|
120
|
-
`${count} ${noun}${count === 1 ? '' : 's'}`
|
|
121
|
-
|
|
122
|
-
const frameRow = (cells: string): string =>
|
|
123
|
-
`<span class="frame">│</span> <span class="ok">✓</span> ${cells}`
|
|
124
|
-
|
|
125
|
-
/** Indented to the depth a rule inside the `<style>` block sits at. */
|
|
126
|
-
const indentTokens = (css: string): string =>
|
|
127
|
-
css
|
|
128
|
-
.trimEnd()
|
|
129
|
-
.split('\n')
|
|
130
|
-
.map((line) => (line === '' ? '' : ` ${line}`))
|
|
131
|
-
.join('\n')
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* The frames rendered in both themes. No README shows the install pair now,
|
|
135
|
-
* and it stays for the landing page redesign to pick from. The hero is a
|
|
136
|
-
* landing-page frame, so it stays dark.
|
|
137
|
-
*/
|
|
138
|
-
const LIGHT_TEMPLATES = new Set(['install.html.tmpl'])
|
|
139
|
-
|
|
140
|
-
function lightTokens(tokenCss: string): string {
|
|
141
|
-
const roles = [...tokenCss.matchAll(/--color-light-([a-z-]+):/g)].map(
|
|
142
|
-
(match) => match[1],
|
|
143
|
-
)
|
|
144
|
-
if (roles.length === 0) {
|
|
145
|
-
throw new FrameError(
|
|
146
|
-
'token css carries no --color-light-* roles, refusing to write a light frame',
|
|
147
|
-
)
|
|
148
|
-
}
|
|
149
|
-
return [
|
|
150
|
-
tokenCss,
|
|
151
|
-
'',
|
|
152
|
-
' :root {',
|
|
153
|
-
...roles.map(
|
|
154
|
-
(role) => ` --color-${role}: var(--color-light-${role});`,
|
|
155
|
-
),
|
|
156
|
-
' --frame-shadow: rgb(0 0 0 / 16%);',
|
|
157
|
-
' }',
|
|
158
|
-
].join('\n')
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** The favicon the web page serves, so the hero's tab matches it rather than the accent. */
|
|
162
|
-
function faviconUri(markSvg: string): string {
|
|
163
|
-
try {
|
|
164
|
-
return `data:image/svg+xml,${encodeURIComponent(renderFavicon(markSvg, FAVICON_COLORS))}`
|
|
165
|
-
} catch (error) {
|
|
166
|
-
throw new FrameError(
|
|
167
|
-
`assets/brand/mark.svg cannot be drawn as a favicon: ${error instanceof Error ? error.message : String(error)}`,
|
|
168
|
-
)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Builds the value map every template takes and the frame list a template set
|
|
174
|
-
* expands to. An empty array is well-formed, and the in-process readers return
|
|
175
|
-
* one quietly for a missing tree, so every catalog is refused when empty
|
|
176
|
-
* rather than shipping a frame claiming the domain has nothing in it.
|
|
177
|
-
*/
|
|
178
|
-
export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
|
|
179
|
-
const { gov, toolingStacks, skills } = catalogs
|
|
180
|
-
const markSvg = catalogs.markSvg.trim()
|
|
181
|
-
if (!markSvg) {
|
|
182
|
-
throw new FrameError(
|
|
183
|
-
'assets/brand/mark.svg read empty, refusing to write a hero with no mark',
|
|
184
|
-
)
|
|
185
|
-
}
|
|
186
|
-
if (!catalogs.commandCount) {
|
|
187
|
-
throw new FrameError(
|
|
188
|
-
'the commands catalog read no commands, refusing to write a zeroed hero',
|
|
189
|
-
)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const tokenCss = indentTokens(catalogs.tokenCss)
|
|
193
|
-
const rules = gov.rules.map((entry) => entry.name)
|
|
194
|
-
// A rule no stack names is opt-in behind `--add`, so featuring one advertises
|
|
195
|
-
// an entry no ordinary `canon gov install` delivers. The column samples the
|
|
196
|
-
// stack-reached subset while its count and remainder describe the whole.
|
|
197
|
-
const stacked = new Set(gov.stacks.flatMap((stack) => stack.rules))
|
|
198
|
-
const deliveredEntries = gov.rules.filter((entry) => stacked.has(entry.name))
|
|
199
|
-
const deliveredRules = deliveredEntries.map((entry) => entry.name)
|
|
200
|
-
|
|
201
|
-
for (const [label, list] of [
|
|
202
|
-
['skills', skills],
|
|
203
|
-
['rules', rules],
|
|
204
|
-
['tooling stacks', toolingStacks],
|
|
205
|
-
['gov stacks', gov.stacks],
|
|
206
|
-
['stack-delivered rules', deliveredRules],
|
|
207
|
-
] as const) {
|
|
208
|
-
if (list.length === 0) {
|
|
209
|
-
throw new FrameError(
|
|
210
|
-
`the ${label} catalog is empty, refusing to write a zeroed hero`,
|
|
211
|
-
)
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const govStackRows = gov.stacks
|
|
216
|
-
.map((stack) =>
|
|
217
|
-
frameRow(
|
|
218
|
-
`<span class="name">${escape(pad(stack.name, 17))}</span>` +
|
|
219
|
-
`<span class="muted">${escape(pad(plural(stack.rules.length, 'rule'), 11))}</span>` +
|
|
220
|
-
`<span class="muted">${escape(stack.extends ? `extends ${stack.extends}` : '')}</span>`,
|
|
221
|
-
),
|
|
222
|
-
)
|
|
223
|
-
.join('\n')
|
|
224
|
-
|
|
225
|
-
// Each rule sits beside the glob that loads it rather than its description,
|
|
226
|
-
// since the glob is the mechanism a reader cannot otherwise see. A rule
|
|
227
|
-
// carrying no glob is not unscoped, it loads every session, and saying so is
|
|
228
|
-
// the contrast that makes the column mean anything.
|
|
229
|
-
const govRuleRows = sample(deliveredEntries)
|
|
230
|
-
.map((entry) => {
|
|
231
|
-
const globs = entry.paths ?? []
|
|
232
|
-
const scope =
|
|
233
|
-
globs.length > 0 ? clip(globs.join(' '), GLOB_WIDTH) : 'every session'
|
|
234
|
-
return frameRow(
|
|
235
|
-
`<span class="name">${escape(pad(entry.name, 24))}</span>` +
|
|
236
|
-
`<span class="domain">${escape(pad(`[${entry.domain}]`, 12))}</span>` +
|
|
237
|
-
`<span class="muted">${escape(scope)}</span>`,
|
|
238
|
-
)
|
|
239
|
-
})
|
|
240
|
-
.join('\n')
|
|
241
|
-
|
|
242
|
-
// A tooling stack is counted rather than listed, because what it lays down is
|
|
243
|
-
// dev dependencies, run scripts, and ignore groups rather than named entries.
|
|
244
|
-
// The inheritance is the part worth showing, since it is why a stack carrying
|
|
245
|
-
// two of its own arrives with far more than two.
|
|
246
|
-
const toolingStackRows = toolingStacks
|
|
247
|
-
.map((stack) =>
|
|
248
|
-
frameRow(
|
|
249
|
-
`<span class="name">${escape(pad(stack.name, 14))}</span>` +
|
|
250
|
-
`<span class="muted">${escape(pad(plural(stack.devDeps, 'dep'), 10))}</span>` +
|
|
251
|
-
`<span class="muted">${escape(pad(plural(stack.scripts, 'script'), 12))}</span>` +
|
|
252
|
-
`<span class="muted">${escape(stack.extends ? `extends ${stack.extends}` : '')}</span>`,
|
|
253
|
-
),
|
|
254
|
-
)
|
|
255
|
-
.join('\n')
|
|
256
|
-
|
|
257
|
-
for (const [label, rows] of [
|
|
258
|
-
['governance stack rows', govStackRows],
|
|
259
|
-
['governance rule rows', govRuleRows],
|
|
260
|
-
['tooling stack rows', toolingStackRows],
|
|
261
|
-
] as const) {
|
|
262
|
-
if (rows === '') {
|
|
263
|
-
throw new FrameError(
|
|
264
|
-
`the ${label} rendered empty, refusing to write a blank frame`,
|
|
265
|
-
)
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const values: Record<string, string> = {
|
|
270
|
-
TOKENS: tokenCss,
|
|
271
|
-
GOV_STACK_ROWS: govStackRows,
|
|
272
|
-
GOV_RULE_ROWS: govRuleRows,
|
|
273
|
-
TOOLING_STACK_ROWS: toolingStackRows,
|
|
274
|
-
SKILL_COUNT: String(skills.length),
|
|
275
|
-
RULE_COUNT: String(rules.length),
|
|
276
|
-
GOV_STACK_COUNT: String(gov.stacks.length),
|
|
277
|
-
TOOLING_STACK_COUNT: String(toolingStacks.length),
|
|
278
|
-
COMMAND_COUNT: String(catalogs.commandCount),
|
|
279
|
-
MARK_SVG: markSvg,
|
|
280
|
-
MARK_PIP: FAVICON_COLORS.pip.dark,
|
|
281
|
-
FAVICON: faviconUri(markSvg),
|
|
282
|
-
SKILL_ENTRIES: markup(featured(skills, FEATURED_SKILLS)),
|
|
283
|
-
RULE_ENTRIES: markup(sample(deliveredRules)),
|
|
284
|
-
SKILL_MORE: remaining(skills),
|
|
285
|
-
RULE_MORE: remaining(rules),
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const light = lightTokens(tokenCss)
|
|
289
|
-
|
|
290
|
-
return {
|
|
291
|
-
values,
|
|
292
|
-
frames: (templates) =>
|
|
293
|
-
templates.flatMap((template) => {
|
|
294
|
-
const base = template.replace(/\.html\.tmpl$/, '')
|
|
295
|
-
const dark = { template, out: `${base}.html`, tokens: tokenCss }
|
|
296
|
-
return LIGHT_TEMPLATES.has(template)
|
|
297
|
-
? [dark, { template, out: `${base}-light.html`, tokens: light }]
|
|
298
|
-
: [dark]
|
|
299
|
-
}),
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Every template takes the same value map, so one carrying no count
|
|
305
|
-
* placeholder simply resolves none of them. What a template must not do is name
|
|
306
|
-
* a placeholder nothing fills.
|
|
307
|
-
*/
|
|
308
|
-
export function fillTemplate(
|
|
309
|
-
template: string,
|
|
310
|
-
source: string,
|
|
311
|
-
values: Readonly<Record<string, string>>,
|
|
312
|
-
tokens: string,
|
|
313
|
-
): string {
|
|
314
|
-
let html = source
|
|
315
|
-
for (const [key, value] of Object.entries({ ...values, TOKENS: tokens })) {
|
|
316
|
-
html = html.replaceAll(`{{${key}}}`, value)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const unresolved = html.match(/{{[A-Z_]+}}/g)
|
|
320
|
-
if (unresolved) {
|
|
321
|
-
throw new FrameError(
|
|
322
|
-
`${template} carries unresolved placeholders ${[...new Set(unresolved)].join(', ')}`,
|
|
323
|
-
)
|
|
324
|
-
}
|
|
325
|
-
return html
|
|
326
|
-
}
|