@erclx/canon 5.25.0 → 5.27.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 (315) hide show
  1. package/README.md +4 -3
  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 +14 -1
  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/REQUIREMENT.md +6 -2
  20. package/claude/skills/context-fold/SKILL.md +9 -21
  21. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  22. package/claude/skills/context-fold/references/completion-report.md +26 -0
  23. package/claude/skills/create-rule/SKILL.md +2 -0
  24. package/claude/skills/create-skill/SKILL.md +2 -0
  25. package/claude/skills/create-standard/SKILL.md +2 -0
  26. package/claude/skills/decision-escalate/SKILL.md +2 -0
  27. package/claude/skills/deploy-app/SKILL.md +2 -0
  28. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  29. package/claude/skills/design-extract/SKILL.md +2 -0
  30. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  31. package/claude/skills/design-taste/SKILL.md +6 -2
  32. package/claude/skills/design-taste/references/adopted.md +40 -0
  33. package/claude/skills/design-taste/references/kinds.md +1 -1
  34. package/claude/skills/design-taste/references/systems.md +1 -7
  35. package/claude/skills/design-taste/references/tells.md +2 -24
  36. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  37. package/claude/skills/docs-sync/SKILL.md +2 -0
  38. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  39. package/claude/skills/document-health/SKILL.md +23 -0
  40. package/claude/skills/document-health/references/axes.md +12 -0
  41. package/claude/skills/draft-and-pick/SKILL.md +3 -1
  42. package/claude/skills/draft-doc/SKILL.md +2 -0
  43. package/claude/skills/draft-doc/references/wiki.md +1 -1
  44. package/claude/skills/draft-figure/SKILL.md +2 -0
  45. package/claude/skills/draft-identity/SKILL.md +3 -1
  46. package/claude/skills/draft-ready/SKILL.md +2 -0
  47. package/claude/skills/draft-screencast/SKILL.md +11 -7
  48. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  49. package/claude/skills/draft-slides/SKILL.md +33 -20
  50. package/claude/skills/git-branch/SKILL.md +2 -0
  51. package/claude/skills/git-commit/SKILL.md +2 -0
  52. package/claude/skills/git-followup/SKILL.md +2 -0
  53. package/claude/skills/git-issue/SKILL.md +2 -0
  54. package/claude/skills/git-issue/references/issue.md +10 -0
  55. package/claude/skills/git-pr/SKILL.md +2 -0
  56. package/claude/skills/git-pr/references/evidence.md +14 -6
  57. package/claude/skills/git-pr/references/labels.md +12 -0
  58. package/claude/skills/git-ship/SKILL.md +2 -0
  59. package/claude/skills/git-split/SKILL.md +21 -8
  60. package/claude/skills/git-stage/SKILL.md +2 -0
  61. package/claude/skills/git-worktree/SKILL.md +2 -0
  62. package/claude/skills/index-lookup/SKILL.md +2 -0
  63. package/claude/skills/markdown-propose/SKILL.md +2 -0
  64. package/claude/skills/markdown-propose/references/format.md +9 -0
  65. package/claude/skills/memory-capture/SKILL.md +2 -0
  66. package/claude/skills/memory-review/SKILL.md +2 -0
  67. package/claude/skills/memory-review/references/later-phases.md +7 -0
  68. package/claude/skills/plan-feature/SKILL.md +2 -0
  69. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  70. package/claude/skills/plan-intake/SKILL.md +2 -0
  71. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  72. package/claude/skills/project-commands/SKILL.md +2 -0
  73. package/claude/skills/read-frames/SKILL.md +2 -0
  74. package/claude/skills/record-screencast/SKILL.md +2 -0
  75. package/claude/skills/repo-metadata/SKILL.md +2 -0
  76. package/claude/skills/restate-plainly/SKILL.md +2 -0
  77. package/claude/skills/review-address/SKILL.md +3 -7
  78. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  79. package/claude/skills/review-branch/SKILL.md +2 -0
  80. package/claude/skills/review-craft/SKILL.md +2 -0
  81. package/claude/skills/review-craft/references/ui.md +2 -2
  82. package/claude/skills/review-pr/SKILL.md +4 -8
  83. package/claude/skills/review-pr/references/body-variants.md +52 -0
  84. package/claude/skills/review-pr/references/key-changes.md +16 -0
  85. package/claude/skills/review-pr/references/post.md +15 -43
  86. package/claude/skills/review-ui/SKILL.md +3 -1
  87. package/claude/skills/review-ui/references/post.md +4 -2
  88. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  89. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  91. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  92. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  93. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  94. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  95. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  96. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  97. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  98. package/claude/skills/role-planner/SKILL.md +2 -0
  99. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  100. package/claude/skills/role-reviewer/SKILL.md +2 -0
  101. package/claude/skills/role-worker/SKILL.md +2 -0
  102. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  103. package/claude/skills/search-craft/SKILL.md +5 -1
  104. package/claude/skills/search-craft/references/adopted.md +30 -0
  105. package/claude/skills/seed-sync/SKILL.md +2 -0
  106. package/claude/skills/session-compact/SKILL.md +2 -0
  107. package/claude/skills/session-map/SKILL.md +2 -0
  108. package/claude/skills/session-relay/SKILL.md +2 -0
  109. package/claude/skills/session-resume/SKILL.md +2 -0
  110. package/claude/skills/session-worktree/SKILL.md +3 -11
  111. package/claude/skills/session-worktree/references/install-command.md +20 -0
  112. package/claude/skills/sketch-design/SKILL.md +2 -0
  113. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  114. package/claude/skills/target-check/SKILL.md +2 -0
  115. package/claude/skills/target-setup/SKILL.md +2 -0
  116. package/claude/skills/target-setup/references/detect.md +8 -0
  117. package/claude/skills/target-setup/references/indexes.md +13 -0
  118. package/claude/skills/target-setup/references/verify.md +8 -0
  119. package/claude/skills/task-board/SKILL.md +2 -0
  120. package/claude/skills/teach-workspace/SKILL.md +5 -15
  121. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  122. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  123. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  124. package/claude/skills/test-craft/SKILL.md +2 -0
  125. package/claude/skills/test-first/SKILL.md +4 -0
  126. package/claude/skills/test-first/references/adopted.md +36 -0
  127. package/claude/skills/ui-checklist/SKILL.md +2 -0
  128. package/claude/skills/ux-audit/SKILL.md +2 -0
  129. package/claude/skills/ux-measure/SKILL.md +2 -0
  130. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  131. package/claude/skills/video-craft/SKILL.md +2 -0
  132. package/claude/skills/write-human/SKILL.md +3 -1
  133. package/claude/skills/write-human/references/machine-tells.md +13 -0
  134. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  135. package/docs/agents/canvas.md +24 -6
  136. package/docs/agents/capture.md +6 -6
  137. package/docs/agents/commands.md +5 -5
  138. package/docs/agents/index.md +5 -5
  139. package/docs/agents/install-and-sync.md +7 -0
  140. package/docs/agents/key-changes.md +1 -1
  141. package/docs/agents/pr-evidence.md +38 -15
  142. package/docs/agents/pr-local.md +19 -13
  143. package/docs/agents/pr-preview.md +44 -21
  144. package/docs/agents/records-stale.md +22 -9
  145. package/docs/agents/records.md +1 -1
  146. package/docs/agents/sandbox.md +29 -5
  147. package/docs/agents/scripting.md +8 -1
  148. package/docs/agents/skills-audit.md +11 -3
  149. package/docs/agents/slides.md +15 -3
  150. package/docs/workflow/ai-workflow.md +1 -1
  151. package/docs/workflow/index.md +1 -0
  152. package/docs/workflow/operating-model.md +5 -6
  153. package/docs/workflow/parallel-features.md +23 -2
  154. package/docs/workflow/skill-map-on-request.md +42 -0
  155. package/docs/workflow/skill-map.md +3 -34
  156. package/docs/workflow/zshrc-aliases.md +1 -1
  157. package/package.json +1 -1
  158. package/scripts/core/regen-hero.ts +1 -1
  159. package/scripts/lib/sandbox-git.sh +1 -9
  160. package/scripts/lib/sandbox-path.sh +6 -162
  161. package/scripts/sandbox-hook.sh +99 -0
  162. package/src/audits/run.ts +1 -1
  163. package/src/canvas/address.ts +12 -3
  164. package/src/canvas/capture.ts +1 -9
  165. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  166. package/src/canvas/client/inspector/color-field.tsx +398 -0
  167. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  168. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  169. package/src/canvas/client/inspector/color-space.ts +240 -0
  170. package/src/canvas/client/inspector/field.tsx +5 -1
  171. package/src/canvas/client/inspector/fields.css +547 -17
  172. package/src/canvas/client/inspector/size-field.tsx +69 -0
  173. package/src/canvas/client/inspector/values.ts +166 -1
  174. package/src/canvas/client/inspector.tsx +236 -73
  175. package/src/canvas/client/selection.tsx +197 -0
  176. package/src/canvas/client/shell.css +104 -12
  177. package/src/canvas/client/state.ts +69 -9
  178. package/src/canvas/client/surface.tsx +147 -22
  179. package/src/canvas/content.ts +50 -1
  180. package/src/canvas/edit.ts +37 -4
  181. package/src/canvas/editing.ts +203 -0
  182. package/src/canvas/global.d.ts +15 -0
  183. package/src/canvas/server.ts +149 -12
  184. package/src/canvas/tokens.ts +59 -17
  185. package/src/capture/render.ts +45 -7
  186. package/src/capture/sources.ts +43 -0
  187. package/src/census/count.ts +2 -2
  188. package/src/claude/cases/workflow.ts +5 -0
  189. package/src/claude/seeds.ts +2 -2
  190. package/src/claude/skills-audit.ts +96 -14
  191. package/src/claude/skills-drift.ts +1 -1
  192. package/src/claude/skills-families.ts +36 -0
  193. package/src/claude/skills-list.ts +76 -15
  194. package/src/commands/audits.ts +2 -2
  195. package/src/commands/canvas.ts +179 -2
  196. package/src/commands/claude.ts +94 -11
  197. package/src/commands/design.ts +142 -2
  198. package/src/commands/feedback.ts +3 -3
  199. package/src/commands/gov.ts +1 -1
  200. package/src/commands/hooks.ts +1 -1
  201. package/src/commands/indexes.ts +1 -1
  202. package/src/commands/init.ts +1 -1
  203. package/src/commands/intake.ts +1 -1
  204. package/src/commands/migrate.ts +1 -1
  205. package/src/commands/pr.ts +105 -58
  206. package/src/commands/records.ts +89 -7
  207. package/src/commands/repo.ts +1 -1
  208. package/src/commands/sandbox.ts +103 -11
  209. package/src/commands/sessions.ts +1 -1
  210. package/src/commands/slides.ts +31 -77
  211. package/src/commands/sync.ts +1 -1
  212. package/src/commands/tasks.ts +2 -2
  213. package/src/commands/teach.ts +1 -1
  214. package/src/commands/transcripts.ts +1 -1
  215. package/src/commands/worktrees.ts +1 -1
  216. package/src/comments/scan.ts +1 -1
  217. package/src/comments/trend.ts +1 -1
  218. package/src/context/audit.ts +1 -1
  219. package/src/context/citations.ts +1 -1
  220. package/src/context/classify/extract.ts +2 -2
  221. package/src/design/project-fonts.ts +392 -0
  222. package/src/design/tokens.ts +2 -18
  223. package/src/docs/read.ts +1 -1
  224. package/src/{github.ts → feedback/github.ts} +2 -2
  225. package/src/{client-commands.ts → gate/client-commands.ts} +1 -1
  226. package/src/gate/measures.ts +53 -13
  227. package/src/gate/sequencer.ts +1 -1
  228. package/src/gate/stages.ts +9 -0
  229. package/src/{git-files.ts → git/files.ts} +2 -2
  230. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  231. package/src/{worktree.ts → git/worktree.ts} +1 -1
  232. package/src/gov/citations.ts +2 -2
  233. package/src/gov/install.ts +1 -1
  234. package/src/gov/payload.ts +1 -1
  235. package/src/gov/superseded.ts +3 -3
  236. package/src/gov/test-order.ts +2 -2
  237. package/src/help.ts +1 -1
  238. package/src/hooks/post-merge.ts +1 -1
  239. package/src/indexes/walk.ts +1 -1
  240. package/src/labels/audit.ts +1 -1
  241. package/src/markdown/ceiling.ts +1 -1
  242. package/src/markdown/files.ts +1 -1
  243. package/src/migrate/apply.ts +1 -1
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +96 -43
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/record-root.ts +1 -0
  249. package/src/records/backup.ts +2 -1
  250. package/src/records/push-guard.ts +1 -1
  251. package/src/records/stale-canonical.ts +146 -0
  252. package/src/records/stale.ts +1 -1
  253. package/src/sandbox/headless/dispatch.ts +264 -0
  254. package/src/sandbox/headless/run.ts +653 -0
  255. package/src/sandbox/headless/watch.ts +145 -0
  256. package/src/sandbox/hooks.ts +128 -0
  257. package/src/sandbox/provision.ts +607 -0
  258. package/src/sandbox/tree.ts +99 -4
  259. package/src/secrets/marker.ts +2 -2
  260. package/src/secrets/scan.ts +2 -2
  261. package/src/sessions/claim.ts +1 -1
  262. package/src/sessions/resolve.ts +1 -1
  263. package/src/sessions/transfer/bundle.ts +1 -1
  264. package/src/shipped/references.ts +2 -2
  265. package/src/slides/convert/export.ts +2 -16
  266. package/src/slides/decks.ts +110 -0
  267. package/src/slides/package/fonts.ts +6 -2
  268. package/src/standards/audit.ts +2 -2
  269. package/src/standards/read.ts +1 -1
  270. package/src/sync/check.ts +2 -2
  271. package/src/sync/engine.ts +2 -2
  272. package/src/sync/git.ts +1 -1
  273. package/src/sync/history.ts +1 -1
  274. package/src/sync/reverse.ts +1 -1
  275. package/src/sync/seeds-report.ts +1 -1
  276. package/src/sync/target.ts +1 -1
  277. package/src/sync/workflow.ts +1 -1
  278. package/src/targets/pulls.ts +2 -2
  279. package/src/targets/sweep.ts +2 -2
  280. package/src/tasks/answers.ts +1 -1
  281. package/src/tasks/archive.ts +1 -1
  282. package/src/tasks/reach.ts +1 -1
  283. package/src/tasks/trunk.ts +1 -1
  284. package/src/teach/nav.ts +1 -1
  285. package/src/tooling/inject.ts +2 -2
  286. package/src/tooling/subfolder.ts +1 -1
  287. package/src/web/readme-citations.ts +2 -2
  288. package/src/worktrees/bare-flag.ts +1 -1
  289. package/src/worktrees/reclaim.ts +2 -2
  290. package/src/worktrees/remove.ts +1 -1
  291. package/standards/architecture.md +1 -0
  292. package/standards/canvas.md +3 -2
  293. package/standards/requirements.md +2 -0
  294. package/standards/skill-practice.md +2 -1
  295. package/standards/skill.md +2 -0
  296. package/standards/wiki.md +12 -3
  297. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  298. package/scripts/lib/sandbox-dispatch.sh +0 -190
  299. package/scripts/manage-sandbox.sh +0 -472
  300. package/src/slides/layouts.ts +0 -478
  301. package/src/slides/parse.ts +0 -84
  302. package/src/slides/render.ts +0 -110
  303. package/src/slides/styles.ts +0 -96
  304. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  305. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  306. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  307. /package/src/{git-base.ts → git/base.ts} +0 -0
  308. /package/src/{git-env.ts → git/env.ts} +0 -0
  309. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  310. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  311. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  312. /package/src/{copy.ts → sync/copy.ts} +0 -0
  313. /package/src/{target.ts → targets/validate.ts} +0 -0
  314. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  315. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -1,8 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { isBinary } from '@/binary'
