@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
package/src/tasks/archive.ts
CHANGED
|
@@ -417,6 +417,33 @@ export function retargetPlanLine(
|
|
|
417
417
|
return lines.join('\n')
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Appends a link to a located `Plan:` line right after its last target, so
|
|
422
|
+
* trailing prose and a line ending stay where they were. A bare target is
|
|
423
|
+
* rewritten as a link on the way, since the reader takes bare paths only when
|
|
424
|
+
* no link sits on the line and the appended one would otherwise hide them.
|
|
425
|
+
*/
|
|
426
|
+
export function appendPlanLink(line: string, target: string): string {
|
|
427
|
+
const tokens = planTokens(line)
|
|
428
|
+
if (tokens.length === 0) {
|
|
429
|
+
const trailing = /\s*$/.exec(line)?.[0] ?? ''
|
|
430
|
+
return `${line.slice(0, line.length - trailing.length)} ${planLink(target)}${trailing}`
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let rewritten = ''
|
|
434
|
+
let at = 0
|
|
435
|
+
for (const token of tokens) {
|
|
436
|
+
const isLink = line[token.start] === '['
|
|
437
|
+
rewritten += line.slice(at, token.start)
|
|
438
|
+
rewritten += isLink
|
|
439
|
+
? line.slice(token.start, token.end)
|
|
440
|
+
: planLink(token.target)
|
|
441
|
+
at = token.end
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return `${rewritten}, ${planLink(target)}${line.slice(at)}`
|
|
445
|
+
}
|
|
446
|
+
|
|
420
447
|
/** The `Ready:` line in either form, the link's target captured ahead of the bare path. */
|
|
421
448
|
const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
|
|
422
449
|
|
package/src/tasks/record.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, join, relative } from 'node:path'
|
|
4
4
|
import { planCandidates } from '@/tasks/answers'
|
|
5
5
|
import {
|
|
6
|
+
appendPlanLink,
|
|
6
7
|
describeUnmatchedStem,
|
|
7
8
|
fenceMask,
|
|
8
9
|
linkTo,
|
|
@@ -240,12 +241,33 @@ export type PlanLineWrite =
|
|
|
240
241
|
* A line already linking several plans is refused whole, even when one of them
|
|
241
242
|
* is the target, since overwriting it drops links the caller never named and
|
|
242
243
|
* reporting it unchanged hides a line that breaks the one-plan rule.
|
|
244
|
+
*
|
|
245
|
+
* `add` is the sliced task's writer, which links one plan per slice. It keeps
|
|
246
|
+
* every link in order and appends the target, reports `unchanged` when the
|
|
247
|
+
* target is already listed, and so never refuses a line holding several.
|
|
243
248
|
*/
|
|
244
|
-
export function writePlanLine(
|
|
249
|
+
export function writePlanLine(
|
|
250
|
+
text: string,
|
|
251
|
+
target: string,
|
|
252
|
+
add = false,
|
|
253
|
+
): PlanLineWrite {
|
|
245
254
|
const line = planLine(target)
|
|
246
255
|
const lines = text.split('\n')
|
|
247
256
|
const existing = planLineIndex(lines)
|
|
248
257
|
|
|
258
|
+
if (existing !== -1 && add) {
|
|
259
|
+
if (readPlanTargets(lines[existing]).includes(target)) {
|
|
260
|
+
return { ok: true, text, action: 'unchanged', replaced: undefined }
|
|
261
|
+
}
|
|
262
|
+
lines[existing] = appendPlanLink(lines[existing], target)
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
text: lines.join('\n'),
|
|
266
|
+
action: 'appended',
|
|
267
|
+
replaced: undefined,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
249
271
|
if (existing !== -1) {
|
|
250
272
|
const targets = readPlanTargets(lines[existing])
|
|
251
273
|
if (targets.length > 1) return { ok: false, targets }
|
|
@@ -449,6 +471,7 @@ export async function recordPlan(
|
|
|
449
471
|
root: string,
|
|
450
472
|
stem: string,
|
|
451
473
|
reference: string,
|
|
474
|
+
add = false,
|
|
452
475
|
): Promise<PlanOutcome> {
|
|
453
476
|
const opened = await openTask(root, { kind: 'stem', stem })
|
|
454
477
|
if ('ok' in opened) return opened
|
|
@@ -464,7 +487,7 @@ export async function recordPlan(
|
|
|
464
487
|
}
|
|
465
488
|
|
|
466
489
|
const target = linkTo(dir, plan)
|
|
467
|
-
const written = writePlanLine(await readFile(path, 'utf8'), target)
|
|
490
|
+
const written = writePlanLine(await readFile(path, 'utf8'), target, add)
|
|
468
491
|
|
|
469
492
|
if (!written.ok) {
|
|
470
493
|
return refuse(
|
package/standards/tasks.md
CHANGED
|
@@ -124,9 +124,9 @@ An intake folder answers that direction at folder scope rather than item scope.
|
|
|
124
124
|
|
|
125
125
|
A project that archived plans before the folder nested under `.canon/plans/` holds closed tasks pointing at `../plans-archive/` or `../.tmp/plans-archive/`. Leave those pointers where they are, since each resolves against the files it names and a task retargeted without its plan moving leads nowhere.
|
|
126
126
|
|
|
127
|
-
One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for.
|
|
127
|
+
One plan per task, or one per slice on a sliced task. A plan cited by two tasks is a misfile rather than a shape to design for.
|
|
128
128
|
|
|
129
|
-
`canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, so the line is
|
|
129
|
+
`canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, and `--add` appends a slice's plan, so the line is never hand-edited.
|
|
130
130
|
|
|
131
131
|
`Groundwork:` points at `../groundwork/<slug>/`, the folder `plan-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
|
|
132
132
|
|
|
@@ -1,419 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Fills every assets/captures/*.html.tmpl from the CLI catalogs and the design
|
|
3
|
-
# source, writing the .html beside each one.
|
|
4
|
-
#
|
|
5
|
-
# The name says hero because the hero was the only template when it was written
|
|
6
|
-
# and every citation of it across docs/, canon/context/, and src/ spells that
|
|
7
|
-
# name. Renaming the file is a sweep across roughly twenty surfaces for a
|
|
8
|
-
# cosmetic gain, so the file keeps its name and the loop below covers whatever
|
|
9
|
-
# templates the folder holds.
|
|
10
|
-
#
|
|
11
|
-
# The palette arrives the same way the counts do. `canon design css
|
|
12
|
-
# --no-components` emits the custom properties from src/design/tokens.ts and
|
|
13
|
-
# they land on the {{TOKENS}} placeholder, so neither frame carries its own copy
|
|
14
|
-
# of a hex value and a token moved at the source moves both captures. The
|
|
15
|
-
# component half is left out on purpose: it is a scrollbar and a status marker,
|
|
16
|
-
# and a static capture frame renders neither.
|
|
17
|
-
#
|
|
18
|
-
# A frame a README shows in both themes fills once more against the light roles
|
|
19
|
-
# and writes `<name>-light.html` beside the dark one, so each theme is its own
|
|
20
|
-
# capture set with its own stamp.
|
|
21
|
-
#
|
|
22
|
-
# Only the HTML regenerates here. The PNG beside it is a chromium render whose
|
|
23
|
-
# bytes move with the browser version, so asserting it in verify.sh would fail
|
|
24
|
-
# on a machine whose chromium differs rather than on a stale count. Rebuild the
|
|
25
|
-
# images with `canon capture assets/captures --selector .window --out assets/evidence`
|
|
26
|
-
# after this script reports a change. The markup and the image sit in two
|
|
27
|
-
# folders, so that run reads the sources here and sends every PNG and stamp to
|
|
28
|
-
# assets/evidence/, where the documents point and where `canon pr evidence`
|
|
29
|
-
# compares a changed image. The selector has no default, since
|
|
30
|
-
# the element a capture crops to belongs to the page rather than to the command,
|
|
31
|
-
# and `.window` is the class this repository's own sources declare.
|
|
32
|
-
# That capture also writes a .stamp beside each PNG, which records the digest of
|
|
33
|
-
# the markup it rendered and is what the Hero stage compares, so a frame's three
|
|
34
|
-
# files commit together. The frame carries no version. `package.json` is bumped on main by the release
|
|
35
|
-
# tooling, so embedding it drifts every open branch on the next release and the
|
|
36
|
-
# stage then fails for work that touched nothing.
|
|
37
|
-
#
|
|
38
|
-
# Clone-only. `canon capture` ships now, but this script reads the repository's
|
|
39
|
-
# own catalogs, which a registry install does not carry.
|
|
40
|
-
set -e
|
|
41
|
-
set -o pipefail
|
|
42
|
-
|
|
43
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
44
|
-
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
45
|
-
|
|
46
|
-
ASSET_DIR="$PROJECT_ROOT/assets/captures"
|
|
47
|
-
LISTED=10
|
|
48
|
-
|
|
49
|
-
# `--check` fills every template into a temp folder and discards it, so a renamed
|
|
50
|
-
# featured skill, an empty catalog, or an unresolved placeholder still fails a
|
|
51
|
-
# branch while no frame file changes. Frames are refreshed after a merge rather
|
|
52
|
-
# than committed by each branch, so a branch asks whether the regen can run and
|
|
53
|
-
# never whether its output is current.
|
|
54
|
-
OUT_DIR="$ASSET_DIR"
|
|
55
|
-
case "${1:-}" in
|
|
56
|
-
'') ;;
|
|
57
|
-
--check) OUT_DIR="$(mktemp -d)" ;;
|
|
58
|
-
*)
|
|
59
|
-
echo "regen-hero: unknown argument $1" >&2
|
|
60
|
-
exit 1
|
|
61
|
-
;;
|
|
62
|
-
esac
|
|
63
|
-
|
|
64
|
-
# `bun src/cli.ts` rather than `canon`, since a globally linked binary resolves to
|
|
65
|
-
# the main checkout no matter which worktree is running.
|
|
66
|
-
catalog() {
|
|
67
|
-
(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts "$@" --json 2>/dev/null)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
TEMPLATES=("$ASSET_DIR"/*.html.tmpl)
|
|
71
|
-
if [ ! -f "${TEMPLATES[0]}" ]; then
|
|
72
|
-
echo "regen-hero: no templates under $ASSET_DIR" >&2
|
|
73
|
-
exit 1
|
|
74
|
-
fi
|
|
75
|
-
|
|
76
|
-
# Not routed through `catalog`, since the design emitter writes CSS on stdout
|
|
77
|
-
# rather than a `--json` record.
|
|
78
|
-
TOKEN_CSS="$(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts design css --no-components)"
|
|
79
|
-
if [ -z "$TOKEN_CSS" ]; then
|
|
80
|
-
echo "regen-hero: the design source emitted nothing, refusing to write an unstyled frame" >&2
|
|
81
|
-
exit 1
|
|
82
|
-
fi
|
|
83
|
-
|
|
84
|
-
# The commands have no `--json` catalog of their own, so the count comes from
|
|
85
|
-
# `canon gov counts`, which already reads the registration block in the CLI
|
|
86
|
-
# entry point to judge a document stating its own command count. Reading that
|
|
87
|
-
# figure here rather than re-deriving it with a second regex is what keeps the
|
|
88
|
-
# two readings of `src/cli.ts` from drifting apart unwatched, which is the
|
|
89
|
-
# defect `canon gov counts` itself exists to catch.
|
|
90
|
-
# `gov counts` exits non-zero on an ordinary finding, unlike every `list` verb
|
|
91
|
-
# below, so its output is read past that under `set -e` rather than aborting
|
|
92
|
-
# a hero rebuild for a stale count somewhere unrelated in the tree.
|
|
93
|
-
COUNTS_JSON="$(catalog gov counts || true)"
|
|
94
|
-
SKILLS_JSON="$(catalog claude skills list)"
|
|
95
|
-
GOV_JSON="$(catalog gov list)"
|
|
96
|
-
STANDARDS_JSON="$(catalog standards list)"
|
|
97
|
-
TOOLING_JSON="$(catalog tooling list)"
|
|
98
|
-
|
|
99
|
-
for payload in "$COUNTS_JSON" "$SKILLS_JSON" "$GOV_JSON" "$STANDARDS_JSON" "$TOOLING_JSON"; do
|
|
100
|
-
if [ -z "$payload" ]; then
|
|
101
|
-
echo "regen-hero: a catalog returned nothing, refusing to write a zeroed hero" >&2
|
|
102
|
-
exit 1
|
|
103
|
-
fi
|
|
104
|
-
done
|
|
105
|
-
|
|
106
|
-
# The catalogs reach the eval as files rather than as environment entries. Linux
|
|
107
|
-
# caps a single env string at 128KB, and the standards payload crossed it, which
|
|
108
|
-
# fails the exec with E2BIG before any stage can report a stale count. A file
|
|
109
|
-
# path is bounded whatever the catalogs grow to.
|
|
110
|
-
PAYLOAD_DIR="$(mktemp -d)"
|
|
111
|
-
trap 'rm -rf "$PAYLOAD_DIR"; [ "$OUT_DIR" = "$ASSET_DIR" ] || rm -rf "$OUT_DIR"' EXIT
|
|
112
|
-
|
|
113
|
-
printf '%s' "$COUNTS_JSON" >"$PAYLOAD_DIR/counts.json"
|
|
114
|
-
printf '%s' "$SKILLS_JSON" >"$PAYLOAD_DIR/skills.json"
|
|
115
|
-
printf '%s' "$GOV_JSON" >"$PAYLOAD_DIR/gov.json"
|
|
116
|
-
printf '%s' "$STANDARDS_JSON" >"$PAYLOAD_DIR/standards.json"
|
|
117
|
-
printf '%s' "$TOOLING_JSON" >"$PAYLOAD_DIR/tooling.json"
|
|
118
|
-
printf '%s' "$TOKEN_CSS" >"$PAYLOAD_DIR/tokens.css"
|
|
119
|
-
|
|
120
|
-
export PAYLOAD_DIR
|
|
121
|
-
export ASSET_DIR OUT_DIR LISTED PROJECT_ROOT
|
|
122
|
-
|
|
123
|
-
bun --eval '
|
|
124
|
-
const { readFileSync, readdirSync } = require("node:fs")
|
|
125
|
-
|
|
126
|
-
const { PAYLOAD_DIR, ASSET_DIR, OUT_DIR, LISTED, PROJECT_ROOT } = process.env
|
|
127
|
-
|
|
128
|
-
const payload = (name) => readFileSync(PAYLOAD_DIR + "/" + name + ".json", "utf8")
|
|
129
|
-
|
|
130
|
-
// Indented to the depth a rule inside the `<style>` block sits at, so the
|
|
131
|
-
// emitted file passes the same formatter every other committed asset does.
|
|
132
|
-
const tokenCss = readFileSync(PAYLOAD_DIR + "/tokens.css", "utf8")
|
|
133
|
-
.trimEnd()
|
|
134
|
-
.split("\n")
|
|
135
|
-
.map((line) => (line === "" ? "" : " " + line))
|
|
136
|
-
.join("\n")
|
|
137
|
-
|
|
138
|
-
const COUNTS_JSON = payload("counts")
|
|
139
|
-
const SKILLS_JSON = payload("skills")
|
|
140
|
-
const GOV_JSON = payload("gov")
|
|
141
|
-
const STANDARDS_JSON = payload("standards")
|
|
142
|
-
const TOOLING_JSON = payload("tooling")
|
|
143
|
-
|
|
144
|
-
const listed = Number(LISTED)
|
|
145
|
-
const skills = JSON.parse(SKILLS_JSON).skills.map((entry) => entry.name)
|
|
146
|
-
const gov = JSON.parse(GOV_JSON)
|
|
147
|
-
// Rule names carry a numeric prefix that orders the load, not the identity a
|
|
148
|
-
// reader knows them by, so the frame shows the slug alone.
|
|
149
|
-
const slug = (entry) => entry.name.replace(/^\d+-/, "")
|
|
150
|
-
const rules = gov.rules.map(slug)
|
|
151
|
-
// A rule no stack names is opt-in behind `--add`, so featuring one advertises
|
|
152
|
-
// an entry no ordinary `canon gov install` delivers. The column samples the
|
|
153
|
-
// stack-reached subset while its count and remainder describe the whole catalog.
|
|
154
|
-
const stacked = new Set(gov.stacks.flatMap((stack) => stack.rules))
|
|
155
|
-
const deliveredRules = gov.rules.filter((entry) => stacked.has(entry.name)).map(slug)
|
|
156
|
-
const standards = JSON.parse(STANDARDS_JSON).standards.map((entry) => entry.name)
|
|
157
|
-
const toolingStacks = JSON.parse(TOOLING_JSON).stacks
|
|
158
|
-
const commandCount = JSON.parse(COUNTS_JSON).catalogs.commands
|
|
159
|
-
if (!commandCount) {
|
|
160
|
-
console.error("regen-hero: gov counts read no commands catalog, refusing to write a zeroed hero")
|
|
161
|
-
process.exit(1)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const MARK_SVG = (await Bun.file(`${PROJECT_ROOT}/assets/brand/mark.svg`).text()).trim()
|
|
165
|
-
if (!MARK_SVG) {
|
|
166
|
-
console.error("regen-hero: assets/brand/mark.svg read empty, refusing to write a hero with no mark")
|
|
167
|
-
process.exit(1)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const accentMatch = tokenCss.match(/--color-accent:\s*(#[0-9a-fA-F]{3,8})/)
|
|
171
|
-
if (!accentMatch) {
|
|
172
|
-
console.error("regen-hero: token css carries no --color-accent value, refusing to write a colorless favicon")
|
|
173
|
-
process.exit(1)
|
|
174
|
-
}
|
|
175
|
-
const faviconSvg = MARK_SVG.replace(/<!--[\s\S]*?-->/, "").trim().replaceAll("currentColor", accentMatch[1])
|
|
176
|
-
const FAVICON = `data:image/svg+xml,${encodeURIComponent(faviconSvg)}`
|
|
177
|
-
|
|
178
|
-
const escape = (value) =>
|
|
179
|
-
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
180
|
-
|
|
181
|
-
// Even spacing across the sorted catalog rather than its first N, so a column
|
|
182
|
-
// whose names share a prefix does not read as a narrower catalog than the one
|
|
183
|
-
// that ships. Rules and standards sample. Skills do not, because the even step
|
|
184
|
-
// lands on the entries closest to commodity and on none of the workflow the
|
|
185
|
-
// toolkit exists to carry.
|
|
186
|
-
const sample = (names) => {
|
|
187
|
-
if (names.length <= listed) return names
|
|
188
|
-
const step = names.length / listed
|
|
189
|
-
return Array.from({ length: listed }, (_, i) => names[Math.floor(i * step)])
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// The first seven are the method and exist nowhere else. The last three are
|
|
193
|
-
// recognizable on sight and are what keeps a column of chosen names from
|
|
194
|
-
// reading as an all-`claude-` catalog, which is the failure the sampler above
|
|
195
|
-
// was written against.
|
|
196
|
-
const FEATURED_SKILLS = [
|
|
197
|
-
"plan-feature",
|
|
198
|
-
"plan-groundwork",
|
|
199
|
-
"plan-intake",
|
|
200
|
-
"role-orchestrator",
|
|
201
|
-
"auto-ship",
|
|
202
|
-
"review-pr",
|
|
203
|
-
"task-board",
|
|
204
|
-
"git-ship",
|
|
205
|
-
"target-setup",
|
|
206
|
-
"systematic-debugging",
|
|
207
|
-
]
|
|
208
|
-
|
|
209
|
-
// A chosen name is maintained by hand where a sample never goes stale, so a
|
|
210
|
-
// rename has to fail the run rather than quietly shrink the column. This is
|
|
211
|
-
// what makes the trade acceptable.
|
|
212
|
-
//
|
|
213
|
-
// The length is asserted because the `+N more` figure below counts down from
|
|
214
|
-
// `listed` rather than from what this returns, so a list of any other length
|
|
215
|
-
// renders a remainder that is wrong by the difference.
|
|
216
|
-
const featured = (names, chosen) => {
|
|
217
|
-
if (chosen.length !== listed) {
|
|
218
|
-
console.error(`regen-hero: ${chosen.length} featured skills, expected ${listed}`)
|
|
219
|
-
process.exit(1)
|
|
220
|
-
}
|
|
221
|
-
const catalog = new Set(names)
|
|
222
|
-
const missing = chosen.filter((name) => !catalog.has(name))
|
|
223
|
-
if (missing.length > 0) {
|
|
224
|
-
console.error(`regen-hero: featured skills missing from the catalog: ${missing.join(", ")}`)
|
|
225
|
-
process.exit(1)
|
|
226
|
-
}
|
|
227
|
-
return chosen
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const markup = (names) =>
|
|
231
|
-
names
|
|
232
|
-
.map((name) => ` <div class="entry">${escape(name)}</div>`)
|
|
233
|
-
.join("\n")
|
|
234
|
-
|
|
235
|
-
const entries = (names) => markup(sample(names))
|
|
236
|
-
|
|
237
|
-
const remaining = (names) => String(Math.max(0, names.length - listed))
|
|
238
|
-
|
|
239
|
-
// An empty array is well-formed JSON, so the shell guard on an empty payload
|
|
240
|
-
// does not catch a catalog whose tree went missing. A zeroed count would ship a
|
|
241
|
-
// frame claiming the domain has nothing in it.
|
|
242
|
-
for (const [label, list] of [
|
|
243
|
-
["skills", skills], ["rules", rules], ["standards", standards],
|
|
244
|
-
["tooling stacks", toolingStacks], ["gov stacks", gov.stacks],
|
|
245
|
-
["stack-delivered rules", deliveredRules],
|
|
246
|
-
]) {
|
|
247
|
-
if (list.length === 0) {
|
|
248
|
-
console.error(`regen-hero: the ${label} catalog is empty, refusing to write a zeroed hero`)
|
|
249
|
-
process.exit(1)
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// A terminal frame renders lines into a `<pre>` where the hero renders names
|
|
254
|
-
// into divs, so the two cannot share a value however similar the catalog read
|
|
255
|
-
// behind them looks. Padding happens before escaping, since an entity is longer
|
|
256
|
-
// than the character it replaces and would push a column out of line.
|
|
257
|
-
const pad = (value, width) => value + " ".repeat(Math.max(0, width - value.length))
|
|
258
|
-
const frameRow = (cells) => `<span class="frame">│</span> <span class="ok">✓</span> ${cells}`
|
|
259
|
-
|
|
260
|
-
// The frame sets `white-space: pre` at a fixed window width, so an overlong
|
|
261
|
-
// cell is clipped by the window edge rather than wrapped. Truncating here is
|
|
262
|
-
// what keeps the longest row inside the capture, and the ellipsis is what stops
|
|
263
|
-
// a clipped value from reading as the whole value.
|
|
264
|
-
const GLOB_WIDTH = 44
|
|
265
|
-
const clip = (value, width) =>
|
|
266
|
-
value.length <= width ? value : `${value.slice(0, width - 1)}…`
|
|
267
|
-
|
|
268
|
-
const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`
|
|
269
|
-
|
|
270
|
-
const govStackRows = gov.stacks
|
|
271
|
-
.map((stack) =>
|
|
272
|
-
frameRow(
|
|
273
|
-
`<span class="name">${escape(pad(stack.name, 17))}</span>` +
|
|
274
|
-
`<span class="muted">${escape(pad(plural(stack.rules.length, "rule"), 11))}</span>` +
|
|
275
|
-
`<span class="muted">${escape(stack.extends ? `extends ${stack.extends}` : "")}</span>`,
|
|
276
|
-
),
|
|
277
|
-
)
|
|
278
|
-
.join("\n")
|
|
279
|
-
|
|
280
|
-
// Sampled from the stack-reached subset for the reason the hero column is, and
|
|
281
|
-
// showing each rule beside the glob that loads it rather than its description,
|
|
282
|
-
// since the glob is the mechanism a reader cannot otherwise see.
|
|
283
|
-
const deliveredEntries = gov.rules.filter((entry) => stacked.has(entry.name))
|
|
284
|
-
const govRuleRows = sample(deliveredEntries)
|
|
285
|
-
.map((entry) => {
|
|
286
|
-
const globs = [entry.paths ?? []].flat()
|
|
287
|
-
// A rule carrying no glob is not unscoped, it loads every session, and
|
|
288
|
-
// saying so is the contrast that makes the column mean anything.
|
|
289
|
-
const scope = globs.length > 0 ? clip(globs.join(" "), GLOB_WIDTH) : "every session"
|
|
290
|
-
return frameRow(
|
|
291
|
-
`<span class="name">${escape(pad(slug(entry), 24))}</span>` +
|
|
292
|
-
`<span class="domain">${escape(pad(`[${entry.domain}]`, 12))}</span>` +
|
|
293
|
-
`<span class="muted">${escape(scope)}</span>`,
|
|
294
|
-
)
|
|
295
|
-
})
|
|
296
|
-
.join("\n")
|
|
297
|
-
|
|
298
|
-
// A standard either governs an artifact by path or is opened by name when a
|
|
299
|
-
// session decides it needs it. Rendering the first and naming the second is the
|
|
300
|
-
// contrast, since nothing else in the catalog tells a reader that the corpus
|
|
301
|
-
// installs into no project and is read through a verb instead.
|
|
302
|
-
const standardEntries = JSON.parse(STANDARDS_JSON).standards
|
|
303
|
-
const standardRows = sample(standardEntries)
|
|
304
|
-
.map((entry) => {
|
|
305
|
-
const applies = [entry.appliesTo ?? []].flat()
|
|
306
|
-
const governs =
|
|
307
|
-
applies.length > 0 ? clip(applies.join(" "), GLOB_WIDTH) : "read by name"
|
|
308
|
-
return frameRow(
|
|
309
|
-
`<span class="name">${escape(pad(entry.name, 18))}</span>` +
|
|
310
|
-
`<span class="muted">${escape(governs)}</span>`,
|
|
311
|
-
)
|
|
312
|
-
})
|
|
313
|
-
.join("\n")
|
|
314
|
-
|
|
315
|
-
// A tooling stack is counted rather than listed, because what it lays down is
|
|
316
|
-
// dev dependencies, run scripts, and ignore groups rather than named entries a
|
|
317
|
-
// reader would recognize. The inheritance is the part worth showing, since it
|
|
318
|
-
// is why a stack carrying two of its own arrives with far more than two.
|
|
319
|
-
const toolingStackRows = toolingStacks
|
|
320
|
-
.map((stack) =>
|
|
321
|
-
frameRow(
|
|
322
|
-
`<span class="name">${escape(pad(stack.name, 14))}</span>` +
|
|
323
|
-
`<span class="muted">${escape(pad(plural(stack.devDeps, "dep"), 10))}</span>` +
|
|
324
|
-
`<span class="muted">${escape(pad(plural(stack.scripts, "script"), 12))}</span>` +
|
|
325
|
-
`<span class="muted">${escape(stack.extends ? `extends ${stack.extends}` : "")}</span>`,
|
|
326
|
-
),
|
|
327
|
-
)
|
|
328
|
-
.join("\n")
|
|
329
|
-
|
|
330
|
-
for (const [label, rows] of [
|
|
331
|
-
["governance stack rows", govStackRows],
|
|
332
|
-
["governance rule rows", govRuleRows],
|
|
333
|
-
["standard rows", standardRows],
|
|
334
|
-
["tooling stack rows", toolingStackRows],
|
|
335
|
-
]) {
|
|
336
|
-
if (rows === "") {
|
|
337
|
-
console.error(`regen-hero: the ${label} rendered empty, refusing to write a blank frame`)
|
|
338
|
-
process.exit(1)
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const values = {
|
|
343
|
-
TOKENS: tokenCss,
|
|
344
|
-
GOV_STACK_ROWS: govStackRows,
|
|
345
|
-
GOV_RULE_ROWS: govRuleRows,
|
|
346
|
-
STANDARD_ROWS: standardRows,
|
|
347
|
-
TOOLING_STACK_ROWS: toolingStackRows,
|
|
348
|
-
SKILL_COUNT: String(skills.length),
|
|
349
|
-
RULE_COUNT: String(rules.length),
|
|
350
|
-
STANDARD_COUNT: String(standards.length),
|
|
351
|
-
GOV_STACK_COUNT: String(gov.stacks.length),
|
|
352
|
-
TOOLING_STACK_COUNT: String(toolingStacks.length),
|
|
353
|
-
COMMAND_COUNT: String(commandCount),
|
|
354
|
-
MARK_SVG,
|
|
355
|
-
FAVICON,
|
|
356
|
-
SKILL_ENTRIES: markup(featured(skills, FEATURED_SKILLS)),
|
|
357
|
-
RULE_ENTRIES: entries(deliveredRules),
|
|
358
|
-
STANDARD_ENTRIES: entries(standards),
|
|
359
|
-
SKILL_MORE: remaining(skills),
|
|
360
|
-
RULE_MORE: remaining(rules),
|
|
361
|
-
STANDARD_MORE: remaining(standards),
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Every template takes the same value map, so one carrying no count placeholder
|
|
365
|
-
// simply resolves none of them. What a template must not do is name a
|
|
366
|
-
// placeholder nobody fills, which the unresolved check below catches per file.
|
|
367
|
-
const templates = readdirSync(ASSET_DIR)
|
|
368
|
-
.filter((name) => name.endsWith(".html.tmpl"))
|
|
369
|
-
.sort()
|
|
370
|
-
|
|
371
|
-
if (templates.length === 0) {
|
|
372
|
-
console.error(`regen-hero: no templates under ${ASSET_DIR}`)
|
|
373
|
-
process.exit(1)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// The frames a README serves in both themes. The emitter already carries a
|
|
377
|
-
// `--color-light-*` role beside each `--color-*` one, so the light frame is the
|
|
378
|
-
// same template with the plain roles pointed at the light ones rather than a
|
|
379
|
-
// second copy of any hex value. The hero is a landing-page frame no README
|
|
380
|
-
// shows, so it stays dark.
|
|
381
|
-
const LIGHT_TEMPLATES = new Set(["install.html.tmpl"])
|
|
382
|
-
|
|
383
|
-
const lightRoles = [...tokenCss.matchAll(/--color-light-([a-z-]+):/g)].map((match) => match[1])
|
|
384
|
-
if (lightRoles.length === 0) {
|
|
385
|
-
console.error("regen-hero: token css carries no --color-light-* roles, refusing to write a light frame")
|
|
386
|
-
process.exit(1)
|
|
387
|
-
}
|
|
388
|
-
const lightTokenCss = [
|
|
389
|
-
tokenCss,
|
|
390
|
-
"",
|
|
391
|
-
" :root {",
|
|
392
|
-
...lightRoles.map((role) => ` --color-${role}: var(--color-light-${role});`),
|
|
393
|
-
" --frame-shadow: rgb(0 0 0 / 16%);",
|
|
394
|
-
" }",
|
|
395
|
-
].join("\n")
|
|
396
|
-
|
|
397
|
-
const frames = templates.flatMap((template) => {
|
|
398
|
-
const base = template.replace(/\.html\.tmpl$/, "")
|
|
399
|
-
const dark = { template, out: `${base}.html`, tokens: tokenCss }
|
|
400
|
-
return LIGHT_TEMPLATES.has(template)
|
|
401
|
-
? [dark, { template, out: `${base}-light.html`, tokens: lightTokenCss }]
|
|
402
|
-
: [dark]
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
for (const { template, out, tokens } of frames) {
|
|
406
|
-
let html = await Bun.file(ASSET_DIR + "/" + template).text()
|
|
407
|
-
for (const [key, value] of Object.entries({ ...values, TOKENS: tokens })) {
|
|
408
|
-
html = html.replaceAll(`{{${key}}}`, value)
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const unresolved = html.match(/{{[A-Z_]+}}/g)
|
|
412
|
-
if (unresolved) {
|
|
413
|
-
console.error(`regen-hero: ${template} carries unresolved placeholders ${[...new Set(unresolved)].join(", ")}`)
|
|
414
|
-
process.exit(1)
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
await Bun.write(OUT_DIR + "/" + out, html)
|
|
418
|
-
}
|
|
419
|
-
'
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -e
|
|
3
|
-
set -o pipefail
|
|
4
|
-
|
|
5
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
-
PROJECT_ROOT="${PROJECT_ROOT:-$(dirname "$(dirname "$SCRIPT_DIR")")}"
|
|
7
|
-
|
|
8
|
-
source "$PROJECT_ROOT/scripts/lib/ui.sh"
|
|
9
|
-
source "$PROJECT_ROOT/scripts/lib/tooling.sh"
|
|
10
|
-
trap close_timeline EXIT
|
|
11
|
-
|
|
12
|
-
show_help() {
|
|
13
|
-
echo -e "${GREY}┌${NC}"
|
|
14
|
-
log_step "Tooling create usage"
|
|
15
|
-
echo -e "${GREY}│${NC} ${WHITE}Usage:${NC} canon tooling create [stack]"
|
|
16
|
-
echo -e "${GREY}│${NC}"
|
|
17
|
-
echo -e "${GREY}│${NC} Creates a new stack with stub manifest, reference, configs, and seeds."
|
|
18
|
-
echo -e "${GREY}│${NC}"
|
|
19
|
-
echo -e "${GREY}│${NC} ${WHITE}Arguments:${NC}"
|
|
20
|
-
echo -e "${GREY}│${NC} stack Name of the new stack to create"
|
|
21
|
-
echo -e "${GREY}│${NC}"
|
|
22
|
-
echo -e "${GREY}│${NC} ${WHITE}Options:${NC}"
|
|
23
|
-
echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
|
|
24
|
-
echo -e "${GREY}└${NC}"
|
|
25
|
-
exit 0
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
main() {
|
|
29
|
-
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
|
30
|
-
show_help
|
|
31
|
-
fi
|
|
32
|
-
|
|
33
|
-
local stack="$1"
|
|
34
|
-
|
|
35
|
-
echo -e "${GREY}┌${NC}"
|
|
36
|
-
echo -e "${GREY}│${NC} ${WHITE}canon tooling create${NC}"
|
|
37
|
-
|
|
38
|
-
if [ -z "$stack" ]; then
|
|
39
|
-
ask "Stack name?" "stack"
|
|
40
|
-
fi
|
|
41
|
-
|
|
42
|
-
if [ -z "$stack" ]; then
|
|
43
|
-
log_error "Stack name is required"
|
|
44
|
-
fi
|
|
45
|
-
|
|
46
|
-
if is_tooling_stack_excluded "$stack"; then
|
|
47
|
-
log_error "Reserved name: $stack. Managed by \`canon claude\`, not \`canon tooling\`."
|
|
48
|
-
fi
|
|
49
|
-
|
|
50
|
-
local dest="$PROJECT_ROOT/tooling/$stack"
|
|
51
|
-
|
|
52
|
-
if [ -d "$dest" ]; then
|
|
53
|
-
log_error "Stack already exists: $stack"
|
|
54
|
-
fi
|
|
55
|
-
|
|
56
|
-
log_step "Creating"
|
|
57
|
-
log_add "tooling/$stack/configs/"
|
|
58
|
-
log_add "tooling/$stack/seeds/"
|
|
59
|
-
log_add "tooling/$stack/manifest.toml"
|
|
60
|
-
log_add "tooling/$stack/reference.md"
|
|
61
|
-
|
|
62
|
-
select_option "Create stack at tooling/$stack?" "Yes" "No"
|
|
63
|
-
|
|
64
|
-
if [ "$SELECTED_OPTION" = "No" ]; then
|
|
65
|
-
log_warn "Cancelled"
|
|
66
|
-
exit 0
|
|
67
|
-
fi
|
|
68
|
-
|
|
69
|
-
log_step "Applying"
|
|
70
|
-
|
|
71
|
-
mkdir -p "$dest/configs"
|
|
72
|
-
log_add "tooling/$stack/configs/"
|
|
73
|
-
|
|
74
|
-
mkdir -p "$dest/seeds"
|
|
75
|
-
log_add "tooling/$stack/seeds/"
|
|
76
|
-
|
|
77
|
-
cat >"$dest/manifest.toml" <<EOF
|
|
78
|
-
[stack]
|
|
79
|
-
name = "$stack"
|
|
80
|
-
extends = ""
|
|
81
|
-
runtime = ""
|
|
82
|
-
scaffold = ""
|
|
83
|
-
|
|
84
|
-
[dependencies.dev]
|
|
85
|
-
packages = []
|
|
86
|
-
|
|
87
|
-
[scripts]
|
|
88
|
-
"script-key" = "command --flag"
|
|
89
|
-
|
|
90
|
-
[gitignore]
|
|
91
|
-
"# $stack" = ["pattern/", ".file"]
|
|
92
|
-
EOF
|
|
93
|
-
log_add "tooling/$stack/manifest.toml"
|
|
94
|
-
cat >"$dest/reference.md" <<EOF
|
|
95
|
-
# Tooling $stack reference
|
|
96
|
-
|
|
97
|
-
## Overview
|
|
98
|
-
|
|
99
|
-
[One or two sentences: what this stack provides and its purpose.]
|
|
100
|
-
|
|
101
|
-
EOF
|
|
102
|
-
log_add "tooling/$stack/reference.md"
|
|
103
|
-
|
|
104
|
-
trap - EXIT
|
|
105
|
-
echo -e "${GREY}└${NC}\n"
|
|
106
|
-
echo -e "${GREEN}✓ Stack created${NC}"
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
main "$@"
|