@erclx/canon 5.32.0 → 5.34.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 (250) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +1 -2
  3. package/claude/skills/api-design/SKILL.md +2 -3
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/auto-ship/SKILL.md +1 -1
  6. package/claude/skills/auto-ship/references/worktree-name.md +1 -1
  7. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  8. package/claude/skills/backlog-triage/SKILL.md +1 -1
  9. package/claude/skills/canon-cli/REQUIREMENT.md +16 -13
  10. package/claude/skills/canon-cli/SKILL.md +75 -32
  11. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  12. package/claude/skills/canon-feedback/SKILL.md +11 -29
  13. package/claude/skills/canon-feedback/references/template.md +53 -0
  14. package/claude/skills/{canvas → canvas-board}/REQUIREMENT.md +2 -2
  15. package/claude/skills/{canvas → canvas-board}/SKILL.md +6 -3
  16. package/claude/skills/{canvas → canvas-board}/references/canvas.md +26 -3
  17. package/{standards → claude/skills/canvas-board/references}/design.md +11 -2
  18. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  19. package/claude/skills/code-craft/SKILL.md +0 -1
  20. package/claude/skills/context-fold/REQUIREMENT.md +25 -33
  21. package/claude/skills/context-fold/SKILL.md +25 -70
  22. package/claude/skills/context-fold/references/classify.md +3 -3
  23. package/claude/skills/context-fold/references/completion-report.md +4 -4
  24. package/claude/skills/context-fold/references/context-refresh.md +5 -5
  25. package/{standards → claude/skills/context-fold/references}/context.md +16 -5
  26. package/claude/skills/context-fold/references/unusable-baseline.md +3 -5
  27. package/claude/skills/create-rule/SKILL.md +3 -3
  28. package/{standards → claude/skills/create-rule/references}/rule.md +15 -6
  29. package/claude/skills/create-skill/SKILL.md +2 -2
  30. package/{standards → claude/skills/create-skill/references}/skill.md +18 -15
  31. package/claude/skills/design-taste/REQUIREMENT.md +3 -3
  32. package/claude/skills/design-taste/SKILL.md +1 -1
  33. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  34. package/claude/skills/docs-sync/SKILL.md +3 -3
  35. package/{standards → claude/skills/docs-sync/references}/docs.md +11 -0
  36. package/{standards → claude/skills/docs-sync/references}/readme.md +9 -2
  37. package/claude/skills/draft-and-pick/SKILL.md +5 -5
  38. package/claude/skills/draft-doc/SKILL.md +6 -6
  39. package/claude/skills/draft-figure/REQUIREMENT.md +2 -2
  40. package/claude/skills/draft-figure/SKILL.md +2 -2
  41. package/claude/skills/draft-figure/references/hand-drawn.md +1 -1
  42. package/claude/skills/draft-figure/references/mermaid-path.md +1 -1
  43. package/claude/skills/draft-identity/REQUIREMENT.md +2 -2
  44. package/claude/skills/draft-identity/SKILL.md +2 -2
  45. package/claude/skills/draft-ready/SKILL.md +1 -1
  46. package/claude/skills/git-branch/SKILL.md +1 -1
  47. package/{standards → claude/skills/git-branch/references}/branch.md +1 -1
  48. package/claude/skills/git-commit/SKILL.md +2 -2
  49. package/claude/skills/git-followup/REQUIREMENT.md +1 -1
  50. package/claude/skills/git-followup/SKILL.md +1 -1
  51. package/claude/skills/git-issue/SKILL.md +1 -1
  52. package/claude/skills/git-issue/references/issue.md +4 -1
  53. package/claude/skills/git-pr/SKILL.md +13 -13
  54. package/claude/skills/git-pr/references/evidence.md +2 -2
  55. package/claude/skills/git-pr/references/final-command.md +38 -14
  56. package/claude/skills/git-pr/references/labels.md +5 -1
  57. package/{standards → claude/skills/git-pr/references}/pr.md +19 -9
  58. package/{standards → claude/skills/git-pr/references}/publish.md +4 -2
  59. package/claude/skills/git-pr/references/task-number.md +1 -1
  60. package/claude/skills/git-ship/SKILL.md +15 -14
  61. package/claude/skills/git-split/SKILL.md +3 -3
  62. package/claude/skills/git-stage/SKILL.md +1 -1
  63. package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
  64. package/claude/skills/review-address/SKILL.md +1 -1
  65. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  66. package/claude/skills/review-craft/references/ui.md +1 -1
  67. package/claude/skills/review-pr/REQUIREMENT.md +1 -1
  68. package/claude/skills/review-pr/SKILL.md +3 -3
  69. package/claude/skills/review-pr/references/body-variants.md +2 -2
  70. package/claude/skills/review-pr/references/key-changes.md +3 -3
  71. package/claude/skills/review-pr/references/post.md +4 -2
  72. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  73. package/claude/skills/review-ui/SKILL.md +8 -6
  74. package/claude/skills/review-ui/references/driving.md +8 -8
  75. package/claude/skills/review-ui/references/post.md +53 -17
  76. package/claude/skills/role-orchestrator/REQUIREMENT.md +3 -0
  77. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  78. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -2
  79. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +8 -5
  80. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +32 -1
  81. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
  82. package/claude/skills/role-orchestrator/references/orchestrator-resume.md +3 -0
  83. package/claude/skills/role-planner/REQUIREMENT.md +2 -1
  84. package/claude/skills/role-planner/SKILL.md +13 -2
  85. package/claude/skills/role-reviewer/SKILL.md +1 -1
  86. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  87. package/claude/skills/role-worker/SKILL.md +9 -1
  88. package/claude/skills/session-map/SKILL.md +1 -1
  89. package/claude/skills/session-resume/REQUIREMENT.md +2 -2
  90. package/claude/skills/session-worktree/SKILL.md +5 -5
  91. package/claude/skills/target-setup/SKILL.md +10 -4
  92. package/claude/skills/target-setup/references/detect.md +20 -3
  93. package/claude/skills/task-board/REQUIREMENT.md +7 -12
  94. package/claude/skills/task-board/SKILL.md +5 -17
  95. package/claude/skills/task-board/references/tasks.md +2 -2
  96. package/claude/skills/task-complete/REQUIREMENT.md +47 -0
  97. package/claude/skills/task-complete/SKILL.md +86 -0
  98. package/claude/skills/{task-board → task-complete}/references/archive.md +4 -4
  99. package/claude/skills/{context-fold → task-complete}/references/receipt-sweep.md +5 -5
  100. package/claude/skills/task-complete/references/unusable-baseline.md +19 -0
  101. package/claude/skills/teach-workspace/REQUIREMENT.md +6 -2
  102. package/claude/skills/teach-workspace/SKILL.md +8 -4
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  104. package/claude/skills/{context-fold/references/promoted-pages.md → teach-workspace/references/promotion-fold.md} +3 -3
  105. package/claude/skills/teach-workspace/references/promotion.md +3 -3
  106. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  107. package/claude/skills/test-craft/SKILL.md +15 -3
  108. package/claude/skills/ui-checklist/SKILL.md +1 -1
  109. package/claude/skills/ux-audit/SKILL.md +1 -1
  110. package/claude/skills/ux-measure/SKILL.md +1 -1
  111. package/docs/agents/canvas.md +6 -2
  112. package/docs/agents/commands.md +4 -3
  113. package/docs/agents/context-audit-checks.md +4 -4
  114. package/docs/agents/gov-detect.md +47 -0
  115. package/docs/agents/index.md +7 -4
  116. package/docs/agents/indexes.md +1 -1
  117. package/docs/agents/install-and-sync.md +10 -11
  118. package/docs/agents/key-changes.md +5 -5
  119. package/docs/agents/label-coverage.md +11 -0
  120. package/docs/agents/migrate.md +1 -1
  121. package/docs/agents/pr-evidence-layout.md +70 -0
  122. package/docs/agents/pr-evidence.md +8 -9
  123. package/docs/agents/pr-frames.md +7 -2
  124. package/docs/agents/pr-lift.md +132 -0
  125. package/docs/agents/records-push.md +2 -2
  126. package/docs/agents/records-stale.md +12 -47
  127. package/docs/agents/records-validate.md +3 -7
  128. package/docs/agents/records.md +5 -27
  129. package/docs/agents/rule-citations.md +1 -1
  130. package/docs/agents/sandbox.md +4 -4
  131. package/docs/agents/scripting.md +2 -1
  132. package/docs/agents/skills-audit.md +1 -1
  133. package/docs/agents/tasks-plans.md +2 -2
  134. package/docs/agents/tasks.md +2 -2
  135. package/docs/target/migrations.md +10 -2
  136. package/docs/target/projects.md +4 -4
  137. package/docs/target/sync.md +1 -1
  138. package/docs/workflow/operating-model.md +2 -2
  139. package/docs/workflow/parallel-features.md +7 -7
  140. package/docs/workflow/skill-map-on-request.md +9 -9
  141. package/docs/workflow/skill-map.md +12 -11
  142. package/docs/workflow/visual-design-workflow.md +2 -2
  143. package/governance/detect.toml +201 -0
  144. package/governance/renames.toml +0 -11
  145. package/governance/rules/canon/context.md +13 -0
  146. package/governance/rules/claude/skill.md +17 -0
  147. package/governance/rules/code/000-code.md +1 -13
  148. package/governance/rules/code/010-testing.md +2 -30
  149. package/governance/rules/ui/400-ui.md +1 -1
  150. package/governance/rules/ui/480-canvas.md +13 -0
  151. package/governance/stacks/base.toml +2 -4
  152. package/package.json +4 -4
  153. package/scripts/core/install-check.sh +3 -3
  154. package/scripts/core/regen-hero.ts +0 -6
  155. package/scripts/lib/ui.sh +0 -168
  156. package/src/audits/catalog.ts +0 -1
  157. package/src/canvas/arrange.ts +66 -0
  158. package/src/canvas/capture.ts +20 -6
  159. package/src/canvas/content.ts +39 -0
  160. package/src/canvas/fit.ts +238 -0
  161. package/src/canvas/tokens.ts +28 -10
  162. package/src/capture/frame-values.ts +2 -32
  163. package/src/claude/cases/misc.ts +1 -1
  164. package/src/claude/cases/setup.ts +4 -0
  165. package/src/claude/cases/workflow.ts +6 -1
  166. package/src/claude/seeds.ts +1 -1
  167. package/src/claude/skills-audit.ts +2 -2
  168. package/src/cli/help.ts +3 -1
  169. package/src/cli/ui.ts +2 -3
  170. package/src/commands/canvas.ts +179 -1
  171. package/src/commands/claude.ts +1 -1
  172. package/src/commands/gov.ts +146 -16
  173. package/src/commands/labels.ts +326 -7
  174. package/src/commands/migrate.ts +6 -1
  175. package/src/commands/pr.ts +377 -2
  176. package/src/commands/records.ts +13 -399
  177. package/src/commands/sandbox.ts +3 -6
  178. package/src/commands/tasks.ts +3 -3
  179. package/src/context/audit.ts +5 -5
  180. package/src/context/folders.ts +1 -1
  181. package/src/design/budget.ts +1 -1
  182. package/src/design/components.ts +1 -1
  183. package/src/design/document.ts +1 -1
  184. package/src/design/regen.ts +1 -1
  185. package/src/design/render.ts +1 -1
  186. package/src/feedback/body.ts +26 -5
  187. package/src/gate/measures.ts +59 -45
  188. package/src/gate/sequencer.ts +56 -1
  189. package/src/gate/stages.ts +4 -11
  190. package/src/gov/detect.ts +301 -0
  191. package/src/gov/install.ts +2 -3
  192. package/src/gov/list.ts +23 -2
  193. package/src/gov/stacks.ts +10 -1
  194. package/src/hooks/post-merge.ts +1 -1
  195. package/src/labels/apply.ts +39 -0
  196. package/src/labels/format.ts +3 -3
  197. package/src/labels/phase.ts +3 -3
  198. package/src/labels/sections.ts +43 -0
  199. package/src/migrate/skill-names.ts +20 -1
  200. package/src/pr/evidence.ts +218 -45
  201. package/src/pr/frames.ts +23 -3
  202. package/src/pr/lift.ts +418 -0
  203. package/src/pr/paths.ts +4 -4
  204. package/src/pr/rest.ts +3 -0
  205. package/src/pr/review-scope.ts +76 -0
  206. package/src/records/backup.ts +16 -6
  207. package/src/records/stale-canonical.ts +31 -1
  208. package/src/records/validate.ts +2 -188
  209. package/src/sandbox/census.ts +1 -1
  210. package/src/sandbox/coverage.ts +2 -6
  211. package/src/sandbox/equivalence.ts +19 -51
  212. package/src/sandbox/expect.ts +4 -4
  213. package/src/sandbox/provision.ts +13 -36
  214. package/src/sandbox/scenario.ts +26 -48
  215. package/src/sandbox/tree.ts +2 -6
  216. package/src/standards/read.ts +14 -1
  217. package/src/sync/git.ts +1 -1
  218. package/src/tasks/archive.ts +5 -5
  219. package/src/tasks/branch.ts +3 -3
  220. package/src/tasks/label.ts +1 -1
  221. package/src/tasks/record.ts +2 -2
  222. package/src/tasks/validate.ts +1 -1
  223. package/standards/index.md +0 -13
  224. package/standards/standard.md +1 -7
  225. package/tooling/base/reference.md +1 -1
  226. package/tooling/base/seeds/.github/pull_request_template.md +2 -2
  227. package/tooling/claude/reference.md +1 -1
  228. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +9 -1
  229. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +171 -0
  230. package/tooling/claude/seeds/.claude/settings.json +9 -0
  231. package/governance/rules/standards/context.md +0 -14
  232. package/governance/rules/standards/design.md +0 -12
  233. package/governance/rules/standards/readme.md +0 -12
  234. package/governance/rules/standards/rule.md +0 -13
  235. package/governance/rules/standards/skill.md +0 -21
  236. package/governance/rules/standards/standard.md +0 -13
  237. package/scripts/config.sh +0 -11
  238. package/scripts/lib/gov.sh +0 -14
  239. package/scripts/lib/sandbox-fixtures.sh +0 -191
  240. package/scripts/lib/sandbox-git.sh +0 -125
  241. package/scripts/lib/sandbox-path.sh +0 -50
  242. package/scripts/sandbox-hook.sh +0 -99
  243. package/src/gov/standard-rules.ts +0 -179
  244. package/src/records/migrate.ts +0 -78
  245. package/src/records/stale.ts +0 -243
  246. package/src/sandbox/hooks.ts +0 -128
  247. /package/{standards → claude/skills/draft-figure/references}/figures.md +0 -0
  248. /package/{standards → claude/skills/git-branch/references}/slug.md +0 -0
  249. /package/{standards → claude/skills/git-commit/references}/commit.md +0 -0
  250. /package/{standards → claude/skills/git-commit/references}/versioning.md +0 -0
