@erclx/canon 5.32.0 → 5.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +1 -2
  3. package/claude/skills/api-design/SKILL.md +2 -3
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/auto-ship/SKILL.md +1 -1
  6. package/claude/skills/auto-ship/references/worktree-name.md +1 -1
  7. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  8. package/claude/skills/backlog-triage/SKILL.md +1 -1
  9. package/claude/skills/canon-cli/REQUIREMENT.md +16 -13
  10. package/claude/skills/canon-cli/SKILL.md +75 -32
  11. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  12. package/claude/skills/canon-feedback/SKILL.md +11 -29
  13. package/claude/skills/canon-feedback/references/template.md +53 -0
  14. package/claude/skills/{canvas → canvas-board}/REQUIREMENT.md +2 -2
  15. package/claude/skills/{canvas → canvas-board}/SKILL.md +6 -3
  16. package/claude/skills/{canvas → canvas-board}/references/canvas.md +26 -3
  17. package/{standards → claude/skills/canvas-board/references}/design.md +11 -2
  18. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  19. package/claude/skills/code-craft/SKILL.md +0 -1
  20. package/claude/skills/context-fold/REQUIREMENT.md +25 -33
  21. package/claude/skills/context-fold/SKILL.md +25 -70
  22. package/claude/skills/context-fold/references/classify.md +3 -3
  23. package/claude/skills/context-fold/references/completion-report.md +4 -4
  24. package/claude/skills/context-fold/references/context-refresh.md +5 -5
  25. package/{standards → claude/skills/context-fold/references}/context.md +16 -5
  26. package/claude/skills/context-fold/references/unusable-baseline.md +3 -5
  27. package/claude/skills/create-rule/SKILL.md +3 -3
  28. package/{standards → claude/skills/create-rule/references}/rule.md +15 -6
  29. package/claude/skills/create-skill/SKILL.md +2 -2
  30. package/{standards → claude/skills/create-skill/references}/skill.md +18 -15
  31. package/claude/skills/design-taste/REQUIREMENT.md +3 -3
  32. package/claude/skills/design-taste/SKILL.md +1 -1
  33. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  34. package/claude/skills/docs-sync/SKILL.md +3 -3
  35. package/{standards → claude/skills/docs-sync/references}/docs.md +11 -0
  36. package/{standards → claude/skills/docs-sync/references}/readme.md +9 -2
  37. package/claude/skills/draft-and-pick/SKILL.md +5 -5
  38. package/claude/skills/draft-doc/SKILL.md +6 -6
  39. package/claude/skills/draft-figure/REQUIREMENT.md +2 -2
  40. package/claude/skills/draft-figure/SKILL.md +2 -2
  41. package/claude/skills/draft-figure/references/hand-drawn.md +1 -1
  42. package/claude/skills/draft-figure/references/mermaid-path.md +1 -1
  43. package/claude/skills/draft-identity/REQUIREMENT.md +2 -2
  44. package/claude/skills/draft-identity/SKILL.md +2 -2
  45. package/claude/skills/draft-ready/SKILL.md +1 -1
  46. package/claude/skills/git-branch/SKILL.md +1 -1
  47. package/{standards → claude/skills/git-branch/references}/branch.md +1 -1
  48. package/claude/skills/git-commit/SKILL.md +2 -2
  49. package/claude/skills/git-followup/REQUIREMENT.md +1 -1
  50. package/claude/skills/git-followup/SKILL.md +1 -1
  51. package/claude/skills/git-issue/SKILL.md +1 -1
  52. package/claude/skills/git-issue/references/issue.md +4 -1
  53. package/claude/skills/git-pr/SKILL.md +13 -13
  54. package/claude/skills/git-pr/references/evidence.md +2 -2
  55. package/claude/skills/git-pr/references/final-command.md +38 -14
  56. package/claude/skills/git-pr/references/labels.md +5 -1
  57. package/{standards → claude/skills/git-pr/references}/pr.md +19 -9
  58. package/{standards → claude/skills/git-pr/references}/publish.md +4 -2
  59. package/claude/skills/git-pr/references/task-number.md +1 -1
  60. package/claude/skills/git-ship/SKILL.md +15 -14
  61. package/claude/skills/git-split/SKILL.md +3 -3
  62. package/claude/skills/git-stage/SKILL.md +1 -1
  63. package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
  64. package/claude/skills/review-address/SKILL.md +1 -1
  65. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  66. package/claude/skills/review-craft/references/ui.md +1 -1
  67. package/claude/skills/review-pr/REQUIREMENT.md +1 -1
  68. package/claude/skills/review-pr/SKILL.md +3 -3
  69. package/claude/skills/review-pr/references/body-variants.md +2 -2
  70. package/claude/skills/review-pr/references/key-changes.md +3 -3
  71. package/claude/skills/review-pr/references/post.md +4 -2
  72. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  73. package/claude/skills/review-ui/SKILL.md +8 -6
  74. package/claude/skills/review-ui/references/driving.md +8 -8
  75. package/claude/skills/review-ui/references/post.md +53 -17
  76. package/claude/skills/role-orchestrator/REQUIREMENT.md +3 -0
  77. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  78. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -2
  79. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +8 -5
  80. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +32 -1
  81. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
  82. package/claude/skills/role-orchestrator/references/orchestrator-resume.md +3 -0
  83. package/claude/skills/role-planner/REQUIREMENT.md +2 -1
  84. package/claude/skills/role-planner/SKILL.md +13 -2
  85. package/claude/skills/role-reviewer/SKILL.md +1 -1
  86. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  87. package/claude/skills/role-worker/SKILL.md +9 -1
  88. package/claude/skills/session-map/SKILL.md +1 -1
  89. package/claude/skills/session-resume/REQUIREMENT.md +2 -2
  90. package/claude/skills/session-worktree/SKILL.md +5 -5
  91. package/claude/skills/target-setup/SKILL.md +10 -4
  92. package/claude/skills/target-setup/references/detect.md +20 -3
  93. package/claude/skills/task-board/REQUIREMENT.md +7 -12
  94. package/claude/skills/task-board/SKILL.md +5 -17
  95. package/claude/skills/task-board/references/tasks.md +2 -2
  96. package/claude/skills/task-complete/REQUIREMENT.md +47 -0
  97. package/claude/skills/task-complete/SKILL.md +86 -0
  98. package/claude/skills/{task-board → task-complete}/references/archive.md +4 -4
  99. package/claude/skills/{context-fold → task-complete}/references/receipt-sweep.md +5 -5
  100. package/claude/skills/task-complete/references/unusable-baseline.md +19 -0
  101. package/claude/skills/teach-workspace/REQUIREMENT.md +6 -2
  102. package/claude/skills/teach-workspace/SKILL.md +8 -4
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  104. package/claude/skills/{context-fold/references/promoted-pages.md → teach-workspace/references/promotion-fold.md} +3 -3
  105. package/claude/skills/teach-workspace/references/promotion.md +3 -3
  106. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  107. package/claude/skills/test-craft/SKILL.md +15 -3
  108. package/claude/skills/ui-checklist/SKILL.md +1 -1
  109. package/claude/skills/ux-audit/SKILL.md +1 -1
  110. package/claude/skills/ux-measure/SKILL.md +1 -1
  111. package/docs/agents/canvas.md +6 -2
  112. package/docs/agents/commands.md +4 -3
  113. package/docs/agents/context-audit-checks.md +4 -4
  114. package/docs/agents/gov-detect.md +47 -0
  115. package/docs/agents/index.md +7 -4
  116. package/docs/agents/indexes.md +1 -1
  117. package/docs/agents/install-and-sync.md +10 -11
  118. package/docs/agents/key-changes.md +5 -5
  119. package/docs/agents/label-coverage.md +11 -0
  120. package/docs/agents/migrate.md +1 -1
  121. package/docs/agents/pr-evidence-layout.md +70 -0
  122. package/docs/agents/pr-evidence.md +8 -9
  123. package/docs/agents/pr-frames.md +7 -2
  124. package/docs/agents/pr-lift.md +132 -0
  125. package/docs/agents/records-push.md +2 -2
  126. package/docs/agents/records-stale.md +12 -47
  127. package/docs/agents/records-validate.md +3 -7
  128. package/docs/agents/records.md +5 -27
  129. package/docs/agents/rule-citations.md +1 -1
  130. package/docs/agents/sandbox.md +4 -4
  131. package/docs/agents/scripting.md +2 -1
  132. package/docs/agents/skills-audit.md +1 -1
  133. package/docs/agents/tasks-plans.md +2 -2
  134. package/docs/agents/tasks.md +2 -2
  135. package/docs/target/migrations.md +10 -2
  136. package/docs/target/projects.md +4 -4
  137. package/docs/target/sync.md +1 -1
  138. package/docs/workflow/operating-model.md +2 -2
  139. package/docs/workflow/parallel-features.md +7 -7
  140. package/docs/workflow/skill-map-on-request.md +9 -9
  141. package/docs/workflow/skill-map.md +12 -11
  142. package/docs/workflow/visual-design-workflow.md +2 -2
  143. package/governance/detect.toml +201 -0
  144. package/governance/renames.toml +0 -11
  145. package/governance/rules/canon/context.md +13 -0
  146. package/governance/rules/claude/skill.md +17 -0
  147. package/governance/rules/code/000-code.md +1 -13
  148. package/governance/rules/code/010-testing.md +2 -30
  149. package/governance/rules/ui/400-ui.md +1 -1
  150. package/governance/rules/ui/480-canvas.md +13 -0
  151. package/governance/stacks/base.toml +2 -4
  152. package/package.json +4 -4
  153. package/scripts/core/install-check.sh +3 -3
  154. package/scripts/core/regen-hero.ts +0 -6
  155. package/scripts/lib/ui.sh +0 -168
  156. package/src/audits/catalog.ts +0 -1
  157. package/src/canvas/arrange.ts +66 -0
  158. package/src/canvas/capture.ts +20 -6
  159. package/src/canvas/content.ts +39 -0
  160. package/src/canvas/fit.ts +238 -0
  161. package/src/canvas/tokens.ts +28 -10
  162. package/src/capture/frame-values.ts +2 -32
  163. package/src/claude/cases/misc.ts +1 -1
  164. package/src/claude/cases/setup.ts +4 -0
  165. package/src/claude/cases/workflow.ts +6 -1
  166. package/src/claude/seeds.ts +1 -1
  167. package/src/claude/skills-audit.ts +2 -2
  168. package/src/cli/help.ts +3 -1
  169. package/src/cli/ui.ts +2 -3
  170. package/src/commands/canvas.ts +179 -1
  171. package/src/commands/claude.ts +1 -1
  172. package/src/commands/gov.ts +146 -16
  173. package/src/commands/labels.ts +326 -7
  174. package/src/commands/migrate.ts +6 -1
  175. package/src/commands/pr.ts +377 -2
  176. package/src/commands/records.ts +13 -399
  177. package/src/commands/sandbox.ts +3 -6
  178. package/src/commands/tasks.ts +3 -3
  179. package/src/context/audit.ts +5 -5
  180. package/src/context/folders.ts +1 -1
  181. package/src/design/budget.ts +1 -1
  182. package/src/design/components.ts +1 -1
  183. package/src/design/document.ts +1 -1
  184. package/src/design/regen.ts +1 -1
  185. package/src/design/render.ts +1 -1
  186. package/src/feedback/body.ts +26 -5
  187. package/src/gate/measures.ts +59 -45
  188. package/src/gate/sequencer.ts +56 -1
  189. package/src/gate/stages.ts +4 -11
  190. package/src/gov/detect.ts +301 -0
  191. package/src/gov/install.ts +2 -3
  192. package/src/gov/list.ts +23 -2
  193. package/src/gov/stacks.ts +10 -1
  194. package/src/hooks/post-merge.ts +1 -1
  195. package/src/labels/apply.ts +39 -0
  196. package/src/labels/format.ts +3 -3
  197. package/src/labels/phase.ts +3 -3
  198. package/src/labels/sections.ts +43 -0
  199. package/src/migrate/skill-names.ts +20 -1
  200. package/src/pr/evidence.ts +218 -45
  201. package/src/pr/frames.ts +23 -3
  202. package/src/pr/lift.ts +418 -0
  203. package/src/pr/paths.ts +4 -4
  204. package/src/pr/rest.ts +3 -0
  205. package/src/pr/review-scope.ts +76 -0
  206. package/src/records/backup.ts +16 -6
  207. package/src/records/stale-canonical.ts +31 -1
  208. package/src/records/validate.ts +2 -188
  209. package/src/sandbox/census.ts +1 -1
  210. package/src/sandbox/coverage.ts +2 -6
  211. package/src/sandbox/equivalence.ts +19 -51
  212. package/src/sandbox/expect.ts +4 -4
  213. package/src/sandbox/provision.ts +13 -36
  214. package/src/sandbox/scenario.ts +26 -48
  215. package/src/sandbox/tree.ts +2 -6
  216. package/src/standards/read.ts +14 -1
  217. package/src/sync/git.ts +1 -1
  218. package/src/tasks/archive.ts +5 -5
  219. package/src/tasks/branch.ts +3 -3
  220. package/src/tasks/label.ts +1 -1
  221. package/src/tasks/record.ts +2 -2
  222. package/src/tasks/validate.ts +1 -1
  223. package/standards/index.md +0 -13
  224. package/standards/standard.md +1 -7
  225. package/tooling/base/reference.md +1 -1
  226. package/tooling/base/seeds/.github/pull_request_template.md +2 -2
  227. package/tooling/claude/reference.md +1 -1
  228. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +9 -1
  229. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +171 -0
  230. package/tooling/claude/seeds/.claude/settings.json +9 -0
  231. package/governance/rules/standards/context.md +0 -14
  232. package/governance/rules/standards/design.md +0 -12
  233. package/governance/rules/standards/readme.md +0 -12
  234. package/governance/rules/standards/rule.md +0 -13
  235. package/governance/rules/standards/skill.md +0 -21
  236. package/governance/rules/standards/standard.md +0 -13
  237. package/scripts/config.sh +0 -11
  238. package/scripts/lib/gov.sh +0 -14
  239. package/scripts/lib/sandbox-fixtures.sh +0 -191
  240. package/scripts/lib/sandbox-git.sh +0 -125
  241. package/scripts/lib/sandbox-path.sh +0 -50
  242. package/scripts/sandbox-hook.sh +0 -99
  243. package/src/gov/standard-rules.ts +0 -179
  244. package/src/records/migrate.ts +0 -78
  245. package/src/records/stale.ts +0 -243
  246. package/src/sandbox/hooks.ts +0 -128
  247. /package/{standards → claude/skills/draft-figure/references}/figures.md +0 -0
  248. /package/{standards → claude/skills/git-branch/references}/slug.md +0 -0
  249. /package/{standards → claude/skills/git-commit/references}/commit.md +0 -0
  250. /package/{standards → claude/skills/git-commit/references}/versioning.md +0 -0
