@erclx/canon 5.19.0 → 5.21.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 (135) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/references/verb-absent.md +1 -1
  3. package/claude/skills/create-skill/SKILL.md +1 -0
  4. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  5. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  6. package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
  7. package/claude/skills/draft-ready/SKILL.md +6 -4
  8. package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
  9. package/claude/skills/markdown-propose/SKILL.md +1 -1
  10. package/claude/skills/plan-feature/SKILL.md +1 -1
  11. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  12. package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
  13. package/claude/skills/record-screencast/SKILL.md +2 -2
  14. package/claude/skills/role-worker/REQUIREMENT.md +3 -1
  15. package/claude/skills/role-worker/SKILL.md +5 -3
  16. package/claude/skills/session-resume/SKILL.md +1 -1
  17. package/claude/skills/task-board/REQUIREMENT.md +1 -0
  18. package/claude/skills/task-board/SKILL.md +6 -0
  19. package/claude/skills/task-board/references/decline.md +2 -0
  20. package/claude/skills/video-craft/REQUIREMENT.md +48 -0
  21. package/claude/skills/video-craft/SKILL.md +80 -0
  22. package/claude/skills/video-craft/references/adopted.md +66 -0
  23. package/docs/agents/canvas.md +4 -0
  24. package/docs/agents/commands.md +1 -0
  25. package/docs/agents/demo.md +1 -1
  26. package/docs/agents/index.md +1 -0
  27. package/docs/agents/install-and-sync.md +17 -3
  28. package/docs/agents/markdown-audit.md +1 -3
  29. package/docs/agents/rule-citations.md +4 -4
  30. package/docs/agents/sandbox.md +8 -7
  31. package/docs/agents/slides.md +120 -0
  32. package/docs/agents/tasks-validate.md +1 -1
  33. package/docs/agents/tasks.md +1 -1
  34. package/docs/target/migrations.md +1 -1
  35. package/docs/workflow/ai-workflow.md +1 -1
  36. package/docs/workflow/operating-model.md +9 -9
  37. package/docs/workflow/parallel-features.md +1 -1
  38. package/docs/workflow/skill-map.md +12 -13
  39. package/governance/renames.toml +24 -0
  40. package/governance/rules/lib/330-testing-py.md +1 -1
  41. package/governance/rules/lib/336-testing-php.md +1 -1
  42. package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
  43. package/governance/rules/standards/context.md +15 -0
  44. package/governance/rules/standards/design.md +13 -0
  45. package/governance/rules/standards/diagrams.md +17 -0
  46. package/governance/rules/standards/groundwork.md +13 -0
  47. package/governance/rules/standards/intake.md +12 -0
  48. package/governance/rules/standards/markdown.md +15 -0
  49. package/governance/rules/standards/memory.md +12 -0
  50. package/governance/rules/standards/mermaid.md +15 -0
  51. package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
  52. package/governance/rules/standards/readme.md +12 -0
  53. package/governance/rules/standards/ready.md +13 -0
  54. package/governance/rules/standards/requirements.md +13 -0
  55. package/governance/rules/standards/rule.md +13 -0
  56. package/governance/rules/standards/session.md +13 -0
  57. package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
  58. package/governance/rules/standards/standard.md +13 -0
  59. package/governance/rules/standards/tasks.md +15 -0
  60. package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
  61. package/governance/rules/standards/wiki.md +13 -0
  62. package/governance/rules/standards/wireframes.md +14 -0
  63. package/governance/rules/writing/800-prose.md +1 -1
  64. package/governance/stacks/base.toml +4 -2
  65. package/package.json +1 -1
  66. package/src/canvas/capture.ts +228 -15
  67. package/src/claude/cases/misc.ts +5 -0
  68. package/src/claude/cases/workflow.ts +4 -20
  69. package/src/claude/routing.ts +1 -1
  70. package/src/commands/canvas.ts +76 -2
  71. package/src/commands/gov.ts +19 -4
  72. package/src/commands/sandbox.ts +7 -0
  73. package/src/commands/slides.ts +13 -3
  74. package/src/commands/tasks.ts +46 -2
  75. package/src/gate/stages.ts +7 -1
  76. package/src/gov/citations.ts +1 -1
  77. package/src/gov/standard-rules.ts +179 -0
  78. package/src/migrate/skill-names.ts +0 -7
  79. package/src/sandbox/equivalence.ts +43 -14
  80. package/src/sandbox/stub-remote.ts +140 -0
  81. package/src/slides/convert/chart.ts +165 -0
  82. package/src/slides/convert/deck.ts +164 -0
  83. package/src/slides/convert/export.ts +217 -7
  84. package/src/slides/convert/master.ts +195 -0
  85. package/src/slides/convert/shapes.ts +1 -1
  86. package/src/slides/convert/walk.ts +187 -2
  87. package/src/tasks/record.ts +11 -1
  88. package/src/tasks/validate.ts +14 -0
  89. package/standards/architecture.md +5 -0
  90. package/standards/context.md +6 -0
  91. package/standards/design.md +3 -0
  92. package/standards/diagrams.md +8 -0
  93. package/standards/groundwork-spikes.md +66 -0
  94. package/standards/groundwork.md +5 -25
  95. package/standards/index.md +6 -2
  96. package/standards/intake.md +2 -0
  97. package/standards/markdown.md +6 -0
  98. package/standards/memory.md +2 -0
  99. package/standards/mermaid.md +6 -0
  100. package/standards/plan-lifecycle.md +36 -0
  101. package/standards/plan.md +13 -11
  102. package/standards/readme.md +2 -0
  103. package/standards/ready.md +4 -0
  104. package/standards/requirements.md +3 -0
  105. package/standards/rule.md +6 -1
  106. package/standards/session.md +4 -0
  107. package/standards/skill-paths.md +50 -0
  108. package/standards/skill.md +15 -23
  109. package/standards/standard.md +7 -1
  110. package/standards/tasks-decline.md +38 -0
  111. package/standards/tasks.md +9 -14
  112. package/standards/teach.md +5 -0
  113. package/standards/wiki.md +4 -0
  114. package/standards/wireframes.md +5 -0
  115. package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
  116. package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
  117. package/claude/skills/docs-fold/SKILL.md +0 -9
  118. package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
  119. package/claude/skills/standards-audit/SKILL.md +0 -99
  120. package/governance/rules/canon/610-context.md +0 -17
  121. package/governance/rules/canon/620-wireframes.md +0 -13
  122. package/governance/rules/canon/630-requirements.md +0 -12
  123. package/governance/rules/canon/650-design.md +0 -12
  124. package/governance/rules/canon/655-tasks.md +0 -13
  125. package/governance/rules/canon/656-groundwork.md +0 -11
  126. package/governance/rules/canon/657-intake.md +0 -11
  127. package/governance/rules/canon/659-memory.md +0 -11
  128. package/governance/rules/canon/660-diagrams.md +0 -19
  129. package/governance/rules/canon/662-session.md +0 -15
  130. package/governance/rules/canon/663-ready.md +0 -15
  131. package/governance/rules/claude/590-rule-authoring.md +0 -12
  132. package/governance/rules/writing/801-markdown.md +0 -14
  133. package/governance/rules/writing/802-mermaid.md +0 -14
  134. package/governance/rules/writing/880-readme.md +0 -11
  135. package/governance/rules/writing/891-standard-authoring.md +0 -12
