@erclx/canon 4.60.0 → 4.62.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/claude-tasks/REQUIREMENT.md +2 -2
- package/claude/skills/claude-tasks/SKILL.md +4 -4
- package/claude/skills/setup-indexes/REQUIREMENT.md +3 -0
- package/claude/skills/setup-indexes/SKILL.md +8 -0
- package/claude/skills/setup-smoke/REQUIREMENT.md +38 -0
- package/claude/skills/setup-smoke/SKILL.md +53 -0
- package/claude/skills/setup-verify/REQUIREMENT.md +4 -4
- package/claude/skills/setup-verify/SKILL.md +3 -3
- package/docs/agents/commands.md +83 -79
- package/docs/agents/tasks.md +26 -0
- package/docs/target-projects.md +2 -0
- package/docs/workflow/ai-workflow.md +11 -10
- package/governance/rules/ui/440-surface-capture.md +4 -2
- package/package.json +1 -1
- package/src/claude/cases/setup.ts +5 -0
- package/src/commands/migrate.ts +136 -0
- package/src/commands/tasks.ts +84 -0
- package/src/migrate/scratch-evidence.ts +319 -0
- package/src/tasks/label.ts +110 -0
- package/tooling/astro/reference.md +6 -0
- package/tooling/web/configs/e2e/screenshot.ts +28 -1
- package/tooling/web/manifest.toml +1 -0
- package/tooling/web/reference.md +3 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { archiveDir, listTaskStems, tasksDir } from '@/tasks/archive'
|
|
4
|
+
|
|
5
|
+
/** Every label in the corpus today stops here before rolling to the next major. */
|
|
6
|
+
const MINOR_ROLLOVER = 9
|
|
7
|
+
|
|
8
|
+
/** The label a board with no live or archived task yet allocates first. */
|
|
9
|
+
const FIRST_LABEL: Label = { major: 1, minor: 0 }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Matches a task filename stem's leading label. Anchored, so a sibling such as
|
|
13
|
+
* `TASK-ARCHIVE` fails it outright, and a reserved stem such as `index` or
|
|
14
|
+
* `priority` never reaches it at all, since `listTaskStems` already filters
|
|
15
|
+
* those out before this pattern sees a stem.
|
|
16
|
+
*/
|
|
17
|
+
export const LABEL_PATTERN = /^v(\d+)\.(\d+)-/
|
|
18
|
+
|
|
19
|
+
interface Label {
|
|
20
|
+
readonly major: number
|
|
21
|
+
readonly minor: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NextLabel {
|
|
25
|
+
readonly ok: true
|
|
26
|
+
readonly label: string
|
|
27
|
+
/** The label this run was derived from, absent when neither folder holds one. */
|
|
28
|
+
readonly highest: string | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LabelRefused {
|
|
32
|
+
readonly ok: false
|
|
33
|
+
readonly reason: 'no-board'
|
|
34
|
+
readonly message: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type LabelOutcome = NextLabel | LabelRefused
|
|
38
|
+
|
|
39
|
+
function parseLabel(stem: string): Label | undefined {
|
|
40
|
+
const match = LABEL_PATTERN.exec(stem)
|
|
41
|
+
if (!match) return undefined
|
|
42
|
+
|
|
43
|
+
return { major: Number(match[1]), minor: Number(match[2]) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isHigher(candidate: Label, current: Label): boolean {
|
|
47
|
+
return candidate.major !== current.major
|
|
48
|
+
? candidate.major > current.major
|
|
49
|
+
: candidate.minor > current.minor
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatLabel(label: Label): string {
|
|
53
|
+
return `v${String(label.major).padStart(2, '0')}.${label.minor}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The label after the one given, rolling a minor of 9 to the next major rather
|
|
58
|
+
* than continuing to a second minor digit. Every one of the 587 labels measured
|
|
59
|
+
* across the live board and its archive on 2026-09-06 stops at a single digit,
|
|
60
|
+
* so this is the rollover the whole corpus already follows rather than a rule
|
|
61
|
+
* this verb introduces.
|
|
62
|
+
*/
|
|
63
|
+
function next(label: Label): Label {
|
|
64
|
+
return label.minor >= MINOR_ROLLOVER
|
|
65
|
+
? { major: label.major + 1, minor: 0 }
|
|
66
|
+
: { major: label.major, minor: label.minor + 1 }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reports the next unused phase label, read off the true maximum across
|
|
71
|
+
* `.canon/tasks/` and its `archive/` sibling together. A scan confined to the
|
|
72
|
+
* live board is blind to every label the archive already spent, which is what
|
|
73
|
+
* let two sessions hand out the same label within minutes of each other.
|
|
74
|
+
*
|
|
75
|
+
* It reports and never writes. Two sessions calling it in the same second can
|
|
76
|
+
* still take the same answer, since the board is gitignored files rather than
|
|
77
|
+
* a store with a lock, and `standards/versioning.md` permits free renumbering,
|
|
78
|
+
* so a collision costs a rename rather than anything worse. A duplicate label
|
|
79
|
+
* already sitting in the tree, and a gap left by a renumbering, both fold into
|
|
80
|
+
* the same max scan without needing a dedicated check.
|
|
81
|
+
*/
|
|
82
|
+
export async function nextLabel(root: string): Promise<LabelOutcome> {
|
|
83
|
+
const dir = tasksDir(root)
|
|
84
|
+
|
|
85
|
+
if (!existsSync(dir)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: 'no-board',
|
|
89
|
+
message: `No task board at ${relative(root, dir)}.`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const archive = archiveDir(root)
|
|
94
|
+
const dirs = existsSync(archive) ? [dir, archive] : [dir]
|
|
95
|
+
const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
|
|
96
|
+
|
|
97
|
+
const highest = stems
|
|
98
|
+
.map(parseLabel)
|
|
99
|
+
.filter((label): label is Label => label !== undefined)
|
|
100
|
+
.reduce<Label | undefined>(
|
|
101
|
+
(max, label) => (max === undefined || isHigher(label, max) ? label : max),
|
|
102
|
+
undefined,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
label: formatLabel(highest === undefined ? FIRST_LABEL : next(highest)),
|
|
108
|
+
highest: highest === undefined ? undefined : formatLabel(highest),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -59,6 +59,12 @@ Append to the `## Scripts` table:
|
|
|
59
59
|
|
|
60
60
|
In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``.
|
|
61
61
|
|
|
62
|
+
## Gitignore (extend)
|
|
63
|
+
|
|
64
|
+
`[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
|
|
65
|
+
|
|
66
|
+
- `"# Astro" = [".astro/"]`
|
|
67
|
+
|
|
62
68
|
## Scenario switcher
|
|
63
69
|
|
|
64
70
|
- `src/components/dev/scenarios.astro` ships as a golden config, always overwritten on sync, since it is toolkit-authored infrastructure rather than a file a project hand-edits.
|
|
@@ -9,11 +9,19 @@ interface CaptureCase {
|
|
|
9
9
|
route: string
|
|
10
10
|
width: number
|
|
11
11
|
height: number
|
|
12
|
+
evidence?: boolean
|
|
12
13
|
setup?: (page: Page) => Promise<void>
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const CASES: CaptureCase[] = [
|
|
16
|
-
{
|
|
17
|
+
{
|
|
18
|
+
section: 'home',
|
|
19
|
+
theme: 'default',
|
|
20
|
+
route: '/',
|
|
21
|
+
width: 1280,
|
|
22
|
+
height: 800,
|
|
23
|
+
evidence: true,
|
|
24
|
+
},
|
|
17
25
|
{
|
|
18
26
|
section: 'home',
|
|
19
27
|
theme: 'dark',
|
|
@@ -47,8 +55,12 @@ const OUT_DIR = path.join('screenshots', hostname)
|
|
|
47
55
|
|
|
48
56
|
const browser = await chromium.launch()
|
|
49
57
|
const consoleErrors: string[] = []
|
|
58
|
+
let ranCases = 0
|
|
50
59
|
|
|
51
60
|
for (const captureCase of CASES) {
|
|
61
|
+
if (captureCase.evidence && requireBaseUrl) continue
|
|
62
|
+
|
|
63
|
+
ranCases++
|
|
52
64
|
const context = await browser.newContext({
|
|
53
65
|
viewport: { width: captureCase.width, height: captureCase.height },
|
|
54
66
|
})
|
|
@@ -76,11 +88,26 @@ for (const captureCase of CASES) {
|
|
|
76
88
|
await page.screenshot({ path: file, fullPage: true })
|
|
77
89
|
console.log(`captured ${file}`)
|
|
78
90
|
|
|
91
|
+
if (captureCase.evidence) {
|
|
92
|
+
const evidenceDir = path.join('evidence', captureCase.section)
|
|
93
|
+
await mkdir(evidenceDir, { recursive: true })
|
|
94
|
+
const evidenceFile = path.join(evidenceDir, `${captureCase.theme}.png`)
|
|
95
|
+
await page.screenshot({ path: evidenceFile, fullPage: true })
|
|
96
|
+
console.log(`captured ${evidenceFile}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
await context.close()
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
await browser.close()
|
|
83
103
|
|
|
104
|
+
if (requireBaseUrl && ranCases === 0) {
|
|
105
|
+
console.error(
|
|
106
|
+
'every CASES entry is flagged evidence: true, so --require-base-url skipped all of them and checked nothing',
|
|
107
|
+
)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
if (checkConsoleClean && consoleErrors.length > 0) {
|
|
85
112
|
console.error('console errors detected:')
|
|
86
113
|
for (const error of consoleErrors) console.error(` ${error}`)
|
|
@@ -57,4 +57,5 @@ packages = [
|
|
|
57
57
|
"# Build" = ["dist/"]
|
|
58
58
|
"# Coverage" = ["coverage/"]
|
|
59
59
|
"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]
|
|
60
|
+
"# Screenshots" = ["screenshots/"]
|
|
60
61
|
"# VSCode" = [".vscode/*", "!.vscode/extensions.json", "!.vscode/settings.json"]
|
package/tooling/web/reference.md
CHANGED
|
@@ -12,7 +12,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
|
|
|
12
12
|
|
|
13
13
|
- `eslint.config.js`: flat config with `@eslint/js`, `typescript-eslint`, React hooks, import sort, check-file, vitest rules scoped to test files, `eslint-config-prettier` last.
|
|
14
14
|
- `src/test/setup.ts`: `@testing-library/jest-dom` import, `cleanup` after each test.
|
|
15
|
-
- `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
|
|
15
|
+
- `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. A case flagged `evidence: true` additionally writes `evidence/<section>/<theme>.png`, with no hostname segment. `--require-base-url` skips a flagged case entirely, so a production smoke run neither writes to the committed path nor counts an evidence route in its console-clean check, and exits 1 when that leaves zero cases run, so flagging every case cannot silence the production check without saying so. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
|
|
16
16
|
- `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
|
|
17
17
|
- `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
|
|
18
18
|
- `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
|
|
@@ -112,13 +112,14 @@ Append rows:
|
|
|
112
112
|
|
|
113
113
|
`governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a route changes. It fires on route and page files rather than on every component, so a shared component changing every screen fires nothing and the operator runs the capture by hand.
|
|
114
114
|
|
|
115
|
-
The
|
|
115
|
+
The sweep under `screenshots/` is ignored again, and only a flagged case's `evidence/` output tracks in git, so the first capture a scaffolded target runs after this change is the baseline it commits there.
|
|
116
116
|
|
|
117
117
|
## Gitignore (extend)
|
|
118
118
|
|
|
119
119
|
`[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
|
|
120
120
|
|
|
121
121
|
- `"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]`
|
|
122
|
+
- `"# Screenshots" = ["screenshots/"]`
|
|
122
123
|
|
|
123
124
|
## Verify script
|
|
124
125
|
|