package/src/gov/list.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { basename, join } from 'node:path'
3
+ import { loadDetectEntries, staleDetectKeys } from '@/gov/detect'
3
4
  import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
4
5
  import {
5
6
  expandStackEntry,
@@ -15,17 +16,26 @@ export interface StackEntry {
15
16
  readonly rules: string[]
16
17
  }
17
18
 
19
+ /** The evidence `governance/detect.toml` records for one opt-in rule. */
20
+ export interface RuleDetect {
21
+ readonly deps: readonly string[]
22
+ readonly files: readonly string[]
23
+ }
24
+
18
25
  export interface RuleEntry {
19
26
  readonly name: string
20
27
  readonly domain: string
21
28
  readonly description: string
22
29
  readonly paths: string[] | null
30
+ readonly detect: RuleDetect | null
23
31
  }
24
32
 
25
33
  export interface GovCatalog {
26
34
  readonly stacks: StackEntry[]
27
35
  readonly rules: RuleEntry[]
28
36
  readonly unreferenced: string[]
37
+ /** `detect.toml` keys naming no live rule, which the gate fails on. */
38
+ readonly staleDetect: string[]
29
39
  }
30
40
 
31
41
  /**
@@ -64,24 +74,34 @@ export function buildStackEntries(root: string): StackEntry[] {
64
74
 
65
75
  /**
66
76
  * Reads the catalog straight off the source tree. A rule's domain is the
67
- * subdirectory it sits in, which is the band grouping install preserves.
77
+ * subdirectory it sits in, which is the band grouping install preserves, and
78
+ * its `detect` entry is the evidence that proposes it, null for a rule
79
+ * `detect.toml` does not name.
68
80
  */
69
81
  export function buildRuleEntries(root: string): RuleEntry[] {
70
82
  const rulesRoot = rulesSourceDir(root)
83
+ const detect = new Map(
84
+ loadDetectEntries(root).map((entry) => [
85
+ entry.rule,
86
+ { deps: entry.deps, files: entry.files },
87
+ ]),
88
+ )
71
89
 
72
90
  return listRuleSourcePaths(root).map((rel) => {
73
91
  const frontmatter = parseFrontmatter(
74
92
  readFileSync(join(rulesRoot, rel), 'utf8'),
75
93
  )
76
94
  const paths = frontmatter?.fields.paths
95
+ const name = basename(rel, '.md')
77
96
 
78
97
  return {
79
- name: basename(rel, '.md'),
98
+ name,
80
99
  domain: rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : '',
81
100
  description: readField(frontmatter, 'description') ?? '',
82
101
  paths: Array.isArray(paths)
83
102
  ? paths.filter((entry): entry is string => typeof entry === 'string')
84
103
  : null,
104
+ detect: detect.get(name) ?? null,
85
105
  }
86
106
  })
87
107
  }
@@ -91,6 +111,7 @@ export function buildGovCatalog(root: string): GovCatalog {
91
111
  stacks: buildStackEntries(root),
92
112
  rules: buildRuleEntries(root),
93
113
  unreferenced: unreferencedRules(root),
114
+ staleDetect: staleDetectKeys(root),
94
115
  }
95
116
  }
