@erclx/canon 5.36.0 → 5.38.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 (165) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/SKILL.md +6 -0
  3. package/claude/skills/auto-ship/SKILL.md +1 -1
  4. package/claude/skills/backlog-triage/SKILL.md +8 -8
  5. package/claude/skills/build-in-slices/SKILL.md +7 -1
  6. package/claude/skills/build-in-slices/references/adopted.md +3 -0
  7. package/claude/skills/canon-cli/SKILL.md +3 -0
  8. package/claude/skills/canon-feedback/SKILL.md +5 -0
  9. package/claude/skills/canon-feedback-triage/SKILL.md +3 -0
  10. package/claude/skills/canvas-board/SKILL.md +3 -0
  11. package/claude/skills/canvas-board/references/canvas.md +12 -0
  12. package/claude/skills/ci-workflow/SKILL.md +5 -0
  13. package/claude/skills/code-craft/SKILL.md +6 -0
  14. package/claude/skills/context-fold/references/context.md +48 -34
  15. package/claude/skills/create-rule/SKILL.md +11 -9
  16. package/claude/skills/create-rule/references/rule.md +6 -0
  17. package/claude/skills/create-skill/SKILL.md +11 -2
  18. package/claude/skills/create-skill/references/skill-disclosure.md +26 -0
  19. package/claude/skills/create-skill/references/skill-paths.md +3 -0
  20. package/claude/skills/create-skill/references/skill-reference.md +12 -0
  21. package/claude/skills/create-skill/references/skill.md +25 -24
  22. package/claude/skills/deprecation-migration/SKILL.md +6 -0
  23. package/claude/skills/deprecation-migration/references/adopted.md +3 -0
  24. package/claude/skills/design-taste/SKILL.md +6 -0
  25. package/claude/skills/design-taste/references/craft.md +3 -0
  26. package/claude/skills/design-taste/references/preflight.md +16 -0
  27. package/claude/skills/docs-sync/references/docs.md +6 -0
  28. package/claude/skills/docs-sync/references/readme.md +15 -0
  29. package/claude/skills/draft-and-pick/SKILL.md +12 -0
  30. package/claude/skills/draft-doc/references/context.md +1 -1
  31. package/claude/skills/draft-doc/references/readme.md +3 -0
  32. package/claude/skills/draft-figure/references/mermaid.md +6 -0
  33. package/claude/skills/draft-slides/SKILL.md +3 -0
  34. package/claude/skills/git-branch/references/branch.md +16 -11
  35. package/claude/skills/git-commit/references/commit.md +19 -11
  36. package/claude/skills/git-commit/references/versioning.md +9 -0
  37. package/claude/skills/git-issue/references/issue.md +13 -9
  38. package/claude/skills/git-pr/SKILL.md +5 -0
  39. package/claude/skills/git-pr/references/pr.md +24 -6
  40. package/claude/skills/git-ship/SKILL.md +3 -0
  41. package/claude/skills/git-split/SKILL.md +6 -0
  42. package/claude/skills/git-stage/SKILL.md +3 -0
  43. package/claude/skills/git-worktree/SKILL.md +6 -6
  44. package/claude/skills/markdown-craft/references/markdown.md +25 -6
  45. package/claude/skills/plan-feature/SKILL.md +9 -0
  46. package/claude/skills/plan-feature/references/plan.md +20 -8
  47. package/claude/skills/plan-groundwork/SKILL.md +6 -0
  48. package/claude/skills/plan-groundwork/references/groundwork.md +61 -29
  49. package/claude/skills/plan-intake/references/intake.md +6 -6
  50. package/claude/skills/read-frames/SKILL.md +3 -0
  51. package/claude/skills/review-craft/SKILL.md +3 -0
  52. package/claude/skills/review-pr/references/post.md +1 -1
  53. package/claude/skills/review-ui/SKILL.md +6 -3
  54. package/claude/skills/role-orchestrator/SKILL.md +4 -1
  55. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +7 -4
  56. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  57. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +3 -0
  58. package/claude/skills/role-planner/SKILL.md +4 -1
  59. package/claude/skills/role-worker/SKILL.md +13 -1
  60. package/claude/skills/search-craft/SKILL.md +3 -0
  61. package/claude/skills/systematic-debugging/SKILL.md +6 -0
  62. package/claude/skills/target-check/references/domains.md +5 -0
  63. package/claude/skills/target-setup/SKILL.md +8 -4
  64. package/claude/skills/target-setup/references/verify.md +6 -5
  65. package/claude/skills/task-board/references/tasks.md +12 -7
  66. package/claude/skills/task-complete/references/archive.md +3 -0
  67. package/claude/skills/teach-workspace/SKILL.md +3 -0
  68. package/claude/skills/teach-workspace/references/glossary.md +3 -0
  69. package/claude/skills/teach-workspace/references/lesson-build.md +3 -0
  70. package/claude/skills/teach-workspace/references/lesson-craft.md +3 -0
  71. package/claude/skills/teach-workspace/references/teach.md +8 -8
  72. package/claude/skills/test-craft/SKILL.md +5 -0
  73. package/claude/skills/video-craft/SKILL.md +6 -0
  74. package/claude/skills/write-human/SKILL.md +9 -0
  75. package/docs/agents/capture.md +2 -0
  76. package/docs/agents/commands.md +54 -54
  77. package/docs/agents/context-audit-checks.md +6 -0
  78. package/docs/agents/context-audit.md +8 -8
  79. package/docs/agents/context-classify.md +2 -0
  80. package/docs/agents/drift-surfaces.md +5 -5
  81. package/docs/agents/gate.md +6 -7
  82. package/docs/agents/index.md +1 -1
  83. package/docs/agents/install-and-sync.md +2 -2
  84. package/docs/agents/markdown-audit-weight.md +11 -3
  85. package/docs/agents/markdown-audit.md +2 -2
  86. package/docs/agents/pr-frames.md +17 -3
  87. package/docs/agents/sessions-transfer.md +8 -6
  88. package/docs/agents/skills-audit.md +9 -1
  89. package/docs/target/projects.md +1 -1
  90. package/docs/target/scenarios.md +2 -2
  91. package/docs/target/standards-moved.md +20 -18
  92. package/docs/workflow/ai-workflow.md +3 -0
  93. package/docs/workflow/visual-design-workflow.md +5 -0
  94. package/governance/detect.toml +6 -1
  95. package/package.json +7 -4
  96. package/scripts/core/install-check.sh +1 -1
  97. package/src/cli/help.ts +1 -1
  98. package/src/commands/claude.ts +0 -1
  99. package/src/commands/context.ts +29 -4
  100. package/src/commands/gate.ts +1 -8
  101. package/src/commands/markdown.ts +52 -0
  102. package/src/commands/pr.ts +18 -11
  103. package/src/commands/records.ts +0 -1
  104. package/src/context/audit.ts +83 -0
  105. package/src/context/classify/prompts.ts +6 -1
  106. package/src/context/gate.ts +6 -3
  107. package/src/docs/read.ts +15 -21
  108. package/src/gate/measures.ts +186 -1
  109. package/src/gate/sequencer.ts +0 -4
  110. package/src/gate/stages.ts +46 -27
  111. package/src/git/files.ts +30 -0
  112. package/src/gov/adapter.ts +2 -2
  113. package/src/gov/install.ts +2 -2
  114. package/src/gov/list.ts +1 -1
  115. package/src/gov/payload.ts +1 -1
  116. package/src/gov/stacks.ts +1 -1
  117. package/src/markdown/gate.ts +10 -0
  118. package/src/markdown/growth.ts +43 -0
  119. package/src/markdown/stacks.ts +74 -0
  120. package/src/markdown/structure.ts +73 -5
  121. package/src/migrate/rule-layout.ts +12 -12
  122. package/src/pr/frames.ts +66 -1
  123. package/src/sandbox/equivalence.ts +7 -9
  124. package/src/sync/check.ts +23 -23
  125. package/src/teach/browser/head.ts +2 -2
  126. package/src/teach/browser/sidebar.ts +3 -3
  127. package/src/teach/browser/theme.ts +2 -2
  128. package/src/tooling/inject.ts +2 -2
  129. package/src/tooling/package.ts +15 -18
  130. package/src/tooling/verify-stack.ts +6 -3
  131. package/tooling/astro/configs/.oxfmtrc.json +8 -0
  132. package/tooling/astro/configs/.prettierrc +13 -0
  133. package/tooling/astro/configs/tsconfig.json +1 -1
  134. package/tooling/astro/manifest.toml +13 -0
  135. package/tooling/astro/reference.md +17 -7
  136. package/tooling/base/configs/.oxfmtrc.json +7 -0
  137. package/tooling/base/configs/scripts/verify.json +1 -1
  138. package/tooling/base/configs/scripts/verify.ts +3 -9
  139. package/tooling/base/manifest.toml +6 -6
  140. package/tooling/base/reference.md +20 -17
  141. package/tooling/base/seeds/.lintstagedrc +1 -3
  142. package/tooling/base/seeds/canon/context/ci.md +7 -7
  143. package/tooling/base/seeds/canon/context/development.md +10 -10
  144. package/tooling/claude/manifest.toml +5 -1
  145. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +8 -0
  146. package/tooling/go/reference.md +2 -2
  147. package/tooling/nextjs/configs/.oxlintrc.json +223 -0
  148. package/tooling/nextjs/manifest.toml +2 -5
  149. package/tooling/nextjs/reference.md +4 -4
  150. package/tooling/php/manifest.toml +6 -1
  151. package/tooling/php/reference.md +2 -2
  152. package/tooling/python/manifest.toml +6 -1
  153. package/tooling/python/reference.md +4 -4
  154. package/tooling/vite-react/configs/tsconfig.json +1 -1
  155. package/tooling/vite-react/configs/vitest.config.ts +1 -1
  156. package/tooling/vite-react/manifest.toml +1 -4
  157. package/tooling/vite-react/reference.md +5 -5
  158. package/tooling/web/configs/.oxlintrc.json +213 -0
  159. package/tooling/web/configs/src/test/setup.ts +1 -1
  160. package/tooling/web/manifest.toml +12 -16
  161. package/tooling/web/reference.md +15 -15
  162. package/tooling/base/configs/.prettierrc +0 -12
  163. package/tooling/base/seeds/.prettierignore +0 -0
  164. package/tooling/nextjs/configs/eslint.config.js +0 -91
  165. package/tooling/web/configs/eslint.config.js +0 -82
