@erclx/canon 5.26.0 → 5.28.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 (320) hide show
  1. package/README.md +2 -2
  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 +13 -0
  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/SKILL.md +3 -17
  20. package/claude/skills/context-fold/references/completion-report.md +26 -0
  21. package/claude/skills/create-rule/SKILL.md +2 -0
  22. package/claude/skills/create-skill/SKILL.md +2 -0
  23. package/claude/skills/create-standard/SKILL.md +2 -0
  24. package/claude/skills/decision-escalate/SKILL.md +2 -0
  25. package/claude/skills/deploy-app/SKILL.md +2 -0
  26. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  27. package/claude/skills/design-extract/SKILL.md +2 -0
  28. package/claude/skills/design-taste/SKILL.md +2 -0
  29. package/claude/skills/docs-sync/SKILL.md +2 -0
  30. package/claude/skills/document-health/SKILL.md +2 -0
  31. package/claude/skills/draft-and-pick/SKILL.md +2 -0
  32. package/claude/skills/draft-doc/SKILL.md +2 -0
  33. package/claude/skills/draft-doc/references/wiki.md +1 -1
  34. package/claude/skills/draft-figure/SKILL.md +2 -0
  35. package/claude/skills/draft-identity/SKILL.md +2 -0
  36. package/claude/skills/draft-ready/SKILL.md +5 -1
  37. package/claude/skills/draft-screencast/SKILL.md +2 -0
  38. package/claude/skills/draft-slides/SKILL.md +2 -0
  39. package/claude/skills/git-branch/SKILL.md +2 -0
  40. package/claude/skills/git-commit/SKILL.md +2 -0
  41. package/claude/skills/git-followup/SKILL.md +2 -0
  42. package/claude/skills/git-issue/SKILL.md +2 -0
  43. package/claude/skills/git-pr/SKILL.md +2 -0
  44. package/claude/skills/git-pr/references/evidence.md +7 -7
  45. package/claude/skills/git-ship/SKILL.md +2 -0
  46. package/claude/skills/git-split/SKILL.md +2 -0
  47. package/claude/skills/git-stage/SKILL.md +2 -0
  48. package/claude/skills/git-worktree/SKILL.md +2 -0
  49. package/claude/skills/index-lookup/SKILL.md +2 -0
  50. package/claude/skills/markdown-propose/SKILL.md +2 -0
  51. package/claude/skills/memory-capture/SKILL.md +2 -0
  52. package/claude/skills/memory-review/SKILL.md +2 -0
  53. package/claude/skills/plan-feature/SKILL.md +2 -0
  54. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  55. package/claude/skills/plan-intake/SKILL.md +2 -0
  56. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  57. package/claude/skills/project-commands/SKILL.md +2 -0
  58. package/claude/skills/read-frames/SKILL.md +2 -0
  59. package/claude/skills/record-screencast/SKILL.md +2 -0
  60. package/claude/skills/repo-metadata/SKILL.md +2 -0
  61. package/claude/skills/restate-plainly/SKILL.md +2 -0
  62. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  63. package/claude/skills/review-address/SKILL.md +19 -14
  64. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  65. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  66. package/claude/skills/review-branch/SKILL.md +2 -0
  67. package/claude/skills/review-craft/SKILL.md +2 -0
  68. package/claude/skills/review-craft/references/ui.md +6 -6
  69. package/claude/skills/review-pr/SKILL.md +4 -8
  70. package/claude/skills/review-pr/references/body-variants.md +52 -0
  71. package/claude/skills/review-pr/references/key-changes.md +16 -0
  72. package/claude/skills/review-pr/references/post.md +15 -43
  73. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  74. package/claude/skills/review-ui/SKILL.md +16 -12
  75. package/claude/skills/review-ui/references/driving.md +8 -8
  76. package/claude/skills/review-ui/references/post.md +13 -3
  77. package/claude/skills/review-ui/references/tick.md +22 -0
  78. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  79. package/claude/skills/role-orchestrator/SKILL.md +31 -42
  80. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  81. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
  82. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  83. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
  84. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  85. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  86. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +12 -23
  87. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  88. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  89. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  91. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  92. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  93. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  94. package/claude/skills/role-planner/SKILL.md +4 -2
  95. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  96. package/claude/skills/role-reviewer/SKILL.md +3 -1
  97. package/claude/skills/role-worker/SKILL.md +5 -2
  98. package/claude/skills/search-craft/SKILL.md +2 -0
  99. package/claude/skills/seed-sync/SKILL.md +2 -0
  100. package/claude/skills/session-compact/SKILL.md +2 -0
  101. package/claude/skills/session-map/SKILL.md +2 -0
  102. package/claude/skills/session-relay/SKILL.md +2 -0
  103. package/claude/skills/session-resume/SKILL.md +2 -0
  104. package/claude/skills/session-worktree/SKILL.md +3 -11
  105. package/claude/skills/session-worktree/references/install-command.md +20 -0
  106. package/claude/skills/sketch-design/SKILL.md +2 -0
  107. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  108. package/claude/skills/target-check/SKILL.md +2 -0
  109. package/claude/skills/target-setup/SKILL.md +2 -0
  110. package/claude/skills/task-board/SKILL.md +2 -0
  111. package/claude/skills/teach-workspace/SKILL.md +5 -15
  112. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  113. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  114. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  115. package/claude/skills/test-craft/SKILL.md +2 -0
  116. package/claude/skills/test-first/SKILL.md +2 -0
  117. package/claude/skills/ui-checklist/SKILL.md +3 -1
  118. package/claude/skills/ux-audit/SKILL.md +2 -0
  119. package/claude/skills/ux-measure/SKILL.md +2 -0
  120. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  121. package/claude/skills/video-craft/SKILL.md +2 -0
  122. package/claude/skills/write-human/SKILL.md +2 -0
  123. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  124. package/docs/agents/canvas.md +24 -8
  125. package/docs/agents/capture.md +5 -5
  126. package/docs/agents/commands.md +2 -2
  127. package/docs/agents/gate.md +3 -1
  128. package/docs/agents/index.md +5 -4
  129. package/docs/agents/install-and-sync.md +7 -0
  130. package/docs/agents/key-changes.md +1 -1
  131. package/docs/agents/pr-evidence.md +47 -23
  132. package/docs/agents/pr-local.md +19 -13
  133. package/docs/agents/pr-preview.md +44 -21
  134. package/docs/agents/pr-tick.md +72 -0
  135. package/docs/agents/sandbox.md +30 -6
  136. package/docs/agents/scripting.md +8 -1
  137. package/docs/agents/skills-audit.md +2 -1
  138. package/docs/agents/tasks-plans.md +1 -1
  139. package/docs/agents/tasks-validate.md +18 -18
  140. package/docs/agents/tasks.md +1 -1
  141. package/docs/workflow/operating-model.md +15 -21
  142. package/docs/workflow/parallel-features.md +24 -3
  143. package/docs/workflow/skill-map.md +3 -1
  144. package/docs/workflow/zshrc-aliases.md +1 -1
  145. package/package.json +1 -1
  146. package/scripts/core/regen-hero.ts +1 -1
  147. package/scripts/lib/sandbox-path.sh +6 -162
  148. package/src/audits/baseline.ts +1 -1
  149. package/src/audits/run.ts +2 -2
  150. package/src/canvas/address.ts +12 -3
  151. package/src/canvas/capture.ts +1 -1
  152. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  153. package/src/canvas/client/inspector/color-field.tsx +201 -171
  154. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  155. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  156. package/src/canvas/client/inspector/color-space.ts +240 -0
  157. package/src/canvas/client/inspector/field.tsx +66 -3
  158. package/src/canvas/client/inspector/fields.css +473 -6
  159. package/src/canvas/client/inspector/size-field.tsx +69 -0
  160. package/src/canvas/client/inspector/token-select.tsx +109 -0
  161. package/src/canvas/client/inspector/values.ts +117 -3
  162. package/src/canvas/client/inspector.tsx +311 -15
  163. package/src/canvas/client/keys.ts +54 -0
  164. package/src/canvas/client/selection.tsx +199 -0
  165. package/src/canvas/client/shell.css +210 -12
  166. package/src/canvas/client/state.ts +165 -14
  167. package/src/canvas/client/surface.tsx +341 -39
  168. package/src/canvas/client/tools.tsx +64 -0
  169. package/src/canvas/content.ts +51 -2
  170. package/src/canvas/edit.ts +49 -6
  171. package/src/canvas/editing.ts +203 -0
  172. package/src/canvas/global.d.ts +15 -0
  173. package/src/canvas/server.ts +149 -12
  174. package/src/canvas/tokens.ts +60 -18
  175. package/src/census/count.ts +1 -1
  176. package/src/claude/seeds.ts +3 -3
  177. package/src/claude/skills-audit.ts +28 -0
  178. package/src/claude/skills-drift.ts +1 -1
  179. package/src/claude/skills-families.ts +36 -0
  180. package/src/claude/skills-list.ts +76 -15
  181. package/src/claude/skills-reach.ts +1 -1
  182. package/src/commands/audits.ts +2 -2
  183. package/src/commands/canvas.ts +180 -3
  184. package/src/commands/claude.ts +70 -12
  185. package/src/commands/design.ts +145 -5
  186. package/src/commands/docs.ts +1 -1
  187. package/src/commands/feedback.ts +2 -2
  188. package/src/commands/gate.ts +9 -2
  189. package/src/commands/gov.ts +3 -3
  190. package/src/commands/hooks.ts +1 -1
  191. package/src/commands/indexes.ts +1 -1
  192. package/src/commands/init.ts +2 -2
  193. package/src/commands/intake.ts +1 -1
  194. package/src/commands/migrate.ts +2 -2
  195. package/src/commands/pr.ts +288 -65
  196. package/src/commands/records.ts +1 -1
  197. package/src/commands/repo.ts +1 -1
  198. package/src/commands/sandbox.ts +64 -15
  199. package/src/commands/sessions.ts +1 -1
  200. package/src/commands/slides.ts +1 -1
  201. package/src/commands/sync.ts +2 -2
  202. package/src/commands/tasks.ts +21 -4
  203. package/src/commands/teach.ts +1 -1
  204. package/src/commands/tooling.ts +1 -1
  205. package/src/commands/transcripts.ts +2 -2
  206. package/src/commands/upgrade.ts +1 -1
  207. package/src/commands/wiki.ts +1 -1
  208. package/src/commands/worktrees.ts +1 -1
  209. package/src/comments/scan.ts +1 -1
  210. package/src/comments/trend.ts +1 -1
  211. package/src/context/architecture.ts +1 -1
  212. package/src/context/citations.ts +3 -3
  213. package/src/context/classify/extract.ts +3 -3
  214. package/src/context/folders.ts +2 -2
  215. package/src/context/requirements.ts +1 -1
  216. package/src/design/project-fonts.ts +392 -0
  217. package/src/docs/read.ts +1 -1
  218. package/src/exec.ts +1 -1
  219. package/src/feedback/github.ts +2 -2
  220. package/src/gate/measures.ts +48 -10
  221. package/src/gate/sequencer.ts +56 -4
  222. package/src/gate/stages.ts +17 -0
  223. package/src/gate/suite-lock.ts +214 -0
  224. package/src/{git-files.ts → git/files.ts} +2 -2
  225. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  226. package/src/{worktree.ts → git/worktree.ts} +1 -1
  227. package/src/gov/citations.ts +1 -1
  228. package/src/gov/install.ts +1 -1
  229. package/src/gov/superseded.ts +1 -1
  230. package/src/gov/test-order.ts +2 -2
  231. package/src/help.ts +1 -1
  232. package/src/hooks/post-merge.ts +1 -1
  233. package/src/indexes/walk.ts +1 -1
  234. package/src/intake/folder.ts +1 -1
  235. package/src/labels/audit.ts +1 -1
  236. package/src/labels/map.ts +1 -1
  237. package/src/labels/phase.ts +3 -3
  238. package/src/markdown/files.ts +1 -1
  239. package/src/migrate/apply.ts +1 -1
  240. package/src/migrate/record-layout.ts +1 -1
  241. package/src/migrate/records.ts +11 -11
  242. package/src/migrate/scratch-evidence.ts +1 -1
  243. package/src/migrate/surface-roots.ts +6 -6
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +250 -59
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/pr/tick.ts +56 -0
  249. package/src/process/harness.ts +1 -1
  250. package/src/records/backup.ts +2 -2
  251. package/src/records/ordinal.ts +1 -1
  252. package/src/records/prune.ts +1 -1
  253. package/src/records/size.ts +1 -1
  254. package/src/records/stale-canonical.ts +2 -2
  255. package/src/records/stale.ts +1 -1
  256. package/src/records/validate.ts +2 -2
  257. package/src/{project-root.ts → roots/project.ts} +1 -1
  258. package/src/{record-root.ts → roots/record.ts} +1 -1
  259. package/src/sandbox/coverage.ts +11 -12
  260. package/src/sandbox/equivalence.ts +57 -26
  261. package/src/sandbox/headless/dispatch.ts +264 -0
  262. package/src/sandbox/headless/run.ts +653 -0
  263. package/src/sandbox/headless/watch.ts +145 -0
  264. package/src/sandbox/hooks.ts +1 -1
  265. package/src/sandbox/provision.ts +90 -29
  266. package/src/sandbox/scenario.ts +588 -0
  267. package/src/sandbox/tree.ts +4 -8
  268. package/src/secrets/scan.ts +1 -1
  269. package/src/sessions/claim.ts +1 -1
  270. package/src/sessions/live.ts +4 -1
  271. package/src/sessions/resolve.ts +1 -1
  272. package/src/sessions/transfer/bundle.ts +2 -2
  273. package/src/slides/decks.ts +1 -1
  274. package/src/slides/package/fonts.ts +6 -2
  275. package/src/standards/audit.ts +2 -2
  276. package/src/standards/read.ts +1 -1
  277. package/src/sync/check.ts +2 -2
  278. package/src/sync/engine.ts +3 -3
  279. package/src/sync/git.ts +1 -1
  280. package/src/sync/history.ts +1 -1
  281. package/src/sync/layout.ts +2 -2
  282. package/src/sync/reverse.ts +1 -1
  283. package/src/sync/stamp.ts +1 -1
  284. package/src/sync/target.ts +1 -1
  285. package/src/sync/workflow.ts +1 -1
  286. package/src/targets/pulls.ts +2 -2
  287. package/src/targets/sweep.ts +2 -2
  288. package/src/{target.ts → targets/validate.ts} +1 -1
  289. package/src/tasks/answers.ts +1 -1
  290. package/src/tasks/archive.ts +1 -1
  291. package/src/tasks/label.ts +1 -1
  292. package/src/tasks/reach.ts +2 -2
  293. package/src/tasks/trunk.ts +1 -1
  294. package/src/tasks/validate.ts +38 -14
  295. package/src/teach/nav.ts +1 -1
  296. package/src/teach/workspace.ts +1 -1
  297. package/src/tooling/inject.ts +2 -2
  298. package/src/tooling/read.ts +1 -1
  299. package/src/tooling/scan.ts +1 -1
  300. package/src/tooling/subfolder.ts +1 -1
  301. package/src/version/installed.ts +1 -1
  302. package/src/version/skew.ts +1 -1
  303. package/src/web/readme-citations.ts +1 -1
  304. package/src/worktrees/bare-flag.ts +1 -1
  305. package/src/worktrees/reclaim.ts +2 -2
  306. package/src/worktrees/remove.ts +1 -1
  307. package/standards/board.md +3 -3
  308. package/standards/canvas.md +2 -1
  309. package/standards/plan.md +13 -7
  310. package/standards/skill.md +1 -0
  311. package/standards/wiki.md +12 -3
  312. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  313. package/tsconfig.json +1 -1
  314. package/scripts/lib/sandbox-dispatch.sh +0 -190
  315. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  316. /package/src/{git-base.ts → git/base.ts} +0 -0
  317. /package/src/{git-env.ts → git/env.ts} +0 -0
  318. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  319. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
  320. /package/src/{copy.ts → sync/copy.ts} +0 -0
