@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
@@ -27,7 +27,7 @@ import {
27
27
  readEditing,
28
28
  } from '@/canvas/editing'
29
29
  import { CANVAS_PORT, startCanvas } from '@/canvas/server'
30
- import { PROJECT_ROOT } from '@/project-root'
30
+ import { PROJECT_ROOT } from '@/roots/project'
31
31
  import { resolveFrameTokens } from '@/canvas/tokens'
32
32
  import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
33
33
  import {
@@ -71,9 +71,9 @@ import {
71
71
  } from '@/claude/settings'
72
72
  import { copyPreservingMode } from '@/sync/copy'
73
73
  import { execScript } from '@/exec'
74
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
75
- import { recordDir } from '@/record-root'
76
- import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
74
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
75
+ import { recordDir } from '@/roots/record'
76
+ import { SURFACE_ENTRIES, surfaceDir } from '@/roots/surface'
77
77
  import { isDirectory, resolveTarget } from '@/targets/validate'
78
78
  import { injectGitignore, pruneGitignore } from '@/tooling/inject'
79
79
  import {
@@ -11,9 +11,9 @@ import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
11
11
  import { addProjectFace, listProjectFaces } from '@/design/project-fonts'
12
12
  import { renderDesignDoc } from '@/design/render'
13
13
  import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
14
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
15
- import { creationRel, SCRATCH } from '@/record-root'
16
- import { surfaceDir } from '@/surface-root'
14
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
15
+ import { creationRel, SCRATCH } from '@/roots/record'
16
+ import { surfaceDir } from '@/roots/surface'
17
17
  import { recordStamp, runDomainSync } from '@/sync/engine'
18
18
  import { isDirectory, resolveTarget } from '@/targets/validate'
19
19
  import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
@@ -1,7 +1,7 @@
1
1
  import type { Command } from 'commander'
2
2
  import { type DocEntry, listDocs } from '@/docs/list'
3
3
  import { listTopics, readTopic, resolveTopic } from '@/docs/read'
4
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
4
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
5
5
  import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
6
6
 
7
7
  export function register(program: Command): void {
@@ -8,8 +8,8 @@ import {
8
8
  missingField,
9
9
  missingFieldMessage,
10
10
  } from '@/feedback/body'
11
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
12
- import { creationRel } from '@/record-root'
11
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
12
+ import { creationRel } from '@/roots/record'
13
13
  import { createGithubIssue } from '@/feedback/github'
14
14
  import { issueFailureMessage } from '@/feedback/github-format'
15
15
  import { frameError, frameSuccess, logWarn, palette } from '@/ui'
@@ -11,7 +11,7 @@ import {
11
11
  summarize,
12
12
  } from '@/gate/sequencer'
13
13
  import { STAGES } from '@/gate/stages'
14
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
14
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
15
15
  import {
16
16
  intro,
17
17
  logError,
@@ -113,6 +113,12 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
113
113
  cli: cliRunner(root),
114
114
  write,
115
115
  changed: changed.scoped ? changed.files : undefined,
116
+ onQueue: (label, holder) => {
117
+ const text = `${label} is waiting for the suite pid ${holder.pid} runs at ${holder.root}`
118
+ // stderr in both modes, so a JSON caller's stdout stays one record.
119
+ if (emitJson) process.stderr.write(`${text}\n`)
120
+ else logWarn(text)
121
+ },
116
122
  }
117
123
 
118
124
  const results = await runStages(
@@ -138,12 +144,13 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
138
144
  changed: changed.files.length,
139
145
  summary,
140
146
  ms: results.reduce((total, result) => total + result.ms, 0),
141
- stages: results.map(({ id, label, status, failure, ms }) => ({
147
+ stages: results.map(({ id, label, status, failure, ms, queuedMs }) => ({
142
148
  id,
143
149
  label,
144
150
  status,
145
151
  failure,
146
152
  ms,
153
+ queuedMs,
147
154
  })),
148
155
  })}\n`,
149
156
  )
@@ -8,8 +8,8 @@ import {
8
8
  type CountsReport,
9
9
  scanCounts,
10
10
  } from '@/counts/scan'
11
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
12
- import { creationRel, SCRATCH } from '@/record-root'
11
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
12
+ import { creationRel, SCRATCH } from '@/roots/record'
13
13
  import { createGovAdapter } from '@/gov/adapter'
14
14
  import { regenConsumedRules } from '@/gov/consumed'
15
15
  import { regenStandardRules } from '@/gov/standard-rules'
@@ -1,6 +1,6 @@
1
1
  import type { Command } from 'commander'
2
2
  import { cliPath, cliRun } from '@/cli-run'
3
- import { PROJECT_ROOT } from '@/project-root'
3
+ import { PROJECT_ROOT } from '@/roots/project'
4
4
  import { applyInitOptions, flagsProvided } from '@/init/flags'
5
5
  import { type InitFlags, parseSkip, planInit } from '@/init/plan'
6
6
  import { runDomains } from '@/init/run'
@@ -44,7 +44,7 @@ import {
44
44
  type ScratchEvidencePlan,
45
45
  walkScratchEvidenceCorpus,
46
46
  } from '@/migrate/scratch-evidence'
47
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
47
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
48
48
  import { readStamp, stampedHashes } from '@/sync/stamp'
49
49
  import { logError, logInfo, logStep, logWarn, plural } from '@/ui'
50
50
 
@@ -29,12 +29,15 @@ import {
29
29
  findEvidenceCaseCount,
30
30
  findEvidenceChecklist,
31
31
  findEvidenceCommentId,
32
+ findEvidenceHead,
32
33
  findEvidenceLocal,
33
34
  findEvidencePreview,
34
35
  groupEvidence,
35
36
  hasMarkedEvidenceComment,
37
+ readChecklistBoxes,
36
38
  readOwed,
37
39
  renderEvidenceBody,
40
+ settleChecklist,
38
41
  } from '@/pr/evidence'
39
42
  import {
40
43
  dropFrames,
@@ -71,6 +74,7 @@ import {
71
74
  servesChange,
72
75
  type WorkflowFile,
73
76
  } from '@/pr/preview'
77
+ import { type TickRefusal, tickBoxes } from '@/pr/tick'
74
78
  import {
75
79
  commentRowOf,
76
80
  identityOf,
@@ -113,6 +117,28 @@ interface LocalOptions extends ReadOptions {
113
117
  readonly remove?: boolean
114
118
  }
115
119
 
120
+ interface TickOptions extends ReadOptions {
121
+ readonly boxes?: string
122
+ readonly head?: string
123
+ }
124
+
125
+ type TickReason = TickRefusal | 'stale-head' | 'bad-boxes' | 'no-comment'
126
+
127
+ const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
128
+ 'stale-head':
129
+ 'The head named is not the branch tip on the remote, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
130
+ 'bad-boxes':
131
+ '--boxes takes box numbers as a comma-separated list of positive whole numbers, such as 1,3, and --head takes the commit that was driven.',
132
+ 'no-comment': 'No comment on this pull request carries the evidence marker.',
133
+ 'no-checklist': 'The evidence comment carries no checklist to tick.',
134
+ 'no-box':
135
+ 'A named box is past the end of the checklist. Number boxes off `canon pr evidence --json`.',
136
+ 'taste-box':
137
+ 'A named box ends in (taste), which no driver passes. Nothing was written.',
138
+ 'gh-failed':
139
+ 'gh could not read or edit the marked comment on this pull request.',
140
+ }
141
+
116
142
  /** Shared by every verb that resolves a pull request from the checkout's branch. */
117
143
  const AMBIGUOUS_PULL =
118
144
  'More than one pull request is open on this branch, each against another base. Name the pull request number.'
@@ -563,10 +589,12 @@ export function register(program: Command): void {
563
589
  '--checklist closes the body with a visual checklist, below the',
564
590
  'comparison it annotates. A checklist the marked comment already',
565
591
  'carries is read back and carried forward the same way the preview',
566
- 'address is, which keeps a re-render from wiping ticked boxes. It',
567
- 'turns no-evidence into ok: a branch with a checklist and no evidence',
568
- 'image renders a marked body holding the checklist, so a later call',
569
- 'finds and edits that comment.',
592
+ 'address is. A tick survives only a render at the head it names: one',
593
+ 'stamped `passed at <sha>` for another head is cleared and unstamped,',
594
+ 'and so is one carrying no stamp when the comment described another',
595
+ 'head. A checklist, supplied or carried, turns no-evidence into ok: a',
596
+ 'branch with a checklist and no evidence image renders a marked body',
597
+ 'holding the checklist, so a later call finds and edits that comment.',
570
598
  '',
571
599
  '--local adds a **Local preview:** segment after any hosted one on the',
572
600
  'address line, or carries the line alone, and is carried forward the',
@@ -580,6 +608,8 @@ export function register(program: Command): void {
580
608
  ' preview the hosted **Preview:** address the comment carries',
581
609
  ' local the **Local preview:** address beside it',
582
610
  ' checklist the checklist between its delimiters, ticks included',
611
+ ' boxes each checklist box with its number, tick, stamp, and taste',
612
+ ' mark, the numbering `canon pr tick` takes',
583
613
  'These come from the comment already posted, never from the flags this',
584
614
  'call passed. A checklist the caller posted raw after a refused render',
585
615
  'carries no marker, so the record reports none of the three for it. An',
@@ -741,6 +771,51 @@ export function register(program: Command): void {
741
771
  : await runLocal(opts)
742
772
  })
743
773
 
774
+ pr.command('tick')
775
+ .description(
776
+ "Tick the checklist boxes a UI pass drove, stamping each with the commit it passed at, on the pull request's evidence comment",
777
+ )
778
+ .argument('[number]', 'Pull request to edit, defaulting to this branch')
779
+ .helpOption('-h, --help', 'Show this help message')
780
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
781
+ .option('--json', 'Add a machine-readable record on stdout')
782
+ .requiredOption(
783
+ '--boxes <list>',
784
+ 'Box numbers to tick, comma-separated, as `canon pr evidence --json` numbers them',
785
+ )
786
+ .requiredOption('--head <sha>', 'The commit the boxes were driven at')
787
+ .addHelpText(
788
+ 'after',
789
+ [
790
+ '',
791
+ 'Edits the marked evidence comment in place, so a skill never',
792
+ 'hand-edits a comment body. Each named box becomes',
793
+ '`- [x] <box> · passed at <short sha>`. Ticking a box again restamps',
794
+ 'it. A later render at another head clears a tick whose stamp names',
795
+ 'a different commit.',
796
+ '',
797
+ 'Read `reason` on the JSON record:',
798
+ ' ticked the boxes were ticked, with the tip in `head`',
799
+ ' stale-head --head is not the remote branch tip, nothing written',
800
+ ' taste-box a named box ends in (taste), nothing written',
801
+ ' no-box a named number is past the checklist, nothing written',
802
+ ' no-checklist the evidence comment carries no checklist',
803
+ ' no-comment no comment carries the evidence marker',
804
+ ' bad-boxes --boxes or --head is malformed',
805
+ '',
806
+ 'Exit codes:',
807
+ ' 0 the boxes were ticked',
808
+ ' 1 refused, with the reason on stderr or in the JSON record',
809
+ '',
810
+ 'Examples:',
811
+ ' canon pr tick 1341 --boxes 1,2,4 --head 1a2b3c4 --json',
812
+ '',
813
+ ].join('\n'),
814
+ )
815
+ .action(async (number: string | undefined, opts: TickOptions) => {
816
+ process.exitCode = await runTick(number, opts)
817
+ })
818
+
744
819
  pr.command('frames')
745
820
  .description(
746
821
  `Push a UI review frame to the never-merged ${FRAMES_BRANCH} branch and return its embed link, or drop frames from it`,
@@ -1786,7 +1861,10 @@ async function runEvidence(
1786
1861
  const carried = {
1787
1862
  ...(carriedPreview !== undefined && { preview: carriedPreview }),
1788
1863
  ...(carriedLocal !== undefined && { local: carriedLocal }),
1789
- ...(carriedChecklist !== undefined && { checklist: carriedChecklist }),
1864
+ ...(carriedChecklist !== undefined && {
1865
+ checklist: carriedChecklist,
1866
+ boxes: readChecklistBoxes(carriedChecklist),
1867
+ }),
1790
1868
  }
1791
1869
 
1792
1870
  // A deploy that resolves and does not build from any changed path makes a
@@ -1834,7 +1912,8 @@ async function runEvidence(
1834
1912
  // comment a later call edits. A local address rides on a checklist or a
1835
1913
  // hosted preview and never opens one alone, so a docs-only branch with a
1836
1914
  // server up posts nothing.
1837
- const hasChecklist = suppliedChecklist !== undefined
1915
+ const hasChecklist =
1916
+ suppliedChecklist !== undefined || carriedChecklist !== undefined
1838
1917
  if (
1839
1918
  grouped.kind === 'refused' &&
1840
1919
  opts.preview === undefined &&
@@ -1870,7 +1949,11 @@ async function runEvidence(
1870
1949
 
1871
1950
  const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
1872
1951
  const local = opts.local ?? carriedLocal
1873
- const checklist = suppliedChecklist ?? carriedChecklist
1952
+ const unsettled = suppliedChecklist ?? carriedChecklist
1953
+ const checklist =
1954
+ unsettled === undefined
1955
+ ? undefined
1956
+ : settleChecklist(unsettled, identity.head, findEvidenceHead(comments))
1874
1957
  const states = grouped.kind === 'read' ? grouped.states : []
1875
1958
  const body = renderEvidenceBody(
1876
1959
  states,
@@ -2402,6 +2485,99 @@ async function runLocalRemove(
2402
2485
 
2403
2486
  const POSITIVE_WHOLE = /^[1-9]\d*$/
2404
2487
 
2488
+ const MIN_TICK_HEAD_LENGTH = 7
2489
+
2490
+ /**
2491
+ * Ticks the named boxes on the marked comment. It refuses a head that is not
2492
+ * the remote tip and a taste box before writing, so both rules hold as checks
2493
+ * rather than as prose a driver can talk itself out of.
2494
+ */
2495
+ async function runTick(
2496
+ number: string | undefined,
2497
+ opts: TickOptions,
2498
+ ): Promise<number> {
2499
+ const root = resolve(opts.root ?? process.cwd())
2500
+ const emitJson = opts.json ?? false
2501
+
2502
+ intro('canon pr tick')
2503
+
2504
+ const refuseTick = (reason: TickReason | 'gh-failed'): number =>
2505
+ refuseWith(reason, TICK_REFUSALS[reason], emitJson, root)
2506
+
2507
+ const numbers = (opts.boxes ?? '').split(',').map((part) => part.trim())
2508
+ const head = (opts.head ?? '').trim()
2509
+ if (
2510
+ numbers.length === 0 ||
2511
+ !numbers.every((part) => POSITIVE_WHOLE.test(part)) ||
2512
+ !/^[0-9a-f]+$/.test(head) ||
2513
+ head.length < MIN_TICK_HEAD_LENGTH
2514
+ ) {
2515
+ return refuseTick('bad-boxes')
2516
+ }
2517
+
2518
+ const read = await readIdentity(root, number)
2519
+ if (read.kind === 'refused') {
2520
+ return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
2521
+ }
2522
+ const { identity } = read
2523
+ if (identity.number === undefined) return refuseTick('gh-failed')
2524
+
2525
+ const reading = await resolveTip(identity.branch, refReader(root))
2526
+ if (reading.kind === 'refused') {
2527
+ return refuseWith(
2528
+ reading.reason,
2529
+ PULL_REFUSALS[reading.reason],
2530
+ emitJson,
2531
+ root,
2532
+ )
2533
+ }
2534
+ if (!reading.tip.startsWith(head)) return refuseTick('stale-head')
2535
+
2536
+ const comments = await listComments(root, String(identity.number))
2537
+ if (comments === undefined) return refuseTick('gh-failed')
2538
+ const commentId = findEvidenceCommentId(comments)
2539
+ const marked = comments.find((comment) =>
2540
+ comment.url?.endsWith(`#issuecomment-${commentId}`),
2541
+ )
2542
+ if (commentId === undefined || marked === undefined) {
2543
+ return refuseTick('no-comment')
2544
+ }
2545
+
2546
+ const result = tickBoxes(marked.body, numbers.map(Number), reading.tip)
2547
+ if (result.kind === 'refused') return refuseTick(result.reason)
2548
+
2549
+ if (result.body !== marked.body) {
2550
+ const patched = await gh(root, [
2551
+ 'api',
2552
+ '-X',
2553
+ 'PATCH',
2554
+ `repos/{owner}/{repo}/issues/comments/${commentId}`,
2555
+ '-f',
2556
+ `body=${result.body}`,
2557
+ ])
2558
+ if (patched === null) return refuseTick('gh-failed')
2559
+ }
2560
+
2561
+ logStep('Ticked')
2562
+ logInfo(
2563
+ `${plural(numbers.length, 'box')} at ${reading.tip.slice(0, 7)} on comment ${commentId}`,
2564
+ )
2565
+ outro()
2566
+ if (emitJson) {
2567
+ process.stdout.write(
2568
+ `${JSON.stringify({
2569
+ root,
2570
+ number: identity.number,
2571
+ reason: 'ticked',
2572
+ head: reading.tip,
2573
+ boxes: numbers.map(Number),
2574
+ commentId,
2575
+ })}\n`,
2576
+ )
2577
+ }
2578
+ return 0
2579
+ }
2580
+
2405
2581
  /** One `gh api` call, keeping the HTTP status of a failure so a refused write can be told from a missing ref. */
2406
2582
  type ApiCall =
2407
2583
  | { readonly ok: true; readonly data: unknown }
@@ -1,7 +1,7 @@
1
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
4
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
5
5
  import {
6
6
  assertedPercent,
7
7
  collectCensus,
@@ -27,6 +27,7 @@ import {
27
27
  } from '@/sandbox/expect'
28
28
  import { runHeadless } from '@/sandbox/headless/run'
29
29
  import { runSandbox } from '@/sandbox/provision'
30
+ import { listCategories, listScenarioFiles } from '@/sandbox/scenario'
30
31
  import { sandboxTree } from '@/sandbox/tree'
31
32
  import {
32
33
  frameError,
@@ -71,9 +72,6 @@ function reportAbsentScenarioTree(): boolean {
71
72
  return true
72
73
  }
73
74
 
74
- /** Holds fixture content for scenarios rather than scenarios of its own. */
75
- const FIXTURES_DIR = 'fixtures'
76
-
77
75
  /**
78
76
  * The provision verb takes positional words rather than options, since `reset`
79
77
  * and `clean` share its slot with a target, so its usage is written out here
@@ -125,17 +123,16 @@ interface CoverageOptions {
125
123
  }
126
124
 
127
125
  function getCategories(): string[] {
128
- return readdirSync(SANDBOX_DIR, { withFileTypes: true })
129
- .filter((d) => d.isDirectory() && d.name !== FIXTURES_DIR)
130
- .map((d) => d.name)
131
- .sort()
126
+ return listCategories(SANDBOX_DIR)
132
127
  }
133
128
 
129
+ /** Lists a stem in both forms too, so picking it reaches the refusal that names it. */
134
130
  function getCommands(category: string): string[] {
135
- return readdirSync(join(SANDBOX_DIR, category), { withFileTypes: true })
136
- .filter((f) => f.isFile() && f.name.endsWith('.sh'))
137
- .map((f) => f.name.replace(/\.sh$/, ''))
138
- .sort()
131
+ const { scenarios, ambiguous } = listScenarioFiles(
132
+ join(SANDBOX_DIR, category),
133
+ )
134
+
135
+ return [...scenarios.map((s) => s.command), ...ambiguous].sort()
139
136
  }
140
137
 
141
138
  async function interactivePicker(): Promise<string> {
@@ -1,7 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { INSTALL_BROWSER } from '@/browser/engine'
4
- import { creationRel, SCRATCH } from '@/record-root'
4
+ import { creationRel, SCRATCH } from '@/roots/record'
5
5
  import { listDecks, resolveDeck, SLIDES_FOLDER } from '@/slides/decks'
6
6
  import { openDeck } from '@/slides/open'
7
7
  import { intro, outro, palette } from '@/ui'
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { cliPath, cliRun } from '@/cli-run'
5
- import { PROJECT_ROOT } from '@/project-root'
5
+ import { PROJECT_ROOT } from '@/roots/project'
6
6
  import {
7
7
  buildCheckReport,
8
8
  type CheckReport,
@@ -42,6 +42,7 @@ import {
42
42
  import {
43
43
  type Finding,
44
44
  type FolderClaim,
45
+ type SharedFiles,
45
46
  type WideToken,
46
47
  type Unplaced,
47
48
  type Untested,
@@ -239,7 +240,8 @@ export function register(program: Command): void {
239
240
  ' no Needs a plan row has a live plan, and every Up next row has one',
240
241
  ' every task file carries a board row or a backlog line, never both',
241
242
  ' no task carries more than one row',
242
- ' no two Run now rows touch the same file',
243
+ ' every Run now row names the files it touches',
244
+ ' a file two Run now rows share is listed, never failed',
243
245
  '',
244
246
  'Exit codes:',
245
247
  ' 0 every check passed',
@@ -1383,7 +1385,7 @@ function reportValidation(
1383
1385
  logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
1384
1386
  if (outcome.findings.length === 0) {
1385
1387
  logInfo(
1386
- 'every row resolves, every task sits on one surface, and each touches its own files',
1388
+ 'every row resolves, every task sits on one surface, and each run now row names its files',
1387
1389
  )
1388
1390
  } else {
1389
1391
  for (const finding of outcome.findings) logWarn(describe(finding))
@@ -1412,6 +1414,16 @@ function reportValidation(
1412
1414
  for (const claim of outcome.claims) logWarn(describeClaim(claim))
1413
1415
  }
1414
1416
 
1417
+ // A shared file holds nothing under the dispatch gate, so the pairs report
1418
+ // with the warn glyph and move no exit code. They stay listed for merge
1419
+ // order and for the holds a Touches cell cannot state.
1420
+ logStep('Shared files')
1421
+ if (outcome.shared.length === 0) {
1422
+ logInfo('no two run now rows name the same file')
1423
+ } else {
1424
+ for (const pair of outcome.shared) logWarn(describeShared(pair))
1425
+ }
1426
+
1415
1427
  // The width limit is a heuristic over one preview pane, so a wide token
1416
1428
  // reports and moves no exit code, and a measure failing on a cell that
1417
1429
  // fits would teach a reader to skip it.
@@ -1447,6 +1459,7 @@ function reportValidation(
1447
1459
  findings: outcome.findings,
1448
1460
  untested: outcome.untested,
1449
1461
  claims: outcome.claims,
1462
+ shared: outcome.shared,
1450
1463
  wide: outcome.wide,
1451
1464
  unplaced: outcome.unplaced,
1452
1465
  })}\n`,
@@ -1469,6 +1482,10 @@ function describeClaim(claim: FolderClaim): string {
1469
1482
  return `${claim.group}: ${claim.subject} ${claim.message}`
1470
1483
  }
1471
1484
 
1485
+ function describeShared(pair: SharedFiles): string {
1486
+ return `${pair.group}: ${pair.subject} ${pair.message}`
1487
+ }
1488
+
1472
1489
  function describeWide(token: WideToken): string {
1473
1490
  return `${token.group}: ${token.subject} ${token.message}`
1474
1491
  }
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
3
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  injectConfigs,
6
6
  injectGitignore,
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { recordDir } from '@/record-root'
3
+ import { recordDir } from '@/roots/record'
4
4
  import { ensureYtDlp, fetchOne } from '@/transcripts/fetch'
5
5
  import { palette } from '@/ui'
6
6
  import { mainWorktreeRoot } from '@/git/worktree'
@@ -1,6 +1,6 @@
1
1
  import type { Command } from 'commander'
2
2
  import { execa } from 'execa'
3
- import { findCheckoutMismatch, PROJECT_ROOT } from '@/project-root'
3
+ import { findCheckoutMismatch, PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  frameError,
6
6
  intro,
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { PROJECT_ROOT } from '@/project-root'
3
+ import { PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  intro,
6
6
  logAdd,
@@ -2,7 +2,7 @@ import { access, readFile } from 'node:fs/promises'
2
2
  import { join, relative } from 'node:path'
3
3
  import { AUDITS } from '@/audits/catalog'
4
4
  import { type BodyLine, bodyLines } from '@/markdown/scan'
5
- import { surfaceDir } from '@/surface-root'
5
+ import { surfaceDir } from '@/roots/surface'
6
6
 
7
7
  /**
8
8
  * The record this measures, relative to `root`.
@@ -2,8 +2,8 @@ import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
4
  import { listRepositoryFiles } from '@/git/files'
5
- import { RECORD_ROOTS } from '@/record-root'
6
- import { SURFACE_ROOTS } from '@/surface-root'
5
+ import { RECORD_ROOTS } from '@/roots/record'
6
+ import { SURFACE_ROOTS } from '@/roots/surface'
7
7
 
8
8
  /**
9
9
  * Suppresses citation checking for the source line carrying it.
@@ -4,7 +4,7 @@ import { $ } from 'bun'
4
4
  import { resolveFolders } from '@/context/folders'
5
5
  import { gitEnv } from '@/git/env'
6
6
  import { listChangedFiles, resolveBaseRef } from '@/git/files'
7
- import { surfaceDir } from '@/surface-root'
7
+ import { surfaceDir } from '@/roots/surface'
8
8
 
9
9
  /**
10
10
  * The five canonical doc types the classifier reaches, matching the file
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, relative, resolve } from 'node:path'
3
3
  import { INDEX_FILE, listIndexes } from '@/indexes/walk'
4
- import { RECORD_ROOTS } from '@/record-root'
5
- import { SURFACE_ROOTS } from '@/surface-root'
4
+ import { RECORD_ROOTS } from '@/roots/record'
5
+ import { SURFACE_ROOTS } from '@/roots/surface'
6
6
 
7
7
  /**
8
8
  * Folder names under a record root audited by default.
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'
2
2
  import { join, relative } from 'node:path'
3
3
  import { wordCount } from '@/context/architecture'
4
4
  import { bodyLines } from '@/markdown/scan'
5
- import { surfaceDir } from '@/surface-root'
5
+ import { surfaceDir } from '@/roots/surface'
6
6
 
7
7
  /**
8
8
  * The record this measures, relative to `root`, resolved at either surface
package/src/docs/read.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
2
  import { basename, join } from 'node:path'
3
3
  import { stripFrontmatter } from '@/markdown/frontmatter'
4
- import { SURFACE_ROOTS } from '@/surface-root'
4
+ import { SURFACE_ROOTS } from '@/roots/surface'
5
5
 
6
6
  const INDEX_TOPIC = 'index'
7
7
 
package/src/exec.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path'
2
2
  import { execa } from 'execa'
3
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
3
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
4
4
  import { logWarn } from '@/ui'
5
5
 
6
6
  export async function execScript(
@@ -1,6 +1,6 @@
1
1
  import { execa } from 'execa'
2
2
  import { gitEnv } from '@/git/env'
3
- import { PROJECT_ROOT } from '@/project-root'
3
+ import { PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  buildIssueArgs,
6
6
  failureDetail,