@@ -105,7 +105,7 @@ interface Confidence {
105
105
  }
106
106
 
107
107
  /**
108
- * The columns the confidence ratio reads, fixed by `standards/design.md`. The
108
+ * The columns the confidence ratio reads, fixed by `claude/skills/canvas-board/references/design.md`. The
109
109
  * first column of each table names its row, and `Multiplier` and `When used`
110
110
  * restate what the row already carries, so none of them is something a source
111
111
  * could anchor and none belongs in the denominator.
@@ -18,13 +18,18 @@ export const REQUIRED_FIELDS = ['Surface', 'Observed', 'Proposed fix'] as const
18
18
  * retire, and the validator would then enforce nothing.
19
19
  */
20
20
  const FIELD_HEADING = /^###\s+(.+?)\s*$/
21
- const FENCE = /^\s*(```|~~~)/
21
+ const FENCE = /^\s*(`{3,}|~{3,})(.*)$/
22
+
23
+ interface OpenFence {
24
+ readonly marker: string
25
+ readonly length: number
26
+ }
22
27
 
23
28
  export function parseSections(body: string): Map<string, string> {
24
29
  const sections = new Map<string, string>()
25
30
  let heading: string | undefined
26
31
  let lines: string[] = []
27
- let fenced = false
32
+ let fence: OpenFence | undefined
28
33
 
29
34
  const flush = (): void => {
30
35
  if (heading === undefined) return
@@ -34,9 +39,25 @@ export function parseSections(body: string): Map<string, string> {
34
39
  for (const line of body.split('\n')) {
35
40
  // A Repro field routinely carries a fenced block, and a `###` comment
36
41
  // inside one is shell rather than the next field. Reading it as a heading
37
- // splits the section and drops everything under it.
38
- if (FENCE.test(line)) fenced = !fenced
39
- const match = fenced ? null : line.match(FIELD_HEADING)
42
+ // splits the section and drops everything under it. A Suggested shape
43
+ // wraps a template that holds its own fence in a longer one, so a fence
44
+ // closes only on the same marker at least as long, per CommonMark.
45
+ const fenceMatch = line.match(FENCE)
46
+ const run = fenceMatch?.[1]
47
+ if (run !== undefined) {
48
+ const marker = run.charAt(0)
49
+ const isBare = (fenceMatch?.[2] ?? '').trim() === ''
50
+ if (fence === undefined) {
51
+ fence = { marker, length: run.length }
52
+ } else if (
53
+ isBare &&
54
+ marker === fence.marker &&
55
+ run.length >= fence.length
56
+ ) {
57
+ fence = undefined
58
+ }
59
+ }
60
+ const match = fence ? null : line.match(FIELD_HEADING)
40
61
  if (match?.[1] === undefined) {
41
62
  lines.push(line)
42
63
  continue
@@ -104,28 +104,6 @@ const info = (text: string): Emission => ({ kind: 'info', text })
104
104
  const warn = (text: string): Emission => ({ kind: 'warn', text })
105
105
  const output = (text: string): Emission => ({ kind: 'output', text })
106
106
 
107
- /**
108
- * Rules no stack reaches, sorted the way `canon gov list` emits them.
109
- * `260-shadcn` and `320-tanstack-query` are opt-in libraries a project may not
110
- * want.
111
- *
112
- * `130-go`, `140-php`, `335-testing-go`, and `336-testing-php` are opt-in by
113
- * `--add` until a Go or PHP stack ships, which `target-setup` reaches by
114
- * matching a detected language against each rule's description.
115
- *
116
- * All are recorded here rather than in a config file: the list is what a
117
- * reader compares a new arrival against, and a config file would absorb the
118
- * arrival silently.
119
- */
120
- export const GOV_EXPECTED_UNREFERENCED = [
121
- '130-go',
122
- '140-php',
123
- '260-shadcn',
124
- '320-tanstack-query',
125
- '335-testing-go',
126
- '336-testing-php',
127
- ]
128
-
129
107
  /**
130
108
  * Scenarios declaring no expectation, taken from `canon sandbox coverage`
131
109
  * against a clean tree. Raising it is a deliberate edit that says which
@@ -170,16 +148,19 @@ function parseJson(payload: string): unknown {
170
148
  }
171
149
 
172
150
  /**
173
- * A rule no stack names is a report rather than a gate. All three standing
174
- * findings ship that way on purpose, so failing would fail every push over the
175
- * deliberate case and teach a reader to route around the stage.
151
+ * A rule no stack reaches arrives at a project only through `canon gov
152
+ * detect`, so each one needs a `governance/detect.toml` entry or nothing ever
153
+ * proposes it. A key naming a retired rule fails too, since it would hide from
154
+ * the rename sweep that keys the file by basename.
155
+ *
156
+ * The check replaced a fixed list of expected unreferenced rules. Once the
157
+ * stacks are thin, that list is most of the catalog and would be rewritten on
158
+ * every new rule, which is a list nobody reads.
176
159
  *
177
- * The catalog is parsed here rather than piped through `bun --eval`, which the
178
- * script this replaces had to do and had to guard: that interpreter exits 0
179
- * when its script throws while stdin is a pipe, so a payload that is not JSON
180
- * printed nothing and exited clean, and empty already means every rule is
181
- * reached. A sentinel string carried success past that. Parsing in process
182
- * leaves the two states distinguishable with nothing to carry.
160
+ * The catalog is parsed here rather than piped through `bun --eval`, whose
161
+ * interpreter exits 0 when its script throws while stdin is a pipe, so a
162
+ * payload that is not JSON would print nothing and read as every rule
163
+ * reached. Parsing in process keeps the two states distinguishable.
183
164
  */
184
165
  export const unreferencedRules: Measure = async (ctx) => {
185
166
  const { exitCode, stdout } = await ctx.cli(['gov', 'list', '--json'])
@@ -190,34 +171,67 @@ export const unreferencedRules: Measure = async (ctx) => {
190
171
  }
191
172
  }
192
173
 
193
- const record = parseJson(stdout) as { unreferenced?: unknown } | undefined
194
- if (!Array.isArray(record?.unreferenced)) {
174
+ const record = parseJson(stdout) as
175
+ | { unreferenced?: unknown; rules?: unknown; staleDetect?: unknown }
176
+ | undefined
177
+ if (
178
+ !Array.isArray(record?.unreferenced) ||
179
+ !Array.isArray(record?.rules) ||
180
+ !Array.isArray(record?.staleDetect)
181
+ ) {
195
182
  return {
196
183
  emissions: [],
197
184
  unmeasured:
198
- 'The governance catalog carried no readable unreferenced list.',
185
+ 'The governance catalog carried no readable unreferenced, rules, or staleDetect list.',
199
186
  }
200
187
  }
201
188
 
189
+ const detected = new Set(
190
+ record.rules
191
+ .filter(
192
+ (rule): rule is { name: string; detect: unknown } =>
193
+ typeof rule === 'object' &&
194
+ rule !== null &&
195
+ typeof (rule as { name?: unknown }).name === 'string',
196
+ )
197
+ .filter((rule) => rule.detect !== null && rule.detect !== undefined)
198
+ .map((rule) => rule.name),
199
+ )
202
200
  const unreferenced = record.unreferenced.map(String)
203
- if (unreferenced.length === 0) {
204
- return { emissions: [info('Every rule is reached by a stack')] }
201
+ const undetected = unreferenced.filter((rule) => !detected.has(rule))
202
+ const stale = record.staleDetect.map(String)
203
+
204
+ const emissions: Emission[] = []
205
+ if (undetected.length > 0) {
206
+ emissions.push(
207
+ warn(
208
+ `Reached by no stack and proposed by nothing: ${undetected.join(' ')}`,
209
+ ),
210
+ warn(
211
+ 'Name each in a stack, or add a table to governance/detect.toml with the deps or files that mark a project as wanting it.',
212
+ ),
213
+ )
214
+ }
215
+ if (stale.length > 0) {
216
+ emissions.push(
217
+ warn(`governance/detect.toml names no live rule: ${stale.join(' ')}`),
218
+ warn('Rename the key to the rule it became, or delete the table.'),
219
+ )
205
220
  }
206
221
 
207
- const listed = unreferenced.join(' ')
208
- if (listed === GOV_EXPECTED_UNREFERENCED.join(' ')) {
222
+ if (emissions.length > 0) {
209
223
  return {
210
- emissions: [
211
- info(`Reached by no stack: ${listed} (each recorded above with why)`),
212
- ],
224
+ emissions,
225
+ failure: 'An opt-in rule has no route to a project',
213
226
  }
214
227
  }
215
228
 
216
229
  return {
217
230
  emissions: [
218
- warn(`Reached by no stack: ${listed}`),
219
- warn(
220
- `Expected: ${GOV_EXPECTED_UNREFERENCED.join(' ')}. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in src/gate/measures.ts and say why it reaches no stack.`,
231
+ info(
232
+ unreferenced.length === 0
233
+ ? 'Every rule is reached by a stack'
234
+ : `${plural(unreferenced.length, 'rule')} reached by no stack, each proposed by governance/detect.toml`,
221
235
  ),
222
236
  ],
223
237
  }
@@ -317,7 +331,7 @@ export const designProse: Measure = async (ctx) => {
317
331
  if (failures.length > 0) {
318
332
  return {
319
333
  emissions: [],
320
- failure: `${failures.join(' ')} Cut to the budget in standards/design.md, moving the why to the context entry.`,
334
+ failure: `${failures.join(' ')} Cut to the budget in claude/skills/canvas-board/references/design.md, moving the why to the context entry.`,
321
335
  }
322
336
  }
323
337
  if (emissions.length === 0)
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { availableParallelism } from 'node:os'
1
3
  import { join } from 'node:path'
2
4
  import { execa } from 'execa'
3
5
  import type {
@@ -44,6 +46,28 @@ export interface StageResult {
44
46
  readonly queuedMs?: number
45
47
  }
46
48
 
49
+ export interface LoadReading {
50
+ /** The 1-minute load average. */
51
+ readonly loadAverage: number
52
+ readonly cores: number
53
+ }
54
+
55
+ /**
56
+ * Reads the 1-minute load average from `/proc/loadavg`. A platform without
57
+ * it, as macOS, has no reading to give, so this answers `undefined` rather
58
+ * than throwing into a stage that has already failed.
59
+ */
60
+ export function readProcLoad(): LoadReading | undefined {
61
+ try {
62
+ const first = readFileSync('/proc/loadavg', 'utf8').split(' ')[0]
63
+ const loadAverage = Number.parseFloat(first)
64
+ if (!Number.isFinite(loadAverage)) return undefined
65
+ return { loadAverage, cores: availableParallelism() }
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
47
71
  export interface GateContext extends MeasureContext {
48
72
  /** Whether the format stage writes or checks. */
49
73
  readonly write: boolean
@@ -58,6 +82,11 @@ export interface GateContext extends MeasureContext {
58
82
  * wait and reads as a hung suite.
59
83
  */
60
84
  readonly onQueue?: (label: string, holder: LockHolder) => void
85
+ /**
86
+ * Reads the machine load a failed locked stage ran under. Defaults to
87
+ * `/proc/loadavg`, and answers `undefined` where there is none.
88
+ */
89
+ readonly readLoad?: () => LoadReading | undefined
61
90
  }
62
91
 
63
92
  export interface ChangedSet {
@@ -195,7 +224,11 @@ async function executeStage(
195
224
  ]
196
225
 
197
226
  try {
198
- const outcome = await runChecks(stage, ctx, queued)
227
+ const outcome = withLoadLine(
228
+ await runChecks(stage, ctx, queued),
229
+ stage,
230
+ ctx,
231
+ )
199
232
  return held.waitedOn === undefined
200
233
  ? outcome
201
234
  : { ...outcome, queuedMs: held.waitedMs }
@@ -204,6 +237,28 @@ async function executeStage(
204
237
  }
205
238
  }
206
239
 
240
+ /**
241
+ * The lock serializes suites and does nothing about the rest of the box, so a
242
+ * starved spawn test and a real failure read alike. The load beside the
243
+ * failure is what lets a report tell them apart.
244
+ */
245
+ function withLoadLine(
246
+ outcome: StageOutcome,
247
+ stage: Stage,
248
+ ctx: GateContext,
249
+ ): StageOutcome {
250
+ if (outcome.status !== 'failed') return outcome
251
+
252
+ const reading = (ctx.readLoad ?? readProcLoad)()
253
+ if (reading === undefined) return outcome
254
+
255
+ const text = `Load average ${reading.loadAverage} on ${reading.cores} cores when the ${stage.label} stage failed`
256
+ return {
257
+ ...outcome,
258
+ emissions: [...outcome.emissions, { kind: 'info', text }],
259
+ }
260
+ }
261
+
207
262
  async function runChecks(
208
263
  stage: Stage,
209
264
  ctx: GateContext,
@@ -198,17 +198,11 @@ export const STAGES: readonly Stage[] = [
198
198
  argv: ['gov', 'regen'],
199
199
  failure: 'Consumed-copy regen failed',
200
200
  },
201
- {
202
- kind: 'drift',
203
- pathspec: 'governance/rules/standards',
204
- failure:
205
- 'Standard-pointer rules drifted. Run bun run check and commit governance/rules/standards and .claude/rules.',
206
- },
207
201
  {
208
202
  kind: 'drift',
209
203
  pathspec: '.claude/rules',
210
204
  failure:
211
- 'Consumed copies drifted. Run bun run check and commit governance/rules/standards and .claude/rules.',
205
+ 'Consumed copies drifted. Run bun run check and commit .claude/rules.',
212
206
  },
213
207
  ],
214
208
  success: 'Consumed copies clean',
@@ -334,10 +328,9 @@ export const STAGES: readonly Stage[] = [
334
328
  success: 'Capability seeding clean',
335
329
  },
336
330
  {
337
- // A stack entry naming a rule folder takes every rule in it, which is what
338
- // stops a new rule from needing a second edit to reach a target. The
339
- // failure it leaves open is a rule authored into a folder no stack names,
340
- // which `canon gov install` never reaches on its own.
331
+ // A rule no stack names reaches a target only when `canon gov detect`
332
+ // proposes it, so every such rule needs a governance/detect.toml entry,
333
+ // and every key there has to name a live rule.
341
334
  id: 'unreferenced-rules',
342
335
  label: 'Unreferenced rules',
343
336
  checks: [{ kind: 'measure', measure: unreferencedRules }],
@@ -0,0 +1,301 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { basename, dirname, join } from 'node:path'
3
+ import { listRepositoryFiles } from '@/git/files'
4
+ import { indexRuleSources } from '@/gov/install'
5
+ import { resolveRules } from '@/gov/stacks'
6
+
7
+ /** One table of `governance/detect.toml`. */
8
+ export interface DetectEntry {
9
+ readonly rule: string
10
+ readonly deps: readonly string[]
11
+ readonly files: readonly string[]
12
+ }
13
+
14
+ /**
15
+ * What matched for one proposal. `path` is the manifest a dependency was read
16
+ * from, or the path a file glob matched, so a reader can open the evidence.
17
+ */
18
+ export interface Evidence {
19
+ readonly kind: 'dep' | 'file'
20
+ readonly match: string
21
+ readonly path: string
22
+ }
23
+
24
+ export interface Proposal {
25
+ readonly rule: string
26
+ readonly evidence: readonly Evidence[]
27
+ }
28
+
29
+ export type DetectResult =
30
+ | { readonly ok: true; readonly proposals: readonly Proposal[] }
31
+ | { readonly ok: false; readonly missingStack: string }
32
+
33
+ /** How deep detection reads, matching `target-setup`'s marker walk. */
34
+ const MAX_DIRS = 2
35
+
36
+ /** Trees an outside-git walk skips, since each carries manifests of its own. */
37
+ const SKIPPED_DIRS = new Set(['.git', 'node_modules', 'vendor', '.venv'])
38
+
39
+ export function detectFilePath(root: string): string {
40
+ return join(root, 'governance', 'detect.toml')
41
+ }
42
+
43
+ function stringList(value: unknown): string[] {
44
+ return Array.isArray(value)
45
+ ? value.filter((item): item is string => typeof item === 'string')
46
+ : []
47
+ }
48
+
49
+ /** Reads every table in file order. An absent file reads as no entries. */
50
+ export function loadDetectEntries(root: string): DetectEntry[] {
51
+ const path = detectFilePath(root)
52
+ if (!existsSync(path)) return []
53
+
54
+ const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
55
+ string,
56
+ unknown
57
+ >
58
+
59
+ return Object.entries(parsed).flatMap(([rule, table]) => {
60
+ if (typeof table !== 'object' || table === null) return []
61
+ const fields = table as Record<string, unknown>
62
+ return [
63
+ { rule, deps: stringList(fields.deps), files: stringList(fields.files) },
64
+ ]
65
+ })
66
+ }
67
+
68
+ /** Keys naming no rule under `governance/rules/`, sorted. */
69
+ export function staleDetectKeys(root: string): string[] {
70
+ const live = indexRuleSources(root)
71
+ return loadDetectEntries(root)
72
+ .map((entry) => entry.rule)
73
+ .filter((rule) => !live.has(rule))
74
+ .sort()
75
+ }
76
+
77
+ function depth(path: string): number {
78
+ return path.split('/').length - 1
79
+ }
80
+
81
+ /** Files to depth two when the target is not a git checkout. */
82
+ function walkFiles(target: string): string[] {
83
+ const files: string[] = []
84
+
85
+ const visit = (rel: string): void => {
86
+ const abs = rel === '' ? target : join(target, rel)
87
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
88
+ const child = rel === '' ? entry.name : `${rel}/${entry.name}`
89
+ if (entry.isDirectory()) {
90
+ if (SKIPPED_DIRS.has(entry.name)) continue
91
+ if (depth(child) < MAX_DIRS + 1) visit(child)
92
+ } else if (entry.isFile()) {
93
+ files.push(child)
94
+ }
95
+ }
96
+ }
97
+
98
+ visit('')
99
+ return files.sort()
100
+ }
101
+
102
+ /**
103
+ * The paths a file glob is matched against: every file to depth two, plus
104
+ * every folder on the way to any listed file, so an evidence folder such as
105
+ * `.github/workflows` matches without naming the file inside it.
106
+ */
107
+ async function candidatePaths(target: string): Promise<string[]> {
108
+ const listed = (await listRepositoryFiles(target)) ?? walkFiles(target)
109
+ const paths = new Set<string>()
110
+
111
+ for (const file of listed) {
112
+ let dir = dirname(file)
113
+ while (dir !== '.') {
114
+ if (depth(dir) <= MAX_DIRS) paths.add(dir)
115
+ dir = dirname(dir)
116
+ }
117
+ if (depth(file) <= MAX_DIRS) paths.add(file)
118
+ }
119
+
120
+ return [...paths].sort()
121
+ }
122
+
123
+ /** A PEP 508 requirement's distribution name, normalized per PEP 503. */
124
+ function pythonName(requirement: string): string {
125
+ const name = requirement.trim().split(/[\s<>=!~;[(@,]/)[0] ?? ''
126
+ return name.toLowerCase().replace(/[_.]+/g, '-')
127
+ }
128
+
129
+ function objectKeys(value: unknown): string[] {
130
+ return typeof value === 'object' && value !== null ? Object.keys(value) : []
131
+ }
132
+
133
+ function packageJsonDeps(text: string): string[] {
134
+ const parsed = JSON.parse(text) as Record<string, unknown>
135
+ return [
136
+ ...objectKeys(parsed.dependencies),
137
+ ...objectKeys(parsed.devDependencies),
138
+ ]
139
+ }
140
+
141
+ function composerDeps(text: string): string[] {
142
+ const parsed = JSON.parse(text) as Record<string, unknown>
143
+ return [...objectKeys(parsed.require), ...objectKeys(parsed['require-dev'])]
144
+ }
145
+
146
+ function pyprojectDeps(text: string): string[] {
147
+ const parsed = Bun.TOML.parse(text) as Record<string, unknown>
148
+ const project = (parsed.project ?? {}) as Record<string, unknown>
149
+ const requirements = [
150
+ ...stringList(project.dependencies),
151
+ ...Object.values(
152
+ (project['optional-dependencies'] ?? {}) as Record<string, unknown>,
153
+ ).flatMap(stringList),
154
+ ...Object.values(
155
+ (parsed['dependency-groups'] ?? {}) as Record<string, unknown>,
156
+ ).flatMap(stringList),
157
+ ]
158
+
159
+ const poetry = ((parsed.tool ?? {}) as Record<string, unknown>).poetry as
160
+ | Record<string, unknown>
161
+ | undefined
162
+ const groups = (poetry?.group ?? {}) as Record<string, unknown>
163
+ const poetryNames = [
164
+ ...objectKeys(poetry?.dependencies),
165
+ ...objectKeys(poetry?.['dev-dependencies']),
166
+ ...Object.values(groups).flatMap((group) =>
167
+ objectKeys((group as Record<string, unknown>)?.dependencies),
168
+ ),
169
+ ]
170
+
171
+ return [...requirements, ...poetryNames].map(pythonName)
172
+ }
173
+
174
+ function goModDeps(text: string): string[] {
175
+ const names: string[] = []
176
+ let inBlock = false
177
+
178
+ for (const raw of text.split('\n')) {
179
+ const line = raw.replace(/\/\/.*$/, '').trim()
180
+ if (inBlock) {
181
+ if (line === ')') inBlock = false
182
+ else if (line !== '') names.push(line.split(/\s+/)[0] ?? '')
183
+ } else if (/^require\s*\($/.test(line)) {
184
+ inBlock = true
185
+ } else if (line.startsWith('require ')) {
186
+ names.push(line.split(/\s+/)[1] ?? '')
187
+ }
188
+ }
189
+
190
+ return names.filter(Boolean)
191
+ }
192
+
193
+ /** Python names are normalized on read, so `detect.toml` lists them lowercase. */
194
+ const READERS: Readonly<Record<string, (text: string) => string[]>> = {
195
+ 'package.json': packageJsonDeps,
196
+ 'composer.json': composerDeps,
197
+ 'pyproject.toml': pyprojectDeps,
198
+ 'go.mod': goModDeps,
199
+ }
200
+
201
+ /**
202
+ * Every dependency name a manifest to depth two declares, mapped to the
203
+ * manifests declaring it. A manifest that does not parse contributes nothing
204
+ * rather than failing the run, since one broken fixture folder should not cost
205
+ * a project every other proposal.
206
+ */
207
+ function readDependencies(
208
+ target: string,
209
+ paths: readonly string[],
210
+ ): Map<string, string[]> {
211
+ const deps = new Map<string, string[]>()
212
+
213
+ for (const path of paths) {
214
+ const reader = READERS[basename(path)]
215
+ if (reader === undefined) continue
216
+
217
+ let declared: string[]
218
+ try {
219
+ declared = reader(readFileSync(join(target, path), 'utf8'))
220
+ } catch {
221
+ continue
222
+ }
223
+
224
+ for (const dep of declared) {
225
+ const holders = deps.get(dep) ?? []
226
+ if (!holders.includes(path)) holders.push(path)
227
+ deps.set(dep, holders)
228
+ }
229
+ }
230
+
231
+ return deps
232
+ }
233
+
234
+ /** A glob carrying no wildcard names one path rather than a pattern. */
235
+ function isLiteral(pattern: string): boolean {
236
+ return !/[*?[\]{}]/.test(pattern)
237
+ }
238
+
239
+ /**
240
+ * A literal pattern is also tested on disk, since evidence such as
241
+ * `.canon/canvas` sits in a folder a target's own `.gitignore` excludes, and
242
+ * the git listing never carries it.
243
+ */
244
+ function matchEntry(
245
+ entry: DetectEntry,
246
+ target: string,
247
+ deps: ReadonlyMap<string, readonly string[]>,
248
+ paths: readonly string[],
249
+ ): Evidence[] {
250
+ const evidence: Evidence[] = []
251
+
252
+ for (const dep of entry.deps) {
253
+ const holders = deps.get(dep) ?? []
254
+ for (const path of holders) evidence.push({ kind: 'dep', match: dep, path })
255
+ }
256
+
257
+ for (const pattern of entry.files) {
258
+ const glob = new Bun.Glob(pattern)
259
+ const hit =
260
+ paths.find((path) => glob.match(path)) ??
261
+ (isLiteral(pattern) && existsSync(join(target, pattern))
262
+ ? pattern
263
+ : undefined)
264
+ if (hit !== undefined)
265
+ evidence.push({ kind: 'file', match: pattern, path: hit })
266
+ }
267
+
268
+ return evidence
269
+ }
270
+
271
+ /**
272
+ * Proposes each opt-in rule whose evidence the target holds. A rule `stack`
273
+ * already resolves is left out, since proposing it would install nothing, and
274
+ * a key naming no live rule is skipped rather than proposed, which the
275
+ * `unreferenced-rules` gate stage reports on the toolkit side.
276
+ */
277
+ export async function detectRules(
278
+ root: string,
279
+ target: string,
280
+ stack?: string,
281
+ ): Promise<DetectResult> {
282
+ let covered = new Set<string>()
283
+ if (stack !== undefined) {
284
+ const resolution = resolveRules(root, stack)
285
+ if (!resolution.ok) return resolution
286
+ covered = new Set(resolution.rules)
287
+ }
288
+
289
+ const live = indexRuleSources(root)
290
+ const paths = await candidatePaths(target)
291
+ const deps = readDependencies(target, paths)
292
+
293
+ const proposals = loadDetectEntries(root)
294
+ .filter((entry) => live.has(entry.rule) && !covered.has(entry.rule))
295
+ .flatMap((entry) => {
296
+ const evidence = matchEntry(entry, target, deps, paths)
297
+ return evidence.length === 0 ? [] : [{ rule: entry.rule, evidence }]
298
+ })
299
+
300
+ return { ok: true, proposals }
301
+ }
@@ -66,9 +66,8 @@ export function installedRuleNames(
66
66
  }
67
67
 
68
68
  /**
69
- * Mirrors `rule_subdir` in `scripts/lib/gov.sh`, which stays in bash for the
70
- * sandbox loops. A rule sitting directly under `governance/rules/` has no
71
- * subdirectory and installs flat.
69
+ * A rule sitting directly under `governance/rules/` has no subdirectory and
70
+ * installs flat.
72
71
  */
73
72
  export function ruleSubdir(src: string, rulesRoot: string): string {
74
73
  const subdir = dirname(relative(rulesRoot, src))