@erclx/canon 5.66.0 → 5.67.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 (66) hide show
  1. package/CHANGELOG.md +11 -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/review-ui/SKILL.md +5 -4
  13. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  14. package/claude/skills/review-ui/references/driving.md +1 -1
  15. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  16. package/claude/skills/role-planner/SKILL.md +2 -2
  17. package/claude/skills/role-worker/SKILL.md +4 -6
  18. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  19. package/claude/skills/session-map/SKILL.md +8 -1
  20. package/claude/skills/session-map/references/session.md +19 -5
  21. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  22. package/claude/skills/ui-record/SKILL.md +103 -0
  23. package/claude/skills/ui-record/references/config.md +40 -0
  24. package/claude/skills/ui-record/references/handoff.md +48 -0
  25. package/claude/skills/ui-record/references/take-craft.md +92 -0
  26. package/docs/agents/records.md +1 -0
  27. package/docs/workflow/parallel-features.md +1 -1
  28. package/docs/workflow/skill-map-on-request.md +1 -0
  29. package/docs/workflow/skill-map.md +1 -0
  30. package/docs/workflow/visual-design-workflow.md +2 -1
  31. package/package.json +1 -1
  32. package/src/audits/catalog.ts +5 -1
  33. package/src/browser/agent-cli.ts +68 -0
  34. package/src/claude/cases/workflow.ts +20 -0
  35. package/src/cli/help.ts +3 -0
  36. package/src/cli.ts +4 -0
  37. package/src/commands/browser.ts +102 -0
  38. package/src/commands/intake.ts +181 -1
  39. package/src/commands/pr.ts +15 -6
  40. package/src/commands/records.ts +18 -1
  41. package/src/commands/take.ts +266 -0
  42. package/src/context/requirements.ts +1 -11
  43. package/src/git/status.ts +73 -0
  44. package/src/intake/drive-check.ts +355 -0
  45. package/src/intake/folder.ts +1 -1
  46. package/src/markdown/scan.ts +10 -0
  47. package/src/pr/drive-copy.ts +97 -84
  48. package/src/records/session-map.ts +75 -0
  49. package/src/records/validate.ts +64 -8
  50. package/src/take/camera.ts +309 -0
  51. package/src/take/encode.ts +170 -0
  52. package/src/take/film.ts +206 -0
  53. package/src/take/fixtures/fade.html +49 -0
  54. package/src/take/fixtures/fetch.html +38 -0
  55. package/src/take/log.ts +109 -0
  56. package/src/take/perform.ts +200 -0
  57. package/src/take/plan.ts +193 -0
  58. package/src/take/screencast.ts +94 -0
  59. package/src/take/shell.ts +287 -0
  60. package/src/take/stepper.ts +252 -0
  61. package/src/take/zoom/NOTICE.md +45 -0
  62. package/src/take/zoom/math-utils.ts +69 -0
  63. package/src/take/zoom/region-utils.ts +55 -0
  64. package/src/take/zoom/spring.ts +133 -0
  65. package/src/tasks/archive.ts +1 -3
  66. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -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
+ }
@@ -83,14 +83,12 @@ import {
83
83
  type MergeRequest,
84
84
  } from '@/pr/merge'
85
85
  import { listWorktrees } from '@/git/worktree'
86
+ import { branchSlug, buildDriveCopy, loadPreviewConfig } from '@/pr/drive-copy'
86
87
  import {
87
- branchSlug,
88
- buildDriveCopy,
89
88
  changedSince,
90
- loadPreviewConfig,
91
89
  readWorktreeStatus,
92
90
  type StatusEntry,
93
- } from '@/pr/drive-copy'
91
+ } from '@/git/status'
94
92
  import { machineListenerReader } from '@/listeners/machine'
95
93
  import { loadPaintedPatterns } from '@/pr/painted'
96
94
  import { LOCAL_CONFIG_REL, readHealthPaths } from '@/pr/health'
