@erclx/canon 5.26.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 (320) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +13 -0
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/SKILL.md +3 -17
  20. package/claude/skills/context-fold/references/completion-report.md +26 -0
  21. package/claude/skills/create-rule/SKILL.md +2 -0
  22. package/claude/skills/create-skill/SKILL.md +2 -0
  23. package/claude/skills/create-standard/SKILL.md +2 -0
  24. package/claude/skills/decision-escalate/SKILL.md +2 -0
  25. package/claude/skills/deploy-app/SKILL.md +2 -0
  26. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  27. package/claude/skills/design-extract/SKILL.md +2 -0
  28. package/claude/skills/design-taste/SKILL.md +2 -0
  29. package/claude/skills/docs-sync/SKILL.md +2 -0
  30. package/claude/skills/document-health/SKILL.md +2 -0
  31. package/claude/skills/draft-and-pick/SKILL.md +2 -0
  32. package/claude/skills/draft-doc/SKILL.md +2 -0
  33. package/claude/skills/draft-doc/references/wiki.md +1 -1
  34. package/claude/skills/draft-figure/SKILL.md +2 -0
  35. package/claude/skills/draft-identity/SKILL.md +2 -0
  36. package/claude/skills/draft-ready/SKILL.md +5 -1
  37. package/claude/skills/draft-screencast/SKILL.md +2 -0
  38. package/claude/skills/draft-slides/SKILL.md +2 -0
  39. package/claude/skills/git-branch/SKILL.md +2 -0
  40. package/claude/skills/git-commit/SKILL.md +2 -0
  41. package/claude/skills/git-followup/SKILL.md +2 -0
  42. package/claude/skills/git-issue/SKILL.md +2 -0
  43. package/claude/skills/git-pr/SKILL.md +2 -0
  44. package/claude/skills/git-pr/references/evidence.md +7 -7
  45. package/claude/skills/git-ship/SKILL.md +2 -0
  46. package/claude/skills/git-split/SKILL.md +2 -0
  47. package/claude/skills/git-stage/SKILL.md +2 -0
  48. package/claude/skills/git-worktree/SKILL.md +2 -0
  49. package/claude/skills/index-lookup/SKILL.md +2 -0
  50. package/claude/skills/markdown-propose/SKILL.md +2 -0
  51. package/claude/skills/memory-capture/SKILL.md +2 -0
  52. package/claude/skills/memory-review/SKILL.md +2 -0
  53. package/claude/skills/plan-feature/SKILL.md +2 -0
  54. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  55. package/claude/skills/plan-intake/SKILL.md +2 -0
  56. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  57. package/claude/skills/project-commands/SKILL.md +2 -0
  58. package/claude/skills/read-frames/SKILL.md +2 -0
  59. package/claude/skills/record-screencast/SKILL.md +2 -0
  60. package/claude/skills/repo-metadata/SKILL.md +2 -0
  61. package/claude/skills/restate-plainly/SKILL.md +2 -0
  62. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  63. package/claude/skills/review-address/SKILL.md +19 -14
  64. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  65. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  66. package/claude/skills/review-branch/SKILL.md +2 -0
  67. package/claude/skills/review-craft/SKILL.md +2 -0
  68. package/claude/skills/review-craft/references/ui.md +6 -6
  69. package/claude/skills/review-pr/SKILL.md +4 -8
  70. package/claude/skills/review-pr/references/body-variants.md +52 -0
  71. package/claude/skills/review-pr/references/key-changes.md +16 -0
  72. package/claude/skills/review-pr/references/post.md +15 -43
  73. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  74. package/claude/skills/review-ui/SKILL.md +16 -12
  75. package/claude/skills/review-ui/references/driving.md +8 -8
  76. package/claude/skills/review-ui/references/post.md +13 -3
  77. package/claude/skills/review-ui/references/tick.md +22 -0
  78. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  79. package/claude/skills/role-orchestrator/SKILL.md +31 -42
  80. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  81. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
  82. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  83. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
  84. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  85. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  86. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +12 -23
  87. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  88. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  89. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  91. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  92. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  93. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  94. package/claude/skills/role-planner/SKILL.md +4 -2
  95. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  96. package/claude/skills/role-reviewer/SKILL.md +3 -1
  97. package/claude/skills/role-worker/SKILL.md +5 -2
  98. package/claude/skills/search-craft/SKILL.md +2 -0
  99. package/claude/skills/seed-sync/SKILL.md +2 -0
  100. package/claude/skills/session-compact/SKILL.md +2 -0
  101. package/claude/skills/session-map/SKILL.md +2 -0
  102. package/claude/skills/session-relay/SKILL.md +2 -0
  103. package/claude/skills/session-resume/SKILL.md +2 -0
  104. package/claude/skills/session-worktree/SKILL.md +3 -11
  105. package/claude/skills/session-worktree/references/install-command.md +20 -0
  106. package/claude/skills/sketch-design/SKILL.md +2 -0
  107. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  108. package/claude/skills/target-check/SKILL.md +2 -0
  109. package/claude/skills/target-setup/SKILL.md +2 -0
  110. package/claude/skills/task-board/SKILL.md +2 -0
  111. package/claude/skills/teach-workspace/SKILL.md +5 -15
  112. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  113. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  114. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  115. package/claude/skills/test-craft/SKILL.md +2 -0
  116. package/claude/skills/test-first/SKILL.md +2 -0
  117. package/claude/skills/ui-checklist/SKILL.md +3 -1
  118. package/claude/skills/ux-audit/SKILL.md +2 -0
  119. package/claude/skills/ux-measure/SKILL.md +2 -0
  120. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  121. package/claude/skills/video-craft/SKILL.md +2 -0
  122. package/claude/skills/write-human/SKILL.md +2 -0
  123. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  124. package/docs/agents/canvas.md +24 -8
  125. package/docs/agents/capture.md +5 -5
  126. package/docs/agents/commands.md +2 -2
  127. package/docs/agents/gate.md +3 -1
  128. package/docs/agents/index.md +5 -4
  129. package/docs/agents/install-and-sync.md +7 -0
  130. package/docs/agents/key-changes.md +1 -1
  131. package/docs/agents/pr-evidence.md +47 -23
  132. package/docs/agents/pr-local.md +19 -13
  133. package/docs/agents/pr-preview.md +44 -21
  134. package/docs/agents/pr-tick.md +72 -0
  135. package/docs/agents/sandbox.md +30 -6
  136. package/docs/agents/scripting.md +8 -1
  137. package/docs/agents/skills-audit.md +2 -1
  138. package/docs/agents/tasks-plans.md +1 -1
  139. package/docs/agents/tasks-validate.md +18 -18
  140. package/docs/agents/tasks.md +1 -1
  141. package/docs/workflow/operating-model.md +15 -21
  142. package/docs/workflow/parallel-features.md +24 -3
  143. package/docs/workflow/skill-map.md +3 -1
  144. package/docs/workflow/zshrc-aliases.md +1 -1
  145. package/package.json +1 -1
  146. package/scripts/core/regen-hero.ts +1 -1
  147. package/scripts/lib/sandbox-path.sh +6 -162
  148. package/src/audits/baseline.ts +1 -1
  149. package/src/audits/run.ts +2 -2
  150. package/src/canvas/address.ts +12 -3
  151. package/src/canvas/capture.ts +1 -1
  152. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  153. package/src/canvas/client/inspector/color-field.tsx +201 -171
  154. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  155. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  156. package/src/canvas/client/inspector/color-space.ts +240 -0
  157. package/src/canvas/client/inspector/field.tsx +66 -3
  158. package/src/canvas/client/inspector/fields.css +473 -6
  159. package/src/canvas/client/inspector/size-field.tsx +69 -0
  160. package/src/canvas/client/inspector/token-select.tsx +109 -0
  161. package/src/canvas/client/inspector/values.ts +117 -3
  162. package/src/canvas/client/inspector.tsx +311 -15
  163. package/src/canvas/client/keys.ts +54 -0
  164. package/src/canvas/client/selection.tsx +199 -0
  165. package/src/canvas/client/shell.css +210 -12
  166. package/src/canvas/client/state.ts +165 -14
  167. package/src/canvas/client/surface.tsx +341 -39
  168. package/src/canvas/client/tools.tsx +64 -0
  169. package/src/canvas/content.ts +51 -2
  170. package/src/canvas/edit.ts +49 -6
  171. package/src/canvas/editing.ts +203 -0
  172. package/src/canvas/global.d.ts +15 -0
  173. package/src/canvas/server.ts +149 -12
  174. package/src/canvas/tokens.ts +60 -18
  175. package/src/census/count.ts +1 -1
  176. package/src/claude/seeds.ts +3 -3
  177. package/src/claude/skills-audit.ts +28 -0
  178. package/src/claude/skills-drift.ts +1 -1
  179. package/src/claude/skills-families.ts +36 -0
  180. package/src/claude/skills-list.ts +76 -15
  181. package/src/claude/skills-reach.ts +1 -1
  182. package/src/commands/audits.ts +2 -2
  183. package/src/commands/canvas.ts +180 -3
  184. package/src/commands/claude.ts +70 -12
  185. package/src/commands/design.ts +145 -5
  186. package/src/commands/docs.ts +1 -1
  187. package/src/commands/feedback.ts +2 -2
  188. package/src/commands/gate.ts +9 -2
  189. package/src/commands/gov.ts +3 -3
  190. package/src/commands/hooks.ts +1 -1
  191. package/src/commands/indexes.ts +1 -1
  192. package/src/commands/init.ts +2 -2
  193. package/src/commands/intake.ts +1 -1
  194. package/src/commands/migrate.ts +2 -2
  195. package/src/commands/pr.ts +288 -65
  196. package/src/commands/records.ts +1 -1
  197. package/src/commands/repo.ts +1 -1
  198. package/src/commands/sandbox.ts +64 -15
  199. package/src/commands/sessions.ts +1 -1
  200. package/src/commands/slides.ts +1 -1
  201. package/src/commands/sync.ts +2 -2
  202. package/src/commands/tasks.ts +21 -4
  203. package/src/commands/teach.ts +1 -1
  204. package/src/commands/tooling.ts +1 -1
  205. package/src/commands/transcripts.ts +2 -2
  206. package/src/commands/upgrade.ts +1 -1
  207. package/src/commands/wiki.ts +1 -1
  208. package/src/commands/worktrees.ts +1 -1
  209. package/src/comments/scan.ts +1 -1
  210. package/src/comments/trend.ts +1 -1
  211. package/src/context/architecture.ts +1 -1
  212. package/src/context/citations.ts +3 -3
  213. package/src/context/classify/extract.ts +3 -3
  214. package/src/context/folders.ts +2 -2
  215. package/src/context/requirements.ts +1 -1
  216. package/src/design/project-fonts.ts +392 -0
  217. package/src/docs/read.ts +1 -1
  218. package/src/exec.ts +1 -1
  219. package/src/feedback/github.ts +2 -2
  220. package/src/gate/measures.ts +48 -10
  221. package/src/gate/sequencer.ts +56 -4
  222. package/src/gate/stages.ts +17 -0
  223. package/src/gate/suite-lock.ts +214 -0
  224. package/src/{git-files.ts → git/files.ts} +2 -2
  225. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  226. package/src/{worktree.ts → git/worktree.ts} +1 -1
  227. package/src/gov/citations.ts +1 -1
  228. package/src/gov/install.ts +1 -1
  229. package/src/gov/superseded.ts +1 -1
  230. package/src/gov/test-order.ts +2 -2
  231. package/src/help.ts +1 -1
  232. package/src/hooks/post-merge.ts +1 -1
  233. package/src/indexes/walk.ts +1 -1
  234. package/src/intake/folder.ts +1 -1
  235. package/src/labels/audit.ts +1 -1
  236. package/src/labels/map.ts +1 -1
  237. package/src/labels/phase.ts +3 -3
  238. package/src/markdown/files.ts +1 -1
  239. package/src/migrate/apply.ts +1 -1
  240. package/src/migrate/record-layout.ts +1 -1
  241. package/src/migrate/records.ts +11 -11
  242. package/src/migrate/scratch-evidence.ts +1 -1
  243. package/src/migrate/surface-roots.ts +6 -6
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +250 -59
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/pr/tick.ts +56 -0
  249. package/src/process/harness.ts +1 -1
  250. package/src/records/backup.ts +2 -2
  251. package/src/records/ordinal.ts +1 -1
  252. package/src/records/prune.ts +1 -1
  253. package/src/records/size.ts +1 -1
  254. package/src/records/stale-canonical.ts +2 -2
  255. package/src/records/stale.ts +1 -1
  256. package/src/records/validate.ts +2 -2
  257. package/src/{project-root.ts → roots/project.ts} +1 -1
  258. package/src/{record-root.ts → roots/record.ts} +1 -1
  259. package/src/sandbox/coverage.ts +11 -12
  260. package/src/sandbox/equivalence.ts +57 -26
  261. package/src/sandbox/headless/dispatch.ts +264 -0
  262. package/src/sandbox/headless/run.ts +653 -0
  263. package/src/sandbox/headless/watch.ts +145 -0
  264. package/src/sandbox/hooks.ts +1 -1
  265. package/src/sandbox/provision.ts +90 -29
  266. package/src/sandbox/scenario.ts +588 -0
  267. package/src/sandbox/tree.ts +4 -8
  268. package/src/secrets/scan.ts +1 -1
  269. package/src/sessions/claim.ts +1 -1
  270. package/src/sessions/live.ts +4 -1
  271. package/src/sessions/resolve.ts +1 -1
  272. package/src/sessions/transfer/bundle.ts +2 -2
  273. package/src/slides/decks.ts +1 -1
  274. package/src/slides/package/fonts.ts +6 -2
  275. package/src/standards/audit.ts +2 -2
  276. package/src/standards/read.ts +1 -1
  277. package/src/sync/check.ts +2 -2
  278. package/src/sync/engine.ts +3 -3
  279. package/src/sync/git.ts +1 -1
  280. package/src/sync/history.ts +1 -1
  281. package/src/sync/layout.ts +2 -2
  282. package/src/sync/reverse.ts +1 -1
  283. package/src/sync/stamp.ts +1 -1
  284. package/src/sync/target.ts +1 -1
  285. package/src/sync/workflow.ts +1 -1
  286. package/src/targets/pulls.ts +2 -2
  287. package/src/targets/sweep.ts +2 -2
  288. package/src/{target.ts → targets/validate.ts} +1 -1
  289. package/src/tasks/answers.ts +1 -1
  290. package/src/tasks/archive.ts +1 -1
  291. package/src/tasks/label.ts +1 -1
  292. package/src/tasks/reach.ts +2 -2
  293. package/src/tasks/trunk.ts +1 -1
  294. package/src/tasks/validate.ts +38 -14
  295. package/src/teach/nav.ts +1 -1
  296. package/src/teach/workspace.ts +1 -1
  297. package/src/tooling/inject.ts +2 -2
  298. package/src/tooling/read.ts +1 -1
  299. package/src/tooling/scan.ts +1 -1
  300. package/src/tooling/subfolder.ts +1 -1
  301. package/src/version/installed.ts +1 -1
  302. package/src/version/skew.ts +1 -1
  303. package/src/web/readme-citations.ts +1 -1
  304. package/src/worktrees/bare-flag.ts +1 -1
  305. package/src/worktrees/reclaim.ts +2 -2
  306. package/src/worktrees/remove.ts +1 -1
  307. package/standards/board.md +3 -3
  308. package/standards/canvas.md +2 -1
  309. package/standards/plan.md +13 -7
  310. package/standards/skill.md +1 -0
  311. package/standards/wiki.md +12 -3
  312. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  313. package/tsconfig.json +1 -1
  314. package/scripts/lib/sandbox-dispatch.sh +0 -190
  315. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  316. /package/src/{git-base.ts → git/base.ts} +0 -0
  317. /package/src/{git-env.ts → git/env.ts} +0 -0
  318. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  319. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
  320. /package/src/{copy.ts → sync/copy.ts} +0 -0
