@erclx/canon 4.97.0 → 4.99.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 (38) hide show
  1. package/README.md +29 -27
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/SKILL.md +6 -4
  4. package/claude/skills/draft-and-pick/REQUIREMENT.md +3 -1
  5. package/claude/skills/draft-and-pick/SKILL.md +3 -2
  6. package/claude/skills/draft-and-pick/references/frame.html +118 -0
  7. package/claude/skills/draft-ready/SKILL.md +3 -3
  8. package/claude/skills/git-pr/SKILL.md +41 -21
  9. package/claude/skills/test-craft/REQUIREMENT.md +1 -1
  10. package/claude/skills/test-first/REQUIREMENT.md +1 -1
  11. package/claude/skills/ui-checklist/REQUIREMENT.md +49 -0
  12. package/claude/skills/ui-checklist/SKILL.md +71 -0
  13. package/claude/skills/ux-audit/REQUIREMENT.md +1 -1
  14. package/claude/skills/ux-measure/REQUIREMENT.md +1 -1
  15. package/docs/agents/capture.md +2 -2
  16. package/docs/agents/demo.md +3 -1
  17. package/docs/agents/pr-evidence.md +34 -11
  18. package/docs/agents/pr-preview.md +4 -3
  19. package/docs/agents/tasks.md +3 -1
  20. package/docs/workflow/ai-workflow.md +4 -4
  21. package/docs/workflow/visual-design-workflow.md +2 -2
  22. package/package.json +1 -1
  23. package/scripts/core/regen-hero.sh +33 -1
  24. package/src/claude/cases/workflow.ts +3 -2
  25. package/src/commands/demo.ts +4 -3
  26. package/src/commands/pr.ts +53 -1
  27. package/src/commands/tasks.ts +16 -0
  28. package/src/demo/compile.ts +20 -0
  29. package/src/demo/drive.ts +1 -0
  30. package/src/migrate/skill-names.ts +14 -6
  31. package/src/pr/evidence.ts +36 -1
  32. package/src/sandbox/expect.ts +2 -1
  33. package/src/slides/styles.ts +28 -3
  34. package/src/tasks/archive.ts +115 -3
  35. package/standards/ready.md +2 -2
  36. package/standards/tasks.md +4 -2
  37. package/claude/skills/ui-test/REQUIREMENT.md +0 -48
  38. package/claude/skills/ui-test/SKILL.md +0 -87
@@ -18,9 +18,9 @@ canon capture https://example.com --selector .window --out preview.png
18
18
 
19
19
  ## What this repository captures
20
20
 
21
- A capture set here spans two folders. `assets/captures/` holds five sources and the template each is written from, and `assets/evidence/` holds the five images a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the five is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
21
+ A capture set here spans two folders. `assets/captures/` holds three sources and the template each is written from, and `assets/evidence/` holds their images and the built-page capture a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the three sources is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
22
22
 
23
- Three of the five take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` and `task-board.html` are the two exceptions: the first holds terminal text from a real run and the second holds a hand-frozen snapshot of a gitignored board, each in its template rather than derived from a live catalog at build time.
23
+ Two of the three take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` is the exception, since it holds terminal text from a real run in its template rather than derived from a live catalog at build time. `assets/evidence/arrival.png` is the one image with no source here: it is the built page's `.fold` element captured in dark mode against a local `web/` preview, since `canon capture` takes no color scheme and the page follows the browser's default of light, so it is retaken by hand when the page's first screen changes.
24
24
 
