@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.
- 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/git-followup/SKILL.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-craft/REQUIREMENT.md +8 -2
- package/claude/skills/review-craft/SKILL.md +63 -7
- package/claude/skills/review-craft/references/adopted.md +49 -0
- 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 +3 -1
- package/docs/agents/hooks.md +56 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +33 -17
- package/docs/agents/records-push.md +1 -1
- package/docs/agents/records-validate.md +1 -1
- package/docs/agents/scripting.md +6 -3
- package/docs/agents/tasks-archive.md +4 -2
- package/docs/agents/tasks-plans.md +8 -5
- package/docs/agents/tasks.md +1 -1
- package/docs/agents/worktrees.md +2 -2
- package/docs/target/scenarios.md +2 -2
- 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 +3 -0
- package/src/cli.ts +2 -0
- package/src/commands/claude.ts +1 -1
- package/src/commands/docs.ts +51 -11
- package/src/commands/hooks.ts +67 -0
- package/src/commands/pr.ts +146 -76
- package/src/commands/standards.ts +33 -2
- package/src/commands/tasks.ts +57 -2
- package/src/commands/tooling.ts +5 -5
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/worktrees.ts +4 -3
- package/src/counts/catalogs.ts +3 -3
- package/src/design/tokens.ts +1 -1
- package/src/docs/list.ts +103 -0
- package/src/docs/read.ts +63 -19
- package/src/gate/stages.ts +1 -1
- package/src/gov/stacks.ts +1 -1
- package/src/help.ts +2 -1
- package/src/hooks/post-merge.ts +331 -0
- package/src/pr/evidence.ts +21 -0
- package/src/records/validate.ts +1 -38
- package/src/standards/list.ts +33 -0
- package/src/standards/read.ts +13 -0
- package/src/standards/scope.ts +56 -0
- package/src/sync/check.ts +38 -4
- package/src/tasks/archive.ts +195 -3
- package/src/tasks/record.ts +25 -2
- package/src/tooling/inject.ts +4 -3
- package/src/tooling/manifest.ts +62 -8
- package/src/tooling/scan.ts +4 -3
- package/src/tooling/stamp.ts +11 -2
- package/src/tooling/subfolder.ts +7 -3
- package/standards/tasks.md +2 -2
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/scripts/verify.json +3 -0
- package/tooling/base/configs/scripts/verify.sh +1 -110
- package/tooling/base/configs/scripts/verify.ts +378 -0
- package/tooling/base/manifest.toml +1 -0
- package/tooling/base/reference.md +5 -3
- package/tooling/go/configs/scripts/verify.json +10 -0
- package/tooling/go/reference.md +5 -4
- package/tooling/php/configs/scripts/verify.json +7 -0
- package/tooling/php/reference.md +5 -4
- package/tooling/python/configs/scripts/verify.json +4 -0
- package/tooling/python/reference.md +3 -3
- package/tooling/web/configs/scripts/verify.json +3 -0
- package/tooling/web/reference.md +3 -3
- package/scripts/core/regen-hero.sh +0 -419
- package/scripts/docs/list.sh +0 -184
- package/scripts/lib/frontmatter.sh +0 -30
- package/scripts/standards/list.sh +0 -115
- package/scripts/tooling/create.sh +0 -109
- package/src/commands/pass-through.ts +0 -39
- package/tooling/go/configs/scripts/verify.sh +0 -142
- package/tooling/php/configs/scripts/verify.sh +0 -147
- package/tooling/python/configs/scripts/verify.sh +0 -141
- package/tooling/web/configs/scripts/verify.sh +0 -153
|
@@ -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',
|
|
@@ -34,6 +34,9 @@ export const CORPORA: readonly string[] = [
|
|
|
34
34
|
export const PRACTICE_SKILLS: readonly string[] = [
|
|
35
35
|
join(SHIPPED_CORPUS, 'codebase-layout'),
|
|
36
36
|
join(SHIPPED_CORPUS, 'test-craft'),
|
|
37
|
+
join(SHIPPED_CORPUS, 'review-craft'),
|
|
38
|
+
join(SHIPPED_CORPUS, 'systematic-debugging'),
|
|
39
|
+
join(SHIPPED_CORPUS, 'api-design'),
|
|
37
40
|
]
|
|
38
41
|
|
|
39
42
|
/** The closing H2s a practice skill carries, matched exactly outside fences. */
|
package/src/cli.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { register as migrate } from '@/commands/migrate'
|
|
|
30
30
|
import { register as records } from '@/commands/records'
|
|
31
31
|
import { register as sessions } from '@/commands/sessions'
|
|
32
32
|
import { register as worktrees } from '@/commands/worktrees'
|
|
33
|
+
import { register as hooks } from '@/commands/hooks'
|
|
33
34
|
import { register as audits } from '@/commands/audits'
|
|
34
35
|
import { register as gate } from '@/commands/gate'
|
|
35
36
|
import { register as secrets } from '@/commands/secrets'
|
|
@@ -91,6 +92,7 @@ migrate(program)
|
|
|
91
92
|
sessions(program)
|
|
92
93
|
targets(program)
|
|
93
94
|
worktrees(program)
|
|
95
|
+
hooks(program)
|
|
94
96
|
secrets(program)
|
|
95
97
|
deps(program)
|
|
96
98
|
labels(program)
|
package/src/commands/claude.ts
CHANGED
|
@@ -111,7 +111,7 @@ interface PluginUpdateOptions {
|
|
|
111
111
|
/**
|
|
112
112
|
* `no-claude` and `no-plugin` name permanent conditions on a machine that
|
|
113
113
|
* never carries the marketplace plugin at all, matching the `gh-missing` and
|
|
114
|
-
* `no-repository` reasons
|
|
114
|
+
* `no-repository` reasons the post-merge hook's other two steps already stay
|
|
115
115
|
* quiet on forever rather than nagging a project that will never fix them.
|
|
116
116
|
* Every other reason is a real defect and prints.
|
|
117
117
|
*/
|
package/src/commands/docs.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
|
-
import {
|
|
2
|
+
import { type DocEntry, listDocs } from '@/docs/list'
|
|
3
3
|
import { listTopics, readTopic, resolveTopic } from '@/docs/read'
|
|
4
|
-
import { execScript } from '@/exec'
|
|
5
4
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
6
5
|
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
7
6
|
|
|
@@ -22,19 +21,60 @@ export function register(program: Command): void {
|
|
|
22
21
|
'',
|
|
23
22
|
].join('\n'),
|
|
24
23
|
)
|
|
25
|
-
.action(
|
|
26
|
-
|
|
27
|
-
intro('canon docs')
|
|
28
|
-
await execScript('docs/list.sh', [])
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
process.exitCode = get(topic)
|
|
24
|
+
.action((topic: string | undefined) => {
|
|
25
|
+
process.exitCode = topic === undefined ? list({}) : get(topic)
|
|
33
26
|
})
|
|
34
27
|
|
|
35
|
-
|
|
28
|
+
docs
|
|
29
|
+
.command('list')
|
|
30
|
+
.description('List the docs and domain context with their descriptions')
|
|
31
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
32
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
33
|
+
.action((opts: ListOptions) => {
|
|
34
|
+
process.exitCode = list(opts)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ListOptions {
|
|
39
|
+
readonly json?: boolean
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The text listing is frame lines on stderr and the `--json` record is the one
|
|
44
|
+
* thing on stdout, so a caller piping it through a wrapper reads JSON alone.
|
|
45
|
+
* The frame opens after the `--json` branch and never wraps it.
|
|
46
|
+
*/
|
|
47
|
+
function list(opts: ListOptions): number {
|
|
48
|
+
const catalog = listDocs(PROJECT_ROOT)
|
|
49
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
50
|
+
|
|
51
|
+
if (opts.json) {
|
|
52
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
53
|
+
const docs: DocEntry[] = catalog.hasContext
|
|
54
|
+
? [...catalog.docs, ...catalog.context]
|
|
55
|
+
: catalog.docs
|
|
56
|
+
process.stdout.write(`${JSON.stringify({ docs })}\n`)
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
intro('canon docs')
|
|
61
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
62
|
+
|
|
63
|
+
logStep('Docs')
|
|
64
|
+
for (const entry of catalog.docs) logInfo(describeEntry(entry))
|
|
65
|
+
|
|
66
|
+
if (catalog.hasContext) {
|
|
67
|
+
logStep('Domain context')
|
|
68
|
+
for (const entry of catalog.context) logInfo(describeEntry(entry))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
outro()
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const describeEntry = (entry: DocEntry): string =>
|
|
76
|
+
`${entry.name} : ${entry.description}`
|
|
77
|
+
|
|
38
78
|
/**
|
|
39
79
|
* Writes the document body to stdout and every frame line to stderr, so a
|
|
40
80
|
* caller capturing the output with `$(...)` receives the document alone.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import {
|
|
3
|
+
readMergedSubjects,
|
|
4
|
+
runCanon,
|
|
5
|
+
runPostMerge,
|
|
6
|
+
STEP_VERBS,
|
|
7
|
+
} from '@/hooks/post-merge'
|
|
8
|
+
import { mainWorktreeRoot } from '@/worktree'
|
|
9
|
+
|
|
10
|
+
interface PostMergeOptions {
|
|
11
|
+
readonly root?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function register(program: Command): void {
|
|
15
|
+
const hooks = program
|
|
16
|
+
.command('hooks')
|
|
17
|
+
.description('Run the steps a git hook drives')
|
|
18
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
19
|
+
|
|
20
|
+
hooks
|
|
21
|
+
.command('post-merge')
|
|
22
|
+
.description('Run the post-merge steps in order and report each in a line')
|
|
23
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
24
|
+
.option(
|
|
25
|
+
'--root <path>',
|
|
26
|
+
'Main worktree root holding the board and the records',
|
|
27
|
+
)
|
|
28
|
+
.addHelpText(
|
|
29
|
+
'after',
|
|
30
|
+
[
|
|
31
|
+
'',
|
|
32
|
+
'Runs these steps in order, each a child call reading its --json record:',
|
|
33
|
+
...Object.values(STEP_VERBS).map((verb) => ` canon ${verb.join(' ')}`),
|
|
34
|
+
'',
|
|
35
|
+
'The archive runs once per pull request number the merge brought in.',
|
|
36
|
+
'Lines go to stderr, and an ordinary merge prints nothing.',
|
|
37
|
+
'',
|
|
38
|
+
'The reclaim runs from the working directory with no root, so a pull',
|
|
39
|
+
'inside a linked worktree never removes the worktree it stands in.',
|
|
40
|
+
'',
|
|
41
|
+
'Environment:',
|
|
42
|
+
' CANON_SKIP_RECLAIM=1 skip the worktree reclaim',
|
|
43
|
+
' CANON_SKIP_UPGRADE=1 skip the CLI reinstall',
|
|
44
|
+
' CANON_SKIP_PLUGIN_UPDATE=1 skip the plugin update',
|
|
45
|
+
'',
|
|
46
|
+
'Exit codes:',
|
|
47
|
+
' 0 always, since a hook failing aborts nothing useful',
|
|
48
|
+
'',
|
|
49
|
+
'Examples:',
|
|
50
|
+
' canon hooks post-merge',
|
|
51
|
+
' CANON_SKIP_UPGRADE=1 canon hooks post-merge --root "$root"',
|
|
52
|
+
'',
|
|
53
|
+
].join('\n'),
|
|
54
|
+
)
|
|
55
|
+
.action(async (opts: PostMergeOptions) => {
|
|
56
|
+
const cwd = process.cwd()
|
|
57
|
+
await runPostMerge({
|
|
58
|
+
root: opts.root ?? (await mainWorktreeRoot()),
|
|
59
|
+
cwd,
|
|
60
|
+
subjects: await readMergedSubjects(cwd),
|
|
61
|
+
env: process.env,
|
|
62
|
+
run: runCanon,
|
|
63
|
+
write: (text) => process.stderr.write(text),
|
|
64
|
+
})
|
|
65
|
+
process.exitCode = 0
|
|
66
|
+
})
|
|
67
|
+
}
|