@erclx/canon 5.41.0 → 5.43.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 (220) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/references/adopted.md +34 -14
  3. package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
  4. package/claude/skills/auto-ship/SKILL.md +24 -4
  5. package/claude/skills/auto-ship/references/draft-mark.md +32 -6
  6. package/claude/skills/auto-ship/references/failure-recovery.md +15 -13
  7. package/claude/skills/auto-ship/references/tier-failures.md +31 -10
  8. package/claude/skills/auto-ship/references/ui-checklist.md +9 -4
  9. package/claude/skills/auto-ship/references/worktree-name.md +9 -3
  10. package/claude/skills/build-in-slices/references/adopted.md +35 -21
  11. package/claude/skills/canvas-board/references/canvas.md +17 -6
  12. package/claude/skills/canvas-board/references/design.md +26 -7
  13. package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
  14. package/claude/skills/ci-workflow/SKILL.md +12 -2
  15. package/claude/skills/ci-workflow/references/workflows.md +12 -9
  16. package/claude/skills/code-craft/references/adopted.md +58 -20
  17. package/claude/skills/codebase-layout/references/adopted.md +49 -16
  18. package/claude/skills/codebase-layout/references/frontend.md +11 -4
  19. package/claude/skills/codebase-layout/references/tests.md +4 -2
  20. package/claude/skills/context-fold/references/classify.md +10 -15
  21. package/claude/skills/context-fold/references/completion-report.md +11 -9
  22. package/claude/skills/context-fold/references/context-refresh.md +26 -9
  23. package/claude/skills/context-fold/references/unusable-baseline.md +5 -3
  24. package/claude/skills/create-rule/REQUIREMENT.md +5 -0
  25. package/claude/skills/create-rule/SKILL.md +10 -1
  26. package/claude/skills/create-rule/references/checklist.md +26 -0
  27. package/claude/skills/create-rule/references/rule.md +98 -20
  28. package/claude/skills/create-skill/SKILL.md +1 -0
  29. package/claude/skills/create-skill/references/skill-disclosure.md +10 -2
  30. package/claude/skills/create-skill/references/skill-frontmatter.md +43 -0
  31. package/claude/skills/create-skill/references/skill-paths.md +7 -4
  32. package/claude/skills/create-skill/references/skill-practice.md +6 -3
  33. package/claude/skills/create-skill/references/skill-requirement.md +4 -2
  34. package/claude/skills/create-skill/references/skill.md +67 -44
  35. package/claude/skills/decision-escalate/SKILL.md +1 -2
  36. package/claude/skills/deploy-app/references/vercel.md +2 -1
  37. package/claude/skills/deprecation-migration/references/adopted.md +44 -25
  38. package/claude/skills/design-taste/references/adopted.md +9 -1
  39. package/claude/skills/design-taste/references/craft.md +3 -1
  40. package/claude/skills/design-taste/references/kinds.md +8 -7
  41. package/claude/skills/design-taste/references/preflight.md +12 -0
  42. package/claude/skills/design-taste/references/systems.md +7 -6
  43. package/claude/skills/design-taste/references/tells.md +24 -25
  44. package/claude/skills/design-taste/references/vocabulary.md +7 -5
  45. package/claude/skills/document-health/references/axes.md +55 -30
  46. package/claude/skills/draft-and-pick/references/live-arms.md +2 -2
  47. package/claude/skills/draft-doc/references/context.md +4 -3
  48. package/claude/skills/draft-doc/references/docs.md +6 -3
  49. package/claude/skills/draft-doc/references/readme/agent-facing.md +6 -0
  50. package/claude/skills/draft-doc/references/readme/application.md +8 -2
  51. package/claude/skills/draft-doc/references/readme/cli.md +5 -1
  52. package/claude/skills/draft-doc/references/readme/library.md +4 -0
  53. package/claude/skills/draft-doc/references/readme/plugin.md +9 -1
  54. package/claude/skills/draft-doc/references/readme.md +30 -13
  55. package/claude/skills/draft-doc/references/requirements.md +17 -4
  56. package/claude/skills/draft-doc/references/wiki-kind.md +20 -10
  57. package/claude/skills/draft-doc/references/wiki.md +1 -1
  58. package/claude/skills/draft-figure/references/architecture-set.md +7 -3
  59. package/claude/skills/draft-figure/references/figures.md +8 -6
  60. package/claude/skills/draft-figure/references/hand-drawn.md +3 -1
  61. package/claude/skills/draft-figure/references/mermaid-path.md +19 -9
  62. package/claude/skills/draft-figure/references/mermaid.md +10 -5
  63. package/claude/skills/draft-ready/references/ready.md +22 -15
  64. package/claude/skills/git-branch/references/branch.md +11 -1
  65. package/claude/skills/git-branch/references/slug.md +17 -5
  66. package/claude/skills/git-commit/references/commit.md +9 -5
  67. package/claude/skills/git-commit/references/versioning.md +23 -17
  68. package/claude/skills/git-followup/REQUIREMENT.md +1 -1
  69. package/claude/skills/git-followup/SKILL.md +5 -6
  70. package/claude/skills/git-issue/references/issue.md +29 -16
  71. package/claude/skills/git-pr/REQUIREMENT.md +1 -5
  72. package/claude/skills/git-pr/SKILL.md +11 -22
  73. package/claude/skills/git-pr/references/final-command.md +21 -8
  74. package/claude/skills/git-pr/references/labels.md +45 -28
  75. package/claude/skills/git-pr/references/pr.md +29 -9
  76. package/claude/skills/git-pr/references/publish.md +42 -28
  77. package/claude/skills/git-pr/references/task-number.md +20 -8
  78. package/claude/skills/git-ship/SKILL.md +1 -1
  79. package/claude/skills/plan-feature/SKILL.md +1 -1
  80. package/claude/skills/plan-feature/references/plan-lifecycle.md +10 -3
  81. package/claude/skills/plan-feature/references/plan.md +58 -21
  82. package/claude/skills/plan-groundwork/references/groundwork-spikes.md +4 -1
  83. package/claude/skills/plan-groundwork/references/groundwork.md +14 -16
  84. package/claude/skills/plan-intake/references/intake.md +29 -24
  85. package/claude/skills/pr-evidence/REQUIREMENT.md +40 -0
  86. package/claude/skills/pr-evidence/SKILL.md +53 -0
  87. package/claude/skills/pr-evidence/references/evidence.md +150 -0
  88. package/claude/skills/review-address/references/post-review-findings.md +10 -3
  89. package/claude/skills/review-address/references/rebase-conflicts.md +15 -7
  90. package/claude/skills/review-address/references/rest-reads.md +3 -1
  91. package/claude/skills/review-craft/references/adopted.md +35 -18
  92. package/claude/skills/review-craft/references/security.md +42 -7
  93. package/claude/skills/review-craft/references/ui.md +23 -8
  94. package/claude/skills/review-pr/SKILL.md +6 -6
  95. package/claude/skills/review-pr/references/body-variants.md +29 -9
  96. package/claude/skills/review-pr/references/close-out.md +20 -5
  97. package/claude/skills/review-pr/references/key-changes.md +27 -3
  98. package/claude/skills/review-pr/references/post.md +83 -31
  99. package/claude/skills/review-pr/references/scope.md +54 -19
  100. package/claude/skills/review-ui/references/close-out.md +7 -5
  101. package/claude/skills/review-ui/references/driving.md +24 -4
  102. package/claude/skills/review-ui/references/post.md +43 -15
  103. package/claude/skills/role-orchestrator/SKILL.md +2 -0
  104. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +67 -17
  105. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +80 -40
  106. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +52 -15
  107. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +48 -26
  108. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +12 -13
  109. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +72 -36
  110. package/claude/skills/role-orchestrator/references/orchestrator-output.md +16 -4
  111. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +64 -24
  112. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +80 -26
  113. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +41 -18
  114. package/claude/skills/role-orchestrator/references/orchestrator-resume.md +19 -7
  115. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +22 -12
  116. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +15 -6
  117. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +25 -7
  118. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +34 -8
  119. package/claude/skills/role-orchestrator/scripts/poll.ts +16 -24
  120. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  121. package/claude/skills/role-planner/SKILL.md +3 -3
  122. package/claude/skills/role-worker/REQUIREMENT.md +3 -0
  123. package/claude/skills/role-worker/SKILL.md +34 -2
  124. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  125. package/claude/skills/search-craft/SKILL.md +2 -2
  126. package/claude/skills/search-craft/references/adopted.md +18 -6
  127. package/claude/skills/session-map/references/session.md +26 -7
  128. package/claude/skills/session-worktree/references/main-root-writes.md +25 -8
  129. package/claude/skills/systematic-debugging/references/adopted.md +30 -16
  130. package/claude/skills/target-check/references/domains.md +39 -11
  131. package/claude/skills/target-setup/references/detect.md +80 -65
  132. package/claude/skills/target-setup/references/indexes.md +8 -6
  133. package/claude/skills/task-board/references/board.md +86 -30
  134. package/claude/skills/task-board/references/decline.md +29 -13
  135. package/claude/skills/task-board/references/tasks-decline.md +12 -5
  136. package/claude/skills/task-board/references/tasks.md +65 -33
  137. package/claude/skills/task-complete/references/archive.md +41 -17
  138. package/claude/skills/task-complete/references/receipt-sweep.md +23 -9
  139. package/claude/skills/task-complete/references/unusable-baseline.md +10 -3
  140. package/claude/skills/teach-workspace/references/glossary.md +6 -2
  141. package/claude/skills/teach-workspace/references/lesson-build.md +44 -16
  142. package/claude/skills/teach-workspace/references/lesson-craft.md +12 -7
  143. package/claude/skills/teach-workspace/references/pedagogy.md +6 -3
  144. package/claude/skills/teach-workspace/references/promotion-fold.md +17 -11
  145. package/claude/skills/teach-workspace/references/promotion.md +31 -8
  146. package/claude/skills/teach-workspace/references/revisit.md +8 -2
  147. package/claude/skills/teach-workspace/references/teach.md +17 -9
  148. package/claude/skills/test-craft/references/adopted.md +48 -14
  149. package/claude/skills/test-craft/references/component.md +7 -4
  150. package/claude/skills/test-craft/references/e2e.md +25 -7
  151. package/claude/skills/test-craft/references/unit.md +3 -1
  152. package/claude/skills/test-first/references/adopted.md +12 -4
  153. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  154. package/claude/skills/ui-checklist/SKILL.md +1 -1
  155. package/claude/skills/video-craft/references/adopted.md +62 -18
  156. package/claude/skills/write-human/references/adopted.md +21 -8
  157. package/docs/agents/audits.md +1 -0
  158. package/docs/agents/migrate.md +11 -8
  159. package/docs/target/migrations.md +11 -0
  160. package/docs/workflow/parallel-features.md +1 -1
  161. package/docs/workflow/skill-map.md +1 -0
  162. package/governance/detect.toml +4 -0
  163. package/governance/rules/canon/context.md +2 -1
  164. package/governance/rules/canon/plan.md +3 -5
  165. package/governance/rules/canon/scratch.md +3 -1
  166. package/governance/rules/canon/task-scope.md +2 -1
  167. package/governance/rules/canon/tasks.md +2 -1
  168. package/governance/rules/canon/worktrees.md +5 -2
  169. package/governance/rules/claude/output.md +7 -4
  170. package/governance/rules/claude/skill.md +6 -4
  171. package/governance/rules/tooling/ci-workflow.md +6 -39
  172. package/governance/rules/ui/design-reference.md +37 -0
  173. package/governance/rules/writing/markdown.md +2 -1
  174. package/package.json +6 -6
  175. package/src/audits/catalog.ts +42 -0
  176. package/src/claude/cases/workflow.ts +5 -0
  177. package/src/claude/settings.ts +8 -4
  178. package/src/commands/claude.ts +11 -1
  179. package/src/commands/gov.ts +198 -0
  180. package/src/commands/migrate.ts +121 -2
  181. package/src/commands/pr.ts +146 -105
  182. package/src/commands/tooling.ts +3 -3
  183. package/src/commands/worktrees.ts +49 -0
  184. package/src/gate/stages.ts +15 -0
  185. package/src/gov/audit.ts +440 -0
  186. package/src/listeners/belongs.ts +21 -0
  187. package/src/listeners/machine.ts +93 -0
  188. package/src/listeners/parse.ts +41 -0
  189. package/src/markdown/scope.ts +144 -0
  190. package/src/migrate/session-defaults.ts +138 -0
  191. package/src/pr/lift.ts +6 -3
  192. package/src/pr/local.ts +4 -69
  193. package/src/pr/review-state.ts +182 -0
  194. package/src/sync/check.ts +15 -0
  195. package/src/tooling/stamp.ts +56 -12
  196. package/src/worktrees/stop.ts +92 -0
  197. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
  198. package/tooling/base/configs/commitlint.config.js +0 -20
  199. package/tooling/base/reference.md +1 -1
  200. package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
  201. package/tooling/claude/reference.md +13 -7
  202. package/tooling/claude/seeds/.claude/settings.json +4 -0
  203. package/tooling/claude/user/settings.template.json +0 -4
  204. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
  205. package/tooling/public/configs/.github/dependabot.yml +12 -0
  206. package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
  207. package/tooling/public/manifest.toml +8 -0
  208. package/tooling/public/reference.md +53 -0
  209. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
  210. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
  211. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
  212. package/tooling/public/seeds/.release-please-manifest.json +3 -0
  213. package/tooling/public/seeds/CONTRIBUTING.md +50 -0
  214. package/tooling/public/seeds/LICENSE +21 -0
  215. package/tooling/public/seeds/SECURITY.md +13 -0
  216. package/tooling/public/seeds/release-please-config.json +12 -0
  217. package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
  218. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
  219. package/tooling/web/configs/.github/workflows/verify.yml +15 -12
  220. package/claude/skills/git-pr/references/evidence.md +0 -124