25
25
  `assets/captures/` is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source nested a further folder down is skipped by all three with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/captures/` no longer mixes markup in with the images.
26
26
 
@@ -24,13 +24,15 @@ So `canon demo compile` writes a second artifact rather than adding fields to a
24
24
 
25
25
  The plan is committed, not scratch. Its timing is a starting point tuned by watching a recording, and the draft cannot reproduce a tuned value, so a recompile over an existing plan refuses and names `--force`.
26
26
 
27
+ A plan can carry an optional `colorScheme` of `light` or `dark`, which the recording context starts in so a page following the system preference paints that scheme with no click beat of its own. An absent field keeps the engine's default, which is light, and `compile` never seeds it. Any other value makes the plan unreadable. A page keyed on a stored theme rather than the preference ignores the field.
28
+
27
29
  `record-screencast` is the routed way to run both verbs from a draft path: it compiles only when no plan exists yet, then runs, stopping to report any field still unresolved rather than guessing one.
28
30
 
29
31
  | Option | Behavior |
30
32
  | --------------- | ----------------------------------------------------------------- |
31
33
  | `--out <dir>` | Directory the plan and its output paths point at, default `demos` |
32
34
  | `--slug <slug>` | Plan name, defaulting to the draft filename |
33
- | `--force` | Overwrite an existing plan, losing any timing tuned by hand |
35
+ | `--force` | Overwrite an existing plan, losing any field set by hand |
34
36
  | `--json` | Add a record on stdout carrying the beats and what is unfilled |
35
37
 
36
38
  ## What a run does
@@ -46,17 +46,18 @@ image or marks the case new.
46
46
 
47
47
  `reason` on the record is what a caller branches on, not the exit code:
48
48
 
49
- | Reason | What it means |
50
- | -------------------- | ----------------------------------------------------------------------------- |
51
- | `ok` | A body was rendered. `commentId` is set when a marked comment already exists. |
52
- | `no-evidence` | Nothing in the diff carries an `evidence/` segment. An ordinary silent no-op. |
53
- | `gh-missing` | `gh` is not on the path, so no pull request could be resolved. |
54
- | `gh-failed` | `gh` could not answer for this repository or branch, or read its comments. |
55
- | `no-branch` | The pull request carries no head branch name. |
56
- | `no-object-head` | The pull request object reported no head commit. |
57
- | `no-base` | No base resolves against the trunk. |
58
- | `unreadable-tree` | git could not read the tree at the base commit. |
59
- | `unreadable-changes` | git could not list what this branch changed. |
49
+ | Reason | What it means |
50
+ | ---------------------- | ----------------------------------------------------------------------------- |
51
+ | `ok` | A body was rendered. `commentId` is set when a marked comment already exists. |
52
+ | `no-evidence` | Nothing in the diff carries an `evidence/` segment. An ordinary silent no-op. |
53
+ | `unreadable-checklist` | `--checklist` named a path that could not be read, or one holding nothing. |
54
+ | `gh-missing` | `gh` is not on the path, so no pull request could be resolved. |
55
+ | `gh-failed` | `gh` could not answer for this repository or branch, or read its comments. |
56
+ | `no-branch` | The pull request carries no head branch name. |
57
+ | `no-object-head` | The pull request object reported no head commit. |
58
+ | `no-base` | No base resolves against the trunk. |
59
+ | `unreadable-tree` | git could not read the tree at the base commit. |
60
+ | `unreadable-changes` | git could not list what this branch changed. |
60
61
 
61
62
  `no-evidence` is not a refusal a caller reports. A project on a stack that
62
63
  carries no evidence path, such as `base` or `python`, hits this reason on
@@ -107,6 +108,28 @@ no evidence in the diff and an address in hand, the body is the address and the
107
108
  marker alone, reported as `ok`. `canon docs pr-preview` covers where the
108
109
  address comes from.
109
110
 
111
+ ## The checklist line
112
+
113
+ `--checklist <path>` closes the body with a visual checklist, below the
114
+ comparison it annotates, so one comment carries the preview address, the
115
+ screenshots, and what a reviewer has to look at. The file is what
116
+ `canon:ui-checklist` writes to `.canon/tmp/handoff/ui-checklist/<slug>.md`, and
117
+ `git-pr` passes it here rather than posting it as a second comment.
118
+
119
+ A run without the option carries forward whatever checklist the marked comment
120
+ already holds, the same way the preview address is carried. That is what keeps
121
+ `git-followup`'s re-render after a push from wiping boxes a reviewer has
122
+ already ticked, since `git-pr` deletes the handoff once the first post reports
123
+ success and no later call has a file to pass.
124
+
125
+ The option does not decide `no-evidence`. A branch with a checklist and no
126
+ changed evidence image still reports `no-evidence`, and the caller posts the
127
+ checklist on its own, which is what it did before this option existed. A path
128
+ that cannot be read, or one holding nothing, refuses as
129
+ `unreadable-checklist` rather than rendering without it: the caller deletes the
130
+ handoff once a post succeeds, so a silently dropped checklist is the only copy
131
+ gone.
132
+
110
133
  ## What a collapsed comment still leaves to GitHub
111
134
 
112
135
  GitHub already draws its own before-and-after comparison on the Files Changed
@@ -81,13 +81,14 @@ going, so the preview may still land after the verb has given up on it.
81
81
 
82
82
  `canon pr evidence --preview <url>` puts `**Preview:** <url>` on the first line
83
83
  of the evidence body. With no evidence image in the diff, the body is that line
84
- and the trailing marker alone, so a pull request with a checklist and no
85
- screenshots still gets one comment a later call can find and edit.
84
+ and the trailing marker alone, so a pull request whose screenshots did not
85
+ change still gets one comment a later call can find and edit.
86
86
 
87
87
  A later `canon pr evidence` run without `--preview` reads the address off the
88
88
  marked comment and carries it into the new body. `git-followup` re-renders the
89
89
  comment after every push, and without that carry the first push would delete
90
- the link.
90
+ the link. A visual checklist folded in through `--checklist` carries the same
91
+ way, which is what keeps that push from wiping boxes a reviewer already ticked.
91
92
 
92
93
  ## Removing a preview
93
94
 
@@ -54,7 +54,9 @@ An outcome whose body is struck reads as cut rather than open or closed, whateve
54
54
 
55
55
  The task carries its plan with it. When the closing task is the last live one whose `Plan:` line resolves onto that file, the plan moves to `.canon/plans/archive/` under its own name and the archived task's line is rewritten as `Plan: [feature-<slug>](../../plans/archive/feature-<slug>.md)`, a folder deeper than the live task wrote it. The `plan` field on the success record carries that `from` and `to`, and is `null` when nothing moved.
56
56
 
57
- A plan several tasks share stays where it is, and the task archives anyway. Moving it on the first task to close strands every sibling's pointer at a path that has gone, and `.canon/plans/` is gitignored so no history recovers the target. A `Plan:` line resolving to no file leaves the plan alone too, since a pointer somebody typed wrong is not a plan to move and holding the whole archive over it would park the board behind a repair the merge cannot make.
57
+ The ready folder the task's `Ready:` line names moves with the plan to `.canon/ready/archive/`, falling back to a `.canon/ready/<nn>-<slug>/` path in the plan when the line is absent. The `Ready:` line and the plan's literal path to the folder are retargeted, and the `ready` field on the success record carries `from` and `to`, or `null` when nothing moved. A folder that resolves to nothing, sits already archived, or meets a taken destination moves nothing and refuses nothing.
58
+
59
+ A plan several tasks share stays where it is, and its ready folder with it, and the task archives anyway. Moving it on the first task to close strands every sibling's pointer at a path that has gone, and `.canon/plans/` is gitignored so no history recovers the target. A `Plan:` line resolving to no file leaves the plan alone too, since a pointer somebody typed wrong is not a plan to move and holding the whole archive over it would park the board behind a repair the merge cannot make.
58
60
 
59
61
  The merge is what settles a plan. `.husky/post-merge` reads the pull request number off the squash subject and calls this verb, so both halves close in one act with nobody naming a file. That is why the move sits inside this verb rather than in a second call the hook would make after it, which could leave the task archived and the plan live.
60
62
 
@@ -58,7 +58,7 @@ Work in Claude Code directly. It reads `CLAUDE.md` automatically and has full fi
58
58
  - When the current state is unmeasured and more than one approach is live, invoke `canon:plan-groundwork` first. It opens a track folder under `.canon/groundwork/<nn>-<slug>/` and ends in a decision, which may be to do nothing. Skip it when the approach is already settled.
59
59
  - Invoke `canon:plan-feature` to scan for code-level conflicts and ambiguities, confirm approach before proceeding
60
60
  - Implement the feature, then Claude Code runs the commands defined in `CLAUDE.md`, fixes failures, and iterates until all pass
61
- - For UI changes, invoke `canon:ui-test` to route each change to its test layer and run the tests it writes, with Playwright kept for journeys
61
+ - For UI changes, invoke `canon:ui-checklist` to write what a reviewer has to look at and name any behavior shipping without a test
62
62
  End the session once the feature works and tests pass. Invoke `canon:docs-fold` to capture any decisions made during implementation before closing.
63
63
 
64
64
  The routing test is whether the repository can answer an item today. A session grepping handles the yes, and a groundwork track handles the no.
@@ -81,7 +81,7 @@ When features are independent, run them in parallel instead of sequentially. Use
81
81
 
82
82
  - Create a worktree per feature, then start a Claude Code session in each
83
83
  - Invoke `canon:plan-feature` in each session. Plans land at the main worktree root as `.canon/plans/feature-<slug>.md`, one per feature, no collisions. Small features stay in chat and skip the file.
84
- - Implement, verify, and review each feature independently. `review-branch` writes a per-branch report at the main worktree root (`review/branch-<slug>.md`), and `ui-test` writes a per-branch checklist handoff there too (`tmp/handoff/ui-checklist/<slug>.md`) that `git-pr` posts to the pull request and removes, so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
84
+ - Implement, verify, and review each feature independently. `review-branch` writes a per-branch report at the main worktree root (`review/branch-<slug>.md`), and `ui-checklist` writes a per-branch checklist handoff there too (`tmp/handoff/ui-checklist/<slug>.md`) that `git-pr` posts to the pull request and removes, so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
85
85
  - Ship each worktree separately with `canon:git-ship`
86
86
  - For full autonomy per worktree, invoke `canon:auto-ship` instead of the manual chain. Approve the plan, walk away, come back to a pull request the chain marked as a draft and then read the flag back on. The mark says the work has had no review yet, and it holds no window, since readying a pull request to merge lifts it directly, an act reserved to the operator or to the controlling session that closed the review.
87
87
 
@@ -174,7 +174,7 @@ The receipt is collected once every item on it has been decided, and it survives
174
174
 
175
175
  ### UI polish
176
176
 
177
- Verify the change manually in the browser. Invoke `canon:ui-test` if you need tests at the right layer and a visual verification checklist for the session. For the fix itself, describe the change in Claude Code directly.
177
+ Verify the change manually in the browser. Invoke `canon:ui-checklist` if you need a written list of what to look at. For the fix itself, describe the change in Claude Code directly.
178
178
 
179
179
  ### Quick fix
180
180
 
@@ -235,7 +235,7 @@ This section is the corpus the coverage claim is measured against: every name `c
235
235
  | `canon:test-first` | Before implementing a planned change, to run its test red, green, then refactor |
236
236
  | `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
237
237
  | `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
238
- | `canon:ui-test` | After a UI change, to generate layered tests and a visual checklist |
238
+ | `canon:ui-checklist` | After a UI change, to write what to look at and name what ships untested |
239
239
 
240
240
  ### Check the work before it leaves the branch
241
241
 
@@ -31,13 +31,13 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
31
31
  ### Tools
32
32
 
33
33
  - None beyond Claude Code itself
34
- - Playwright CLI optional for verifying journeys across routes and browser-only behavior. See [`ui-test`](../../claude/skills/ui-test/SKILL.md).
34
+ - Playwright CLI optional for verifying journeys across routes and browser-only behavior. See [`test-craft`](../../claude/skills/test-craft/SKILL.md) for which layer a journey belongs at.
35
35
 
36
36
  ### Skills
37
37
 
38
38
  - `canon:sketch-design` to trace a design direction from reference images or URLs, before `design-extract`'s greenfield path proposes from a personality paragraph alone
39
39
  - `canon:design-extract` to draft `canon/DESIGN.md`, from existing project signals or from requirements alone on day one
40
- - `canon:ui-test` for layered test generation after UI changes
40
+ - `canon:ui-checklist` for what a reviewer has to look at after UI changes, and what ships without a test
41
41
  - `canon:ux-audit` for UX gap detection on existing surfaces
