@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,12 +29,19 @@ import {
29
29
  findEvidenceCaseCount,
30
30
  findEvidenceChecklist,
31
31
  findEvidenceCommentId,
32
+ findEvidenceHead,
32
33
  findEvidenceLocal,
33
34
  findEvidencePreview,
35
+ type EvidenceItem,
36
+ type EvidenceReading,
34
37
  groupEvidence,
35
38
  hasMarkedEvidenceComment,
39
+ PNG_HEADER_BYTES,
40
+ readChecklistBoxes,
36
41
  readOwed,
42
+ readPngWidth,
37
43
  renderEvidenceBody,
44
+ settleChecklist,
38
45
  } from '@/pr/evidence'
39
46
  import {
40
47
  dropFrames,
@@ -71,6 +78,7 @@ import {
71
78
  servesChange,
72
79
  type WorkflowFile,
73
80
  } from '@/pr/preview'
81
+ import { type TickRefusal, tickBoxes } from '@/pr/tick'
74
82
  import {
75
83
  commentRowOf,
76
84
  identityOf,
@@ -83,7 +91,7 @@ import {
83
91
  selectBranchPull,
84
92
  } from '@/pr/rest'
85
93
  import { resolveReviewScope } from '@/pr/review-scope'
86
- import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
94
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/cli/ui'
87
95
 
88
96
  const GH_TIMEOUT_MS = 30_000
89
97
 
@@ -113,6 +121,28 @@ interface LocalOptions extends ReadOptions {
113
121
  readonly remove?: boolean
114
122
  }
115
123
 
124
+ interface TickOptions extends ReadOptions {
125
+ readonly boxes?: string
126
+ readonly head?: string
127
+ }
128
+
129
+ type TickReason = TickRefusal | 'stale-head' | 'bad-boxes' | 'no-comment'
130
+
131
+ const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
132
+ 'stale-head':
133
+ '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.',
134
+ 'bad-boxes':
135
+ '--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.',
136
+ 'no-comment': 'No comment on this pull request carries the evidence marker.',
137
+ 'no-checklist': 'The evidence comment carries no checklist to tick.',
138
+ 'no-box':
139
+ 'A named box is past the end of the checklist. Number boxes off `canon pr evidence --json`.',
140
+ 'taste-box':
141
+ 'A named box ends in (taste), which no driver passes. Nothing was written.',
142
+ 'gh-failed':
143
+ 'gh could not read or edit the marked comment on this pull request.',
144
+ }
145
+
116
146
  /** Shared by every verb that resolves a pull request from the checkout's branch. */
117
147
  const AMBIGUOUS_PULL =
118
148
  'More than one pull request is open on this branch, each against another base. Name the pull request number.'
@@ -563,10 +593,12 @@ export function register(program: Command): void {
563
593
  '--checklist closes the body with a visual checklist, below the',
564
594
  'comparison it annotates. A checklist the marked comment already',
565
595
  '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.',
596
+ 'address is. A tick survives only a render at the head it names: one',
597
+ 'stamped `passed at <sha>` for another head is cleared and unstamped,',
598
+ 'and so is one carrying no stamp when the comment described another',
599
+ 'head. A checklist, supplied or carried, turns no-evidence into ok: a',
600
+ 'branch with a checklist and no evidence image renders a marked body',
601
+ 'holding the checklist, so a later call finds and edits that comment.',
570
602
  '',
571
603
  '--local adds a **Local preview:** segment after any hosted one on the',
572
604
  'address line, or carries the line alone, and is carried forward the',
@@ -580,6 +612,8 @@ export function register(program: Command): void {
580
612
  ' preview the hosted **Preview:** address the comment carries',
581
613
  ' local the **Local preview:** address beside it',
582
614
  ' checklist the checklist between its delimiters, ticks included',
615
+ ' boxes each checklist box with its number, tick, stamp, and taste',
616
+ ' mark, the numbering `canon pr tick` takes',
583
617
  'These come from the comment already posted, never from the flags this',
584
618
  'call passed. A checklist the caller posted raw after a refused render',
585
619
  'carries no marker, so the record reports none of the three for it. An',
@@ -741,6 +775,51 @@ export function register(program: Command): void {
741
775
  : await runLocal(opts)
742
776
  })
743
777
 
778
+ pr.command('tick')
779
+ .description(
780
+ "Tick the checklist boxes a UI pass drove, stamping each with the commit it passed at, on the pull request's evidence comment",
781
+ )
782
+ .argument('[number]', 'Pull request to edit, defaulting to this branch')
783
+ .helpOption('-h, --help', 'Show this help message')
784
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
785
+ .option('--json', 'Add a machine-readable record on stdout')
786
+ .requiredOption(
787
+ '--boxes <list>',
788
+ 'Box numbers to tick, comma-separated, as `canon pr evidence --json` numbers them',
789
+ )
790
+ .requiredOption('--head <sha>', 'The commit the boxes were driven at')
791
+ .addHelpText(
792
+ 'after',
793
+ [
794
+ '',
795
+ 'Edits the marked evidence comment in place, so a skill never',
796
+ 'hand-edits a comment body. Each named box becomes',
797
+ '`- [x] <box> · passed at <short sha>`. Ticking a box again restamps',
798
+ 'it. A later render at another head clears a tick whose stamp names',
799
+ 'a different commit.',
800
+ '',
801
+ 'Read `reason` on the JSON record:',
802
+ ' ticked the boxes were ticked, with the tip in `head`',
803
+ ' stale-head --head is not the remote branch tip, nothing written',
804
+ ' taste-box a named box ends in (taste), nothing written',
805
+ ' no-box a named number is past the checklist, nothing written',
806
+ ' no-checklist the evidence comment carries no checklist',
807
+ ' no-comment no comment carries the evidence marker',
808
+ ' bad-boxes --boxes or --head is malformed',
809
+ '',
810
+ 'Exit codes:',
811
+ ' 0 the boxes were ticked',
812
+ ' 1 refused, with the reason on stderr or in the JSON record',
813
+ '',
814
+ 'Examples:',
815
+ ' canon pr tick 1341 --boxes 1,2,4 --head 1a2b3c4 --json',
816
+ '',
817
+ ].join('\n'),
818
+ )
819
+ .action(async (number: string | undefined, opts: TickOptions) => {
820
+ process.exitCode = await runTick(number, opts)
821
+ })
822
+
744
823
  pr.command('frames')
745
824
  .description(
746
825
  `Push a UI review frame to the never-merged ${FRAMES_BRANCH} branch and return its embed link, or drop frames from it`,
@@ -1671,6 +1750,134 @@ async function readPullMergeBase(
1671
1750
  return sha === null || sha.trim() === '' ? undefined : sha.trim()
1672
1751
  }
1673
1752
 
1753
+ /** How many width reads run at once, since each downloads a whole image. */
1754
+ const WIDTH_READ_CONCURRENCY = 4
1755
+
1756
+ type WidthRead =
1757
+ | { readonly kind: 'read'; readonly width?: number }
1758
+ | { readonly kind: 'failed' }
1759
+
1760
+ /**
1761
+ * The pixel width of a PNG at `sha`, read over the contents API like every
1762
+ * other evidence read. The endpoint ignores a `Range` header, so the whole
1763
+ * file arrives and only its first bytes are read. A format other than PNG
1764
+ * reads without a width, and a refused or timed-out call reads as failed, so
1765
+ * the caller can keep the ceiling width and still report the cause.
1766
+ */
1767
+ async function readImageWidth(
1768
+ cwd: string,
1769
+ path: string,
1770
+ sha: string,
1771
+ ): Promise<WidthRead> {
1772
+ if (!/\.png$/i.test(path)) return { kind: 'read' }
1773
+ try {
1774
+ const result = await execa(
1775
+ 'gh',
1776
+ [
1777
+ 'api',
1778
+ '-H',
1779
+ 'Accept: application/vnd.github.raw',
1780
+ `repos/{owner}/{repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${sha}`,
1781
+ ],
1782
+ {
1783
+ cwd,
1784
+ timeout: GH_TIMEOUT_MS,
1785
+ env: gitEnv(),
1786
+ extendEnv: false,
1787
+ encoding: 'buffer',
1788
+ },
1789
+ )
1790
+ const width = readPngWidth(result.stdout.subarray(0, PNG_HEADER_BYTES))
1791
+ return width === undefined ? { kind: 'read' } : { kind: 'read', width }
1792
+ } catch {
1793
+ return { kind: 'failed' }
1794
+ }
1795
+ }
1796
+
1797
+ /** Runs `task` over `inputs` with at most `limit` in flight, keeping input order. */
1798
+ async function mapPool<T, R>(
1799
+ inputs: readonly T[],
1800
+ limit: number,
1801
+ task: (input: T) => Promise<R>,
1802
+ ): Promise<R[]> {
1803
+ const results: R[] = new Array(inputs.length)
1804
+ let next = 0
1805
+ const lane = async (): Promise<void> => {
1806
+ while (next < inputs.length) {
1807
+ const index = next++
1808
+ results[index] = await task(inputs[index] as T)
1809
+ }
1810
+ }
1811
+ await Promise.all(
1812
+ Array.from({ length: Math.min(limit, inputs.length) }, lane),
1813
+ )
1814
+ return results
1815
+ }
1816
+
1817
+ interface SizedEvidence {
1818
+ readonly reading: EvidenceReading
1819
+ /** Each image whose width read failed, as `head:<path>` or `base:<path>`, which rendered at the ceiling. */
1820
+ readonly unread: readonly string[]
1821
+ }
1822
+
1823
+ /** Each item with the pixel width of its head image and, when it has one, its base image. */
1824
+ async function withImageWidths(
1825
+ cwd: string,
1826
+ reading: EvidenceReading,
1827
+ base: string | undefined,
1828
+ head: string,
1829
+ ): Promise<SizedEvidence> {
1830
+ if (reading.kind === 'refused') return { reading, unread: [] }
1831
+ const jobs = reading.states.flatMap((entry, stateIndex) =>
1832
+ entry.items.flatMap((item, itemIndex) => [
1833
+ {
1834
+ stateIndex,
1835
+ itemIndex,
1836
+ side: 'head' as const,
1837
+ path: item.path,
1838
+ sha: head,
1839
+ },
1840
+ ...(item.added || base === undefined
1841
+ ? []
1842
+ : [
1843
+ {
1844
+ stateIndex,
1845
+ itemIndex,
1846
+ side: 'base' as const,
1847
+ path: item.path,
1848
+ sha: base,
1849
+ },
1850
+ ]),
1851
+ ]),
1852
+ )
1853
+ const reads = await mapPool(jobs, WIDTH_READ_CONCURRENCY, (job) =>
1854
+ readImageWidth(cwd, job.path, job.sha),
1855
+ )
1856
+ const unread: string[] = []
1857
+ const states = reading.states.map((entry, stateIndex) => ({
1858
+ ...entry,
1859
+ items: entry.items.map((item, itemIndex): EvidenceItem => {
1860
+ const widths: { width?: number; baseWidth?: number } = {}
1861
+ jobs.forEach((job, index) => {
1862
+ const read = reads[index]
1863
+ if (
1864
+ job.stateIndex !== stateIndex ||
1865
+ job.itemIndex !== itemIndex ||
1866
+ read === undefined
1867
+ ) {
1868
+ return
1869
+ }
1870
+ if (read.kind === 'failed') unread.push(`${job.side}:${job.path}`)
1871
+ else if (read.width !== undefined) {
1872
+ widths[job.side === 'head' ? 'width' : 'baseWidth'] = read.width
1873
+ }
1874
+ })
1875
+ return { ...item, ...widths }
1876
+ }),
1877
+ }))
1878
+ return { reading: { kind: 'read', states }, unread }
1879
+ }
1880
+
1674
1881
  async function runEvidence(
1675
1882
  number: string | undefined,
1676
1883
  opts: EvidenceOptions,
@@ -1786,7 +1993,10 @@ async function runEvidence(
1786
1993
  const carried = {
1787
1994
  ...(carriedPreview !== undefined && { preview: carriedPreview }),
1788
1995
  ...(carriedLocal !== undefined && { local: carriedLocal }),
1789
- ...(carriedChecklist !== undefined && { checklist: carriedChecklist }),
1996
+ ...(carriedChecklist !== undefined && {
1997
+ checklist: carriedChecklist,
1998
+ boxes: readChecklistBoxes(carriedChecklist),
1999
+ }),
1790
2000
  }
1791
2001
 
1792
2002
  // A deploy that resolves and does not build from any changed path makes a
@@ -1834,7 +2044,8 @@ async function runEvidence(
1834
2044
  // comment a later call edits. A local address rides on a checklist or a
1835
2045
  // hosted preview and never opens one alone, so a docs-only branch with a
1836
2046
  // server up posts nothing.
1837
- const hasChecklist = suppliedChecklist !== undefined
2047
+ const hasChecklist =
2048
+ suppliedChecklist !== undefined || carriedChecklist !== undefined
1838
2049
  if (
1839
2050
  grouped.kind === 'refused' &&
1840
2051
  opts.preview === undefined &&
@@ -1870,8 +2081,13 @@ async function runEvidence(
1870
2081
 
1871
2082
  const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
1872
2083
  const local = opts.local ?? carriedLocal
1873
- const checklist = suppliedChecklist ?? carriedChecklist
1874
- const states = grouped.kind === 'read' ? grouped.states : []
2084
+ const unsettled = suppliedChecklist ?? carriedChecklist
2085
+ const checklist =
2086
+ unsettled === undefined
2087
+ ? undefined
2088
+ : settleChecklist(unsettled, identity.head, findEvidenceHead(comments))
2089
+ const sized = await withImageWidths(root, grouped, base, identity.head)
2090
+ const states = sized.reading.kind === 'read' ? sized.reading.states : []
1875
2091
  const body = renderEvidenceBody(
1876
2092
  states,
1877
2093
  repo,
@@ -1910,6 +2126,11 @@ async function runEvidence(
1910
2126
  logInfo(
1911
2127
  `${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
1912
2128
  )
2129
+ if (sized.unread.length > 0) {
2130
+ logWarn(
2131
+ `width unread for ${plural(sized.unread.length, 'image')}, rendered at 1000: ${sized.unread.join(', ')}`,
2132
+ )
2133
+ }
1913
2134
  if (preview !== undefined) logInfo(`preview ${preview}`)
1914
2135
  if (local !== undefined) logInfo(`local preview ${local}`)
1915
2136
  if (checklist !== undefined) {
@@ -1932,6 +2153,7 @@ async function runEvidence(
1932
2153
  head: identity.head,
1933
2154
  body,
1934
2155
  states,
2156
+ ...(sized.unread.length > 0 && { widthUnread: sized.unread }),
1935
2157
  ...(commentId !== undefined && { commentId }),
1936
2158
  ...carried,
1937
2159
  })}\n`,
@@ -2402,6 +2624,99 @@ async function runLocalRemove(
2402
2624
 
2403
2625
  const POSITIVE_WHOLE = /^[1-9]\d*$/
2404
2626
 
2627
+ const MIN_TICK_HEAD_LENGTH = 7
2628
+
2629
+ /**
2630
+ * Ticks the named boxes on the marked comment. It refuses a head that is not
2631
+ * the remote tip and a taste box before writing, so both rules hold as checks
2632
+ * rather than as prose a driver can talk itself out of.
2633
+ */
2634
+ async function runTick(
2635
+ number: string | undefined,
2636
+ opts: TickOptions,
2637
+ ): Promise<number> {
2638
+ const root = resolve(opts.root ?? process.cwd())
2639
+ const emitJson = opts.json ?? false
2640
+
2641
+ intro('canon pr tick')
2642
+
2643
+ const refuseTick = (reason: TickReason | 'gh-failed'): number =>
2644
+ refuseWith(reason, TICK_REFUSALS[reason], emitJson, root)
2645
+
2646
+ const numbers = (opts.boxes ?? '').split(',').map((part) => part.trim())
2647
+ const head = (opts.head ?? '').trim()
2648
+ if (
2649
+ numbers.length === 0 ||
2650
+ !numbers.every((part) => POSITIVE_WHOLE.test(part)) ||
2651
+ !/^[0-9a-f]+$/.test(head) ||
2652
+ head.length < MIN_TICK_HEAD_LENGTH
2653
+ ) {
2654
+ return refuseTick('bad-boxes')
2655
+ }
2656
+
2657
+ const read = await readIdentity(root, number)
2658
+ if (read.kind === 'refused') {
2659
+ return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
2660
+ }
2661
+ const { identity } = read
2662
+ if (identity.number === undefined) return refuseTick('gh-failed')
2663
+
2664
+ const reading = await resolveTip(identity.branch, refReader(root))
2665
+ if (reading.kind === 'refused') {
2666
+ return refuseWith(
2667
+ reading.reason,
2668
+ PULL_REFUSALS[reading.reason],
2669
+ emitJson,
2670
+ root,
2671
+ )
2672
+ }
2673
+ if (!reading.tip.startsWith(head)) return refuseTick('stale-head')
2674
+
2675
+ const comments = await listComments(root, String(identity.number))
2676
+ if (comments === undefined) return refuseTick('gh-failed')
2677
+ const commentId = findEvidenceCommentId(comments)
2678
+ const marked = comments.find((comment) =>
2679
+ comment.url?.endsWith(`#issuecomment-${commentId}`),
2680
+ )
2681
+ if (commentId === undefined || marked === undefined) {
2682
+ return refuseTick('no-comment')
2683
+ }
2684
+
2685
+ const result = tickBoxes(marked.body, numbers.map(Number), reading.tip)
2686
+ if (result.kind === 'refused') return refuseTick(result.reason)
2687
+
2688
+ if (result.body !== marked.body) {
2689
+ const patched = await gh(root, [
2690
+ 'api',
2691
+ '-X',
2692
+ 'PATCH',
2693
+ `repos/{owner}/{repo}/issues/comments/${commentId}`,
2694
+ '-f',
2695
+ `body=${result.body}`,
2696
+ ])
2697
+ if (patched === null) return refuseTick('gh-failed')
2698
+ }
2699
+
2700
+ logStep('Ticked')
2701
+ logInfo(
2702
+ `${plural(numbers.length, 'box')} at ${reading.tip.slice(0, 7)} on comment ${commentId}`,
2703
+ )
2704
+ outro()
2705
+ if (emitJson) {
2706
+ process.stdout.write(
2707
+ `${JSON.stringify({
2708
+ root,
2709
+ number: identity.number,
2710
+ reason: 'ticked',
2711
+ head: reading.tip,
2712
+ boxes: numbers.map(Number),
2713
+ commentId,
2714
+ })}\n`,
2715
+ )
2716
+ }
2717
+ return 0
2718
+ }
2719
+
2405
2720
  /** One `gh api` call, keeping the HTTP status of a failure so a refused write can be told from a missing ref. */
2406
2721
  type ApiCall =
2407
2722
  | { readonly ok: true; readonly data: unknown }
@@ -55,7 +55,7 @@ import {
55
55
  outro,
56
56
  pipeOutput,
57
57
  plural,
58
- } from '@/ui'
58
+ } from '@/cli/ui'
59
59
  import { currentWorktreeRoot, mainWorktreeRoot } from '@/git/worktree'
60
60
 
61
61
  /** Returned when a record carries a finding, which is the gating result. */
@@ -17,7 +17,7 @@ import {
17
17
  logStep,
18
18
  logWarn,
19
19
  outro,
20
- } from '@/ui'
20
+ } from '@/cli/ui'
21
21
 
22
22
  const GH_TIMEOUT_MS = 30_000
23
23
 
@@ -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,
@@ -39,7 +40,7 @@ import {
39
40
  outro,
40
41
  palette,
41
42
  select,
42
- } from '@/ui'
43
+ } from '@/cli/ui'
43
44
 
44
45
  const SANDBOX_DIR = join(PROJECT_ROOT, 'sandbox')
45
46
 
@@ -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,15 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type ScanRefusal, scanShippedTree } from '@/secrets/scan'
4
- import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
4
+ import {
5
+ intro,
6
+ logError,
7
+ logInfo,
8
+ logStep,
9
+ logWarn,
10
+ outro,
11
+ plural,
12
+ } from '@/cli/ui'
5
13
 
6
14
  interface ScanCommandOptions {
7
15
  readonly json?: boolean
@@ -23,7 +23,7 @@ import {
23
23
  outro,
24
24
  pipeOutput,
25
25
  plural,
26
- } from '@/ui'
26
+ } from '@/cli/ui'
27
27
  import { mainWorktreeRoot } from '@/git/worktree'
28
28
 
29
29
  interface ListCommandOptions {
@@ -1,10 +1,10 @@
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
- import { intro, outro, palette } from '@/ui'
7
+ import { intro, outro, palette } from '@/cli/ui'
8
8
 
9
9
  const SLIDES_AT = creationRel(process.cwd(), SLIDES_FOLDER)
10
10
 
@@ -17,7 +17,7 @@ import {
17
17
  outro,
18
18
  pipeOutput,
19
19
  plural,
20
- } from '@/ui'
20
+ } from '@/cli/ui'
21
21
 
22
22
  interface StandardsListOptions {
23
23
  readonly json?: boolean
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { cliPath, cliRun } from '@/cli-run'
5
- import { PROJECT_ROOT } from '@/project-root'
4
+ import { cliPath, cliRun } from '@/cli/run'
5
+ import { PROJECT_ROOT } from '@/roots/project'
6
6
  import {
7
7
  buildCheckReport,
8
8
  type CheckReport,
@@ -30,7 +30,7 @@ import {
30
30
  logWarn,
31
31
  outro,
32
32
  palette,
33
- } from '@/ui'
33
+ } from '@/cli/ui'
34
34
  import { describeSkew } from '@/version/skew'
35
35
 
36
36
  const SYNC_ARGS: Record<SyncDomain, readonly string[]> = {
@@ -17,7 +17,7 @@ import {
17
17
  outro,
18
18
  pipeOutput,
19
19
  plural,
20
- } from '@/ui'
20
+ } from '@/cli/ui'
21
21
 
22
22
  interface ListOptions {
23
23
  readonly json?: boolean
@@ -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,
@@ -58,7 +59,7 @@ import {
58
59
  logWarn,
59
60
  outro,
60
61
  pipeOutput,
61
- } from '@/ui'
62
+ } from '@/cli/ui'
62
63
  import { mainWorktreeRoot } from '@/git/worktree'
63
64
 
64
65
  /** Returned when the board carries a finding, which is the gating result. */
@@ -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
  }
@@ -42,7 +42,7 @@ import {
42
42
  logWarn,
43
43
  outro,
44
44
  pipeOutput,
45
- } from '@/ui'
45
+ } from '@/cli/ui'
46
46
  import { mainWorktreeRoot } from '@/git/worktree'
47
47
 
48
48
  /**