@erclx/canon 5.27.0 → 5.28.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 (139) 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 +13 -3
  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 +4 -4
  31. package/docs/agents/gate.md +3 -1
  32. package/docs/agents/index.md +2 -1
  33. package/docs/agents/pr-evidence.md +9 -8
  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/inspector/field.tsx +61 -2
  46. package/src/canvas/client/inspector/fields.css +78 -0
  47. package/src/canvas/client/inspector/token-select.tsx +109 -0
  48. package/src/canvas/client/inspector/values.ts +74 -2
  49. package/src/canvas/client/inspector.tsx +121 -11
  50. package/src/canvas/client/keys.ts +54 -0
  51. package/src/canvas/client/selection.tsx +3 -1
  52. package/src/canvas/client/shell.css +108 -2
  53. package/src/canvas/client/state.ts +96 -5
  54. package/src/canvas/client/surface.tsx +194 -17
  55. package/src/canvas/client/tools.tsx +64 -0
  56. package/src/canvas/content.ts +1 -1
  57. package/src/canvas/edit.ts +12 -2
  58. package/src/canvas/tokens.ts +1 -1
  59. package/src/claude/seeds.ts +2 -2
  60. package/src/claude/skills-reach.ts +1 -1
  61. package/src/commands/canvas.ts +1 -1
  62. package/src/commands/claude.ts +3 -3
  63. package/src/commands/design.ts +3 -3
  64. package/src/commands/docs.ts +1 -1
  65. package/src/commands/feedback.ts +2 -2
  66. package/src/commands/gate.ts +9 -2
  67. package/src/commands/gov.ts +2 -2
  68. package/src/commands/init.ts +1 -1
  69. package/src/commands/migrate.ts +1 -1
  70. package/src/commands/pr.ts +183 -7
  71. package/src/commands/sandbox.ts +10 -13
  72. package/src/commands/slides.ts +1 -1
  73. package/src/commands/sync.ts +1 -1
  74. package/src/commands/tasks.ts +19 -2
  75. package/src/commands/tooling.ts +1 -1
  76. package/src/commands/transcripts.ts +1 -1
  77. package/src/commands/upgrade.ts +1 -1
  78. package/src/commands/wiki.ts +1 -1
  79. package/src/context/architecture.ts +1 -1
  80. package/src/context/citations.ts +2 -2
  81. package/src/context/classify/extract.ts +1 -1
  82. package/src/context/folders.ts +2 -2
  83. package/src/context/requirements.ts +1 -1
  84. package/src/docs/read.ts +1 -1
  85. package/src/exec.ts +1 -1
  86. package/src/feedback/github.ts +1 -1
  87. package/src/gate/measures.ts +1 -1
  88. package/src/gate/sequencer.ts +55 -3
  89. package/src/gate/stages.ts +8 -0
  90. package/src/gate/suite-lock.ts +214 -0
  91. package/src/intake/folder.ts +1 -1
  92. package/src/labels/map.ts +1 -1
  93. package/src/labels/phase.ts +3 -3
  94. package/src/migrate/record-layout.ts +1 -1
  95. package/src/migrate/records.ts +11 -11
  96. package/src/migrate/scratch-evidence.ts +1 -1
  97. package/src/migrate/surface-roots.ts +6 -6
  98. package/src/pr/evidence.ts +154 -16
  99. package/src/pr/tick.ts +56 -0
  100. package/src/process/harness.ts +1 -1
  101. package/src/records/backup.ts +1 -1
  102. package/src/records/ordinal.ts +1 -1
  103. package/src/records/prune.ts +1 -1
  104. package/src/records/size.ts +1 -1
  105. package/src/records/stale-canonical.ts +1 -1
  106. package/src/records/stale.ts +1 -1
  107. package/src/records/validate.ts +2 -2
  108. package/src/{project-root.ts → roots/project.ts} +1 -1
  109. package/src/{record-root.ts → roots/record.ts} +1 -1
  110. package/src/sandbox/coverage.ts +11 -12
  111. package/src/sandbox/equivalence.ts +57 -26
  112. package/src/sandbox/hooks.ts +1 -1
  113. package/src/sandbox/provision.ts +78 -28
  114. package/src/sandbox/scenario.ts +588 -0
  115. package/src/sessions/live.ts +4 -1
  116. package/src/sessions/transfer/bundle.ts +1 -1
  117. package/src/slides/decks.ts +1 -1
  118. package/src/standards/read.ts +1 -1
  119. package/src/sync/engine.ts +1 -1
  120. package/src/sync/layout.ts +2 -2
  121. package/src/sync/stamp.ts +1 -1
  122. package/src/targets/validate.ts +1 -1
  123. package/src/tasks/answers.ts +1 -1
  124. package/src/tasks/archive.ts +1 -1
  125. package/src/tasks/label.ts +1 -1
  126. package/src/tasks/reach.ts +1 -1
  127. package/src/tasks/validate.ts +38 -14
  128. package/src/teach/nav.ts +1 -1
  129. package/src/teach/workspace.ts +1 -1
  130. package/src/tooling/inject.ts +1 -1
  131. package/src/tooling/read.ts +1 -1
  132. package/src/tooling/scan.ts +1 -1
  133. package/src/version/installed.ts +1 -1
  134. package/src/version/skew.ts +1 -1
  135. package/standards/board.md +3 -3
  136. package/standards/plan.md +13 -7
  137. package/tsconfig.json +1 -1
  138. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  139. /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
 
