@erclx/canon 5.68.0 → 5.70.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 (125) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +33 -159
  3. package/assets/brand/mark.svg +17 -9
  4. package/claude/.claude-plugin/plugin.json +1 -1
  5. package/claude/skills/app-drive/REQUIREMENT.md +5 -5
  6. package/claude/skills/app-drive/SKILL.md +17 -6
  7. package/claude/skills/app-drive/references/drive-record.md +61 -8
  8. package/claude/skills/app-drive/references/isolate.md +1 -1
  9. package/claude/skills/canon-cli/SKILL.md +2 -2
  10. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
  11. package/claude/skills/canon-feedback/SKILL.md +1 -1
  12. package/claude/skills/design-identity/REQUIREMENT.md +4 -4
  13. package/claude/skills/design-identity/SKILL.md +7 -7
  14. package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
  15. package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +19 -10
  16. package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
  17. package/claude/skills/design-pick/references/design-config.md +33 -0
  18. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  19. package/claude/skills/design-record/SKILL.md +1 -1
  20. package/claude/skills/design-taste/REQUIREMENT.md +2 -2
  21. package/claude/skills/design-taste/SKILL.md +3 -3
  22. package/claude/skills/design-taste/references/preflight.md +1 -1
  23. package/claude/skills/git-issue/REQUIREMENT.md +1 -1
  24. package/claude/skills/git-issue/references/issue.md +1 -1
  25. package/claude/skills/plan-feature/SKILL.md +1 -1
  26. package/claude/skills/plan-feature/references/plan.md +21 -6
  27. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  28. package/claude/skills/pr-evidence/references/evidence.md +2 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
  30. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  31. package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
  32. package/claude/skills/role-planner/SKILL.md +4 -2
  33. package/claude/skills/role-worker/SKILL.md +5 -6
  34. package/claude/skills/task-board/references/tasks.md +1 -1
  35. package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
  36. package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
  37. package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
  38. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  39. package/claude/skills/ui-craft/SKILL.md +1 -1
  40. package/claude/skills/ui-craft/references/copy.md +4 -1
  41. package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +6 -4
  42. package/claude/skills/{ui-record → ui-film}/SKILL.md +26 -13
  43. package/claude/skills/ui-film/references/compose-craft.md +81 -0
  44. package/claude/skills/{ui-record → ui-film}/references/config.md +9 -4
  45. package/claude/skills/{ui-record → ui-film}/references/handoff.md +17 -7
  46. package/claude/skills/{ui-record → ui-film}/references/pr-clip.md +5 -5
  47. package/claude/skills/{ui-record → ui-film}/references/take-craft.md +28 -2
  48. package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
  49. package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
  50. package/docs/agents/audits.md +1 -1
  51. package/docs/agents/migrate.md +13 -13
  52. package/docs/index.md +1 -0
  53. package/docs/migrations/by-hand.md +107 -0
  54. package/docs/migrations/by-verb.md +118 -0
  55. package/docs/migrations/index.md +14 -0
  56. package/docs/target/index.md +1 -2
  57. package/docs/target/projects.md +3 -3
  58. package/docs/target/sync.md +23 -3
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/skill-map-on-request.md +1 -2
  61. package/docs/workflow/skill-map.md +9 -10
  62. package/docs/workflow/visual-design-workflow.md +6 -1
  63. package/governance/ledger/claude/background.md +26 -0
  64. package/governance/ledger/skills/ui-craft/copy.md +8 -4
  65. package/governance/rules/claude/background.md +19 -0
  66. package/governance/stacks/base.toml +1 -0
  67. package/package.json +6 -4
  68. package/scripts/ci/install-playwright-deps.sh +52 -0
  69. package/scripts/core/design-sync.ts +204 -0
  70. package/scripts/core/regen-web-favicon.ts +27 -12
  71. package/scripts/core/regen-web-previews.ts +8 -1
  72. package/scripts/core/render-film.ts +179 -0
  73. package/src/capture/frame-values.ts +12 -12
  74. package/src/claude/cases/authoring.ts +2 -2
  75. package/src/claude/cases/misc.ts +1 -1
  76. package/src/claude/cases/setup.ts +0 -5
  77. package/src/claude/cases/workflow.ts +6 -6
  78. package/src/commands/fixtures/gh.sh +64 -0
  79. package/src/commands/gov.ts +33 -3
  80. package/src/commands/migrate.ts +12 -2
  81. package/src/commands/records.ts +3 -2
  82. package/src/commands/sandbox.ts +9 -2
  83. package/src/commands/take.ts +159 -11
  84. package/src/commands/tasks.ts +183 -0
  85. package/src/design/favicon.ts +85 -13
  86. package/src/design/reference.ts +98 -0
  87. package/src/design/render.ts +16 -14
  88. package/src/design/tokens.ts +4 -3
  89. package/src/gate/stages.ts +8 -2
  90. package/src/gov/audit.ts +48 -11
  91. package/src/gov/detect.ts +1 -1
  92. package/src/init/steps.ts +2 -2
  93. package/src/markdown/scope.ts +9 -14
  94. package/src/migrate/record-layout.ts +5 -3
  95. package/src/migrate/skill-names.ts +21 -4
  96. package/src/migrate/status.ts +3 -3
  97. package/src/records/validate.ts +15 -2
  98. package/src/sandbox/expect.ts +32 -0
  99. package/src/sandbox/headless/dispatch-shim.sh +15 -0
  100. package/src/sandbox/headless/dispatch.ts +12 -14
  101. package/src/sandbox/headless/run.ts +6 -3
  102. package/src/sandbox/stub-gh.sh +26 -0
  103. package/src/sandbox/stub-remote.ts +8 -29
  104. package/src/sync/engine.ts +1 -1
  105. package/src/take/camera.ts +100 -11
  106. package/src/take/film.ts +55 -4
  107. package/src/take/fixtures/input.html +138 -0
  108. package/src/take/keys.ts +193 -0
  109. package/src/take/log.ts +94 -1
  110. package/src/take/perform.ts +165 -10
  111. package/src/take/plan.ts +101 -6
  112. package/src/take/pointer.ts +2 -1
  113. package/src/take/screencast.ts +28 -3
  114. package/src/take/stepper.ts +141 -23
  115. package/src/take/zoom/NOTICE.md +1 -1
  116. package/src/tasks/launch-wait.ts +160 -0
  117. package/tooling/claude/reference.md +1 -1
  118. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
  119. package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
  120. package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
  121. package/claude/skills/plan-ready/SKILL.md +0 -136
  122. package/claude/skills/plan-ready/references/assembly.md +0 -75
  123. package/claude/skills/plan-ready/references/ready.md +0 -128
  124. package/docs/target/migrations.md +0 -203
  125. /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
