@erclx/canon 5.26.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 (242) 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 +2 -0
  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 +6 -6
  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/SKILL.md +3 -7
  63. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  64. package/claude/skills/review-branch/SKILL.md +2 -0
  65. package/claude/skills/review-craft/SKILL.md +2 -0
  66. package/claude/skills/review-craft/references/ui.md +2 -2
  67. package/claude/skills/review-pr/SKILL.md +4 -8
  68. package/claude/skills/review-pr/references/body-variants.md +52 -0
  69. package/claude/skills/review-pr/references/key-changes.md +16 -0
  70. package/claude/skills/review-pr/references/post.md +15 -43
  71. package/claude/skills/review-ui/SKILL.md +3 -1
  72. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  73. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  74. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  75. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  76. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  77. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  78. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  79. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  80. package/claude/skills/role-planner/SKILL.md +2 -0
  81. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  82. package/claude/skills/role-reviewer/SKILL.md +2 -0
  83. package/claude/skills/role-worker/SKILL.md +2 -0
  84. package/claude/skills/search-craft/SKILL.md +2 -0
  85. package/claude/skills/seed-sync/SKILL.md +2 -0
  86. package/claude/skills/session-compact/SKILL.md +2 -0
  87. package/claude/skills/session-map/SKILL.md +2 -0
  88. package/claude/skills/session-relay/SKILL.md +2 -0
  89. package/claude/skills/session-resume/SKILL.md +2 -0
  90. package/claude/skills/session-worktree/SKILL.md +3 -11
  91. package/claude/skills/session-worktree/references/install-command.md +20 -0
  92. package/claude/skills/sketch-design/SKILL.md +2 -0
  93. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  94. package/claude/skills/target-check/SKILL.md +2 -0
  95. package/claude/skills/target-setup/SKILL.md +2 -0
  96. package/claude/skills/task-board/SKILL.md +2 -0
  97. package/claude/skills/teach-workspace/SKILL.md +5 -15
  98. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  99. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  100. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  101. package/claude/skills/test-craft/SKILL.md +2 -0
  102. package/claude/skills/test-first/SKILL.md +2 -0
  103. package/claude/skills/ui-checklist/SKILL.md +2 -0
  104. package/claude/skills/ux-audit/SKILL.md +2 -0
  105. package/claude/skills/ux-measure/SKILL.md +2 -0
  106. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  107. package/claude/skills/video-craft/SKILL.md +2 -0
  108. package/claude/skills/write-human/SKILL.md +2 -0
  109. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  110. package/docs/agents/canvas.md +23 -7
  111. package/docs/agents/capture.md +5 -5
  112. package/docs/agents/commands.md +2 -2
  113. package/docs/agents/index.md +3 -3
  114. package/docs/agents/install-and-sync.md +7 -0
  115. package/docs/agents/key-changes.md +1 -1
  116. package/docs/agents/pr-evidence.md +38 -15
  117. package/docs/agents/pr-local.md +19 -13
  118. package/docs/agents/pr-preview.md +44 -21
  119. package/docs/agents/sandbox.md +28 -4
  120. package/docs/agents/scripting.md +8 -1
  121. package/docs/agents/skills-audit.md +2 -1
  122. package/docs/workflow/operating-model.md +5 -6
  123. package/docs/workflow/parallel-features.md +22 -1
  124. package/docs/workflow/skill-map.md +3 -1
  125. package/docs/workflow/zshrc-aliases.md +1 -1
  126. package/package.json +1 -1
  127. package/scripts/core/regen-hero.ts +1 -1
  128. package/scripts/lib/sandbox-path.sh +6 -162
  129. package/src/audits/run.ts +1 -1
  130. package/src/canvas/address.ts +12 -3
  131. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  132. package/src/canvas/client/inspector/color-field.tsx +201 -171
  133. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  134. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  135. package/src/canvas/client/inspector/color-space.ts +240 -0
  136. package/src/canvas/client/inspector/field.tsx +5 -1
  137. package/src/canvas/client/inspector/fields.css +395 -6
  138. package/src/canvas/client/inspector/size-field.tsx +69 -0
  139. package/src/canvas/client/inspector/values.ts +43 -1
  140. package/src/canvas/client/inspector.tsx +190 -4
  141. package/src/canvas/client/selection.tsx +197 -0
  142. package/src/canvas/client/shell.css +104 -12
  143. package/src/canvas/client/state.ts +69 -9
  144. package/src/canvas/client/surface.tsx +147 -22
  145. package/src/canvas/content.ts +50 -1
  146. package/src/canvas/edit.ts +37 -4
  147. package/src/canvas/editing.ts +203 -0
  148. package/src/canvas/global.d.ts +15 -0
  149. package/src/canvas/server.ts +149 -12
  150. package/src/canvas/tokens.ts +59 -17
  151. package/src/census/count.ts +1 -1
  152. package/src/claude/seeds.ts +1 -1
  153. package/src/claude/skills-audit.ts +28 -0
  154. package/src/claude/skills-drift.ts +1 -1
  155. package/src/claude/skills-families.ts +36 -0
  156. package/src/claude/skills-list.ts +76 -15
  157. package/src/commands/audits.ts +2 -2
  158. package/src/commands/canvas.ts +179 -2
  159. package/src/commands/claude.ts +67 -9
  160. package/src/commands/design.ts +142 -2
  161. package/src/commands/gov.ts +1 -1
  162. package/src/commands/hooks.ts +1 -1
  163. package/src/commands/indexes.ts +1 -1
  164. package/src/commands/init.ts +1 -1
  165. package/src/commands/intake.ts +1 -1
  166. package/src/commands/migrate.ts +1 -1
  167. package/src/commands/pr.ts +105 -58
  168. package/src/commands/records.ts +1 -1
  169. package/src/commands/repo.ts +1 -1
  170. package/src/commands/sandbox.ts +54 -2
  171. package/src/commands/sessions.ts +1 -1
  172. package/src/commands/sync.ts +1 -1
  173. package/src/commands/tasks.ts +2 -2
  174. package/src/commands/teach.ts +1 -1
  175. package/src/commands/transcripts.ts +1 -1
  176. package/src/commands/worktrees.ts +1 -1
  177. package/src/comments/scan.ts +1 -1
  178. package/src/comments/trend.ts +1 -1
  179. package/src/context/citations.ts +1 -1
  180. package/src/context/classify/extract.ts +2 -2
  181. package/src/design/project-fonts.ts +392 -0
  182. package/src/feedback/github.ts +1 -1
  183. package/src/gate/measures.ts +47 -9
  184. package/src/gate/sequencer.ts +1 -1
  185. package/src/gate/stages.ts +9 -0
  186. package/src/{git-files.ts → git/files.ts} +2 -2
  187. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  188. package/src/{worktree.ts → git/worktree.ts} +1 -1
  189. package/src/gov/citations.ts +1 -1
  190. package/src/gov/install.ts +1 -1
  191. package/src/gov/superseded.ts +1 -1
  192. package/src/gov/test-order.ts +2 -2
  193. package/src/help.ts +1 -1
  194. package/src/hooks/post-merge.ts +1 -1
  195. package/src/indexes/walk.ts +1 -1
  196. package/src/labels/audit.ts +1 -1
  197. package/src/markdown/files.ts +1 -1
  198. package/src/migrate/apply.ts +1 -1
  199. package/src/pr/bijection.ts +1 -1
  200. package/src/pr/evidence.ts +96 -43
  201. package/src/pr/local.ts +16 -10
  202. package/src/pr/preview.ts +78 -2
  203. package/src/records/backup.ts +1 -1
  204. package/src/records/stale-canonical.ts +1 -1
  205. package/src/sandbox/headless/dispatch.ts +264 -0
  206. package/src/sandbox/headless/run.ts +653 -0
  207. package/src/sandbox/headless/watch.ts +145 -0
  208. package/src/sandbox/provision.ts +12 -1
  209. package/src/sandbox/tree.ts +4 -8
  210. package/src/secrets/scan.ts +1 -1
  211. package/src/sessions/claim.ts +1 -1
  212. package/src/sessions/resolve.ts +1 -1
  213. package/src/sessions/transfer/bundle.ts +1 -1
  214. package/src/slides/package/fonts.ts +6 -2
  215. package/src/standards/audit.ts +2 -2
  216. package/src/sync/check.ts +2 -2
  217. package/src/sync/engine.ts +2 -2
  218. package/src/sync/git.ts +1 -1
  219. package/src/sync/history.ts +1 -1
  220. package/src/sync/reverse.ts +1 -1
  221. package/src/sync/target.ts +1 -1
  222. package/src/sync/workflow.ts +1 -1
  223. package/src/targets/pulls.ts +2 -2
  224. package/src/targets/sweep.ts +2 -2
  225. package/src/tasks/reach.ts +1 -1
  226. package/src/tasks/trunk.ts +1 -1
  227. package/src/tooling/inject.ts +1 -1
  228. package/src/tooling/subfolder.ts +1 -1
  229. package/src/web/readme-citations.ts +1 -1
  230. package/src/worktrees/bare-flag.ts +1 -1
  231. package/src/worktrees/reclaim.ts +2 -2
  232. package/src/worktrees/remove.ts +1 -1
  233. package/standards/canvas.md +2 -1
  234. package/standards/skill.md +1 -0
  235. package/standards/wiki.md +12 -3
  236. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  237. package/scripts/lib/sandbox-dispatch.sh +0 -190
  238. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  239. /package/src/{git-base.ts → git/base.ts} +0 -0
  240. /package/src/{git-env.ts → git/env.ts} +0 -0
  241. /package/src/{copy.ts → sync/copy.ts} +0 -0
  242. /package/src/{target.ts → targets/validate.ts} +0 -0
