@erclx/canon 5.25.0 → 5.27.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 (315) hide show
  1. package/README.md +4 -3
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +14 -1
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  20. package/claude/skills/context-fold/SKILL.md +9 -21
  21. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  22. package/claude/skills/context-fold/references/completion-report.md +26 -0
  23. package/claude/skills/create-rule/SKILL.md +2 -0
  24. package/claude/skills/create-skill/SKILL.md +2 -0
  25. package/claude/skills/create-standard/SKILL.md +2 -0
  26. package/claude/skills/decision-escalate/SKILL.md +2 -0
  27. package/claude/skills/deploy-app/SKILL.md +2 -0
  28. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  29. package/claude/skills/design-extract/SKILL.md +2 -0
  30. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  31. package/claude/skills/design-taste/SKILL.md +6 -2
  32. package/claude/skills/design-taste/references/adopted.md +40 -0
  33. package/claude/skills/design-taste/references/kinds.md +1 -1
  34. package/claude/skills/design-taste/references/systems.md +1 -7
  35. package/claude/skills/design-taste/references/tells.md +2 -24
  36. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  37. package/claude/skills/docs-sync/SKILL.md +2 -0
  38. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  39. package/claude/skills/document-health/SKILL.md +23 -0
  40. package/claude/skills/document-health/references/axes.md +12 -0
  41. package/claude/skills/draft-and-pick/SKILL.md +3 -1
  42. package/claude/skills/draft-doc/SKILL.md +2 -0
  43. package/claude/skills/draft-doc/references/wiki.md +1 -1
  44. package/claude/skills/draft-figure/SKILL.md +2 -0
  45. package/claude/skills/draft-identity/SKILL.md +3 -1
  46. package/claude/skills/draft-ready/SKILL.md +2 -0
  47. package/claude/skills/draft-screencast/SKILL.md +11 -7
  48. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  49. package/claude/skills/draft-slides/SKILL.md +33 -20
  50. package/claude/skills/git-branch/SKILL.md +2 -0
  51. package/claude/skills/git-commit/SKILL.md +2 -0
  52. package/claude/skills/git-followup/SKILL.md +2 -0
  53. package/claude/skills/git-issue/SKILL.md +2 -0
  54. package/claude/skills/git-issue/references/issue.md +10 -0
  55. package/claude/skills/git-pr/SKILL.md +2 -0
  56. package/claude/skills/git-pr/references/evidence.md +14 -6
  57. package/claude/skills/git-pr/references/labels.md +12 -0
  58. package/claude/skills/git-ship/SKILL.md +2 -0
  59. package/claude/skills/git-split/SKILL.md +21 -8
  60. package/claude/skills/git-stage/SKILL.md +2 -0
  61. package/claude/skills/git-worktree/SKILL.md +2 -0
  62. package/claude/skills/index-lookup/SKILL.md +2 -0
  63. package/claude/skills/markdown-propose/SKILL.md +2 -0
  64. package/claude/skills/markdown-propose/references/format.md +9 -0
  65. package/claude/skills/memory-capture/SKILL.md +2 -0
  66. package/claude/skills/memory-review/SKILL.md +2 -0
  67. package/claude/skills/memory-review/references/later-phases.md +7 -0
  68. package/claude/skills/plan-feature/SKILL.md +2 -0
  69. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  70. package/claude/skills/plan-intake/SKILL.md +2 -0
  71. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  72. package/claude/skills/project-commands/SKILL.md +2 -0
  73. package/claude/skills/read-frames/SKILL.md +2 -0
  74. package/claude/skills/record-screencast/SKILL.md +2 -0
  75. package/claude/skills/repo-metadata/SKILL.md +2 -0
  76. package/claude/skills/restate-plainly/SKILL.md +2 -0
  77. package/claude/skills/review-address/SKILL.md +3 -7
  78. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  79. package/claude/skills/review-branch/SKILL.md +2 -0
  80. package/claude/skills/review-craft/SKILL.md +2 -0
  81. package/claude/skills/review-craft/references/ui.md +2 -2
  82. package/claude/skills/review-pr/SKILL.md +4 -8
  83. package/claude/skills/review-pr/references/body-variants.md +52 -0
  84. package/claude/skills/review-pr/references/key-changes.md +16 -0
  85. package/claude/skills/review-pr/references/post.md +15 -43
  86. package/claude/skills/review-ui/SKILL.md +3 -1
  87. package/claude/skills/review-ui/references/post.md +4 -2
  88. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  89. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  91. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  92. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  93. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  94. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  95. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  96. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  97. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  98. package/claude/skills/role-planner/SKILL.md +2 -0
  99. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  100. package/claude/skills/role-reviewer/SKILL.md +2 -0
  101. package/claude/skills/role-worker/SKILL.md +2 -0
  102. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  103. package/claude/skills/search-craft/SKILL.md +5 -1
  104. package/claude/skills/search-craft/references/adopted.md +30 -0
  105. package/claude/skills/seed-sync/SKILL.md +2 -0
  106. package/claude/skills/session-compact/SKILL.md +2 -0
  107. package/claude/skills/session-map/SKILL.md +2 -0
  108. package/claude/skills/session-relay/SKILL.md +2 -0
  109. package/claude/skills/session-resume/SKILL.md +2 -0
  110. package/claude/skills/session-worktree/SKILL.md +3 -11
  111. package/claude/skills/session-worktree/references/install-command.md +20 -0
  112. package/claude/skills/sketch-design/SKILL.md +2 -0
  113. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  114. package/claude/skills/target-check/SKILL.md +2 -0
  115. package/claude/skills/target-setup/SKILL.md +2 -0
  116. package/claude/skills/target-setup/references/detect.md +8 -0
  117. package/claude/skills/target-setup/references/indexes.md +13 -0
  118. package/claude/skills/target-setup/references/verify.md +8 -0
  119. package/claude/skills/task-board/SKILL.md +2 -0
  120. package/claude/skills/teach-workspace/SKILL.md +5 -15
  121. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  122. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  123. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  124. package/claude/skills/test-craft/SKILL.md +2 -0
  125. package/claude/skills/test-first/SKILL.md +4 -0
  126. package/claude/skills/test-first/references/adopted.md +36 -0
  127. package/claude/skills/ui-checklist/SKILL.md +2 -0
  128. package/claude/skills/ux-audit/SKILL.md +2 -0
  129. package/claude/skills/ux-measure/SKILL.md +2 -0
  130. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  131. package/claude/skills/video-craft/SKILL.md +2 -0
  132. package/claude/skills/write-human/SKILL.md +3 -1
  133. package/claude/skills/write-human/references/machine-tells.md +13 -0
  134. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  135. package/docs/agents/canvas.md +24 -6
  136. package/docs/agents/capture.md +6 -6
  137. package/docs/agents/commands.md +5 -5
  138. package/docs/agents/index.md +5 -5
  139. package/docs/agents/install-and-sync.md +7 -0
  140. package/docs/agents/key-changes.md +1 -1
  141. package/docs/agents/pr-evidence.md +38 -15
  142. package/docs/agents/pr-local.md +19 -13
  143. package/docs/agents/pr-preview.md +44 -21
  144. package/docs/agents/records-stale.md +22 -9
  145. package/docs/agents/records.md +1 -1
  146. package/docs/agents/sandbox.md +29 -5
  147. package/docs/agents/scripting.md +8 -1
  148. package/docs/agents/skills-audit.md +11 -3
  149. package/docs/agents/slides.md +15 -3
  150. package/docs/workflow/ai-workflow.md +1 -1
  151. package/docs/workflow/index.md +1 -0
  152. package/docs/workflow/operating-model.md +5 -6
  153. package/docs/workflow/parallel-features.md +23 -2
  154. package/docs/workflow/skill-map-on-request.md +42 -0
  155. package/docs/workflow/skill-map.md +3 -34
  156. package/docs/workflow/zshrc-aliases.md +1 -1
  157. package/package.json +1 -1
  158. package/scripts/core/regen-hero.ts +1 -1
  159. package/scripts/lib/sandbox-git.sh +1 -9
  160. package/scripts/lib/sandbox-path.sh +6 -162
  161. package/scripts/sandbox-hook.sh +99 -0
  162. package/src/audits/run.ts +1 -1
  163. package/src/canvas/address.ts +12 -3
  164. package/src/canvas/capture.ts +1 -9
  165. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  166. package/src/canvas/client/inspector/color-field.tsx +398 -0
  167. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  168. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  169. package/src/canvas/client/inspector/color-space.ts +240 -0
  170. package/src/canvas/client/inspector/field.tsx +5 -1
  171. package/src/canvas/client/inspector/fields.css +547 -17
  172. package/src/canvas/client/inspector/size-field.tsx +69 -0
  173. package/src/canvas/client/inspector/values.ts +166 -1
  174. package/src/canvas/client/inspector.tsx +236 -73
  175. package/src/canvas/client/selection.tsx +197 -0
  176. package/src/canvas/client/shell.css +104 -12
  177. package/src/canvas/client/state.ts +69 -9
  178. package/src/canvas/client/surface.tsx +147 -22
  179. package/src/canvas/content.ts +50 -1
  180. package/src/canvas/edit.ts +37 -4
  181. package/src/canvas/editing.ts +203 -0
  182. package/src/canvas/global.d.ts +15 -0
  183. package/src/canvas/server.ts +149 -12
  184. package/src/canvas/tokens.ts +59 -17
  185. package/src/capture/render.ts +45 -7
  186. package/src/capture/sources.ts +43 -0
  187. package/src/census/count.ts +2 -2
  188. package/src/claude/cases/workflow.ts +5 -0
  189. package/src/claude/seeds.ts +2 -2
  190. package/src/claude/skills-audit.ts +96 -14
  191. package/src/claude/skills-drift.ts +1 -1
  192. package/src/claude/skills-families.ts +36 -0
  193. package/src/claude/skills-list.ts +76 -15
  194. package/src/commands/audits.ts +2 -2
  195. package/src/commands/canvas.ts +179 -2
  196. package/src/commands/claude.ts +94 -11
  197. package/src/commands/design.ts +142 -2
  198. package/src/commands/feedback.ts +3 -3
  199. package/src/commands/gov.ts +1 -1
  200. package/src/commands/hooks.ts +1 -1
  201. package/src/commands/indexes.ts +1 -1
  202. package/src/commands/init.ts +1 -1
  203. package/src/commands/intake.ts +1 -1
  204. package/src/commands/migrate.ts +1 -1
  205. package/src/commands/pr.ts +105 -58
  206. package/src/commands/records.ts +89 -7
  207. package/src/commands/repo.ts +1 -1
  208. package/src/commands/sandbox.ts +103 -11
  209. package/src/commands/sessions.ts +1 -1
  210. package/src/commands/slides.ts +31 -77
  211. package/src/commands/sync.ts +1 -1
  212. package/src/commands/tasks.ts +2 -2
  213. package/src/commands/teach.ts +1 -1
  214. package/src/commands/transcripts.ts +1 -1
  215. package/src/commands/worktrees.ts +1 -1
  216. package/src/comments/scan.ts +1 -1
  217. package/src/comments/trend.ts +1 -1
  218. package/src/context/audit.ts +1 -1
  219. package/src/context/citations.ts +1 -1
  220. package/src/context/classify/extract.ts +2 -2
  221. package/src/design/project-fonts.ts +392 -0
  222. package/src/design/tokens.ts +2 -18
  223. package/src/docs/read.ts +1 -1
  224. package/src/{github.ts → feedback/github.ts} +2 -2
  225. package/src/{client-commands.ts → gate/client-commands.ts} +1 -1
  226. package/src/gate/measures.ts +53 -13
  227. package/src/gate/sequencer.ts +1 -1
  228. package/src/gate/stages.ts +9 -0
  229. package/src/{git-files.ts → git/files.ts} +2 -2
  230. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  231. package/src/{worktree.ts → git/worktree.ts} +1 -1
  232. package/src/gov/citations.ts +2 -2
  233. package/src/gov/install.ts +1 -1
  234. package/src/gov/payload.ts +1 -1
  235. package/src/gov/superseded.ts +3 -3
  236. package/src/gov/test-order.ts +2 -2
  237. package/src/help.ts +1 -1
  238. package/src/hooks/post-merge.ts +1 -1
  239. package/src/indexes/walk.ts +1 -1
  240. package/src/labels/audit.ts +1 -1
  241. package/src/markdown/ceiling.ts +1 -1
  242. package/src/markdown/files.ts +1 -1
  243. package/src/migrate/apply.ts +1 -1
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +96 -43
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/record-root.ts +1 -0
  249. package/src/records/backup.ts +2 -1
  250. package/src/records/push-guard.ts +1 -1
  251. package/src/records/stale-canonical.ts +146 -0
  252. package/src/records/stale.ts +1 -1
  253. package/src/sandbox/headless/dispatch.ts +264 -0
  254. package/src/sandbox/headless/run.ts +653 -0
  255. package/src/sandbox/headless/watch.ts +145 -0
  256. package/src/sandbox/hooks.ts +128 -0
  257. package/src/sandbox/provision.ts +607 -0
  258. package/src/sandbox/tree.ts +99 -4
  259. package/src/secrets/marker.ts +2 -2
  260. package/src/secrets/scan.ts +2 -2
  261. package/src/sessions/claim.ts +1 -1
  262. package/src/sessions/resolve.ts +1 -1
  263. package/src/sessions/transfer/bundle.ts +1 -1
  264. package/src/shipped/references.ts +2 -2
  265. package/src/slides/convert/export.ts +2 -16
  266. package/src/slides/decks.ts +110 -0
  267. package/src/slides/package/fonts.ts +6 -2
  268. package/src/standards/audit.ts +2 -2
  269. package/src/standards/read.ts +1 -1
  270. package/src/sync/check.ts +2 -2
  271. package/src/sync/engine.ts +2 -2
  272. package/src/sync/git.ts +1 -1
  273. package/src/sync/history.ts +1 -1
  274. package/src/sync/reverse.ts +1 -1
  275. package/src/sync/seeds-report.ts +1 -1
  276. package/src/sync/target.ts +1 -1
  277. package/src/sync/workflow.ts +1 -1
  278. package/src/targets/pulls.ts +2 -2
  279. package/src/targets/sweep.ts +2 -2
  280. package/src/tasks/answers.ts +1 -1
  281. package/src/tasks/archive.ts +1 -1
  282. package/src/tasks/reach.ts +1 -1
  283. package/src/tasks/trunk.ts +1 -1
  284. package/src/teach/nav.ts +1 -1
  285. package/src/tooling/inject.ts +2 -2
  286. package/src/tooling/subfolder.ts +1 -1
  287. package/src/web/readme-citations.ts +2 -2
  288. package/src/worktrees/bare-flag.ts +1 -1
  289. package/src/worktrees/reclaim.ts +2 -2
  290. package/src/worktrees/remove.ts +1 -1
  291. package/standards/architecture.md +1 -0
  292. package/standards/canvas.md +3 -2
  293. package/standards/requirements.md +2 -0
  294. package/standards/skill-practice.md +2 -1
  295. package/standards/skill.md +2 -0
  296. package/standards/wiki.md +12 -3
  297. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  298. package/scripts/lib/sandbox-dispatch.sh +0 -190
  299. package/scripts/manage-sandbox.sh +0 -472
  300. package/src/slides/layouts.ts +0 -478
  301. package/src/slides/parse.ts +0 -84
  302. package/src/slides/render.ts +0 -110
  303. package/src/slides/styles.ts +0 -96
  304. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  305. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  306. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  307. /package/src/{git-base.ts → git/base.ts} +0 -0
  308. /package/src/{git-env.ts → git/env.ts} +0 -0
  309. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  310. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  311. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  312. /package/src/{copy.ts → sync/copy.ts} +0 -0
  313. /package/src/{target.ts → targets/validate.ts} +0 -0
  314. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  315. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -2,9 +2,13 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { basename, dirname } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { chromium } from 'playwright-core'
5
- import type { Browser, Page } from 'playwright-core'
5
+ import type { Browser, Locator, Page } from 'playwright-core'
6
6
  import type { CaptureSource } from '@/capture/sources'
7
- import { primaryFontFamily, resolveCaptureSources } from '@/capture/sources'
7
+ import {
8
+ isGenericFamily,
9
+ resolveCaptureSources,
10
+ textFamilies,
11
+ } from '@/capture/sources'
8
12
  import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
9
13
 
10
14
  /**
@@ -97,15 +101,22 @@ async function captureOne(
97
101
  }
98
102
 
99
103
  await page.evaluate(() => document.fonts.ready.then(() => undefined))
100
- const family = primaryFontFamily(
101
- await element.evaluate((node) => getComputedStyle(node).fontFamily),
102
- )
103
- if (family && !(await resolvesFont(page, family))) {
104
+ const declarations = await renderedTextFamilies(element)
105
+ const generic = declarations.find(isGenericFamily)
106
+ if (generic !== undefined) {
104
107
  return failed(
105
108
  source,
106
- `${family} is not installed, so the capture would rewrap against a fallback`,
109
+ `${generic.split(',')[0]?.trim()} is a generic family that resolves differently per machine, so name a real font`,
107
110
  )
108
111
  }
112
+ for (const family of textFamilies(declarations)) {
113
+ if (!(await resolvesFont(page, family))) {
114
+ return failed(
115
+ source,
116
+ `${family} is not installed, so the capture would rewrap against a fallback`,
117
+ )
118
+ }
119
+ }
109
120
 
110
121
  mkdirSync(dirname(source.pngPath), { recursive: true })
111
122
  const png = await element.screenshot({ omitBackground: true })
@@ -170,6 +181,33 @@ function failed(source: CaptureSource, reason: string): CaptureResult {
170
181
  return { status: 'failed', source: sourceIdentifier(source), reason }
171
182
  }
172
183
 
184
+ /**
185
+ * The computed families of the captured element and of each element under it
186
+ * holding a non-blank text node of its own that draws a box, which is the text
187
+ * the screenshot shows. Testing the text node's box rather than the element's
188
+ * keeps out `<style>`, `<script>`, `<title>`, and anything hidden, whose
189
+ * computed family is the browser default and would refuse every source that
190
+ * styles a descendant rather than the root, and keeps in text directly inside a
191
+ * `display: contents` element, which draws no box of its own.
192
+ *
193
+ * Returns nothing when no element holds text, since a font cannot rewrap text
194
+ * that is not there, so a container of iframes skips the probe.
195
+ */
196
+ async function renderedTextFamilies(element: Locator): Promise<string[]> {
197
+ return element.evaluate((root) => {
198
+ const range = document.createRange()
199
+ const holdsText = (node: Element): boolean =>
200
+ [...node.childNodes].some((child) => {
201
+ if (child.nodeType !== Node.TEXT_NODE) return false
202
+ if ((child.textContent ?? '').trim() === '') return false
203
+ range.selectNodeContents(child)
204
+ return range.getClientRects().length > 0
205
+ })
206
+ const texts = [root, ...root.querySelectorAll('*')].filter(holdsText)
207
+ return texts.map((node) => getComputedStyle(node).fontFamily)
208
+ })
209
+ }
210
+
173
211
  /**
174
212
  * Compares the family's text metrics against a family that cannot exist. Equal
175
213
  * widths mean the browser fell through to its default for both, which is the
@@ -66,3 +66,46 @@ export function primaryFontFamily(declaration: string): string {
66
66
  const first = declaration.split(',')[0]?.trim() ?? ''
67
67
  return first.replace(/^['"]|['"]$/g, '')
68
68
  }
69
+
70
+ /**
71
+ * The CSS generic family keywords. A generic names no font, so each machine
72
+ * maps it to whatever it has, and a capture measured against one cannot
73
+ * reproduce on another.
74
+ */
75
+ export const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
76
+ 'serif',
77
+ 'sans-serif',
78
+ 'monospace',
79
+ 'cursive',
80
+ 'fantasy',
81
+ 'system-ui',
82
+ 'ui-serif',
83
+ 'ui-sans-serif',
84
+ 'ui-monospace',
85
+ 'ui-rounded',
86
+ 'math',
87
+ 'emoji',
88
+ 'fangsong',
89
+ ])
90
+
91
+ /**
92
+ * Whether the first family of a computed `font-family` is a generic keyword.
93
+ * Takes the raw declaration rather than `primaryFontFamily`'s result, since
94
+ * quoting is what separates the keyword from a family that happens to share its
95
+ * name, and the stripped form has lost it. Keywords match case-insensitively.
96
+ */
97
+ export function isGenericFamily(declaration: string): boolean {
98
+ const first = declaration.split(',')[0]?.trim() ?? ''
99
+ return GENERIC_FAMILIES.has(first.toLowerCase())
100
+ }
101
+
102
+ /**
103
+ * Reduces the computed `font-family` of each rendered text element to the
104
+ * distinct families a capture has to prove resolved, in document order, so a
105
+ * frame that sets its font on any ancestor of its text is checked against the
106
+ * face the text actually draws in.
107
+ */
108
+ export function textFamilies(declarations: readonly string[]): string[] {
109
+ const families = declarations.map(primaryFontFamily).filter(Boolean)
110
+ return [...new Set(families)]
111
+ }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { extname, join } from 'node:path'
3
- import { isBinary } from '@/binary'
4
- import { listRepositoryFiles } from '@/git-files'
3
+ import { isBinary } from '@/sweep/binary'
4
+ import { listRepositoryFiles } from '@/git/files'
5
5
 
6
6
  export type CensusRefusal = 'no-git'
7
7
 
@@ -146,6 +146,11 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
146
146
  'Which of our docs have gone stale, grown too long, or ended up in the wrong file?',
147
147
  expect: 'document-health',
148
148
  },
149
+ {
150
+ prompt:
151
+ 'Are our requirements and architecture docs still true, and when were they last reviewed?',
152
+ expect: 'document-health',
153
+ },
149
154
  {
150
155
  prompt: 'Open a new entry on the task board for this piece of work.',
151
156
  expect: 'task-board',
@@ -1,9 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative } from 'node:path'
4
- import { copyPreservingMode } from '@/copy'
4
+ import { copyPreservingMode } from '@/sync/copy'
5
5
  import { creationRel, isRecordEntry } from '@/record-root'
6
- import { rewritesOnInstall, stripSeedMarker } from '@/seed-marker'
6
+ import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
7
7
  import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
8
8
 
9
9
  const SEEDS_DIR = join('tooling', 'claude', 'seeds')
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
+ import { isFamilyKey } from '@/claude/skills-families'
3
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
4
5
  import { bodyLines, maskDisplayed } from '@/markdown/scan'
5
6
 
@@ -40,6 +41,21 @@ export const PRACTICE_SKILLS: readonly string[] = [
40
41
  join(SHIPPED_CORPUS, 'code-craft'),
41
42
  join(SHIPPED_CORPUS, 'build-in-slices'),
42
43
  join(SHIPPED_CORPUS, 'deprecation-migration'),
44
+ join(SHIPPED_CORPUS, 'search-craft'),
45
+ join(SHIPPED_CORPUS, 'video-craft'),
46
+ ]
47
+
48
+ /**
49
+ * Every skill carrying the source ledger, being the practice skills and the
50
+ * skills that teach a craft without the practice closing sections. Kept apart
51
+ * from `PRACTICE_SKILLS` so citing sources never forces those sections onto a
52
+ * skill whose body is not shaped around a practice a session cuts short.
53
+ */
54
+ export const LEDGER_SKILLS: readonly string[] = [
55
+ ...PRACTICE_SKILLS,
56
+ join(SHIPPED_CORPUS, 'design-taste'),
57
+ join(SHIPPED_CORPUS, 'write-human'),
58
+ join(SHIPPED_CORPUS, 'test-first'),
43
59
  ]