96
117
 
package/src/gov/stacks.ts CHANGED
@@ -79,7 +79,7 @@ export function loadGovStack(
79
79
  * shape rather than two the caller has to tell apart.
80
80
  *
81
81
  * The directory wins over a rule file of the same name. They cannot collide
82
- * while `standards/rule.md` requires a numeric prefix on a rule slug, since a
82
+ * while `claude/skills/create-rule/references/rule.md` requires a numeric prefix on a rule slug, since a
83
83
  * band folder carries none.
84
84
  */
85
85
  export function expandStackEntry(root: string, entry: string): string[] {
@@ -207,3 +207,12 @@ export function mergeExtraRules(
207
207
 
208
208
  return merged
209
209
  }
210
+
211
+ /**
212
+ * Resolves `--add` names with no stack under them. It runs the same merge as
213
+ * the stack path against an empty list, so an add with no stack and a layered one
214
+ * split and dedupe their names identically.
215
+ */
216
+ export function resolveExtraRules(add: string): string[] {
217
+ return mergeExtraRules([], add)
218
+ }
@@ -163,7 +163,7 @@ function archiveLines(
163
163
  }
164
164
  return (
165
165
  `\n📋 Task not archived for #${number}: ${text(fields, 'message') ?? reason}\n` +
166
- 'Run /task-board to resolve it by hand.\n\n'
166
+ `Run /task-complete archive #${number} to resolve it by hand.\n\n`
167
167
  )
168
168
  }
169
169
 
@@ -0,0 +1,39 @@
1
+ /** What a label apply decided to do, before any write. */
2
+ export interface LabelDiff {
3
+ /** Earned labels the pull request lacks and the remote carries, so addable. */
4
+ readonly toAdd: readonly string[]
5
+ /** Every earned label the pull request lacks, whether or not it is addable. */
6
+ readonly owed: readonly string[]
7
+ /** Owed labels the remote does not carry, which are reported and never created. */
8
+ readonly missingOnRemote: readonly string[]
9
+ }
10
+
11
+ /**
12
+ * Diffs what a branch earns against what its pull request already carries and
13
+ * what the remote can attach.
14
+ *
15
+ * The diff only adds. A label on the pull request that the map does not earn
16
+ * is a person's decision and never appears here, so nothing in the result can
17
+ * be turned into a removal. Order follows `earned`, which the map declares, so
18
+ * two runs over one branch produce one list.
19
+ *
20
+ * A label the remote lacks is separated from the addable set rather than
21
+ * dropped, because the REST add endpoint creates any label it is handed and
22
+ * that write to the repository settings is not the map's call.
23
+ */
24
+ export function diffLabels(
25
+ earned: readonly string[],
26
+ present: readonly string[],
27
+ remote: readonly string[],
28
+ ): LabelDiff {
29
+ const has = new Set(present)
30
+ const carried = new Set(remote)
31
+
32
+ const owed = earned.filter((label) => !has.has(label))
33
+
34
+ return {
35
+ owed,
36
+ toAdd: owed.filter((label) => carried.has(label)),
37
+ missingOnRemote: owed.filter((label) => !carried.has(label)),
38
+ }
39
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Which rule in `standards/pr.md`'s `## Title` section a title breaks.
2
+ * Which rule in `claude/skills/git-pr/references/pr.md`'s `## Title` section a title breaks.
3
3
  *
4
4
  * `structure` stands alone rather than beside the other three, because a
5
5
  * title failing the shape match has no parsed `<type>`, `<scope>`, or
@@ -28,9 +28,9 @@ const LEADING_LETTERS = /^[A-Za-z]+/
28
28
 
29
29
  /**
30
30
  * Grades a pull request title, or a commit subject sharing the same form,
31
- * against `standards/pr.md`'s structure, casing, and length rules.
31
+ * against `claude/skills/git-pr/references/pr.md`'s structure, casing, and length rules.
32
32
  *
33
- * Does not check `<type>` against `standards/commit.md`'s fixed enum.
33
+ * Does not check `<type>` against `claude/skills/git-commit/references/commit.md`'s fixed enum.
34
34
  * `pr.md`'s own `## Title` section states format, casing, and length only,
35
35
  * and names no type list of its own to check against.
36
36
  */
@@ -3,7 +3,7 @@ import { RECORD_ENTRIES, RECORD_ROOTS } from '@/roots/record'
3
3
  import type { RecordRoot } from '@/roots/record'
4
4
 
5
5
  /**
6
- * The two version namespaces `standards/versioning.md` keeps apart, and why a
6
+ * The two version namespaces `claude/skills/git-commit/references/versioning.md` keeps apart, and why a
7
7
  * pull request cannot be sorted between them by pattern alone.
8
8
  *
9
9
  * A phase label (`v68.5`) and a semver reference (`v3.44`) both match
@@ -186,7 +186,7 @@ const RELEASE_TITLE_PREFIX = 'chore(main): release '
186
186
  * phase label past this check. Together they name release-please's own act of
187
187
  * opening the pull request, which nothing else on the remote can perform.
188
188
  */
189
- function isReleasePullRequest(input: PhaseScanInput): boolean {
189
+ export function isReleasePullRequest(input: PhaseScanInput): boolean {
190
190
  return (
191
191
  input.headRefName.startsWith(RELEASE_HEAD_PREFIX) &&
192
192
  input.title.startsWith(RELEASE_TITLE_PREFIX)
@@ -265,7 +265,7 @@ function sessionLinks(text: string): string[] {
265
265
  * The split runs on the pull request rather than on each token. A release
266
266
  * pull request's tokens are read as the semver references its generated body
267
267
  * legitimately carries, and every other pull request's tokens are read as
268
- * leaked phase labels, which is what `standards/versioning.md` names the
268
+ * leaked phase labels, which is what `claude/skills/git-commit/references/versioning.md` names the
269
269
  * defect this exists to catch.
270
270
  *
271
271
  * A release pull request reports no board reference either, and the ground is
@@ -0,0 +1,43 @@
1
+ import { linesOutsideFences } from '@/markdown/scan'
2
+
3
+ /**
4
+ * The closed list of section headings a pull request body may carry, in the
5
+ * sentence case `claude/skills/git-pr/references/pr.md` states.
6
+ */
7
+ const ALLOWED_HEADINGS: readonly string[] = [
8
+ '## Summary',
9
+ '## Key changes',
10
+ '## Technical context',
11
+ '## Testing',
12
+ '## For the reviewer',
13
+ ]
14
+
15
+ const REQUIRED_HEADINGS: readonly string[] = ['## Summary', '## Key changes']
16
+
17
+ const H2 = /^##\s/
18
+
19
+ export interface SectionsCheck {
20
+ /** Level-two headings the closed list does not hold, as written. */
21
+ readonly outside: readonly string[]
22
+ /** Required headings the body never carries. */
23
+ readonly missing: readonly string[]
24
+ }
25
+
26
+ /**
27
+ * Grades the level-two headings of a pull request body against the closed list.
28
+ *
29
+ * The comparison is exact, so `## Key Changes` fails. `canon pr key-changes`
30
+ * reads its section case-insensitively, which keeps a body posted before the
31
+ * rename parsing while the scan holds new text to one spelling. Subheadings and
32
+ * fenced lines are not headings, so the list governs level two alone.
33
+ */
34
+ export function checkSections(body: string): SectionsCheck {
35
+ const headings = linesOutsideFences(body)
36
+ .map((line) => line.trim())
37
+ .filter((line) => H2.test(line))
38
+
39
+ return {
40
+ outside: headings.filter((heading) => !ALLOWED_HEADINGS.includes(heading)),
41
+ missing: REQUIRED_HEADINGS.filter((heading) => !headings.includes(heading)),
42
+ }
43
+ }
@@ -99,6 +99,25 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
99
99
  'draft-diagram': 'draft-figure',
100
100
  }
101
101
 
102
+ /**
103
+ * The skills that took a two-word name from a single word, which the map above
104
+ * may not carry as a bare key.
105
+ *
106
+ * `canvas` became `canvas-board`, and a bare `canvas` key would rewrite every
107
+ * unrelated use of the word: `canon canvas serve`, `src/canvas/`,
108
+ * `.canon/canvas/`, and the `internal-canvas` skill. So each row keys on a form
109
+ * only the skill spells, a plugin invocation or a skill folder path. The tail
110
+ * guard rejects a following `-`, so a rewritten `canon:canvas-board` is not
111
+ * matched again.
112
+ *
113
+ * What this leaves behind is a prose mention of the skill by its bare name,
114
+ * which has no token for a sweep to find and is fixed by hand.
115
+ */
116
+ export const SKILL_CITATION_MAP: Readonly<Record<string, string>> = {
117
+ 'canon:canvas': 'canon:canvas-board',
118
+ 'skills/canvas': 'skills/canvas-board',
119
+ }
120
+
102
121
  /**
103
122
  * The skill rename.
104
123
  *
@@ -132,7 +151,7 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
132
151
  * listing every sweep reads never reaches them.
133
152
  */
134
153
  export const SKILL_NAME_RULES: RenameRules = defineRenameRules({
135
- replacements: SKILL_NAME_MAP,
154
+ replacements: { ...SKILL_NAME_MAP, ...SKILL_CITATION_MAP },
136
155
  keepMarker: 'canon-keep-retired',
137
156
  wholeToken: true,
138
157
  excludedPrefixes: ['scripts/eval/result-'],
@@ -188,23 +188,218 @@ function evidenceImage(url: string, alt: string, width?: number): string {
188
188
 
189
189
  const EVIDENCE_IMAGE = String.raw`(?:!\[\]\([^)]*\)|<img [^>]*>)`
190
190
 
191
- /** A case row in any shape a render has posted: a Base cell before the head image, or the head image alone. */
192
- const EVIDENCE_ROW_PATTERN = new RegExp(
191
+ /** A case row in a shape before the head image carried an alt: a Base cell before the head image, or the head image alone. */
192
+ const LEGACY_ROW_PATTERN = new RegExp(
193
193
  String.raw`^\| .+ \| (?:(?:\*\(new\)\*|${EVIDENCE_IMAGE}) \| )?${EVIDENCE_IMAGE} \|$`,
194
- 'gm',
195
194
  )
196
195
 
196
+ /** A head image, which every render since the alt text was added names with a trailing `head`. A row carries one per case it holds. */
197
+ const HEAD_IMAGE_PATTERN = /<img [^>]*alt="[^"]* head">/g
198
+
199
+ /** The form a case name takes when it names a view, a theme, and a width. Anchored on the right, since a view may carry hyphens. */
200
+ const CASE_NAME = /^(.+)-(dark|light)-(\d+)$/
201
+
202
+ const THEMES = ['dark', 'light'] as const
203
+
204
+ type Theme = (typeof THEMES)[number]
205
+
206
+ interface ViewCase {
207
+ readonly item: EvidenceItem
208
+ readonly theme: Theme
209
+ readonly width: string
210
+ }
211
+
212
+ function isTheme(value: string): value is Theme {
213
+ return value === 'dark' || value === 'light'
214
+ }
215
+
216
+ function readCaseName(stem: string):
217
+ | {
218
+ readonly view: string
219
+ readonly theme: Theme
220
+ readonly width: string
221
+ }
222
+ | undefined {
223
+ const match = CASE_NAME.exec(stem)
224
+ const view = match?.[1]
225
+ const theme = match?.[2]
226
+ const width = match?.[3]
227
+ if (view === undefined || theme === undefined || width === undefined) {
228
+ return undefined
229
+ }
230
+ return isTheme(theme) ? { view, theme, width } : undefined
231
+ }
232
+
233
+ function compareWidths(a: string, b: string): number {
234
+ return Number(a) - Number(b) || a.localeCompare(b)
235
+ }
236
+
237
+ function commitLink(repo: string, sha: string): string {
238
+ return `[\`${sha.slice(0, SHORT_SHA_LENGTH)}\`](https://github.com/${repo}/commit/${sha})`
239
+ }
240
+
241
+ function baseImage(item: EvidenceItem, repo: string, base: string): string {
242
+ return item.added
243
+ ? '*(new)*'
244
+ : evidenceImage(
245
+ rawUrl(repo, base, item.path),
246
+ `${escapeCell(item.stem)} base`,
247
+ item.baseWidth,
248
+ )
249
+ }
250
+
251
+ function headImage(item: EvidenceItem, repo: string, head: string): string {
252
+ return evidenceImage(
253
+ rawUrl(repo, head, item.path),
254
+ `${escapeCell(item.stem)} head`,
255
+ item.width,
256
+ )
257
+ }
258
+
259
+ function block(
260
+ opener: string,
261
+ summary: string,
262
+ header: readonly string[],
263
+ rows: readonly string[],
264
+ ): string {
265
+ return [
266
+ opener,
267
+ `<summary>${summary}</summary>`,
268
+ '',
269
+ `| ${header.join(' | ')} |`,
270
+ `| ${header.map(() => '---').join(' | ')} |`,
271
+ ...rows,
272
+ '',
273
+ '</details>',
274
+ ].join('\n')
275
+ }
276
+
277
+ /** One block for the cases of a state that did not name a view, one case per row. */
278
+ function renderFlat(
279
+ label: string,
280
+ items: readonly EvidenceItem[],
281
+ repo: string,
282
+ base: string,
283
+ head: string,
284
+ opener: string,
285
+ ): string {
286
+ const hasBase = items.some((item) => !item.added)
287
+ const rows = items.map((item) => {
288
+ const stem = escapeCell(item.stem)
289
+ const after = headImage(item, repo, head)
290
+ return hasBase
291
+ ? `| ${stem} | ${baseImage(item, repo, base)} | ${after} |`
292
+ : `| ${stem} | ${after} |`
293
+ })
294
+ return block(
295
+ opener,
296
+ `${label} (${items.length})`,
297
+ hasBase ? ['Case', 'Base', 'Head'] : ['Case', 'Head'],
298
+ rows,
299
+ )
300
+ }
301
+
302
+ /** One block for a view, a width per row and a theme per column, each theme taking a base column when the view already exists at the base. */
303
+ function renderView(
304
+ label: string,
305
+ cases: readonly ViewCase[],
306
+ repo: string,
307
+ base: string,
308
+ head: string,
309
+ opener: string,
310
+ ): string {
311
+ const themes = THEMES.filter((theme) =>
312
+ cases.some((entry) => entry.theme === theme),
313
+ )
314
+ const hasBase = cases.some((entry) => !entry.item.added)
315
+ const widths = [...new Set(cases.map((entry) => entry.width))].sort(
316
+ compareWidths,
317
+ )
318
+ const header = [
319
+ 'Width',
320
+ ...themes.flatMap((theme) => {
321
+ const name = theme === 'dark' ? 'Dark' : 'Light'
322
+ return hasBase ? [`${name} base`, `${name} head`] : [name]
323
+ }),
324
+ ]
325
+ const rows = widths.map((width) => {
326
+ const cells = themes.flatMap((theme) => {
327
+ const found = cases.find(
328
+ (entry) => entry.theme === theme && entry.width === width,
329
+ )
330
+ if (found === undefined) return hasBase ? ['', ''] : ['']
331
+ const after = headImage(found.item, repo, head)
332
+ return hasBase ? [baseImage(found.item, repo, base), after] : [after]
333
+ })
334
+ return `| ${[width, ...cells].join(' | ')} |`
335
+ })
336
+ return block(opener, `${label} (${cases.length})`, header, rows)
337
+ }
338
+
339
+ /**
340
+ * Renders the blocks of one state: a block per view the case names split
341
+ * into, then one flat block for the names that did not. Nothing is dropped,
342
+ * so a state of unparsed names reads as the flat table it always was.
343
+ */
344
+ function renderState(
345
+ entry: EvidenceState,
346
+ repo: string,
347
+ base: string,
348
+ head: string,
349
+ opener: string,
350
+ ): string[] {
351
+ const views = new Map<string, ViewCase[]>()
352
+ const flat: EvidenceItem[] = []
353
+ const taken = new Set<string>()
354
+ for (const item of entry.items) {
355
+ const name = readCaseName(item.stem)
356
+ // Two files can share a stem across extensions, and a view cell holds one.
357
+ const cell =
358
+ name === undefined ? '' : `${name.view}|${name.theme}|${name.width}`
359
+ if (name === undefined || taken.has(cell)) {
360
+ flat.push(item)
361
+ continue
362
+ }
363
+ taken.add(cell)
364
+ const cases = views.get(name.view)
365
+ const viewCase = { item, theme: name.theme, width: name.width }
366
+ if (cases === undefined) views.set(name.view, [viewCase])
367
+ else cases.push(viewCase)
368
+ }
369
+
370
+ const prefix = entry.state === '' ? '' : `${escapeCell(entry.state)} / `
371
+ const blocks = [...views.entries()]
372
+ .sort(([a], [b]) => a.localeCompare(b))
373
+ .map(([view, cases]) =>
374
+ renderView(
375
+ `${prefix}${escapeCell(view)}`,
376
+ cases,
377
+ repo,
378
+ base,
379
+ head,
380
+ opener,
381
+ ),
382
+ )
383
+ if (flat.length > 0) {
384
+ const label = entry.state === '' ? 'evidence' : escapeCell(entry.state)
385
+ blocks.push(renderFlat(label, flat, repo, base, head, opener))
386
+ }
387
+ return blocks
388
+ }
389
+
197
390
  export function evidenceMarker(head: string): string {
198
391
  return `${MARKER_PREFIX} head=${head} -->`
199
392
  }
200
393
 
201
394
  /**
202
- * Renders the whole comment body: one `<details>` block per state, a Base/Head
203
- * row per case, and the trailing marker naming the head this body describes.
395
+ * Renders the whole comment body: one `<details>` block per view within each
396
+ * state, a width per row with a column per theme, a flat Base/Head table for
397
+ * the case names that do not split into a view, and the trailing marker naming
398
+ * the head this body describes.
204
399
  * Every state opens while the comment carries at most `EVIDENCE_OPEN_LIMIT`
205
400
  * images, one per row for the head plus one per row not added, and every state
206
401
  * closes past it, so a comment is never half open. A visible line under
207
- * `## Evidence` names the base and head as short shas. Every image URL is
402
+ * `## Evidence` names the base and head as short shas, each linking its commit. Every image URL is
208
403
  * pinned to a commit sha rather than a branch, so the comment keeps showing
209
404
  * what it claimed even after the branch moves.
210
405
  *
@@ -252,42 +447,13 @@ export function renderEvidenceBody(
252
447
  const opener =
253
448
  imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
254
449
 
255
- const sections = states.map((entry) => {
256
- const hasBase = entry.items.some((item) => !item.added)
257
- const rows = entry.items.map((item) => {
258
- const stem = escapeCell(item.stem)
259
- const before = item.added
260
- ? '*(new)*'
261
- : evidenceImage(
262
- rawUrl(repo, base, item.path),
263
- `${stem} base`,
264
- item.baseWidth,
265
- )
266
- const after = evidenceImage(
267
- rawUrl(repo, head, item.path),
268
- `${stem} head`,
269
- item.width,
270
- )
271
- return hasBase
272
- ? `| ${stem} | ${before} | ${after} |`
273
- : `| ${stem} | ${after} |`
274
- })
275
-
276
- return [
277
- opener,
278
- `<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
279
- '',
280
- hasBase ? '| Case | Base | Head |' : '| Case | Head |',
281
- hasBase ? '| --- | --- | --- |' : '| --- | --- |',
282
- ...rows,
283
- '',
284
- '</details>',
285
- ].join('\n')
286
- })
450
+ const sections = states.flatMap((entry) =>
451
+ renderState(entry, repo, base, head, opener),
452
+ )
287
453
 
288
454
  return [
289
455
  ...opening,
290
- `${BASE_PREFIX} \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
456
+ `${BASE_PREFIX} ${commitLink(repo, base)} · **Head:** ${commitLink(repo, head)}`,
291
457
  '',
292
458
  ...sections,
293
459
  '',
@@ -297,14 +463,21 @@ export function renderEvidenceBody(
297
463
  }
298
464
 
299
465
  /**
300
- * How many case rows a rendered body carries, read off the row shapes
301
- * `renderEvidenceBody` writes. A row always ends in a head image, which a
302
- * checklist line or a preview address never does. The pattern also reads the
303
- * `![](url)` three-column shape an earlier render posted, so a comment posted
304
- * before the change still counts its cases and the `would-empty` guard holds.
466
+ * How many cases a rendered body carries. Every case has exactly one head
467
+ * image, so a row counts the head images it holds, which is one in the flat
468
+ * table and up to two in a view's row. A row from before the alt text named
469
+ * its head image counts as one case when it matches the old row shape, so a
470
+ * comment posted before the change still counts its cases and the
471
+ * `would-empty` guard holds.
305
472
  */
306
473
  export function countEvidenceCases(body: string): number {
307
- return body.match(EVIDENCE_ROW_PATTERN)?.length ?? 0
474
+ let total = 0
475
+ for (const line of body.split('\n')) {
476
+ if (!line.startsWith('| ')) continue
477
+ const heads = line.match(HEAD_IMAGE_PATTERN)?.length ?? 0
478
+ total += heads > 0 ? heads : LEGACY_ROW_PATTERN.test(line) ? 1 : 0
479
+ }
480
+ return total
308
481
  }
309
482
 
310
483
  export interface EvidenceComment {
package/src/pr/frames.ts CHANGED
@@ -20,6 +20,10 @@ const FOLDER_PATTERN = /^pr-(\d+)$/
20
20
 
21
21
  const DAY_MS = 86_400_000
22
22
 
23
+ const THEMES = ['dark', 'light'] as const
24
+
25
+ export type FrameTheme = (typeof THEMES)[number]
26
+
23
27
  export interface TreeEntry {
24
28
  readonly path: string
25
29
  readonly mode: string
@@ -71,6 +75,7 @@ export interface FrameInput {
71
75
  readonly head: string
72
76
  readonly pass: string
73
77
  readonly box: number
78
+ readonly theme?: FrameTheme
74
79
  readonly bytes: Uint8Array
75
80
  }
76
81
 
@@ -113,18 +118,27 @@ export function isFramePass(pass: string): boolean {
113
118
  return PASS_PATTERN.test(pass)
114
119
  }
115
120
 
121
+ /** Whether a value names a theme a box can be shot at, the only theme segment the path takes. */
122
+ export function isFrameTheme(theme: string): theme is FrameTheme {
123
+ return (THEMES as readonly string[]).includes(theme)
124
+ }
125
+
116
126
  /**
117
127
  * Where a frame lands. The pass segment keeps a second pass at the same head
118
128
  * and box from replacing the image an earlier review comment shows, since a
119
- * branch-path link reads whatever the path holds now.
129
+ * branch-path link reads whatever the path holds now. The theme segment keeps
130
+ * two shots of one box in one pass apart, and a frame without one keeps the
131
+ * plain `box-<n>.png` form.
120
132
  */
121
133
  export function framePath(
122
134
  number: number,
123
135
  head: string,
124
136
  pass: string,
125
137
  box: number,
138
+ theme?: FrameTheme,
126
139
  ): string {
127
- return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/box-${box}.png`
140
+ const name = theme === undefined ? `box-${box}` : `box-${box}-${theme}`
141
+ return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/${name}.png`
128
142
  }
129
143
 
130
144
  /**
@@ -160,7 +174,13 @@ export async function pushFrame(
160
174
  api: FramesApi,
161
175
  input: FrameInput,
162
176
  ): Promise<PushResult> {
163
- const path = framePath(input.number, input.head, input.pass, input.box)
177
+ const path = framePath(
178
+ input.number,
179
+ input.head,
180
+ input.pass,
181
+ input.box,
182
+ input.theme,
183
+ )
164
184
  const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
165
185
  if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
166
186
  const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }