@erclx/canon 5.66.0 → 5.68.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 (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/plan-feature/references/plan.md +1 -1
  11. package/claude/skills/plan-intake/references/intake.md +12 -2
  12. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
  13. package/claude/skills/pr-evidence/SKILL.md +7 -1
  14. package/claude/skills/pr-evidence/references/evidence.md +33 -3
  15. package/claude/skills/review-craft/references/ui.md +30 -10
  16. package/claude/skills/review-ui/SKILL.md +5 -4
  17. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  18. package/claude/skills/review-ui/references/driving.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  20. package/claude/skills/role-planner/SKILL.md +2 -2
  21. package/claude/skills/role-worker/SKILL.md +4 -6
  22. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  23. package/claude/skills/session-map/SKILL.md +8 -1
  24. package/claude/skills/session-map/references/session.md +19 -5
  25. package/claude/skills/ui-record/REQUIREMENT.md +36 -0
  26. package/claude/skills/ui-record/SKILL.md +114 -0
  27. package/claude/skills/ui-record/references/config.md +40 -0
  28. package/claude/skills/ui-record/references/handoff.md +67 -0
  29. package/claude/skills/ui-record/references/pr-clip.md +84 -0
  30. package/claude/skills/ui-record/references/take-craft.md +99 -0
  31. package/docs/agents/records.md +2 -0
  32. package/docs/workflow/parallel-features.md +1 -1
  33. package/docs/workflow/skill-map-on-request.md +1 -0
  34. package/docs/workflow/skill-map.md +1 -0
  35. package/docs/workflow/visual-design-workflow.md +2 -1
  36. package/package.json +5 -2
  37. package/src/audits/catalog.ts +5 -1
  38. package/src/browser/agent-cli.ts +68 -0
  39. package/src/claude/cases/workflow.ts +20 -0
  40. package/src/cli/help.ts +3 -0
  41. package/src/cli.ts +4 -0
  42. package/src/commands/browser.ts +102 -0
  43. package/src/commands/intake.ts +181 -1
  44. package/src/commands/pr.ts +252 -85
  45. package/src/commands/records.ts +18 -1
  46. package/src/commands/take.ts +302 -0
  47. package/src/context/requirements.ts +1 -11
  48. package/src/git/status.ts +73 -0
  49. package/src/intake/drive-check.ts +355 -0
  50. package/src/intake/folder.ts +1 -1
  51. package/src/markdown/scan.ts +10 -0
  52. package/src/pr/drive-copy.ts +97 -84
  53. package/src/pr/evidence.ts +208 -78
  54. package/src/pr/frames.ts +42 -4
  55. package/src/pr/pixels.ts +44 -0
  56. package/src/records/backup.ts +28 -4
  57. package/src/records/session-map.ts +75 -0
  58. package/src/records/validate.ts +64 -8
  59. package/src/take/camera.ts +309 -0
  60. package/src/take/encode.ts +207 -0
  61. package/src/take/film.ts +211 -0
  62. package/src/take/fixtures/fade.html +49 -0
  63. package/src/take/fixtures/fetch.html +38 -0
  64. package/src/take/log.ts +109 -0
  65. package/src/take/perform.ts +200 -0
  66. package/src/take/plan.ts +193 -0
  67. package/src/take/pointer.ts +168 -0
  68. package/src/take/reel.ts +215 -0
  69. package/src/take/screencast.ts +97 -0
  70. package/src/take/shell.ts +287 -0
  71. package/src/take/stepper.ts +255 -0
  72. package/src/take/zoom/NOTICE.md +45 -0
  73. package/src/take/zoom/math-utils.ts +69 -0
  74. package/src/take/zoom/region-utils.ts +55 -0
  75. package/src/take/zoom/spring.ts +133 -0
  76. package/src/tasks/archive.ts +1 -3
  77. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -0,0 +1,99 @@
1
+ ---
2
+ title: Take craft
3
+ description: The filming rules a take plan follows, the frame-stepping limits, and the pointer and zoom rules the camera relies on, each with its source
4
+ ---
5
+
6
+ # Take craft
7
+
8
+ Read at Step 3 of `ui-record`, before writing the first beat. `canon take --help` carries every step kind, the target forms, and each refusal code, so this file carries the judgment and leaves the vocabulary to the verb.
9
+
10
+ ## Contents
11
+
12
+ - [The plan](#the-plan)
13
+ - [One action per take](#one-action-per-take)
14
+ - [Frame-stepping limits](#frame-stepping-limits)
15
+ - [Targets](#targets)
16
+ - [The pointer](#the-pointer)
17
+ - [The zoom](#the-zoom)
18
+ - [Sources](#sources)
19
+
20
+ ## The plan
21
+
22
+ A plan holds the approved beats in order. Each beat names its setup, which runs before the first kept frame, and its steps, which are filmed.
23
+
24
+ ```json
25
+ {
26
+ "beats": [
27
+ {
28
+ "name": "Open the kit",
29
+ "setup": [{ "kind": "click", "target": "text=Design" }],
30
+ "steps": [
31
+ { "kind": "click", "target": "[aria-label='Open 01-kit']" },
32
+ { "kind": "wait", "ms": 900 }
33
+ ]
34
+ }
35
+ ]
36
+ }
37
+ ```
38
+
39
+ ## One action per take
40
+
41
+ - Film one action per beat, such as one click, one fill, or one scroll, so the edit can cut, reorder, or drop a beat without re-filming its neighbors
42
+ - Keep a take as long as its beat. The verb opens and closes each one on a 0.6 s hold, which gives the edit a still to cut on, so add no hold of your own around the action.
43
+ - Add a `wait` after an action whose result animates, loads, or settles, long enough for the result to finish on screen
44
+ - Reach the beat's start state through `setup`, since every beat opens the page fresh and nothing a setup step does is filmed
45
+ - Write the beat's `name` as the operator approved it, since it names the clip's folder
46
+
47
+ ## Frame-stepping limits
48
+
49
+ A step beat runs the page on a virtual clock, one frame per step. Most pages film this way, and some inputs are known to stall it.
50
+
51
+ - Typing films by stepping on a canvas board, though an earlier stepper stalled on it. Keep `fill` text short, and expect a fallback to the screencast for a beat whose frames stall past 2 s.
52
+ - A scroll has not been proven under frame stepping, and a smooth scroll can jump. Give a scroll its own beat so a fallback costs that beat alone.
53
+ - An animated GIF can freeze, so expect one to hold its first frame
54
+ - Set `"route": "screencast"` on a beat known to stall, rather than paying for the stall before the fallback
55
+
56
+ A fallback films the beat again from a fresh load, replaying its setup and the steps the stepper already ran. A beat whose steps persist in the app, such as a fill that renames something, would then start from the changed state, so set `"route": "screencast"` on it when it risks a stall.
57
+
58
+ A screencast beat films in real time at 1920x1080, which goes soft at the 1.8x zoom. Tell the operator which beats ran that way.
59
+
60
+ ### Reading a take back
61
+
62
+ Two rules hold once a beat is filmed.
63
+
64
+ - A stepped still is 3840x2160 and opens at 2000 wide. Crop it for text too small to read there, with `ffmpeg -v error -y -i <dir>/still.jpg -vf crop=<w>:<h>:<x>:<y> <dir>/crop.png` on the still's own pixels.
65
+ - Read when something happened from `log.json` and `camera.json`, both on the clip's clock, never from a clock printed in the page, which starts before the first kept frame
66
+
67
+ ## Targets
68
+
69
+ - Prefer a stable attribute, such as an `aria-label`, a `data-testid`, or an `id`, over a position like `:nth-child`
70
+ - Use `text=<words>` for a control whose only stable handle is its label
71
+ - Use a `move` to a point only for a target the page reveals on hover, and follow it with the click
72
+ - Read each target off the served page before writing it. The verb refuses `target-missing` rather than filming a guess, and a refusal costs a reload.
73
+
74
+ ## The pointer
75
+
76
+ - Draw no pointer in the page. The edit draws one from the log at a constant size, and two pointers on one frame leave the viewer guessing which one acted.
77
+ - Start from a state where the app paints no cursor of its own, since a painted one films into the clip and grows with every zoom
78
+ - Check that each click lands inside its target's box on the closing still. The verb aims at the box's center, and an element covering it takes the click instead.
79
+
80
+ ## The zoom
81
+
82
+ The camera track already carries the zoom, so these rules decide what to film for it to aim at.
83
+
84
+ - Earn a zoom with an action. The camera aims only at clicks and typing, and a wait, a move, or a scroll aims nowhere, so a result too small to read needs a click on it.
85
+ - Expect a scroll to end a zoom, since the content moves under the camera
86
+ - Expect a target filling most of the view to get less zoom, and one filling the page to get none
87
+ - The push reaches 90% of its depth in about 633 ms, inside the 1 s ceiling a push onto an action keeps
88
+
89
+ ## Sources
90
+
91
+ | Rule | Source |
92
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | Zoom on a click | Screen Studio's [auto zoom guide](https://screen.studio/guide/auto-zoom) keys its zoom on click positions |
94
+ | The 1 s ceiling | Material Design 3's [duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs) end at 1000 ms, the top of the ambient band |
95
+ | The 633 ms push | Twelve moves in a Screen Studio clip reached 90% in a median of 667 ms, measured when the camera was tuned |
96
+ | The typing and scroll risk | Takes of a canvas design board filmed while the stepper was built |
97
+ | Jumping scrolls and GIFs | [hasuwini77/demo-reel#28](https://github.com/hasuwini77/demo-reel/pull/28), read through a search summary rather than the page |
98
+
99
+ The pointer rules carry no number. One pointer and a click inside its box follow from the log recording each target's box.
@@ -28,6 +28,7 @@ printf '.canon/\n' >> .gitignore
28
28
  ```
29
29
 
30
30
  - `push` commits every top-level folder under `.canon/` except `tmp`, `ordinal-locks`, and `.records.git`, then pushes. New folders list under `added`.
31
+ - `push` backs up a video workspace under `video/` without its media, skipping `takes/`, `renders/`, `snapshots/`, `node_modules/`, and any `.mp4`, `.webm`, or `.mov` file.
31
32
  - `pull` restores the folders on a new machine after the setup above. It refuses rather than overwrite records the history lacks.
32
33
  - `size` reports one row per folder, heaviest first, with file count, bytes, and growth over 7 and 30 days. After a `pull` every file reads as new.
33
34
  - `prune-tmp` lists scratch untouched for 14 days and deletes it only with `--write`. It never offers `tmp/handoff/` or `tmp/pr/poll/`.
@@ -58,4 +59,5 @@ Branch on `reason` in the `--json` record, not the exit code.
58
59
 
59
60
  - `canon records ordinal <kind> <slug> --claim`: reserve the next intake or groundwork ordinal
60
61
  - `canon records validate <kind> [record] --help`: where a record disagrees with its standard, plus a `structure` report that never sets the exit
62
+ - `canon records validate sessions [stem]`: each session map's words against its cap, under a `length` report that never sets the exit
61
63
  - `canon records stale --help`: when the requirements and design records were last reviewed
@@ -32,7 +32,7 @@ Assert the orchestrator role in one warm session with `canon:role-orchestrator`.
32
32
 
33
33
  - To pick otherwise, rewrite that line as `overridden at execution to <pick>,` plus the measurement, and add one line to the open task's `## Findings`
34
34
 
35
- - `canon records validate plans [plan]` reports a plan breaking its standard, plus `structure` misses that never set the exit, and takes `groundwork` and `intake`
35
+ - `canon records validate plans [plan]` reports a plan breaking its standard, plus `structure` misses that never set the exit, and takes `groundwork`, `intake`, and `sessions`
36
36
  - `canon records push` and `canon records pull` carry `.canon/` to and from a private repository. See [records](../agents/records.md).
37
37
  - `canon records size`, `canon records prune-tmp`, and `canon records stale canonical` report folder size, idle scratch, and releases since the requirements and design records were last read
38
38
  - `canon tasks archive` moves a shipped plan to `.canon/plans/archive/` and retargets the task's `Plan:` line
@@ -19,6 +19,7 @@ These skills serve a request rather than a moment. With the [skill map](skill-ma
19
19
  | `canon:draw-figure` | A figure or architecture view inside a doc |
20
20
  | `canon:ci-workflow` | A GitHub Actions workflow file |
21
21
  | `canon:design-identity` | A logo mark and social card |
22
+ | `canon:ui-record` | Real footage of the app for a video |
22
23
 
23
24
  ## Answer a question at any point
24
25
 
@@ -55,6 +55,7 @@ Groups run in the order a project meets them. Every name `canon claude skills li
55
55
  | `canon:document-health` | Documents must answer for length and staleness |
56
56
  | `canon:ux-audit` | To read UI source for missing states |
57
57
  | `canon:ux-measure` | To measure paint, processor, and layout cost |
58
+ | `canon:app-drive` | To drive a built app with the operator |
58
59
 
59
60
  ## Ship it
60
61
 
@@ -43,8 +43,9 @@ Run `canon drive --help` for the step kinds and the run file shape.
43
43
  Two reviews run beside each other and finish on their own clocks.
44
44
 
45
45
  - `canon:review-pr` reads the code and the stills in the evidence comment, and posts under `## Review`.
46
- - `canon:review-ui` never reads the diff. It drives each checklist box in the running app through the pinned `@playwright/cli` and posts under `## UI review`.
46
+ - `canon:review-ui` never reads the diff. It drives each checklist box in the running app through `canon browser`, which runs the pinned `@playwright/cli`, and posts under `## UI review`.
47
47
  - Every box gets one verdict, being pass, fail, needs eyes, or not driven.
48
+ - The pass refuses rather than download a browser. Install it once per machine with `canon browser install-browser chromium`.
48
49
 
49
50
  After the boxes, `review-ui` sweeps every route they reached:
50
51
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.66.0",
4
+ "version": "5.68.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -54,7 +54,9 @@
54
54
  "axe-core": "4.14.0",
55
55
  "commander": "^13.1.0",
56
56
  "execa": "^9.5.2",
57
- "playwright-core": "1.64.1"
57
+ "pixelmatch": "8.0.0",
58
+ "playwright-core": "1.64.1",
59
+ "pngjs": "7.0.0"
58
60
  },
59
61
  "devDependencies": {
60
62
  "@astrojs/check": "^0.9.8",
@@ -66,6 +68,7 @@
66
68
  "@testing-library/jest-dom": "^6.9.1",
67
69
  "@types/bun": "^1.2.10",
68
70
  "@types/micromatch": "^4.0.10",
71
+ "@types/pngjs": "6.0.5",
69
72
  "astro": "^6.1.9",
70
73
  "husky": "^9.1.7",
71
74
  "micromatch": "^4.0.8",
@@ -451,7 +451,11 @@ function testOrderCounts(record: unknown): Record<string, number> | undefined {
451
451
  })
452
452
  }
453
453
 
454
- /** The record kinds `canon records validate` takes, and the corpus each reads. */
454
+ /**
455
+ * The record kinds whose findings the aggregate counts, and the corpus each
456
+ * reads. `sessions` is left out because it reports length alone and never a
457
+ * finding, so a row for it would always count zero.
458
+ */
455
459
  const RECORD_KINDS: readonly (readonly [string, Corpus])[] = [
456
460
  ['plans', 'per-machine'],
457
461
  ['groundwork', 'per-machine'],
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The pinned agent browser CLI every skill driving a page reaches through
3
+ * `canon browser`, so every call in a run and every run on a machine reach one
4
+ * version. `open` is forced onto the bundled Chromium, since the CLI's default
5
+ * channel is a branded Chrome most machines lack, and it refuses rather than
6
+ * downloading when that browser is absent, since a dispatched reviewer that
7
+ * installs mid-pass spends its budget on the download.
8
+ */
9
+
10
+ export const AGENT_CLI = '@playwright/cli@0.1.22'
11
+
12
+ /**
13
+ * The Chromium revision that CLI version requires. It moves with the pin, and
14
+ * it is separate from the revision `INSTALL_BROWSER` fetches for the engine.
15
+ */
16
+ export const CHROMIUM_REVISION = '1247'
17
+
18
+ export const INSTALL_AGENT_BROWSER = 'canon browser install-browser chromium'
19
+
20
+ export type AgentCall =
21
+ | { readonly kind: 'run'; readonly args: readonly string[] }
22
+ | { readonly kind: 'refused'; readonly message: string }
23
+
24
+ /**
25
+ * Rewrites the caller's words into the child's, reading the revision only for
26
+ * `open`, the one verb that launches a browser. Global flags may lead the verb,
27
+ * as in `-s=<name> --raw open <url>`.
28
+ */
29
+ export function planAgentCall(
30
+ args: readonly string[],
31
+ hasRevision: () => boolean,
32
+ ): AgentCall {
33
+ const verbAt = args.findIndex((arg) => !arg.startsWith('-'))
34
+ if (verbAt === -1 || args[verbAt] !== 'open') return { kind: 'run', args }
35
+
36
+ if (!hasRevision()) {
37
+ return {
38
+ kind: 'refused',
39
+ message: `Chromium revision ${CHROMIUM_REVISION} is not installed. Run once, outside the pass: ${INSTALL_AGENT_BROWSER}`,
40
+ }
41
+ }
42
+
43
+ const namesBrowser = args.some(
44
+ (arg) => arg === '--browser' || arg.startsWith('--browser='),
45
+ )
46
+ if (namesBrowser) return { kind: 'run', args }
47
+
48
+ return {
49
+ kind: 'run',
50
+ args: [
51
+ ...args.slice(0, verbAt + 1),
52
+ '--browser=chromium',
53
+ ...args.slice(verbAt + 1),
54
+ ],
55
+ }
56
+ }
57
+
58
+ /** Where Playwright keeps downloaded browsers, by its own lookup order. */
59
+ export function browsersRoot(
60
+ env: Readonly<Record<string, string | undefined>>,
61
+ platform: string,
62
+ home: string,
63
+ ): string {
64
+ const override = env.PLAYWRIGHT_BROWSERS_PATH
65
+ if (override !== undefined && override !== '') return override
66
+ if (platform === 'darwin') return `${home}/Library/Caches/ms-playwright`
67
+ return `${home}/.cache/ms-playwright`
68
+ }
@@ -149,6 +149,26 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
149
149
  prompt: 'Tell me the paint and layout cost of this page right now.',
150
150
  expect: 'ux-measure',
151
151
  },
152
+ {
153
+ prompt:
154
+ 'We just tagged the release. Spin up a copy of the build and use it with me as its users would, then file whatever breaks.',
155
+ expect: 'app-drive',
156
+ },
157
+ {
158
+ prompt:
159
+ 'Before we plan the next wave, put the built app through every goal in the requirements with me and write up what we hit.',
160
+ expect: 'app-drive',
161
+ },
162
+ {
163
+ prompt:
164
+ 'I need real footage of the board import flow for the launch video. Film it from our app so HyperFrames can zoom in on the clicks.',
165
+ expect: 'ui-record',
166
+ },
167
+ {
168
+ prompt:
169
+ 'Shoot the takes for the storyboard we wrote, one clip per beat of the app doing its thing.',
170
+ expect: 'ui-record',
171
+ },
152
172
  {
153
173
  prompt:
154
174
  'I am building this branch for another session. What am I on the hook for, and what is off limits?',
package/src/cli/help.ts CHANGED
@@ -42,6 +42,8 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
42
42
  ['serve [dir]', 'Serve a directory on localhost, print the link'],
43
43
  ['inventory [subj]', 'Report one computed property across every route'],
44
44
  ['drive <url> <run>', 'Walk a page through interactions, measure each'],
45
+ ['browser [args]', 'Drive the pinned agent browser CLI'],
46
+ ['take <url> <plan>', 'Film a web app with a click log and camera track'],
45
47
  ['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
46
48
  ],
47
49
  },
@@ -100,6 +102,7 @@ const EXAMPLES = [
100
102
  'canon capture assets/captures/install.html --selector .window --out assets/frames',
101
103
  'canon inventory focus --json',
102
104
  'canon drive http://localhost:4173 run.json --json',
105
+ 'canon take http://localhost:4173 plan.json --out takes --json',
103
106
  'pbpaste | canon feedback',
104
107
  'canon transcripts https://youtu.be/VIDEO_ID',
105
108
  'canon tasks --help --all',
package/src/cli.ts CHANGED
@@ -14,6 +14,8 @@ import { register as capture } from '@/commands/capture'
14
14
  import { register as serve } from '@/commands/serve'
15
15
  import { register as inventory } from '@/commands/inventory'
16
16
  import { register as driver } from '@/commands/driver'
17
+ import { register as browser } from '@/commands/browser'
18
+ import { register as take } from '@/commands/take'
17
19
  import { register as feedback } from '@/commands/feedback'
18
20
  import { register as transcripts } from '@/commands/transcripts'
19
21
  import { register as tasks } from '@/commands/tasks'
@@ -78,6 +80,8 @@ capture(program)
78
80
  serve(program)
79
81
  inventory(program)
80
82
  driver(program)
83
+ browser(program)
84
+ take(program)
81
85
  feedback(program)
82
86
  transcripts(program)
83
87
  tasks(program)
@@ -0,0 +1,102 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { constants, homedir } from 'node:os'
4
+ import type { Command } from 'commander'
5
+ import {
6
+ AGENT_CLI,
7
+ CHROMIUM_REVISION,
8
+ INSTALL_AGENT_BROWSER,
9
+ browsersRoot,
10
+ planAgentCall,
11
+ } from '@/browser/agent-cli'
12
+
13
+ /**
14
+ * Every byte on stdout and stderr belongs to the child, which skills read
15
+ * snapshot paths and `--raw eval` values off, so this verb frames nothing and
16
+ * marks its own refusals with a prefix rather than a record.
17
+ */
18
+ const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
19
+
20
+ export function register(program: Command): void {
21
+ program
22
+ .command('browser')
23
+ .description('Drive the pinned agent browser CLI')
24
+ .argument('[args...]', 'Words passed to the browser CLI untouched')
25
+ .helpOption('-h, --help', 'Show this help message')
26
+ .allowUnknownOption()
27
+ .allowExcessArguments(true)
28
+ .passThroughOptions()
29
+ .addHelpText(
30
+ 'after',
31
+ [
32
+ '',
33
+ `Runs ${AGENT_CLI} through bunx in the current directory, so every`,
34
+ 'skill driving a page reaches one version. Snapshots land under',
35
+ '.playwright-cli/ in the folder it runs in.',
36
+ '',
37
+ 'open is forced onto the bundled Chromium unless the call names a',
38
+ `--browser, and refuses rather than downloading when revision ${CHROMIUM_REVISION}`,
39
+ 'is absent. Install it once, outside any pass, with:',
40
+ ` ${INSTALL_AGENT_BROWSER}`,
41
+ '',
42
+ "Stdout, stderr, and the exit code are the browser CLI's. A refusal of",
43
+ "this verb's own reads 'canon browser:' on stderr.",
44
+ '',
45
+ 'Exit codes:',
46
+ " n the browser CLI's own exit code",
47
+ ' 1 refused, with the reason on stderr',
48
+ '',
49
+ 'Examples:',
50
+ ' canon browser --version',
51
+ ' canon browser -s=canon-12 open http://localhost:4321',
52
+ ' canon browser -s=canon-12 --raw eval "document.title"',
53
+ '',
54
+ ].join('\n'),
55
+ )
56
+ .action(async (args: string[]) => {
57
+ process.exitCode = await runBrowser(args)
58
+ })
59
+ }
60
+
61
+ async function runBrowser(args: readonly string[]): Promise<number> {
62
+ if (Bun.which('bunx') === null) {
63
+ return refuse('bunx is not on PATH, so the pinned CLI cannot run')
64
+ }
65
+
66
+ const root = browsersRoot(process.env, process.platform, homedir())
67
+ const call = planAgentCall(args, () =>
68
+ existsSync(`${root}/chromium-${CHROMIUM_REVISION}`),
69
+ )
70
+ if (call.kind === 'refused') return refuse(call.message)
71
+
72
+ const child = spawn('bunx', ['-y', AGENT_CLI, ...call.args], {
73
+ stdio: 'inherit',
74
+ })
75
+ // A killed pass must not leave a browser running behind the parent, which a
76
+ // shell `exec` never had between the caller and the child.
77
+ const forward = (signal: NodeJS.Signals) => () => child.kill(signal)
78
+ const handlers = FORWARDED_SIGNALS.map((signal) => {
79
+ const handler = forward(signal)
80
+ process.on(signal, handler)
81
+ return [signal, handler] as const
82
+ })
83
+
84
+ try {
85
+ return await new Promise<number>((resolve) => {
86
+ child.on('error', (error) => {
87
+ process.stderr.write(`canon browser: ${error.message}\n`)
88
+ resolve(1)
89
+ })
90
+ child.on('exit', (code, signal) => {
91
+ resolve(code ?? 128 + (signal ? constants.signals[signal] : 0))
92
+ })
93
+ })
94
+ } finally {
95
+ for (const [signal, handler] of handlers) process.off(signal, handler)
96
+ }
97
+ }
98
+
99
+ function refuse(message: string): number {
100
+ process.stderr.write(`canon browser: ${message}\n`)
101
+ return 1
102
+ }
@@ -10,6 +10,14 @@ import {
10
10
  readFolder,
11
11
  type Selection,
12
12
  } from '@/intake/folder'
13
+ import {
14
+ type BaselineOutcome,
15
+ type CheckOutcome,
16
+ checkDrive,
17
+ type DriveFindings,
18
+ type DriveRefused,
19
+ recordBaseline,
20
+ } from '@/intake/drive-check'
13
21
  import { isUnread } from '@/intake/items'
14
22
  import {
15
23
  intro,
@@ -37,6 +45,12 @@ interface ListCommandOptions {
37
45
  readonly root?: string
38
46
  }
39
47
 
48
+ interface DriveCheckCommandOptions {
49
+ readonly baseline?: boolean
50
+ readonly json?: boolean
51
+ readonly root?: string
52
+ }
53
+
40
54
  interface AnswerCommandOptions {
41
55
  readonly cluster?: string
42
56
  readonly json?: boolean
@@ -143,6 +157,67 @@ export function register(program: Command): void {
143
157
  .action(async (slug: string, opts: AnswerCommandOptions) => {
144
158
  process.exitCode = await runAnswer(slug, opts)
145
159
  })
160
+
161
+ intake
162
+ .command('drive-check')
163
+ .description(
164
+ 'Check an app drive against its goals, the checkout, and its evidence',
165
+ )
166
+ .argument('<slug>', 'Intake folder name, as in app-drive-1-0')
167
+ .helpOption('-h, --help', 'Show this help message')
168
+ .option(
169
+ '--baseline',
170
+ 'Record the main checkout status before the drive, rather than check',
171
+ )
172
+ .option('--json', 'Emit a machine-readable record on stdout')
173
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
174
+ .addHelpText(
175
+ 'after',
176
+ [
177
+ '',
178
+ 'With --baseline it stores the main checkout status at',
179
+ '.canon/tmp/app-drive/<slug>/baseline.json, any ordinal dropped from',
180
+ 'the slug, so it runs before the intake folder exists. Without it, it',
181
+ 'reads drive.json in the intake folder and fails the drive on:',
182
+ ' unmapped a ## Goals bullet the record carries no entry for',
183
+ ' unreasoned a goal marked not-driven with no reason',
184
+ ' unknown an entry naming no goal the requirements hold',
185
+ ' changed a checkout path that differs from the baseline',
186
+ ' strayEvidence an evidence path outside the folder evidence/',
187
+ ' missingEvidence an evidence path that holds no file',
188
+ ' worktreeLeft the drive worktree the record names, still on disk',
189
+ '',
190
+ 'A goal matches by its text with any bold label and trailing period',
191
+ 'dropped. A changed path is named and never reverted.',
192
+ '',
193
+ 'Read `reason` off the record: `recorded` for a baseline, `clean` or',
194
+ '`failed` for a check, and a refusal otherwise.',
195
+ '',
196
+ 'Refusals, read off `reason`:',
197
+ ' no-intake the project holds no intake folder',
198
+ ' no-folder no folder matches the slug',
199
+ ' ambiguous-slug a bare slug matches more than one folder',
200
+ ' bad-input the slug could leave the scratch folder',
201
+ ' no-record the folder carries no drive.json',
202
+ ' bad-record drive.json does not parse or breaks its shape',
203
+ ' no-requirements the project carries no requirements record',
204
+ ' no-baseline no baseline was recorded for the slug',
205
+ ' unreadable-tree git cannot read the main checkout status',
206
+ '',
207
+ 'Exit codes:',
208
+ ' 0 the baseline was recorded, or the drive is clean',
209
+ ' 1 refused, with the reason on stderr or in the JSON record',
210
+ ' 2 the drive failed, with every finding in the record',
211
+ '',
212
+ 'Examples:',
213
+ ' canon intake drive-check app-drive-1-0 --baseline --json',
214
+ ' canon intake drive-check app-drive-1-0 --json',
215
+ '',
216
+ ].join('\n'),
217
+ )
218
+ .action(async (slug: string, opts: DriveCheckCommandOptions) => {
219
+ process.exitCode = await runDriveCheck(slug, opts)
220
+ })
146
221
  }
147
222
 
148
223
  function collectSelection(value: string, previous: string[]): string[] {
@@ -262,9 +337,23 @@ async function runAnswer(
262
337
  return reportAnswer(outcome, emitJson, root)
263
338
  }
264
339
 
340
+ async function runDriveCheck(
341
+ slug: string,
342
+ opts: DriveCheckCommandOptions,
343
+ ): Promise<number> {
344
+ const emitJson = opts.json ?? false
345
+ const root = opts.root ?? (await mainWorktreeRoot())
346
+
347
+ if (opts.baseline) {
348
+ return reportBaseline(await recordBaseline(root, slug), emitJson, root)
349
+ }
350
+
351
+ return reportDriveCheck(await checkDrive(root, slug), emitJson, root)
352
+ }
353
+
265
354
  function reportRefusal(
266
355
  title: string,
267
- refused: IntakeRefused,
356
+ refused: IntakeRefused | DriveRefused,
268
357
  emitJson: boolean,
269
358
  root: string,
270
359
  ): number {
@@ -421,3 +510,94 @@ function reportAnswer(
421
510
 
422
511
  return 0
423
512
  }
513
+
514
+ function reportBaseline(
515
+ outcome: BaselineOutcome,
516
+ emitJson: boolean,
517
+ root: string,
518
+ ): number {
519
+ if (!outcome.ok) {
520
+ return reportRefusal('canon intake drive-check', outcome, emitJson, root)
521
+ }
522
+
523
+ const path = relative(root, outcome.path)
524
+
525
+ if (emitJson) {
526
+ process.stdout.write(
527
+ `${JSON.stringify({
528
+ ok: true,
529
+ root,
530
+ slug: outcome.slug,
531
+ reason: 'recorded',
532
+ baseline: path,
533
+ entries: outcome.entries,
534
+ })}\n`,
535
+ )
536
+ return 0
537
+ }
538
+
539
+ intro('canon intake drive-check')
540
+ logStep('Baseline')
541
+ logInfo(`Recorded ${outcome.entries} status line(s).`)
542
+ logAdd(path)
543
+ outro()
544
+
545
+ return 0
546
+ }
547
+
548
+ const FINDING_LABELS: Record<keyof DriveFindings, string> = {
549
+ unmapped: 'Goals with no entry',
550
+ unreasoned: 'Goals not driven, with no reason',
551
+ unknown: 'Entries naming no goal',
552
+ changed: 'Changed in the checkout',
553
+ strayEvidence: 'Evidence outside evidence/',
554
+ missingEvidence: 'Evidence holding no file',
555
+ worktreeLeft: 'Drive worktree still on disk',
556
+ }
557
+
558
+ function reportDriveCheck(
559
+ outcome: CheckOutcome,
560
+ emitJson: boolean,
561
+ root: string,
562
+ ): number {
563
+ if (!outcome.ok) {
564
+ return reportRefusal('canon intake drive-check', outcome, emitJson, root)
565
+ }
566
+
567
+ const code = outcome.reason === 'clean' ? 0 : 2
568
+
569
+ if (emitJson) {
570
+ process.stdout.write(
571
+ `${JSON.stringify({
572
+ ok: true,
573
+ root,
574
+ slug: outcome.slug,
575
+ reason: outcome.reason,
576
+ goals: outcome.goals,
577
+ findings: outcome.findings,
578
+ })}\n`,
579
+ )
580
+ return code
581
+ }
582
+
583
+ intro('canon intake drive-check')
584
+
585
+ if (outcome.reason === 'clean') {
586
+ logStep('Clean')
587
+ logInfo(
588
+ `${outcome.goals} goal(s) mapped, the checkout untouched, the evidence in place.`,
589
+ )
590
+ outro()
591
+ return code
592
+ }
593
+
594
+ for (const [key, label] of Object.entries(FINDING_LABELS)) {
595
+ const list = outcome.findings[key as keyof DriveFindings]
596
+ if (list.length === 0) continue
597
+ logStep(label)
598
+ for (const entry of list) logError(entry)
599
+ }
600
+ outro()
601
+
602
+ return code
603
+ }