@@ -34,6 +34,7 @@ import {
34
34
  } from '@/gov/restated'
35
35
  import {
36
36
  auditExit,
37
+ CORE_FOLDERS,
37
38
  hasFinding,
38
39
  PRACTICE_FOLDERS,
39
40
  readRuleAudit,
@@ -62,6 +63,11 @@ import {
62
63
  readTestOrder,
63
64
  type TestOrderReport,
64
65
  } from '@/gov/test-order'
66
+ import {
67
+ type ChangedSet,
68
+ collectChangedFiles,
69
+ commandRunner,
70
+ } from '@/gate/sequencer'
65
71
  import { recordStamp, runDomainSync } from '@/sync/engine'
66
72
  import { detectRules } from '@/gov/detect'
67
73
  import { writeChainStamp } from '@/sync/stamp'
@@ -138,6 +144,7 @@ interface AuditOptions {
138
144
  readonly root?: string
139
145
  readonly json?: boolean
140
146
  readonly gate?: boolean
147
+ readonly touched?: boolean
141
148
  }
142
149
 
143
150
  /** What a reader does about each way the sweep produced no reading. */
@@ -613,6 +620,10 @@ export function register(program: Command): void {
613
620
  '--gate',
614
621
  'Set 2 only for a finding in a gated folder, which the push stage reads',
615
622
  )
623
+ .option(
624
+ '--touched',
625
+ 'Owe a ledger for each core rule the branch changed against origin/main',
626
+ )
616
627
  .addHelpText(
617
628
  'after',
618
629
  [
@@ -632,6 +643,9 @@ export function register(program: Command): void {
632
643
  ` asks a ledger of each rule under ${PRACTICE_FOLDERS.join(', ')}`,
633
644
  ' at governance/ledger/<folder>/<slug>.md, one table row per bullet',
634
645
  ' under a heading of the same name.',
646
+ ` A rule under ${CORE_FOLDERS.join(', ')} has its ledger checked where`,
647
+ ' one exists, and under --touched owes one once the branch changed it.',
648
+ ' With no baseline against main, --touched reads no rule as touched.',
635
649
  ' Any other tree reads .claude/rules/project/ alone and asks no ledger.',
636
650
  ' .claude/rules/canon/ is fixed at the toolkit source and never read',
637
651
  ' unless named. A named path resolves against --root and must sit',
@@ -654,6 +668,7 @@ export function register(program: Command): void {
654
668
  ' canon gov audit',
655
669
  ' canon gov audit .claude/rules/project/api.md',
656
670
  ' canon gov audit --json',
671
+ ' canon gov audit --gate --touched',
657
672
  '',
658
673
  ].join('\n'),
659
674
  )
@@ -672,7 +687,13 @@ async function runAudit(
672
687
  opts: AuditOptions,
673
688
  ): Promise<number> {
674
689
  const root = resolve(opts.root ?? process.cwd())
675
- const report = await readRuleAudit(root, paths)
690
+ const changed = opts.touched
691
+ ? await collectChangedFiles(commandRunner(root))
692
+ : undefined
693
+ // An unscoped set lists nothing, and reading it as every file would owe a
694
+ // ledger of every core rule on a branch that touched none of them.
695
+ const touched = changed?.scoped ? changed.files : []
696
+ const report = await readRuleAudit(root, paths, { touched })
676
697
  const emitJson = opts.json ?? false
677
698
 
678
699
  if (report.kind === 'unreadable') {
@@ -690,7 +711,7 @@ async function runAudit(
690
711
  return 1
691
712
  }
692
713
 
693
- reportAudit(report, root, paths.length > 0)
714
+ reportAudit(report, root, paths.length > 0, changed)
694
715
 
695
716
  if (emitJson) {
696
717
  process.stdout.write(
@@ -700,6 +721,9 @@ async function runAudit(
700
721
  limits: RULE_LIMITS,
701
722
  gated: RULE_GATED_FOLDERS,
702
723
  exempt: RULE_GATE_EXEMPT,
724
+ ...(changed === undefined
725
+ ? {}
726
+ : { touched: { scoped: changed.scoped } }),
703
727
  ...report,
704
728
  })}\n`,
705
729
  )
@@ -728,6 +752,7 @@ function reportAudit(
728
752
  report: Extract<RuleAuditReport, { kind: 'measured' }>,
729
753
  root: string,
730
754
  named: boolean,
755
+ changed: ChangedSet | undefined,
731
756
  ): void {
732
757
  intro('canon gov audit')
733
758
 
@@ -761,10 +786,15 @@ function reportAudit(
761
786
  }
762
787
 
763
788
  logStep('Ledgers')
789
+ if (changed !== undefined && !changed.scoped) {
790
+ logInfo(
791
+ 'no baseline against main, so --touched reads no core rule as touched',
792
+ )
793
+ }
764
794
  if (report.scope === 'target') {
765
795
  logInfo('not read, since ledgers live in the toolkit and never ship')
766
796
  } else if (ledgers.length === 0) {
767
- logInfo('every outside-practice rule has a ledger row per bullet')
797
+ logInfo('every rule owing a ledger has one, with a row per bullet')
768
798
  } else {
769
799
  for (const line of ledgers) logWarn(line)
770
800
  }
@@ -1309,6 +1309,13 @@ export function register(program: Command): void {
1309
1309
  'any ordinal evidence/ already carries. The dry run labels every',
1310
1310
  'derived destination, so check the split before passing --write.',
1311
1311
  '',
1312
+ 'Moves the design board to the folder Frameboard names:',
1313
+ ' canvas/ -> board/',
1314
+ '',
1315
+ 'Stop a frameboard serve on canvas/ before --write. A project holding',
1316
+ 'both folders gets a collision, and a design.toml board key naming',
1317
+ '.canon/canvas is left for you to update.',
1318
+ '',
1312
1319
  'A citation reaching into a moved folder or naming a moved file is',
1313
1320
  'repointed, live or archived. A bare mention of an emptied folder, such',
1314
1321
  'as review/evidence/ with no slug, matches no row and stays as written.',
@@ -1389,8 +1396,11 @@ export function register(program: Command): void {
1389
1396
  `It also carries ${Object.keys(SKILL_CITATION_MAP).length} namespaced-only rows for a skill renamed from a single word.`,
1390
1397
  'The seven draft- skills move onto verb-first names, such as',
1391
1398
  // The old spellings below are the rename itself. canon-keep-retired
1392
- 'draft-doc to write-doc and draft-and-pick to pick-by-eye, and a',
1393
- 'pointer left under each old name is never moved.',
1399
+ 'draft-doc to write-doc and draft-slides to make-deck, and a',
1400
+ 'pointer left under each old name is never moved. Four skills then',
1401
+ 'moved into the family their job belongs to, such as',
1402
+ // canon-keep-retired
1403
+ 'ux-audit to ui-audit and pick-by-eye to design-pick.',
1394
1404
  'It takes no scope. The skill folders it moves are authored in the',
1395
1405
  'toolkit and no target holds a copy of that catalog, so in a target it',
1396
1406
  'rewrites citations of a renamed skill and moves nothing.',
@@ -110,8 +110,9 @@ export function register(program: Command): void {
110
110
  '',
111
111
  'Structure, under `structure` and never in the exit:',
112
112
  " S1 to S5 from the plan standard, read on plans, a track's README and",
113
- ' numbered files, and intake clusters, plus intake-spill for content under',
114
- ' a field. A record written before the criteria misses S1, so it reports alone.',
113
+ " numbered files, intake clusters, and an intake folder's",
114
+ ' evidence/drive-log.md, plus intake-spill for content under a field.',
115
+ ' A record written before the criteria misses S1, so it reports alone.',
115
116
  '',
116
117
  'Refusals, read off `reason`:',
117
118
  " no-folder none of the kind's directories exist at either record root",
@@ -25,7 +25,7 @@ import {
25
25
  type RunEnvelope,
26
26
  type Verdict,
27
27
  } from '@/sandbox/expect'
28
- import { runHeadless } from '@/sandbox/headless/run'
28
+ import { runHeadless, runSettings } from '@/sandbox/headless/run'
29
29
  import { runSandbox } from '@/sandbox/provision'
30
30
  import {
31
31
  EXPECTED_FILE,
@@ -120,7 +120,12 @@ function showProvisionHelp(): void {
120
120
  }
121
121
 
122
122
  /** A run that reported nothing counts as clean, so only real signals fail. */
123
- const CLEAN_ENVELOPE: RunEnvelope = { isError: false, turns: 0, denials: 0 }
123
+ const CLEAN_ENVELOPE: RunEnvelope = {
124
+ isError: false,
125
+ isTruncated: false,
126
+ turns: 0,
127
+ denials: 0,
128
+ }
124
129
 
125
130
  interface CheckOptions {
126
131
  readonly envelope?: string
@@ -190,6 +195,8 @@ function readEnvelope(path: string | undefined): RunEnvelope | undefined {
190
195
 
191
196
  return {
192
197
  isError: parsed.is_error === true,
198
+ isTruncated: parsed.subtype === 'error_max_turns',
199
+ turnCap: Number(runSettings(process.env).maxTurns),
193
200
  turns: typeof parsed.num_turns === 'number' ? parsed.num_turns : 0,
194
201
  denials: Array.isArray(denials) ? denials.length : 0,
195
202
  reply: typeof parsed.result === 'string' ? parsed.result : undefined,
@@ -1,7 +1,9 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
5
+ import { cameraTrack, readRate } from '@/take/camera'
6
+ import { readTakeLog } from '@/take/log'
5
7
  import { readTakePlan } from '@/take/plan'
6
8
  import type { TakePlanRefusal } from '@/take/plan'
7
9
  import type { TakeRefusal } from '@/take/film'
@@ -24,11 +26,17 @@ import {
24
26
  type Take = typeof import('@/take/film')
25
27
 
26
28
  interface RunOptions {
27
- readonly out: string
29
+ readonly out?: string
28
30
  readonly reel?: string
29
31
  readonly json?: boolean
30
32
  }
31
33
 
34
+ interface CameraOptions {
35
+ readonly out?: string
36
+ readonly rate?: string
37
+ readonly json?: boolean
38
+ }
39
+
32
40
  const INSTALL_FFMPEG =
33
41
  'install FFmpeg from your package manager, such as apt install ffmpeg or brew install ffmpeg'
34
42
 
@@ -40,7 +48,8 @@ const PLAN_REFUSALS: Record<TakePlanRefusal, string> = {
40
48
  'bad-beat':
41
49
  'A beat is missing its name or carries a setup that is not a list.',
42
50
  'no-steps': 'A beat names no step, so its take would film nothing.',
43
- 'unknown-route': 'A beat names a route this build does not record on.',
51
+ 'unknown-route':
52
+ 'The plan or a beat names a route this build does not record on.',
44
53
  'bad-step': 'A step is missing something the take needs to perform it.',
45
54
  }
46
55
 
@@ -53,14 +62,19 @@ const TAKE_REFUSALS: Record<TakeRefusal, string> = {
53
62
  }
54
63
 
55
64
  export function register(program: Command): void {
56
- program
65
+ const take = program
57
66
  .command('take')
58
67
  .description(
59
68
  'Film a web app beat by beat with a click log and camera track',
60
69
  )
61
70
  .argument('<url>', 'Address to film')
62
71
  .argument('<plan>', 'JSON file naming the beats and their steps')
63
- .requiredOption('--out <dir>', 'Folder the clips, logs, and tracks land in')
72
+ // Checked in the action rather than required here, since commander checks
73
+ // a parent's required options on every subcommand, `camera` included.
74
+ .option('--out <dir>', 'Folder the clips, logs, and tracks land in')
75
+ // Keeps this command's options ahead of `camera`, which reuses --out and
76
+ // --json for its own.
77
+ .enablePositionalOptions()
64
78
  .helpOption('-h, --help', 'Show this help message')
65
79
  .option(
66
80
  '--reel <file>',
@@ -106,12 +120,33 @@ export function register(program: Command): void {
106
120
  'Step kinds:',
107
121
  ' move <target>|<x,y> glide the pointer to a target or a point',
108
122
  ' click <target> glide to the target and press it',
123
+ ' dblclick <target> glide to the target and press it twice',
124
+ ' drag <from> <to> press the from target, glide held, release on to',
125
+ ' key <keys> press a combination such as Control+K',
109
126
  ' fill <target> <text> press the target and type the text',
110
127
  ' scroll <target> <dy> glide to the target and wheel dy pixels',
111
128
  ' wait <ms> hold, for a state the page reaches on its own',
112
129
  '',
113
- 'A target is a CSS selector, or text=<words> for the innermost element',
114
- 'whose whole text is those words. Setup steps run before the first kept',
130
+ 'click, dblclick, and drag take modifiers, a list of Alt, Control, Meta,',
131
+ 'and Shift held through the press, as in "modifiers": ["Shift"]. keys',
132
+ 'names those modifiers joined by + ahead of one character or a named key:',
133
+ 'Enter, Tab, Escape, Backspace, Delete, Insert, Space, Home, End, PageUp,',
134
+ 'PageDown, the four arrows as ArrowUp and the like, and F1 to F12. A key',
135
+ 'moves no pointer, so the camera neither aims at it nor ends a zoom.',
136
+ '',
137
+ 'drag drives pointer events, the kind a canvas board listens for. It',
138
+ 'starts no native HTML5 drag of a draggable element on the stepped route,',
139
+ 'so a beat needing one films the same only on the screencast route.',
140
+ '',
141
+ 'A top-level route sets the route of every beat that names none of its',
142
+ 'own. A beat carrying push: false keeps its camera on the whole page,',
143
+ 'with the cursor and click ring still in the track, and its log.json',
144
+ 'records push: false. Each log.json carries clip, the clip size in',
145
+ 'pixels, which caps the zoom depth so a 1920-wide screencast stays sharp.',
146
+ '',
147
+ 'A target is a CSS selector, or text=<words> for the first innermost',
148
+ 'element with a size whose whole text is those words, so a hidden copy',
149
+ 'earlier in the page is skipped. Setup steps run before the first kept',
115
150
  'frame. Every beat opens and closes on a 0.6 s hold.',
116
151
  '',
117
152
  'Needs a reachable page, FFmpeg, and the headless shell. Install with:',
@@ -123,7 +158,8 @@ export function register(program: Command): void {
123
158
  ' no-beats the plan names no beat',
124
159
  ' bad-beat a beat has no name or a setup that is not a list',
125
160
  ' no-steps a beat names no step',
126
- ' unknown-route a beat names a route other than step or screencast',
161
+ ' unknown-route the plan or a beat names a route other than step or',
162
+ ' screencast',
127
163
  ' bad-step a step is missing what the take needs to perform it',
128
164
  ' engine-missing the browser engine is not installed in this project',
129
165
  ' ffmpeg-missing FFmpeg is not on the PATH',
@@ -133,6 +169,13 @@ export function register(program: Command): void {
133
169
  ' take-failed the take failed against a reachable page',
134
170
  ' reel-failed every beat was filmed and the reel did not encode',
135
171
  '',
172
+ 'A refusal in a beat carries beat. target-missing carries still when the',
173
+ "page answered a capture, the beat folder's still.jpg as the step met it. A",
174
+ 'beat that fell back to the screencast before refusing carries fallback,',
175
+ 'the reason it fell back. The screencast opens the address a second time,',
176
+ 'so a single-use link is spent by then: check the access before the',
177
+ 'selector.',
178
+ '',
136
179
  'Exit codes:',
137
180
  ' 0 every beat was filmed, and the reel written when asked for',
138
181
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -144,15 +187,113 @@ export function register(program: Command): void {
144
187
  '',
145
188
  ].join('\n'),
146
189
  )
147
- .action(async (url: string, plan: string, opts: RunOptions) => {
148
- process.exitCode = await runTake(url, plan, opts)
190
+ .action(
191
+ async (url: string, plan: string, opts: RunOptions, command: Command) => {
192
+ if (!opts.out)
193
+ command.error("error: required option '--out <dir>' not specified")
194
+ process.exitCode = await runTake(url, plan, { ...opts, out: opts.out })
195
+ },
196
+ )
197
+
198
+ take
199
+ .command('camera')
200
+ .description("Build a camera track from a take's log.json")
201
+ .argument('<log>', "A beat's log.json, as a take wrote it")
202
+ .helpOption('-h, --help', 'Show this help message')
203
+ .option('--out <file>', 'Write the track to a file rather than stdout')
204
+ .option(
205
+ '--rate <n>',
206
+ 'How many times faster the edit plays the clip, 1 by default',
207
+ )
208
+ .option('--json', 'Add a machine-readable record on stdout with --out')
209
+ .addHelpText(
210
+ 'after',
211
+ [
212
+ '',
213
+ "Builds the same camera track a take writes beside each beat's clip, from",
214
+ 'the log alone, so a track can be rebuilt for a clip the edit plays at',
215
+ 'another speed. With no --out the track is the data on stdout, one line,',
216
+ 'and a refusal under --json is the record there instead.',
217
+ '',
218
+ '--rate <n> builds the track on the playback clock of a clip sped up n',
219
+ 'times, dividing every time in the log by n, so a zoom settles at the',
220
+ 'speed a viewer sees rather than pumping several times a beat.',
221
+ '',
222
+ "The zoom depth is capped by the clip's resolution, read from the log's",
223
+ 'clip field, or from its route on a log written before that field. A log',
224
+ 'carrying push: false holds the whole page with the cursor and ring.',
225
+ '',
226
+ 'Reasons:',
227
+ ' no-log-file nothing readable at the log path',
228
+ " unreadable-log the file is not a take's log.json",
229
+ ' bad-rate --rate is not a positive number',
230
+ '',
231
+ 'Exit codes:',
232
+ ' 0 the track was written',
233
+ ' 1 refused, with the reason on stderr or in the JSON record',
234
+ '',
235
+ 'Examples:',
236
+ " canon take camera takes/01-open/log.json | jq '.frames | length'",
237
+ ' canon take camera takes/01-open/log.json --rate 2 --out camera-2x.json',
238
+ '',
239
+ ].join('\n'),
240
+ )
241
+ .action((log: string, opts: CameraOptions) => {
242
+ process.exitCode = runCamera(log, opts)
149
243
  })
150
244
  }
151
245
 
246
+ function runCamera(logPath: string, opts: CameraOptions): number {
247
+ const emitJson = opts.json ?? false
248
+ const path = resolve(logPath)
249
+ const refuseCamera = (reason: string, message: string): number => {
250
+ logWarn(message)
251
+ emit(emitJson, { log: path, reason, message })
252
+ return 1
253
+ }
254
+
255
+ const rate = opts.rate === undefined ? 1 : readRate(opts.rate)
256
+ if (rate === undefined) {
257
+ return refuseCamera(
258
+ 'bad-rate',
259
+ `--rate ${opts.rate} is not a positive number, such as 1.5 or 2.`,
260
+ )
261
+ }
262
+
263
+ let source: string
264
+ try {
265
+ source = readFileSync(path, 'utf8')
266
+ } catch (error) {
267
+ return refuseCamera(
268
+ 'no-log-file',
269
+ `No log file at ${path}. ${error instanceof Error ? error.message : String(error)}`,
270
+ )
271
+ }
272
+ const read = readTakeLog(source)
273
+ if (read.kind === 'refused') {
274
+ return refuseCamera(
275
+ 'unreadable-log',
276
+ `${path} is not a take's log.json, being the file beside a beat's clip.mp4. ${read.detail}`,
277
+ )
278
+ }
279
+
280
+ const track = cameraTrack(read.log, { rate })
281
+ const line = `${JSON.stringify(track)}\n`
282
+ if (!opts.out) {
283
+ process.stdout.write(line)
284
+ return 0
285
+ }
286
+ const out = resolve(opts.out)
287
+ writeFileSync(out, line)
288
+ logInfo(`${out} ${plural(track.frames.length, 'frame')} rate ${rate}`)
289
+ emit(emitJson, { log: path, out, frames: track.frames.length, rate })
290
+ return 0
291
+ }
292
+
152
293
  async function runTake(
153
294
  url: string,
154
295
  planPath: string,
155
- opts: RunOptions,
296
+ opts: RunOptions & { readonly out: string },
156
297
  ): Promise<number> {
157
298
  const emitJson = opts.json ?? false
158
299
  const path = resolve(planPath)
@@ -209,6 +350,11 @@ async function runTake(
209
350
  logStep('Refused')
210
351
  logWarn(TAKE_REFUSALS[result.reason])
211
352
  if (result.beat) logWarn(`in beat ${result.beat}`)
353
+ if (result.fallback) {
354
+ logWarn(
355
+ `after falling back to a screencast, which opened the address again: ${result.fallback}`,
356
+ )
357
+ }
212
358
  if (result.reason === 'server-unreachable') {
213
359
  logWarn(`Start whatever serves ${url}, then run this again.`)
214
360
  }
@@ -224,6 +370,8 @@ async function runTake(
224
370
  reason: result.reason,
225
371
  message: result.message,
226
372
  ...(result.beat ? { beat: result.beat } : {}),
373
+ ...(result.fallback ? { fallback: result.fallback } : {}),
374
+ ...(result.still ? { still: result.still } : {}),
227
375
  ...(result.reason === 'browser-missing'
228
376
  ? { install: INSTALL_BROWSER }
229
377
  : {}),
@@ -3,6 +3,13 @@ import type { Command } from 'commander'
3
3
  import { execa } from 'execa'
4
4
  import { gitEnv } from '@/git/env'
5
5
  import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
6
+ import {
7
+ DEFAULT_LAUNCH_WAIT_SECONDS,
8
+ type LaunchOutcome,
9
+ type LaunchWait,
10
+ readLaunchable,
11
+ waitForLaunchable,
12
+ } from '@/tasks/launch-wait'
6
13
  import {
7
14
  type BoardOutcome,
8
15
  type BoardRefused,
@@ -112,6 +119,12 @@ interface AnswersCommandOptions {
112
119
  readonly root?: string
113
120
  }
114
121
 
122
+ interface LaunchableCommandOptions {
123
+ readonly json?: boolean
124
+ readonly root?: string
125
+ readonly wait?: string | true
126
+ }
127
+
115
128
  interface BranchCommandOptions {
116
129
  readonly json?: boolean
117
130
  readonly root?: string
@@ -414,6 +427,72 @@ export function register(program: Command): void {
414
427
  process.exitCode = await runAnswers(plan, opts)
415
428
  })
416
429
 
430
+ tasks
431
+ .command('launchable')
432
+ .description(
433
+ 'Report whether a row has a linked plan the dispatch may launch',
434
+ )
435
+ .argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
436
+ .helpOption('-h, --help', 'Show this help message')
437
+ .option('--json', 'Emit a machine-readable record on stdout')
438
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
439
+ .option(
440
+ '--wait [seconds]',
441
+ `Hold until the row is launchable, default ${DEFAULT_LAUNCH_WAIT_SECONDS}`,
442
+ )
443
+ .addHelpText(
444
+ 'after',
445
+ [
446
+ '',
447
+ "Resolves the task's Plan: line and runs the plan-answers gate on the",
448
+ 'plan it names, in one read. It keys on the line rather than on the',
449
+ 'plan file, since a planner links a plan only after it validates, so a',
450
+ 'plan still being written never reads as landed. It answers planning',
451
+ 'alone: the branch and claim checks stay in the dispatch runbook.',
452
+ '',
453
+ 'The record carries task, plan, launchable, and open, the last three',
454
+ 'named as plan-answers names them. Read `reason`:',
455
+ ' unlinked the task carries no Plan: line yet',
456
+ ' open the plan still holds a question for the operator',
457
+ ' launchable the plan waits on nobody',
458
+ '',
459
+ '`--wait [seconds]` re-reads every 30 seconds until the row is',
460
+ 'launchable or the deadline passes. `reason` then reads `launchable`',
461
+ 'or `timeout`, the record carries the last reading, and',
462
+ '`waitedSeconds` is added. A refusal ends the wait at once. A wait',
463
+ 'past 600 seconds outlasts a foreground shell call, so run it in the',
464
+ 'background and act on the wake.',
465
+ '',
466
+ 'Refusals, read off `reason`:',
467
+ ' no-board the project carries no .canon/tasks/',
468
+ ' no-match no task carries that stem exactly',
469
+ ' ambiguous several tasks start with the name, so pass one in full',
470
+ ' several-plans the Plan: line links more than one plan',
471
+ ' no-plan the Plan: line names no file',
472
+ ' archived the Plan: line points into the plans archive',
473
+ ' bad-input the Plan: line carries an empty target',
474
+ ' bad-wait --wait takes a whole number of seconds above zero',
475
+ '',
476
+ 'Exit codes:',
477
+ ' 0 the row is launchable',
478
+ ' 1 refused, with the reason on stderr or in the JSON record',
479
+ ' 2 the row is unlinked or open, or the wait reached its deadline',
480
+ '',
481
+ 'It reports and never writes. Branch on `reason` rather than on the',
482
+ 'exit code, which a shell function wrapping canon can flatten to zero.',
483
+ '',
484
+ 'Examples:',
485
+ ' canon tasks launchable v28.1-trigger-escalation',
486
+ ' canon tasks launchable v28.1-trigger-escalation --json',
487
+ ' canon tasks launchable v28.1-trigger-escalation --wait --json',
488
+ ' canon tasks launchable v28.1-trigger-escalation --wait 900 --json',
489
+ '',
490
+ ].join('\n'),
491
+ )
492
+ .action(async (task: string, opts: LaunchableCommandOptions) => {
493
+ process.exitCode = await runLaunchable(task, opts)
494
+ })
495
+
417
496
  tasks
418
497
  .command('plan-branch')
419
498
  .description('Derive the branch name a dispatch and a worker both take')
@@ -1827,6 +1906,110 @@ function reportAnswers(
1827
1906
  return EXIT_FINDINGS
1828
1907
  }
1829
1908
 
1909
+ const BAD_WAIT = '--wait takes a whole number of seconds above zero.'
1910
+
1911
+ interface BadWait {
1912
+ readonly ok: false
1913
+ readonly reason: 'bad-wait'
1914
+ readonly message: string
1915
+ }
1916
+
1917
+ async function runLaunchable(
1918
+ task: string,
1919
+ opts: LaunchableCommandOptions,
1920
+ ): Promise<number> {
1921
+ const emitJson = opts.json ?? false
1922
+ const root = opts.root ?? (await mainWorktreeRoot())
1923
+
1924
+ let waitSeconds: number | undefined
1925
+ if (opts.wait === true) {
1926
+ waitSeconds = DEFAULT_LAUNCH_WAIT_SECONDS
1927
+ } else if (opts.wait !== undefined) {
1928
+ if (!/^\d+$/.test(opts.wait) || Number(opts.wait) === 0) {
1929
+ const refused: BadWait = {
1930
+ ok: false,
1931
+ reason: 'bad-wait',
1932
+ message: BAD_WAIT,
1933
+ }
1934
+ return reportLaunchable(refused, emitJson, root)
1935
+ }
1936
+ waitSeconds = Number(opts.wait)
1937
+ }
1938
+
1939
+ const outcome =
1940
+ waitSeconds === undefined
1941
+ ? await readLaunchable(root, task)
1942
+ : await waitForLaunchable(root, task, {
1943
+ waitSeconds,
1944
+ clock: {
1945
+ now: () => Date.now(),
1946
+ sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
1947
+ },
1948
+ })
1949
+
1950
+ return reportLaunchable(outcome, emitJson, root)
1951
+ }
1952
+
1953
+ function reportLaunchable(
1954
+ outcome: LaunchOutcome | LaunchWait | BadWait,
1955
+ emitJson: boolean,
1956
+ root: string,
1957
+ ): number {
1958
+ if (!outcome.ok) {
1959
+ intro('canon tasks launchable')
1960
+ logStep('Refused')
1961
+ logError(outcome.message)
1962
+ outro()
1963
+
1964
+ if (emitJson) {
1965
+ process.stdout.write(
1966
+ `${JSON.stringify({
1967
+ ok: false,
1968
+ reason: outcome.reason,
1969
+ message: outcome.message,
1970
+ ...('waitedSeconds' in outcome && {
1971
+ waitedSeconds: outcome.waitedSeconds,
1972
+ }),
1973
+ root,
1974
+ })}\n`,
1975
+ )
1976
+ }
1977
+ return 1
1978
+ }
1979
+
1980
+ const code = outcome.launchable ? 0 : EXIT_FINDINGS
1981
+
1982
+ if (emitJson) {
1983
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
1984
+ return code
1985
+ }
1986
+
1987
+ intro('canon tasks launchable')
1988
+ logStep(outcome.task)
1989
+
1990
+ if (outcome.plan === undefined) {
1991
+ logWarn('carries no Plan: line yet, so no plan has landed.')
1992
+ } else if (outcome.launchable) {
1993
+ logInfo(`${outcome.plan} waits on nobody, so the dispatch may launch it.`)
1994
+ } else {
1995
+ for (const question of outcome.open) {
1996
+ logWarn(`${question.label} ${question.why}`)
1997
+ }
1998
+ logError(`${outcome.plan} still waits on the operator.`)
1999
+ }
2000
+
2001
+ if ('waitedSeconds' in outcome) {
2002
+ logInfo(
2003
+ outcome.reason === 'timeout'
2004
+ ? `not launchable within ${outcome.waitedSeconds}s`
2005
+ : `launchable after ${outcome.waitedSeconds}s`,
2006
+ )
2007
+ }
2008
+
2009
+ outro()
2010
+ return code
2011
+ }
2012
+
1830
2013
  async function runBranch(
1831
2014
  plan: string,
1832
2015
  opts: BranchCommandOptions,