@erclx/canon 5.35.0 → 5.37.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 (238) hide show
  1. package/README.md +11 -11
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +6 -0
  4. package/claude/skills/backlog-triage/SKILL.md +7 -7
  5. package/claude/skills/build-in-slices/SKILL.md +6 -0
  6. package/claude/skills/build-in-slices/references/adopted.md +3 -0
  7. package/claude/skills/canon-cli/REQUIREMENT.md +2 -2
  8. package/claude/skills/canon-cli/SKILL.md +5 -1
  9. package/claude/skills/canon-feedback/SKILL.md +5 -0
  10. package/claude/skills/canon-feedback-triage/SKILL.md +3 -0
  11. package/claude/skills/canvas-board/REQUIREMENT.md +1 -1
  12. package/claude/skills/canvas-board/SKILL.md +5 -2
  13. package/claude/skills/canvas-board/references/canvas.md +12 -0
  14. package/claude/skills/canvas-board/references/design.md +53 -36
  15. package/claude/skills/ci-workflow/SKILL.md +5 -0
  16. package/claude/skills/code-craft/SKILL.md +6 -0
  17. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  18. package/claude/skills/codebase-layout/SKILL.md +2 -2
  19. package/claude/skills/context-fold/references/context.md +48 -34
  20. package/claude/skills/create-rule/REQUIREMENT.md +6 -8
  21. package/claude/skills/create-rule/SKILL.md +19 -17
  22. package/claude/skills/create-rule/references/rule.md +17 -23
  23. package/claude/skills/create-skill/SKILL.md +11 -2
  24. package/claude/skills/create-skill/references/skill-disclosure.md +26 -0
  25. package/claude/skills/create-skill/references/skill-paths.md +3 -0
  26. package/claude/skills/create-skill/references/skill-reference.md +12 -0
  27. package/claude/skills/create-skill/references/skill.md +26 -25
  28. package/claude/skills/deprecation-migration/SKILL.md +6 -0
  29. package/claude/skills/deprecation-migration/references/adopted.md +3 -0
  30. package/claude/skills/design-taste/REQUIREMENT.md +6 -6
  31. package/claude/skills/design-taste/SKILL.md +8 -2
  32. package/claude/skills/design-taste/references/craft.md +4 -1
  33. package/claude/skills/design-taste/references/preflight.md +18 -2
  34. package/claude/skills/design-taste/references/tells.md +1 -1
  35. package/claude/skills/docs-sync/references/docs.md +6 -0
  36. package/claude/skills/docs-sync/references/readme.md +15 -0
  37. package/claude/skills/draft-and-pick/SKILL.md +12 -0
  38. package/claude/skills/draft-doc/references/context.md +1 -1
  39. package/claude/skills/draft-doc/references/readme.md +3 -0
  40. package/claude/skills/draft-figure/references/mermaid.md +6 -0
  41. package/claude/skills/draft-identity/SKILL.md +3 -2
  42. package/claude/skills/draft-slides/SKILL.md +3 -0
  43. package/claude/skills/git-branch/references/branch.md +16 -11
  44. package/claude/skills/git-commit/references/commit.md +19 -11
  45. package/claude/skills/git-commit/references/versioning.md +9 -0
  46. package/claude/skills/git-issue/references/issue.md +12 -9
  47. package/claude/skills/git-pr/SKILL.md +5 -0
  48. package/claude/skills/git-pr/references/pr.md +24 -6
  49. package/claude/skills/git-ship/SKILL.md +3 -0
  50. package/claude/skills/git-split/SKILL.md +6 -0
  51. package/claude/skills/git-stage/SKILL.md +3 -0
  52. package/claude/skills/git-worktree/SKILL.md +6 -6
  53. package/claude/skills/markdown-craft/references/markdown.md +27 -8
  54. package/claude/skills/plan-feature/SKILL.md +10 -1
  55. package/claude/skills/plan-feature/references/plan.md +20 -8
  56. package/claude/skills/plan-groundwork/SKILL.md +6 -0
  57. package/claude/skills/plan-groundwork/references/groundwork.md +10 -7
  58. package/claude/skills/plan-intake/references/intake.md +6 -6
  59. package/claude/skills/read-frames/SKILL.md +3 -0
  60. package/claude/skills/review-craft/SKILL.md +3 -0
  61. package/claude/skills/review-craft/references/ui.md +1 -1
  62. package/claude/skills/review-ui/SKILL.md +6 -3
  63. package/claude/skills/role-orchestrator/SKILL.md +3 -0
  64. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +7 -4
  65. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +3 -0
  66. package/claude/skills/role-planner/SKILL.md +3 -0
  67. package/claude/skills/role-worker/SKILL.md +12 -0
  68. package/claude/skills/search-craft/SKILL.md +3 -0
  69. package/claude/skills/systematic-debugging/SKILL.md +6 -0
  70. package/claude/skills/target-check/references/domains.md +5 -0
  71. package/claude/skills/target-setup/SKILL.md +5 -0
  72. package/claude/skills/task-board/references/tasks.md +9 -6
  73. package/claude/skills/task-complete/references/archive.md +3 -0
  74. package/claude/skills/teach-workspace/SKILL.md +3 -0
  75. package/claude/skills/teach-workspace/references/glossary.md +3 -0
  76. package/claude/skills/teach-workspace/references/lesson-build.md +3 -0
  77. package/claude/skills/teach-workspace/references/lesson-craft.md +3 -0
  78. package/claude/skills/teach-workspace/references/teach.md +8 -8
  79. package/claude/skills/test-craft/SKILL.md +7 -2
  80. package/claude/skills/ux-audit/REQUIREMENT.md +1 -1
  81. package/claude/skills/ux-audit/SKILL.md +2 -2
  82. package/claude/skills/ux-measure/REQUIREMENT.md +1 -1
  83. package/claude/skills/video-craft/SKILL.md +6 -0
  84. package/claude/skills/write-human/SKILL.md +9 -0
  85. package/claude/skills/write-human/references/machine-tells.md +12 -0
  86. package/docs/agents/capture.md +2 -0
  87. package/docs/agents/commands.md +59 -56
  88. package/docs/agents/comments.md +1 -1
  89. package/docs/agents/context-audit-checks.md +6 -0
  90. package/docs/agents/context-audit.md +8 -8
  91. package/docs/agents/context-classify.md +2 -0
  92. package/docs/agents/drift-surfaces.md +5 -5
  93. package/docs/agents/gate.md +6 -7
  94. package/docs/agents/gov-detect.md +1 -1
  95. package/docs/agents/index.md +2 -2
  96. package/docs/agents/install-and-sync.md +6 -6
  97. package/docs/agents/markdown-audit-weight.md +11 -3
  98. package/docs/agents/markdown-audit.md +14 -2
  99. package/docs/agents/output-shape.md +1 -1
  100. package/docs/agents/overview.md +2 -2
  101. package/docs/agents/pr-frames.md +17 -3
  102. package/docs/agents/rule-citations.md +5 -10
  103. package/docs/agents/scripting.md +1 -1
  104. package/docs/agents/sessions-transfer.md +8 -6
  105. package/docs/agents/skills-audit.md +9 -1
  106. package/docs/target/projects.md +2 -2
  107. package/docs/target/scenarios.md +5 -5
  108. package/docs/target/standards-moved.md +20 -18
  109. package/docs/workflow/ai-workflow.md +4 -1
  110. package/docs/workflow/visual-design-workflow.md +8 -3
  111. package/governance/detect.toml +32 -32
  112. package/governance/renames.toml +44 -0
  113. package/governance/rules/lib/{305-e2e-reliability.md → e2e-reliability.md} +1 -1
  114. package/governance/rules/lib/{306-test-scope.md → test-scope.md} +2 -2
  115. package/governance/rules/ui/{410-a11y.md → a11y.md} +1 -1
  116. package/governance/rules/ui/{440-surface-capture.md → surface-capture.md} +3 -3
  117. package/governance/stacks/base.toml +6 -6
  118. package/governance/stacks/go.toml +1 -1
  119. package/governance/stacks/node.toml +1 -1
  120. package/governance/stacks/php.toml +1 -1
  121. package/governance/stacks/python.toml +1 -1
  122. package/package.json +2 -2
  123. package/scripts/core/install-check.sh +2 -7
  124. package/src/capture/frame-values.ts +3 -6
  125. package/src/cli/help-all.ts +96 -0
  126. package/src/cli/help.ts +28 -27
  127. package/src/cli.ts +8 -1
  128. package/src/commands/claude.ts +29 -0
  129. package/src/commands/context.ts +29 -4
  130. package/src/commands/design.ts +22 -1
  131. package/src/commands/gate.ts +1 -8
  132. package/src/commands/gov.ts +25 -3
  133. package/src/commands/init.ts +10 -1
  134. package/src/commands/markdown.ts +148 -3
  135. package/src/commands/pr.ts +17 -10
  136. package/src/commands/records.ts +1 -1
  137. package/src/commands/sync.ts +11 -0
  138. package/src/commands/tooling.ts +8 -0
  139. package/src/context/audit.ts +83 -0
  140. package/src/context/classify/prompts.ts +6 -1
  141. package/src/context/gate.ts +6 -3
  142. package/src/design/budget.ts +14 -10
  143. package/src/design/document.ts +5 -5
  144. package/src/design/parse.ts +21 -4
  145. package/src/design/render.ts +3 -3
  146. package/src/gate/measures.ts +187 -2
  147. package/src/gate/sequencer.ts +0 -4
  148. package/src/gate/stages.ts +32 -27
  149. package/src/git/files.ts +30 -0
  150. package/src/gov/adapter.ts +2 -2
  151. package/src/gov/citations.ts +10 -33
  152. package/src/gov/install.ts +2 -2
  153. package/src/gov/list.ts +1 -1
  154. package/src/gov/payload.ts +1 -1
  155. package/src/gov/stacks.ts +4 -4
  156. package/src/inventory/subjects.ts +1 -1
  157. package/src/markdown/ceiling.ts +6 -1
  158. package/src/markdown/colons.ts +86 -0
  159. package/src/markdown/gate.ts +25 -0
  160. package/src/markdown/growth.ts +43 -0
  161. package/src/markdown/heading-case.ts +74 -0
  162. package/src/markdown/stacks.ts +74 -0
  163. package/src/markdown/structure.ts +72 -4
  164. package/src/migrate/rule-layout.ts +12 -12
  165. package/src/pr/frames.ts +66 -1
  166. package/src/shipped/references.ts +14 -14
  167. package/src/sync/check.ts +23 -23
  168. package/src/sync/copy.ts +4 -5
  169. package/tooling/astro/configs/tsconfig.json +1 -1
  170. package/tooling/astro/manifest.toml +0 -1
  171. package/tooling/astro/reference.md +2 -10
  172. package/tooling/base/configs/scripts/verify.json +1 -1
  173. package/tooling/base/configs/scripts/verify.ts +23 -13
  174. package/tooling/base/manifest.toml +6 -5
  175. package/tooling/base/reference.md +15 -22
  176. package/tooling/base/seeds/.github/workflows/verify.yml +0 -6
  177. package/tooling/base/seeds/.lintstagedrc +1 -2
  178. package/tooling/base/seeds/canon/context/ci.md +7 -8
  179. package/tooling/base/seeds/canon/context/development.md +10 -10
  180. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +9 -1
  181. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +27 -2
  182. package/tooling/claude/seeds/CLAUDE.md +1 -1
  183. package/tooling/claude/seeds/canon/DESIGN.md +24 -37
  184. package/tooling/shell/configs/.github/workflows/shell.yml +30 -0
  185. package/tooling/shell/manifest.toml +12 -0
  186. package/tooling/shell/reference.md +46 -0
  187. package/tooling/vite-react/configs/tsconfig.json +1 -1
  188. package/tooling/vite-react/configs/vitest.config.ts +1 -1
  189. package/tooling/vite-react/manifest.toml +0 -1
  190. package/tooling/vite-react/reference.md +2 -10
  191. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -5
  192. package/tooling/web/configs/.github/workflows/verify.yml +0 -6
  193. package/tooling/web/configs/src/test/setup.ts +1 -1
  194. package/tooling/web/manifest.toml +4 -4
  195. package/tooling/web/reference.md +4 -4
  196. package/tooling/base/configs/scripts/verify.sh +0 -2
  197. /package/governance/rules/canon/{606-git.md → git.md} +0 -0
  198. /package/governance/rules/canon/{611-indexes.md → indexes.md} +0 -0
  199. /package/governance/rules/canon/{604-scratch.md → scratch.md} +0 -0
  200. /package/governance/rules/canon/{602-tasks.md → task-scope.md} +0 -0
  201. /package/governance/rules/canon/{605-worktrees.md → worktrees.md} +0 -0
  202. /package/governance/rules/claude/{566-output.md → output.md} +0 -0
  203. /package/governance/rules/code/{000-code.md → routing.md} +0 -0
  204. /package/governance/rules/code/{010-testing.md → testing.md} +0 -0
  205. /package/governance/rules/framework/{210-astro.md → astro.md} +0 -0
  206. /package/governance/rules/framework/{220-fastapi.md → fastapi.md} +0 -0
  207. /package/governance/rules/framework/{230-nextjs.md → nextjs.md} +0 -0
  208. /package/governance/rules/framework/{200-react.md → react.md} +0 -0
  209. /package/governance/rules/framework/{260-shadcn.md → shadcn.md} +0 -0
  210. /package/governance/rules/framework/{250-tailwind.md → tailwind.md} +0 -0
  211. /package/governance/rules/lang/{130-go.md → go.md} +0 -0
  212. /package/governance/rules/lang/{140-php.md → php.md} +0 -0
  213. /package/governance/rules/lang/{110-python.md → python.md} +0 -0
  214. /package/governance/rules/lang/{100-typescript.md → typescript.md} +0 -0
  215. /package/governance/rules/lib/{370-database.md → database.md} +0 -0
  216. /package/governance/rules/lib/{340-pydantic.md → pydantic.md} +0 -0
  217. /package/governance/rules/lib/{360-security-server.md → security-server.md} +0 -0
  218. /package/governance/rules/lib/{350-security-web.md → security-web.md} +0 -0
  219. /package/governance/rules/lib/{320-tanstack-query.md → tanstack-query.md} +0 -0
  220. /package/governance/rules/lib/{335-testing-go.md → testing-go.md} +0 -0
  221. /package/governance/rules/lib/{336-testing-php.md → testing-php.md} +0 -0
  222. /package/governance/rules/lib/{330-testing-py.md → testing-py.md} +0 -0
  223. /package/governance/rules/lib/{300-testing-ts.md → testing-ts.md} +0 -0
  224. /package/governance/rules/lib/{310-zod.md → zod.md} +0 -0
  225. /package/governance/rules/tooling/{700-ci-workflow.md → ci-workflow.md} +0 -0
  226. /package/governance/rules/tooling/{790-code-comments.md → code-comments.md} +0 -0
  227. /package/governance/rules/tooling/{780-config-comments.md → config-comments.md} +0 -0
  228. /package/governance/rules/tooling/{775-dependencies.md → dependencies.md} +0 -0
  229. /package/governance/rules/ui/{480-canvas.md → canvas.md} +0 -0
  230. /package/governance/rules/ui/{400-ui.md → copy.md} +0 -0
  231. /package/governance/rules/ui/{460-design-taste.md → design-taste.md} +0 -0
  232. /package/governance/rules/ui/{420-forms.md → forms.md} +0 -0
  233. /package/governance/rules/ui/{465-interface-casing.md → interface-casing.md} +0 -0
  234. /package/governance/rules/ui/{450-link-behavior.md → link-behavior.md} +0 -0
  235. /package/governance/rules/ui/{470-motion.md → motion.md} +0 -0
  236. /package/governance/rules/ui/{430-ux-completeness.md → ux-completeness.md} +0 -0
  237. /package/tooling/{base → shell}/configs/.editorconfig +0 -0
  238. /package/tooling/{base → shell}/configs/.shellcheckrc +0 -0