44
60
 
45
61
  /** The closing H2s a practice skill carries, matched exactly outside fences. */
@@ -52,6 +68,16 @@ export const PRACTICE_SECTIONS: readonly string[] = [
52
68
  /** The source ledger every practice skill carries beside its body. */
53
69
  export const PRACTICE_LEDGER = join('references', 'adopted.md')
54
70
 
71
+ /**
72
+ * The line count past which a reference opens with a contents list, from
73
+ * Anthropic's skill authoring guidance. Counted over the whole file the way
74
+ * `wc -l` counts it, so "over 100" holds at 101 and not at 100.
75
+ */
76
+ export const CONTENTS_THRESHOLD = 100
77
+
78
+ /** The H2 a long reference opens with, matched exactly outside fences. */
79
+ export const CONTENTS_SECTION = 'Contents'
80
+
55
81
  /**
56
82
  * The one reason this audit refuses. A project carrying neither corpus is the
57
83
  * ordinary state of a target that has not adopted either skills convention,
@@ -102,6 +128,8 @@ export interface SkillsAudit {
102
128
  readonly requirementSections: readonly SkillFinding[]
103
129
  readonly datedProvenance: readonly SkillFinding[]
104
130
  readonly practiceShape: readonly SkillFinding[]
131
+ readonly referenceContents: readonly SkillFinding[]
132
+ readonly family: readonly SkillFinding[]
105
133
  }
106
134
 
107
135
  interface SkillSource {
@@ -110,6 +138,8 @@ interface SkillSource {
110
138
  readonly hasReadme: boolean
111
139
  readonly name: string | undefined
112
140
  readonly description: string | undefined
141
+ /** `metadata.family`, undefined when absent, blank, or not a string. */
142
+ readonly family: string | undefined
113
143
  /** Undefined when the folder carries no `REQUIREMENT.md` at all. */
114
144
  readonly requirementHeadings: readonly string[] | undefined
115
145
  /** Every H2 in `SKILL.md` outside a fence, for the practice shape. */
@@ -126,6 +156,7 @@ interface SkillSource {
126
156
  interface SkillText {
127
157
  readonly rel: string
128
158
  readonly text: string
159
+ readonly isReference: boolean
129
160
  }
130
161
 
131
162
  /**
@@ -177,6 +208,10 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
177
208
  ),
178
209
  ...sources.flatMap(practiceFindings),
179
210
  ],
211
+ referenceContents: sources.flatMap((source) =>
212
+ source.bodies.flatMap(contentsFindings),
213
+ ),
214
+ family: sources.flatMap(familyFindings),
180
215
  }
181
216
  }
182
217
 
@@ -231,18 +266,17 @@ async function readSkill(
231
266
  hasReadme: existsSync(join(skillDir, 'README.md')),
232
267
  name: declared(readField(fields, 'name')),
233
268
  description: declared(readField(fields, 'description')),
269
+ family: declaredFamily(fields?.fields.metadata),
234
270
  requirementHeadings:
235
271
  requirement === undefined ? undefined : headings(requirement),
236
- sections: bodyLines(body)
237
- .filter((line) => !line.fenced)
238
- .map((line) => SECTION.exec(line.text)?.[1])
239
- .filter((text): text is string => text !== undefined),
272
+ sections: sectionsOf(body),
240
273
  hasLedger: existsSync(join(skillDir, PRACTICE_LEDGER)),
241
274
  bodies: [
242
- { rel: join(skillRel, 'SKILL.md'), text: body },
275
+ { rel: join(skillRel, 'SKILL.md'), text: body, isReference: false },
243
276
  ...references.map((path, index) => ({
244
277
  rel: join(skillRel, path),
245
278
  text: referenceTexts[index] ?? '',
279
+ isReference: true,
246
280
  })),
247
281
  ],
248
282
  }
@@ -257,6 +291,21 @@ function declared(value: string | undefined): string | undefined {
257
291
  return trimmed === undefined || trimmed === '' ? undefined : trimmed
258
292
  }
259
293
 
294
+ function declaredFamily(metadata: unknown): string | undefined {
295
+ if (typeof metadata !== 'object' || metadata === null) return undefined
296
+ if (Array.isArray(metadata)) return undefined
297
+ const { family } = metadata as Record<string, unknown>
298
+ return typeof family === 'string' ? declared(family) : undefined
299
+ }
300
+
301
+ /** Every H2 outside a fence, the one level both exact-section checks read. */
302
+ function sectionsOf(source: string): string[] {
303
+ return bodyLines(source)
304
+ .filter((line) => !line.fenced)
305
+ .map((line) => SECTION.exec(line.text)?.[1])
306
+ .filter((text): text is string => text !== undefined)
307
+ }
308
+
260
309
  function headings(source: string): string[] {
261
310
  return source
262
311
  .split('\n')
@@ -311,7 +360,7 @@ function missingPracticeSkills(
311
360
  ): SkillFinding[] {
312
361
  if (!hasShippedCorpus) return []
313
362
  const found = new Set(sources.map((source) => source.rel))
314
- return PRACTICE_SKILLS.filter((rel) => !found.has(rel)).map((rel) => ({
363
+ return LEDGER_SKILLS.filter((rel) => !found.has(rel)).map((rel) => ({
315
364
  rel,
316
365
  detail: 'missing skill: no folder under the shipped corpus',
317
366
  }))
@@ -323,14 +372,15 @@ function missingPracticeSkills(
323
372
  * standard never names.
324
373
  */
325
374
  function practiceFindings(source: SkillSource): SkillFinding[] {
326
- if (!PRACTICE_SKILLS.includes(source.rel)) return []
327
-
328
- const sections = PRACTICE_SECTIONS.filter(
329
- (section) => !source.sections.includes(section),
330
- ).map((section) => ({
331
- rel: source.rel,
332
- detail: `missing section: ${section}`,
333
- }))
375
+ if (!LEDGER_SKILLS.includes(source.rel)) return []
376
+
377
+ const isPractice = PRACTICE_SKILLS.includes(source.rel)
378
+ const sections = (isPractice ? PRACTICE_SECTIONS : [])
379
+ .filter((section) => !source.sections.includes(section))
380
+ .map((section) => ({
381
+ rel: source.rel,
382
+ detail: `missing section: ${section}`,
383
+ }))
334
384
  const ledger = source.hasLedger
335
385
  ? []
336
386
  : [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
@@ -338,6 +388,38 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
338
388
  return [...sections, ...ledger]
339
389
  }
340
390
 
391
+ /**
392
+ * Asks only the shipped corpus for a family, since the family names the skill
393
+ * map group a skill's row sits under and an internal skill takes no row.
394
+ */
395
+ function familyFindings(source: SkillSource): SkillFinding[] {
396
+ if (dirname(source.rel) !== SHIPPED_CORPUS) return []
397
+ if (source.family === undefined) {
398
+ return [
399
+ { rel: source.rel, detail: 'frontmatter declares no metadata.family' },
400
+ ]
401
+ }
402
+ if (isFamilyKey(source.family)) return []
403
+ return [{ rel: source.rel, detail: `unknown family: ${source.family}` }]
404
+ }
405
+
406
+ /**
407
+ * Reads references alone, since `SKILL.md` carries its own length checkpoint
408
+ * and no contents rule. Frontmatter counts toward the length, so the count a
409
+ * finding names is the one `wc -l` prints beside it.
410
+ */
411
+ function contentsFindings(source: SkillText): SkillFinding[] {
412
+ if (!source.isReference) return []
413
+
414
+ const lines = source.text.replace(/\n$/, '').split('\n').length
415
+ if (lines <= CONTENTS_THRESHOLD) return []
416
+ if (sectionsOf(source.text).includes(CONTENTS_SECTION)) return []
417
+
418
+ return [
419
+ { rel: source.rel, detail: `${lines} lines, no ## ${CONTENTS_SECTION}` },
420
+ ]
421
+ }
422
+
341
423
  /**
342
424
  * A date inside a fence or a code span is example data a reader is shown, such
343
425
  * as a sample frontmatter block, so only prose outside both is read.
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { execaSync } from 'execa'
4
- import { gitEnv } from '@/git-env'
4
+ import { gitEnv } from '@/git/env'
5
5
 
6
6
  /**
7
7
  * The shipped corpus alone. A session loads a body from the plugin root, which
@@ -0,0 +1,36 @@
1
+ export interface SkillFamily {
2
+ /** The value a shipped `SKILL.md` declares as `metadata.family`. */
3
+ readonly key: string
4
+ /** The skill map heading the family's rows sit under, verbatim. */
5
+ readonly group: string
6
+ }
7
+
8
+ /**
9
+ * The one vocabulary a shipped skill's family is drawn from, in the order the
10
+ * skill map reads across its two pages. A heading is the map's prose and a key
11
+ * is what frontmatter carries, so renaming a heading touches this constant and
12
+ * no skill.
13
+ *
14
+ * Import-free on purpose: `skills-list.ts` and `skills-audit.ts` both read it,
15
+ * and the first already imports the second.
16
+ */
17
+ export const SKILL_FAMILIES: readonly SkillFamily[] = [
18
+ { key: 'setup', group: 'Set up a project' },
19
+ { key: 'decide', group: 'Decide what to build' },
20
+ { key: 'build', group: 'Build the feature' },
21
+ { key: 'check', group: 'Check the work before it leaves the branch' },
22
+ { key: 'ship', group: 'Ship it' },
23
+ { key: 'after-pr', group: 'After the pull request opens' },
24
+ { key: 'parallel', group: 'Run several tracks at once' },
25
+ { key: 'upkeep', group: 'Keep the project current with the toolkit' },
26
+ { key: 'generate', group: 'Generate an artifact on demand' },
27
+ { key: 'answer', group: 'Answer a question at any point' },
28
+ ]
29
+
30
+ export function familyKeys(): string[] {
31
+ return SKILL_FAMILIES.map((family) => family.key)
32
+ }
33
+
34
+ export function isFamilyKey(value: string): boolean {
35
+ return SKILL_FAMILIES.some((family) => family.key === value)
36
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { CORPORA } from '@/claude/skills-audit'
4
+ import { isFamilyKey, SKILL_FAMILIES } from '@/claude/skills-families'
4
5
 
5
6
  const FRONTMATTER = /^---\n([\s\S]*?)\n---/
6
7
 
@@ -8,6 +9,11 @@ export interface SkillListing {
8
9
  readonly name: string
9
10
  readonly description: string
10
11
  readonly requirement: boolean
12
+ /**
13
+ * The `metadata.family` the frontmatter declares, read as written. Whether it
14
+ * names a vocabulary key is the audit's finding, not the listing's.
15
+ */
16
+ readonly family: string | null
11
17
  }
12
18
 
13
19
  export interface SkillsCorpus {
@@ -72,28 +78,83 @@ export function listSkillsAt(skillsRoot: string): SkillListing[] {
72
78
  }),
73
79
  ].sort()
74
80
 
75
- return paths.map((path) => ({
76
- name: dirname(path),
77
- description: readDescription(join(skillsRoot, path)),
78
- requirement: existsSync(join(skillsRoot, dirname(path), 'REQUIREMENT.md')),
79
- }))
81
+ return paths.map((path) => {
82
+ const { description, family } = readListedFields(join(skillsRoot, path))
83
+ return {
84
+ name: dirname(path),
85
+ description,
86
+ requirement: existsSync(
87
+ join(skillsRoot, dirname(path), 'REQUIREMENT.md'),
88
+ ),
89
+ family,
90
+ }
91
+ })
92
+ }
93
+
94
+ export interface FamilyGroup {
95
+ readonly key: string
96
+ readonly group: string
97
+ readonly skills: readonly string[]
98
+ }
99
+
100
+ export interface FamilyGrouping {
101
+ /** Vocabulary order, holding only the families some listing declares. */
102
+ readonly groups: readonly FamilyGroup[]
103
+ /** Listings whose family is missing or names no vocabulary key. */
104
+ readonly unassigned: readonly string[]
105
+ }
106
+
107
+ export function groupByFamily(
108
+ listings: readonly SkillListing[],
109
+ ): FamilyGrouping {
110
+ const groups = SKILL_FAMILIES.map((family) => ({
111
+ ...family,
112
+ skills: listings
113
+ .filter((listing) => listing.family === family.key)
114
+ .map((listing) => listing.name),
115
+ })).filter((group) => group.skills.length > 0)
116
+
117
+ return {
118
+ groups,
119
+ unassigned: listings
120
+ .filter(
121
+ (listing) => listing.family === null || !isFamilyKey(listing.family),
122
+ )
123
+ .map((listing) => listing.name),
124
+ }
125
+ }
126
+
127
+ interface ListedFields {
128
+ readonly description: string
129
+ readonly family: string | null
80
130
  }
81
131
 
82
132
  /**
83
- * Returns an empty description rather than throwing on a skill whose
84
- * frontmatter is missing or unparseable, so one malformed file does not hide
85
- * the rest of the catalog from a caller counting it.
133
+ * Returns an empty description and a null family rather than throwing on a
134
+ * skill whose frontmatter is missing or unparseable, so one malformed file
135
+ * does not hide the rest of the catalog from a caller counting it.
86
136
  */
87
- function readDescription(path: string): string {
137
+ function readListedFields(path: string): ListedFields {
138
+ const empty = { description: '', family: null }
88
139
  const match = FRONTMATTER.exec(readFileSync(path, 'utf8'))
89
- if (!match) return ''
140
+ if (!match) return empty
90
141
 
142
+ let parsed: unknown
91
143
  try {
92
- const parsed = Bun.YAML.parse(match[1])
93
- if (typeof parsed !== 'object' || parsed === null) return ''
94
- const { description } = parsed as Record<string, unknown>
95
- return typeof description === 'string' ? description : ''
144
+ parsed = Bun.YAML.parse(match[1])
96
145
  } catch {
97
- return ''
146
+ return empty
98
147
  }
148
+ if (!isRecord(parsed)) return empty
149
+
150
+ const { description, metadata } = parsed
151
+ const family = isRecord(metadata) ? metadata.family : undefined
152
+ return {
153
+ description: typeof description === 'string' ? description : '',
154
+ family: typeof family === 'string' ? family : null,
155
+ }
156
+ }
157
+
158
+ function isRecord(value: unknown): value is Record<string, unknown> {
159
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
99
160
  }
@@ -19,9 +19,9 @@ import {
19
19
  type Summary,
20
20
  summarize,
21
21
  } from '@/audits/run'
22
- import { gitEnv } from '@/git-env'
22
+ import { gitEnv } from '@/git/env'
23
23
  import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
24
- import { currentWorktreeRoot } from '@/worktree'
24
+ import { currentWorktreeRoot } from '@/git/worktree'
25
25
 
26
26
  interface RunCommandOptions {
27
27
  readonly json?: boolean