@erclx/canon 5.13.0 → 5.14.1

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 (68) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/SKILL.md +3 -2
  3. package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
  4. package/claude/skills/deprecation-migration/SKILL.md +115 -0
  5. package/claude/skills/deprecation-migration/references/adopted.md +50 -0
  6. package/claude/skills/git-followup/REQUIREMENT.md +1 -0
  7. package/claude/skills/git-followup/SKILL.md +3 -0
  8. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  9. package/claude/skills/git-pr/SKILL.md +1 -1
  10. package/claude/skills/git-pr/references/evidence.md +2 -2
  11. package/claude/skills/git-ship/REQUIREMENT.md +3 -0
  12. package/claude/skills/git-ship/SKILL.md +12 -1
  13. package/claude/skills/review-pr/SKILL.md +1 -1
  14. package/claude/skills/review-pr/references/close-out.md +1 -1
  15. package/claude/skills/review-pr/references/post.md +1 -1
  16. package/claude/skills/review-ui/REQUIREMENT.md +5 -2
  17. package/claude/skills/review-ui/SKILL.md +12 -4
  18. package/claude/skills/review-ui/references/driving.md +5 -3
  19. package/claude/skills/review-ui/references/post.md +3 -2
  20. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
  22. package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
  23. package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
  24. package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
  25. package/docs/agents/commands.md +1 -1
  26. package/docs/agents/index.md +1 -0
  27. package/docs/agents/markdown-audit.md +1 -1
  28. package/docs/agents/pr-evidence.md +5 -0
  29. package/docs/agents/pr-frames.md +112 -0
  30. package/docs/agents/pr-reads.md +1 -1
  31. package/docs/agents/sandbox.md +3 -3
  32. package/docs/agents/state-scoped-risk.md +2 -2
  33. package/docs/workflow/skill-map.md +15 -14
  34. package/package.json +4 -5
  35. package/scripts/core/check-color-source.sh +11 -1
  36. package/scripts/core/install-check.sh +13 -4
  37. package/scripts/lib/sandbox-dispatch.sh +1 -1
  38. package/scripts/lib/sandbox-fixtures.sh +2 -2
  39. package/scripts/lib/sandbox-path.sh +1 -1
  40. package/scripts/manage-sandbox.sh +2 -2
  41. package/src/claude/cases/misc.ts +5 -0
  42. package/src/claude/skills-audit.ts +1 -0
  43. package/src/commands/pr.ts +452 -0
  44. package/src/commands/sandbox.ts +2 -2
  45. package/src/context/architecture.ts +5 -2
  46. package/src/context/citations.ts +1 -1
  47. package/src/gate/boundaries.ts +509 -0
  48. package/src/gate/measures.ts +8 -19
  49. package/src/gate/stages.ts +12 -28
  50. package/src/pr/frames.ts +323 -0
  51. package/src/sandbox/census.ts +1 -1
  52. package/src/sandbox/coverage.ts +1 -1
  53. package/src/sandbox/expect.ts +0 -1
  54. package/src/shipped/references.ts +8 -10
  55. package/src/targets/pulls.ts +2 -2
  56. package/src/tooling/manifest.ts +2 -3
  57. package/src/tooling/seeds.ts +37 -0
  58. package/tooling/claude/manifest.toml +3 -3
  59. package/tsconfig.json +1 -1
  60. package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
  61. package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
  62. package/scripts/core/check-capability-seeding.sh +0 -134
  63. package/scripts/core/check-ignore-parity.sh +0 -141
  64. package/scripts/core/check-plugin-boundary.sh +0 -45
  65. package/scripts/core/check-seed-independence.sh +0 -62
  66. package/scripts/core/check-skill-paths.sh +0 -24
  67. package/scripts/core/list-seed-roots.sh +0 -18
  68. package/scripts/lib/tooling.sh +0 -37
@@ -6,7 +6,7 @@
6
6
  FIXTURE_SUFFIX=".fixture"