@@ -0,0 +1,86 @@
1
+ import { type BodyLine, LINK, maskDisplayed } from '@/markdown/scan'
2
+
3
+ export interface ColonFinding {
4
+ readonly line: number
5
+ /** Zero-based, the colon itself. */
6
+ readonly column: number
7
+ /** The whole source line, so a reader can match it against new text. */
8
+ readonly text: string
9
+ /**
10
+ * The run of non-space characters ending at the colon. A hook matches it
11
+ * against new text, since `column` is a UTF-16 offset that a reader counting
12
+ * code points would misplace after an emoji.
13
+ */
14
+ readonly lead: string
15
+ }
16
+
17
+ const HEADING = /^#{1,6}\s/
18
+ const TABLE_ROW = /^\s*\|/
19
+ const LIST_MARKER = /^\s*(?:[-*+]|\d+[.)])\s+/
20
+ const QUOTE_MARKER = /^(?:\s*>\s?)+/
21
+
22
+ /** A colon followed by whitespace and more text, ruling out `10:30` and a scheme. */
23
+ const COLON = /:(?=\s+\S)/g
24
+
25
+ const BOLD_LABEL = /^(\*\*|__)[^*_]+\1$/
26
+
27
+ /** Blanks a span while holding its width, so a column stays where it was. */
28
+ function blank(match: string): string {
29
+ return ' '.repeat(match.length)
30
+ }
31
+
32
+ /**
33
+ * Whether the text before a line's first colon is a label rather than a
34
+ * clause: the lead of a list item, or a prefix that is only emphasis and a
35
+ * masked code span.
36
+ */
37
+ function isLabel(prefix: string, listItem: boolean): boolean {
38
+ if (BOLD_LABEL.test(prefix.trim())) return true
39
+
40
+ const words = prefix.replace(/[*_]/g, ' ').trim()
41
+ if (words === '') return true
42
+ return listItem && words.split(/\s+/).length <= 4
43
+ }
44
+
45
+ /**
46
+ * Finds a colon joining two clauses in the middle of a prose sentence.
47
+ *
48
+ * A heading, a table row, a fence, a code span, a link, and a line-final colon
49
+ * are left alone, and so is the first colon of a list item or of a line opening
50
+ * on a bold or code label. The measure is advisory, because a colon is
51
+ * sometimes right and the corpus count shows a gate would be a sweep nobody
52
+ * asked for.
53
+ */
54
+ export function findColons(lines: readonly BodyLine[]): ColonFinding[] {
55
+ const found: ColonFinding[] = []
56
+
57
+ for (const line of lines) {
58
+ if (line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
59
+ continue
60
+ }
61
+
62
+ const masked = maskDisplayed(line.text.replace(LINK, blank))
63
+ const quote = QUOTE_MARKER.exec(masked)?.[0] ?? ''
64
+ const marker = LIST_MARKER.exec(masked.slice(quote.length))?.[0] ?? ''
65
+ const start = quote.length + marker.length
66
+ let labelled = false
67
+
68
+ for (const match of masked.matchAll(COLON)) {
69
+ if (match.index < start) continue
70
+
71
+ if (!labelled) {
72
+ labelled = true
73
+ if (isLabel(masked.slice(start, match.index), marker !== '')) continue
74
+ }
75
+
76
+ found.push({
77
+ line: line.number,
78
+ column: match.index,
79
+ text: line.text,
80
+ lead: line.text.slice(0, match.index).replace(/^.*\s/, ''),
81
+ })
82
+ }
83
+ }
84
+
85
+ return found
86
+ }
@@ -1,5 +1,8 @@
1
+ import type { ColonFinding } from '@/markdown/colons'
2
+ import type { HeadingCaseFinding } from '@/markdown/heading-case'
1
3
  import type { LinkFinding } from '@/markdown/links'