@@ -130,6 +139,35 @@ function rawUrl(repo: string, sha: string, path: string): string {
130
139
  return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
131
140
  }
132
141
 
142
+ /**
143
+ * The pixel width every screenshot asks for. GitHub sizes a comment table to
144
+ * its content and clamps an image to its cell, so a width past any comment
145
+ * fills the cell, where `width="100%"` leaves a narrow screenshot narrow.
146
+ */
147
+ const IMAGE_WIDTH = 1000
148
+
149
+ /** A table cell and an attribute value share one escape, since a stem is a path segment placed in both. */
150
+ function escapeCell(text: string): string {
151
+ return text
152
+ .replaceAll('&', '&amp;')
153
+ .replaceAll('<', '&lt;')
154
+ .replaceAll('>', '&gt;')
155
+ .replaceAll('"', '&quot;')
156
+ .replaceAll('|', '&#124;')
157
+ }
158
+
159
+ function evidenceImage(url: string, alt: string): string {
160
+ return `<img src="${url}" width="${IMAGE_WIDTH}" alt="${alt}">`
161
+ }
162
+
163
+ const EVIDENCE_IMAGE = String.raw`(?:!\[\]\([^)]*\)|<img [^>]*>)`
164
+
165
+ /** A case row in any shape a render has posted: a Base cell before the head image, or the head image alone. */
166
+ const EVIDENCE_ROW_PATTERN = new RegExp(
167
+ String.raw`^\| .+ \| (?:(?:\*\(new\)\*|${EVIDENCE_IMAGE}) \| )?${EVIDENCE_IMAGE} \|$`,
168
+ 'gm',
169
+ )
170
+
133
171
  export function evidenceMarker(head: string): string {
134
172
  return `${MARKER_PREFIX} head=${head} -->`
135
173
  }
