@erclx/canon 5.18.0 → 5.20.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 (116) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-skill/SKILL.md +1 -0
  4. package/claude/skills/plan-feature/SKILL.md +1 -1
  5. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  6. package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
  7. package/claude/skills/record-screencast/SKILL.md +2 -2
  8. package/claude/skills/role-worker/REQUIREMENT.md +3 -1
  9. package/claude/skills/role-worker/SKILL.md +5 -3
  10. package/claude/skills/session-resume/SKILL.md +1 -1
  11. package/claude/skills/task-board/REQUIREMENT.md +1 -0
  12. package/claude/skills/task-board/SKILL.md +6 -0
  13. package/claude/skills/task-board/references/decline.md +2 -0
  14. package/claude/skills/video-craft/REQUIREMENT.md +48 -0
  15. package/claude/skills/video-craft/SKILL.md +80 -0
  16. package/claude/skills/video-craft/references/adopted.md +66 -0
  17. package/docs/agents/canvas.md +4 -0
  18. package/docs/agents/commands.md +1 -0
  19. package/docs/agents/demo.md +1 -1
  20. package/docs/agents/install-and-sync.md +17 -3
  21. package/docs/agents/rule-citations.md +4 -4
  22. package/docs/agents/sandbox.md +8 -7
  23. package/docs/agents/teach.md +7 -1
  24. package/docs/workflow/operating-model.md +9 -9
  25. package/docs/workflow/parallel-features.md +1 -1
  26. package/docs/workflow/skill-map.md +1 -0
  27. package/governance/renames.toml +24 -0
  28. package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
  29. package/governance/rules/standards/context.md +15 -0
  30. package/governance/rules/standards/design.md +13 -0
  31. package/governance/rules/standards/diagrams.md +17 -0
  32. package/governance/rules/standards/groundwork.md +13 -0
  33. package/governance/rules/standards/intake.md +12 -0
  34. package/governance/rules/standards/markdown.md +15 -0
  35. package/governance/rules/standards/memory.md +12 -0
  36. package/governance/rules/standards/mermaid.md +15 -0
  37. package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
  38. package/governance/rules/standards/readme.md +12 -0
  39. package/governance/rules/standards/ready.md +13 -0
  40. package/governance/rules/standards/requirements.md +13 -0
  41. package/governance/rules/standards/rule.md +13 -0
  42. package/governance/rules/standards/session.md +13 -0
  43. package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
  44. package/governance/rules/standards/standard.md +13 -0
  45. package/governance/rules/standards/tasks.md +15 -0
  46. package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
  47. package/governance/rules/standards/wiki.md +13 -0
  48. package/governance/rules/standards/wireframes.md +14 -0
  49. package/governance/rules/writing/800-prose.md +1 -1
  50. package/governance/stacks/base.toml +4 -2
  51. package/package.json +2 -1
  52. package/src/canvas/capture.ts +228 -15
  53. package/src/claude/cases/misc.ts +5 -0
  54. package/src/claude/routing.ts +1 -1
  55. package/src/commands/canvas.ts +76 -2
  56. package/src/commands/gov.ts +19 -4
  57. package/src/commands/sandbox.ts +7 -0
  58. package/src/commands/slides.ts +73 -4
  59. package/src/commands/teach.ts +112 -4
  60. package/src/gate/stages.ts +7 -1
  61. package/src/gov/citations.ts +1 -1
  62. package/src/gov/standard-rules.ts +179 -0
  63. package/src/sandbox/equivalence.ts +43 -14
  64. package/src/sandbox/stub-remote.ts +140 -0
  65. package/src/serve/live.ts +113 -0
  66. package/src/serve/static.ts +60 -2
  67. package/src/slides/convert/export.ts +323 -0
  68. package/src/slides/convert/shapes.ts +764 -0
  69. package/src/slides/convert/svg.ts +38 -0
  70. package/src/slides/convert/walk.ts +444 -0
  71. package/src/teach/browser/reload.ts +9 -0
  72. package/src/teach/nav.ts +7 -6
  73. package/src/teach/workspace.ts +2 -1
  74. package/src/teach/write-if-changed.ts +17 -0
  75. package/standards/architecture.md +5 -0
  76. package/standards/context.md +6 -0
  77. package/standards/design.md +3 -0
  78. package/standards/diagrams.md +8 -0
  79. package/standards/groundwork-spikes.md +66 -0
  80. package/standards/groundwork.md +5 -25
  81. package/standards/index.md +6 -2
  82. package/standards/intake.md +2 -0
  83. package/standards/markdown.md +6 -0
  84. package/standards/memory.md +2 -0
  85. package/standards/mermaid.md +6 -0
  86. package/standards/plan-lifecycle.md +36 -0
  87. package/standards/plan.md +13 -11
  88. package/standards/readme.md +2 -0
  89. package/standards/ready.md +4 -0
  90. package/standards/requirements.md +3 -0
  91. package/standards/rule.md +6 -1
  92. package/standards/session.md +4 -0
  93. package/standards/skill-paths.md +50 -0
  94. package/standards/skill.md +15 -23
  95. package/standards/standard.md +7 -1
  96. package/standards/tasks-decline.md +38 -0
  97. package/standards/tasks.md +8 -13
  98. package/standards/teach.md +5 -0
  99. package/standards/wiki.md +4 -0
  100. package/standards/wireframes.md +5 -0
  101. package/governance/rules/canon/610-context.md +0 -17
  102. package/governance/rules/canon/620-wireframes.md +0 -13
  103. package/governance/rules/canon/630-requirements.md +0 -12
  104. package/governance/rules/canon/650-design.md +0 -12
  105. package/governance/rules/canon/655-tasks.md +0 -13
  106. package/governance/rules/canon/656-groundwork.md +0 -11
  107. package/governance/rules/canon/657-intake.md +0 -11
  108. package/governance/rules/canon/659-memory.md +0 -11
  109. package/governance/rules/canon/660-diagrams.md +0 -19
  110. package/governance/rules/canon/662-session.md +0 -15
  111. package/governance/rules/canon/663-ready.md +0 -15
  112. package/governance/rules/claude/590-rule-authoring.md +0 -12
  113. package/governance/rules/writing/801-markdown.md +0 -14
  114. package/governance/rules/writing/802-mermaid.md +0 -14
  115. package/governance/rules/writing/880-readme.md +0 -11
  116. package/governance/rules/writing/891-standard-authoring.md +0 -12