4
- import { isMarked } from '@/exempt-marker'
5
- import { listRepositoryFiles } from '@/git-files'
3
+ import { isBinary } from '@/sweep/binary'
4
+ import { isMarked } from '@/sweep/exempt-marker'
5
+ import { listRepositoryFiles } from '@/git/files'
6
6
 
7
7
  /**
8
8
  * The inline token exempting one line from this sweep, shaped on the
@@ -1,6 +1,6 @@
1
1
  import { execaSync } from 'execa'
2
- import { baseCandidates, isMergeBase } from '@/git-base'
3
- import { gitEnv } from '@/git-env'
2
+ import { baseCandidates, isMergeBase } from '@/git/base'
3
+ import { gitEnv } from '@/git/env'
4
4
 
5
5
  /**
6
6
  * The extensions this check can pair. A test sits beside its subject under one
package/src/help.ts CHANGED
@@ -105,7 +105,7 @@ const EXAMPLES = [
105
105
  'canon canvas list --json',
106
106
  'canon slides render',
107
107
  'canon slides list --json',
108
- 'canon capture assets/captures/install.html --selector .window --out assets/evidence',
108
+ 'canon capture assets/captures/install.html --selector .window --out assets/frames',
109
109
  'canon serve .canon/teach',
110
110
  'canon inventory focus --json',
111
111
  'canon drive http://localhost:4173 run.json --json',
@@ -1,5 +1,5 @@
1
1
  import { execa } from 'execa'
2
- import { gitEnv } from '@/git-env'
2
+ import { gitEnv } from '@/git/env'
3
3
 
4
4
  /**
5
5
  * One child `canon` call. Each step runs as its own process rather than
@@ -1,6 +1,6 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { dirname, resolve } from 'node:path'
3
- import { listIgnored } from '@/git-ignore'
3
+ import { listIgnored } from '@/git/ignore'
4
4
 
5
5
  const INDEX_FILE = 'index.md'
6
6
 
@@ -1,4 +1,4 @@
1
- import { listChangedFiles, resolveBaseRef } from '@/git-files'
1
+ import { listChangedFiles, resolveBaseRef } from '@/git/files'
2
2
  import { type Coverage, resolveCoverage } from '@/labels/coverage'
3
3
  import { type MapRefusal, readLabelMap } from '@/labels/map'
4
4
 
@@ -8,7 +8,7 @@ import { CHECKPOINTS, documentHeight } from '@/markdown/structure'
8
8
  *
9
9
  * The whole line rather than anywhere on it, so a standard quoting the marker
10
10
  * inside a code span to document it does not exempt itself. A marker with no
11
- * reason does not count, for the reason `isMarked` in `src/exempt-marker.ts`
11
+ * reason does not count, for the reason `isMarked` in `src/sweep/exempt-marker.ts`
12
12
  * gives: a bare token reads as a line that meant to say something and did not.
13
13
  */