42
42
  - `canon:ux-measure` for what a running surface costs to paint, read against published thresholds
43
43
  - `canon:draft-and-pick` for a call settled by looking, drafting several candidates onto one page and taking your pick
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.97.0",
4
+ "version": "4.99.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -77,8 +77,11 @@ GOV_JSON="$(catalog gov list)"
77
77
  STANDARDS_JSON="$(catalog standards list)"
78
78
  SNIPPETS_JSON="$(catalog snippets list)"
79
79
  TOOLING_JSON="$(catalog tooling list)"
80
+ # The catalog ledger names three commands, and the registered set is only
81
+ # readable from the root help, which lists one per line under `Commands:`.
82
+ COMMANDS_HELP="$(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts --help 2>/dev/null)"
80
83
 
81
- for payload in "$COUNTS_JSON" "$SKILLS_JSON" "$GOV_JSON" "$STANDARDS_JSON" "$SNIPPETS_JSON" "$TOOLING_JSON"; do
84
+ for payload in "$COUNTS_JSON" "$SKILLS_JSON" "$GOV_JSON" "$STANDARDS_JSON" "$SNIPPETS_JSON" "$TOOLING_JSON" "$COMMANDS_HELP"; do
82
85
  if [ -z "$payload" ]; then
83
86
  echo "regen-hero: a catalog returned nothing, refusing to write a zeroed hero" >&2
84
87
  exit 1
@@ -98,6 +101,7 @@ printf '%s' "$GOV_JSON" >"$PAYLOAD_DIR/gov.json"
98
101
  printf '%s' "$STANDARDS_JSON" >"$PAYLOAD_DIR/standards.json"
99
102
  printf '%s' "$SNIPPETS_JSON" >"$PAYLOAD_DIR/snippets.json"
100
103
  printf '%s' "$TOOLING_JSON" >"$PAYLOAD_DIR/tooling.json"
104
+ printf '%s' "$COMMANDS_HELP" >"$PAYLOAD_DIR/commands-help.txt"
101
105
  printf '%s' "$TOKEN_CSS" >"$PAYLOAD_DIR/tokens.css"
102
106
 
103
107
  export PAYLOAD_DIR
@@ -326,8 +330,36 @@ for (const [label, rows] of [
326
330
  }
327
331
  }
328
332
 
