@erclx/canon 5.65.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 (84) hide show
  1. package/CHANGELOG.md +26 -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/git-pr/references/labels.md +6 -2
  11. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  12. package/claude/skills/git-pr-close/SKILL.md +55 -0
  13. package/claude/skills/git-pr-close/references/close.md +54 -0
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/plan-intake/references/intake.md +12 -2
  16. package/claude/skills/pr-evidence/SKILL.md +2 -2
  17. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  18. package/claude/skills/review-ui/SKILL.md +5 -4
  19. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  20. package/claude/skills/review-ui/references/driving.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  22. package/claude/skills/role-planner/SKILL.md +2 -2
  23. package/claude/skills/role-worker/SKILL.md +6 -8
  24. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  25. package/claude/skills/session-map/SKILL.md +8 -1
  26. package/claude/skills/session-map/references/session.md +19 -5
  27. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  28. package/claude/skills/ui-record/SKILL.md +103 -0
  29. package/claude/skills/ui-record/references/config.md +40 -0
  30. package/claude/skills/ui-record/references/handoff.md +48 -0
  31. package/claude/skills/ui-record/references/take-craft.md +92 -0
  32. package/docs/agents/records.md +1 -0
  33. package/docs/workflow/parallel-features.md +1 -1
  34. package/docs/workflow/skill-map-on-request.md +1 -0
  35. package/docs/workflow/skill-map.md +2 -0
  36. package/docs/workflow/visual-design-workflow.md +2 -1
  37. package/package.json +1 -1
  38. package/src/audits/catalog.ts +5 -1
  39. package/src/browser/agent-cli.ts +68 -0
  40. package/src/claude/cases/git.ts +4 -0
  41. package/src/claude/cases/workflow.ts +20 -0
  42. package/src/cli/help.ts +3 -0
  43. package/src/cli.ts +4 -0
  44. package/src/commands/browser.ts +102 -0
  45. package/src/commands/intake.ts +181 -1
  46. package/src/commands/labels.ts +152 -4
  47. package/src/commands/pr.ts +278 -8
  48. package/src/commands/records.ts +18 -1
  49. package/src/commands/take.ts +266 -0
  50. package/src/context/requirements.ts +1 -11
  51. package/src/git/status.ts +73 -0
  52. package/src/intake/drive-check.ts +355 -0
  53. package/src/intake/folder.ts +1 -1
  54. package/src/labels/apply.ts +10 -5
  55. package/src/labels/sync.ts +17 -0
  56. package/src/markdown/scan.ts +10 -0
  57. package/src/pr/close.ts +152 -0
  58. package/src/pr/drive-copy.ts +97 -84
  59. package/src/pr/health.ts +56 -0
  60. package/src/pr/local.ts +49 -8
  61. package/src/pr/rest.ts +10 -2
  62. package/src/records/session-map.ts +75 -0
  63. package/src/records/validate.ts +64 -8
  64. package/src/sandbox/replay/run.ts +30 -5
  65. package/src/take/camera.ts +309 -0
  66. package/src/take/encode.ts +170 -0
  67. package/src/take/film.ts +206 -0
  68. package/src/take/fixtures/fade.html +49 -0
  69. package/src/take/fixtures/fetch.html +38 -0
  70. package/src/take/log.ts +109 -0
  71. package/src/take/perform.ts +200 -0
  72. package/src/take/plan.ts +193 -0
  73. package/src/take/screencast.ts +94 -0
  74. package/src/take/shell.ts +287 -0
  75. package/src/take/stepper.ts +252 -0
  76. package/src/take/zoom/NOTICE.md +45 -0
  77. package/src/take/zoom/math-utils.ts +69 -0
  78. package/src/take/zoom/region-utils.ts +55 -0
  79. package/src/take/zoom/spring.ts +133 -0
  80. package/src/tasks/archive.ts +1 -3
  81. package/src/tooling/verify-stack.ts +4 -5
  82. package/tooling/base/reference.md +1 -0
  83. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  84. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -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.65.0",