@@ -11,10 +11,12 @@ import {
11
11
  captureStamps,
12
12
  clientCommandCitations,
13
13
  designProse,
14
+ bulletStacks,
14
15
  documentCeiling,
15
16
  folderEchoedNames,
16
17
  markdownBans,
17
18
  type Measure,
19
+ paragraphGrowth,
18
20
  pluginManifests,
19
21
  readmeCitations,
20
22
  recordIdempotence,
@@ -81,11 +83,6 @@ export interface Stage {
81
83
  readonly scope?: RegExp
82
84
  /** What a scoped-out stage says, so a skip never reads as a pass. */
83
85
  readonly skipped?: string
84
- /**
85
- * Whether this stage belongs in the run at all, read off the write grant
86
- * rather than the changed set. Absent means it always belongs.
87
- */
88
- readonly when?: (options: { readonly write: boolean }) => boolean
89
86
  readonly checks: readonly Check[]
90
87
  /**
91
88
  * The machine lock this stage holds while its checks run, so one box runs it
@@ -144,28 +141,15 @@ const script = (name: string, failure: string): Check => ({
144
141
  * whole set at once.
145
142
  */
146
143
  export const STAGES: readonly Stage[] = [
147
- {
148
- id: 'format',
149
- label: 'Formatting',
150
- when: ({ write }) => write,
151
- checks: [
152
- {
153
- kind: 'command',
154
- argv: ['bun', 'run', 'format'],
155
- failure: 'Format failed',
156
- },
157
- ],
158
- success: 'Format applied',
159
- },
160
144
  {
161
145
  id: 'format-check',
162
146
  label: 'Format check',
163
- when: ({ write }) => !write,
164
147
  checks: [
165
148
  {
166
149
  kind: 'command',
167
150
  argv: ['bun', 'run', 'check:format'],
168
- failure: 'Format check failed',
151
+ failure:
152
+ 'Format check failed. Run bun run format and commit the result.',
169
153
  },
170
154
  ],
171
155
  success: 'Format check passed',
@@ -335,19 +319,23 @@ export const STAGES: readonly Stage[] = [
335
319
  checks: [{ kind: 'measure', measure: unreferencedRules }],
336
320
  },
337
321
  {
338
- // Only the citation half of the audit gates. Its length, depth, table, and
339
- // index findings are judgment thresholds, and failing a push on one would
340
- // make the stage something to route around. The one length reading that
341
- // gates is the whole-document ceiling, which the Document ceiling stage
342
- // below owns rather than this audit.
322
+ // The facts the audit can answer from the files gate: a cited path that
323
+ // does not resolve, a required section an entry does not declare, an H2
324
+ // outside the closed set of four, and an index disagreeing with its folder.
325
+ // Its length, depth, bullet, table, and provenance findings are judgment
326
+ // thresholds, and failing a push on one would make the stage something to
327
+ // route around. The length readings that gate sit in their own stages
328
+ // below, apart from the audit's exit code: the whole-document ceiling, the
329
+ // bullet cap, and the growth of a paragraph or a bullet past its
330
+ // checkpoint.
343
331
  id: 'context-citations',
344
332
  label: 'Context citations',
345
333
  checks: [
346
334
  {
347
335
  kind: 'cli',
348
- argv: ['context', 'audit', '--citations-only'],
336
+ argv: ['context', 'audit', '--gate'],
349
337
  failure:
350
- 'A cited context path does not resolve. Run bun src/cli.ts context audit.',
338
+ 'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, Decisions, and Gotchas, or an index disagrees with its folder. Run bun src/cli.ts context audit --gate.',
351
339
  },
352
340
  ],
353
341
  success: 'Context citations resolve',
@@ -366,6 +354,23 @@ export const STAGES: readonly Stage[] = [
366
354
  label: 'Document ceiling',
367
355
  checks: [{ kind: 'measure', measure: documentCeiling }],
368
356
  },
357
+ {
358
+ // Unscoped, since any branch can add a bullet to any entry. A run of more
359
+ // than 5 plain bullets fails the push in the context entries, the docs
360
+ // pages, and the skill bodies and references. Apart from the audit's exit
361
+ // code for the reason the Document ceiling stage above states.
362
+ id: 'bullet-stacks',
363
+ label: 'Bullet stacks',
364
+ checks: [{ kind: 'measure', measure: bulletStacks }],
365
+ },
366
+ {
367
+ // Context entries only, where length drifts. A paragraph past 700
368
+ // characters or a top-level bullet past 250 fails when this branch added or
369
+ // changed it, and an unchanged one passes until it is edited.
370
+ id: 'paragraph-growth',
371
+ label: 'Paragraph growth',
372
+ checks: [{ kind: 'measure', measure: paragraphGrowth }],
373
+ },
369
374
  {
370
375
  // Unscoped, since any branch can add a file anywhere and one git ls-files
371
376
  // costs nothing. Reads paths rather than markdown, so it sits here rather
@@ -539,6 +544,20 @@ export const STAGES: readonly Stage[] = [
539
544
  ],
540
545
  success: 'Shell check passed',
541
546
  },
547
+ {
548
+ id: 'lint',
549
+ label: 'Lint',
550
+ // Unscoped, since a full run costs less than spawning the process and a
551
+ // scope is one more pattern to fall behind the extensions oxlint reads.
552
+ checks: [
553
+ {
554
+ kind: 'command',
555
+ argv: ['bun', 'run', 'check:lint'],
556
+ failure: 'Lint failed',
557
+ },
558
+ ],
559
+ success: 'Lint passed',
560
+ },
542
561
  {
543
562
  id: 'types',
544
563
  label: 'Types',
package/src/git/files.ts CHANGED
@@ -165,3 +165,33 @@ export async function listRepositoryFiles(
165
165
 
166
166
  return [...new Set([...tracked, ...untracked])].sort()
167
167
  }
168
+
169
+ /**
170
+ * A file as `ref` holds it, or undefined where the ref holds no such path or
171
+ * git cannot answer. A path absent at the ref and a failed read are the same
172
+ * answer for a caller asking what the copy before this branch said.
173
+ */
174
+ export async function readFileAtRef(
175
+ root: string,
176
+ ref: string,
177
+ rel: string,
178
+ ): Promise<string | undefined> {
179
+ return git(root, ['show', `${ref}:${rel}`])
180
+ }
181
+
182
+ /** Every path under `prefix` that `ref` holds, or undefined where git cannot answer. */
183
+ export async function listFilesAtRef(
184
+ root: string,
185
+ ref: string,
186
+ prefix: string,
187
+ ): Promise<string[] | undefined> {
188
+ const listed = await git(root, [
189
+ 'ls-tree',
190
+ '-r',
191
+ '--name-only',
192
+ ref,
193
+ '--',
194
+ prefix,
195
+ ])
196
+ return listed === undefined ? undefined : listed.split('\n').filter(Boolean)
197
+ }
@@ -98,8 +98,8 @@ export function createGovAdapter(root: string): SyncAdapter {
98
98
  }
99
99
 
100
100
  /**
101
- * Where the target already holds a rule, in whichever band folder. A
102
- * successor held outside the source's band stays where it is, the same way
101
+ * Where the target already holds a rule, in whichever rule folder. A
102
+ * successor held outside the source's folder stays where it is, the same way
103
103
  * `locateSource` syncs a moved rule in place, rather than gaining a second copy.
104
104
  *
105
105
  * Holding the predecessor is the whole entitlement test, so no stamped chain
@@ -28,7 +28,7 @@ export function canonRulesDir(target: string): string {
28
28
 
29
29
  /**
30
30
  * This repository's own toolkit-only rules, installed beside `canon/` rather
31
- * than interleaved into its bands. No target ever holds this folder.
31
+ * than interleaved into its folders. No target ever holds this folder.
32
32
  */
33
33
  export function installedInternalRulesDir(target: string): string {
34
34
  return join(installedRulesDir(target), 'internal')
@@ -134,7 +134,7 @@ export function lookupRules(
134
134
 
135
135
  /**
136
136
  * Copies each rule into the subdirectory it was authored in, so the installed
137
- * tree keeps the band structure `governance/rules/` carries. Returns the
137
+ * tree keeps the rule folder structure `governance/rules/` carries. Returns the
138
138
  * target-relative paths the timeline prints.
139
139
  *
140
140
  * `destSubdir` picks which wrapper the rule lands under: `canon` for every
package/src/gov/list.ts CHANGED
@@ -74,7 +74,7 @@ export function buildStackEntries(root: string): StackEntry[] {
74
74
 
75
75
  /**
76
76
  * Reads the catalog straight off the source tree. A rule's domain is the
77
- * subdirectory it sits in, which is the band grouping install preserves, and
77
+ * subdirectory it sits in, which is the rule folder grouping install preserves, and
78
78
  * its `detect` entry is the evidence that proposes it, null for a rule
79
79
  * `detect.toml` does not name.
80
80
  */
@@ -5,7 +5,7 @@ import { stripFrontmatter } from '@/markdown/frontmatter'
5
5
  /**
6
6
  * Lists installed rules in the order the payload concatenates them. Sorting by
7
7
  * path keeps the numeric filename prefixes ordered inside each subdirectory,
8
- * which is how the rule bands are meant to read.
8
+ * which is how the rule folders are meant to read.
9
9
  */
10
10
  export function listRuleFiles(rulesDir: string): string[] {
11
11
  return [
package/src/gov/stacks.ts CHANGED
@@ -163,7 +163,7 @@ export function unreferencedRules(root: string): string[] {
163
163
  * does not hold. `resolveRules` already walks a stack's `extends` ancestors,
164
164
  * so reading its leaf entry is enough; no second walk resolves the chain
165
165
  * itself. A stack the toolkit no longer ships resolves to nothing rather than
166
- * throwing, the same way `readNewRules`'s band fallback already treats it.
166
+ * throwing, the same way `readNewRules`'s folder fallback already treats it.
167
167
  *
168
168
  * A held rule's declared successor counts as held, since the sync that
169
169
  * renames the old file installs it.
@@ -2,6 +2,7 @@ import type { ColonFinding } from '@/markdown/colons'
2
2
  import type { HeadingCaseFinding } from '@/markdown/heading-case'
3
3
  import type { LinkFinding } from '@/markdown/links'
4
4
  import type { BanFinding } from '@/markdown/scan'
5
+ import type { StackFinding } from '@/markdown/stacks'
5
6
  import type { StructureReport } from '@/markdown/structure'
6
7
 
7
8
  export interface GateInput {
@@ -31,6 +32,15 @@ export interface GateInput {
31
32
  * case, and gating it would need a names list and an escape marker.
32
33
  */
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[]
34
44
  }
35
45
 
36
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 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
+ }
@@ -86,7 +86,7 @@ const BLOCKQUOTE = /^\s*>/
86
86
  * follows, and the price is that a span opening a fragment mid-paragraph reads
87
87
  * as a sentence start.
88
88
  */
89
- const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“(\[]*[A-Z]|`)|\s*$)/g
89
+ const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“([]*[A-Z]|`)|\s*$)/g
90
90
 
91
91
  /**
92
92
  * Checkpoints the structural measures run against.
@@ -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
+ }
@@ -674,7 +674,7 @@ async function pool<T, R>(
674
674
  size: number,
675
675
  work: (item: T, index: number) => Promise<R>,
676
676
  ): Promise<R[]> {
677
- const results: R[] = new Array(items.length)
677
+ const results: R[] = Array.from({ length: items.length })
678
678
  let next = 0
679
679
 
680
680
  async function lane(): Promise<void> {
@@ -827,14 +827,12 @@ export async function runEquivalence(
827
827
  const records: ArmRecord[] = [
828
828
  ...offline,
829
829
  ...anchored,
830
- ...skipped.map(
831
- (arm): ArmRecord => ({
832
- arm: armKey(arm),
833
- state: 'skipped-anchor',
834
- differs: [],
835
- masked: [],
836
- }),
837
- ),
830
+ ...skipped.map((arm): ArmRecord => ({
831
+ arm: armKey(arm),
832
+ state: 'skipped-anchor',
833
+ differs: [],
834
+ masked: [],
835
+ })),
838
836
  ].sort((x, y) => x.arm.localeCompare(y.arm))
839
837
 
840
838
  const counts: Record<ArmState, number> = {