@@ -1306,18 +1304,28 @@ export function register(program: Command): void {
1306
1304
  [
1307
1305
  '',
1308
1306
  'A project names what its preview writes in',
1309
- '`canon/config/preview.json` as `{ "writes": [path], "env": "<NAME>" }`.',
1307
+ '`canon/config/preview.json` as',
1308
+ '`{ "writes": [path], "keep": [path], "env": "<NAME>" }`.',
1310
1309
  'Each `writes` path is relative to the worktree, and `env`, defaulting',
1311
1310
  'to `CANON_PREVIEW_ROOT`, is the variable the server reads for the copy',
1312
1311
  'root. The server resolves each declared path under that root.',
1313
1312
  '',
1313
+ 'Each `keep` path sits below a `writes` path, never equal to one, and',
1314
+ 'names state the running server owns there, such as a run file. A',
1315
+ "rebuild leaves it in the copy and never copies the worktree's own, so",
1316
+ 'list it for a server that writes under a declared path. The key is',
1317
+ 'optional.',
1318
+ '',
1314
1319
  'The copy lives at `.canon/tmp/pr/review-ui/<slug>/board/` at the main',
1315
1320
  'worktree root, where `<slug>` is the branch with a leading type',
1316
1321
  'segment dropped and each `/` made `-`. With a number the branch is the',
1317
1322
  "pull request's head, and without one it is the checkout's, so the",
1318
1323
  'worker starting its server before a pull request exists and the',
1319
1324
  'reviewer naming the number land on one copy. Every run but --check',
1320
- "rebuilds it from the worktree, dropping the last pass's drives.",
1325
+ "resets each declared path from the worktree, dropping the last pass's",
1326
+ 'drives, and leaves the rest of the copy root and each `keep` path as',
1327
+ 'they are. A page added only to the copy, inside a declared path, is',
1328
+ 'wiped.',
1321
1329
  '',
1322
1330
  '--baseline records `git status` for the worktree under',
1323
1331
  '`.canon/tmp/pr/review-ui/<number>/`, and --check reports each path',
@@ -4485,6 +4493,7 @@ async function runDriveCopy(
4485
4493
  target.worktree,
4486
4494
  copyRoot,
4487
4495
  config.config.writes,
4496
+ config.config.keep,
4488
4497
  )
4489
4498
  logStep('Copied')
4490
4499
  logInfo(`${config.config.env}=${copyRoot}`)
@@ -34,6 +34,7 @@ import {
34
34
  type StructureFinding,
35
35
  validateRecords,
36
36
  } from '@/records/validate'
37
+ import type { SessionMapLength } from '@/records/session-map'
37
38
  import {
38
39
  intro,
39
40
  logError,
@@ -92,7 +93,7 @@ export function register(program: Command): void {
92
93
  .argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
93
94
  .argument(
94
95
  '[record]',
95
- 'One plan stem or folder slug, narrowing both findings and structure to it',
96
+ 'One plan stem, folder slug, or session map stem, narrowing the report to it',
96
97
  )
97
98
  .helpOption('-h, --help', 'Show this help message')
98
99
  .option('--json', 'Add a machine-readable record on stdout')
@@ -105,6 +106,7 @@ export function register(program: Command): void {
105
106
  ' plans filename, required sections, and the suggested-and-answer contract',
106
107
  ' groundwork README and current-state files, numbering, dating, and a half-closed track',
107
108
  ' intake overview file, numbering, dating, and the four bullets every item carries',
109
+ " sessions each session map's words against its cap, under `length` and never in the exit",
108
110
  '',
109
111
  'Structure, under `structure` and never in the exit:',
110
112
  " S1 to S5 from the plan standard, read on plans, a track's README and",
@@ -128,6 +130,7 @@ export function register(program: Command): void {
128
130
  ' canon records validate plans',
129
131
  ' canon records validate plans feature-records-by-heading',
130
132
  ' canon records validate intake --json',
133
+ ' canon records validate sessions session-orchestrator',
131
134
  '',
132
135
  ].join('\n'),
133
136
  )
@@ -828,6 +831,7 @@ function report(
828
831
  records: outcome.records,
829
832
  findings: outcome.findings,
830
833
  structure: outcome.structure,
834
+ ...(outcome.length && { length: outcome.length }),
831
835
  })}\n`,
832
836
  )
833
837
  } else {
@@ -846,6 +850,14 @@ function report(
846
850
  logStep('Structure, reported and never in the exit')
847
851
  for (const entry of outcome.structure) logInfo(describeStructure(entry))
848
852
  }
853
+
854
+ if (outcome.length) {
855
+ logStep('Length, reported and never in the exit')
856
+ for (const entry of outcome.length) {
857
+ const log = entry.over ? logWarn : logInfo
858
+ log(describeLength(entry))
859
+ }
860
+ }
849
861
  outro()
850
862
  }
851
863
 
@@ -857,6 +869,11 @@ function describe(found: Finding): string {
857
869
  return `${scope}${found.subject} ${found.message}`
858
870
  }
859
871
 
872
+ function describeLength(entry: SessionMapLength): string {
873
+ const verdict = entry.over ? 'over the cap' : 'within the cap'
874
+ return `${entry.record}: ${entry.words} of ${entry.cap} words, ${verdict}`
875
+ }
876
+
860
877
  function describeStructure(entry: StructureFinding): string {
861
878
  const scope = entry.record === entry.subject ? '' : `${entry.record}/`
862
879
  return `${entry.criterion} ${scope}${entry.subject}:${entry.line} ${entry.message}`