4
+ "version": "5.67.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": {
@@ -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
+ }
@@ -22,6 +22,10 @@ export const GIT_CASES: readonly SkillCase[] = [
22
22
  prompt: 'Turn this bug into a GitHub issue and file it.',
23
23
  expect: 'git-issue',
24
24
  },
25
+ {
26
+ prompt: 'Close #42 without merging, #57 supersedes it.',
27
+ expect: 'git-pr-close',
28
+ },
25
29
  {
26
30
  prompt: 'Write the title and body for this pull request.',
27
31
  expect: 'git-pr',
@@ -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
+ }
@@ -14,9 +14,10 @@ import {
14
14
  } from '@/labels/density'
15
15
  import { resolveScanInput } from '@/labels/event'
16
16
  import { checkTitleFormat, type TitleFormatIssue } from '@/labels/format'
17
- import { MAP_REL } from '@/labels/map'
17
+ import { MAP_REL, readLabelMap } from '@/labels/map'
18
18
  import { isReleasePullRequest, scanPhaseLabels } from '@/labels/phase'
19
19
  import { checkSections } from '@/labels/sections'
20
+ import { missingLabels } from '@/labels/sync'
20
21
  import { BAN_SETS } from '@/markdown/bans'
21
22
  import { bodyLines, scanBans } from '@/markdown/scan'
22
23
  import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