@@ -0,0 +1,13 @@
1
+ ---
2
+ # Generated by `canon gov regen` from standards/requirements.md. Edit that frontmatter, not this file.
3
+ description: Shape and content rules for canon/REQUIREMENTS.md
4
+ paths:
5
+ - 'canon/REQUIREMENTS.md'
6
+ - '.claude/REQUIREMENTS.md'
7
+ ---
8
+
9
+ # Requirements standards
10
+
11
+ ## Authority
12
+
13
+ - Follow the requirements standard for shape and content rules for canon/REQUIREMENTS.md. It is the single source. Read it with `canon standards requirements`.
@@ -0,0 +1,13 @@
1
+ ---
2
+ # Generated by `canon gov regen` from standards/rule.md. Edit that frontmatter, not this file.
3
+ description: Rule frontmatter, body shape, and voice for .claude/rules files
4
+ paths:
5
+ - '.claude/rules/**/*.md'
6
+ ---
7
+
8
+ # Governance rule standards
9
+
10
+ ## Authority
11
+
12
+ - Follow the rule standard for rule frontmatter, body shape, and voice for .claude/rules files. It is the single source. Read it with `canon standards rule`.
13
+ - Read it before writing or editing a rule. Do not work the shape from memory.
@@ -0,0 +1,13 @@
1
+ ---
2
+ # Generated by `canon gov regen` from standards/session.md. Edit that frontmatter, not this file.
3
+ description: Filename and location, the sections a handoff carries, the write and read procedures, and how a role extends it
4
+ paths:
5
+ - '.canon/tasks/session-*.md'
6
+ ---
7
+
8
+ # Session map standards
9
+
10
+ ## Authority
11
+
12
+ - Follow the session standard for filename and location, the sections a handoff carries, the write and read procedures, and how a role extends it. It is the single source. Read it with `canon standards session`.
13
+ - Follow this rule rather than the tasks rule for a `session-` file. A handoff is not a task and carries neither `## Outcomes` nor `## Findings`.
@@ -1,5 +1,6 @@
1
1
  ---