@@ -10,18 +10,36 @@ const SHORT_SHA_LENGTH = 7
10
10
  /** Prefix of the trailing marker this module writes and reads back. */
11
11
  const MARKER_PREFIX = '<!-- pr-evidence:'
12
12
 
13
- /** How the first line of a body names the branch's preview deployment. */
13
+ /** How the address line names the branch's preview deployment. */
14
14
  const PREVIEW_PREFIX = '**Preview:**'
15
15
 
16
- /** How the opening block names the branch's server on the operator's own machine, under any hosted preview line. */
17
- export const LOCAL_PREFIX = '**Local preview:**'
16
+ /** How the address line names the branch's server on the operator's own machine, after any hosted segment. */
17
+ const LOCAL_PREFIX = '**Local preview:**'
18
+
19
+ /** What joins the hosted and local segments on the one address line. */
20
+ const ADDRESS_SEPARATOR = ' · '
21
+
22
+ /** The heading every body opens with. */
23
+ const EVIDENCE_HEADING = '## Evidence'
24
+
25
+ /** How the visible commit line opens. */
26
+ const BASE_PREFIX = '**Base:**'
18
27
 
19
28
  /** The heading the checklist sits under, printed outside the delimiters so a carried checklist never doubles it. */
20
29
  const CHECKLIST_HEADING = '## What to look at'