@@ -33,6 +34,12 @@ interface ApplyOptions extends AuditOptions {
33
34
 
34
35
  type ApplyRefusal = 'bad-number' | 'no-branch' | 'gh-failed' | 'head-mismatch'
35
36
 
37
+ interface SyncOptions {
38
+ readonly root?: string
39
+ readonly check?: boolean
40
+ readonly json?: boolean
41
+ }
42
+
36
43
  const GH_TIMEOUT_MS = 30_000
37
44
 
38
45
  interface ScanOptions {
@@ -187,6 +194,48 @@ export function register(program: Command): void {
187
194
  process.exitCode = await runApply(number, opts)
188
195
  })
189
196
 
197
+ labels
198
+ .command('sync')
199
+ .description(
200
+ 'Create on the remote each label the map names that it lacks, never editing or deleting one',
201
+ )
202
+ .helpOption('-h, --help', 'Show this help message')
203
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
204
+ .option('--check', 'Report the labels missing and write nothing')
205
+ .option('--json', 'Add a machine-readable record on stdout')
206
+ .addHelpText(
207
+ 'after',
208
+ [
209
+ '',
210
+ `Reads the label names under [domains] in ${MAP_REL}, reads the remote's`,
211
+ 'labels over REST, and creates each one the remote lacks. A label already',
212
+ 'on the remote is left as it is, and one the map does not name is never',
213
+ 'touched. A project declaring no map creates nothing and exits 0.',
214
+ '',
215
+ 'Run it once after installing a map and again after adding a row, so',
216
+ '`canon labels apply` finds every label it earns.',
217
+ '',
218
+ 'Record: labels, present, created, missing, failed',
219
+ '',
220
+ 'Exit codes:',
221
+ ' 0 the remote carries every label the map names after the run',
222
+ ' 1 refused, with the reason on stderr or in the JSON record',
223
+ ' 2 labels are still missing, being any under --check, or a failed',
224
+ ' create otherwise',
225
+ '',
226
+ 'Reasons:',
227
+ ' gh-failed the remote labels could not be read, so nothing was written',
228
+ '',
229
+ 'Examples:',
230
+ ' canon labels sync',
231
+ ' canon labels sync --check --json',
232
+ '',
233
+ ].join('\n'),
234
+ )
235
+ .action(async (opts: SyncOptions) => {
236
+ process.exitCode = await runSync(opts)
237
+ })
238
+
190
239
  labels
191
240
  .command('scan')
192
241
  .description(
@@ -544,9 +593,7 @@ async function runApply(number: string, opts: ApplyOptions): Promise<number> {
544
593
  }
545
594
 
546
595
  for (const label of diff.missingOnRemote) {
547
- logWarn(
548
- `${label} is not on the remote. Create it with: gh label create ${label}`,
549
- )
596
+ logWarn(`${label} is not on the remote. Create it with: canon labels sync`)
550
597
  }
551
598
  for (const label of failed) logWarn(`Label apply failed: ${label}`)
552
599
 
@@ -577,6 +624,107 @@ async function runApply(number: string, opts: ApplyOptions): Promise<number> {
577
624
  return owed.length === 0 ? 0 : 2
578
625
  }
579
626
 
627
+ async function runSync(opts: SyncOptions): Promise<number> {
628
+ const root = resolve(opts.root ?? process.cwd())
629
+ const emitJson = opts.json ?? false
630
+ const isCheck = opts.check ?? false
631
+
632
+ intro('canon labels sync')
633
+
634
+ const map = readLabelMap(root)
635
+
636
+ if (map.kind === 'refused' && map.reason === 'no-map') {
637
+ logStep('Labels')
638
+ logInfo(REFUSALS['no-map'])
639
+ outro()
640
+ if (emitJson) {
641
+ process.stdout.write(
642
+ `${JSON.stringify({
643
+ reason: 'no-map',
644
+ labels: [],
645
+ present: [],
646
+ created: [],
647
+ missing: [],
648
+ failed: [],
649
+ })}\n`,
650
+ )
651
+ }
652
+ return 0
653
+ }
654
+
655
+ const refuse = (reason: LabelAuditRefusal | 'gh-failed'): number => {
656
+ const message =
657
+ reason === 'gh-failed'
658
+ ? 'gh could not read the remote labels, so nothing was written.'
659
+ : REFUSALS[reason]
660
+ logStep('Refused')
661
+ logWarn(message)
662
+ outro()
663
+ if (emitJson) {
664
+ process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
665
+ }
666
+ return 1
667
+ }
668
+
669
+ if (map.kind === 'refused') return refuse(map.reason)
670
+
671
+ const remoteRaw = await gh(root, [
672
+ 'api',
673
+ '--paginate',
674
+ 'repos/{owner}/{repo}/labels',
675
+ '--jq',
676
+ '.[].name',
677
+ ])
678
+ if (remoteRaw === null) return refuse('gh-failed')
679
+
680
+ const labels = map.domains.map((row) => row.label)
681
+ const present = nonEmptyLines(remoteRaw)
682
+ const toCreate = missingLabels(labels, present)
683
+
684
+ const created: string[] = []
685
+ const failed: string[] = []
686
+
687
+ if (!isCheck) {
688
+ for (const label of toCreate) {
689
+ const wrote = await gh(root, [
690
+ 'api',
691
+ '-X',
692
+ 'POST',
693
+ 'repos/{owner}/{repo}/labels',
694
+ '-f',
695
+ `name=${label}`,
696
+ '--silent',
697
+ ])
698
+ if (wrote === null) failed.push(label)
699
+ else created.push(label)
700
+ }
701
+ }
702
+
703
+ const missing = toCreate.filter((label) => !created.includes(label))
704
+
705
+ logStep('Labels')
706
+ logInfo(labels.join(', '))
707
+
708
+ if (isCheck) {
709
+ logStep(missing.length === 0 ? 'Nothing missing' : 'Missing')
710
+ for (const label of missing) logWarn(label)
711
+ } else {
712
+ logStep('Created')
713
+ logInfo(created.length === 0 ? 'no label was created' : created.join(', '))
714
+ }
715
+ for (const label of failed) logWarn(`Label create failed: ${label}`)
716
+
717
+ outro()
718
+
719
+ if (emitJson) {
720
+ process.stdout.write(
721
+ `${JSON.stringify({ labels, present, created, missing, failed })}\n`,
722
+ )
723
+ }
724
+
725
+ return missing.length === 0 ? 0 : 2
726
+ }
727
+
580
728
  /**
581
729
  * Reads `--closes` as the issues the body must close. Undefined when the flag
582
730
  * is absent, which skips the check, and a message when the value is neither