@@ -0,0 +1,440 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { join, relative, resolve, sep } from 'node:path'
4
+ import { listRuleFiles } from '@/gov/payload'
5
+
6
+ /**
7
+ * The six shape limits a rule body is held to.
8
+ *
9
+ * `create-rule`'s `references/rule.md` states the same six numbers to the
10
+ * session writing a rule, and nothing compares the two, so a change to one is
11
+ * a change to both in the same commit. The numbers come from groundwork 158's
12
+ * decision rather than from the density limits, which bound prose sections a
13
+ * rule never carries.
14
+ */
15
+ export const RULE_LIMITS = {
16
+ words: 300,
17
+ bullets: 15,
18
+ h2: 6,
19
+ perH2: 7,
20
+ chars: 200,
21
+ sentences: 2,
22
+ } as const
23
+
24
+ export type RuleLimit = keyof typeof RULE_LIMITS | 'emphasis'
25
+
26
+ /**
27
+ * Rule folders whose findings fail `--gate`, as path prefixes from the root.
28
+ *
29
+ * Each folder's rewrite appends its own line once that folder reaches zero, so
30
+ * the slices merge without touching one another's entries. `canon/` waits on
31
+ * `plan.md`, which a later slice rewrites.
32
+ */
33
+ export const RULE_GATED_FOLDERS: readonly string[] = [
34
+ join('governance', 'rules', 'claude') + sep,
35
+ join('governance', 'rules', 'code') + sep,
36
+ join('governance', 'rules', 'writing') + sep,
37
+ join('internal', 'rules') + sep,
38
+ ]
39
+
40
+ /** The folders whose rules claim outside practice, so each carries a ledger. */
41
+ export const PRACTICE_FOLDERS: readonly string[] = [
42
+ 'lang',
43
+ 'lib',
44
+ 'framework',
45
+ 'ui',
46
+ 'tooling',
47
+ ]
48
+
49
+ const SHIPPED_RULES = join('governance', 'rules')
50
+ const LEDGERS = join('governance', 'ledger')
51
+
52
+ /** Both corpora authored here, read when the tree is a canon checkout. */
53
+ const CANON_CORPUS: readonly string[] = [
54
+ SHIPPED_RULES,
55
+ join('internal', 'rules'),
56
+ ]
57
+
58
+ /**
59
+ * What a target authors. `.claude/rules/canon/` is installed from the toolkit
60
+ * and fixed at its source, so a finding there is one the target cannot act on.
61
+ */
62
+ const TARGET_CORPUS: readonly string[] = [join('.claude', 'rules', 'project')]
63
+
64
+ export interface ShapeFinding {
65
+ readonly limit: RuleLimit
66
+ readonly count: number
67
+ readonly max: number
68
+ /** One-based file line of the bullet, absent for a whole-body limit. */
69
+ readonly line?: number
70
+ }
71
+
72
+ export interface RuleShape {
73
+ readonly words: number
74
+ readonly bullets: number
75
+ readonly h2: number
76
+ /** The most bullets under one H2, with the text above the first H2 a group. */
77
+ readonly perH2: number
78
+ readonly findings: readonly ShapeFinding[]
79
+ /** Bullets per H2 heading, `null` for the group above the first one. */
80
+ readonly groups: readonly HeadingCount[]
81
+ }
82
+
83
+ export interface HeadingCount {
84
+ readonly heading: string | null
85
+ readonly count: number
86
+ }
87
+
88
+ export interface LedgerMismatch {
89
+ readonly heading: string | null
90
+ readonly bullets: number
91
+ readonly rows: number
92
+ }
93
+
94
+ export type LedgerReading =
95
+ | { readonly status: 'not-required' }
96
+ | { readonly status: 'missing'; readonly path: string }
97
+ | {
98
+ readonly status: 'present'
99
+ readonly path: string
100
+ readonly mismatches: readonly LedgerMismatch[]
101
+ }
102
+
103
+ export interface RuleReading extends Omit<RuleShape, 'groups'> {
104
+ /** Relative to the root that was read. */
105
+ readonly file: string
106
+ readonly ledger: LedgerReading
107
+ }
108
+
109
+ export type RuleAuditRefusal = 'no-rules' | 'no-path' | 'outside-root'
110
+
111
+ export type RuleAuditReport =
112
+ | {
113
+ readonly kind: 'measured'
114
+ /** `canon` reads both authored corpora and ledgers, `target` neither. */
115
+ readonly scope: 'canon' | 'target'
116
+ readonly corpus: readonly string[]
117
+ readonly rules: readonly RuleReading[]
118
+ }
119
+ | {
120
+ readonly kind: 'unreadable'
121
+ readonly reason: RuleAuditRefusal
122
+ readonly message: string
123
+ }
124
+
125
+ const FRONTMATTER = /^---\n[\s\S]*?\n---\n/
126
+ const FENCE = /^\s*(?:```|~~~)/
127
+ const H2 = /^## /
128
+
129
+ /**
130
+ * Case-sensitive on the capitals, since "Never" opening a directive is plain
131
+ * wording and "NEVER" is the shouting the prompting guide says newer models
132
+ * overtrigger on.
133
+ */
134
+ const EMPHASIS = /\b(?:CRITICAL|IMPORTANT|MUST|ALWAYS|NEVER)\b|\b[Yy]ou must\b/
135
+
136
+ function stripCodeSpans(text: string): string {
137
+ return text.replace(/`[^`]*`/g, 'X')
138
+ }
139
+
140
+ /**
141
+ * Sentence ends in one bullet, never fewer than one.
142
+ *
143
+ * Copied from groundwork 158's `audit.py`, which measured the baseline. A
144
+ * period inside a code span or a short abbreviation ends nothing, and counting
145
+ * either would put most of the corpus past two.
146
+ */
147
+ export function countSentences(bullet: string): number {
148
+ const text = stripCodeSpans(bullet).replace(/\b(?:e\.g|i\.e|etc|vs)\./g, 'X')
149
+ return text.match(/[.!?](?:\s|$)/g)?.length ?? 1
150
+ }
151
+
152
+ interface Bullet {
153
+ text: string
154
+ line: number
155
+ }
156
+
157
+ /**
158
+ * Measures one rule file against `RULE_LIMITS`.
159
+ *
160
+ * A bullet is a top-level `- ` line plus any indented line directly under it,
161
+ * so a wrapped bullet is measured whole. A nested bullet is not counted, which
162
+ * matches the groundwork script the baseline came from. Fenced blocks display
163
+ * a list rather than state one.
164
+ */
165
+ export function measureRule(text: string): RuleShape {
166
+ const frontmatter = text.match(FRONTMATTER)
167
+ const body = frontmatter ? text.slice(frontmatter[0].length) : text
168
+ const offset = frontmatter ? frontmatter[0].split('\n').length - 1 : 0
169
+ const lines = body.split('\n')
170
+
171
+ const bullets: Bullet[] = []
172
+ const groups: { heading: string | null; count: number }[] = [
173
+ { heading: null, count: 0 },
174
+ ]
175
+ let fenced = false
176
+ let open: Bullet | undefined
177
+
178
+ for (const [index, line] of lines.entries()) {
179
+ if (FENCE.test(line)) {
180
+ fenced = !fenced
181
+ open = undefined
182
+ continue
183
+ }
184
+ if (fenced) continue
185
+
186
+ if (H2.test(line)) {
187
+ groups.push({ heading: line.slice(3).trim(), count: 0 })
188
+ open = undefined
189
+ } else if (line.startsWith('- ')) {
190
+ open = { text: line.slice(2).trim(), line: offset + index + 1 }
191
+ bullets.push(open)
192
+ groups[groups.length - 1].count += 1
193
+ } else if (
194
+ open !== undefined &&
195
+ line.startsWith(' ') &&
196
+ !line.trim().startsWith('- ')
197
+ ) {
198
+ open.text += ` ${line.trim()}`
199
+ } else {
200
+ open = undefined
201
+ }
202
+ }
203
+
204
+ const words = body.match(/\S+/g)?.length ?? 0
205
+ const h2 = groups.length - 1
206
+ const perH2 = Math.max(...groups.map((group) => group.count))
207
+ const findings: ShapeFinding[] = []
208
+
209
+ const whole: [RuleLimit & keyof typeof RULE_LIMITS, number][] = [
210
+ ['words', words],
211
+ ['bullets', bullets.length],
212
+ ['h2', h2],
213
+ ['perH2', perH2],
214
+ ]
215
+ for (const [limit, count] of whole) {
216
+ if (count > RULE_LIMITS[limit]) {
217
+ findings.push({ limit, count, max: RULE_LIMITS[limit] })
218
+ }
219
+ }
220
+
221
+ for (const bullet of bullets) {
222
+ const chars = [...bullet.text].length
223
+ if (chars > RULE_LIMITS.chars) {
224
+ findings.push({
225
+ limit: 'chars',
226
+ count: chars,
227
+ max: RULE_LIMITS.chars,
228
+ line: bullet.line,
229
+ })
230
+ }
231
+ const sentences = countSentences(bullet.text)
232
+ if (sentences > RULE_LIMITS.sentences) {
233
+ findings.push({
234
+ limit: 'sentences',
235
+ count: sentences,
236
+ max: RULE_LIMITS.sentences,
237
+ line: bullet.line,
238
+ })
239
+ }
240
+ // A code span quotes a word rather than shouting it.
241
+ if (EMPHASIS.test(stripCodeSpans(bullet.text))) {
242
+ findings.push({ limit: 'emphasis', count: 1, max: 0, line: bullet.line })
243
+ }
244
+ }
245
+
246
+ return {
247
+ words,
248
+ bullets: bullets.length,
249
+ h2,
250
+ perH2,
251
+ findings,
252
+ groups: groups.filter((group) => group.count > 0),
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Table rows per H2 in a ledger, `null` for a table above the first H2.
258
+ *
259
+ * A row is a table line after the header and its separator. Rows are matched
260
+ * to bullets by count under a heading of the same name rather than by text,
261
+ * since matching text breaks on every reword and whether a source says what
262
+ * its bullet says is a review judgment either way.
263
+ */
264
+ export function countLedgerRows(text: string): HeadingCount[] {
265
+ const counts = new Map<string | null, number>()
266
+ let heading: string | null = null
267
+ let tableLines = 0
268
+ let fenced = false
269
+
270
+ for (const line of text.split('\n')) {
271
+ if (FENCE.test(line)) fenced = !fenced
272
+ if (fenced) continue
273
+
274
+ if (line.startsWith('|')) {
275
+ tableLines += 1
276
+ if (tableLines > 2) counts.set(heading, (counts.get(heading) ?? 0) + 1)
277
+ continue
278
+ }
279
+ tableLines = 0
280
+ if (H2.test(line)) heading = line.slice(3).trim()
281
+ }
282
+
283
+ return [...counts].map(([key, count]) => ({ heading: key, count }))
284
+ }
285
+
286
+ function compareLedger(
287
+ groups: readonly HeadingCount[],
288
+ rows: readonly HeadingCount[],
289
+ ): LedgerMismatch[] {
290
+ const bullets = new Map(groups.map((group) => [group.heading, group.count]))
291
+ const ledger = new Map(rows.map((row) => [row.heading, row.count]))
292
+ const headings = [...new Set([...bullets.keys(), ...ledger.keys()])]
293
+
294
+ return headings
295
+ .map((heading) => ({
296
+ heading,
297
+ bullets: bullets.get(heading) ?? 0,
298
+ rows: ledger.get(heading) ?? 0,
299
+ }))
300
+ .filter((entry) => entry.bullets !== entry.rows)
301
+ }
302
+
303
+ /** Where a rule's ledger lives, or nothing when its folder carries none. */
304
+ function ledgerPath(file: string): string | undefined {
305
+ const prefix = `${SHIPPED_RULES}${sep}`
306
+ if (!file.startsWith(prefix)) return undefined
307
+ const rest = file.slice(prefix.length)
308
+ const folder = rest.split(sep)[0]
309
+ if (!PRACTICE_FOLDERS.includes(folder)) return undefined
310
+ return join(LEDGERS, rest)
311
+ }
312
+
313
+ async function readLedger(
314
+ root: string,
315
+ file: string,
316
+ groups: readonly HeadingCount[],
317
+ ): Promise<LedgerReading> {
318
+ const path = ledgerPath(file)
319
+ if (path === undefined) return { status: 'not-required' }
320
+
321
+ let text: string
322
+ try {
323
+ text = await readFile(resolve(root, path), 'utf8')
324
+ } catch {
325
+ return { status: 'missing', path }
326
+ }
327
+
328
+ return {
329
+ status: 'present',
330
+ path,
331
+ mismatches: compareLedger(groups, countLedgerRows(text)),
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Measures every rule this tree authors, or the files named.
337
+ *
338
+ * A tree holding `governance/rules/` is a canon checkout, read across both
339
+ * authored corpora with a ledger asked of each outside-practice rule. Any
340
+ * other tree is a target, read at `.claude/rules/project/` alone with no
341
+ * ledger asked, since ledgers never ship.
342
+ */
343
+ export async function readRuleAudit(
344
+ root: string,
345
+ paths: readonly string[] = [],
346
+ ): Promise<RuleAuditReport> {
347
+ const scope = existsSync(resolve(root, SHIPPED_RULES)) ? 'canon' : 'target'
348
+ const corpus = scope === 'canon' ? CANON_CORPUS : TARGET_CORPUS
349
+
350
+ let files: string[]
351
+ if (paths.length > 0) {
352
+ const missing = paths.filter((path) => !existsSync(resolve(root, path)))
353
+ if (missing.length > 0) {
354
+ return {
355
+ kind: 'unreadable',
356
+ reason: 'no-path',
357
+ message: `No rule at ${missing.join(', ')}.`,
358
+ }
359
+ }
360
+ // A path outside the root takes a `../` file name, which no gated prefix
361
+ // and no ledger path can match, so it would pass `--gate` unread.
362
+ const outside = paths.filter((path) =>
363
+ relative(root, resolve(root, path)).startsWith('..'),
364
+ )
365
+ if (outside.length > 0) {
366
+ return {
367
+ kind: 'unreadable',
368
+ reason: 'outside-root',
369
+ message: `${outside.join(', ')} sits outside ${root}. Pass --root at the tree that holds it.`,
370
+ }
371
+ }
372
+ const expanded: string[] = []
373
+ for (const path of paths) {
374
+ const abs = resolve(root, path)
375
+ if (!statSync(abs).isDirectory()) {
376
+ expanded.push(abs)
377
+ continue
378
+ }
379
+ const found = listRuleFiles(abs)
380
+ if (found.length === 0) {
381
+ return {
382
+ kind: 'unreadable',
383
+ reason: 'no-rules',
384
+ message: `No rule under ${path}, and a folder with no rules passes each of its zero rules, so it refuses rather than reporting clean.`,
385
+ }
386
+ }
387
+ expanded.push(...found)
388
+ }
389
+ files = expanded
390
+ } else {
391
+ const dirs = corpus
392
+ .map((rel) => resolve(root, rel))
393
+ .filter((dir) => existsSync(dir))
394
+ if (dirs.length === 0) {
395
+ return {
396
+ kind: 'unreadable',
397
+ reason: 'no-rules',
398
+ message: `No rule corpus under ${root}. Looked for ${corpus.join(', ')}, and a tree with no rules passes each of its zero rules, so it refuses rather than reporting clean.`,
399
+ }
400
+ }
401
+ files = dirs.flatMap((dir) => listRuleFiles(dir))
402
+ }
403
+
404
+ const rules: RuleReading[] = []
405
+ for (const abs of files) {
406
+ const file = relative(root, abs)
407
+ const { groups, ...shape } = measureRule(await readFile(abs, 'utf8'))
408
+ rules.push({ file, ...shape, ledger: await readLedger(root, file, groups) })
409
+ }
410
+
411
+ return { kind: 'measured', scope, corpus, rules }
412
+ }
413
+
414
+ export function hasFinding(reading: RuleReading): boolean {
415
+ if (reading.findings.length > 0) return true
416
+ if (reading.ledger.status === 'missing') return true
417
+ return (
418
+ reading.ledger.status === 'present' && reading.ledger.mismatches.length > 0
419
+ )
420
+ }
421
+
422
+ /**
423
+ * Exit code for a measured report.
424
+ *
425
+ * Without `gate`, any finding sets 2, which is what a session fixing its own
426
+ * rule reads. With it, only a finding in a gated folder does, so the push
427
+ * stage reports the whole corpus and fails on the folders already at zero.
428
+ */
429
+ export function auditExit(
430
+ report: Extract<RuleAuditReport, { kind: 'measured' }>,
431
+ options: { readonly gate: boolean; readonly gated: readonly string[] },
432
+ ): number {
433
+ const failing = report.rules.filter(
434
+ (reading) =>
435
+ hasFinding(reading) &&
436
+ (!options.gate ||
437
+ options.gated.some((prefix) => reading.file.startsWith(prefix))),
438
+ )
439
+ return failing.length > 0 ? 2 : 0
440
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The folder linked worktrees sit under. A listener there belongs to another
3
+ * branch even though its cwd sits inside the main checkout's toplevel.
4
+ */
5
+ const LINKED_WORKTREES = '.claude/worktrees'
6
+
7
+ function isInside(path: string, root: string): boolean {
8
+ return path === root || path.startsWith(`${root}/`)
9
+ }
10
+
11
+ /**
12
+ * Whether a process running in `cwd` serves this worktree. A tool that
13
+ * changes into a subfolder before listening still counts, while a sibling
14
+ * worktree nested under the main checkout's `.claude/worktrees/` does not.
15
+ */
16
+ export function belongsTo(cwd: string, worktreeRoot: string): boolean {
17
+ return (
18
+ isInside(cwd, worktreeRoot) &&
19
+ !isInside(cwd, `${worktreeRoot}/${LINKED_WORKTREES}`)
20
+ )
21
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readdir, readFile, readlink } from 'node:fs/promises'
3
+ import { $ } from 'bun'
4
+ import {
5
+ type Listener,
6
+ parseLsofListeners,
7
+ parseProcNetTcp,
8
+ } from '@/listeners/parse'
9
+
10
+ /** The machine reads a listener lookup makes, injected so it can be driven without binding a socket. */
11
+ export interface ListenerReader {
12
+ /** Every TCP socket in LISTEN with its owning pid, or undefined when this machine offers no way to read them. */
13
+ listListeners(): Promise<readonly Listener[] | undefined>
14
+ cwdOf(pid: number): Promise<string | undefined>
15
+ }
16
+
17
+ async function readText(path: string): Promise<string | undefined> {
18
+ return readFile(path, 'utf8').catch(() => undefined)
19
+ }
20
+
21
+ /**
22
+ * Every listening socket's owning pid, read from `/proc`. Only a process this
23
+ * user owns exposes its `fd` table, which is also the only process whose
24
+ * server this user's worktree could have started.
25
+ */
26
+ async function readProcListeners(): Promise<Listener[] | undefined> {
27
+ const tables = await Promise.all(
28
+ ['/proc/net/tcp', '/proc/net/tcp6'].map(readText),
29
+ )
30
+ if (tables.every((text) => text === undefined)) return undefined
31
+ const byInode = new Map(
32
+ tables.flatMap((text) =>
33
+ text === undefined
34
+ ? []
35
+ : parseProcNetTcp(text).map((s) => [s.inode, s.port] as const),
36
+ ),
37
+ )
38
+
39
+ const processIds = (await readdir('/proc').catch(() => [])).filter((name) =>
40
+ /^\d+$/.test(name),
41
+ )
42
+ const listeners: Listener[] = []
43
+ for (const pid of processIds) {
44
+ const fds = await readdir(`/proc/${pid}/fd`).catch(() => [])
45
+ for (const fd of fds) {
46
+ const target = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => '')
47
+ const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1]
48
+ const port = inode === undefined ? undefined : byInode.get(inode)
49
+ if (port !== undefined) listeners.push({ pid: Number(pid), port })
50
+ }
51
+ }
52
+ return listeners
53
+ }
54
+
55
+ /**
56
+ * The machine half of a listener lookup: `/proc` wherever it exists, lsof on a
57
+ * machine without it, and no reader anywhere else.
58
+ */
59
+ export function machineListenerReader(): ListenerReader {
60
+ const hasLsof = Bun.which('lsof') !== null
61
+ return {
62
+ async listListeners() {
63
+ // lsof 4.95 drops every process whose kernel-truncated name holds an
64
+ // unmatched `(`, which Next's `next-server (vX.Y.Z)` title cuts down to,
65
+ // so `/proc` goes first despite costing several times what lsof does.
66
+ if (existsSync('/proc/net/tcp')) {
67
+ const listeners = await readProcListeners()
68
+ if (listeners !== undefined) return listeners
69
+ }
70
+ if (hasLsof) {
71
+ const result = await $`lsof -nP -iTCP -sTCP:LISTEN -Fpn`
72
+ .quiet()
73
+ .nothrow()
74
+ // lsof exits 1 both when it matched nothing and when it could not
75
+ // read some process, and the rows it did print are real either way.
76
+ if (result.exitCode <= 1) return parseLsofListeners(result.text())
77
+ }
78
+ return undefined
79
+ },
80
+ async cwdOf(pid) {
81
+ if (existsSync(`/proc/${pid}/cwd`)) {
82
+ return readlink(`/proc/${pid}/cwd`).catch(() => undefined)
83
+ }
84
+ if (!hasLsof) return undefined
85
+ const result = await $`lsof -a -p ${pid} -d cwd -Fn`.quiet().nothrow()
86
+ const line = result
87
+ .text()
88
+ .split('\n')
89
+ .find((row) => row.startsWith('n'))
90
+ return line?.slice(1)
91
+ },
92
+ }
93
+ }
@@ -0,0 +1,41 @@
1
+ /** The state `/proc/net/tcp` writes for a socket in LISTEN. */
2
+ const PROC_LISTEN_STATE = '0A'
3
+
4
+ export interface Listener {
5
+ readonly port: number
6
+ readonly pid: number
7
+ }
8
+
9
+ /** Reads `lsof -nP -iTCP -sTCP:LISTEN -Fpn` output, where a `p` line opens a process and each `n` line names one of its sockets. */
10
+ export function parseLsofListeners(text: string): Listener[] {
11
+ const listeners: Listener[] = []
12
+ let pid: number | undefined
13
+ for (const line of text.split('\n')) {
14
+ if (line.startsWith('p')) pid = Number(line.slice(1))
15
+ if (!line.startsWith('n') || pid === undefined) continue
16
+ const port = Number(line.slice(line.lastIndexOf(':') + 1))
17
+ if (Number.isInteger(port) && port > 0) listeners.push({ pid, port })
18
+ }
19
+ return listeners
20
+ }
21
+
22
+ export interface ProcSocket {
23
+ readonly port: number
24
+ readonly inode: string
25
+ }
26
+
27
+ /** Reads `/proc/net/tcp` or `tcp6`, keeping each listening socket's port and inode for matching against `/proc/<pid>/fd`. */
28
+ export function parseProcNetTcp(text: string): ProcSocket[] {
29
+ const sockets: ProcSocket[] = []
30
+ for (const line of text.split('\n').slice(1)) {
31
+ const fields = line.trim().split(/\s+/)
32
+ const local = fields[1]
33
+ const state = fields[3]
34
+ const inode = fields[9]
35
+ if (local === undefined || inode === undefined) continue
36
+ if (state !== PROC_LISTEN_STATE) continue
37
+ const port = Number.parseInt(local.slice(local.lastIndexOf(':') + 1), 16)
38
+ if (Number.isInteger(port) && port > 0) sockets.push({ port, inode })
39
+ }
40
+ return sockets
41
+ }