2
4
  import type { BanFinding } from '@/markdown/scan'
5
+ import type { StackFinding } from '@/markdown/stacks'
3
6
  import type { StructureReport } from '@/markdown/structure'
4
7
 
5
8
  export interface GateInput {
@@ -16,6 +19,28 @@ export interface GateInput {
16
19
  * alone states the same rule and leaves no place to assert it.
17
20
  */
18
21
  readonly structure: readonly StructureReport[]
22
+ /**
23
+ * Every mid-sentence colon the run measured, read by nothing here.
24
+ *
25
+ * A colon is sometimes right and the corpus carries over a thousand
26
+ * candidates, so the measure warns and a push never fails on it.
27
+ */
28
+ readonly colons: readonly ColonFinding[]
29
+ /**
30
+ * Every heading or title with a capital after its first word, read by
31
+ * nothing here. A proper noun is the one case it cannot tell from title
32
+ * case, and gating it would need a names list and an escape marker.
33
+ */
34
+ readonly headingCase: readonly HeadingCaseFinding[]
35
+ /**
36
+ * Every run of plain bullets past the cap, read by nothing here.
37
+ *
38
+ * The stage that gates it is `bullet-stacks` in `src/gate/stages.ts`, which
39
+ * reads a scope of tracked files. This audit also runs on plans and
40
+ * groundwork records kept out of version control, where a long list is not
41
+ * a defect, which is the reason `documentCeiling` gates apart from it too.
42
+ */
43
+ readonly bulletStacks: readonly StackFinding[]
19
44
  }
20
45
 
21
46
  /**
@@ -0,0 +1,43 @@
1
+ import { bodyLines } from '@/markdown/scan'
2
+ import {
3
+ CHECKPOINTS,
4
+ type Checkpoints,
5
+ type LongBlock,
6
+ longBlocks,
7
+ } from '@/markdown/structure'
8
+
9
+ /**
10
+ * Reduces text to the words a reader meets, so a block reflowed, re-marked, or
11
+ * moved under another heading still matches the copy it came from.
12
+ *
13
+ * List markers and emphasis marks are dropped because turning a bullet into a
14
+ * labelled bullet or a paragraph changes the shape of a statement and none of
15
+ * its words, which is the edit the sweep makes. A word changed is a text
16
+ * changed, and that is what the growth stage exists to see.
17
+ */
18
+ export function normalizeProse(text: string): string {
19
+ return text
20
+ .replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
21
+ .replace(/[*_]/g, '')
22
+ .replace(/\s+/g, ' ')
23
+ .trim()
24
+ }
25
+
26
+ /**
27
+ * The paragraphs and top-level bullets of `source` past their checkpoints whose
28
+ * text appears nowhere in `base`.
29
+ *
30
+ * Containment rather than line matching, so an unchanged long block passes
31
+ * wherever it sits and an edited or new one fails. `base` is every document the
32
+ * caller counts as the copy it may have come from, already normalized, since a
33
+ * block moved into a sibling entry is as unchanged as one left in place.
34
+ */
35
+ export function grownBlocks(
36
+ source: string,
37
+ base: string,
38
+ checkpoints: Checkpoints = CHECKPOINTS,
39
+ ): LongBlock[] {
40
+ return longBlocks(bodyLines(source), checkpoints).filter(
41
+ (block) => !base.includes(normalizeProse(block.text)),
42
+ )
43
+ }
@@ -0,0 +1,74 @@
1
+ import { bodyLines, maskDisplayed } from '@/markdown/scan'
2
+
3
+ export interface HeadingCaseFinding {
4
+ readonly line: number
5
+ /** The heading or title as written, markers removed. */
6
+ readonly text: string
7
+ /** Every word after the first that opens on a capital. */
8
+ readonly words: readonly string[]
9
+ }
10
+
11
+ const HEADING = /^#{1,6}\s+(.*?)(?:\s+#+)?\s*$/
12
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/
13
+ const TITLE = /^title:\s*(["']?)(.*?)\1\s*$/
14
+ const CODE_SPAN = /(`+)(?:(?!\1).)*\1/g
15
+ const CAPITALIZED = /^[A-Z][a-z]/
16
+
17
+ /**
18
+ * The words of `text` that break sentence case.
19
+ *
20
+ * A code span stands for one lowercase word, so a heading opening on one
21
+ * counts that span as its first word. The first word with a letter in it is
22
+ * exempt, which keeps a leading numeral from promoting the next word.
23
+ */
24
+ function capitalizedAfterFirst(text: string): string[] {
25
+ const masked = maskDisplayed(text.replace(CODE_SPAN, 'code'))
26
+ .replace(/\s+/g, ' ')
27
+ .trim()
28
+ const words = masked === '' ? [] : masked.split(' ')
29
+ const flagged: string[] = []
30
+ let seenFirst = false
31
+
32
+ for (const raw of words) {
33
+ const word = raw.replace(/^[^\p{L}]+/u, '')
34
+ if (!/\p{L}/u.test(raw)) continue
35
+ if (!seenFirst) {
36
+ seenFirst = true
37
+ continue
38
+ }
39
+ if (CAPITALIZED.test(word)) flagged.push(word)
40
+ }
41
+
42
+ return flagged
43
+ }
44
+
45
+ /**
46
+ * Finds a heading or a frontmatter `title` carrying a capitalized word after
47
+ * its first, outside code spans and all-caps tokens.
48
+ *
49
+ * A proper noun is the one case it cannot tell from title case, so the finding
50
+ * is a warning and no names list backs it.
51
+ */
52
+ export function findHeadingCase(source: string): HeadingCaseFinding[] {
53
+ const found: HeadingCaseFinding[] = []
54
+ const check = (line: number, text: string): void => {
55
+ const words = capitalizedAfterFirst(text)
56
+ if (words.length > 0) found.push({ line, text, words })
57
+ }
58
+
59
+ const frontmatter = FRONTMATTER.exec(source)
60
+ if (frontmatter) {
61
+ for (const [index, text] of frontmatter[1].split('\n').entries()) {
62
+ const title = TITLE.exec(text)
63
+ if (title) check(index + 2, title[2])
64
+ }
65
+ }
66
+
67
+ for (const line of bodyLines(source)) {
68
+ if (line.fenced) continue
69
+ const heading = HEADING.exec(line.text)
70
+ if (heading) check(line.number, heading[1])
71
+ }
72
+
73
+ return found
74
+ }
@@ -0,0 +1,74 @@
1
+ import type { BodyLine } from '@/markdown/scan'
2
+
3
+ /**
4
+ * The most plain bullets one run may carry. Past it a reader holds the list as
5
+ * a whole rather than item by item, and the shape wants prose, an H3 per item,
6
+ * or a split at the seam.
7
+ */
8
+ export const CAP = 5
9
+
10
+ /** A top-level bullet marker followed by its content, at column zero only. */
11
+ const BULLET = /^[-*+][ \t]+(.*)$/
12
+
13
+ /**
14
+ * An item opening with a bold label or a link names its own subject. A task
15
+ * box (`[ ]`, `[x]`) opens with a bracket too and is not a link, so a checklist
16
+ * counts as a plain run.
17
+ */
18
+ const LABELED = /^(?:\*\*|__|\[(?![ xX]\]))/
19
+
20
+ export interface StackFinding {
21
+ /** The line of the run's first item, as an editor numbers it. */
22
+ readonly line: number
23
+ readonly count: number
24
+ }
25
+
26
+ interface Run {
27
+ readonly line: number
28
+ count: number
29
+ labeled: number
30
+ }
31
+
32
+ /**
33
+ * Every run of top-level bullets past `CAP`, in document order.
34
+ *
35
+ * A run is consecutive column-zero `-`, `*`, or `+` items. A blank line, an
36
+ * indented continuation, and a nested item do not break it, and any other
37
+ * column-zero line does, since a paragraph or heading between two lists makes
38
+ * them two. Fenced lines are skipped rather than breaking the run, so a code
39
+ * block inside an item does not split it. A run whose every item opens with a
40
+ * bold label or a link is exempt, because each item carries its own subject
41
+ * and reads as a table of entries rather than a list to hold.
42
+ *
43
+ * Numbered items do not count: a numbered list states an order the reader
44
+ * follows one step at a time.
45
+ */
46
+ export function bulletStacks(lines: readonly BodyLine[]): StackFinding[] {
47
+ const found: StackFinding[] = []
48
+ let run: Run | undefined
49
+
50
+ const close = (): void => {
51
+ if (run && run.count > CAP && run.labeled < run.count) {
52
+ found.push({ line: run.line, count: run.count })
53
+ }
54
+ run = undefined
55
+ }
56
+
57
+ for (const { number, text, fenced } of lines) {
58
+ if (fenced) continue
59
+ if (text.trim() === '' || /^\s/.test(text)) continue
60
+
61
+ const match = BULLET.exec(text)
62
+ if (!match) {
63
+ close()
64
+ continue
65
+ }
66
+
67
+ run ??= { line: number, count: 0, labeled: 0 }
68
+ run.count++
69
+ if (LABELED.test(match[1])) run.labeled++
70
+ }
71
+
72
+ close()
73
+ return found
74
+ }
@@ -109,7 +109,7 @@ const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“(\[]*[A-Z]|`)|\s*$)/g
109
109
  export const CHECKPOINTS = {
110
110
  run: 40,
111
111
  peerBullet: 130,
112
- bullet: 400,
112
+ bullet: 250,
113
113
  paragraph: 700,
114
114
  sentences: 4,
115
115
  renderWidth: 80,
@@ -427,8 +427,28 @@ export function heavyBullets(
427
427
  lines: readonly BodyLine[],
428
428
  checkpoints: Checkpoints,
429
429
  ): BulletFinding[] {
430
- const findings: BulletFinding[] = []
431
- let open: BulletFinding | null = null
430
+ return foldBullets(lines, checkpoints).map(({ line, characters }) => ({
431
+ line,
432
+ characters,
433
+ }))
434
+ }
435
+
436
+ interface FoldedBullet extends BulletFinding {
437
+ /** The bullet's source lines joined by the space a wrap would have carried. */
438
+ readonly text: string
439
+ }
440
+
441
+ /**
442
+ * The walk `heavyBullets` reports from, keeping each past-checkpoint bullet's
443
+ * text so `longBlocks` can ask whether a base copy of it exists. One walk
444
+ * rather than two, for the reason `paragraphBlocks` is shared.
445
+ */
446
+ function foldBullets(
447
+ lines: readonly BodyLine[],
448
+ checkpoints: Checkpoints,
449
+ ): FoldedBullet[] {
450
+ const findings: FoldedBullet[] = []
451
+ let open: FoldedBullet | null = null
432
452
 
433
453
  const close = (): void => {
434
454
  if (open && open.characters > checkpoints.bullet) findings.push(open)
@@ -450,7 +470,11 @@ export function heavyBullets(
450
470
  if (item) {
451
471
  close()
452
472
  if (item[1].length === 0) {
453
- open = { line: line.number, characters: visibleText(text).length }
473
+ open = {
474
+ line: line.number,
475
+ characters: visibleText(text).length,
476
+ text,
477
+ }
454
478
  }
455
479
  continue
456
480
  }
@@ -466,6 +490,7 @@ export function heavyBullets(
466
490
  open = {
467
491
  ...open,
468
492
  characters: open.characters + visibleText(text).length + 1,
493
+ text: `${open.text} ${text}`,
469
494
  }
470
495
  }
471
496
 
@@ -581,6 +606,49 @@ export function heavyParagraphs(
581
606
  return findings
582
607
  }
583
608
 
609
+ export interface LongBlock {
610
+ readonly kind: 'paragraph' | 'bullet'
611
+ readonly line: number
612
+ readonly characters: number
613
+ /** The block's source lines folded into one, as the paragraph and bullet folds read it. */
614
+ readonly text: string
615
+ }
616
+
617
+ /**
618
+ * The paragraphs past the paragraph checkpoint and the top-level bullets past
619
+ * the bullet checkpoint, each with its text.
620
+ *
621
+ * Weight alone, never the sentence cap, since the growth stage asks about the
622
+ * two numbers the standard states in characters. The definitions are the
623
+ * audit's own, so a block this names and a block the weight report names are
624
+ * one set.
625
+ */
626
+ export function longBlocks(
627
+ lines: readonly BodyLine[],
628
+ checkpoints: Checkpoints,
629
+ ): LongBlock[] {
630
+ const blocks: LongBlock[] = []
631
+
632
+ for (const block of paragraphBlocks(lines)) {
633
+ const text = paragraphText(block)
634
+ const characters = visibleText(text).length
635
+ if (characters > checkpoints.paragraph) {
636
+ blocks.push({
637
+ kind: 'paragraph',
638
+ line: block[0].number,
639
+ characters,
640
+ text,
641
+ })
642
+ }
643
+ }
644
+
645
+ for (const bullet of foldBullets(lines, checkpoints)) {
646
+ blocks.push({ kind: 'bullet', ...bullet })
647
+ }
648
+
649
+ return blocks.sort((a, b) => a.line - b.line)
650
+ }
651
+
584
652
  /** Punctuation either side of a word, so an opener is compared on its letters. */
585
653
  const WORD_EDGE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu
586
654
 
@@ -34,12 +34,12 @@ export const RENUMBERED_RULES: Readonly<Record<string, string>> = {
34
34
  }
35
35
 
36
36
  /**
37
- * Band folders the walk skips by name rather than by content, so the
38
- * exclusion holds even for a band the toolkit has not shipped yet.
37
+ * Rule folders the walk skips by name rather than by content, so the
38
+ * exclusion holds even for a folder the toolkit has not shipped yet.
39
39
  * `canon/` and `internal/` are already-migrated destinations, and
40
40
  * `project/` is a target's own rules, which this verb never touches.
41
41
  */
42
- const EXCLUDED_BANDS: readonly string[] = ['project', 'canon', 'internal']
42
+ const EXCLUDED_FOLDERS: readonly string[] = ['project', 'canon', 'internal']
43
43
 
44
44
  /** One flat rule file, before classification. */
45
45
  export interface FlatRuleFile {
@@ -57,9 +57,9 @@ export function flatRulesRoot(target: string): string {
57
57
  }
58
58
 
59
59
  /**
60
- * Every rule file sitting directly under a flat band folder. One level for
61
- * the band and one glob below it for `*.md`, since `governance/rules/` never
62
- * nests a rule deeper than its band, and the flat layout this verb migrates
60
+ * Every rule file sitting directly under a flat rule folder. One level for
61
+ * the folder and one glob below it for `*.md`, since `governance/rules/` never
62
+ * nests a rule deeper than its folder, and the flat layout this verb migrates
63
63
  * off of mirrors that depth.
64
64
  */
65
65
  export async function walkFlatRules(target: string): Promise<FlatRuleFile[]> {
@@ -69,20 +69,20 @@ export async function walkFlatRules(target: string): Promise<FlatRuleFile[]> {
69
69
  )
70
70
  if (entries === undefined) return []
71
71
 
72
- const bands = entries.filter(
73
- (entry) => entry.isDirectory() && !EXCLUDED_BANDS.includes(entry.name),
72
+ const folders = entries.filter(
73
+ (entry) => entry.isDirectory() && !EXCLUDED_FOLDERS.includes(entry.name),
74
74
  )
75
75
 
76
76
  const files: FlatRuleFile[] = []
77
- for (const band of bands) {
77
+ for (const folder of folders) {
78
78
  const glob = new Bun.Glob('*.md')
79
79
  for (const name of glob.scanSync({
80
- cwd: join(root, band.name),
80
+ cwd: join(root, folder.name),
81
81
  onlyFiles: true,
82
82
  })) {
83
83
  files.push({
84
- rel: relPath('.claude', 'rules', band.name, name),
85
- subdir: band.name,
84
+ rel: relPath('.claude', 'rules', folder.name, name),
85
+ subdir: folder.name,
86
86
  name: name.slice(0, -'.md'.length),
87
87
  })
88
88
  }
package/src/pr/frames.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
 
3
3
  /** The branch holding review frames. It never merges into a branch anyone checks out. */
4
- export const FRAMES_BRANCH = 'canon-frames'
4
+ export const FRAMES_BRANCH = 'canon/frames'
5
+
6
+ /** The branch frames lived on before the rename. A drop and a prune drain it and an add never writes it. */
7
+ export const LEGACY_FRAMES_BRANCH = 'canon-frames'
5
8
 
6
9
  /** The eight bytes every PNG opens with. A frame that lacks them embeds as a broken image. */
7
10
  const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
@@ -94,6 +97,7 @@ export type DropResult =
94
97
  readonly removed: readonly number[]
95
98
  readonly commit?: string
96
99
  readonly deleted?: boolean
100
+ readonly legacy?: true
97
101
  }
98
102
  | { readonly kind: 'refused'; readonly reason: FramesRefusal }
99
103
 
@@ -105,6 +109,7 @@ export type PruneResult =
105
109
  readonly unread: readonly number[]
106
110
  readonly commit?: string
107
111
  readonly deleted?: boolean
112
+ readonly legacy?: true
108
113
  }
109
114
  | { readonly kind: 'refused'; readonly reason: FramesRefusal }
110
115
 
@@ -341,3 +346,63 @@ export async function pruneFrames(
341
346
  unread: unread.sort((a, b) => a - b),
342
347
  }
343
348
  }
349
+
350
+ function mergeNumbers(
351
+ first: readonly number[],
352
+ second: readonly number[],
353
+ ): number[] {
354
+ return [...new Set([...first, ...second])].sort((a, b) => a - b)
355
+ }
356
+
357
+ /** Whether a drain pass changed the legacy ref, by removing a pull request or deleting it. */
358
+ function touchedLegacy(result: DropResult): boolean {
359
+ return (
360
+ result.kind === 'dropped' &&
361
+ (result.removed.length > 0 || result.deleted === true)
362
+ )
363
+ }
364
+
365
+ /**
366
+ * Drops on the current branch, then on the legacy one. The legacy pass only
367
+ * removes, and a refusal there never fails a drop the current branch already
368
+ * finished, since a target that never held the legacy ref reads it as missing
369
+ * and a token may be unable to read it at all.
370
+ */
371
+ export async function dropFramesWithLegacy(
372
+ api: FramesApi,
373
+ legacy: FramesApi,
374
+ numbers: readonly number[],
375
+ ): Promise<DropResult> {
376
+ const current = await dropFrames(api, numbers)
377
+ if (current.kind === 'refused') return current
378
+ const drained = await dropFrames(legacy, numbers)
379
+ if (drained.kind === 'refused' || !touchedLegacy(drained)) return current
380
+ return {
381
+ ...current,
382
+ removed: mergeNumbers(current.removed, drained.removed),
383
+ legacy: true,
384
+ }
385
+ }
386
+
387
+ /** The prune counterpart of `dropFramesWithLegacy`, with the same legacy semantics. */
388
+ export async function pruneFramesWithLegacy(
389
+ api: FramesApi,
390
+ legacy: FramesApi,
391
+ days: number,
392
+ now: Date,
393
+ ): Promise<PruneResult> {
394
+ const current = await pruneFrames(api, days, now)
395
+ if (current.kind === 'refused') return current
396
+ const drained = await pruneFrames(legacy, days, now)
397
+ if (drained.kind === 'refused') return current
398
+ const touched = drained.removed.length > 0 || drained.deleted === true
399
+ const kept = mergeNumbers(current.kept, drained.kept)
400
+ const unread = mergeNumbers(current.unread, drained.unread)
401
+ return {
402
+ ...current,
403
+ removed: mergeNumbers(current.removed, drained.removed),
404
+ kept,
405
+ unread,
406
+ ...(touched && { legacy: true as const }),
407
+ }
408
+ }
@@ -54,7 +54,7 @@ export const REFERENCE_MARKER = 'canon-allow-reference'
54
54
  * already ships `anthropics/claude-code#58345` in exactly that form.
55
55
  *
56
56
  * The trailing boundary is what the first shape of this pattern lacked, and it
57
- * costs a live finding to omit. `governance/rules/framework/250-tailwind.md`
57
+ * costs a live finding to omit. `governance/rules/framework/tailwind.md`
58
58
  * writes `bg-[#316ff6]`, a Tailwind arbitrary hex color, which an unbounded
59
59
  * `#[0-9]+` reads as pull request 316. Requiring a non-word character after the
60
60
  * digits rejects it and rejects nothing real, since a citation is always
@@ -171,20 +171,20 @@ const STANDARDS_CALL = /\bcanon standards(?![\w-])/g
171
171
  const PHASE_LABEL = /\bv\d+\.\d+(?!\.\d)\b/g
172
172
 
173
173
  /**
174
- * A `.claude/rules/<segments>/<nnn>-<slug>.md` citation from a body under
175
- * `claude/skills/`, the installed-path spelling `598-authoring-layout.md`
176
- * bans a shipped skill body from citing as authority for its own behavior.
174
+ * A `.claude/rules/<segments>/<slug>.md` citation from a body under
175
+ * `claude/skills/`, the installed-path spelling `authoring-layout.md` bans a
176
+ * shipped skill body from citing as authority for its own behavior.
177
177
  *
178
- * Anchored on the trailing `\d{3}-[\w-]+\.md` rather than on the bare
179
- * `.claude/rules/` prefix, which is what keeps a folder mention carrying no
180
- * number, such as `create-rule`, `target-check`, and `target-setup` already
181
- * write correctly, from matching. The segment group between `rules/` and the
182
- * numbered file admits both a governance-namespace path
183
- * (`canon/canon/604-scratch.md`) and a project-namespace one
184
- * (`project/<subdir>/<n>-<slug>.md`) without distinguishing them, since
185
- * either shape is the same broken citation.
178
+ * Anchored on a trailing `.md` file rather than on the bare `.claude/rules/`
179
+ * prefix, which is what keeps a folder mention, such as `create-rule`,
180
+ * `target-check`, and `target-setup` write, from matching: it ends in `/` or
181
+ * names no `.md`. The segments between `rules/` and the file admit both a
182
+ * governance-namespace path (`canon/canon/scratch.md`) and a
183
+ * project-namespace one (`project/<subdir>/<slug>.md`) without distinguishing
184
+ * them, since either shape is the same broken citation, and the placeholder
185
+ * check spares the bracketed illustration.
186
186
  */
187
- const RULE_PATH = /(?<![\w./-])\.claude\/rules\/[^\s`)\]]*\/\d{3}-[\w-]+\.md\b/g
187
+ const RULE_PATH = /(?<![\w./-])\.claude\/rules\/[^\s`)\]]*?[\w-]+\.md\b/g
188
188
 
189
189
  export interface ShippedReference {
190
190
  readonly file: string
@@ -246,7 +246,7 @@ function isDocsPathReportable(
246
246
  * Whether `file` sits in the one corpus `RULE_PATH` gates: a shipped skill
247
247
  * body, minus its own `REQUIREMENT.md`.
248
248
  *
249
- * The ban lives in `598-authoring-layout.md`, stated for one reader: a
249
+ * The ban lives in `authoring-layout.md`, stated for one reader: a
250
250
  * session loading the `SKILL.md` body a target actually receives.
251
251
  * `REQUIREMENT.md` is excluded for the reason that file states there, since a
252
252
  * maintainer or an audit command reads it rather than a session loading it,