2
- description: Route skill edits to the authoring standard and the requirement consult-first workflow
2
+ # Generated by `canon gov regen` from standards/skill.md. Edit that frontmatter, not this file.
3
+ description: Skill structure and authoring rules for Claude Code
3
4
  paths:
4
5
  - '.claude/skills/**/SKILL.md'
5
6
  - 'claude/skills/**/SKILL.md'
@@ -7,19 +8,14 @@ paths:
7
8
  - 'claude/skills/**/REQUIREMENT.md'
8
9
  ---
9
10
 
10
- # Skill standards
11
+ # Claude skill standards
11
12
 
12
- ## Before editing
13
+ ## Authority
13
14
 
15
+ - Follow the skill standard for skill structure and authoring rules for Claude Code. It is the single source. Read it with `canon standards skill`.
14
16
  - Confirm `canon:create-skill`'s two creation-time questions are answered before a new `SKILL.md` lands, whether drafted by hand, by another skill, or by `canon:create-skill` itself. Carry the third question into the sibling `REQUIREMENT.md`'s `Must not` section as a review criterion rather than a gate.
15
17
  - Report it rather than proceeding silently when `canon:create-skill` does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
16
-
17
- ## After editing
18
-
19
18
  - Re-read a skill body this session edited before invoking that skill again in the same session
20
19
  - Do not read a resolved file path in a held body as evidence the body is current
21
-
22
- ## Authority
23
-
24
- - Follow the skill standard for skill structure, frontmatter fields, and invocation rules. It is the single source. Read it with `canon standards skill`.
25
20
  - Follow the skill requirement standard for the shape a `REQUIREMENT.md` states. Read it with `canon standards skill-requirement`.
21
+ - Follow the skill path standard for which root a path in a body resolves against and how a body cites a standard. Read it with `canon standards skill-paths`.
@@ -0,0 +1,13 @@
1
+ ---
2
+ # Generated by `canon gov regen` from standards/standard.md. Edit that frontmatter, not this file.
3
+ description: Shape and content rules for authoring a standard
4
+ paths:
5
+ - 'standards/**/*.md'
6
+ ---
7
+
8
+ # Standard standards
9
+
10
+ ## Authority
11
+
12
+ - Follow the standard standard for shape and content rules for authoring a standard. It is the single source. Read it with `canon standards standard`.
13
+ - Read it before writing or editing a standard. Do not work the shape from memory.
@@ -0,0 +1,15 @@
1
+ ---
2
+ # Generated by `canon gov regen` from standards/tasks.md. Edit that frontmatter, not this file.
3
+ description: Folder layout, filename convention, file format, origin lines, and archiving for task files under .canon/tasks/
4
+ paths:
5
+ - '.canon/tasks/**'
6
+ ---
7
+
8
+ # Tasks standards
9
+
10
+ ## Authority
11
+
12
+ - Follow the tasks standard for folder layout, filename convention, file format, origin lines, and archiving for task files under .canon/tasks/. It is the single source. Read it with `canon standards tasks`.
13
+ - Follow the board standard for `priority.md` and `backlog.md`, their readiness groups, and row order. Read it with `canon standards board`.
14
+ - Never hand-edit `.canon/tasks/index.md`. A hook regenerates it from sibling frontmatter.
15
+ - Follow the task decline standard for a task decided against. Read it with `canon standards tasks-decline`.
@@ -1,5 +1,6 @@
1
1
  ---
2
- description: Route .canon/teach/ edits to the teach standard for layout, ordinal naming, and the mission and record formats, in the projects that open a learning workspace
2
+ # Generated by `canon gov regen` from standards/teach.md. Edit that frontmatter, not this file.
3
+ description: Workspace layout, ordinal naming, frontmatter, and the mission and learning-record formats for a learning workspace
3
4
  paths:
4
5
  - '.canon/teach/**'
5
6
  ---
@@ -8,6 +9,6 @@ paths:
8
9
 
9
10
  ## Authority
10
11
 
11
- - Follow the teach standard for the folder layout, ordinal naming, frontmatter, and the mission and learning-record formats. It is the single source. Read it with `canon standards teach`.
12
+ - Follow the teach standard for workspace layout, ordinal naming, frontmatter, and the mission and learning-record formats for a learning workspace. It is the single source. Read it with `canon standards teach`.
12
13
  - Follow the glossary standard for the glossary every workspace carries at its root. It is the single source for the entry shape, the ordering, and which terms the file carries. Read it with `canon standards glossary`.