333
+ // The ledger names a few real entries per domain. Chosen by hand for the same
334
+ // reason FEATURED_SKILLS is, and asserted against the catalog so a rename fails
335
+ // the run rather than leaving a name the repository no longer ships.
336
+ const commandNames = readFileSync(PAYLOAD_DIR + "/commands-help.txt", "utf8")
337
+ .split("\n")
338
+ .map((line) => line.match(/^[│\s]{2,}(?<name>[a-z][a-z-]*)\s/)?.groups?.name)
339
+ .filter(Boolean)
340
+ const LEDGER = [
341
+ ["skills", skills.length, ["plan-feature", "role-orchestrator", "review-pr"], skills],
342
+ ["rules", rules.length, ["behavior", "memory", "testing"], rules],
343
+ ["standards", standards.length, ["markdown", "plan", "teach"], standards],
344
+ ["commands", commandCount, ["gov", "tasks", "design"], commandNames],
345
+ ["stacks", toolingStacks.length, ["astro", "python", "web"], toolingStacks.map((stack) => stack.name)],
346
+ ]
347
+ for (const [domain, , chosen, names] of LEDGER) {
348
+ const known = new Set(names)
349
+ const missing = chosen.filter((name) => !known.has(name))
350
+ if (missing.length > 0) {
351
+ console.error(`regen-hero: ledger ${domain} names missing from the catalog: ${missing.join(", ")}`)
352
+ process.exit(1)
353
+ }
354
+ }
355
+ const ledgerRows = LEDGER.map(
356
+ ([domain, count, chosen]) =>
357
+ ` <div class="row"><span class="count">${count}</span><span class="domain">${domain}</span><span class="names">${escape(chosen.join(", "))}</span></div>`,
358
+ ).join("\n")
359
+
329
360
  const values = {
330
361
  TOKENS: tokenCss,
362
+ LEDGER_ROWS: ledgerRows,
331
363
  GOV_STACK_ROWS: govStackRows,
332
364
  GOV_RULE_ROWS: govRuleRows,
333
365
  STANDARD_ROWS: standardRows,
@@ -126,8 +126,9 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
126
126
  expect: 'teach-workspace',
127
127
  },
128
128
  {
129
- prompt: 'Generate the Playwright tests I need after this UI change.',
130
- expect: 'ui-test',
129
+ prompt:
130
+ 'The restyle is finished. Write down what a reviewer has to look at by eye, and which behaviors have no test.',
131
+ expect: 'ui-checklist',
131
132
  },