21
30
 
22
31
  /** Delimiters around the checklist, so a re-render can read back what a reviewer has already ticked. */
23
- const CHECKLIST_START = '<!-- pr-checklist:start -->'
24
- const CHECKLIST_END = '<!-- pr-checklist:end -->'
32
+ export const CHECKLIST_START = '<!-- pr-checklist:start -->'
33
+ export const CHECKLIST_END = '<!-- pr-checklist:end -->'
34
+
35
+ /** One checklist line, a list marker then a box that GitHub renders for `x` in either case. */
36
+ const BOX_LINE = /^(\s*[-*+] \[)([ xX])(\] )(.*)$/
37
+
38
+ /** The trailing segment a tick carries, naming the commit the box passed at. Matched only at the end of a line, so a box whose own text says `passed at` is left alone. */
39
+ const STAMP = / · passed at `([0-9a-f]{7,40})`$/
40
+
41
+ /** What ends the text of a box that a person has to judge by eye. */
42
+ const TASTE_SUFFIX = '(taste)'
25
43
 
26
44
  /** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
27
45
  const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
@@ -29,8 +47,8 @@ const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
29
47
  /**
30
48
  * The image extensions this module embeds. An `evidence/` folder holds
31
49
  * whatever else a project keeps beside its captures, such as a README, a
32
- * capture script, or a raw data file, and none of those render as
33
- * `![](url)` without producing a broken embed.
50
+ * capture script, or a raw data file, and none of those render as an
51
+ * `<img>` without producing a broken embed.
34
52
  */