7
7
 
8
8
  # The tree mirrors the scenario path, so `category` and `scenario` together match
9
- # `scripts/sandbox/<category>/<scenario>.sh`. Both segments are load-bearing:
9
+ # `sandbox/<category>/<scenario>.sh`. Both segments are load-bearing:
10
10
  # four scenario basenames repeat across categories (`claude`, `docs`, `review`,
11
11
  # `sync`), and `docs` is also a category of its own.
12
12
  fixture_stage_dir() {
@@ -14,7 +14,7 @@ fixture_stage_dir() {
14
14
  local scenario="$2"
15
15
  local arm="$3"
16
16
  local stage="$4"
17
- echo "$PROJECT_ROOT/scripts/sandbox/fixtures/$category/$scenario/$arm/$stage"
17
+ echo "$PROJECT_ROOT/sandbox/fixtures/$category/$scenario/$arm/$stage"
18
18
  }
19
19
 
20
20
  list_fixture_files() {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
 
3
- # The sandbox tree lives outside the toolkit worktree. `scripts/sandbox/run.sh`
3
+ # The sandbox tree lives outside the toolkit worktree. `sandbox/run.sh`
4
4
  # sets cwd to it for `claude -p`, and every `CLAUDE.md` between that cwd and the
5
5
  # filesystem root loads into the session. Under the repository the toolkit's own
6
6
  # instructions join that chain beside the seeded copy the scenario installed,
@@ -40,7 +40,7 @@ show_help() {
40
40
  exit 0
41
41
  }
42
42
 
43
- ANCHOR_FIXTURE_DIR="$PROJECT_ROOT/scripts/sandbox/fixtures/anchor/create"
43
+ ANCHOR_FIXTURE_DIR="$PROJECT_ROOT/sandbox/fixtures/anchor/create"
44
44
 
45
45
  # Where an anchor scenario's tree comes from and where its remote points are two
46
46
  # concerns, and only the remote needs a network. Taking the tree from a fixture
@@ -438,7 +438,7 @@ main() {
438
438
  fi
439
439
 
440
440
  SANDBOX="$(resolve_sandbox_dir)"
441
- SANDBOX_DIR="$PROJECT_ROOT/scripts/sandbox"
441
+ SANDBOX_DIR="$PROJECT_ROOT/sandbox"
442
442
 
443
443
  local unsafe
444
444
  if ! unsafe="$(assert_sandbox_dir_safe "$SANDBOX" "$PROJECT_ROOT")"; then
@@ -84,6 +84,11 @@ export const MISC_CASES: readonly SkillCase[] = [
84
84
  "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?",
85
85
  expect: 'build-in-slices',
86
86
  },
87
+ {
88
+ prompt:
89
+ 'The old parseDate helper was replaced months ago. Can I just delete it, or could a few files still be calling it?',
90
+ expect: 'deprecation-migration',
91
+ },
87
92
  {
88
93
  prompt: 'Does our github about text still match what the readme says?',
89
94
  expect: 'repo-metadata',
@@ -39,6 +39,7 @@ export const PRACTICE_SKILLS: readonly string[] = [
39
39
  join(SHIPPED_CORPUS, 'api-design'),
40
40
  join(SHIPPED_CORPUS, 'code-craft'),
41
41
  join(SHIPPED_CORPUS, 'build-in-slices'),
42
+ join(SHIPPED_CORPUS, 'deprecation-migration'),
42
43
  ]
43
44
 
44
45
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
@@ -36,6 +36,19 @@ import {
36
36
  readOwed,
37
37
  renderEvidenceBody,
38
38
  } from '@/pr/evidence'
39
+ import {
40
+ dropFrames,
41
+ FRAMES_BRANCH,
42
+ type FramesApi,
43
+ type FramesRefusal,
44
+ frameLink,
45
+ isFrameHead,
46
+ isFramePass,
47
+ pruneFrames,
48
+ pushFrame,
49
+ readFrame,
50
+ type TreeEntry,
51
+ } from '@/pr/frames'
39
52
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
40
53
  import {
41
54
  findLocalServer,
@@ -116,6 +129,55 @@ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
116
129
  'gh could not read or edit the marked comment on this pull request.',
117
130
  }
118
131
 
132
+ interface FramesOptions extends ReadOptions {
133
+ readonly add?: string
134
+ readonly box?: string
135
+ readonly head?: string
136
+ readonly pass?: string
137
+ readonly drop?: boolean
138
+ readonly prune?: string
139
+ }
140
+
141
+ type FramesCommandRefusal =
142
+ | FramesRefusal
143
+ | 'bad-mode'
144
+ | 'no-number'
145
+ | 'bad-box'
146
+ | 'bad-head'
147
+ | 'bad-pass'
148
+ | 'bad-days'
149
+ | 'unreadable-frame'
150
+ | 'read-only'
151
+ | 'gh-missing'
152
+ | 'gh-failed'
153
+ | 'no-object-head'
154
+
155
+ const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
156
+ 'bad-mode':
157
+ 'Pass exactly one of --add, --drop, or --prune. --prune reads every pull request on the branch, so it takes no number.',
158
+ 'no-number':
159
+ '--add and --drop write for one pull request, so name its number.',
160
+ 'bad-box':
161
+ '--add takes --box <n>, a positive whole number naming the checklist box.',
162
+ 'bad-head':
163
+ '--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
164
+ 'bad-pass':
165
+ '--add takes --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
166
+ 'bad-days': '--prune takes a positive whole number of days.',
167
+ 'unreadable-frame':
168
+ 'The file named by --add is missing, empty, or not a PNG, so it would embed as a broken image. Nothing is pushed.',
169
+ 'read-only': `GitHub refused the write to ${FRAMES_BRANCH}. The token cannot write this repository, which is what a fork pull request's workflow token gets, so describe the frame in words instead.`,
170
+ 'gh-missing':
171
+ 'gh is not on the path, so the branch could not be read or written.',
172
+ 'gh-failed':
173
+ 'gh could not answer for this repository or pull request, so no link could be built.',
174
+ 'no-object-head':
175
+ 'The pull request object reported no head commit and no --head was passed, so the frame has no head to file under.',
176
+ 'unreadable-tip': `The ${FRAMES_BRANCH} branch could not be read, so nothing was written. A missing branch is not this, since an add creates it.`,
177
+ 'push-failed': `A write to ${FRAMES_BRANCH} failed partway. The ref did not move, so the branch is as it was.`,
178
+ 'ref-conflict': `Another writer kept moving ${FRAMES_BRANCH} through every attempt, so the add gave up. Re-run it.`,
179
+ }
180
+
119
181
  interface PreviewOptions extends ReadOptions {
120
182
  readonly timeout?: string
121
183
  readonly check?: boolean
@@ -659,6 +721,82 @@ export function register(program: Command): void {
659
721
  ? await runLocalRemove(number, opts)
660
722
  : await runLocal(opts)
661
723
  })
724
+
725
+ pr.command('frames')
726
+ .description(
727
+ `Push a UI review frame to the never-merged ${FRAMES_BRANCH} branch and return its embed link, or drop frames from it`,
728
+ )
729
+ .argument(
730
+ '[number]',
731
+ 'Pull request the frame belongs to, for --add and --drop',
732
+ )
733
+ .helpOption('-h, --help', 'Show this help message')
734
+ .option('--root <path>', 'Repository to answer for, defaulting to the cwd')
735
+ .option('--json', 'Add a machine-readable record on stdout')
736
+ .option('--add <file>', 'Push this PNG as a frame')
737
+ .option('--box <n>', 'The checklist box the frame shows, with --add')
738
+ .option(
739
+ '--head <sha>',
740
+ "The head the frame was driven at, with --add, defaulting to the pull request's",
741
+ )
742
+ .option(
743
+ '--pass <stamp>',
744
+ 'The instant the pass started, in compact UTC such as 20261002T154450Z, with --add',
745
+ )
746
+ .option('--drop', "Rewrite the branch without this pull request's frames")
747
+ .option(
748
+ '--prune <days>',
749
+ 'Drop the frames of every pull request closed more than this many days ago',
750
+ )
751
+ .addHelpText(
752
+ 'after',
753
+ [
754
+ '',
755
+ `Writes only refs/heads/${FRAMES_BRANCH}, through the GitHub git data API,`,
756
+ 'so nothing is checked out and no other branch is touched. A frame lands',
757
+ 'at pr-<number>/<short-head>/<pass>/box-<n>.png, and `link` names that',
758
+ `path on ${FRAMES_BRANCH}, so it survives a rewrite that drops other pull`,
759
+ 'requests and resolves until its own pull request is dropped. The pass',
760
+ 'segment keeps a second pass at one head and box from replacing an image',
761
+ 'an earlier comment shows. The first --add creates the branch.',
762
+ '',
763
+ '--drop and --prune write one root commit and force-move the ref,',
764
+ 'since a delete commit would keep every image reachable through history.',
765
+ 'A rewrite that empties the branch deletes it. --prune keeps a pull',
766
+ 'request still open, reopened, closed exactly the given days ago, or one',
767
+ 'whose state could not be read, listing that last kind under `unread`.',
768
+ '',
769
+ 'Read `reason` on the JSON record:',
770
+ ' ok an add pushed, or a drop or prune finished',
771
+ ' bad-mode not exactly one of --add, --drop, --prune',
772
+ ' no-number --add or --drop named no pull request',
773
+ ' bad-box --add without a positive --box',
774
+ ' bad-head --head is not 7 to 40 lowercase hex characters',
775
+ ' bad-pass --add without a compact UTC --pass stamp',
776
+ ' bad-days --prune without a positive number of days',
777
+ ' unreadable-frame the --add file is missing, empty, or not a PNG',
778
+ ' read-only GitHub refused the write, as on a fork token',
779
+ ' unreadable-tip the branch could not be read',
780
+ ' push-failed a write failed and the ref did not move',
781
+ ' ref-conflict another writer kept moving the ref',
782
+ ' no-object-head no head to file the frame under',
783
+ ' gh-missing gh is not on the path',
784
+ ' gh-failed gh could not answer for the repository',
785
+ '',
786
+ 'Exit codes:',
787
+ ' 0 pushed, or a drop or prune finished, including one removing nothing',
788
+ ' 1 refused, with the reason on stderr or in the JSON record',
789
+ '',
790
+ 'Examples:',
791
+ ' canon pr frames 1341 --add frames/3.png --box 3 --pass 20261002T154450Z --json',
792
+ ' canon pr frames 1341 --drop --json',
793
+ ' canon pr frames --prune 30 --json',
794
+ '',
795
+ ].join('\n'),
796
+ )
797
+ .action(async (number: string | undefined, opts: FramesOptions) => {
798
+ process.exitCode = await runFrames(number, opts)
799
+ })
662
800
  }
663
801
 
664
802
  interface PullRequestRead {
@@ -1743,6 +1881,7 @@ async function runEvidence(
1743
1881
  base,
1744
1882
  head: identity.head,
1745
1883
  body,
1884
+ states,
1746
1885
  ...(commentId !== undefined && { commentId }),
1747
1886
  ...carried,
1748
1887
  })}\n`,
@@ -2198,6 +2337,319 @@ async function runLocalRemove(
2198
2337
  return finish('removed', `comment ${commentId}`)
2199
2338
  }
2200
2339
 
2340
+ const POSITIVE_WHOLE = /^[1-9]\d*$/
2341
+
2342
+ /** One `gh api` call, keeping the HTTP status of a failure so a refused write can be told from a missing ref. */
2343
+ type ApiCall =
2344
+ | { readonly ok: true; readonly data: unknown }
2345
+ | { readonly ok: false; readonly status: number | undefined }
2346
+
2347
+ type ApiCaller = (
2348
+ method: string,
2349
+ path: string,
2350
+ body?: unknown,
2351
+ ) => Promise<ApiCall>
2352
+
2353
+ /**
2354
+ * Runs `gh api` against the repository's own path, recording every write
2355
+ * GitHub answered 403 or 404, which is how a token without write access
2356
+ * reads, so a failed push can be reported as read-only rather than broken.
2357
+ */
2358
+ function ghApiRunner(root: string): {
2359
+ readonly call: ApiCaller
2360
+ readonly refusedWrites: number[]
2361
+ } {
2362
+ const refusedWrites: number[] = []
2363
+ const call: ApiCaller = async (method, path, body) => {
2364
+ const args = ['api', '-X', method, `repos/{owner}/{repo}/${path}`]
2365
+ if (body !== undefined) args.push('--input', '-')
2366
+ const result = await execa('gh', args, {
2367
+ cwd: root,
2368
+ timeout: GH_TIMEOUT_MS,
2369
+ env: gitEnv(),
2370
+ extendEnv: false,
2371
+ reject: false,
2372
+ ...(body !== undefined && { input: JSON.stringify(body) }),
2373
+ })
2374
+ if (result.exitCode !== 0) {
2375
+ const status = /HTTP (\d{3})/.exec(String(result.stderr))?.[1]
2376
+ const code = status === undefined ? undefined : Number(status)
2377
+ if (method !== 'GET' && (code === 403 || code === 404)) {
2378
+ refusedWrites.push(code)
2379
+ }
2380
+ return { ok: false, status: code }
2381
+ }
2382
+ const text = String(result.stdout).trim()
2383
+ try {
2384
+ return { ok: true, data: text === '' ? null : JSON.parse(text) }
2385
+ } catch {
2386
+ return { ok: false, status: undefined }
2387
+ }
2388
+ }
2389
+ return { call, refusedWrites }
2390
+ }
2391
+
2392
+ function shaOf(data: unknown): string | undefined {
2393
+ if (typeof data !== 'object' || data === null || !('sha' in data)) {
2394
+ return undefined
2395
+ }
2396
+ return typeof data.sha === 'string' ? data.sha : undefined
2397
+ }
2398
+
2399
+ function isTreeEntry(value: unknown): value is TreeEntry {
2400
+ if (typeof value !== 'object' || value === null) return false
2401
+ const entry = value as Record<string, unknown>
2402
+ return (
2403
+ typeof entry.path === 'string' &&
2404
+ typeof entry.mode === 'string' &&
2405
+ (entry.type === 'blob' || entry.type === 'tree') &&
2406
+ typeof entry.sha === 'string'
2407
+ )
2408
+ }
2409
+
2410
+ /**
2411
+ * The git data API behind `canon pr frames`. A 422 on a ref write is GitHub
2412
+ * refusing a move that is not a fast-forward, or a create or update on a ref
2413
+ * that already exists or no longer does, which is another writer's doing and
2414
+ * worth a re-read rather than a failure.
2415
+ */
2416
+ function ghFramesApi(call: ApiCaller): FramesApi {
2417
+ const ref = `git/refs/heads/${FRAMES_BRANCH}`
2418
+ const refWrite = (result: ApiCall) =>
2419
+ result.ok ? 'ok' : result.status === 422 ? 'conflict' : 'failed'
2420
+ return {
2421
+ async readTip() {
2422
+ const head = await call('GET', `git/ref/heads/${FRAMES_BRANCH}`)
2423
+ if (!head.ok) {
2424
+ return head.status === 404
2425
+ ? { kind: 'missing' }
2426
+ : { kind: 'unreadable' }
2427
+ }
2428
+ const commit = (head.data as { object?: { sha?: unknown } } | null)
2429
+ ?.object?.sha
2430
+ if (typeof commit !== 'string') return { kind: 'unreadable' }
2431
+ const read = await call('GET', `git/commits/${commit}`)
2432
+ const tree = read.ok
2433
+ ? (read.data as { tree?: { sha?: unknown } } | null)?.tree?.sha
2434
+ : undefined
2435
+ return typeof tree === 'string'
2436
+ ? { kind: 'found', commit, tree }
2437
+ : { kind: 'unreadable' }
2438
+ },
2439
+ async listTree(tree) {
2440
+ const read = await call('GET', `git/trees/${tree}`)
2441
+ if (!read.ok) return undefined
2442
+ const entries = (read.data as { tree?: unknown } | null)?.tree
2443
+ return Array.isArray(entries) && entries.every(isTreeEntry)
2444
+ ? entries
2445
+ : undefined
2446
+ },
2447
+ async createBlob(base64) {
2448
+ const made = await call('POST', 'git/blobs', {
2449
+ content: base64,
2450
+ encoding: 'base64',
2451
+ })
2452
+ return made.ok ? shaOf(made.data) : undefined
2453
+ },
2454
+ async createTree(entries, base) {
2455
+ const made = await call('POST', 'git/trees', {
2456
+ tree: entries.map(({ path, mode, type, sha }) => ({
2457
+ path,
2458
+ mode,
2459
+ type,
2460
+ sha,
2461
+ })),
2462
+ ...(base !== undefined && { base_tree: base }),
2463
+ })
2464
+ return made.ok ? shaOf(made.data) : undefined
2465
+ },
2466
+ async createCommit(message, tree, parents) {
2467
+ const made = await call('POST', 'git/commits', {
2468
+ message,
2469
+ tree,
2470
+ parents,
2471
+ })
2472
+ return made.ok ? shaOf(made.data) : undefined
2473
+ },
2474
+ async createRef(commit) {
2475
+ return refWrite(
2476
+ await call('POST', 'git/refs', {
2477
+ ref: `refs/heads/${FRAMES_BRANCH}`,
2478
+ sha: commit,
2479
+ }),
2480
+ )
2481
+ },
2482
+ async moveRef(commit, force) {
2483
+ return refWrite(await call('PATCH', ref, { sha: commit, force }))
2484
+ },
2485
+ async deleteRef() {
2486
+ const gone = await call('DELETE', ref)
2487
+ return gone.ok || gone.status === 422
2488
+ },
2489
+ async readPull(number) {
2490
+ const read = await call('GET', `pulls/${number}`)
2491
+ if (!read.ok) return undefined
2492
+ const row = read.data as { state?: unknown; closed_at?: unknown } | null
2493
+ if (row?.state !== 'open' && row?.state !== 'closed') return undefined
2494
+ return {
2495
+ state: row.state,
2496
+ ...(typeof row.closed_at === 'string' && { closedAt: row.closed_at }),
2497
+ }
2498
+ },
2499
+ }
2500
+ }
2501
+
2502
+ /** Validates the mode and its arguments before anything reads the network. */
2503
+ function framesArgumentRefusal(
2504
+ number: string | undefined,
2505
+ opts: FramesOptions,
2506
+ ): FramesCommandRefusal | undefined {
2507
+ const modes = [
2508
+ opts.add !== undefined,
2509
+ opts.drop === true,
2510
+ opts.prune !== undefined,
2511
+ ]
2512
+ if (modes.filter(Boolean).length !== 1) return 'bad-mode'
2513
+ if (opts.prune !== undefined) {
2514
+ if (number !== undefined) return 'bad-mode'
2515
+ return POSITIVE_WHOLE.test(opts.prune) ? undefined : 'bad-days'
2516
+ }
2517
+ if (number === undefined || !POSITIVE_WHOLE.test(number)) return 'no-number'
2518
+ if (
2519
+ opts.add !== undefined &&
2520
+ (opts.box === undefined || !POSITIVE_WHOLE.test(opts.box))
2521
+ ) {
2522
+ return 'bad-box'
2523
+ }
2524
+ if (opts.head !== undefined && !isFrameHead(opts.head)) return 'bad-head'
2525
+ if (
2526
+ opts.add !== undefined &&
2527
+ (opts.pass === undefined || !isFramePass(opts.pass))
2528
+ ) {
2529
+ return 'bad-pass'
2530
+ }
2531
+ return undefined
2532
+ }
2533
+
2534
+ async function readRepoName(root: string): Promise<string | undefined> {
2535
+ const row = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
2536
+ if (row === null) return undefined
2537
+ try {
2538
+ const name = (JSON.parse(row) as { nameWithOwner?: unknown }).nameWithOwner
2539
+ return typeof name === 'string' ? name : undefined
2540
+ } catch {
2541
+ return undefined
2542
+ }
2543
+ }
2544
+
2545
+ async function runFrames(
2546
+ number: string | undefined,
2547
+ opts: FramesOptions,
2548
+ ): Promise<number> {
2549
+ const root = resolve(opts.root ?? process.cwd())
2550
+ const emitJson = opts.json ?? false
2551
+ const refuseFrames = (reason: FramesCommandRefusal) =>
2552
+ refuseWith(reason, FRAMES_REFUSALS[reason], emitJson, root)
2553
+
2554
+ intro('canon pr frames')
2555
+
2556
+ const invalid = framesArgumentRefusal(number, opts)
2557
+ if (invalid !== undefined) return refuseFrames(invalid)
2558
+
2559
+ const bytes =
2560
+ opts.add === undefined ? undefined : await readFrame(resolve(opts.add))
2561
+ if (opts.add !== undefined && bytes === undefined) {
2562
+ return refuseFrames('unreadable-frame')
2563
+ }
2564
+
2565
+ if (Bun.which('gh') === null) return refuseFrames('gh-missing')
2566
+ const runner = ghApiRunner(root)
2567
+ const api = ghFramesApi(runner.call)
2568
+ const refuseWrite = (reason: FramesRefusal) =>
2569
+ refuseFrames(
2570
+ reason === 'push-failed' && runner.refusedWrites.length > 0
2571
+ ? 'read-only'
2572
+ : reason,
2573
+ )
2574
+
2575
+ const finish = (
2576
+ record: Record<string, unknown>,
2577
+ lines: readonly string[],
2578
+ ): number => {
2579
+ logStep('Frames')
2580
+ for (const line of lines) logInfo(line)
2581
+ outro()
2582
+ if (emitJson) {
2583
+ process.stdout.write(
2584
+ `${JSON.stringify({ root, reason: 'ok', ...record })}\n`,
2585
+ )
2586
+ }
2587
+ return 0
2588
+ }
2589
+
2590
+ if (opts.prune !== undefined) {
2591
+ const days = Number(opts.prune)
2592
+ const result = await pruneFrames(api, days, new Date())
2593
+ if (result.kind === 'refused') return refuseWrite(result.reason)
2594
+ const { kind: _kind, ...rest } = result
2595
+ return finish({ days, ...rest }, [
2596
+ `removed ${plural(result.removed.length, 'pull request')}`,
2597
+ ...(result.unread.length > 0
2598
+ ? [`kept ${result.unread.map((n) => `#${n}`).join(', ')} unread`]
2599
+ : []),
2600
+ ])
2601
+ }
2602
+
2603
+ const pull = Number(number)
2604
+ if (opts.drop === true) {
2605
+ const result = await dropFrames(api, [pull])
2606
+ if (result.kind === 'refused') return refuseWrite(result.reason)
2607
+ const { kind: _kind, ...rest } = result
2608
+ return finish({ number: pull, ...rest }, [
2609
+ result.removed.length === 0
2610
+ ? `#${pull} holds no frames on ${FRAMES_BRANCH}`
2611
+ : `dropped the frames of #${pull}`,
2612
+ ])
2613
+ }
2614
+
2615
+ const repo = await readRepoName(root)
2616
+ if (repo === undefined) return refuseFrames('gh-failed')
2617
+
2618
+ let head = opts.head
2619
+ if (head === undefined) {
2620
+ const identity = await readIdentity(root, String(pull))
2621
+ if (identity.kind === 'refused') return refuseFrames('gh-failed')
2622
+ head = identity.identity.head
2623
+ }
2624
+ if (head === undefined || head === '') return refuseFrames('no-object-head')
2625
+ if (!isFrameHead(head)) return refuseFrames('bad-head')
2626
+
2627
+ const box = Number(opts.box)
2628
+ const pass = opts.pass ?? ''
2629
+ const result = await pushFrame(api, {
2630
+ number: pull,
2631
+ head,
2632
+ pass,
2633
+ box,
2634
+ bytes: bytes ?? new Uint8Array(),
2635
+ })
2636
+ if (result.kind === 'refused') return refuseWrite(result.reason)
2637
+ const link = frameLink(repo, result.path)
2638
+ return finish(
2639
+ {
2640
+ number: pull,
2641
+ box,
2642
+ head,
2643
+ pass,
2644
+ path: result.path,
2645
+ commit: result.commit,
2646
+ link,
2647
+ created: result.created,
2648
+ },
2649
+ [link],
2650
+ )
2651
+ }
2652
+
2201
2653
  /**
2202
2654
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
2203
2655
  * so an operator reading the terminal sees the reason rather than a command
@@ -34,13 +34,13 @@ import {
34
34
  select,
35
35
  } from '@/ui'
36
36
 
37
- const SANDBOX_DIR = join(PROJECT_ROOT, 'scripts', 'sandbox')
37
+ const SANDBOX_DIR = join(PROJECT_ROOT, 'sandbox')
38
38
 
39
39
  /**
40
40
  * Reports that the scenario tree does not ship, and answers whether it reported,
41
41
  * so a caller that sees `true` returns without touching the tree.
42
42
  *
43
- * `scripts/sandbox` is excluded from the published package, so an installed
43
+ * `sandbox` sits outside the published package, so an installed
44
44
  * `canon` resolves `SANDBOX_DIR` to a directory that is not there. Both entry
45
45
  * points that walk the tree ask here rather than carrying a check each, because
46
46
  * absence is a property of the install rather than of a verb, and a second copy
@@ -122,8 +122,11 @@ const QUANTIFIES_PATH = new RegExp(
122
122
  'i',
123
123
  )
124
124
 
125
- /** A code span naming a shell check this repository could run. */
126
- const SCRIPT_SPAN = /^scripts\/[\w./-]+\.sh$/
125
+ /**
126
+ * A code span naming a check this repository could run: a shell script, or a
127
+ * gate module whose measures read the tree in-process.
128
+ */
129
+ const SCRIPT_SPAN = /^(?:scripts\/[\w./-]+\.sh|src\/gate\/[\w./-]+\.ts)$/
127
130
  /** A code span invoking the CLI, which may or may not name a registered audit. */
128
131
  const CANON_SPAN = /^canon\s+(.+)$/
129
132
 
@@ -60,7 +60,7 @@ function ignoredCitations(line: string): IgnoredCitations | undefined {
60
60
  * eval harness names paths in its target project. Neither is a reference into
61
61
  * this repository, so an unresolved path there is correct rather than stale.
62
62
  */
63
- const FIXTURE_TREES: readonly string[] = ['scripts/sandbox/', 'scripts/eval/']
63
+ const FIXTURE_TREES: readonly string[] = ['sandbox/', 'scripts/eval/']
64
64
 
65
65
  const FIXTURE_SEGMENTS: readonly string[] = ['fixtures', '__fixtures__']
66
66