@@ -189,20 +227,24 @@ export function renderEvidenceBody(
189
227
  imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
190
228
 
191
229
  const sections = states.map((entry) => {
230
+ const hasBase = entry.items.some((item) => !item.added)
192
231
  const rows = entry.items.map((item) => {
232
+ const stem = escapeCell(item.stem)
193
233
  const before = item.added
194
234
  ? '*(new)*'
195
- : `![](${rawUrl(repo, base, item.path)})`
196
- const after = `![](${rawUrl(repo, head, item.path)})`
197
- return `| ${item.stem} | ${before} | ${after} |`
235
+ : evidenceImage(rawUrl(repo, base, item.path), `${stem} base`)
236
+ const after = evidenceImage(rawUrl(repo, head, item.path), `${stem} head`)
237
+ return hasBase
238
+ ? `| ${stem} | ${before} | ${after} |`
239
+ : `| ${stem} | ${after} |`
198
240
  })
199
241
 
200
242
  return [
201
243
  opener,
202
244
  `<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
203
245
  '',
204
- '| Case | Base | Head |',
205
- '| --- | --- | --- |',
246
+ hasBase ? '| Case | Base | Head |' : '| Case | Head |',
247
+ hasBase ? '| --- | --- | --- |' : '| --- | --- |',
206
248
  ...rows,
207
249
  '',
208
250
  '</details>',
@@ -221,16 +263,14 @@ export function renderEvidenceBody(
221
263
  }
222
264
 
223
265
  /**
224
- * How many case rows a rendered body carries, read off the same row shape
266
+ * How many case rows a rendered body carries, read off the row shapes
225
267
  * `renderEvidenceBody` writes. A row always ends in a head image, which a
226
- * checklist line or a preview address never does.
268
+ * checklist line or a preview address never does. The pattern also reads the
269
+ * `![](url)` three-column shape an earlier render posted, so a comment posted
270
+ * before the change still counts its cases and the `would-empty` guard holds.
227
271
  */
228
272
  export function countEvidenceCases(body: string): number {
229
- return (
230
- body.match(
231
- /^\| .+ \| (?:\*\(new\)\*|!\[\]\([^)]*\)) \| !\[\]\([^)]*\) \|$/gm,
232
- )?.length ?? 0
233
- )
273
+ return body.match(EVIDENCE_ROW_PATTERN)?.length ?? 0
234
274
  }
235
275
 
236
276
  export interface EvidenceComment {
@@ -410,3 +450,101 @@ export function findEvidenceChecklist(
410
450
  marked.body.slice(start + CHECKLIST_START.length, end).trim() || undefined
411
451
  )
412
452
  }
453
+
454
+ export interface ChecklistBox {
455
+ /** One-based position among the checklist's boxes, in the order they appear. */
456
+ readonly number: number
457
+ /** Index of the box's line within the checklist. */
458
+ readonly line: number
459
+ /** The box text with any stamp removed. */
460
+ readonly text: string
461
+ readonly isTicked: boolean
462
+ /** The short sha a tick names, absent on an empty box or a tick nothing stamped. */
463
+ readonly stamp?: string
464
+ /** Whether the box ends in `(taste)`, which no driver ticks. */
465
+ readonly isTaste: boolean
466
+ }
467
+
468
+ export interface BoxLine {
469
+ readonly before: string
470
+ readonly isTicked: boolean
471
+ readonly text: string
472
+ readonly stamp?: string
473
+ }
474
+
475
+ /** Splits one checklist line into its box parts, or undefined when the line is not a box. */
476
+ export function parseBoxLine(line: string): BoxLine | undefined {
477
+ const match = BOX_LINE.exec(line)
478
+ if (match === null) return undefined
479
+ const rest = (match[4] ?? '').trimEnd()
480
+ const stamped = STAMP.exec(rest)
481
+ return {
482
+ before: match[1] ?? '',
483
+ isTicked: (match[2] ?? ' ') !== ' ',
484
+ text: stamped === null ? rest : rest.slice(0, stamped.index),
485
+ ...(stamped?.[1] !== undefined && { stamp: stamped[1] }),
486
+ }
487
+ }
488
+
489
+ /** The box line for the given parts, written back in the one shape the reader accepts. */
490
+ export function writeBoxLine(
491
+ box: Pick<BoxLine, 'before' | 'text'>,
492
+ stampedAt?: string,
493
+ ): string {
494
+ return stampedAt === undefined
495
+ ? `${box.before} ] ${box.text}`
496
+ : `${box.before}x] ${box.text} · passed at \`${stampedAt.slice(0, SHORT_SHA_LENGTH)}\``
497
+ }
498
+
499
+ /** Every box of a checklist in order, numbered the way `canon pr tick` takes them. */
500
+ export function readChecklistBoxes(checklist: string): ChecklistBox[] {
501
+ const boxes: ChecklistBox[] = []
502
+ checklist.split('\n').forEach((line, index) => {
503
+ const parsed = parseBoxLine(line)
504
+ if (parsed === undefined) return
505
+ boxes.push({
506
+ number: boxes.length + 1,
507
+ line: index,
508
+ text: parsed.text,
509
+ isTicked: parsed.isTicked,
510
+ ...(parsed.stamp !== undefined && { stamp: parsed.stamp }),
511
+ isTaste: parsed.text.endsWith(TASTE_SUFFIX),
512
+ })
513
+ })
514
+ return boxes
515
+ }
516
+
517
+ /** The head the marked comment's trailing marker names. */
518
+ export function findEvidenceHead(
519
+ comments: readonly EvidenceComment[],
520
+ ): string | undefined {
521
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
522
+ if (marked === undefined) return undefined
523
+ const lines = marked.body.trimEnd().split('\n')
524
+ const match = /head=(\S+)/.exec(lines[lines.length - 1] ?? '')
525
+ return match?.[1]
526
+ }
527
+
528
+ /**
529
+ * Clears every tick the render's head has not earned. A stamped tick stays
530
+ * while its sha is a prefix of the head. An unstamped one names no head, so
531
+ * it stays only while the comment it came from described this same head.
532
+ */
533
+ export function settleChecklist(
534
+ checklist: string,
535
+ head: string,
536
+ carriedHead: string | undefined,
537
+ ): string {
538
+ return checklist
539
+ .split('\n')
540
+ .map((line) => {
541
+ const parsed = parseBoxLine(line)
542
+ if (parsed === undefined || !parsed.isTicked) return line
543
+ const isEarned =
544
+ parsed.stamp === undefined
545
+ ? carriedHead === head
546
+ : head.startsWith(parsed.stamp)
547
+ return isEarned ? line : writeBoxLine(parsed)
548
+ })
549
+ .join('\n')
550
+ }
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 => {
@@ -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
@@ -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