@erclx/canon 5.64.0 → 5.66.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/git-pr/references/labels.md +6 -2
  4. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  5. package/claude/skills/git-pr-close/SKILL.md +55 -0
  6. package/claude/skills/git-pr-close/references/close.md +54 -0
  7. package/claude/skills/pr-evidence/SKILL.md +2 -2
  8. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  9. package/claude/skills/role-worker/SKILL.md +2 -2
  10. package/claude/skills/test-craft/references/e2e.md +1 -1
  11. package/docs/workflow/skill-map.md +1 -0
  12. package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
  13. package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
  14. package/governance/ledger/skills/codebase-layout/tests.md +26 -0
  15. package/governance/ledger/skills/review-craft/SKILL.md +61 -0
  16. package/governance/ledger/skills/review-craft/security.md +34 -0
  17. package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
  18. package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
  19. package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
  20. package/governance/ledger/skills/test-craft/SKILL.md +67 -0
  21. package/governance/ledger/skills/test-craft/component.md +49 -0
  22. package/governance/ledger/skills/test-craft/e2e.md +55 -0
  23. package/governance/ledger/skills/test-craft/unit.md +54 -0
  24. package/package.json +3 -3
  25. package/src/claude/cases/git.ts +4 -0
  26. package/src/claude/skills-audit.ts +7 -0
  27. package/src/commands/labels.ts +152 -4
  28. package/src/commands/pr.ts +263 -2
  29. package/src/commands/sandbox.ts +247 -2
  30. package/src/gate/measures.ts +3 -0
  31. package/src/gate/stages.ts +25 -0
  32. package/src/labels/apply.ts +10 -5
  33. package/src/labels/sync.ts +17 -0
  34. package/src/migrate/records.ts +6 -0
  35. package/src/pr/close.ts +152 -0
  36. package/src/pr/health.ts +56 -0
  37. package/src/pr/local.ts +49 -8
  38. package/src/pr/rest.ts +10 -2
  39. package/src/sandbox/replay/expected.ts +137 -0
  40. package/src/sandbox/replay/run.ts +348 -0
  41. package/src/sandbox/replay/verdict.ts +102 -0
  42. package/src/tooling/verify-stack.ts +4 -5
  43. package/tooling/astro/configs/playwright.config.ts +2 -2
  44. package/tooling/base/reference.md +1 -0
  45. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  46. package/tooling/nextjs/configs/playwright.config.ts +2 -2
  47. package/tooling/vite-react/configs/playwright.config.ts +2 -2
package/src/pr/rest.ts CHANGED
@@ -15,8 +15,16 @@ export interface RestPull {
15
15
  readonly number?: number
16
16
  readonly title?: string
17
17
  readonly body?: string | null
18
- readonly head?: { readonly ref?: string; readonly sha?: string }
19
- readonly base?: { readonly ref?: string }
18
+ readonly head?: {
19
+ readonly ref?: string
20
+ readonly sha?: string
21
+ /** Null when the fork the head lived in was deleted. */
22
+ readonly repo?: { readonly full_name?: string } | null
23
+ }
24
+ readonly base?: {
25
+ readonly ref?: string
26
+ readonly repo?: { readonly full_name?: string }
27
+ }
20
28
  readonly mergeable_state?: string
21
29
  readonly state?: string
22
30
  readonly draft?: boolean
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { StepResult } from '@/sandbox/replay/run'
4
+
5
+ /** Sits in a snapshot's folder beside its `.fixture` files and never reaches the replay tree. */
6
+ export const EXPECTED_FILE = 'expected.json'
7
+
8
+ export interface StepExpectation {
9
+ readonly exit: number | null
10
+ readonly findings: number
11
+ }
12
+
13
+ /**
14
+ * A snapshot's recorded answer, keyed by step name. It pins the exit and the
15
+ * finding count rather than the finding lines, so rewording a warning moves
16
+ * nothing while a seed sync newly calls missing still drifts.
17
+ */
18
+ export interface ReplayExpectation {
19
+ readonly steps: Readonly<Record<string, StepExpectation>>
20
+ }
21
+
22
+ const RECORD_HINT =
23
+ 'run bun src/cli.ts sandbox replay --record if the change meant it'
24
+
25
+ export function expectationOf(steps: readonly StepResult[]): ReplayExpectation {
26
+ return {
27
+ steps: Object.fromEntries(
28
+ steps.map((s) => [s.step, { exit: s.exit, findings: s.findings.length }]),
29
+ ),
30
+ }
31
+ }
32
+
33
+ function isStepExpectation(value: unknown): value is StepExpectation {
34
+ if (typeof value !== 'object' || value === null) return false
35
+ const { exit, findings } = value as Record<string, unknown>
36
+
37
+ return (
38
+ (exit === null || typeof exit === 'number') && typeof findings === 'number'
39
+ )
40
+ }
41
+
42
+ /**
43
+ * The expectation recorded in `dir`, or undefined when none is. Throws naming
44
+ * the file and the step when the file holds anything but the recorded shape.
45
+ */
46
+ export function readExpectation(dir: string): ReplayExpectation | undefined {
47
+ const path = join(dir, EXPECTED_FILE)
48
+ if (!existsSync(path)) return undefined
49
+
50
+ let parsed: unknown
51
+ try {
52
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
53
+ } catch {
54
+ throw new Error(`${path} does not parse as JSON`)
55
+ }
56
+ const steps =
57
+ typeof parsed === 'object' && parsed !== null
58
+ ? (parsed as Record<string, unknown>).steps
59
+ : undefined
60
+ if (typeof steps !== 'object' || steps === null)
61
+ throw new Error(`${path} carries no steps object`)
62
+ for (const [name, value] of Object.entries(steps))
63
+ if (!isStepExpectation(value))
64
+ throw new Error(
65
+ `${path}: step ${name} needs an exit and a findings count`,
66
+ )
67
+
68
+ return { steps: steps as ReplayExpectation['steps'] }
69
+ }
70
+
71
+ /** Written the way oxfmt leaves JSON, so a recorded file never fails the format stage. */
72
+ export function writeExpectation(
73
+ dir: string,
74
+ expectation: ReplayExpectation,
75
+ ): void {
76
+ writeFileSync(
77
+ join(dir, EXPECTED_FILE),
78
+ `${JSON.stringify(expectation, null, 2)}\n`,
79
+ )
80
+ }
81
+
82
+ function driftOf(
83
+ run: StepResult,
84
+ recorded: StepExpectation | undefined,
85
+ ): string | undefined {
86
+ if (recorded === undefined) return `no recorded answer, ${RECORD_HINT}`
87
+ if (run.exit !== recorded.exit)
88
+ return `exit ${run.exit}, recorded ${recorded.exit}`
89
+ if (run.findings.length !== recorded.findings)
90
+ return `${run.findings.length} findings, recorded ${recorded.findings}`
91
+
92
+ return undefined
93
+ }
94
+
95
+ /**
96
+ * Judges a snapshot's steps against its recorded answer. A step that already
97
+ * failed keeps its verdict, since a crash names more than a changed exit. A
98
+ * step either side lacks drifts, and with no expectation at all every step
99
+ * does, since a missing input read as a pass is what the replay exists to stop.
100
+ */
101
+ export function judgeDrift(
102
+ steps: readonly StepResult[],
103
+ expectation: ReplayExpectation | undefined,
104
+ ): StepResult[] {
105
+ if (expectation === undefined)
106
+ return steps.map((s) =>
107
+ s.verdict === 'ok'
108
+ ? {
109
+ ...s,
110
+ verdict: 'drifted',
111
+ detail: `no ${EXPECTED_FILE} for this snapshot, run bun src/cli.ts sandbox replay --record`,
112
+ }
113
+ : s,
114
+ )
115
+
116
+ const judged = steps.map((s) => {
117
+ if (s.verdict !== 'ok') return s
118
+ const detail = driftOf(s, expectation.steps[s.step])
119
+
120
+ return detail === undefined
121
+ ? s
122
+ : { ...s, verdict: 'drifted' as const, detail }
123
+ })
124
+ const ran = new Set(steps.map((s) => s.step))
125
+ const dropped = Object.keys(expectation.steps)
126
+ .filter((name) => !ran.has(name))
127
+ .map((name): StepResult => ({
128
+ step: name,
129
+ argv: [],
130
+ exit: null,
131
+ verdict: 'drifted',
132
+ detail: `recorded but did not run, ${RECORD_HINT}`,
133
+ findings: [],
134
+ }))
135
+
136
+ return [...judged, ...dropped]
137
+ }
@@ -0,0 +1,348 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import {
3
+ copyFileSync,
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ rmSync,
11
+ } from 'node:fs'
12
+ import { dirname, join, relative } from 'node:path'
13
+ import { gitEnv } from '@/git/env'
14
+ import { EXPECTED_FILE } from '@/sandbox/replay/expected'
15
+ import {
16
+ findingsOf,
17
+ judgeFormat,
18
+ judgeStep,
19
+ type ReplayVerdict,
20
+ type StepOutput,
21
+ } from '@/sandbox/replay/verdict'
22
+ import { FIXTURE_SUFFIX, listFixtureFiles } from '@/sandbox/scenario'
23
+
24
+ export interface ReplaySource {
25
+ readonly name: string
26
+ readonly path: string
27
+ /**
28
+ * A snapshot is a folder of `.fixture` files with no history, committed
29
+ * fresh in the copy. A checkout is a consumer's real repository, copied
30
+ * whole with its history so its gitignored records come along.
31
+ */
32
+ readonly kind: 'snapshot' | 'checkout'
33
+ }
34
+
35
+ export interface ReplayContext {
36
+ /** The canon checkout whose CLI, formatter, and golden config the replay runs. */
37
+ readonly checkoutRoot: string
38
+ /** The folder each replay's temp tree is made under, outside every repository. */
39
+ readonly tempRoot: string
40
+ }
41
+
42
+ export interface StepResult {
43
+ readonly step: string
44
+ readonly argv: readonly string[]
45
+ readonly exit: number | null
46
+ readonly verdict: ReplayVerdict
47
+ readonly detail?: string
48
+ /** The `!` lines the step printed, reported whatever the verdict. */
49
+ readonly findings: readonly string[]
50
+ }
51
+
52
+ export interface ReplayResult {
53
+ readonly name: string
54
+ readonly kind: ReplaySource['kind']
55
+ readonly steps: readonly StepResult[]
56
+ /** Paths sync added or changed, relative to the tree, sorted. */
57
+ readonly written: readonly string[]
58
+ }
59
+
60
+ /**
61
+ * The sync, then the reads a consumer runs after one, each `--json` read a
62
+ * contract on its own stdout. The reads run on the unstaged result, which is
63
+ * the state a consumer's session meets before anyone commits the sync.
64
+ */
65
+ const CLI_STEPS: readonly { step: string; args: readonly string[] }[] = [
66
+ { step: 'sync', args: ['sync', '.'] },
67
+ { step: 'sync --check', args: ['sync', '--check', '.', '--json'] },
68
+ { step: 'context audit', args: ['context', 'audit', '--json'] },
69
+ { step: 'markdown audit', args: ['markdown', 'audit', '--json'] },
70
+ { step: 'migrate status', args: ['migrate', 'status', '--json'] },
71
+ ]
72
+
73
+ /** Each step took seconds on both consumers in the spike, so five minutes is a hang. */
74
+ const STEP_TIMEOUT_MS = 300_000
75
+
76
+ /**
77
+ * A checkout copy skips installs at any depth and the linked worktrees other
78
+ * sessions hold, neither of which a sync or a health read touches.
79
+ */
80
+ function isCopied(rel: string): boolean {
81
+ return rel.split('/').at(-1) !== 'node_modules' && rel !== '.claude/worktrees'
82
+ }
83
+
84
+ /**
85
+ * Git's background maintenance creates and removes files such as
86
+ * `.git/objects/maintenance.lock` while a copy walks the checkout, and a file
87
+ * listed then gone throws ENOENT. A short bound covers every transient name
88
+ * git uses, where a filter on `*.lock` would cover one.
89
+ */
90
+ const COPY_ATTEMPTS = 3
91
+
92
+ export function copyCheckout(
93
+ source: ReplaySource,
94
+ tree: string,
95
+ copy: typeof cpSync = cpSync,
96
+ ): void {
97
+ for (let attempt = 1; ; attempt++) {
98
+ try {
99
+ copy(source.path, tree, {
100
+ recursive: true,
101
+ verbatimSymlinks: true,
102
+ filter: (from) => isCopied(relative(source.path, from)),
103
+ })
104
+ return
105
+ } catch (error) {
106
+ const vanished = (error as NodeJS.ErrnoException).code === 'ENOENT'
107
+ if (!vanished || attempt === COPY_ATTEMPTS) throw error
108
+ rmSync(tree, { recursive: true, force: true })
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Copies a source into `tree`. A snapshot's files lose their `.fixture`
115
+ * suffix and its recorded expectation stays behind, and a checkout copies whole rather than cloning, since a clone
116
+ * drops the gitignored `.canon/` records a consumer's health verbs read.
117
+ */
118
+ export function copySource(source: ReplaySource, tree: string): void {
119
+ if (source.kind === 'checkout') {
120
+ copyCheckout(source, tree)
121
+ return
122
+ }
123
+
124
+ for (const file of listFixtureFiles(source.path)) {
125
+ if (relative(source.path, file) === EXPECTED_FILE) continue
126
+ if (!file.endsWith(FIXTURE_SUFFIX))
127
+ throw new Error(
128
+ `Snapshot file missing the ${FIXTURE_SUFFIX} suffix: ${file}`,
129
+ )
130
+ const target = join(
131
+ tree,
132
+ relative(source.path, file).slice(0, -FIXTURE_SUFFIX.length),
133
+ )
134
+ mkdirSync(dirname(target), { recursive: true })
135
+ copyFileSync(file, target)
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Every child gets git's resolution variables stripped, since a pre-push hook
141
+ * exports `GIT_DIR` and a commit in the temp tree would otherwise land in the
142
+ * canon repository running the gate.
143
+ */
144
+ function childEnv(): NodeJS.ProcessEnv {
145
+ return { ...gitEnv(), CANON_NON_INTERACTIVE: '1', NO_COLOR: '1' }
146
+ }
147
+
148
+ function spawn(
149
+ command: string,
150
+ argv: readonly string[],
151
+ cwd: string,
152
+ json: boolean,
153
+ ): StepOutput {
154
+ const result = spawnSync(command, argv, {
155
+ cwd,
156
+ env: childEnv(),
157
+ encoding: 'utf8',
158
+ stdio: ['ignore', 'pipe', 'pipe'],
159
+ timeout: STEP_TIMEOUT_MS,
160
+ maxBuffer: 64 * 1024 * 1024,
161
+ })
162
+
163
+ return {
164
+ status: result.status,
165
+ signal: result.signal,
166
+ stdout: result.stdout ?? '',
167
+ stderr: result.stderr ?? result.error?.message ?? '',
168
+ json,
169
+ }
170
+ }
171
+
172
+ function git(tree: string, ...args: string[]): void {
173
+ const result = spawnSync(
174
+ 'git',
175
+ [
176
+ '-c',
177
+ 'user.name=canon-replay',
178
+ '-c',
179
+ 'user.email=replay@localhost',
180
+ '-c',
181
+ 'commit.gpgsign=false',
182
+ ...args,
183
+ ],
184
+ { cwd: tree, env: childEnv(), encoding: 'utf8' },
185
+ )
186
+ if (result.status !== 0)
187
+ throw new Error(
188
+ `git ${args.join(' ')} failed in the replay tree: ${result.stderr.trim()}`,
189
+ )
190
+ }
191
+
192
+ /** Every folder under the snapshot root, each one consumer's frozen managed paths. */
193
+ export function listSnapshots(dir: string): ReplaySource[] {
194
+ if (!existsSync(dir)) return []
195
+
196
+ return readdirSync(dir, { withFileTypes: true })
197
+ .filter((entry) => entry.isDirectory())
198
+ .map((entry) => entry.name)
199
+ .sort()
200
+ .map((name) => ({ name, path: join(dir, name), kind: 'snapshot' }))
201
+ }
202
+
203
+ export interface SourceRefusal {
204
+ readonly reason: 'not-found' | 'not-a-repo' | 'dirty'
205
+ readonly message: string
206
+ }
207
+
208
+ /**
209
+ * Why a named checkout cannot be replayed, or undefined when it can. A dirty
210
+ * tree is refused rather than copied, since sync refuses one and the replay
211
+ * would then read a tree nobody synced, reporting the refusal as a clean run.
212
+ */
213
+ export function checkoutRefusal(path: string): SourceRefusal | undefined {
214
+ if (!existsSync(path))
215
+ return { reason: 'not-found', message: `No folder at ${path}` }
216
+
217
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], {
218
+ cwd: path,
219
+ env: childEnv(),
220
+ encoding: 'utf8',
221
+ })
222
+ if (top.status !== 0 || top.stdout.trim() !== realpathSync(path))
223
+ return {
224
+ reason: 'not-a-repo',
225
+ message: `${path} is not the root of a git repository`,
226
+ }
227
+
228
+ const status = spawnSync('git', ['status', '--porcelain'], {
229
+ cwd: path,
230
+ env: childEnv(),
231
+ encoding: 'utf8',
232
+ })
233
+ if (status.stdout.trim() !== '')
234
+ return {
235
+ reason: 'dirty',
236
+ message: `${path} has uncommitted changes. Commit or stash them, then replay it.`,
237
+ }
238
+
239
+ return undefined
240
+ }
241
+
242
+ /** Added, modified, and untracked paths after the sync. A deletion has nothing to format. */
243
+ function writtenPaths(tree: string): string[] {
244
+ const result = spawnSync(
245
+ 'git',
246
+ ['status', '--porcelain', '-z', '--untracked-files=all'],
247
+ {
248
+ cwd: tree,
249
+ env: childEnv(),
250
+ encoding: 'utf8',
251
+ },
252
+ )
253
+
254
+ return result.stdout
255
+ .split('\0')
256
+ .filter((entry) => entry.length > 3 && !entry.slice(0, 2).includes('D'))
257
+ .map((entry) => entry.slice(3))
258
+ .sort()
259
+ }
260
+
261
+ /**
262
+ * Copies one source into a fresh temp tree, syncs it with the checkout's own
263
+ * CLI, and runs the health reads and the format check on the unstaged
264
+ * result. The tree is removed whatever the steps conclude.
265
+ */
266
+ export function replaySource(
267
+ source: ReplaySource,
268
+ context: ReplayContext,
269
+ ): ReplayResult {
270
+ mkdirSync(context.tempRoot, { recursive: true })
271
+ const tree = mkdtempSync(join(context.tempRoot, `${source.name}-`))
272
+ const cli = join(context.checkoutRoot, 'src', 'cli.ts')
273
+ const steps: StepResult[] = []
274
+
275
+ try {
276
+ copySource(source, tree)
277
+ if (source.kind === 'snapshot') {
278
+ git(tree, 'init', '-q', '-b', 'main')
279
+ git(tree, 'add', '-A')
280
+ git(tree, 'commit', '-q', '-m', 'snapshot')
281
+ }
282
+
283
+ for (const { step, args } of CLI_STEPS) {
284
+ const output = spawn('bun', [cli, ...args], tree, args.includes('--json'))
285
+ steps.push({
286
+ step,
287
+ argv: ['canon', ...args],
288
+ exit: output.status,
289
+ ...judgeStep(output),
290
+ findings: findingsOf(output.stderr),
291
+ })
292
+ }
293
+
294
+ const written = writtenPaths(tree)
295
+ steps.push(formatStep(context.checkoutRoot, tree, written))
296
+
297
+ return { name: source.name, kind: source.kind, steps, written }
298
+ } finally {
299
+ rmSync(tree, { recursive: true, force: true })
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Runs the checkout's oxfmt over what sync wrote, under the golden config canon
305
+ * ships to every target rather than oxfmt's defaults, which quote YAML
306
+ * frontmatter differently from every consumer and would fail clean files.
307
+ */
308
+ function formatStep(
309
+ checkoutRoot: string,
310
+ tree: string,
311
+ written: readonly string[],
312
+ ): StepResult {
313
+ const config = join(
314
+ checkoutRoot,
315
+ 'tooling',
316
+ 'base',
317
+ 'configs',
318
+ '.oxfmtrc.json',
319
+ )
320
+ const args = [
321
+ '-c',
322
+ config,
323
+ '--check',
324
+ '--no-error-on-unmatched-pattern',
325
+ ...written,
326
+ ]
327
+ const argv = ['oxfmt', '--check', '<written>']
328
+ if (written.length === 0)
329
+ return { step: 'format', argv, exit: 0, verdict: 'ok', findings: [] }
330
+
331
+ const output = spawn(oxfmtBinary(checkoutRoot), args, tree, false)
332
+
333
+ return {
334
+ step: 'format',
335
+ argv,
336
+ exit: output.status,
337
+ ...judgeFormat(output),
338
+ findings: [],
339
+ }
340
+ }
341
+
342
+ function oxfmtBinary(checkoutRoot: string): string {
343
+ return join(checkoutRoot, 'node_modules', '.bin', 'oxfmt')
344
+ }
345
+
346
+ export function hasFormatter(checkoutRoot: string): boolean {
347
+ return existsSync(oxfmtBinary(checkoutRoot))
348
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What one step of a replay concluded. A finding or a refusal is the verb
3
+ * working, so it reads `ok`. Only a run that broke rather than answered fails:
4
+ * a `crash` threw or died, an `unreadable` run answered in a shape a caller
5
+ * cannot parse, and an `unformatted` tree holds a file sync wrote that the
6
+ * formatter canon ships rejects. A `drifted` read answered a frozen snapshot
7
+ * differently from the answer recorded beside it, which `judgeDrift` decides.
8
+ */
9
+ export type ReplayVerdict =
10
+ | 'ok'
11
+ | 'crash'
12
+ | 'unreadable'
13
+ | 'unformatted'
14
+ | 'drifted'
15
+
16
+ export interface StepOutput {
17
+ /** Null when the process ended on a signal, a timeout included. */
18
+ readonly status: number | null
19
+ readonly signal: string | null
20
+ readonly stdout: string
21
+ readonly stderr: string
22
+ /** Whether the step asked for `--json`, which makes stdout a contract. */
23
+ readonly json: boolean
24
+ }
25
+
26
+ export interface Judgement {
27
+ readonly verdict: ReplayVerdict
28
+ readonly detail?: string
29
+ }
30
+
31
+ /** A JavaScript stack frame, `at fn (file:line:col)` or a bare `at file:line:col`. */
32
+ const STACK_FRAME = /^\s+at .*:\d+:\d+\)?$/m
33
+
34
+ /** The line naming what was thrown, `TypeError: ...` or a system code such as `ENOENT: ...`. */
35
+ const THROWN_LINE = /^(?:error: |[A-Z][A-Za-z]*Error: |E[A-Z]+: ).*$/m
36
+
37
+ function crashOf(output: StepOutput): Judgement | undefined {
38
+ if (output.signal !== null)
39
+ return { verdict: 'crash', detail: `killed by ${output.signal}` }
40
+ if (!STACK_FRAME.test(output.stderr)) return undefined
41
+
42
+ return {
43
+ verdict: 'crash',
44
+ detail: THROWN_LINE.exec(output.stderr)?.[0] ?? 'stack trace on stderr',
45
+ }
46
+ }
47
+
48
+ export function judgeStep(output: StepOutput): Judgement {
49
+ const crash = crashOf(output)
50
+ if (crash !== undefined) return crash
51
+ if (!output.json) return { verdict: 'ok' }
52
+
53
+ try {
54
+ JSON.parse(output.stdout)
55
+ } catch {
56
+ const first = output.stdout.trim().split('\n')[0]
57
+
58
+ return {
59
+ verdict: 'unreadable',
60
+ detail:
61
+ first === undefined || first === ''
62
+ ? 'empty stdout'
63
+ : `stdout does not parse: ${first.slice(0, 120)}`,
64
+ }
65
+ }
66
+
67
+ return { verdict: 'ok' }
68
+ }
69
+
70
+ /**
71
+ * The warnings a step printed, each line the CLI marks `!` on a stream that is
72
+ * not a terminal. They never fail the replay, and carrying them is what lets a
73
+ * reader see a finding the verdict passed, such as a seed sync calls missing.
74
+ */
75
+ export function findingsOf(stderr: string): string[] {
76
+ return stderr
77
+ .split('\n')
78
+ .filter((line) => line.startsWith('! '))
79
+ .map((line) => line.slice(2).trim())
80
+ }
81
+
82
+ /** oxfmt lists each file it would change as `<path> (<n>ms)` under `--check`. */
83
+ const OXFMT_FILE = /^(\S.*) \(\d+m?s\)$/
84
+
85
+ export function judgeFormat(output: StepOutput): Judgement {
86
+ const crash = crashOf(output)
87
+ if (crash !== undefined) return crash
88
+ if (output.status === 0) return { verdict: 'ok' }
89
+
90
+ const files = output.stdout
91
+ .split('\n')
92
+ .map((line) => OXFMT_FILE.exec(line.trim())?.[1])
93
+ .filter((file): file is string => file !== undefined)
94
+
95
+ return {
96
+ verdict: 'unformatted',
97
+ detail:
98
+ files.length > 0
99
+ ? files.join(', ')
100
+ : output.stderr.trim().split('\n')[0] || `exit ${output.status}`,
101
+ }
102
+ }
@@ -95,19 +95,18 @@ async function run({
95
95
  logStep(`Scaffolding ${stack}`)
96
96
  // Manifests carry shell syntax (`&&`, `cd`, quoted globs), so the command
97
97
  // runs under a shell rather than as a split argv.
98
- const scaffold = await shell(
99
- manifest.scaffold.replaceAll('{{name}}', name),
100
- runsDir,
101
- )
98
+ const scaffoldCommand = manifest.scaffold.replaceAll('{{name}}', name)
99
+ const scaffold = await shell(scaffoldCommand, runsDir)
102
100
  if (scaffold.failed) {
103
101
  logWarn('Scaffold failed')
102
+ process.stderr.write(`$ ${scaffoldCommand}\n`)
104
103
  writeTail(scaffold.all)
105
- state.isFailed = true
106
104
  }
107
105
 
108
106
  if (!existsSync(state.dir)) {
109
107
  return refuse(`Scaffold did not produce ${state.dir}`)
110
108
  }
109
+ record(state, 'Scaffold', !scaffold.failed)
111
110
 
112
111
  await shell(
113
112
  'git init -q && git add . && git commit -m "chore: verify scaffold" -q --no-verify --allow-empty',
@@ -14,8 +14,8 @@ export default defineConfig({
14
14
  trace: 'on-first-retry',
15
15
  baseURL,
16
16
  // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
17
- // It is a context option rather than a top-level one, so written beside
18
- // `baseURL` it type-checks nowhere and reaches no page.
17
+ // A top-level `use.reducedMotion` works from Playwright 1.50.
18
+ // `contextOptions` is the spelling that works on every version.
19
19
  contextOptions: { reducedMotion: 'reduce' },
20
20
  },
21
21
  projects: [
@@ -39,6 +39,7 @@ Seeds live in `tooling/base/seeds/`. Sync drops each once on first install and n
39
39
  - `.github/workflows/verify.yml` runs on pull requests targeting `main` and on `workflow_dispatch`. A multi-root project adds its own jobs here.
40
40
  - `.github/pull_request_template.md` has `## Summary`, `## Key changes`, `## Technical context`, and `## Testing`. A project adds its own checklist lines.
41
41
  - `canon/context/development/` holds the `setup` and `ci` entries under a folder index. Extend them with project-specific commands and workflows, and keep canonical rationale here.
42
+ - `canon/config/pr-labels.toml` is the starter pull request label map, covering what the toolkit installs. A project adds a row per surface of its own.
42
43
 
43
44
  ## Tool pairing
44
45
 
@@ -0,0 +1,41 @@
1
+ # Pull request domain labels.
2
+ #
3
+ # `git-pr` matches the paths a branch changed against the prefixes below and
4
+ # applies every label that matches. The rows here cover what the toolkit
5
+ # installs. Add a row per surface of your own, such as `app = ["src/"]`, and
6
+ # `canon labels audit` names any changed path no row reaches.
7
+ #
8
+ # Each label has to exist on the remote before a pull request can carry it.
9
+ # Run `canon labels sync` once to create the ones the remote lacks, and again
10
+ # after adding a row.
11
+ #
12
+ # The matching rule and the `[declined]` table are described in the `git-pr`
13
+ # skill's labels reference.
14
+
15
+ [domains]
16
+ tooling = [
17
+ ".github/",
18
+ ".husky/",
19
+ "scripts/",
20
+ ".gitignore",
21
+ ".lintstagedrc",
22
+ ".oxfmtrc.json",
23
+ ".prettierignore",
24
+ "commitlint.config.js",
25
+ "package.json",
26
+ "bun.lock",
27
+ "canon/config/",
28
+ ]
29
+ claude = [".claude/", "CLAUDE.md"]
30
+ docs = [
31
+ "README.md",
32
+ "docs/",
33
+ "canon/REQUIREMENTS.md",
34
+ "canon/DESIGN.md",
35
+ "canon/context/",
36
+ ]
37
+
38
+ # Paths deliberately left unlabelled, keyed by the reason.
39
+ [declined]
40
+ # Release automation opens its own pull request and applies its own label.
41
+ release-managed = ["CHANGELOG.md", ".release-please-manifest.json"]