@@ -6,7 +6,7 @@ import {
6
6
  CLIENT_COMMANDS,
7
7
  type ClientCommand,
8
8
  clientCommandCitationsIn,
9
- } from '@/client-commands'
9
+ } from '@/gate/client-commands'
10
10
  import {
11
11
  isOverCount,
12
12
  isOverLength,
@@ -23,9 +23,10 @@ import {
23
23
  PRACTICE_SKILLS,
24
24
  SHIPPED_CORPUS,
25
25
  } from '@/claude/skills-audit'
26
+ import { familyKeys } from '@/claude/skills-families'
26
27
  import { measureDesignProse } from '@/design/budget'
27
28
  import { DESIGN_DOCUMENT } from '@/design/regen'
28
- import { listRepositoryFiles } from '@/git-files'
29
+ import { listRepositoryFiles } from '@/git/files'
29
30
  import { ceilingFindings } from '@/markdown/ceiling'
30
31
  import { resolveMarkdown } from '@/markdown/files'
31
32
  import { CHECKPOINTS } from '@/markdown/structure'
@@ -166,7 +167,7 @@ export function auditsBaselineRel(root: string): string {
166
167
  }
167
168
 
168
169
  export const CAPTURE_STAMP_FAILURE =
169
- 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/evidence and commit each frame with its image and its stamp.'
170
+ 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/frames and commit each frame with its image and its stamp.'
170
171
 
171
172
  function parseJson(payload: string): unknown {
172
173
  try {
@@ -449,6 +450,42 @@ export const skillPracticeShape: Measure = async (ctx) => {
449
450
  }
450
451
  }
451
452
 
453
+ /**
454
+ * Every shipped skill against the family vocabulary, since the landing page
455
+ * groups the skill map by the field and a skill without one has no group.
456
+ *
457
+ * Read in-process for the reason `skillProvenance` is. Only the shipped corpus
458
+ * takes a family, so a tree without `claude/skills/` reports unmeasured rather
459
+ * than a vacuous pass.
460
+ */
461
+ export const skillFamily: Measure = async (ctx) => {
462
+ const report = await auditSkills(ctx.root)
463
+ const shipped = report.corpora.find((corpus) => corpus.rel === SHIPPED_CORPUS)
464
+ if (shipped === undefined) {
465
+ return {
466
+ emissions: [],
467
+ unmeasured: `no skill corpus at ${SHIPPED_CORPUS} resolved, so no family was read.`,
468
+ }
469
+ }
470
+
471
+ const found = report.family
472
+ if (found.length > 0) {
473
+ const where = found
474
+ .map((finding) => `${finding.rel} ${finding.detail}`)
475
+ .join(', ')
476
+ return {
477
+ emissions: [],
478
+ failure: `${plural(found.length, 'skill family finding')}: ${where}. Add metadata.family to the frontmatter, one of ${familyKeys().join(', ')}, matching the skill map group its row sits under.`,
479
+ }
480
+ }
481
+
482
+ return {
483
+ emissions: [
484
+ info(`${plural(shipped.skills, 'shipped skill')} declare a known family`),
485
+ ],
486
+ }
487
+ }
488
+
452
489
  /**
453
490
  * Every tracked markdown document against the whole-document ceiling.
454
491
  *
@@ -982,7 +1019,7 @@ export const clientCommandCitations = async (
982
1019
  return {
983
1020
  emissions: [],
984
1021
  failure:
985
- 'The client command table is empty, so the corpus was walked and nothing was looked for. Check src/client-commands.ts.',
1022
+ 'The client command table is empty, so the corpus was walked and nothing was looked for. Check src/gate/client-commands.ts.',
986
1023
  }
987
1024
  }
988
1025
 
@@ -1023,8 +1060,8 @@ export const clientCommandCitations = async (
1023
1060
  ),
1024
1061
  failure:
1025
1062
  found.length === 1
1026
- ? `One tracked citation quotes a client command with the wrong argument. Match it against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`
1027
- : `${found.length} tracked citations quote a client command with the wrong argument. Match each against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`,
1063
+ ? `One tracked citation quotes a client command with the wrong argument. Match it against the canonical form in src/gate/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`
1064
+ : `${found.length} tracked citations quote a client command with the wrong argument. Match each against the canonical form in src/gate/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`,
1028
1065
  }
1029
1066
  }
1030
1067
 
@@ -1478,11 +1515,12 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
1478
1515
  * A capture set spans two folders. The markup and the template beside it are
1479
1516
  * the authored half and sit under `assets/captures/`, while the image a
1480
1517
  * document points at and the stamp answering for it sit under
1481
- * `assets/evidence/`, which is where `--out assets/evidence` sends them and where
1482
- * `canon pr evidence` looks for a changed image.
1518
+ * `assets/frames/`, which is where `--out assets/frames` sends them. They sit
1519
+ * outside `assets/evidence/` so a changed marketing image is not read as dev
1520
+ * evidence.
1483
1521
  */
1484
1522
  const CAPTURE_MARKUP_DIR = 'assets/captures'
1485
- const CAPTURE_OUTPUT_DIR = 'assets/evidence'
1523
+ const CAPTURE_OUTPUT_DIR = 'assets/frames'
1486
1524
 
1487
1525
  /**
1488
1526
  * Every capture under `assets/captures/`, named by the base its three files
@@ -7,7 +7,7 @@ import type {
7
7
  RunCommand,
8
8
  } from '@/gate/measures'
9
9
  import type { Check, Stage } from '@/gate/stages'
10
- import { gitEnv } from '@/git-env'
10
+ import { gitEnv } from '@/git/env'
11
11
  import { PROJECT_ROOT } from '@/project-root'
12
12
 
13
13
  export type StageStatus =
@@ -22,6 +22,7 @@ import {
22
22
  seedStandards,
23
23
  rawFieldFileReference,
24
24
  shippedReferences,
25
+ skillFamily,
25
26
  skillPracticeShape,
26
27
  skillProvenance,
27
28
  standardCriteria,
@@ -496,6 +497,14 @@ export const STAGES: readonly Stage[] = [
496
497
  label: 'Skill practice shape',
497
498
  checks: [{ kind: 'measure', measure: skillPracticeShape }],
498
499
  },
500
+ {
501
+ // The audit verb only reports a shipped skill with a missing or unknown
502
+ // family, so a target is told without failing. The landing page groups the
503
+ // skill map by the field, so the stage holds this corpus at zero.
504
+ id: 'skill-family',
505
+ label: 'Skill family',
506
+ checks: [{ kind: 'measure', measure: skillFamily }],
507
+ },
499
508
  {
500
509
  id: 'standard-criteria',
501
510
  label: 'Standard success criteria',
@@ -1,6 +1,6 @@
1
1
  import { $ } from 'bun'
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
  /** Runs git under `root` with the resolution variables a hook exports stripped. */
6
6
  async function git(
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { $ } from 'bun'
3
- import { gitEnv } from '@/git-env'
3
+ import { gitEnv } from '@/git/env'
4
4
 
5
5
  /**
6
6
  * Reports which of `candidates` git ignores under `root`.
@@ -1,5 +1,5 @@
1
1
  import { $ } from 'bun'
2
- import { gitEnv } from '@/git-env'
2
+ import { gitEnv } from '@/git/env'
3
3
 
4
4
  /**
5
5
  * Resolves the root of the checkout the caller is standing in, which is the
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'
3
3
  import { dirname, join, resolve } from 'node:path'
4
4
  import { $ } from 'bun'
5
5
  import { isMarked } from '@/sweep/exempt-marker'
6
- import { gitEnv } from '@/git-env'
6
+ import { gitEnv } from '@/git/env'
7
7
  import { listRuleFiles } from '@/gov/payload'
8
8
  import { parseFrontmatter } from '@/indexes/frontmatter'
9
9
  import { findBacktickSpans } from '@/markdown/backticks'
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { basename, dirname, join, relative } from 'node:path'
3
- import { copyPreservingMode } from '@/copy'
3
+ import { copyPreservingMode } from '@/sync/copy'
4
4
 
5
5
  export interface RuleSource {
6
6
  readonly rule: string
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { isBinary } from '@/sweep/binary'
4
4
  import { isMarked } from '@/sweep/exempt-marker'
5
- import { listRepositoryFiles } from '@/git-files'
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
 
@@ -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 {
@@ -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
 
@@ -2,7 +2,7 @@ import { $ } from 'bun'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { relative } from 'node:path'
5
- import { gitEnv } from '@/git-env'
5
+ import { gitEnv } from '@/git/env'
6
6
  import { readReviewed } from '@/records/stale'
7
7
  import { surfaceDir } from '@/surface-root'
8
8