132
133
  {
133
134
  prompt:
@@ -187,9 +187,10 @@ function runCompile(draftPath: string, opts: CompileOptions): number {
187
187
  if (existsSync(target) && !opts.force) {
188
188
  logStep('Plan')
189
189
  logError(`${display(target)} already exists`)
190
- // Stated rather than implied, because the value at risk is timing the
191
- // operator tuned by watching a recording and the draft cannot reproduce it.
192
- logWarn('Pass --force to overwrite it, losing any timing tuned by hand.')
190
+ // Stated rather than implied, because the value at risk is whatever the
191
+ // operator set by hand, such as timing tuned by watching a recording or a
192
+ // color scheme, and the draft can reproduce neither.
193
+ logWarn('Pass --force to overwrite it, losing any field set by hand.')
193
194
  outro()
194
195
  emit(opts.json, { plan: target, reason: 'plan-exists' })
195
196
  return 1
@@ -22,6 +22,7 @@ import {
22
22
  } from '@/pr/bijection'
23
23
  import { type CheckRunListing, collapseChecks } from '@/pr/checks'
24
24
  import {
25
+ findEvidenceChecklist,
25
26
  findEvidenceCommentId,
26
27
  findEvidencePreview,
27
28
  groupEvidence,
@@ -70,6 +71,7 @@ interface ReadOptions {
70
71
 
71
72
  interface EvidenceOptions extends ReadOptions {
72
73
  readonly preview?: string
74
+ readonly checklist?: string
73
75
  }
74
76
 
75
77
  interface PreviewOptions extends ReadOptions {
@@ -137,10 +139,13 @@ type EvidenceRefusal =
137
139
  | 'no-base'
138
140
  | 'unreadable-tree'
139
141
  | 'unreadable-changes'
142
+ | 'unreadable-checklist'
140
143
 
141
144
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
142
145
  'gh-failed':
143
146
  'gh could not answer for this repository. Name the pull request number.',
147
+ 'unreadable-checklist':
148
+ 'The file named by --checklist could not be read, or holds nothing. Rendering without it would drop the only copy, since the caller deletes the handoff once a post reports success.',
144
149
  'no-base': 'No base resolves against the trunk. Fetch origin and re-run.',
145
150
  'unreadable-tree':
146
151
  'git could not read the tree at the base commit, so no path could be judged added or changed.',
@@ -369,6 +374,10 @@ export function register(program: Command): void {
369
374
  '--preview <url>',
370
375
  'Open the body with this preview address, from canon pr preview',
371
376
  )
377
+ .option(
378
+ '--checklist <path>',
379
+ 'Close the body with this visual checklist file, from canon:ui-checklist',
380
+ )
372
381
  .addHelpText(
373
382
  'after',
374
383
  [
@@ -393,6 +402,14 @@ export function register(program: Command): void {
393
402
  'Without --preview, an address the marked comment already opens with',
394
403
  'is carried into the new body, so a re-render after a push keeps it.',
395
404
  '',
405
+ '--checklist closes the body with a visual checklist, below the',
406
+ 'comparison it annotates. A checklist the marked comment already',
407
+ 'carries is read back and carried forward the same way the preview',
408
+ 'address is, which keeps a re-render from wiping ticked boxes. It does',
409
+ 'not decide no-evidence: a branch with a checklist and no evidence',
410
+ 'image still reports no-evidence, and the caller posts the checklist',
411
+ 'on its own.',
412
+ '',
396
413
  'Exit codes:',
397
414
  ' 0 read, whether it produced a body or reported no-evidence',
398
415
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -401,6 +418,7 @@ export function register(program: Command): void {
401
418
  ' canon pr evidence --json',
402
419
  ' canon pr evidence 1341 --json',
403
420
  ' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
421
+ ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
404
422
  '',
405
423
  ].join('\n'),
406
424
  )
@@ -1214,6 +1232,23 @@ async function runEvidence(
1214
1232
 
1215
1233
  intro('canon pr evidence')
1216
1234
 
1235
+ // Read ahead of anything else, so a bad path refuses before a `gh` round
1236
+ // trip rather than after one.
1237
+ let suppliedChecklist: string | undefined
1238
+ if (opts.checklist !== undefined) {
1239
+ suppliedChecklist = await readFile(resolve(root, opts.checklist), 'utf8')
1240
+ .then((text) => text.trim() || undefined)
1241
+ .catch(() => undefined)
1242
+ if (suppliedChecklist === undefined) {
1243
+ return refuseWith(
1244
+ 'unreadable-checklist',
1245
+ EVIDENCE_REFUSALS['unreadable-checklist'],
1246
+ emitJson,
1247
+ root,
1248
+ )
1249
+ }
1250
+ }
1251
+
1217
1252
  const read = await readIdentity(root, number)
1218
1253
  if (read.kind === 'refused') {
1219
1254
  return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
@@ -1294,6 +1329,7 @@ async function runEvidence(
1294
1329
 
1295
1330
  let commentId: number | undefined
1296
1331
  let carriedPreview: string | undefined
1332
+ let carriedChecklist: string | undefined
1297
1333
  if (identity.number !== undefined) {
1298
1334
  const commentsRow = await gh(root, [
1299
1335
  'pr',
@@ -1327,11 +1363,20 @@ async function runEvidence(
1327
1363
  }
1328
1364
  commentId = findEvidenceCommentId(comments)
1329
1365
  carriedPreview = findEvidencePreview(comments)
1366
+ carriedChecklist = findEvidenceChecklist(comments)
1330
1367
  }
1331
1368
 
1332
1369
  const preview = opts.preview ?? carriedPreview
1370
+ const checklist = suppliedChecklist ?? carriedChecklist
1333
1371
  const states = grouped.kind === 'read' ? grouped.states : []
1334
- const body = renderEvidenceBody(states, repo, base, identity.head, preview)
1372
+ const body = renderEvidenceBody(
1373
+ states,
1374
+ repo,
1375
+ base,
1376
+ identity.head,
1377
+ preview,
1378
+ checklist,
1379
+ )
1335
1380
 
1336
1381
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
1337
1382
 
@@ -1340,6 +1385,13 @@ async function runEvidence(
1340
1385
  `${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
1341
1386
  )
1342
1387
  if (preview !== undefined) logInfo(`preview ${preview}`)
1388
+ if (checklist !== undefined) {
1389
+ logInfo(
1390
+ suppliedChecklist === undefined
1391
+ ? 'checklist carried forward from the marked comment'
1392
+ : 'checklist folded in below the comparison',
1393
+ )
1394
+ }
1343
1395
 
1344
1396
  outro()
1345
1397
 
@@ -146,6 +146,11 @@ export function register(program: Command): void {
146
146
  'plan, and the archived task keeps a working Plan: pointer at the new',
147
147
  'path. A plan several tasks share stays where it is.',
148
148
  '',
149
+ 'The ready folder the task names moves to .canon/ready/archive/ with its',
150
+ 'plan, and the Ready: line and the plan path to it are retargeted. A',
151
+ 'folder that does not resolve, or a destination already taken, moves',
152
+ 'nothing and refuses nothing.',
153
+ '',
149
154
  'Exit codes:',
150
155
  ' 0 the task was archived',
151
156
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -1310,6 +1315,11 @@ function report(
1310
1315
  logAdd(relative(root, outcome.plan.to))
1311
1316
  logInfo('retargeted the Plan: line')
1312
1317
  }
1318
+ if (outcome.ready) {
1319
+ logRemove(relative(root, outcome.ready.from))
1320
+ logAdd(relative(root, outcome.ready.to))
1321
+ logInfo('retargeted the Ready: line')
1322
+ }
1313
1323
  if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
1314
1324
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
1315
1325
  if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
@@ -1344,6 +1354,12 @@ function recordFor(
1344
1354
  to: relative(root, outcome.plan.to),
1345
1355
  }
1346
1356
  : null,
1357
+ ready: outcome.ready
1358
+ ? {
1359
+ from: relative(root, outcome.ready.from),
1360
+ to: relative(root, outcome.ready.to),
1361
+ }
1362
+ : null,
1347
1363
  closed: outcome.closed,
1348
1364
  cut: outcome.cut,
1349
1365
  }
@@ -107,11 +107,14 @@ export interface DemoStep {
107
107
  readonly note?: string
108
108
  }
109
109
 
110
+ export type ColorScheme = 'light' | 'dark'
111
+
110
112
  export interface DemoPlan {
111
113
  readonly slug: string
112
114
  readonly title: string
113
115
  readonly url: string
114
116
  readonly viewport: { readonly width: number; readonly height: number }
117
+ readonly colorScheme?: ColorScheme
115
118
  readonly output: { readonly video: string; readonly still: string }
116
119
  readonly pointer: { readonly travelMs: number; readonly typeDelayMs: number }
117
120
  readonly annotations: typeof ANNOTATIONS
@@ -229,6 +232,9 @@ export function parsePlan(text: string): PlanParse {
229
232
  steps.push(step.step)
230
233
  }
231
234
 
235
+ const colorScheme = parseColorScheme(raw.colorScheme)
236
+ if (colorScheme.status === 'failed') return colorScheme
237
+
232
238
  const slug = asText(raw.slug)
233
239
  const output = isRecord(raw.output) ? raw.output : {}
234
240
  const pointer = isRecord(raw.pointer) ? raw.pointer : {}
@@ -244,6 +250,7 @@ export function parsePlan(text: string): PlanParse {
244
250
  width: asNumber(viewport.width, VIEWPORT.width),
245
251
  height: asNumber(viewport.height, VIEWPORT.height),
246
252
  },
253
+ ...(colorScheme.value && { colorScheme: colorScheme.value }),
247
254
  output: {
248
255
  video: asText(output.video) || `demos/${slug || 'demo'}.webm`,
249
256
  still: asText(output.still) || `demos/${slug || 'demo'}.png`,
@@ -258,6 +265,19 @@ export function parsePlan(text: string): PlanParse {
258
265
  }
259
266
  }
260
267
 
268
+ function parseColorScheme(
269
+ value: unknown,
270
+ ):
271
+ | { status: 'ok'; value?: ColorScheme }
272
+ | { status: 'failed'; reason: string } {
273
+ if (value === undefined) return { status: 'ok' }
274
+ if (value === 'light' || value === 'dark') return { status: 'ok', value }
275
+ return {
276
+ status: 'failed',
277
+ reason: `colorScheme is ${JSON.stringify(value)}, which is not light or dark`,
278
+ }
279
+ }
280
+
261
281
  function parseStep(
262
282
  entry: unknown,
263
283
  index: number,
package/src/demo/drive.ts CHANGED
@@ -118,6 +118,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
118
118
 
119
119
  context = await browser.value.newContext({
120
120
  viewport: plan.viewport,
121
+ ...(plan.colorScheme && { colorScheme: plan.colorScheme }),
121
122
  // Pointed the opposite way from a test. A recording wants the motion the
122
123
  // interface was designed with, where a test wants it suppressed.
123
124
  reducedMotion: 'no-preference',
@@ -21,6 +21,13 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
21
21
  * reading in sequence with `draft-screencast` as one three-step pipeline with
22
22
  * no prefix forcing that reading.
23
23
  *
24
+ * One row renames a skill rather than retiring a prefix. `ui-test` became
25
+ * `ui-checklist` when the skill stopped writing tests and shrank to the
26
+ * checklist a reviewer reads, so the old name described a job it no longer
27
+ * does. `claude-ui-test` is retargeted onto the same destination rather than
28
+ * left pointing at the retired name, which lands a target on the oldest
29
+ * spelling in one pass instead of two.
30
+ *
24
31
  * Every name takes two words. Ten of these would have landed as a bare single
25
32
  * word under a plain strip, and a bare word such as `review` or `docs` is a
26
33
  * substring of ordinary prose with no token left for a later sweep to find.
@@ -56,7 +63,8 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
56
63
  'claude-standards-audit': 'standards-audit',
57
64
  'claude-tasks': 'task-board',
58
65
  'claude-teach': 'teach-workspace',
59
- 'claude-ui-test': 'ui-test',
66
+ 'claude-ui-test': 'ui-checklist',
67
+ 'ui-test': 'ui-checklist',
60
68
  'claude-ux-audit': 'ux-audit',
61
69
  'claude-ux-measure': 'ux-measure',
62
70
  'claude-worker': 'role-worker',
@@ -72,11 +80,11 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
72
80
  *
73
81
  * It protects nothing. The `aitk` sweep had to guard a sibling repository
74
82
  * whose name contained the token, and no string here contains a skill name
75
- * while meaning something else: the one overlap in the map is
76
- * `claude-intake` inside `claude-intake-answer`, which the longest-first
77
- * ordering settles rather than a protected form. Reading that off the output
78
- * would report a pass either way, since both rows land on a name opening with
79
- * `plan-intake`, so the ordering is asserted directly.
83
+ * while meaning something else. Two rows overlap another row: `claude-intake`
84
+ * inside `claude-intake-answer`, and `ui-test` inside `claude-ui-test`. The
85
+ * longest-first ordering settles both rather than a protected form. Reading
86
+ * either off the output would report a pass anyway, since each pair lands on
87
+ * one destination, so the ordering is asserted directly.
80
88
  *
81
89
  * It matches whole tokens. A skill name is a complete name rather than a word
82
90
  * stem, and the corpus holds one word that opens with a name and means
@@ -7,6 +7,13 @@ const MARKER_PREFIX = '<!-- pr-evidence:'
7
7
  /** How the first line of a body names the branch's preview deployment. */
8
8
  const PREVIEW_PREFIX = '**Preview:**'
9
9
 
10
+ /** The heading the checklist sits under, printed outside the delimiters so a carried checklist never doubles it. */
11
+ const CHECKLIST_HEADING = '## What to look at'
12
+
13
+ /** Delimiters around the checklist, so a re-render can read back what a reviewer has already ticked. */
14
+ const CHECKLIST_START = '<!-- pr-checklist:start -->'
15
+ const CHECKLIST_END = '<!-- pr-checklist:end -->'
16
+
10
17
  /** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
11
18
  const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
12
19
 
@@ -116,6 +123,9 @@ export function evidenceMarker(head: string): string {
116
123
  * A preview address opens the body, and with no states it is the whole body
117
124
  * apart from the marker, so a pull request whose screenshots did not change
118
125
  * still gets one comment a later call can find and edit in place.
126
+ *
127
+ * A checklist closes the body, below the comparison it annotates, so one
128
+ * comment carries the preview address, the screenshots, and what to look at.
119
129
  */
120
130
  export function renderEvidenceBody(
121
131
  states: readonly EvidenceState[],
@@ -123,11 +133,16 @@ export function renderEvidenceBody(
123
133
  base: string,
124
134
  head: string,
125
135
  preview?: string,
136
+ checklist?: string,
126
137
  ): string {
127
138
  const opening =
128
139
  preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`, '']
140
+ const closing =
141
+ checklist === undefined
142
+ ? []
143
+ : [CHECKLIST_HEADING, '', CHECKLIST_START, checklist, CHECKLIST_END, '']
129
144
  if (states.length === 0) {
130
- return [...opening, evidenceMarker(head)].join('\n')
145
+ return [...opening, ...closing, evidenceMarker(head)].join('\n')
131
146
  }
132
147
 
133
148
  const sections = states.map((entry) => {
@@ -157,6 +172,7 @@ export function renderEvidenceBody(
157
172
  '',
158
173
  ...sections,
159
174
  '',
175
+ ...closing,
160
176
  evidenceMarker(head),
161
177
  ].join('\n')
162
178
  }
@@ -204,3 +220,22 @@ export function findEvidencePreview(
204
220
  if (!first.startsWith(PREVIEW_PREFIX)) return undefined
205
221
  return first.slice(PREVIEW_PREFIX.length).trim() || undefined
206
222
  }
223
+
224
+ /**
225
+ * The checklist the marked comment already carries, read back out of its
226
+ * delimiters. The handoff file is deleted once posted, so every later render
227
+ * passes no checklist of its own, and carrying this one forward is what keeps
228
+ * a re-render after a push from wiping the boxes a reviewer has ticked.
229
+ */
230
+ export function findEvidenceChecklist(
231
+ comments: readonly EvidenceComment[],
232
+ ): string | undefined {
233
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
234
+ if (marked === undefined) return undefined
235
+ const start = marked.body.indexOf(CHECKLIST_START)
236
+ const end = marked.body.indexOf(CHECKLIST_END, start)
237
+ if (start === -1 || end === -1) return undefined
238
+ return (
239
+ marked.body.slice(start + CHECKLIST_START.length, end).trim() || undefined
240
+ )
241
+ }
@@ -178,7 +178,8 @@ function contentArray(value: unknown): ContentAssertion[] {
178
178
  // A bare key written below a `[[content]]` header belongs to that table in
179
179
  // TOML, not to the document, so a declaration listing `manual` or
180
180
  // `max_turns` after its content blocks parses clean and silently asserts
181
- // neither. The `claude/ui-test` arm shipped that way: a turn ceiling that
181
+ // neither. The arm then named `claude/ui-test`, now
182
+ // `claude/ui-checklist`, shipped that way: a turn ceiling that
182
183
  // never ran and five manual entries that never reached the unchecked count,
183
184
  // while `canon sandbox coverage` counted the arm as armed. Nothing at the
184
185
  // top level can see the difference, so the check belongs here.