@erclx/canon 5.9.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.
- 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/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/scripting.md +0 -3
- package/docs/agents/tasks-plans.md +8 -5
- package/docs/workflow/skill-map.md +1 -0
- 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/pr.ts +1 -1
- package/src/commands/tasks.ts +12 -2
- package/src/commands/tooling.ts +2 -2
- package/src/counts/catalogs.ts +3 -3
- package/src/design/tokens.ts +1 -1
- package/src/gate/stages.ts +1 -1
- package/src/help.ts +0 -1
- package/src/tasks/archive.ts +27 -0
- package/src/tasks/record.ts +25 -2
- package/standards/tasks.md +2 -2
- package/scripts/core/regen-hero.sh +0 -419
- package/scripts/tooling/create.sh +0 -109
|
@@ -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
|
+
}
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -69,6 +69,11 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
69
69
|
"The e2e folder has specs and helpers mixed together and I'm adding two more, where should they go?",
|
|
70
70
|
expect: 'codebase-layout',
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
prompt:
|
|
74
|
+
"I'm adding a field to the JSON this command prints and renaming one flag, anything to watch?",
|
|
75
|
+
expect: 'api-design',
|
|
76
|
+
},
|
|
72
77
|
{
|
|
73
78
|
prompt: 'Does our github about text still match what the readme says?',
|
|
74
79
|
expect: 'repo-metadata',
|
|
@@ -35,6 +35,8 @@ export const PRACTICE_SKILLS: readonly string[] = [
|
|
|
35
35
|
join(SHIPPED_CORPUS, 'codebase-layout'),
|
|
36
36
|
join(SHIPPED_CORPUS, 'test-craft'),
|
|
37
37
|
join(SHIPPED_CORPUS, 'review-craft'),
|
|
38
|
+
join(SHIPPED_CORPUS, 'systematic-debugging'),
|
|
39
|
+
join(SHIPPED_CORPUS, 'api-design'),
|
|
38
40
|
]
|
|
39
41
|
|
|
40
42
|
/** The closing H2s a practice skill carries, matched exactly outside fences. */
|
package/src/commands/pr.ts
CHANGED
|
@@ -1368,7 +1368,7 @@ async function listPullFiles(
|
|
|
1368
1368
|
'--paginate',
|
|
1369
1369
|
`repos/{owner}/{repo}/pulls/${number}/files`,
|
|
1370
1370
|
'--jq',
|
|
1371
|
-
'.[] |
|
|
1371
|
+
'.[] | [.status, .filename] | @tsv',
|
|
1372
1372
|
])
|
|
1373
1373
|
if (stdout === null) return undefined
|
|
1374
1374
|
const files = new Map<string, boolean>()
|
package/src/commands/tasks.ts
CHANGED
|
@@ -114,6 +114,7 @@ interface PullRequestCommandOptions {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
interface PlanLinkCommandOptions {
|
|
117
|
+
readonly add?: boolean
|
|
117
118
|
readonly json?: boolean
|
|
118
119
|
readonly root?: string
|
|
119
120
|
}
|
|
@@ -454,6 +455,10 @@ export function register(program: Command): void {
|
|
|
454
455
|
.argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
|
|
455
456
|
.argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
|
|
456
457
|
.helpOption('-h, --help', 'Show this help message')
|
|
458
|
+
.option(
|
|
459
|
+
'--add',
|
|
460
|
+
'Append the plan to the line, for a task sliced over plans',
|
|
461
|
+
)
|
|
457
462
|
.option('--json', 'Emit a machine-readable record on stdout')
|
|
458
463
|
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
459
464
|
.addHelpText(
|
|
@@ -461,7 +466,7 @@ export function register(program: Command): void {
|
|
|
461
466
|
[
|
|
462
467
|
'',
|
|
463
468
|
'Exit codes:',
|
|
464
|
-
' 0 the line was added, corrected, or already correct',
|
|
469
|
+
' 0 the line was added, appended to, corrected, or already correct',
|
|
465
470
|
' 1 refused, including several-plans for a line linking more than one',
|
|
466
471
|
' plan, with the reason on stderr or in the JSON record',
|
|
467
472
|
'',
|
|
@@ -471,8 +476,13 @@ export function register(program: Command): void {
|
|
|
471
476
|
'tasks plan-answers does. Safe from a linked worktree, since it',
|
|
472
477
|
'resolves the board root in-process.',
|
|
473
478
|
'',
|
|
479
|
+
'With --add it keeps every link in order and appends the plan, so a',
|
|
480
|
+
'sliced task links one plan per slice. It reports unchanged when the',
|
|
481
|
+
'plan is already listed and is allowed on a line holding several.',
|
|
482
|
+
'',
|
|
474
483
|
'Examples:',
|
|
475
484
|
' canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate',
|
|
485
|
+
' canon tasks plan-link v28.1-trigger-escalation second-slice --add',
|
|
476
486
|
' canon tasks plan-link v28.1-trigger-escalation .canon/plans/feature-dispatch-answer-gate.md --json',
|
|
477
487
|
'',
|
|
478
488
|
].join('\n'),
|
|
@@ -725,7 +735,7 @@ async function runPlanLink(
|
|
|
725
735
|
): Promise<number> {
|
|
726
736
|
const emitJson = opts.json ?? false
|
|
727
737
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
728
|
-
const outcome = await recordPlan(root, task, plan)
|
|
738
|
+
const outcome = await recordPlan(root, task, plan, opts.add ?? false)
|
|
729
739
|
|
|
730
740
|
return reportPlanLink(outcome, emitJson, root)
|
|
731
741
|
}
|
package/src/commands/tooling.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
select,
|
|
35
35
|
} from '@/ui'
|
|
36
36
|
|
|
37
|
-
const PASS_THROUGH_VERBS = ['
|
|
37
|
+
const PASS_THROUGH_VERBS = ['verify'] as const
|
|
38
38
|
|
|
39
39
|
interface SyncOptions {
|
|
40
40
|
readonly skip?: string
|
|
@@ -71,7 +71,7 @@ type Prepared =
|
|
|
71
71
|
export function register(program: Command): void {
|
|
72
72
|
const tooling = program
|
|
73
73
|
.command('tooling')
|
|
74
|
-
.description('Manage tooling stacks (sync, reference
|
|
74
|
+
.description('Manage tooling stacks (sync, reference)')
|
|
75
75
|
.helpOption('-h, --help', 'Show this help message')
|
|
76
76
|
|
|
77
77
|
tooling
|
package/src/counts/catalogs.ts
CHANGED
|
@@ -25,14 +25,14 @@ const REGISTER_IMPORT = /^import \{ register as /gm
|
|
|
25
25
|
/**
|
|
26
26
|
* Every top-level `canon` command, read off the CLI entry point rather than a
|
|
27
27
|
* catalog verb, because a command carries no `--json` listing of its own
|
|
28
|
-
* siblings. `regen-hero.
|
|
28
|
+
* siblings. `regen-hero.ts` imports this reader for its `COMMAND_COUNT`
|
|
29
29
|
* rather than a second regex on `src/cli.ts`, so this is the one place that
|
|
30
30
|
* pattern is written.
|
|
31
31
|
*
|
|
32
32
|
* This is the one catalog with no meaning outside this repository, the way
|
|
33
|
-
* `regen-hero.
|
|
33
|
+
* `regen-hero.ts` documents itself as clone-only for the same reason.
|
|
34
34
|
*/
|
|
35
|
-
function countCommands(root: string): number | undefined {
|
|
35
|
+
export function countCommands(root: string): number | undefined {
|
|
36
36
|
const cli = join(root, 'src', 'cli.ts')
|
|
37
37
|
if (!existsSync(cli)) return undefined
|
|
38
38
|
return [...readFileSync(cli, 'utf8').matchAll(REGISTER_IMPORT)].length
|
package/src/design/tokens.ts
CHANGED
|
@@ -99,7 +99,7 @@ export const TOKENS: DesignTokens = {
|
|
|
99
99
|
'',
|
|
100
100
|
'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
|
|
101
101
|
'',
|
|
102
|
-
'The two rendered captures read it as well. `scripts/core/regen-hero.
|
|
102
|
+
'The two rendered captures read it as well. `scripts/core/regen-hero.ts` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
|
|
103
103
|
'',
|
|
104
104
|
'The terminal framing is the one surface left holding its own values, and that is a decision rather than a gap. `scripts/lib/ui.sh` and `src/ui.ts` each spell six escape constants, and `canon/context/scripts/framing.md` records one color source per language with a check behind each, so generating a third spelling from here would break the rule those two checks enforce. What the record is still incomplete about is the other half of those six: `WHITE` and `GREY` name no role below, so the terminal palette is described here in part rather than in whole.',
|
|
105
105
|
].join('\n'),
|
package/src/gate/stages.ts
CHANGED
|
@@ -203,7 +203,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
203
203
|
checks: [
|
|
204
204
|
{
|
|
205
205
|
kind: 'command',
|
|
206
|
-
argv: ['
|
|
206
|
+
argv: ['bun', 'scripts/core/regen-hero.ts', '--check'],
|
|
207
207
|
failure: 'Hero regen health check failed',
|
|
208
208
|
},
|
|
209
209
|
{ kind: 'measure', measure: captureStamps },
|