14
14
  const EXEMPT_MARKER = /^<!--\s*canon-length-exempt:[ \t]*(\S.*?)\s*-->$/
@@ -1,6 +1,6 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { relative, resolve } from 'node:path'
3
- import { listRepositoryFiles } from '@/git-files'
3
+ import { listRepositoryFiles } from '@/git/files'
4
4
 
5
5
  const MARKDOWN = /\.md$/
6
6
 
@@ -1,7 +1,7 @@
1
1
  import { $ } from 'bun'
2
2
  import { readFile, mkdir, rename, rmdir, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative } from 'node:path'
4
- import { gitEnv } from '@/git-env'
4
+ import { gitEnv } from '@/git/env'
5
5
  import type { RenamePlan, RenameSource } from '@/migrate/plan'
6
6
  import { FROM_ROOT as OLD_ROOT, type RecordsPlan } from '@/migrate/records'
7
7
 
@@ -1,4 +1,4 @@
1
- import type { RenamePair } from '@/git-files'
1
+ import type { RenamePair } from '@/git/files'
2
2
  import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
3
3
 
4
4
  /**
@@ -10,11 +10,20 @@ 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'
@@ -135,15 +144,15 @@ export function evidenceMarker(head: string): string {
135
144
  * pinned to a commit sha rather than a branch, so the comment keeps showing
136
145
  * what it claimed even after the branch moves.
137
146
  *
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.
147
+ * The body opens with `## Evidence` and, under it, one address line joining
148
+ * whichever of the hosted and local previews apply, so the comment reads as
149
+ * evidence first and a preview never stands where the heading should. With no
150
+ * states the heading and the address are the whole body apart from the
151
+ * marker, so a pull request whose screenshots did not change still gets one
152
+ * comment a later call can find and edit in place.
141
153
  *
142
154
  * 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.
155
+ * comment carries the addresses, the screenshots, and what to look at.
147
156
  */