35
53
  const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|avif|svg)$/i
36
54
 
@@ -121,6 +139,35 @@ function rawUrl(repo: string, sha: string, path: string): string {
121
139
  return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
122
140
  }
123
141
 
142
+ /**
143
+ * The pixel width every screenshot asks for. GitHub sizes a comment table to
144
+ * its content and clamps an image to its cell, so a width past any comment
145
+ * fills the cell, where `width="100%"` leaves a narrow screenshot narrow.
146
+ */
147
+ const IMAGE_WIDTH = 1000
148
+
149
+ /** A table cell and an attribute value share one escape, since a stem is a path segment placed in both. */
150
+ function escapeCell(text: string): string {
151
+ return text
152
+ .replaceAll('&', '&amp;')
153
+ .replaceAll('<', '&lt;')
154
+ .replaceAll('>', '&gt;')
155
+ .replaceAll('"', '&quot;')
156
+ .replaceAll('|', '&#124;')
157
+ }
158
+
159
+ function evidenceImage(url: string, alt: string): string {
160
+ return `<img src="${url}" width="${IMAGE_WIDTH}" alt="${alt}">`
161
+ }
162
+
163
+ const EVIDENCE_IMAGE = String.raw`(?:!\[\]\([^)]*\)|<img [^>]*>)`
164
+
165
+ /** A case row in any shape a render has posted: a Base cell before the head image, or the head image alone. */
166
+ const EVIDENCE_ROW_PATTERN = new RegExp(
167
+ String.raw`^\| .+ \| (?:(?:\*\(new\)\*|${EVIDENCE_IMAGE}) \| )?${EVIDENCE_IMAGE} \|$`,
168
+ 'gm',
169
+ )
170
+
124
171
  export function evidenceMarker(head: string): string {
125
172
  return `${MARKER_PREFIX} head=${head} -->`
126
173
  }
