@erclx/canon 5.27.0 → 5.29.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 (177) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-ready/SKILL.md +3 -1
  3. package/claude/skills/git-pr/references/evidence.md +1 -1
  4. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  5. package/claude/skills/review-address/SKILL.md +16 -7
  6. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  7. package/claude/skills/review-craft/references/ui.md +4 -4
  8. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  9. package/claude/skills/review-ui/SKILL.md +13 -11
  10. package/claude/skills/review-ui/references/driving.md +8 -8
  11. package/claude/skills/review-ui/references/post.md +19 -7
  12. package/claude/skills/review-ui/references/tick.md +22 -0
  13. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  14. package/claude/skills/role-orchestrator/SKILL.md +24 -26
  15. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  16. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
  17. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  20. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
  22. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  23. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  24. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  25. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  26. package/claude/skills/role-planner/SKILL.md +2 -2
  27. package/claude/skills/role-reviewer/SKILL.md +1 -1
  28. package/claude/skills/role-worker/SKILL.md +3 -2
  29. package/claude/skills/ui-checklist/SKILL.md +1 -1
  30. package/docs/agents/canvas.md +12 -4
  31. package/docs/agents/gate.md +3 -1
  32. package/docs/agents/index.md +2 -1
  33. package/docs/agents/pr-evidence.md +15 -14
  34. package/docs/agents/pr-tick.md +72 -0
  35. package/docs/agents/sandbox.md +2 -2
  36. package/docs/agents/tasks-plans.md +1 -1
  37. package/docs/agents/tasks-validate.md +18 -18
  38. package/docs/agents/tasks.md +1 -1
  39. package/docs/workflow/operating-model.md +10 -15
  40. package/docs/workflow/parallel-features.md +7 -7
  41. package/package.json +1 -1
  42. package/src/audits/baseline.ts +1 -1
  43. package/src/audits/run.ts +1 -1
  44. package/src/canvas/capture.ts +1 -1
  45. package/src/canvas/client/app.tsx +105 -4
  46. package/src/canvas/client/inspector/field.tsx +61 -2
  47. package/src/canvas/client/inspector/fields.css +78 -0
  48. package/src/canvas/client/inspector/token-select.tsx +109 -0
  49. package/src/canvas/client/inspector/values.ts +74 -2
  50. package/src/canvas/client/inspector.tsx +134 -15
  51. package/src/canvas/client/keys.ts +71 -0
  52. package/src/canvas/client/pages-panel.tsx +5 -1
  53. package/src/canvas/client/panel-handle.tsx +90 -0
  54. package/src/canvas/client/selection.tsx +3 -1
  55. package/src/canvas/client/shell.css +194 -4
  56. package/src/canvas/client/state.ts +255 -11
  57. package/src/canvas/client/surface.tsx +231 -18
  58. package/src/canvas/client/tools.tsx +153 -0
  59. package/src/canvas/content.ts +66 -1
  60. package/src/canvas/edit.ts +207 -25
  61. package/src/canvas/history.ts +116 -0
  62. package/src/canvas/server.ts +119 -13
  63. package/src/canvas/tokens.ts +1 -1
  64. package/src/claude/seeds.ts +2 -2
  65. package/src/claude/skills-reach.ts +1 -1
  66. package/src/{exec.ts → cli/exec.ts} +2 -2
  67. package/src/{help.ts → cli/help.ts} +1 -1
  68. package/src/cli.ts +1 -1
  69. package/src/commands/audits.ts +9 -1
  70. package/src/commands/autoship.ts +1 -1
  71. package/src/commands/canvas.ts +2 -2
  72. package/src/commands/capture.ts +1 -1
  73. package/src/commands/census.ts +1 -1
  74. package/src/commands/claude.ts +5 -5
  75. package/src/commands/comments.ts +1 -1
  76. package/src/commands/context.ts +1 -1
  77. package/src/commands/demo.ts +9 -1
  78. package/src/commands/deps.ts +1 -1
  79. package/src/commands/design.ts +12 -4
  80. package/src/commands/docs.ts +2 -2
  81. package/src/commands/driver.ts +9 -1
  82. package/src/commands/feedback.ts +3 -3
  83. package/src/commands/gate.ts +10 -3
  84. package/src/commands/gov.ts +3 -3
  85. package/src/commands/indexes.ts +1 -1
  86. package/src/commands/init.ts +11 -3
  87. package/src/commands/intake.ts +1 -1
  88. package/src/commands/inventory.ts +9 -1
  89. package/src/commands/labels.ts +1 -1
  90. package/src/commands/markdown.ts +1 -1
  91. package/src/commands/migrate.ts +2 -2
  92. package/src/commands/pr.ts +324 -9
  93. package/src/commands/records.ts +1 -1
  94. package/src/commands/repo.ts +1 -1
  95. package/src/commands/sandbox.ts +11 -14
  96. package/src/commands/secrets.ts +9 -1
  97. package/src/commands/sessions.ts +1 -1
  98. package/src/commands/slides.ts +2 -2
  99. package/src/commands/standards.ts +1 -1
  100. package/src/commands/sync.ts +3 -3
  101. package/src/commands/targets.ts +1 -1
  102. package/src/commands/tasks.ts +20 -3
  103. package/src/commands/teach.ts +1 -1
  104. package/src/commands/tooling.ts +2 -2
  105. package/src/commands/transcripts.ts +2 -2
  106. package/src/commands/upgrade.ts +2 -2
  107. package/src/commands/wiki.ts +2 -2
  108. package/src/commands/worktrees.ts +1 -1
  109. package/src/context/architecture.ts +1 -1
  110. package/src/context/citations.ts +2 -2
  111. package/src/context/classify/extract.ts +1 -1
  112. package/src/context/folders.ts +2 -2
  113. package/src/context/requirements.ts +1 -1
  114. package/src/docs/read.ts +1 -1
  115. package/src/feedback/github.ts +1 -1
  116. package/src/gate/measures.ts +2 -2
  117. package/src/gate/sequencer.ts +55 -3
  118. package/src/gate/stages.ts +8 -0
  119. package/src/gate/suite-lock.ts +214 -0
  120. package/src/init/run.ts +1 -1
  121. package/src/intake/folder.ts +1 -1
  122. package/src/labels/map.ts +1 -1
  123. package/src/labels/phase.ts +3 -3
  124. package/src/migrate/record-layout.ts +1 -1
  125. package/src/migrate/records.ts +11 -11
  126. package/src/migrate/rename.ts +1 -1
  127. package/src/migrate/scratch-evidence.ts +1 -1
  128. package/src/migrate/surface-roots.ts +6 -6
  129. package/src/pr/evidence.ts +188 -16
  130. package/src/pr/tick.ts +56 -0
  131. package/src/process/harness.ts +1 -1
  132. package/src/records/backup.ts +1 -1
  133. package/src/records/ordinal.ts +1 -1
  134. package/src/records/prune.ts +1 -1
  135. package/src/records/size.ts +1 -1
  136. package/src/records/stale-canonical.ts +1 -1
  137. package/src/records/stale.ts +1 -1
  138. package/src/records/validate.ts +2 -2
  139. package/src/{project-root.ts → roots/project.ts} +2 -2
  140. package/src/{record-root.ts → roots/record.ts} +1 -1
  141. package/src/sandbox/coverage.ts +11 -12
  142. package/src/sandbox/equivalence.ts +57 -26
  143. package/src/sandbox/headless/run.ts +1 -1
  144. package/src/sandbox/hooks.ts +1 -1
  145. package/src/sandbox/provision.ts +79 -29
  146. package/src/sandbox/scenario.ts +588 -0
  147. package/src/serve/report.ts +1 -1
  148. package/src/sessions/live.ts +4 -1
  149. package/src/sessions/transfer/bundle.ts +1 -1
  150. package/src/slides/decks.ts +1 -1
  151. package/src/standards/read.ts +1 -1
  152. package/src/sync/engine.ts +2 -2
  153. package/src/sync/git.ts +1 -1
  154. package/src/sync/layout.ts +2 -2
  155. package/src/sync/stamp.ts +1 -1
  156. package/src/sync/workflow.ts +1 -1
  157. package/src/targets/validate.ts +2 -2
  158. package/src/tasks/answers.ts +1 -1
  159. package/src/tasks/archive.ts +1 -1
  160. package/src/tasks/label.ts +1 -1
  161. package/src/tasks/reach.ts +1 -1
  162. package/src/tasks/validate.ts +38 -14
  163. package/src/teach/nav.ts +1 -1
  164. package/src/teach/workspace.ts +1 -1
  165. package/src/tooling/inject.ts +2 -2
  166. package/src/tooling/read.ts +1 -1
  167. package/src/tooling/scan.ts +1 -1
  168. package/src/tooling/verify-stack.ts +1 -1
  169. package/src/version/installed.ts +1 -1
  170. package/src/version/skew.ts +1 -1
  171. package/standards/board.md +3 -3
  172. package/standards/plan.md +13 -7
  173. package/tsconfig.json +1 -1
  174. /package/src/{cli-run.ts → cli/run.ts} +0 -0
  175. /package/src/{ui.ts → cli/ui.ts} +0 -0
  176. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  177. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
@@ -29,8 +29,17 @@ const BASE_PREFIX = '**Base:**'
29
29
  const CHECKLIST_HEADING = '## What to look at'
30
30
 
31
31
  /** Delimiters around the checklist, so a re-render can read back what a reviewer has already ticked. */
32
- const CHECKLIST_START = '<!-- pr-checklist:start -->'
33
- const CHECKLIST_END = '<!-- pr-checklist:end -->'
32
+ export const CHECKLIST_START = '<!-- pr-checklist:start -->'
33
+ export const CHECKLIST_END = '<!-- pr-checklist:end -->'
34
+
35
+ /** One checklist line, a list marker then a box that GitHub renders for `x` in either case. */
36
+ const BOX_LINE = /^(\s*[-*+] \[)([ xX])(\] )(.*)$/
37
+
38
+ /** The trailing segment a tick carries, naming the commit the box passed at. Matched only at the end of a line, so a box whose own text says `passed at` is left alone. */
39
+ const STAMP = / · passed at `([0-9a-f]{7,40})`$/
40
+
41
+ /** What ends the text of a box that a person has to judge by eye. */
42
+ const TASTE_SUFFIX = '(taste)'
34
43
 
35
44
  /** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
36
45
  const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
@@ -38,8 +47,8 @@ const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
38
47
  /**
39
48
  * The image extensions this module embeds. An `evidence/` folder holds
40
49
  * whatever else a project keeps beside its captures, such as a README, a
41
- * capture script, or a raw data file, and none of those render as
42
- * `![](url)` without producing a broken embed.
50
+ * capture script, or a raw data file, and none of those render as an
51
+ * `<img>` without producing a broken embed.
43
52
  */
44
53
  const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|avif|svg)$/i
45
54
 
@@ -48,6 +57,10 @@ export interface EvidenceItem {
48
57
  readonly stem: string
49
58
  /** True when `path` did not exist at the comparison's base commit. */
50
59
  readonly added: boolean
60
+ /** Pixel width of the head image, read from its PNG header. Absent when unread. */
61
+ readonly width?: number
62
+ /** Pixel width of the base image, absent for an added case or an unread header. */
63
+ readonly baseWidth?: number
51
64
  }
52
65
 
53
66
  export interface EvidenceState {
@@ -130,6 +143,57 @@ function rawUrl(repo: string, sha: string, path: string): string {
130
143
  return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
131
144
  }
132
145
 
146
+ /**
147
+ * The most pixels a screenshot asks for. GitHub sizes a comment table to its
148
+ * content and clamps an image to its cell, so a width past any comment fills
149
+ * the cell, where `width="100%"` leaves a narrow screenshot narrow. An image
150
+ * asks for the smaller of its own width and this ceiling, so a small crop
151
+ * shows at its true size and is never scaled up. An image whose width was not
152
+ * read asks for the ceiling.
153
+ */
154
+ const IMAGE_WIDTH = 1000
155
+
156
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
157
+
158
+ /** Bytes of a PNG that reach the end of IHDR's width field. */
159
+ export const PNG_HEADER_BYTES = 24
160
+
161
+ /**
162
+ * The pixel width in a PNG's IHDR chunk, or undefined for bytes that are not
163
+ * a PNG with a header or that carry a zero width.
164
+ */
165
+ export function readPngWidth(bytes: Uint8Array): number | undefined {
166
+ if (bytes.length < PNG_HEADER_BYTES) return undefined
167
+ if (PNG_SIGNATURE.some((byte, index) => bytes[index] !== byte)) {
168
+ return undefined
169
+ }
170
+ const width = new DataView(bytes.buffer, bytes.byteOffset).getUint32(16)
171
+ return width > 0 ? width : undefined
172
+ }
173
+
174
+ /** A table cell and an attribute value share one escape, since a stem is a path segment placed in both. */
175
+ function escapeCell(text: string): string {
176
+ return text
177
+ .replaceAll('&', '&amp;')
178
+ .replaceAll('<', '&lt;')
179
+ .replaceAll('>', '&gt;')
180
+ .replaceAll('"', '&quot;')
181
+ .replaceAll('|', '&#124;')
182
+ }
183
+
184
+ function evidenceImage(url: string, alt: string, width?: number): string {
185
+ const shown = Math.min(width ?? IMAGE_WIDTH, IMAGE_WIDTH)
186
+ return `<img src="${url}" width="${shown}" alt="${alt}">`
187
+ }
188
+
189
+ const EVIDENCE_IMAGE = String.raw`(?:!\[\]\([^)]*\)|<img [^>]*>)`
190
+
191
+ /** A case row in any shape a render has posted: a Base cell before the head image, or the head image alone. */
192
+ const EVIDENCE_ROW_PATTERN = new RegExp(
193
+ String.raw`^\| .+ \| (?:(?:\*\(new\)\*|${EVIDENCE_IMAGE}) \| )?${EVIDENCE_IMAGE} \|$`,
194
+ 'gm',
195
+ )
196
+
133
197
  export function evidenceMarker(head: string): string {
134
198
  return `${MARKER_PREFIX} head=${head} -->`
135
199
  }
@@ -189,20 +253,32 @@ export function renderEvidenceBody(
189
253
  imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
190
254
 
191
255
  const sections = states.map((entry) => {
256
+ const hasBase = entry.items.some((item) => !item.added)
192
257
  const rows = entry.items.map((item) => {
258
+ const stem = escapeCell(item.stem)
193
259
  const before = item.added
194
260
  ? '*(new)*'
195
- : `![](${rawUrl(repo, base, item.path)})`
196
- const after = `![](${rawUrl(repo, head, item.path)})`
197
- return `| ${item.stem} | ${before} | ${after} |`
261
+ : evidenceImage(
262
+ rawUrl(repo, base, item.path),
263
+ `${stem} base`,
264
+ item.baseWidth,
265
+ )
266
+ const after = evidenceImage(
267
+ rawUrl(repo, head, item.path),
268
+ `${stem} head`,
269
+ item.width,
270
+ )
271
+ return hasBase
272
+ ? `| ${stem} | ${before} | ${after} |`
273
+ : `| ${stem} | ${after} |`
198
274
  })
199
275
 
200
276
  return [
201
277
  opener,
202
278
  `<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
203
279
  '',
204
- '| Case | Base | Head |',
205
- '| --- | --- | --- |',
280
+ hasBase ? '| Case | Base | Head |' : '| Case | Head |',
281
+ hasBase ? '| --- | --- | --- |' : '| --- | --- |',
206
282
  ...rows,
207
283
  '',
208
284
  '</details>',
@@ -221,16 +297,14 @@ export function renderEvidenceBody(
221
297
  }
222
298
 
223
299
  /**
224
- * How many case rows a rendered body carries, read off the same row shape
300
+ * How many case rows a rendered body carries, read off the row shapes
225
301
  * `renderEvidenceBody` writes. A row always ends in a head image, which a
226
- * checklist line or a preview address never does.
302
+ * checklist line or a preview address never does. The pattern also reads the
303
+ * `![](url)` three-column shape an earlier render posted, so a comment posted
304
+ * before the change still counts its cases and the `would-empty` guard holds.
227
305
  */
228
306
  export function countEvidenceCases(body: string): number {
229
- return (
230
- body.match(
231
- /^\| .+ \| (?:\*\(new\)\*|!\[\]\([^)]*\)) \| !\[\]\([^)]*\) \|$/gm,
232
- )?.length ?? 0
233
- )
307
+ return body.match(EVIDENCE_ROW_PATTERN)?.length ?? 0
234
308
  }
235
309
 
236
310
  export interface EvidenceComment {
@@ -410,3 +484,101 @@ export function findEvidenceChecklist(
410
484
  marked.body.slice(start + CHECKLIST_START.length, end).trim() || undefined
411
485
  )
412
486
  }
487
+
488
+ export interface ChecklistBox {
489
+ /** One-based position among the checklist's boxes, in the order they appear. */
490
+ readonly number: number
491
+ /** Index of the box's line within the checklist. */
492
+ readonly line: number
493
+ /** The box text with any stamp removed. */
494
+ readonly text: string
495
+ readonly isTicked: boolean
496
+ /** The short sha a tick names, absent on an empty box or a tick nothing stamped. */
497
+ readonly stamp?: string
498
+ /** Whether the box ends in `(taste)`, which no driver ticks. */
499
+ readonly isTaste: boolean
500
+ }
501
+
502
+ export interface BoxLine {
503
+ readonly before: string
504
+ readonly isTicked: boolean
505
+ readonly text: string
506
+ readonly stamp?: string
507
+ }
508
+
509
+ /** Splits one checklist line into its box parts, or undefined when the line is not a box. */
510
+ export function parseBoxLine(line: string): BoxLine | undefined {
511
+ const match = BOX_LINE.exec(line)
512
+ if (match === null) return undefined
513
+ const rest = (match[4] ?? '').trimEnd()
514
+ const stamped = STAMP.exec(rest)
515
+ return {
516
+ before: match[1] ?? '',
517
+ isTicked: (match[2] ?? ' ') !== ' ',
518
+ text: stamped === null ? rest : rest.slice(0, stamped.index),
519
+ ...(stamped?.[1] !== undefined && { stamp: stamped[1] }),
520
+ }
521
+ }
522
+
523
+ /** The box line for the given parts, written back in the one shape the reader accepts. */
524
+ export function writeBoxLine(
525
+ box: Pick<BoxLine, 'before' | 'text'>,
526
+ stampedAt?: string,
527
+ ): string {
528
+ return stampedAt === undefined
529
+ ? `${box.before} ] ${box.text}`
530
+ : `${box.before}x] ${box.text} · passed at \`${stampedAt.slice(0, SHORT_SHA_LENGTH)}\``
531
+ }
532
+
533
+ /** Every box of a checklist in order, numbered the way `canon pr tick` takes them. */
534
+ export function readChecklistBoxes(checklist: string): ChecklistBox[] {
535
+ const boxes: ChecklistBox[] = []
536
+ checklist.split('\n').forEach((line, index) => {
537
+ const parsed = parseBoxLine(line)
538
+ if (parsed === undefined) return
539
+ boxes.push({
540
+ number: boxes.length + 1,
541
+ line: index,
542
+ text: parsed.text,
543
+ isTicked: parsed.isTicked,
544
+ ...(parsed.stamp !== undefined && { stamp: parsed.stamp }),
545
+ isTaste: parsed.text.endsWith(TASTE_SUFFIX),
546
+ })
547
+ })
548
+ return boxes
549
+ }
550
+
551
+ /** The head the marked comment's trailing marker names. */
552
+ export function findEvidenceHead(
553
+ comments: readonly EvidenceComment[],
554
+ ): string | undefined {
555
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
556
+ if (marked === undefined) return undefined
557
+ const lines = marked.body.trimEnd().split('\n')
558
+ const match = /head=(\S+)/.exec(lines[lines.length - 1] ?? '')
559
+ return match?.[1]
560
+ }
561
+
562
+ /**
563
+ * Clears every tick the render's head has not earned. A stamped tick stays
564
+ * while its sha is a prefix of the head. An unstamped one names no head, so
565
+ * it stays only while the comment it came from described this same head.
566
+ */
567
+ export function settleChecklist(
568
+ checklist: string,
569
+ head: string,
570
+ carriedHead: string | undefined,
571
+ ): string {
572
+ return checklist
573
+ .split('\n')
574
+ .map((line) => {
575
+ const parsed = parseBoxLine(line)
576
+ if (parsed === undefined || !parsed.isTicked) return line
577
+ const isEarned =
578
+ parsed.stamp === undefined
579
+ ? carriedHead === head
580
+ : head.startsWith(parsed.stamp)
581
+ return isEarned ? line : writeBoxLine(parsed)
582
+ })
583
+ .join('\n')
584
+ }
package/src/pr/tick.ts ADDED
@@ -0,0 +1,56 @@
1
+ import {
2
+ CHECKLIST_END,
3
+ CHECKLIST_START,
4
+ parseBoxLine,
5
+ readChecklistBoxes,
6
+ writeBoxLine,
7
+ } from '@/pr/evidence'
8
+
9
+ export type TickRefusal = 'no-checklist' | 'no-box' | 'taste-box'
10
+
11
+ export type TickResult =
12
+ | { readonly kind: 'ticked'; readonly body: string }
13
+ | { readonly kind: 'refused'; readonly reason: TickRefusal }
14
+
15
+ /**
16
+ * Ticks the named boxes of a marked body's checklist and stamps each with the
17
+ * commit it passed at. A refusal writes nothing, so a taste box named among
18
+ * passed ones leaves every box as it was. A box already ticked is restamped,
19
+ * which makes a repeat at one head a no-op.
20
+ */
21
+ export function tickBoxes(
22
+ body: string,
23
+ numbers: readonly number[],
24
+ head: string,
25
+ ): TickResult {
26
+ const start = body.indexOf(CHECKLIST_START)
27
+ const end = body.indexOf(CHECKLIST_END, start)
28
+ if (start === -1 || end === -1) {
29
+ return { kind: 'refused', reason: 'no-checklist' }
30
+ }
31
+
32
+ const from = start + CHECKLIST_START.length
33
+ const region = body.slice(from, end)
34
+ const boxes = readChecklistBoxes(region)
35
+
36
+ const named = numbers.map((number) =>
37
+ boxes.find((box) => box.number === number),
38
+ )
39
+ if (named.some((box) => box === undefined)) {
40
+ return { kind: 'refused', reason: 'no-box' }
41
+ }
42
+ if (named.some((box) => box?.isTaste === true)) {
43
+ return { kind: 'refused', reason: 'taste-box' }
44
+ }
45
+
46
+ const lines = region.split('\n')
47
+ for (const box of named) {
48
+ if (box === undefined) continue
49
+ const parsed = parseBoxLine(lines[box.line] ?? '')
50
+ if (parsed !== undefined) lines[box.line] = writeBoxLine(parsed, head)
51
+ }
52
+ return {
53
+ kind: 'ticked',
54
+ body: `${body.slice(0, from)}${lines.join('\n')}${body.slice(end)}`,
55
+ }
56
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { readdirSync, statSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
- import { PROJECT_ROOT } from '@/project-root'
4
+ import { PROJECT_ROOT } from '@/roots/project'
5
5
  import { stateDir } from '@/targets/registry'
6
6
 
7
7
  const CLI = join(PROJECT_ROOT, 'src/cli.ts')
@@ -2,7 +2,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'
2
2
  import { basename, join, relative, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git/env'
5
- import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/record-root'
5
+ import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/roots/record'
6
6
  import { type BlockedPath, guardPayload } from '@/records/push-guard'
7
7
 
8
8
  /**
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readdir, rm, stat } from 'node:fs/promises'
2
2
  import { extractOrdinal } from '@/intake/folder'
3
- import { recordDir } from '@/record-root'
3
+ import { recordDir } from '@/roots/record'
4
4
 
5
5
  /**
6
6
  * The two record folders that share one ordinal sequence, per
@@ -3,7 +3,7 @@ import { readdir, rm, stat } from 'node:fs/promises'
3
3
  import { join, relative } from 'node:path'
4
4
  import { RECORD_LAYOUT_MOVES } from '@/migrate/record-layout'
5
5
  import { PROMOTED_FOLDERS } from '@/migrate/scratch-evidence'
6
- import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record-root'
6
+ import { RECORD_ROOTS, recordDir, SCRATCH } from '@/roots/record'
7
7
  import { day, newestMtime } from '@/records/size'
8
8
 
9
9
  const DAY_MS = 24 * 60 * 60 * 1000
@@ -1,7 +1,7 @@
1
1
  import { existsSync, type Stats } from 'node:fs'
2
2
  import { readdir, stat } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
- import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record-root'
4
+ import { RECORD_ROOTS, recordDir, SCRATCH } from '@/roots/record'
5
5
  import { candidateFolders } from '@/records/backup'
6
6
 
7
7
  /**
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'
4
4
  import { relative } from 'node:path'
5
5
  import { gitEnv } from '@/git/env'
6
6
  import { readReviewed } from '@/records/stale'
7
- import { surfaceDir } from '@/surface-root'
7
+ import { surfaceDir } from '@/roots/surface'
8
8
 
9
9
  /** The two records whose drift is identity rather than a stale path. */
10
10
  const CANONICAL_DOCS = ['REQUIREMENTS.md', 'ARCHITECTURE.md'] as const
@@ -8,7 +8,7 @@ import {
8
8
  recordDir,
9
9
  SCRATCH,
10
10
  spell,
11
- } from '@/record-root'
11
+ } from '@/roots/record'
12
12
 
13
13
  /** The window after which a reviewed entry is due again, in days. */
14
14
  export const DEFAULT_REVIEW_DAYS = 30
@@ -6,7 +6,7 @@ import { linesOutsideFences } from '@/markdown/scan'
6
6
  import {
7
7
  recordDir as resolveRecordDir,
8
8
  recordDirs as resolveRecordDirs,
9
- } from '@/record-root'
9
+ } from '@/roots/record'
10
10
  import { ATTRIBUTE_MARKER, governedPaths, readScope } from '@/standards/scope'
11
11
  import {
12
12
  TEACH_GLOSSARY,
@@ -39,7 +39,7 @@ export type RecordKind = (typeof RECORD_KINDS)[number]
39
39
  * the second, so one order serves both.
40
40
  *
41
41
  * Every other kind is a session record and takes the record roots instead,
42
- * resolved by `@/record-root`. Standards is the one kind that is tracked, so it
42
+ * resolved by `@/roots/record`. Standards is the one kind that is tracked, so it
43
43
  * does not move and spells its own two candidates here.
44
44
  */
45
45
  const STANDARDS_FOLDERS: readonly string[] = [
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url'
16
16
  */
17
17
  export const PROJECT_ROOT = resolve(
18
18
  dirname(fileURLToPath(import.meta.url)),
19
- '..',
19
+ '../..',
20
20
  )
21
21
 
22
22
  const readPackageName = (root: string): string | undefined => {
@@ -72,7 +72,7 @@ export function findCheckoutMismatch(startDir: string): string | undefined {
72
72
  * The warning line every verb that answers from `PROJECT_ROOT` emits when the
73
73
  * caller's cwd sits inside a second checkout, or `undefined` when it does not.
74
74
  *
75
- * It returns the string rather than logging it so this module keeps no `@/ui`
75
+ * It returns the string rather than logging it so this module keeps no `@/cli/ui`
76
76
  * dependency and the wording is unit-testable on its own. The wording names
77
77
  * both roots and no subcommand: the four chokepoints that carry it stand under
78
78
  * eight-plus verbs each, so a per-verb suffix would cost a parameter at every
@@ -57,7 +57,7 @@ const CANON_SCRATCH = 'tmp'
57
57
  * record root, which is the rule the move ran on. `rules`, `skills`, and
58
58
  * `hooks` stay under `.claude/` because the vendor reads them there, while
59
59
  * `context`, `wireframes`, and the loose documents are tracked surfaces that
60
- * `surface-root.ts` resolves under `canon/` instead. A seed and a
60
+ * `surface.ts` resolves under `canon/` instead. A seed and a
61
61
  * superseded-layout report each ask this rather than assuming a root.
62
62
  */
63
63
  export const RECORD_ENTRIES: readonly string[] = [
@@ -1,5 +1,10 @@
1
1
  import { existsSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import {
4
+ FIXTURES_DIR,
5
+ listCategories,
6
+ listScenarioFiles,
7
+ } from '@/sandbox/scenario'
3
8
 
4
9
  /**
5
10
  * The arm a scenario declares when it carries no named arm. `expectFilePath`
@@ -8,9 +13,6 @@ import { join } from 'node:path'
8
13
  */
9
14
  export const DEFAULT_ARM = '(default)'
10
15
 
11
- /** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
12
- const FIXTURES_DIR = 'fixtures'
13
-
14
16
  export interface ScenarioCoverage {
15
17
  readonly category: string
16
18
  readonly command: string
@@ -41,15 +43,12 @@ function directories(path: string): string[] {
41
43
  function listScenarios(sandboxDir: string): ScenarioCoverage[] {
42
44
  const scenarios: ScenarioCoverage[] = []
43
45
 
44
- for (const category of directories(sandboxDir)) {
45
- if (category === FIXTURES_DIR) continue
46
-
47
- const commands = readdirSync(join(sandboxDir, category), {
48
- withFileTypes: true,
49
- })
50
- .filter((entry) => entry.isFile() && entry.name.endsWith('.sh'))
51
- .map((entry) => entry.name.replace(/\.sh$/, ''))
52
- .sort()
46
+ for (const category of listCategories(sandboxDir)) {
47
+ // A stem in both forms counts once, and provisioning refuses it by name.
48
+ const { scenarios: files, ambiguous } = listScenarioFiles(
49
+ join(sandboxDir, category),
50
+ )
51
+ const commands = [...files.map((f) => f.command), ...ambiguous].sort()
53
52
 
54
53
  for (const command of commands) {
55
54
  scenarios.push({ category, command, armed: [] })
@@ -17,11 +17,15 @@ import {
17
17
  import { tmpdir } from 'node:os'
18
18
  import { join } from 'node:path'
19
19
  import { collectCoverage, DEFAULT_ARM } from '@/sandbox/coverage'
20
+ import {
21
+ armNames,
22
+ listCategories,
23
+ listScenarioFiles,
24
+ loadScenario,
25
+ type ScenarioKind,
26
+ } from '@/sandbox/scenario'
20
27
  import { createStubRemote, readStubManifest } from '@/sandbox/stub-remote'
21
28
 
22
- /** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
23
- const FIXTURES_DIR = 'fixtures'
24
-
25
29
  /** One arm provisions inside this budget on each side, or reads as a failed run. */
26
30
  const PROVISION_TIMEOUT_MS = 120_000
27
31
 
@@ -149,13 +153,6 @@ export function armKey(arm: Arm): string {
149
153
  return arm.arm === undefined ? scenario : `${scenario}/${arm.arm}`
150
154
  }
151
155
 
152
- function directories(path: string): string[] {
153
- return readdirSync(path, { withFileTypes: true })
154
- .filter((entry) => entry.isDirectory())
155
- .map((entry) => entry.name)
156
- .sort()
157
- }
158
-
159
156
  /**
160
157
  * Reads the quoted arguments of `select_or_route_scenario`, joining a call that
161
158
  * continues across backslash-newline pairs. The first argument is the prompt and
@@ -201,6 +198,42 @@ function matchesArm(arm: Arm, targets: readonly string[]): boolean {
201
198
  return scoped.includes(armKey(arm))
202
199
  }
203
200
 
201
+ /**
202
+ * Reads a scenario's arm names and anchor flag: off a bash file's source, and
203
+ * off a TypeScript module's own keys, which is what its stage routes on.
204
+ */
205
+ function readArms(
206
+ file: string,
207
+ kind: ScenarioKind,
208
+ ):
209
+ | { ok: true; anchor: boolean; names: string[] }
210
+ | { ok: false; error: string } {
211
+ if (kind === 'sh') {
212
+ const source = readFileSync(file, 'utf8')
213
+
214
+ return {
215
+ ok: true,
216
+ anchor: /^use_anchor\s*\(\)/m.test(source),
217
+ names: parseArmNames(source),
218
+ }
219
+ }
220
+
221
+ try {
222
+ const definition = loadScenario(file)
223
+
224
+ return {
225
+ ok: true,
226
+ anchor: definition.anchor === true,
227
+ names: armNames(definition),
228
+ }
229
+ } catch (error) {
230
+ return {
231
+ ok: false,
232
+ error: error instanceof Error ? error.message : String(error),
233
+ }
234
+ }
235
+ }
236
+
204
237
  /**
205
238
  * Lists every provision a target names, from the scenario source. An armed arm
206
239
  * that `canon sandbox coverage` reports and this list lacks is returned as an
@@ -216,25 +249,23 @@ export function enumerateArms(
216
249
  const errors: string[] = []
217
250
  const coverage = collectCoverage(root)
218
251
 
219
- for (const category of directories(sandboxDir)) {
220
- if (category === FIXTURES_DIR) continue
221
-
222
- const commands = readdirSync(join(sandboxDir, category), {
223
- withFileTypes: true,
224
- })
225
- .filter((f) => f.isFile() && f.name.endsWith('.sh'))
226
- .map((f) => f.name.replace(/\.sh$/, ''))
227
- .sort()
252
+ for (const category of listCategories(sandboxDir)) {
253
+ const { scenarios, ambiguous } = listScenarioFiles(
254
+ join(sandboxDir, category),
255
+ )
256
+ for (const command of ambiguous)
257
+ if (matchesTarget(category, command, targets))
258
+ errors.push(`${category}:${command} exists as both .sh and .ts`)
228
259
 
229
- for (const command of commands) {
260
+ for (const { command, file, kind } of scenarios) {
230
261
  if (!matchesTarget(category, command, targets)) continue
231
262
 
232
- const source = readFileSync(
233
- join(sandboxDir, category, `${command}.sh`),
234
- 'utf8',
235
- )
236
- const anchor = /^use_anchor\s*\(\)/m.test(source)
237
- const names = parseArmNames(source)
263
+ const declared = readArms(file, kind)
264
+ if (!declared.ok) {
265
+ errors.push(`${category}:${command} did not load: ${declared.error}`)
266
+ continue
267
+ }
268
+ const { anchor, names } = declared
238
269
 
239
270
  const found: Arm[] =
240
271
  names.length === 0
@@ -37,7 +37,7 @@ import {
37
37
  logStep,
38
38
  logWarn,
39
39
  outro,
40
- } from '@/ui'
40
+ } from '@/cli/ui'
41
41
 
42
42
  /**
43
43
  * The flags and budget every run takes, each overridable through its
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { PROJECT_ROOT } from '@/project-root'
5
+ import { PROJECT_ROOT } from '@/roots/project'
6
6
 
7
7
  /**
8
8
  * The bash entry a scenario's hooks run through. Scenarios and the library they