148
157
  export function renderEvidenceBody(
149
158
  states: readonly EvidenceState[],
@@ -154,11 +163,15 @@ export function renderEvidenceBody(
154
163
  checklist?: string,
155
164
  local?: string,
156
165
  ): string {
157
- const addresses = [
166
+ const segments = [
158
167
  ...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
159
168
  ...(local === undefined ? [] : [`${LOCAL_PREFIX} ${local}`]),
160
169
  ]
161
- const opening = addresses.length === 0 ? [] : [...addresses, '']
170
+ const opening = [
171
+ EVIDENCE_HEADING,
172
+ '',
173
+ ...(segments.length === 0 ? [] : [segments.join(ADDRESS_SEPARATOR), '']),
174
+ ]
162
175
  const closing =
163
176
  checklist === undefined
164
177
  ? []
@@ -198,9 +211,7 @@ export function renderEvidenceBody(
198
211
 
199
212
  return [
200
213
  ...opening,
201
- '## Evidence',
202
- '',
203
- `**Base:** \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
214
+ `${BASE_PREFIX} \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
204
215
  '',
205
216
  ...sections,
206
217
  '',
@@ -249,7 +260,8 @@ export interface OwedInput {
249
260
  readonly hasEvidenceChange: boolean
250
261
  readonly hasMarkedComment: boolean
251
262
  readonly carriedPreview: boolean
252
- readonly deployWorkflowFound: boolean
263
+ /** A deploy workflow resolves and its push filter matches a path the pull request changed. */
264
+ readonly deployServesChange: boolean
253
265
  }
254
266
 
255
267
  /**
@@ -262,7 +274,7 @@ export function readOwed(input: OwedInput): OwedStep[] {
262
274
  if (input.hasEvidenceChange && !input.hasMarkedComment) owed.push('evidence')
263
275
  if (
264
276
  (input.hasMarkedComment || input.hasEvidenceChange) &&
265
- input.deployWorkflowFound &&
277
+ input.deployServesChange &&
266
278
  !input.carriedPreview
267
279
  ) {
268
280
  owed.push('preview')
@@ -295,33 +307,78 @@ export function findEvidenceCaseCount(
295
307
  return marked === undefined ? 0 : countEvidenceCases(marked.body)
296
308
  }
297
309
 
310
+ /** Lines that close the address region, being whatever a body carries below its addresses. */
311
+ const REGION_ENDS = [
312
+ BASE_PREFIX,
313
+ CHECKLIST_HEADING,
314
+ '<details',
315
+ CHECKLIST_START,
316
+ MARKER_PREFIX,
317
+ ]
318
+
319
+ export interface AddressReading {
320
+ /** Indexes of the lines carrying a hosted or local segment. */
321
+ readonly lines: readonly number[]
322
+ readonly preview?: string
323
+ readonly local?: string
324
+ }
325
+
326
+ function readSegment(segment: string, prefix: string): string | undefined {
327
+ return segment.startsWith(prefix)
328
+ ? segment.slice(prefix.length).trim() || undefined
329
+ : undefined
330
+ }
331
+
298
332
  /**
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.
333
+ * Reads the addresses a body carries in the region above its commit line,
334
+ * checklist, comparison, or marker, matching each prefix wherever it sits in
335
+ * that region. One reader covers the layout that opens on the addresses as
336
+ * well as the one that opens on the heading, so an open pull request keeps
337
+ * its links across the first re-render, and a line quoted further down in a
338
+ * checklist is never taken for an address.
302
339
  */
303
- export function findEvidencePreview(
340
+ export function readAddresses(body: string): AddressReading {
341
+ const lines: number[] = []
342
+ let preview: string | undefined
343
+ let local: string | undefined
344
+ const bodyLines = body.split('\n')
345
+ for (let index = 0; index < bodyLines.length; index += 1) {
346
+ const line = (bodyLines[index] ?? '').trim()
347
+ if (REGION_ENDS.some((end) => line.startsWith(end))) break
348
+ let isAddress = false
349
+ for (const segment of line.split(ADDRESS_SEPARATOR.trim())) {
350
+ const trimmed = segment.trim()
351
+ const hosted = readSegment(trimmed, PREVIEW_PREFIX)
352
+ const own = readSegment(trimmed, LOCAL_PREFIX)
353
+ if (hosted !== undefined) preview ??= hosted
354
+ if (own !== undefined) local ??= own
355
+ if (hosted !== undefined || own !== undefined) isAddress = true
356
+ }
357
+ if (isAddress) lines.push(index)
358
+ }
359
+ return {
360
+ lines,
361
+ ...(preview !== undefined && { preview }),
362
+ ...(local !== undefined && { local }),
363
+ }
364
+ }
365
+
366
+ function findMarked(
304
367
  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
368
+ ): EvidenceComment | undefined {
369
+ return comments.find((comment) => hasEvidenceMarker(comment.body))
310
370
  }
311
371
 
312
372
  /**
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.
373
+ * The preview address the marked comment already carries. A re-render after
374
+ * a later push passes no address of its own, and carrying this one forward
375
+ * keeps that edit from deleting the link a reviewer is using.
316
376
  */
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
377
+ export function findEvidencePreview(
378
+ comments: readonly EvidenceComment[],
379
+ ): string | undefined {
380
+ const marked = findMarked(comments)
381
+ return marked === undefined ? undefined : readAddresses(marked.body).preview
325
382
  }
326
383
 
327
384
  /**
@@ -331,12 +388,8 @@ export function findLocalLineIndex(body: string): number {
331
388
  export function findEvidenceLocal(
332
389
  comments: readonly EvidenceComment[],
333
390
  ): 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
391
+ const marked = findMarked(comments)
392
+ return marked === undefined ? undefined : readAddresses(marked.body).local
340
393
  }
341
394
 
342
395
  /**
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 {
@@ -76,6 +76,7 @@ export const RECORD_ENTRIES: readonly string[] = [
76
76
  'proposals',
77
77
  'ready',
78
78
  'review',
79
+ 'slides',
79
80
  'tasks',
80
81
  'teach',
81
82
  'transcripts',
@@ -1,7 +1,7 @@
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'
4
+ import { gitEnv } from '@/git/env'
5
5
  import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/record-root'
6
6
  import { type BlockedPath, guardPayload } from '@/records/push-guard'
7
7
 
@@ -43,6 +43,7 @@ export const BACKED_FOLDERS = [
43
43
  'plans',
44
44
  'proposals',
45
45
  'review',
46
+ 'slides',
46
47
  'tasks',
47
48
  'teach',
48
49
  'transcripts',
@@ -1,6 +1,6 @@
1
1
  import { lstat, readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { isBinary } from '@/binary'
3
+ import { isBinary } from '@/sweep/binary'
4
4
  import { scanText } from '@/secrets/scan'
5
5
 
6
6
  /**
@@ -0,0 +1,146 @@
1
+ import { $ } from 'bun'
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { relative } from 'node:path'
5
+ import { gitEnv } from '@/git/env'
6
+ import { readReviewed } from '@/records/stale'
7
+ import { surfaceDir } from '@/surface-root'
8
+
9
+ /** The two records whose drift is identity rather than a stale path. */
10
+ const CANONICAL_DOCS = ['REQUIREMENTS.md', 'ARCHITECTURE.md'] as const
11
+
12
+ export interface CanonicalDoc {
13
+ /** Relative to the root, at whichever surface root carries the doc. */
14
+ readonly path: string
15
+ /** `YYYY-MM-DD`, or null when the doc carries no readable review date. */
16
+ readonly reviewed: string | null
17
+ /** The raw value of a `reviewed` field that is not a date, present only then. */
18
+ readonly invalidReviewed?: string
19
+ /** Release tags merged into HEAD after the reviewed day, or null when never reviewed. */
20
+ readonly releasesSince: number | null
21
+ /** The newest release tag merged into HEAD, or null when there is none. */
22
+ readonly latestRelease: string | null
23
+ }
24
+
25
+ export interface CanonicalReport {
26
+ readonly ok: true
27
+ readonly root: string
28
+ /** False when HEAD has merged no tag, so a zero count measured nothing. */
29
+ readonly tagged: boolean
30
+ readonly docs: readonly CanonicalDoc[]
31
+ }
32
+
33
+ export interface CanonicalRefused {
34
+ readonly ok: false
35
+ readonly reason: 'unreadable-tags'
36
+ readonly message: string
37
+ }
38
+
39
+ export type CanonicalOutcome = CanonicalReport | CanonicalRefused
40
+
41
+ interface Release {
42
+ readonly tag: string
43
+ readonly day: string
44
+ }
45
+
46
+ /**
47
+ * A tag ending in a dotted version, bare or behind a component prefix such as
48
+ * `canon-v2.1.0`. An eval, deploy, or snapshot tag on the trunk names no
49
+ * release and would otherwise inflate the count.
50
+ */
51
+ const RELEASE_TAG = /(?:^|[-/@_])v?\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$/
52
+
53
+ /**
54
+ * A repository before its first commit has no HEAD for `--merged` to resolve,
55
+ * which git reports as a failed read. It has no tags either, so it reads as an
56
+ * empty history rather than an unreadable one.
57
+ */
58
+ async function isUnborn(root: string): Promise<boolean> {
59
+ const [gitDir, head] = await Promise.all([
60
+ $`git -C ${root} rev-parse --git-dir`.env(gitEnv()).quiet().nothrow(),
61
+ $`git -C ${root} rev-parse --verify -q HEAD`
62
+ .env(gitEnv())
63
+ .quiet()
64
+ .nothrow(),
65
+ ])
66
+ return gitDir.exitCode === 0 && head.exitCode !== 0
67
+ }
68
+
69
+ /**
70
+ * Every release tag HEAD has merged, newest first, with the day it was created.
71
+ *
72
+ * The creator date is the tagging date on an annotated tag and the commit date
73
+ * on a lightweight one, so a history mixing both counts by two clocks.
74
+ */
75
+ async function mergedReleases(root: string): Promise<Release[] | undefined> {
76
+ // Interpolated rather than inline, since Bun's shell parses the bare
77
+ // parentheses in `%(refname)` as syntax of its own.
78
+ const args = [
79
+ '--merged',
80
+ 'HEAD',
81
+ '--sort=-creatordate',
82
+ '--format=%(creatordate:short) %(refname:short)',
83
+ 'refs/tags',
84
+ ]
85
+ const result = await $`git -C ${root} for-each-ref ${args}`
86
+ .env(gitEnv())
87
+ .quiet()
88
+ .nothrow()
89
+ if (result.exitCode !== 0) return (await isUnborn(root)) ? [] : undefined
90
+
91
+ return result.stdout
92
+ .toString()
93
+ .split('\n')
94
+ .filter((line) => line.trim().length > 0)
95
+ .map((line) => {
96
+ const [day, tag] = line.trim().split(' ')
97
+ return { day, tag }
98
+ })
99
+ .filter((release) => RELEASE_TAG.test(release.tag))
100
+ }
101
+
102
+ /**
103
+ * Reports when each canonical doc was last reviewed and how many releases
104
+ * have shipped since, read from an optional `reviewed:` frontmatter field.
105
+ *
106
+ * The review point is a date rather than a commit, since a commit written on a
107
+ * feature branch never reaches a trunk that squash-merges. A release tagged on
108
+ * the reviewed day itself is not counted, as the review is read as covering
109
+ * that day. It reports and never writes.
110
+ */
111
+ export async function staleCanonical(root: string): Promise<CanonicalOutcome> {
112
+ const releases = await mergedReleases(root)
113
+
114
+ if (releases === undefined) {
115
+ return {
116
+ ok: false,
117
+ reason: 'unreadable-tags',
118
+ message: `Could not read the release tags at ${root}, so no count can be taken.`,
119
+ }
120
+ }
121
+
122
+ const latestRelease = releases[0]?.tag ?? null
123
+ const present = CANONICAL_DOCS.map((entry) => surfaceDir(root, entry)).filter(
124
+ (path) => existsSync(path),
125
+ )
126
+
127
+ const docs = await Promise.all(
128
+ present.map(async (path): Promise<CanonicalDoc> => {
129
+ const text = (await readFile(path, 'utf8')).replaceAll('\r\n', '\n')
130
+ const review = readReviewed(text)
131
+ const { reviewed } = review
132
+
133
+ return {
134
+ path: relative(root, path),
135
+ ...review,
136
+ releasesSince:
137
+ reviewed === null
138
+ ? null
139
+ : releases.filter((release) => release.day > reviewed).length,
140
+ latestRelease,
141
+ }
142
+ }),
143
+ )
144
+
145
+ return { ok: true, root, tagged: releases.length > 0, docs }
146
+ }
@@ -65,7 +65,7 @@ const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
65
65
  * A value that is not a calendar date reads as never reviewed and is carried
66
66
  * back so the review can see it, rather than throwing over one hand edit.
67
67
  */
68
- function readReviewed(
68
+ export function readReviewed(
69
69
  text: string,
70
70
  ): Pick<StaleEntry, 'reviewed' | 'invalidReviewed'> {
71
71
  const block = RAW_FRONTMATTER.exec(text)?.[1]