@@ -0,0 +1,392 @@
1
+ import {
2
+ appendFileSync,
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ statSync,
10
+ } from 'node:fs'
11
+ import {
12
+ basename,
13
+ dirname,
14
+ extname,
15
+ isAbsolute,
16
+ join,
17
+ relative,
18
+ resolve,
19
+ } from 'node:path'
20
+ import { primaryFontFamily } from '@/capture/sources'
21
+ import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
22
+
23
+ /**
24
+ * A project's own faces, declared as plain `@font-face` rules in its override
25
+ * sheets and stored beside them under `project/`, which sync treats as
26
+ * project-authored by location and never writes. CSS is the declaration
27
+ * format because every consumer already reads it, and reading descriptors
28
+ * out of the font's own tables cannot open WOFF2 without a Brotli parser.
29
+ *
30
+ * A rule's `url()` is read from inside `project/` only, after `realpath`, so
31
+ * an override cannot carry a file from elsewhere on the machine into a frame.
32
+ * A hand-edited sheet reaches this module as often as `addProjectFace` does,
33
+ * so the check sits on the read rather than on the write.
34
+ */
35
+
36
+ export const PROJECT_FONTS_DIR = 'fonts'
37
+ export const PROJECT_FONTS_SHEET = 'fonts.css'
38
+
39
+ export type FaceProblem = 'missing' | 'outside-folder'
40
+
41
+ export interface ProjectFace {
42
+ readonly family: string
43
+ /** The `font-weight` descriptor verbatim, `400` where the rule omits it. */
44
+ readonly weight: string
45
+ readonly style: string
46
+ /** Relative to the root. */
47
+ readonly sheet: string
48
+ /** The first local `url()` as the rule writes it. */
49
+ readonly file: string
50
+ readonly present: boolean
51
+ readonly problem?: FaceProblem
52
+ }
53
+
54
+ export interface DroppedFace {
55
+ readonly sheet: string
56
+ readonly file: string
57
+ readonly reason: FaceProblem
58
+ }
59
+
60
+ export interface InlinedSheet {
61
+ readonly css: string
62
+ readonly dropped: readonly DroppedFace[]
63
+ /** The families of the rules that survived, in sheet order. */
64
+ readonly declared: readonly string[]
65
+ }
66
+
67
+ const FORMATS = {
68
+ '.woff2': { mime: 'font/woff2', format: 'woff2', magic: ['wOF2'] },
69
+ '.woff': { mime: 'font/woff', format: 'woff', magic: ['wOFF'] },
70
+ '.ttf': {
71
+ mime: 'font/ttf',
72
+ format: 'truetype',
73
+ magic: ['\u0000\u0001\u0000\u0000', 'true'],
74
+ },
75
+ '.otf': { mime: 'font/otf', format: 'opentype', magic: ['OTTO'] },
76
+ } as const
77
+
78
+ type FontExtension = keyof typeof FORMATS
79
+
80
+ function isFontExtension(ext: string): ext is FontExtension {
81
+ return Object.hasOwn(FORMATS, ext)
82
+ }
83
+
84
+ const FACE_RULE = /@font-face\s*\{[^}]*\}/g
85
+ const URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g
86
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i
87
+ const DECLARATION = /([A-Za-z-]+)\s*:\s*([^;}]*)/g
88
+
89
+ /** Comments blanked to equal length, so an index into the result fits the source. */
90
+ function masked(css: string): string {
91
+ return css.replace(/\/\*[\s\S]*?\*\//g, (comment) =>
92
+ ' '.repeat(comment.length),
93
+ )
94
+ }
95
+
96
+ function descriptor(rule: string, name: string): string | undefined {
97
+ const match = rule.match(new RegExp(`(?:^|[;{\\s])${name}\\s*:\\s*([^;}]*)`))
98
+ return match?.[1]?.trim()
99
+ }
100
+
101
+ function familyOf(rule: string): string {
102
+ return primaryFontFamily(descriptor(rule, 'font-family') ?? '')
103
+ }
104
+
105
+ /**
106
+ * Every family a declaration value lists, quoted or not, across the sheet
107
+ * outside its `@font-face` rules, which declare a face rather than use one.
108
+ * Reads every property rather than only `font-family`, since a token such as
109
+ * `--figure-hand` names its stack under a name that ends in neither.
110
+ */
111
+ export function namedFamilies(css: string): Set<string> {
112
+ const uses = masked(css).replace(FACE_RULE, '')
113
+ const named = new Set<string>()
114
+ for (const [, , value] of uses.matchAll(DECLARATION)) {
115
+ for (const item of (value ?? '').split(',')) {
116
+ const family = primaryFontFamily(item)
117
+ if (family) named.add(family)
118
+ }
119
+ }
120
+ return named
121
+ }
122
+
123
+ function projectDir(root: string): string {
124
+ return join(root, DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
125
+ }
126
+
127
+ function isWithin(folder: string, path: string): boolean {
128
+ const inside = relative(folder, path)
129
+ return inside !== '' && !inside.startsWith('..') && !isAbsolute(inside)
130
+ }
131
+
132
+ type Located =
133
+ | { readonly ok: true; readonly path: string }
134
+ | { readonly ok: false; readonly reason: FaceProblem }
135
+
136
+ /** A URL path as the name on disk, or undefined for a malformed escape. */
137
+ function decoded(url: string): string | undefined {
138
+ try {
139
+ return decodeURIComponent(url.replace(/[?#].*$/, ''))
140
+ } catch {
141
+ return undefined
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Confined lexically before any read, then again after resolving links. The
147
+ * check runs on the decoded path, so an escaped `..` cannot pass it.
148
+ */
149
+ function locate(root: string, sheet: string, url: string): Located {
150
+ const folder = projectDir(root)
151
+ const clean = decoded(url)
152
+ if (clean === undefined) return { ok: false, reason: 'missing' }
153
+ if (isAbsolute(clean)) return { ok: false, reason: 'outside-folder' }
154
+ const path = resolve(join(root, dirname(sheet)), clean)
155
+ if (!isWithin(folder, path)) return { ok: false, reason: 'outside-folder' }
156
+ if (!existsSync(path) || !statSync(path).isFile()) {
157
+ return { ok: false, reason: 'missing' }
158
+ }
159
+ if (!isWithin(realpathSync(folder), realpathSync(path))) {
160
+ return { ok: false, reason: 'outside-folder' }
161
+ }
162
+ return { ok: true, path }
163
+ }
164
+
165
+ function localUrls(rule: string): string[] {
166
+ return [...rule.matchAll(URL)]
167
+ .map((match) => match[2]?.trim() ?? '')
168
+ .filter((url) => url !== '' && !SCHEME.test(url))
169
+ }
170
+
171
+ function mimeOf(path: string): string {
172
+ const ext = extname(path).toLowerCase()
173
+ return isFontExtension(ext) ? FORMATS[ext].mime : 'application/octet-stream'
174
+ }
175
+
176
+ /**
177
+ * Rewrites each local `url()` in a sheet's `@font-face` rules to a `data:`
178
+ * URI, so a frame carries the face rather than a path the frame's origin
179
+ * cannot serve. A rule naming a file that is missing or leads out of
180
+ * `project/` is dropped whole and reported, and the sheet otherwise renders.
181
+ * A `data:` or remote URL is left as written.
182
+ */
183
+ export function inlineProjectFaces(
184
+ root: string,
185
+ sheet: string,
186
+ css: string,
187
+ ): InlinedSheet {
188
+ const dropped: DroppedFace[] = []
189
+ const declared: string[] = []
190
+ let out = ''
191
+ let cursor = 0
192
+ for (const match of masked(css).matchAll(FACE_RULE)) {
193
+ const start = match.index
194
+ const end = start + match[0].length
195
+ const rule = css.slice(start, end)
196
+ out += css.slice(cursor, start)
197
+ cursor = end
198
+
199
+ const urls = localUrls(match[0])
200
+ const located = urls.map((url) => ({ url, at: locate(root, sheet, url) }))
201
+ const failed = located.find(({ at }) => !at.ok)
202
+ if (failed && !failed.at.ok) {
203
+ dropped.push({ sheet, file: failed.url, reason: failed.at.reason })
204
+ continue
205
+ }
206
+ let inlined = rule
207
+ for (const { url, at } of located) {
208
+ if (!at.ok) continue
209
+ const data = `data:${mimeOf(at.path)};base64,${readFileSync(at.path).toString('base64')}`
210
+ inlined = inlined.replace(url, data)
211
+ }
212
+ out += inlined
213
+ declared.push(familyOf(match[0]))
214
+ }
215
+ return { css: out + css.slice(cursor), dropped, declared }
216
+ }
217
+
218
+ /** The project's override sheets, root-relative, in the order a frame cascades them. */
219
+ export function projectSheets(root: string): string[] {
220
+ const folder = projectDir(root)
221
+ if (!existsSync(folder)) return []
222
+ return readdirSync(folder)
223
+ .filter((name) => name.endsWith('.css'))
224
+ .sort((a, b) => a.localeCompare(b))
225
+ .map((name) => join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR, name))
226
+ }
227
+
228
+ /** Every face the override sheets declare, without reading a font file. */
229
+ export function listProjectFaces(root: string): ProjectFace[] {
230
+ return projectSheets(root).flatMap((sheet) => {
231
+ const css = masked(readFileSync(join(root, sheet), 'utf8'))
232
+ return [...css.matchAll(FACE_RULE)].map((match): ProjectFace => {
233
+ const rule = match[0]
234
+ const local = localUrls(rule)[0]
235
+ const external = [...rule.matchAll(URL)][0]?.[2]?.trim() ?? ''
236
+ /*
237
+ * A rule carrying its face inline or remotely has no file to read, and
238
+ * the frame keeps it as written, so it lists as present. A `data:` URI
239
+ * is shortened to its scheme rather than echoing the whole face.
240
+ */
241
+ const file = local ?? (external.startsWith('data:') ? 'data:' : external)
242
+ const at: Located = local
243
+ ? locate(root, sheet, local)
244
+ : external
245
+ ? { ok: true, path: external }
246
+ : { ok: false, reason: 'missing' }
247
+ return {
248
+ family: familyOf(rule),
249
+ weight: descriptor(rule, 'font-weight') ?? '400',
250
+ style: descriptor(rule, 'font-style') ?? 'normal',
251
+ sheet,
252
+ file,
253
+ present: at.ok,
254
+ ...(!at.ok && { problem: at.reason }),
255
+ }
256
+ })
257
+ })
258
+ }
259
+
260
+ export interface FaceSpec {
261
+ readonly family: string
262
+ readonly weight?: string
263
+ readonly style?: string
264
+ }
265
+
266
+ export type AddOutcome =
267
+ | {
268
+ readonly ok: true
269
+ readonly family: string
270
+ readonly weight: string
271
+ readonly style: string
272
+ /** Relative to the sheet's folder, as the rule writes it. */
273
+ readonly file: string
274
+ readonly sheet: string
275
+ }
276
+ | {
277
+ readonly ok: false
278
+ readonly reason: 'invalid-font' | 'invalid-face' | 'exists'
279
+ readonly detail: string
280
+ }
281
+
282
+ /** A name CSS takes inside single quotes with no escaping. */
283
+ const SAFE_FAMILY = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/
284
+ /** A file name a single-quoted `url()` takes with no escaping. */
285
+ const SAFE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
286
+ const WEIGHT = /^\d{1,4}( \d{1,4})?$/
287
+
288
+ function weightInRange(weight: string): boolean {
289
+ return weight.split(' ').every((n) => Number(n) >= 1 && Number(n) <= 1000)
290
+ }
291
+
292
+ /**
293
+ * Copies a face into `project/fonts/` and appends one rule for it to
294
+ * `project/fonts.css`. Refuses before writing anything when the extension is
295
+ * not a font, the bytes disagree with it, a descriptor would need escaping,
296
+ * or the folder already holds a file of that name.
297
+ */
298
+ export function addProjectFace(
299
+ root: string,
300
+ source: string,
301
+ spec: FaceSpec,
302
+ ): AddOutcome {
303
+ const name = basename(source)
304
+ const ext = extname(name).toLowerCase()
305
+ if (!isFontExtension(ext)) {
306
+ return {
307
+ ok: false,
308
+ reason: 'invalid-font',
309
+ detail: `${name} is not a font. Use woff2, woff, ttf, or otf`,
310
+ }
311
+ }
312
+ if (!SAFE_FILE.test(name)) {
313
+ return {
314
+ ok: false,
315
+ reason: 'invalid-font',
316
+ detail: `${name} must be letters, digits, dots, dashes, or underscores to sit in a url(). Rename it`,
317
+ }
318
+ }
319
+ if (!existsSync(source) || !statSync(source).isFile()) {
320
+ return { ok: false, reason: 'invalid-font', detail: `${source} not found` }
321
+ }
322
+ const head = readFileSync(source).subarray(0, 4).toString('binary')
323
+ if (!FORMATS[ext].magic.some((magic) => head === magic)) {
324
+ return {
325
+ ok: false,
326
+ reason: 'invalid-font',
327
+ detail: `${name} does not read as ${ext.slice(1)} from its first bytes`,
328
+ }
329
+ }
330
+
331
+ const weight = spec.weight ?? '400'
332
+ const style = spec.style ?? 'normal'
333
+ if (!SAFE_FAMILY.test(spec.family)) {
334
+ return {
335
+ ok: false,
336
+ reason: 'invalid-face',
337
+ detail: `family ${spec.family} must be letters, digits, spaces, dashes, or underscores`,
338
+ }
339
+ }
340
+ if (!WEIGHT.test(weight) || !weightInRange(weight)) {
341
+ return {
342
+ ok: false,
343
+ reason: 'invalid-face',
344
+ detail: `weight ${weight} must be one number or a range of two, from 1 to 1000`,
345
+ }
346
+ }
347
+ if (style !== 'normal' && style !== 'italic') {
348
+ return {
349
+ ok: false,
350
+ reason: 'invalid-face',
351
+ detail: `style ${style} must be normal or italic`,
352
+ }
353
+ }
354
+
355
+ const folder = join(projectDir(root), PROJECT_FONTS_DIR)
356
+ const dest = join(folder, name)
357
+ const file = `${PROJECT_FONTS_DIR}/${name}`
358
+ if (existsSync(dest)) {
359
+ return {
360
+ ok: false,
361
+ reason: 'exists',
362
+ detail: `${relative(root, dest)} already exists. Remove it or rename the source`,
363
+ }
364
+ }
365
+
366
+ mkdirSync(folder, { recursive: true })
367
+ copyFileSync(source, dest)
368
+ const sheetPath = join(projectDir(root), PROJECT_FONTS_SHEET)
369
+ const lead =
370
+ existsSync(sheetPath) && readFileSync(sheetPath, 'utf8').trim() !== ''
371
+ ? '\n'
372
+ : ''
373
+ appendFileSync(
374
+ sheetPath,
375
+ `${lead}@font-face {
376
+ font-family: '${spec.family}';
377
+ font-weight: ${weight};
378
+ font-style: ${style};
379
+ font-display: swap;
380
+ src: url('${file}') format('${FORMATS[ext].format}');
381
+ }
382
+ `,
383
+ )
384
+ return {
385
+ ok: true,
386
+ family: spec.family,
387
+ weight,
388
+ style,
389
+ file,
390
+ sheet: relative(root, sheetPath),
391
+ }
392
+ }
package/src/docs/read.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
2
  import { basename, join } from 'node:path'
3
3
  import { stripFrontmatter } from '@/markdown/frontmatter'
4
- import { SURFACE_ROOTS } from '@/surface-root'
4
+ import { SURFACE_ROOTS } from '@/roots/surface'
5
5
 
6
6
  const INDEX_TOPIC = 'index'
7
7
 
package/src/exec.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path'
2
2
  import { execa } from 'execa'
3
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
3
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
4
4
  import { logWarn } from '@/ui'
5
5
 
6
6
  export async function execScript(
@@ -1,6 +1,6 @@
1
1
  import { execa } from 'execa'
2
- import { gitEnv } from '@/git-env'
3
- import { PROJECT_ROOT } from '@/project-root'
2
+ import { gitEnv } from '@/git/env'
3
+ import { PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  buildIssueArgs,
6
6
  failureDetail,
@@ -6,7 +6,7 @@ import {
6
6
  CLIENT_COMMANDS,
7
7
  type ClientCommand,
8
8
  clientCommandCitationsIn,
9
- } from '@/client-commands'
9
+ } from '@/gate/client-commands'
10
10
  import {
11
11
  isOverCount,
12
12
  isOverLength,
@@ -23,9 +23,10 @@ import {
23
23
  PRACTICE_SKILLS,
24
24
  SHIPPED_CORPUS,
25
25
  } from '@/claude/skills-audit'
26
+ import { familyKeys } from '@/claude/skills-families'
26
27
  import { measureDesignProse } from '@/design/budget'
27
28
  import { DESIGN_DOCUMENT } from '@/design/regen'
28
- import { listRepositoryFiles } from '@/git-files'
29
+ import { listRepositoryFiles } from '@/git/files'
29
30
  import { ceilingFindings } from '@/markdown/ceiling'
30
31
  import { resolveMarkdown } from '@/markdown/files'
31
32
  import { CHECKPOINTS } from '@/markdown/structure'
@@ -36,7 +37,7 @@ import {
36
37
  SHIPPED_CORPORA,
37
38
  type ShippedReference,
38
39
  } from '@/shipped/references'
39
- import { surfaceDir } from '@/surface-root'
40
+ import { surfaceDir } from '@/roots/surface'
40
41
  import { seedRoots } from '@/tooling/seeds'
41
42
  import { plural } from '@/ui'
42
43
  import {
@@ -166,7 +167,7 @@ export function auditsBaselineRel(root: string): string {
166
167
  }
167
168
 
168
169
  export const CAPTURE_STAMP_FAILURE =
169
- 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/evidence and commit each frame with its image and its stamp.'
170
+ 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/frames and commit each frame with its image and its stamp.'
170
171
 
171
172
  function parseJson(payload: string): unknown {
172
173
  try {
@@ -449,6 +450,42 @@ export const skillPracticeShape: Measure = async (ctx) => {
449
450
  }
450
451
  }
451
452
 
453
+ /**
454
+ * Every shipped skill against the family vocabulary, since the landing page
455
+ * groups the skill map by the field and a skill without one has no group.
456
+ *
457
+ * Read in-process for the reason `skillProvenance` is. Only the shipped corpus
458
+ * takes a family, so a tree without `claude/skills/` reports unmeasured rather
459
+ * than a vacuous pass.
460
+ */
461
+ export const skillFamily: Measure = async (ctx) => {
462
+ const report = await auditSkills(ctx.root)
463
+ const shipped = report.corpora.find((corpus) => corpus.rel === SHIPPED_CORPUS)
464
+ if (shipped === undefined) {
465
+ return {
466
+ emissions: [],
467
+ unmeasured: `no skill corpus at ${SHIPPED_CORPUS} resolved, so no family was read.`,
468
+ }
469
+ }
470
+
471
+ const found = report.family
472
+ if (found.length > 0) {
473
+ const where = found
474
+ .map((finding) => `${finding.rel} ${finding.detail}`)
475
+ .join(', ')
476
+ return {
477
+ emissions: [],
478
+ failure: `${plural(found.length, 'skill family finding')}: ${where}. Add metadata.family to the frontmatter, one of ${familyKeys().join(', ')}, matching the skill map group its row sits under.`,
479
+ }
480
+ }
481
+
482
+ return {
483
+ emissions: [
484
+ info(`${plural(shipped.skills, 'shipped skill')} declare a known family`),
485
+ ],
486
+ }
487
+ }
488
+
452
489
  /**
453
490
  * Every tracked markdown document against the whole-document ceiling.
454
491
  *
@@ -982,7 +1019,7 @@ export const clientCommandCitations = async (
982
1019
  return {
983
1020
  emissions: [],
984
1021
  failure:
985
- 'The client command table is empty, so the corpus was walked and nothing was looked for. Check src/client-commands.ts.',
1022
+ 'The client command table is empty, so the corpus was walked and nothing was looked for. Check src/gate/client-commands.ts.',
986
1023
  }
987
1024
  }
988
1025
 
@@ -1023,8 +1060,8 @@ export const clientCommandCitations = async (
1023
1060
  ),
1024
1061
  failure:
1025
1062
  found.length === 1
1026
- ? `One tracked citation quotes a client command with the wrong argument. Match it against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`
1027
- : `${found.length} tracked citations quote a client command with the wrong argument. Match each against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`,
1063
+ ? `One tracked citation quotes a client command with the wrong argument. Match it against the canonical form in src/gate/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`
1064
+ : `${found.length} tracked citations quote a client command with the wrong argument. Match each against the canonical form in src/gate/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`,
1028
1065
  }
1029
1066
  }
1030
1067
 
@@ -1478,11 +1515,12 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
1478
1515
  * A capture set spans two folders. The markup and the template beside it are
1479
1516
  * the authored half and sit under `assets/captures/`, while the image a
1480
1517
  * document points at and the stamp answering for it sit under
1481
- * `assets/evidence/`, which is where `--out assets/evidence` sends them and where
1482
- * `canon pr evidence` looks for a changed image.
1518
+ * `assets/frames/`, which is where `--out assets/frames` sends them. They sit
1519
+ * outside `assets/evidence/` so a changed marketing image is not read as dev
1520
+ * evidence.
1483
1521
  */
1484
1522
  const CAPTURE_MARKUP_DIR = 'assets/captures'
1485
- const CAPTURE_OUTPUT_DIR = 'assets/evidence'
1523
+ const CAPTURE_OUTPUT_DIR = 'assets/frames'
1486
1524
 
1487
1525
  /**
1488
1526
  * Every capture under `assets/captures/`, named by the base its three files
@@ -7,8 +7,13 @@ import type {
7
7
  RunCommand,
8
8
  } from '@/gate/measures'
9
9
  import type { Check, Stage } from '@/gate/stages'
10
- import { gitEnv } from '@/git-env'
11
- import { PROJECT_ROOT } from '@/project-root'
10
+ import {
11
+ acquireSuiteLock,
12
+ type LockHolder,
13
+ machineLockPath,
14
+ } from '@/gate/suite-lock'
15
+ import { gitEnv } from '@/git/env'
16
+ import { PROJECT_ROOT } from '@/roots/project'
12
17
 
13
18
  export type StageStatus =
14
19
  /** Every check ran and none found a fact. */
@@ -27,8 +32,16 @@ export interface StageResult {
27
32
  readonly emissions: readonly Emission[]
28
33
  /** The remedy line a failed stage prints, naming what to do about it. */
29
34
  readonly failure?: string
30
- /** Wall time the stage's checks took, including every process spawn. */
35
+ /**
36
+ * Wall time the stage took, including every process spawn and, on a stage
37
+ * holding a machine lock, the time it queued behind another worktree.
38
+ */
31
39
  readonly ms: number
40
+ /**
41
+ * The part of `ms` spent queued behind another worktree's hold on the stage's
42
+ * machine lock. Absent where the stage never waited.
43
+ */
44
+ readonly queuedMs?: number
32
45
  }
33
46
 
34
47
  export interface GateContext extends MeasureContext {
@@ -39,6 +52,12 @@ export interface GateContext extends MeasureContext {
39
52
  * scoping is off and every stage runs.
40
53
  */
41
54
  readonly changed?: readonly string[]
55
+ /**
56
+ * Told when a stage starts queuing on its machine lock. A stage prints only
57
+ * once it returns, so without this a queued gate is silent for the whole
58
+ * wait and reads as a hung suite.
59
+ */
60
+ readonly onQueue?: (label: string, holder: LockHolder) => void
42
61
  }
43
62
 
44
63
  export interface ChangedSet {
@@ -157,7 +176,40 @@ async function executeStage(
157
176
  }
158
177
  }
159
178
 
160
- const emissions: Emission[] = []
179
+ // CI runs one suite per runner, so a lock there queues behind nothing.
180
+ if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [])
181
+
182
+ const held = await acquireSuiteLock({
183
+ path: machineLockPath(stage.lock),
184
+ root: ctx.root,
185
+ onWait: (holder) => ctx.onQueue?.(stage.label, holder),
186
+ })
187
+ const queued: Emission[] =
188
+ held.waitedOn === undefined
189
+ ? []
190
+ : [
191
+ {
192
+ kind: 'info',
193
+ text: `Waited ${Math.round(held.waitedMs / 1000)}s for the ${stage.label} stage another worktree held, at ${held.waitedOn.root}`,
194
+ },
195
+ ]
196
+
197
+ try {
198
+ const outcome = await runChecks(stage, ctx, queued)
199
+ return held.waitedOn === undefined
200
+ ? outcome
201
+ : { ...outcome, queuedMs: held.waitedMs }
202
+ } finally {
203
+ held.release()
204
+ }
205
+ }
206
+
207
+ async function runChecks(
208
+ stage: Stage,
209
+ ctx: GateContext,
210
+ leading: readonly Emission[],
211
+ ): Promise<StageOutcome> {
212
+ const emissions: Emission[] = [...leading]
161
213
 
162
214
  for (const check of stage.checks) {
163
215
  const outcome = await runCheck(check, ctx)
@@ -22,6 +22,7 @@ import {
22
22
  seedStandards,
23
23
  rawFieldFileReference,
24
24
  shippedReferences,
25
+ skillFamily,
25
26
  skillPracticeShape,
26
27
  skillProvenance,
27
28
  standardCriteria,
@@ -87,6 +88,11 @@ export interface Stage {
87
88
  */
88
89
  readonly when?: (options: { readonly write: boolean }) => boolean
89
90
  readonly checks: readonly Check[]
91
+ /**
92
+ * The machine lock this stage holds while its checks run, so one box runs it
93
+ * for one worktree at a time. Absent means the stage never queues.
94
+ */
95
+ readonly lock?: string
90
96
  /** The line a clean stage prints, where the checks emit none of their own. */
91
97
  readonly success?: string
92
98
  }
@@ -496,6 +502,14 @@ export const STAGES: readonly Stage[] = [
496
502
  label: 'Skill practice shape',
497
503
  checks: [{ kind: 'measure', measure: skillPracticeShape }],
498
504
  },
505
+ {
506
+ // The audit verb only reports a shipped skill with a missing or unknown
507
+ // family, so a target is told without failing. The landing page groups the
508
+ // skill map by the field, so the stage holds this corpus at zero.
509
+ id: 'skill-family',
510
+ label: 'Skill family',
511
+ checks: [{ kind: 'measure', measure: skillFamily }],
512
+ },
499
513
  {
500
514
  id: 'standard-criteria',
501
515
  label: 'Standard success criteria',
@@ -570,6 +584,9 @@ export const STAGES: readonly Stage[] = [
570
584
  label: 'Tests',
571
585
  scope: TESTS_SCOPE,
572
586
  skipped: 'Skipped, no TypeScript or asserted-corpus changes',
587
+ // Concurrent suites from a wave of worktrees starve each other's spawn
588
+ // and browser tests past their timeouts, so the box runs one at a time.
589
+ lock: 'tests',
573
590
  checks: [
574
591
  {
575
592
  kind: 'command',