@@ -135,15 +182,15 @@ export function evidenceMarker(head: string): string {
135
182
  * pinned to a commit sha rather than a branch, so the comment keeps showing
136
183
  * what it claimed even after the branch moves.
137
184
  *
138
- * A preview address opens the body, and with no states it is the whole body
139
- * apart from the marker, so a pull request whose screenshots did not change
140
- * still gets one comment a later call can find and edit in place.
185
+ * The body opens with `## Evidence` and, under it, one address line joining
186
+ * whichever of the hosted and local previews apply, so the comment reads as
187
+ * evidence first and a preview never stands where the heading should. With no
188
+ * states the heading and the address are the whole body apart from the
189
+ * marker, so a pull request whose screenshots did not change still gets one
190
+ * comment a later call can find and edit in place.
141
191
  *
142
192
  * A checklist closes the body, below the comparison it annotates, so one
143
- * comment carries the preview address, the screenshots, and what to look at.
144
- *
145
- * A local address sits under the hosted one, never above it, so the hosted
146
- * line keeps the first-line position `findEvidencePreview` reads.
193
+ * comment carries the addresses, the screenshots, and what to look at.
147
194
  */
148
195
  export function renderEvidenceBody(
149
196
  states: readonly EvidenceState[],
@@ -154,11 +201,15 @@ export function renderEvidenceBody(
154
201
  checklist?: string,
155
202
  local?: string,
156
203
  ): string {
157
- const addresses = [
204
+ const segments = [
158
205
  ...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
159
206
  ...(local === undefined ? [] : [`${LOCAL_PREFIX} ${local}`]),
160
207
  ]
161
- const opening = addresses.length === 0 ? [] : [...addresses, '']
208
+ const opening = [
209
+ EVIDENCE_HEADING,
210
+ '',
211
+ ...(segments.length === 0 ? [] : [segments.join(ADDRESS_SEPARATOR), '']),
212
+ ]
162
213
  const closing =
163
214
  checklist === undefined
164
215
  ? []
@@ -176,20 +227,24 @@ export function renderEvidenceBody(
176
227
  imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
177
228
 
178
229
  const sections = states.map((entry) => {
230
+ const hasBase = entry.items.some((item) => !item.added)
179
231
  const rows = entry.items.map((item) => {
232
+ const stem = escapeCell(item.stem)
180
233
  const before = item.added
181
234
  ? '*(new)*'
182
- : `![](${rawUrl(repo, base, item.path)})`
183
- const after = `![](${rawUrl(repo, head, item.path)})`
184
- return `| ${item.stem} | ${before} | ${after} |`
235
+ : evidenceImage(rawUrl(repo, base, item.path), `${stem} base`)
236
+ const after = evidenceImage(rawUrl(repo, head, item.path), `${stem} head`)
237
+ return hasBase
238
+ ? `| ${stem} | ${before} | ${after} |`
239
+ : `| ${stem} | ${after} |`
185
240
  })
186
241
 
187
242
  return [
188
243
  opener,
189
244
  `<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
190
245
  '',
191
- '| Case | Base | Head |',
192
- '| --- | --- | --- |',
246
+ hasBase ? '| Case | Base | Head |' : '| Case | Head |',
247
+ hasBase ? '| --- | --- | --- |' : '| --- | --- |',
193
248
  ...rows,
194
249
  '',
195
250
  '</details>',
@@ -198,9 +253,7 @@ export function renderEvidenceBody(
198
253
 
199
254
  return [
200
255
  ...opening,
201
- '## Evidence',
202
- '',
203
- `**Base:** \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
256
+ `${BASE_PREFIX} \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
204
257
  '',
205
258
  ...sections,
206
259
  '',
@@ -210,16 +263,14 @@ export function renderEvidenceBody(
210
263
  }
211
264
 
212
265
  /**
213
- * How many case rows a rendered body carries, read off the same row shape
266
+ * How many case rows a rendered body carries, read off the row shapes
214
267
  * `renderEvidenceBody` writes. A row always ends in a head image, which a
215
- * checklist line or a preview address never does.
268
+ * checklist line or a preview address never does. The pattern also reads the
269
+ * `![](url)` three-column shape an earlier render posted, so a comment posted
270
+ * before the change still counts its cases and the `would-empty` guard holds.
216
271
  */
217
272
  export function countEvidenceCases(body: string): number {
218
- return (
219
- body.match(
220
- /^\| .+ \| (?:\*\(new\)\*|!\[\]\([^)]*\)) \| !\[\]\([^)]*\) \|$/gm,
221
- )?.length ?? 0
222
- )
273
+ return body.match(EVIDENCE_ROW_PATTERN)?.length ?? 0
223
274
  }
224
275
 
225
276
  export interface EvidenceComment {
@@ -249,7 +300,8 @@ export interface OwedInput {
249
300
  readonly hasEvidenceChange: boolean
250
301
  readonly hasMarkedComment: boolean
251
302
  readonly carriedPreview: boolean
252
- readonly deployWorkflowFound: boolean
303
+ /** A deploy workflow resolves and its push filter matches a path the pull request changed. */
304
+ readonly deployServesChange: boolean
253
305
  }
254
306
 
255
307
  /**
@@ -262,7 +314,7 @@ export function readOwed(input: OwedInput): OwedStep[] {
262
314
  if (input.hasEvidenceChange && !input.hasMarkedComment) owed.push('evidence')
263
315
  if (
264
316
  (input.hasMarkedComment || input.hasEvidenceChange) &&
265
- input.deployWorkflowFound &&
317
+ input.deployServesChange &&
266
318
  !input.carriedPreview
267
319
  ) {
268
320
  owed.push('preview')
@@ -295,33 +347,78 @@ export function findEvidenceCaseCount(
295
347
  return marked === undefined ? 0 : countEvidenceCases(marked.body)
296
348
  }
297
349
 
350
+ /** Lines that close the address region, being whatever a body carries below its addresses. */
351
+ const REGION_ENDS = [
352
+ BASE_PREFIX,
353
+ CHECKLIST_HEADING,
354
+ '<details',
355
+ CHECKLIST_START,
356
+ MARKER_PREFIX,
357
+ ]
358
+
359
+ export interface AddressReading {
360
+ /** Indexes of the lines carrying a hosted or local segment. */
361
+ readonly lines: readonly number[]
362
+ readonly preview?: string
363
+ readonly local?: string
364
+ }
365
+
366
+ function readSegment(segment: string, prefix: string): string | undefined {
367
+ return segment.startsWith(prefix)
368
+ ? segment.slice(prefix.length).trim() || undefined
369
+ : undefined
370
+ }
371
+
298
372
  /**
299
- * The preview address the marked comment already opens with. A re-render
300
- * after a later push passes no address of its own, and carrying this one
301
- * forward keeps that edit from deleting the link a reviewer is using.
373
+ * Reads the addresses a body carries in the region above its commit line,
374
+ * checklist, comparison, or marker, matching each prefix wherever it sits in
375
+ * that region. One reader covers the layout that opens on the addresses as
376
+ * well as the one that opens on the heading, so an open pull request keeps
377
+ * its links across the first re-render, and a line quoted further down in a
378
+ * checklist is never taken for an address.
302
379
  */
303
- export function findEvidencePreview(
380
+ export function readAddresses(body: string): AddressReading {
381
+ const lines: number[] = []
382
+ let preview: string | undefined
383
+ let local: string | undefined
384
+ const bodyLines = body.split('\n')
385
+ for (let index = 0; index < bodyLines.length; index += 1) {
386
+ const line = (bodyLines[index] ?? '').trim()
387
+ if (REGION_ENDS.some((end) => line.startsWith(end))) break
388
+ let isAddress = false
389
+ for (const segment of line.split(ADDRESS_SEPARATOR.trim())) {
390
+ const trimmed = segment.trim()
391
+ const hosted = readSegment(trimmed, PREVIEW_PREFIX)
392
+ const own = readSegment(trimmed, LOCAL_PREFIX)
393
+ if (hosted !== undefined) preview ??= hosted
394
+ if (own !== undefined) local ??= own
395
+ if (hosted !== undefined || own !== undefined) isAddress = true
396
+ }
397
+ if (isAddress) lines.push(index)
398
+ }
399
+ return {
400
+ lines,
401
+ ...(preview !== undefined && { preview }),
402
+ ...(local !== undefined && { local }),
403
+ }
404
+ }
405
+
406
+ function findMarked(
304
407
  comments: readonly EvidenceComment[],
305
- ): string | undefined {
306
- const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
307
- const first = marked?.body.split('\n')[0]?.trim() ?? ''
308
- if (!first.startsWith(PREVIEW_PREFIX)) return undefined
309
- return first.slice(PREVIEW_PREFIX.length).trim() || undefined
408
+ ): EvidenceComment | undefined {
409
+ return comments.find((comment) => hasEvidenceMarker(comment.body))
310
410
  }
311
411
 
312
412
  /**
313
- * Where the local address line sits in a body, searched only through the
314
- * opening block above the first blank line, so a line quoted further down in
315
- * a checklist is never taken for it. -1 when the block carries none.
413
+ * The preview address the marked comment already carries. A re-render after
414
+ * a later push passes no address of its own, and carrying this one forward
415
+ * keeps that edit from deleting the link a reviewer is using.
316
416
  */
317
- export function findLocalLineIndex(body: string): number {
318
- const lines = body.split('\n')
319
- for (let index = 0; index < lines.length; index += 1) {
320
- const line = (lines[index] ?? '').trim()
321
- if (line === '') return -1
322
- if (line.startsWith(LOCAL_PREFIX)) return index
323
- }
324
- return -1
417
+ export function findEvidencePreview(
418
+ comments: readonly EvidenceComment[],
419
+ ): string | undefined {
420
+ const marked = findMarked(comments)
421
+ return marked === undefined ? undefined : readAddresses(marked.body).preview
325
422
  }
326
423
 
327
424
  /**
@@ -331,12 +428,8 @@ export function findLocalLineIndex(body: string): number {
331
428
  export function findEvidenceLocal(
332
429
  comments: readonly EvidenceComment[],
333
430
  ): string | undefined {
334
- const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
335
- if (marked === undefined) return undefined
336
- const index = findLocalLineIndex(marked.body)
337
- if (index === -1) return undefined
338
- const line = marked.body.split('\n')[index]?.trim() ?? ''
339
- return line.slice(LOCAL_PREFIX.length).trim() || undefined
431
+ const marked = findMarked(comments)
432
+ return marked === undefined ? undefined : readAddresses(marked.body).local
340
433
  }
341
434
 
342
435
  /**
@@ -357,3 +450,101 @@ export function findEvidenceChecklist(
357
450
  marked.body.slice(start + CHECKLIST_START.length, end).trim() || undefined
358
451
  )
359
452
  }
453
+
454
+ export interface ChecklistBox {
455
+ /** One-based position among the checklist's boxes, in the order they appear. */
456
+ readonly number: number
457
+ /** Index of the box's line within the checklist. */
458
+ readonly line: number
459
+ /** The box text with any stamp removed. */
460
+ readonly text: string
461
+ readonly isTicked: boolean
462
+ /** The short sha a tick names, absent on an empty box or a tick nothing stamped. */
463
+ readonly stamp?: string
464
+ /** Whether the box ends in `(taste)`, which no driver ticks. */
465
+ readonly isTaste: boolean
466
+ }
467
+
468
+ export interface BoxLine {
469
+ readonly before: string
470
+ readonly isTicked: boolean
471
+ readonly text: string
472
+ readonly stamp?: string
473
+ }
474
+
475
+ /** Splits one checklist line into its box parts, or undefined when the line is not a box. */
476
+ export function parseBoxLine(line: string): BoxLine | undefined {
477
+ const match = BOX_LINE.exec(line)
478
+ if (match === null) return undefined
479
+ const rest = (match[4] ?? '').trimEnd()
480
+ const stamped = STAMP.exec(rest)
481
+ return {
482
+ before: match[1] ?? '',
483
+ isTicked: (match[2] ?? ' ') !== ' ',
484
+ text: stamped === null ? rest : rest.slice(0, stamped.index),
485
+ ...(stamped?.[1] !== undefined && { stamp: stamped[1] }),
486
+ }
487
+ }
488
+
489
+ /** The box line for the given parts, written back in the one shape the reader accepts. */
490
+ export function writeBoxLine(
491
+ box: Pick<BoxLine, 'before' | 'text'>,
492
+ stampedAt?: string,
493
+ ): string {
494
+ return stampedAt === undefined
495
+ ? `${box.before} ] ${box.text}`
496
+ : `${box.before}x] ${box.text} · passed at \`${stampedAt.slice(0, SHORT_SHA_LENGTH)}\``
497
+ }
498
+
499
+ /** Every box of a checklist in order, numbered the way `canon pr tick` takes them. */
500
+ export function readChecklistBoxes(checklist: string): ChecklistBox[] {
501
+ const boxes: ChecklistBox[] = []
502
+ checklist.split('\n').forEach((line, index) => {
503
+ const parsed = parseBoxLine(line)
504
+ if (parsed === undefined) return
505
+ boxes.push({
506
+ number: boxes.length + 1,
507
+ line: index,
508
+ text: parsed.text,
509
+ isTicked: parsed.isTicked,
510
+ ...(parsed.stamp !== undefined && { stamp: parsed.stamp }),
511
+ isTaste: parsed.text.endsWith(TASTE_SUFFIX),
512
+ })
513
+ })
514
+ return boxes
515
+ }
516
+
517
+ /** The head the marked comment's trailing marker names. */
518
+ export function findEvidenceHead(
519
+ comments: readonly EvidenceComment[],
520
+ ): string | undefined {
521
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
522
+ if (marked === undefined) return undefined
523
+ const lines = marked.body.trimEnd().split('\n')
524
+ const match = /head=(\S+)/.exec(lines[lines.length - 1] ?? '')
525
+ return match?.[1]
526
+ }
527
+
528
+ /**
529
+ * Clears every tick the render's head has not earned. A stamped tick stays
530
+ * while its sha is a prefix of the head. An unstamped one names no head, so
531
+ * it stays only while the comment it came from described this same head.
532
+ */
533
+ export function settleChecklist(
534
+ checklist: string,
535
+ head: string,
536
+ carriedHead: string | undefined,
537
+ ): string {
538
+ return checklist
539
+ .split('\n')
540
+ .map((line) => {
541
+ const parsed = parseBoxLine(line)
542
+ if (parsed === undefined || !parsed.isTicked) return line
543
+ const isEarned =
544
+ parsed.stamp === undefined
545
+ ? carriedHead === head
546
+ : head.startsWith(parsed.stamp)
547
+ return isEarned ? line : writeBoxLine(parsed)
548
+ })
549
+ .join('\n')
550
+ }
package/src/pr/local.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { findLocalLineIndex } from '@/pr/evidence'
1
+ import { readAddresses } from '@/pr/evidence'
2
2
 
3
3
  /**
4
4
  * The folder linked worktrees sit under. A listener there belongs to another
@@ -111,15 +111,21 @@ export function parseProcNetTcp(text: string): ProcSocket[] {
111
111
  }
112
112
 
113
113
  /**
114
- * Replaces the local address line of a marked body with `note`, leaving
115
- * every other byte in place so the trailing marker, the checklist delimiters,
116
- * and any ticked box survive. Undefined when the body carries no such line,
117
- * which is also what a second strip of the same body returns.
114
+ * Drops the address line of a marked body, hosted and local segments alike,
115
+ * since the close that runs this also deletes the branch's preview
116
+ * deployments and a link left behind points at nothing. Every other byte stays
117
+ * in place, so the trailing marker, the checklist delimiters, and any ticked
118
+ * box survive, and the blank line the address sat on is dropped with it so no
119
+ * gap doubles. Undefined when the body carries no address, which is also what
120
+ * a second drop of the same body returns.
118
121
  */
119
- export function stripLocalLine(body: string, note: string): string | undefined {
120
- const index = findLocalLineIndex(body)
121
- if (index === -1) return undefined
122
- const lines = body.split('\n')
123
- lines[index] = note
122
+ export function dropAddressLine(body: string): string | undefined {
123
+ const { lines: indexes } = readAddresses(body)
124
+ const first = indexes[0]
125
+ if (first === undefined) return undefined
126
+ const dropped = new Set(indexes)
127
+ const lines = body.split('\n').filter((_, index) => !dropped.has(index))
128
+ const isGapAbove = first === 0 || (lines[first - 1] ?? '').trim() === ''
129
+ if (isGapAbove && (lines[first] ?? '').trim() === '') lines.splice(first, 1)
124
130
  return lines.join('\n')
125
131
  }
package/src/pr/preview.ts CHANGED
@@ -16,6 +16,7 @@ const BRANCH_FENCE = '--branch=${{ github.ref_name }}'
16
16
 
17
17
  export type PreviewRefusal =
18
18
  | 'no-deploy'
19
+ | 'unserved'
19
20
  | 'unfenced'
20
21
  | 'no-alias'
21
22
  | 'gh-failed'
@@ -27,8 +28,77 @@ export interface WorkflowFile {
27
28
  readonly text: string
28
29
  }
29
30
 
31
+ /**
32
+ * Which changed paths a deploy builds from, read off its own push trigger, so
33
+ * the workflow that decides when production deploys is also the one statement
34
+ * of what a branch preview serves.
35
+ */
36
+ export type ServedFilter =
37
+ | { readonly kind: 'all' }
38
+ | { readonly kind: 'paths'; readonly patterns: readonly string[] }
39
+ | { readonly kind: 'paths-ignore'; readonly patterns: readonly string[] }
40
+
41
+ function isRecord(value: unknown): value is Record<string, unknown> {
42
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
43
+ }
44
+
45
+ function readPatterns(value: unknown): string[] | undefined {
46
+ if (!Array.isArray(value)) return undefined
47
+ return value.filter((entry): entry is string => typeof entry === 'string')
48
+ }
49
+
50
+ /**
51
+ * Reads `on.push.paths` or `on.push.paths-ignore`. A workflow with neither,
52
+ * with no push trigger, or that does not parse serves every change, since
53
+ * reading unserved by mistake stops every reader asking for a preview.
54
+ */
55
+ export function readServedPaths(workflowText: string): ServedFilter {
56
+ let parsed: unknown
57
+ try {
58
+ parsed = Bun.YAML.parse(workflowText)
59
+ } catch {
60
+ return { kind: 'all' }
61
+ }
62
+ const on = isRecord(parsed) ? parsed.on : undefined
63
+ const push = isRecord(on) ? on.push : undefined
64
+ if (!isRecord(push)) return { kind: 'all' }
65
+
66
+ const paths = readPatterns(push.paths)
67
+ if (paths !== undefined) return { kind: 'paths', patterns: paths }
68
+ const ignored = readPatterns(push['paths-ignore'])
69
+ if (ignored !== undefined) return { kind: 'paths-ignore', patterns: ignored }
70
+ return { kind: 'all' }
71
+ }
72
+
73
+ /** Whether the patterns match a path, applied in order so a later `!` pattern overrides an earlier one, as GitHub applies them. */
74
+ function matchesInOrder(patterns: readonly string[], path: string): boolean {
75
+ let isMatched = false
76
+ for (const pattern of patterns) {
77
+ const isNegated = pattern.startsWith('!')
78
+ const glob = new Bun.Glob(isNegated ? pattern.slice(1) : pattern)
79
+ if (glob.match(path)) isMatched = !isNegated
80
+ }
81
+ return isMatched
82
+ }
83
+
84
+ /** Whether a push carrying `changedPaths` would run the deploy, by the filter its trigger declares. */
85
+ export function servesChange(
86
+ filter: ServedFilter,
87
+ changedPaths: readonly string[],
88
+ ): boolean {
89
+ if (filter.kind === 'all') return true
90
+ if (filter.kind === 'paths') {
91
+ return changedPaths.some((path) => matchesInOrder(filter.patterns, path))
92
+ }
93
+ return changedPaths.some((path) => !matchesInOrder(filter.patterns, path))
94
+ }
95
+
30
96
  export type WorkflowPick =
31
- | { readonly kind: 'found'; readonly path: string }
97
+ | {
98
+ readonly kind: 'found'
99
+ readonly path: string
100
+ readonly filter: ServedFilter
101
+ }
32
102
  | {
33
103
  readonly kind: 'refused'
34
104
  readonly reason: 'no-deploy' | 'unfenced' | 'no-alias'
@@ -36,6 +106,7 @@ export type WorkflowPick =
36
106
 
37
107
  interface WorkflowShape {
38
108
  readonly path: string
109
+ readonly text: string
39
110
  readonly deployLines: readonly string[]
40
111
  readonly isDispatchable: boolean
41
112
  readonly hasMarker: boolean
@@ -51,6 +122,7 @@ function readShape(file: WorkflowFile): WorkflowShape {
51
122
  .filter((line) => !line.trimStart().startsWith('#'))
52
123
  return {
53
124
  path: file.path,
125
+ text: file.text,
54
126
  deployLines: lines.filter((line) => line.includes('pages deploy')),
55
127
  isDispatchable: lines.some((line) => /^\s*workflow_dispatch:/.test(line)),
56
128
  hasMarker: lines.some((line) => line.includes(ALIAS_MARKER)),
@@ -80,7 +152,11 @@ export function findDeployWorkflow(
80
152
  const marked = fenced.find((shape) => shape.hasMarker)
81
153
  if (marked === undefined) return { kind: 'refused', reason: 'no-alias' }
82
154
 
83
- return { kind: 'found', path: marked.path }
155
+ return {
156
+ kind: 'found',
157
+ path: marked.path,
158
+ filter: readServedPaths(marked.text),
159
+ }
84
160
  }
85
161
 
86
162
  export function readPreviewAlias(log: string): string | undefined {
package/src/pr/tick.ts ADDED
@@ -0,0 +1,56 @@
1
+ import {
2
+ CHECKLIST_END,
3
+ CHECKLIST_START,
4
+ parseBoxLine,
5
+ readChecklistBoxes,
6
+ writeBoxLine,
7
+ } from '@/pr/evidence'
8
+
9
+ export type TickRefusal = 'no-checklist' | 'no-box' | 'taste-box'
10
+
11
+ export type TickResult =
12
+ | { readonly kind: 'ticked'; readonly body: string }
13
+ | { readonly kind: 'refused'; readonly reason: TickRefusal }
14
+
15
+ /**
16
+ * Ticks the named boxes of a marked body's checklist and stamps each with the
17
+ * commit it passed at. A refusal writes nothing, so a taste box named among
18
+ * passed ones leaves every box as it was. A box already ticked is restamped,
19
+ * which makes a repeat at one head a no-op.
20
+ */
21
+ export function tickBoxes(
22
+ body: string,
23
+ numbers: readonly number[],
24
+ head: string,
25
+ ): TickResult {
26
+ const start = body.indexOf(CHECKLIST_START)
27
+ const end = body.indexOf(CHECKLIST_END, start)
28
+ if (start === -1 || end === -1) {
29
+ return { kind: 'refused', reason: 'no-checklist' }
30
+ }
31
+
32
+ const from = start + CHECKLIST_START.length
33
+ const region = body.slice(from, end)
34
+ const boxes = readChecklistBoxes(region)
35
+
36
+ const named = numbers.map((number) =>
37
+ boxes.find((box) => box.number === number),
38
+ )
39
+ if (named.some((box) => box === undefined)) {
40
+ return { kind: 'refused', reason: 'no-box' }
41
+ }
42
+ if (named.some((box) => box?.isTaste === true)) {
43
+ return { kind: 'refused', reason: 'taste-box' }
44
+ }
45
+
46
+ const lines = region.split('\n')
47
+ for (const box of named) {
48
+ if (box === undefined) continue
49
+ const parsed = parseBoxLine(lines[box.line] ?? '')
50
+ if (parsed !== undefined) lines[box.line] = writeBoxLine(parsed, head)
51
+ }
52
+ return {
53
+ kind: 'ticked',
54
+ body: `${body.slice(0, from)}${lines.join('\n')}${body.slice(end)}`,
55
+ }
56
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { readdirSync, statSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
- import { PROJECT_ROOT } from '@/project-root'
4
+ import { PROJECT_ROOT } from '@/roots/project'
5
5
  import { stateDir } from '@/targets/registry'
6
6
 
7
7
  const CLI = join(PROJECT_ROOT, 'src/cli.ts')
@@ -1,8 +1,8 @@
1
1
  import { existsSync, lstatSync, readdirSync } from 'node:fs'
2
2
  import { basename, join, relative, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
- import { gitEnv } from '@/git-env'
5
- import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/record-root'
4
+ import { gitEnv } from '@/git/env'
5
+ import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/roots/record'
6
6
  import { type BlockedPath, guardPayload } from '@/records/push-guard'
7
7
 
8
8
  /**
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readdir, rm, stat } from 'node:fs/promises'
2
2
  import { extractOrdinal } from '@/intake/folder'
3
- import { recordDir } from '@/record-root'
3
+ import { recordDir } from '@/roots/record'
4
4
 
5
5
  /**
6
6
  * The two record folders that share one ordinal sequence, per
@@ -3,7 +3,7 @@ import { readdir, rm, stat } from 'node:fs/promises'
3
3
  import { join, relative } from 'node:path'
4
4
  import { RECORD_LAYOUT_MOVES } from '@/migrate/record-layout'
5
5
  import { PROMOTED_FOLDERS } from '@/migrate/scratch-evidence'
6
- import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record-root'
6
+ import { RECORD_ROOTS, recordDir, SCRATCH } from '@/roots/record'
7
7
  import { day, newestMtime } from '@/records/size'
8
8
 
9
9
  const DAY_MS = 24 * 60 * 60 * 1000