@@ -1,6 +1,12 @@
1
- import { join } from 'node:path'
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
2
3
  import type { CaptureResult } from '@/capture/render'
3
- import { type ContentRefused, type Frame, readPage } from '@/canvas/content'
4
+ import {
5
+ type ContentRefused,
6
+ canvasDir,
7
+ type Frame,
8
+ readPage,
9
+ } from '@/canvas/content'
4
10
  import {
5
11
  type CanvasOptions,
6
12
  type CanvasOutcome,
@@ -134,6 +140,34 @@ export async function captureCanvas(
134
140
  const early = resolveCaptureTargets(root, spec, '')
135
141
  if (!early.ok) return early
136
142
 
143
+ return serveFor(root, deps, async (base, capture) => {
144
+ const resolved = resolveCaptureTargets(root, spec, base, out)
145
+ if (!resolved.ok) return resolved
146
+
147
+ const captures: { target: CaptureTarget; result: CaptureResult }[] = []
148
+ for (const target of resolved.targets) {
149
+ const [result] = await capture(target.url, {
150
+ selector: CAPTURE_SELECTOR,
151
+ width: target.width,
152
+ outDir: target.pngPath,
153
+ })
154
+ if (result) captures.push({ target, result })
155
+ }
156
+ return { ok: true, captures }
157
+ })
158
+ }
159
+
160
+ type ServeRefused = {
161
+ readonly ok: false
162
+ readonly reason: 'no-server' | 'capture-failed'
163
+ readonly detail: string
164
+ }
165
+
166
+ async function serveFor<T>(
167
+ root: string,
168
+ deps: CaptureDeps,
169
+ run: (base: string, capture: CaptureEngine) => Promise<T>,
170
+ ): Promise<T | ServeRefused> {
137
171
  const start = deps.start ?? startCanvas
138
172
  const server = start(root, { port: 0, shell: new Response('') })
139
173
  if (!server.ok) {
@@ -145,21 +179,9 @@ export async function captureCanvas(
145
179
  }
146
180
 
147
181
  try {
148
- const resolved = resolveCaptureTargets(root, spec, server.url, out)
149
- if (!resolved.ok) return resolved
150
-
151
182
  const capture =
152
183
  deps.capture ?? (await import('@/capture/render')).captureSources
153
- const captures: { target: CaptureTarget; result: CaptureResult }[] = []
154
- for (const target of resolved.targets) {
155
- const [result] = await capture(target.url, {
156
- selector: CAPTURE_SELECTOR,
157
- width: target.width,
158
- outDir: target.pngPath,
159
- })
160
- if (result) captures.push({ target, result })
161
- }
162
- return { ok: true, captures }
184
+ return await run(server.url, capture)
163
185
  } catch (error) {
164
186
  return {
165
187
  ok: false,
@@ -170,3 +192,194 @@ export async function captureCanvas(
170
192
  await server.stop()
171
193
  }
172
194
  }
195
+
196
+ /** The element a composite is captured by, holding every frame and no chrome. */
197
+ export const COMPOSITE_SELECTOR = '#canvas-composite'
198
+
199
+ /**
200
+ * The container holds no text, so its family exists only for the engine's
201
+ * font probe, which rejects a family the machine lacks. The browser default is
202
+ * Times New Roman, which a Linux runner often lacks, and `system-ui` is the one
203
+ * every platform maps. Each frame keeps its own fonts inside its iframe.
204
+ */
205
+ const COMPOSITE_FONT = 'system-ui'
206
+
207
+ export interface Placement {
208
+ readonly frame: string
209
+ readonly url: string
210
+ readonly left: number
211
+ readonly top: number
212
+ readonly width: number
213
+ readonly height: number
214
+ }
215
+
216
+ export interface Composite {
217
+ readonly placements: readonly Placement[]
218
+ readonly width: number
219
+ readonly height: number
220
+ readonly html: string
221
+ }
222
+
223
+ /**
224
+ * One document showing every frame of a page at its layout position. Offsets
225
+ * run from the top left of the union of the boxes, so a negative coordinate
226
+ * never clips and a lone frame fills the container exactly. Each iframe takes
227
+ * its box, so a document taller than its frame clips as it does on the board,
228
+ * and frames paint in the order given, which is the board's.
229
+ */
230
+ export function buildComposite(
231
+ page: string,
232
+ frames: readonly Frame[],
233
+ base: string,
234
+ ): Composite {
235
+ const left = Math.min(...frames.map((frame) => frame.x))
236
+ const top = Math.min(...frames.map((frame) => frame.y))
237
+ const right = Math.max(...frames.map((frame) => frame.x + frame.width))
238
+ const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
239
+ const width = right - left
240
+ const height = bottom - top
241
+
242
+ const placements = frames.map((frame) => ({
243
+ frame: frame.name,
244
+ url: frameUrl(base, page, frame),
245
+ left: frame.x - left,
246
+ top: frame.y - top,
247
+ width: frame.width,
248
+ height: frame.height,
249
+ }))
250
+
251
+ const iframes = placements.map(
252
+ (p) =>
253
+ `<iframe title="${escapeAttribute(p.frame)}" src="${escapeAttribute(p.url)}" style="left:${p.left}px;top:${p.top}px;width:${p.width}px;height:${p.height}px"></iframe>`,
254
+ )
255
+ const html = [
256
+ '<!doctype html>',
257
+ '<html><head><meta charset="utf-8">',
258
+ `<style>html,body{margin:0;background:transparent}#canvas-composite{position:relative;font-family:${COMPOSITE_FONT}}#canvas-composite iframe{position:absolute;display:block;border:0;background:transparent}</style>`,
259
+ '</head><body>',
260
+ `<div id="canvas-composite" style="width:${width}px;height:${height}px">`,
261
+ ...iframes,
262
+ '</div></body></html>',
263
+ '',
264
+ ].join('\n')
265
+
266
+ return { placements, width, height, html }
267
+ }
268
+
269
+ function escapeAttribute(value: string): string {
270
+ return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;')
271
+ }
272
+
273
+ /**
274
+ * Where the generated document is served from, inside the canvas folder so it
275
+ * shares the frames' origin. Chromium skips painting a cross-origin iframe
276
+ * outside the viewport, and the engine's viewport is 720 pixels tall, so a
277
+ * document on another origin captured every frame below that line blank. A dot
278
+ * folder is no page name, so the board never lists it.
279
+ */
280
+ const COMPOSITE_FOLDER = '.composite'
281
+
282
+ export interface CompositeTarget {
283
+ readonly page: string
284
+ readonly composite: Composite
285
+ readonly htmlPath: string
286
+ /** The served address of the document at `htmlPath`. */
287
+ readonly url: string
288
+ readonly pngPath: string
289
+ }
290
+
291
+ export type CompositeTargetOutcome =
292
+ | { readonly ok: true; readonly target: CompositeTarget }
293
+ | ContentRefused
294
+
295
+ /**
296
+ * Turns `<page>` into one composite. `out` is the PNG, and without it the PNG
297
+ * lands at `<page>.png` beside the per-frame folder of the same name, so the
298
+ * two captures of one page never collide.
299
+ */
300
+ export function resolveCompositeTarget(
301
+ root: string,
302
+ spec: string,
303
+ base: string,
304
+ out?: string,
305
+ ): CompositeTargetOutcome {
306
+ if (spec.includes('/')) {
307
+ return refuse(
308
+ 'invalid-name',
309
+ `--composite takes a page, not ${spec}, so capture <page> or drop the flag`,
310
+ )
311
+ }
312
+
313
+ const page = readPage(root, spec)
314
+ if (!page) return refuse('no-page', `page ${spec} does not exist`)
315
+ if (page.frames.length === 0) {
316
+ return refuse('no-frame', `page ${spec} has no frames`)
317
+ }
318
+
319
+ const file = `${spec}.html`
320
+ return {
321
+ ok: true,
322
+ target: {
323
+ page: spec,
324
+ composite: buildComposite(spec, page.frames, base),
325
+ htmlPath: join(canvasDir(root), COMPOSITE_FOLDER, file),
326
+ url: `${base}${FRAMES_PREFIX.slice(1)}${COMPOSITE_FOLDER}/${encodeURIComponent(file)}`,
327
+ pngPath:
328
+ out ?? join(recordDir(root, 'tmp', 'canvas-capture'), `${spec}.png`),
329
+ },
330
+ }
331
+ }
332
+
333
+ export type CompositeOutcome =
334
+ | {
335
+ readonly ok: true
336
+ readonly composite: {
337
+ readonly target: CompositeTarget
338
+ readonly result: CaptureResult
339
+ }
340
+ }
341
+ | ContentRefused
342
+ | ServeRefused
343
+
344
+ /**
345
+ * Captures a page as one PNG through the served canvas, the way `captureCanvas`
346
+ * captures each frame. The viewport is the container's width, so no frame's
347
+ * media query resolves against a window narrower than the frame itself. The
348
+ * page load the engine waits on waits on every iframe.
349
+ */
350
+ export async function captureComposite(
351
+ root: string,
352
+ spec: string,
353
+ out?: string,
354
+ deps: CaptureDeps = {},
355
+ ): Promise<CompositeOutcome> {
356
+ const early = resolveCompositeTarget(root, spec, '')
357
+ if (!early.ok) return early
358
+
359
+ return serveFor(root, deps, async (base, capture) => {
360
+ const resolved = resolveCompositeTarget(root, spec, base, out)
361
+ if (!resolved.ok) return resolved
362
+
363
+ const { target } = resolved
364
+ mkdirSync(dirname(target.htmlPath), { recursive: true })
365
+ writeFileSync(target.htmlPath, target.composite.html)
366
+ let result: CaptureResult | undefined
367
+ try {
368
+ ;[result] = await capture(target.url, {
369
+ selector: COMPOSITE_SELECTOR,
370
+ width: target.composite.width,
371
+ outDir: target.pngPath,
372
+ })
373
+ } finally {
374
+ rmSync(target.htmlPath, { force: true })
375
+ }
376
+ if (!result) {
377
+ return {
378
+ ok: false,
379
+ reason: 'capture-failed',
380
+ detail: `the engine rendered nothing for ${spec}`,
381
+ }
382
+ }
383
+ return { ok: true, composite: { target, result } }
384
+ })
385
+ }
@@ -84,6 +84,11 @@ export const MISC_CASES: readonly SkillCase[] = [
84
84
  'Find me the strongest evidence on whether rent control reduces housing supply. Where should I look?',
85
85
  expect: 'search-craft',
86
86
  },
87
+ {
88
+ prompt:
89
+ 'The composed demo video looks generic and the zoom drags. What should the captions say, and how fast should the push-in be?',
90
+ expect: 'video-craft',
91
+ },
87
92
  {
88
93
  prompt:
89
94
  "I've got the plan approved and it touches the parser, the command, and the docs. How should I build it so I don't end up with one giant diff?",
@@ -196,7 +196,7 @@ function frontmatter(text: string): string {
196
196
  *
197
197
  * A glob opening `**` reaches every folder in the tree, so it answers that a
198
198
  * file type is governed and never that a named path is. Counting one would
199
- * report every markdown path covered by `801-markdown` and leave the column
199
+ * report every markdown path covered by the markdown rule and leave the column
200
200
  * saying nothing a reader could act on.
201
201
  */
202
202
  function isAnchored(glob: string): boolean {
@@ -13,7 +13,7 @@ import {
13
13
  readSelection,
14
14
  renamePage,
15
15
  } from '@/canvas/content'
16
- import { captureCanvas } from '@/canvas/capture'
16
+ import { captureCanvas, captureComposite } from '@/canvas/capture'
17
17
  import {
18
18
  type EditRefused,
19
19
  editFrameAtIndex,
@@ -468,6 +468,10 @@ export function register(program: Command): void {
468
468
  .option(
469
469
  '-o, --out <path>',
470
470
  'The PNG for a frame, or the folder for a page; defaults to session scratch',
471
+ )
472
+ .option(
473
+ '--composite',
474
+ 'Capture a page as one PNG with every frame at its layout position; --out is then the PNG',
471
475
  ),
472
476
  )
473
477
  .addHelpText(
@@ -478,18 +482,25 @@ export function register(program: Command): void {
478
482
  'the injected tokens are in the PNG and each frame renders at its own',
479
483
  'width. The raw file on disk is never captured.',
480
484
  '',
485
+ 'With --composite, each frame is clipped to its box as on the board and',
486
+ 'shows in its default theme, with no captions or selection outline.',
487
+ '',
481
488
  'Exit codes:',
482
489
  ' 0 every frame rendered',
483
490
  ' 1 refused or a frame failed, with the reason on stderr or in the JSON record',
484
491
  '',
485
492
  ].join('\n'),
486
493
  )
487
- .action(async (target: string, opts: RootOptions & { out?: string }) => {
494
+ .action(async (target: string, opts: CaptureOptions) => {
488
495
  const resolved = await resolveRoot(opts)
489
496
  if (!resolved.ok) {
490
497
  process.exitCode = refuse(resolved, opts.json ?? false)
491
498
  return
492
499
  }
500
+ if (opts.composite) {
501
+ process.exitCode = await runComposite(resolved.root, target, opts)
502
+ return
503
+ }
493
504
  let outcome: Awaited<ReturnType<typeof captureCanvas>>
494
505
  try {
495
506
  outcome = await captureCanvas(
@@ -543,6 +554,69 @@ export function register(program: Command): void {
543
554
  })
544
555
  }
545
556
 
557
+ interface CaptureOptions extends RootOptions {
558
+ readonly out?: string
559
+ readonly composite?: boolean
560
+ }
561
+
562
+ async function runComposite(
563
+ root: string,
564
+ target: string,
565
+ opts: CaptureOptions,
566
+ ): Promise<number> {
567
+ const emitJson = opts.json ?? false
568
+ let outcome: Awaited<ReturnType<typeof captureComposite>>
569
+ try {
570
+ outcome = await captureComposite(
571
+ root,
572
+ target,
573
+ opts.out ? resolve(process.cwd(), opts.out) : undefined,
574
+ )
575
+ } catch (error) {
576
+ const detail = error instanceof Error ? error.message : String(error)
577
+ return refuse({ ok: false, reason: 'capture-failed', detail }, emitJson)
578
+ }
579
+ if (!outcome.ok) return refuse(outcome, emitJson)
580
+
581
+ const { target: item, result } = outcome.composite
582
+ if (result.status === 'failed') {
583
+ if (emitJson) {
584
+ writeJson({
585
+ ok: false,
586
+ composite: {
587
+ page: item.page,
588
+ status: result.status,
589
+ reason: result.reason,
590
+ },
591
+ })
592
+ }
593
+ intro(BANNER)
594
+ logError(`${item.page}: ${result.reason}`)
595
+ outro()
596
+ return 1
597
+ }
598
+
599
+ if (emitJson) {
600
+ writeJson({
601
+ ok: true,
602
+ composite: {
603
+ page: item.page,
604
+ status: result.status,
605
+ path: result.pngPath,
606
+ width: result.width,
607
+ height: result.height,
608
+ },
609
+ })
610
+ }
611
+ intro(BANNER)
612
+ logAdd(
613
+ `${item.page} composite of ${plural(item.composite.placements.length, 'frame')}`,
614
+ )
615
+ logInfo(`${displayPath(result.pngPath)} ${result.width}x${result.height}`)
616
+ outro()
617
+ return 0
618
+ }
619
+
546
620
  /** NaN for a value that is not a number, which the content module refuses. */
547
621
  function parsePosition(raw: string | undefined, fallback: number): number {
548
622
  if (raw === undefined) return fallback
@@ -12,6 +12,7 @@ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
12
12
  import { creationRel, SCRATCH } from '@/record-root'
13
13
  import { createGovAdapter } from '@/gov/adapter'
14
14
  import { regenConsumedRules } from '@/gov/consumed'
15
+ import { regenStandardRules } from '@/gov/standard-rules'
15
16
  import { installRules, lookupRules } from '@/gov/install'
16
17
  import { buildGovCatalog, describeRule, describeStack } from '@/gov/list'
17
18
  import { buildRulesPayload, listRuleFiles } from '@/gov/payload'
@@ -190,15 +191,19 @@ export function register(program: Command): void {
190
191
 
191
192
  gov
192
193
  .command('regen')
193
- .description("Rebuild a repository's own .claude/rules/ from its record")
194
+ .description(
195
+ "Rebuild the standard-pointer rules and a repository's own .claude/rules/",
196
+ )
194
197
  .helpOption('-h, --help', 'Show this help message')
195
198
  .option('--root <path>', 'Repository root to regenerate', PROJECT_ROOT)
196
199
  .addHelpText(
197
200
  'after',
198
201
  [
199
202
  '',
200
- 'Reads internal/governance.toml and installs the stack it names, plus',
201
- 'any rules under internal/rules/. Unlike install and sync, this runs',
203
+ 'First writes governance/rules/standards/ from the paths: and rule:',
204
+ 'fields in each standard under standards/. Then reads',
205
+ 'internal/governance.toml and installs the stack it names, plus any',
206
+ 'rules under internal/rules/. Unlike install and sync, this runs',
202
207
  'against the toolkit root, whose .claude/rules/ is produced output.',
203
208
  '',
204
209
  ].join('\n'),
@@ -1114,7 +1119,17 @@ async function runRegen(
1114
1119
  if (mismatch !== undefined) logWarn(mismatch)
1115
1120
  }
1116
1121
 
1117
- const result = await regenConsumedRules(resolve(opts.root ?? PROJECT_ROOT))
1122
+ const root = resolve(opts.root ?? PROJECT_ROOT)
1123
+
1124
+ // The consumed copy installs from `governance/rules/`, so the band this
1125
+ // writes has to be fresh before that copy is built from it.
1126
+ const generated = await regenStandardRules(root)
1127
+ if (!generated.ok) {
1128
+ process.stderr.write(`Standard-rules regen failed: ${generated.reason}\n`)
1129
+ return 1
1130
+ }
1131
+
1132
+ const result = await regenConsumedRules(root)
1118
1133
 
1119
1134
  if (!result.ok) {
1120
1135
  process.stderr.write(`Consumed-rules regen failed: ${result.reason}\n`)
@@ -346,6 +346,7 @@ function runCoverage(options: CoverageOptions): void {
346
346
  interface EquivalenceOptions {
347
347
  readonly base: string
348
348
  readonly includeAnchor?: boolean
349
+ readonly stubRemote?: boolean
349
350
  readonly out?: string
350
351
  readonly masks: boolean
351
352
  readonly json?: boolean
@@ -372,6 +373,7 @@ async function runEquivalence(
372
373
  targets,
373
374
  base: options.base,
374
375
  includeAnchor: options.includeAnchor === true,
376
+ stubRemote: options.stubRemote === true,
375
377
  useMasks: options.masks,
376
378
  out: options.out,
377
379
  })
@@ -568,6 +570,10 @@ export function register(program: Command): void {
568
570
  '--include-anchor',
569
571
  'Also run the arms that push to the shared GitHub anchor, one at a time (attended)',
570
572
  )
573
+ .option(
574
+ '--stub-remote',
575
+ 'Run the anchor arms against a local bare repository and a stub gh, writing nothing to the shared anchor',
576
+ )
571
577
  .option('--out <dir>', 'Keep the provisioned trees and logs in this folder')
572
578
  .option(
573
579
  '--no-masks',
@@ -583,6 +589,7 @@ export function register(program: Command): void {
583
589
  ' canon sandbox equivalence claude:plan-feature --base origin/main',
584
590
  '',
585
591
  'Provisions offline only and never runs sandbox/run.sh or a claude binary.',
592
+ '--stub-remote also runs the anchor arms, and wins over --include-anchor.',
586
593
  'Exit codes: 0 when every compared arm is identical or red the same way',
587
594
  'on both sides, 1 on any difference, exit mismatch, or enumeration error.',
588
595
  ].join('\n'),
@@ -1,6 +1,7 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { INSTALL_BROWSER } from '@/browser/engine'
4
5
  import { creationRel, SCRATCH } from '@/record-root'
5
6
  import { LAYOUTS } from '@/slides/layouts'
6
7
  import { openDeck } from '@/slides/open'
@@ -17,8 +18,14 @@ export function register(program: Command): void {
17
18
  slides
18
19
  .command('render')
19
20
  .helpOption('-h, --help', 'Show this help message')
20
- .description('Render a SLIDES.md source into a PowerPoint deck')
21
- .option('-s, --source <path>', 'Source SLIDES.md path', '.claude/SLIDES.md')
21
+ .description(
22
+ 'Render a SLIDES.md source, or a folder of HTML slides, into a PowerPoint deck',
23
+ )
24
+ .option(
25
+ '-s, --source <path>',
26
+ 'Source SLIDES.md file, or a folder of .html slides read in filename order',
27
+ '.claude/SLIDES.md',
28
+ )
22
29
  .option(
23
30
  '-o, --out <path>',
24
31
  'Output directory',
@@ -44,8 +51,15 @@ export function register(program: Command): void {
44
51
  if (!existsSync(sourcePath)) {
45
52
  fail(`${opts.source} not found`)
46
53
  }
47
- const variant = parseVariant(opts.variant)
48
54
  const mirror = resolveMirror(opts.mirror)
55
+ if (statSync(sourcePath).isDirectory()) {
56
+ if (opts.variant !== undefined) {
57
+ fail('--variant applies to a SLIDES.md source, not a folder')
58
+ }
59
+ await renderHtmlFolder(sourcePath, outDir, mirror, opts.open)
60
+ return
61
+ }
62
+ const variant = parseVariant(opts.variant)
49
63
  const { GREEN, GREY, NC, RED } = palette(process.stderr)
50
64
  intro('Render slides')
51
65
  const result = await renderSlidesDoc(sourcePath, outDir, {
@@ -103,6 +117,61 @@ export function register(program: Command): void {
103
117
  })
104
118
  }
105
119
 
120
+ /**
121
+ * The converter loads on demand, so no other command resolves pptxgenjs,
122
+ * jszip, or the token module at startup. A fallback or a refused link reports
123
+ * one line each and keeps the exit at 0, as an unrecognized layout does on the
124
+ * markdown path, since the deck is written either way.
125
+ */
126
+ async function renderHtmlFolder(
127
+ sourceDir: string,
128
+ outDir: string,
129
+ mirror: string | undefined,
130
+ shouldOpen: boolean | undefined,
131
+ ): Promise<void> {
132
+ const { exportHtmlDeck } = await import('@/slides/convert/export')
133
+ const { GREEN, GREY, NC, RED } = palette(process.stderr)
134
+ const result = await exportHtmlDeck(sourceDir, outDir, {
135
+ root: process.cwd(),
136
+ mirror,
137
+ })
138
+ if (result.status === 'refused') {
139
+ fail(
140
+ result.reason === 'browser-missing'
141
+ ? `Chromium is not installed. Run ${INSTALL_BROWSER}`
142
+ : result.message,
143
+ )
144
+ }
145
+ intro('Render slides')
146
+ process.stderr.write(
147
+ `${GREY}│${NC} ${GREEN}✓${NC} ${result.slideCount} slides\n${GREY}│${NC} ${GREEN}✓${NC} ${result.pptxPath}\n`,
148
+ )
149
+ for (const { slide, selector, properties } of result.fallbacks) {
150
+ process.stderr.write(
151
+ `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} drawn as a picture for ${properties.join(', ')}\n`,
152
+ )
153
+ }
154
+ for (const { slide, selector, href, reason } of result.refusedLinks) {
155
+ process.stderr.write(
156
+ `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} link "${href}" left out: ${reason}\n`,
157
+ )
158
+ }
159
+ if (result.mirrorPath) {
160
+ process.stderr.write(
161
+ `${GREY}│${NC} ${GREEN}✓${NC} mirrored to ${result.mirrorPath}\n`,
162
+ )
163
+ }
164
+ if (shouldOpen) {
165
+ const target = result.mirrorPath ?? result.pptxPath
166
+ const opened = await openDeck(target)
167
+ const mark = opened ? `${GREEN}✓${NC}` : `${RED}✗${NC}`
168
+ process.stderr.write(
169
+ `${GREY}│${NC} ${mark} ${opened ? 'opened' : 'could not open'} ${target}\n`,
170
+ )
171
+ }
172
+ outro()
173
+ }
174
+
106
175
  function parseVariant(value: string | undefined): Variant | undefined {
107
176
  if (value === undefined) return undefined
108
177
  if (value === 'light' || value === 'dark') return value