13
14
  - Report it rather than proceeding silently when that file does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
@@ -0,0 +1,13 @@
1
+ ---
2
+ # Generated by `canon gov regen` from standards/wiki.md. Edit that frontmatter, not this file.
3
+ description: Folder split, frontmatter, naming convention, and sourcing for a wiki reference page
4
+ paths:
5
+ - 'wiki/**/*.md'
6
+ ---
7
+
8
+ # Wiki standards
9
+
10
+ ## Authority
11
+
12
+ - Follow the wiki standard for folder split, frontmatter, naming convention, and sourcing for a wiki reference page. It is the single source. Read it with `canon standards wiki`.
13
+ - Read it before drafting a page. Do not work the placement criterion or the naming convention from memory.
@@ -0,0 +1,14 @@
1
+ ---
2
+ # Generated by `canon gov regen` from standards/wireframes.md. Edit that frontmatter, not this file.
3
+ description: Shape and content rules for canon/wireframes/<surface>.md files
4
+ paths:
5
+ - 'canon/wireframes/**'
6
+ - '.claude/wireframes/**'
7
+ ---
8
+
9
+ # Wireframe standards
10
+
11
+ ## Authority
12
+
13
+ - Follow the wireframes standard for shape and content rules for canon/wireframes/<surface>.md files. It is the single source. Read it with `canon standards wireframes`.
14
+ - Read it before adding or revising a surface.
@@ -12,4 +12,4 @@ paths:
12
12
  - Load it before drafting a passage, not after revising one.
13
13
  - Report it rather than proceeding silently when the skill does not resolve.
14
14
  - Do not work these rules from memory.
15
- - Punctuation, formatting, and file references are a separate topic. `801-markdown` routes them.
15
+ - Punctuation, formatting, and file references are a separate topic. The markdown rule routes them.
@@ -5,5 +5,7 @@ extends = ""
5
5
  # session conduct and `.claude/` authoring rules, `tooling` the dev setup and
6
6
  # CI workflow rules, and `writing` the prose and document-shape rules. The
7
7
  # `code` group is left to the stacks that write code, so a docs or writing
8
- # project loads no rule it can never apply.
9
- rules = ["canon", "claude", "tooling", "writing", "120-bash"]
8
+ # project loads no rule it can never apply. `standards` holds one rule per
9
+ # standard routing its paths to it, generated by `canon gov regen` from each
10
+ # standard's frontmatter rather than authored.
11
+ rules = ["canon", "claude", "standards", "tooling", "writing", "120-bash"]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.19.0",
4
+ "version": "5.21.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": {
@@ -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?",
@@ -38,12 +38,6 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
38
38
  'Fold what we decided this session into the context entries and mark off what shipped on the task board.',
39
39
  expect: 'context-fold',
40
40
  },
41
- {
42
- prompt:
43
- 'I typed the old fold skill name and it says it was renamed, where did it go?',
44
- // The pointer answers under its old name on purpose. canon-keep-retired
45
- expect: 'docs-fold',
46
- },
47
41
  {
48
42
  prompt:
49
43
  'Sketch out a plan for adding this new capability before we touch any code.',
@@ -143,16 +137,10 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
143
137
  prompt: 'Turn this topic into a slide deck I can render.',
144
138
  expect: 'draft-slides',
145
139
  },
146
- {
147
- prompt:
148
- 'Check whether the markdown I changed violates any authoring standards.',
149
- expect: 'standards-audit',
150
- },
151
- // Rot across documents nobody changed is what separates this from
152
- // `standards-audit`, which is diff-scoped and reports a stated rule broken on
153
- // a given line. It is also not `target-check`, which asks whether a document
154
- // exists and holds its sections rather than whether it still describes the
155
- // tree.
140
+ // Rot across documents nobody changed is what this reads, rather than a
141
+ // stated rule broken on a given line. It is also not `target-check`, which
142
+ // asks whether a document exists and holds its sections rather than whether
143
+ // it still describes the tree.
156
144
  {
157
145
  prompt:
158
146
  'Which of our docs have gone stale, grown too long, or ended up in the wrong file?',
@@ -230,10 +218,6 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
230
218
  "Go through what's still unanswered in the intake folder and decide.",
231
219
  expect: 'plan-intake-answer',
232
220
  },
233
- {
234
- prompt: 'Check this markdown against the house style rules.',
235
- expect: 'standards-audit',
236
- },
237
221
  {
238
222
  prompt: 'Find the rough, unfinished-feeling spots in this interface.',
239
223
  expect: 'ux-audit',
@@ -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