geneseed 3.8.0 → 3.10.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 (229) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/DESIGN.md +13 -10
  3. package/QUICKSTART.md +1 -1
  4. package/README.md +31 -32
  5. package/SETUP.md +7 -42
  6. package/SHIPPED.md +4 -4
  7. package/adapters/bob/README.md +17 -1
  8. package/adapters/openclaude/README.md +1 -1
  9. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
  10. package/adapters/opencode/README.md +3 -3
  11. package/adapters/opencode/plugins/geneseed-context.js +78 -8
  12. package/adapters/opencode/plugins/geneseed-guard.js +9 -9
  13. package/adapters/opencode/plugins/geneseed-workflow.js +1 -1
  14. package/adapters/opencode/workflows/_runtime.js +2 -2
  15. package/adapters/opencode/workflows/dispatch.js +1 -1
  16. package/bin/geneseed-hook.mjs +1 -1
  17. package/docs/candidate-hosts.md +12 -5
  18. package/docs/extending.md +72 -40
  19. package/docs/token-footprint.md +26 -21
  20. package/docs/web/claude-hooks.md +1 -1
  21. package/docs/web/collaboration.md +1 -1
  22. package/docs/web/exclusions.md +0 -1
  23. package/docs/web/footprint.md +1 -1
  24. package/docs/web/harnesses.md +14 -14
  25. package/docs/web/install-quick.md +1 -1
  26. package/docs/web/plugin-guard.md +2 -2
  27. package/docs/web/rules.md +1 -1
  28. package/docs/web/themes-author.md +2 -2
  29. package/docs/web-ui.md +2 -2
  30. package/harness.config.json +1 -1
  31. package/js/README.md +8 -7
  32. package/js/build/driver.mjs +26 -95
  33. package/js/build/emit-claude.mjs +76 -81
  34. package/js/build/generate.mjs +0 -1
  35. package/js/build/render.mjs +10 -3
  36. package/js/build/source.mjs +105 -1
  37. package/js/build/stubs.mjs +25 -4
  38. package/js/cli-table.json +8 -8
  39. package/js/hosts/hooks.mjs +91 -59
  40. package/js/hosts/hosts.mjs +5 -21
  41. package/js/hosts/installs.mjs +4 -6
  42. package/js/hosts/mcp.mjs +3 -13
  43. package/js/hosts/native.mjs +28 -51
  44. package/js/hosts/opencode.mjs +1 -1
  45. package/js/hosts/settings.mjs +4 -78
  46. package/js/inspect/checks-authoring.mjs +153 -117
  47. package/js/inspect/checks-repo.mjs +45 -1
  48. package/js/inspect/diff.mjs +11 -3
  49. package/js/inspect/doctor.mjs +3 -3
  50. package/js/inspect/excludes.mjs +0 -4
  51. package/js/inspect/inventory.mjs +1 -2
  52. package/js/inspect/status.mjs +4 -4
  53. package/js/maintain/migrate.mjs +5 -5
  54. package/js/maintain/setup.mjs +3 -5
  55. package/js/maintain/uninstall.mjs +8 -42
  56. package/js/ui/tui.mjs +1 -1
  57. package/js/web/actions.mjs +4 -5
  58. package/js/web/api.mjs +10 -5
  59. package/package.json +1 -2
  60. package/registry.json +7 -0
  61. package/src/AGENT.md.tmpl +86 -65
  62. package/src/agents/_template.md +3 -3
  63. package/src/agents/advocate.md +3 -3
  64. package/src/agents/architect.md +3 -3
  65. package/src/agents/developer.md +4 -4
  66. package/src/agents/docs.md +3 -3
  67. package/src/agents/empiricist.md +4 -4
  68. package/src/agents/explorer.md +4 -4
  69. package/src/agents/framer.md +3 -3
  70. package/src/agents/historian.md +3 -3
  71. package/src/agents/operator.md +3 -3
  72. package/src/agents/pragmatist.md +3 -3
  73. package/src/agents/researcher.md +7 -7
  74. package/src/agents/reviewer.md +3 -3
  75. package/src/agents/security.md +3 -3
  76. package/src/agents/skeptic.md +3 -3
  77. package/src/agents/steward.md +3 -3
  78. package/src/agents/tester.md +3 -3
  79. package/src/agents/user-advocate.md +3 -3
  80. package/src/agents/visionary.md +3 -3
  81. package/src/doctrines/README.md +4 -3
  82. package/src/doctrines/comms.md +4 -4
  83. package/src/doctrines/craft.md +20 -20
  84. package/src/doctrines/ops.md +19 -19
  85. package/src/doctrines/process.md +20 -20
  86. package/src/doctrines/rigor.md +22 -22
  87. package/src/laws/universal.md +26 -44
  88. package/src/memory/README.md +3 -3
  89. package/src/modes/foreman.md +1 -1
  90. package/src/notebook/README.md +1 -1
  91. package/src/ontology/universal.md +8 -10
  92. package/src/postures/artisan.md +2 -2
  93. package/src/skills/_self-improvement.md +4 -4
  94. package/src/skills/_template.md +1 -1
  95. package/src/skills/brainstorm.md +3 -3
  96. package/src/skills/bruno-collection-generator.md +6 -6
  97. package/src/skills/bruno-test-writer.md +5 -5
  98. package/src/skills/ci-fix.md +4 -4
  99. package/src/skills/commit.md +11 -10
  100. package/src/skills/consolidate-memory.md +4 -4
  101. package/src/skills/council.md +2 -2
  102. package/src/skills/daydream/SKILL.md +10 -3
  103. package/src/skills/daydream/VENDOR.md +10 -2
  104. package/src/skills/daydream/instructions.md +2 -2
  105. package/src/skills/debug.md +4 -4
  106. package/src/skills/deps-audit.md +3 -3
  107. package/src/skills/develop.md +7 -7
  108. package/src/skills/document-project.md +6 -6
  109. package/src/skills/explain-changes/SKILL.md +69 -0
  110. package/src/skills/explain-changes/VENDOR.md +20 -0
  111. package/src/skills/explain-changes/scripts/render_changes.mjs +558 -0
  112. package/src/skills/forge-mcp.md +4 -4
  113. package/src/skills/fresh-eyes.md +4 -4
  114. package/src/skills/frontend-design.md +1 -1
  115. package/src/skills/geneseed.md +4 -4
  116. package/src/skills/git-archaeology.md +5 -5
  117. package/src/skills/git-rescue.md +7 -7
  118. package/src/skills/handoff.md +3 -3
  119. package/src/skills/ingest.md +5 -5
  120. package/src/skills/migrate.md +8 -8
  121. package/src/skills/parallel-agents.md +2 -2
  122. package/src/skills/pipeline.md +2 -2
  123. package/src/skills/plan.md +3 -3
  124. package/src/skills/ponytail.md +2 -2
  125. package/src/skills/profile.md +2 -2
  126. package/src/skills/prose.md +1 -1
  127. package/src/skills/prototype.md +1 -1
  128. package/src/skills/react-view-transitions/SKILL.md +1 -1
  129. package/src/skills/react-view-transitions/VENDOR.md +7 -2
  130. package/src/skills/refactor.md +3 -3
  131. package/src/skills/release.md +5 -5
  132. package/src/skills/repo-map.md +4 -4
  133. package/src/skills/research.md +9 -9
  134. package/src/skills/review-response.md +4 -4
  135. package/src/skills/rule.md +3 -3
  136. package/src/skills/security-audit.md +5 -5
  137. package/src/skills/ship.md +5 -5
  138. package/src/skills/skill-forge.md +4 -4
  139. package/src/skills/token-report/SKILL.md +2 -3
  140. package/src/skills/token-report/VENDOR.md +2 -3
  141. package/src/skills/token-report/scripts/token_report.mjs +7 -121
  142. package/src/skills/wiki.md +5 -5
  143. package/src/skills/workflow.md +3 -4
  144. package/src/skills/worktree.md +4 -4
  145. package/themes/_TEMPLATE.json +37 -39
  146. package/themes/biker.json +37 -39
  147. package/themes/commentator.json +37 -39
  148. package/themes/cyberpunk.json +37 -39
  149. package/themes/gamer.json +37 -39
  150. package/themes/imperial.json +37 -39
  151. package/themes/joker.json +37 -39
  152. package/themes/marvin.json +37 -39
  153. package/themes/mean.json +37 -39
  154. package/themes/military.json +37 -39
  155. package/themes/neutral.json +37 -39
  156. package/themes/pirate.json +37 -39
  157. package/themes/sports.json +37 -39
  158. package/themes/verstappen.json +37 -39
  159. package/themes/wizard.json +37 -39
  160. package/web/dist/assets/Activity-BjVp522q.js +1 -0
  161. package/web/dist/assets/ActivityDetail-8ep1bspW.js +1 -0
  162. package/web/dist/assets/Dashboard-OkC_PCH6.js +2 -0
  163. package/web/dist/assets/{Docs-DrJNDPwI.js → Docs-CKPWz00o.js} +2 -2
  164. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  165. package/web/dist/assets/GreenhouseView-D0cbCJ5m.js +2 -0
  166. package/web/dist/assets/Installs-BtGGdoTt.js +2 -0
  167. package/web/dist/assets/JournalView-Ckl4dmzR.js +1 -0
  168. package/web/dist/assets/Laws-CLpdRRk8.js +3 -0
  169. package/web/dist/assets/Library-wPPuKQrI.js +1 -0
  170. package/web/dist/assets/{Markdown-BQAvN8xx.js → Markdown-CkFyfgNf.js} +14 -14
  171. package/web/dist/assets/OperatorHudView-CiAkAkQL.js +2 -0
  172. package/web/dist/assets/Personal-D8CnDo9v.js +1 -0
  173. package/web/dist/assets/Tabs-B4HNrrCs.js +1 -0
  174. package/web/dist/assets/drawable-BiwaOo6x.js +1 -0
  175. package/web/dist/assets/geist-mono-latin-400-normal-DTRLJnHl.woff2 +0 -0
  176. package/web/dist/assets/geist-mono-latin-500-normal-YINYabwD.woff2 +0 -0
  177. package/web/dist/assets/index-C1yil5j1.js +2 -0
  178. package/web/dist/assets/index-CDPDk2vj.css +1 -0
  179. package/web/dist/assets/{react-DbkvxWG5.js → react-B9Z_tXRV.js} +1 -1
  180. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  181. package/web/dist/assets/usePoll-O0sJEw1Y.js +1 -0
  182. package/web/dist/index.html +4 -4
  183. package/web/src/pages/Laws.jsx +616 -361
  184. package/adapters/copilot/README.md +0 -105
  185. package/web/dist/assets/Activity-CH-7cPIM.js +0 -1
  186. package/web/dist/assets/ActivityDetail-DZvz0xS5.js +0 -1
  187. package/web/dist/assets/CatalogRow-DftXpZHY.js +0 -1
  188. package/web/dist/assets/Diff-DvwdG0L9.js +0 -2
  189. package/web/dist/assets/Doctor-OTmwmYZg.js +0 -1
  190. package/web/dist/assets/FilterInput-BakJLvaV.js +0 -1
  191. package/web/dist/assets/GreenhouseView-XxrAdriX.js +0 -2
  192. package/web/dist/assets/Harness-C_LYNk5c.js +0 -1
  193. package/web/dist/assets/JournalView-BcuyeEaR.js +0 -1
  194. package/web/dist/assets/Laws-CWHrTKY4.js +0 -3
  195. package/web/dist/assets/Library-CA9IHghn.js +0 -1
  196. package/web/dist/assets/OperatorHudView-BMRY1Tt4.js +0 -2
  197. package/web/dist/assets/Profile-wceET1JY.js +0 -1
  198. package/web/dist/assets/Rules-BEXvY4Tx.js +0 -1
  199. package/web/dist/assets/Settings-DS2zF0Ez.js +0 -1
  200. package/web/dist/assets/Skills-DahlOJuI.js +0 -1
  201. package/web/dist/assets/StatusBadge-Cg6j-NJL.js +0 -1
  202. package/web/dist/assets/activity-iWaWVtsR.js +0 -1
  203. package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
  204. package/web/dist/assets/drawable-BTHjTzF7.js +0 -1
  205. package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
  206. package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
  207. package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
  208. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  209. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  210. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  211. package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
  212. package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
  213. package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
  214. package/web/dist/assets/index-AN5Emnrg.js +0 -2
  215. package/web/dist/assets/index-Ruh93A3A.css +0 -1
  216. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  217. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  218. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  219. package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  220. package/web/dist/assets/lawCats-Btjp3Hk_.js +0 -1
  221. package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
  222. package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
  223. package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
  224. package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
  225. package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
  226. package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
  227. package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
  228. package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
  229. package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +0 -1
@@ -649,7 +649,7 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
649
649
  * rule is no longer BINDING — so a gate that stopped every commit to ask for consent to a rule
650
650
  * the install did not adopt is the disagreement the three-tier split forbids. The group stays
651
651
  * and gains `--no-consent`, which skips only the process-5 branch. It once DROPPED the whole
652
- * group instead, and Law IV went with it — on Claude only, since Bob and Copilot carry it
652
+ * group instead, and Law IV went with it — on Claude only, since Bob carries it
653
653
  * through `tool-gate`. The command changing is also what re-wires an existing install:
654
654
  * `mergeClaudeSettings` prunes every previously-managed group that is not in this return
655
655
  * value.
@@ -700,22 +700,6 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
700
700
  Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
701
701
  };
702
702
  }
703
- if (host === 'copilot') {
704
- // COPILOT'S OWN SCHEMA (docs.github.com/en/copilot/reference/hooks-configuration): camelCase
705
- // events, each an ARRAY of `{type: "command", command}` entries — no matcher groups, so the
706
- // array elements ARE the groups this merge appends and prunes. `preToolUse` answers with
707
- // a top-level `permissionDecision` (`--host copilot`), and `agentStop`/`preCompact` carry
708
- // `transcriptPath`, so `learn` runs here as on Claude. No matcher, so `tool-gate` reads
709
- // the payload's shape. Unverified live: no Copilot CLI on the authoring machine.
710
- const c = ' --host copilot';
711
- const entry = (command) => [{ type: 'command', command }];
712
- return {
713
- sessionStart: entry(`${run} context --root "${cfg}"${c} || exit 0`),
714
- preToolUse: entry(`${run} tool-gate --root "${cfg}"${c}`),
715
- agentStop: entry(`${run} learn ${mem} || exit 0`),
716
- preCompact: entry(`${run} learn ${mem} || exit 0`),
717
- };
718
- }
719
703
  // OpenClaude takes Claude's whole group set and verdicts unchanged; only `context` needs
720
704
  // the host, to know which root file OpenClaude already loads by itself.
721
705
  const h = host === 'openclaude' ? ' --host openclaude' : '';
@@ -753,63 +737,6 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
753
737
  };
754
738
  }
755
739
 
756
- /**
757
- * LEGACY: the single-slot Copilot shape (`hooks.<event>` = ONE object, events `sessionStart` /
758
- * `toolCall`) an emit wrote before Copilot's hooks became event → array. Copilot has no
759
- * `toolCall` event any more, so those gates never ran. Nothing writes this shape now; these
760
- * two only take an older install's recorded `copilot_hooks` claims back out.
761
- */
762
- /** Remove exactly the recorded Copilot hooks from `p`. True when the file was rewritten. */
763
- export function unwireCopilotSettings(p, recorded) {
764
- if (!existsSync(p) || !recorded || !recorded.length) return false;
765
- let loaded;
766
- let hadComments;
767
- try {
768
- [loaded, hadComments] = readJsonc(readText(p));
769
- } catch (e) {
770
- if (!isOsError(e)) throw e;
771
- return false;
772
- }
773
- if (hadComments || !isDict(loaded)) return false;
774
- const hooks = get(loaded, 'hooks');
775
- if (!isDict(hooks)) return false;
776
- for (const rec of recorded) {
777
- const event = get(rec, 'event');
778
- if (deepEquals(get(hooks, event), get(rec, 'hook'))) delete hooks[event];
779
- }
780
- if (!Object.keys(hooks).length) delete loaded.hooks;
781
- try {
782
- atomicWriteJson(p, loaded);
783
- } catch (e) {
784
- if (!isOsError(e)) throw e;
785
- return false;
786
- }
787
- return true;
788
- }
789
-
790
- /**
791
- * The Copilot twin of `settingsIntegrityCheck`: every recorded hook present (or absent) in
792
- * the file it was claimed against. Same contract — problems as strings, each also WARNed on
793
- * stderr, empty when clean — so a merge that did not stick is loud at emit time rather than
794
- * a hook that quietly never fires.
795
- */
796
- export function copilotIntegrityCheck(p, recorded, expect = 'present') {
797
- const problems = [];
798
- const hooks = existsSync(p) ? (() => {
799
- try { return get(readJsonc(readText(p))[0], 'hooks'); } catch { return null; }
800
- })() : null;
801
- for (const rec of (recorded || []).filter(isDict)) {
802
- const hit = deepEquals(get(hooks, rec.event), rec.hook);
803
- if (expect === 'present' && !hit) {
804
- problems.push(`${p}: recorded Copilot hook missing — event=${rec.event}`);
805
- } else if (expect === 'absent' && hit) {
806
- problems.push(`${p}: recorded Copilot hook still present after unwire — event=${rec.event}`);
807
- }
808
- }
809
- for (const x of problems) process.stderr.write(`[geneseed] WARN: ${x}\n`);
810
- return problems;
811
- }
812
-
813
740
  /**
814
741
  * `_build_settings._merge_claude_settings` — returns `[target, managed]`.
815
742
  *
@@ -1045,8 +972,8 @@ const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
1045
972
  *
1046
973
  * PURE — and currently reached only END TO END, by the `migrate/a-node-baked-shim-reads-as-current`
1047
974
  * cell. No unit corpus drives it directly, which is worth knowing before trusting it.
1048
- * 'none' is not a fault: a Copilot install wires no hooks, and a user's own
1049
- * settings.json may carry three hand-written hooks and no Geneseed entry. Both must read as
975
+ * 'none' is not a fault: a user's own settings.json may carry three hand-written hooks
976
+ * and no Geneseed entry. That must read as
1050
977
  * "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
1051
978
  * config on any machine.
1052
979
  */
@@ -1181,8 +1108,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1181
1108
  for (const [event, group] of presentGroups) {
1182
1109
  const key = `${formatRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
1183
1110
  if (recordedSet.has(key)) continue;
1184
- // A Claude group nests its commands under `hooks`; a Copilot entry IS the command.
1185
- const cmds = [...(get(group, 'hooks') || []).filter(isDict), group]
1111
+ const cmds = (get(group, 'hooks') || []).filter(isDict)
1186
1112
  .map((h) => (has(h, 'command') ? h.command : ''));
1187
1113
  if (cmds.some((c) => typeof c === 'string'
1188
1114
  && GENESEED_HOOK_SNIFF.some((mk) => c.includes(mk)))) {
@@ -13,7 +13,8 @@
13
13
  */
14
14
  import path from 'node:path';
15
15
  import {
16
- CONFIG, PACK_ORDER, PLUGIN_SRC, ROOT, SRC, THEMES, knownRuleIds,
16
+ CONFIG, PACK_ORDER, PLUGIN_SRC, RETIRED_RULE_IDS, ROOT, RULE_ID_RE, SRC, THEMES, canonFiles,
17
+ knownRuleIds, ruleCanon, titleKey,
17
18
  } from '../build/source.mjs';
18
19
  import { themeFiles } from '../hosts/installs.mjs';
19
20
  import { descBlockProblem, firstBlockquote } from '../hosts/native.mjs';
@@ -21,7 +22,9 @@ import { readText } from '../lib/fs.mjs';
21
22
  import { parseJson, formatRepr } from '../lib/json.mjs';
22
23
  import { which } from '../lib/paths.mjs';
23
24
  import { NO_WINDOW } from '../lib/proc.mjs';
24
- import { registryProblems, secretProblems, vendorPinProblems } from './checks-repo.mjs';
25
+ import {
26
+ folderSkillProblems, registryProblems, secretProblems, vendorPinProblems,
27
+ } from './checks-repo.mjs';
25
28
  import { LAW_CLASS, LAW_CLASSES, SKILL_CLASS } from './inventory.mjs';
26
29
  import { NOTE, globSorted, has, isDir, isFile, rglob, srcStems } from './scan.mjs';
27
30
  import { spawnSync } from 'node:child_process';
@@ -47,7 +50,11 @@ export function romanToInt(num) {
47
50
  }
48
51
 
49
52
  /**
50
- * `_harness_build._LAW_META_ROW` — `<arabic>: ['<class>', '<principle>'],` in the web page.
53
+ * `_harness_build._LAW_META_ROW` — `<arabic>: ['<class>', '<principle>', '<id>'],` in the web page.
54
+ *
55
+ * The third field PINS the row to a rule id. The key is the number the catalogue renders, and a
56
+ * number is only a position: remove a law and every later row describes its predecessor's
57
+ * neighbour, with every class still plausible. The pin is what makes that a doctor failure.
51
58
  *
52
59
  * Either quote style, because a principle carrying an apostrophe is double-quoted. Every
53
60
  * `\s*` spans newlines and the trailing comma before `]` is optional, so a row a prettier has
@@ -57,7 +64,8 @@ export function romanToInt(num) {
57
64
  * `(?m)` is the `m` flag; JS has no inline form. The `.` inside the captures must NOT match a
58
65
  * newline (Python has no `re.S` here), which is JS's default.
59
66
  */
60
- const LAW_META_ROW = /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s*,?\s*\]\s*,?[^\S\n]*$/gm;
67
+ const LAW_META_ROW =
68
+ /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s*(?:,\s*(['"])(.*?)\6\s*)?,?\s*\]\s*,?[^\S\n]*$/gm;
61
69
 
62
70
  /**
63
71
  * `_harness_build._law_meta_problems` — the web Laws ledger's per-rule copy stays complete.
@@ -67,7 +75,7 @@ const LAW_META_ROW = /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s
67
75
  * description and the wrong class chip, while everything upstream is fine. That is exactly
68
76
  * how two laws shipped description-less.
69
77
  */
70
- export function lawMetaProblems(lawNums, lawClass, lawClasses) {
78
+ export function lawMetaProblems(lawNums, lawClass, lawClasses, lawIds = null) {
71
79
  const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
72
80
  let text;
73
81
  try { text = readText(page); } catch (e) {
@@ -80,11 +88,11 @@ export function lawMetaProblems(lawNums, lawClass, lawClasses) {
80
88
  }
81
89
  const meta = new Map();
82
90
  for (const m of block[1].matchAll(LAW_META_ROW)) {
83
- meta.set(Number(m[1]), [m[3], m[5]]);
91
+ meta.set(Number(m[1]), [m[3], m[5], m[7]]);
84
92
  }
85
93
  const problems = [];
86
94
  const seen = new Set();
87
- for (const roman of lawNums) {
95
+ for (const [i, roman] of lawNums.entries()) {
88
96
  const n = romanToInt(roman);
89
97
  seen.add(n);
90
98
  if (!meta.has(n)) {
@@ -93,7 +101,12 @@ export function lawMetaProblems(lawNums, lawClass, lawClasses) {
93
101
  + 'render it with a blank Principle');
94
102
  continue;
95
103
  }
96
- const [klass, principle] = meta.get(n);
104
+ const [klass, principle, pin] = meta.get(n);
105
+ if (lawIds && pin !== lawIds[i]) {
106
+ problems.push(`[authoring] LAW_META[${n}] is pinned to '${pin ?? '(no id)'}' but rule ${roman} is `
107
+ + `'${lawIds[i]}' — the rows no longer follow the canon's order, so this principle `
108
+ + 'describes a different law');
109
+ }
97
110
  if (!principle.trim()) {
98
111
  problems.push(`[authoring] LAW_META[${n}] has an empty principle line — rule `
99
112
  + `${roman} would render with a blank description`);
@@ -112,9 +125,9 @@ export function lawMetaProblems(lawNums, lawClass, lawClasses) {
112
125
  return problems;
113
126
  }
114
127
 
115
- /** `LAW_META_ROW`'s twin for a STRING key — `'<pack>.<n>': ['<pack>', '<principle>'],`. */
128
+ /** `LAW_META_ROW`'s twin for a STRING key — `'<pack>.<n>': ['<pack>', '<principle>', '<id>'],`. */
116
129
  const DOCTRINE_META_ROW =
117
- /^[^\S\n]*(['"])([a-z]+\.\d+)\1:\s*\[\s*(['"])(.*?)\3\s*,\s*(['"])(.*?)\5\s*,?\s*\]\s*,?[^\S\n]*$/gm;
130
+ /^[^\S\n]*(['"])([a-z]+\.\d+)\1:\s*\[\s*(['"])(.*?)\3\s*,\s*(['"])(.*?)\5\s*(?:,\s*(['"])(.*?)\7\s*)?,?\s*\]\s*,?[^\S\n]*$/gm;
118
131
 
119
132
  /**
120
133
  * `lawMetaProblems`' doctrine half — the console's per-rule Principle column stays complete.
@@ -130,7 +143,7 @@ const DOCTRINE_META_ROW =
130
143
  * error a six-way vocabulary cannot: a row pasted from the pack above it, keeping the class of
131
144
  * the pack it came from, which would colour the row and label its chip with the wrong pack.
132
145
  */
133
- export function doctrineMetaProblems(addrs) {
146
+ export function doctrineMetaProblems(addrs, ids = null) {
134
147
  const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
135
148
  let text;
136
149
  try { text = readText(page); } catch (e) {
@@ -142,17 +155,21 @@ export function doctrineMetaProblems(addrs) {
142
155
  + "console's doctrine rows would have no gate on their Principle column"];
143
156
  }
144
157
  const meta = new Map();
145
- for (const m of block[1].matchAll(DOCTRINE_META_ROW)) meta.set(m[2], [m[4], m[6]]);
158
+ for (const m of block[1].matchAll(DOCTRINE_META_ROW)) meta.set(m[2], [m[4], m[6], m[8]]);
146
159
  const problems = [];
147
160
  const seen = new Set();
148
- for (const addr of addrs) {
161
+ for (const [i, addr] of addrs.entries()) {
149
162
  seen.add(addr);
150
163
  if (!meta.has(addr)) {
151
164
  problems.push(`[authoring] doctrine rule ${addr} has no row in DOCTRINE_META `
152
165
  + '(web/src/pages/Laws.jsx) — the console would render it with a blank Principle');
153
166
  continue;
154
167
  }
155
- const [klass, principle] = meta.get(addr);
168
+ const [klass, principle, pin] = meta.get(addr);
169
+ if (ids && pin !== ids[i]) {
170
+ problems.push(`[authoring] DOCTRINE_META['${addr}'] is pinned to '${pin ?? '(no id)'}' but `
171
+ + `that address is '${ids[i]}' now — the row describes a different rule`);
172
+ }
156
173
  if (!principle.trim()) {
157
174
  problems.push(`[authoring] DOCTRINE_META['${addr}'] has an empty principle line — that `
158
175
  + 'rule would render with a blank description');
@@ -267,22 +284,35 @@ export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '
267
284
 
268
285
  /** `_build_render._TMPL_SPEC_RE`, as the count gate spells it. */
269
286
  const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
270
- const LAW_HEADING_RE = /^### \{\{LAW\}\} ([IVXLCDM]+)\b/gm;
271
-
272
- // The doctrine twins of `LAW_HEADING_RE`, over the UNRENDERED source — `js/inspect/inventory.mjs`'s
273
- // pair of the same names read the rendered form. A citation is the heading minus its `### `,
274
- // so one regex cannot serve both: a heading is an anchored line, a citation is anywhere.
275
- const DOCTRINE_HEADING_RE = /^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm;
276
- const DOCTRINE_CITE_RE = /\{\{DOCTRINE\}\}\s+([a-z]+)\s+(\d+)/g;
277
- // `\{\{DOCTRINE\}\}` and not `DOCTRINE`: `{{DOCTRINES}}` is the SECTION noun and is legal
278
- // everywhere, including in an invariant that points at the tier as a whole.
279
- const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE\}\}/;
280
- const DOC_TOKEN_RE = /\{\{(DOC_[A-Z0-9_]+)\}\}/g;
287
+ // A rule is DECLARED by its heading and CITED by its id — see `ruleCanon` in
288
+ // `js/build/source.mjs`. The gates below read the RAW source, because the render resolves ids and
289
+ // a bad one would already be gone from what it hands back.
290
+ const DECL_LINE_RE = /^### \{\{(LAW|DOCTRINE):([^}]*)\}\}[ \t]+\S/;
291
+ const ID_CITE_RE = /\{\{(LAW|DOCTRINE):([^}]*)\}\}/g;
292
+ // The pre-id spellings. Either one in src/ is a citation that names a POSITION, which is exactly
293
+ // what stops being true the next time the canon moves.
294
+ const BARE_CITE_RE = /\{\{LAW\}\}\s+[IVXLCDM]+\b|\{\{DOCTRINE\}\}\s+[a-z]+\s+\d+\b/g;
295
+ // `{{DOCTRINE}}` or `{{DOCTRINE:` and not `DOCTRINE`: `{{DOCTRINES}}` is the SECTION noun and is
296
+ // legal everywhere, including in an invariant that points at the tier as a whole.
297
+ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
298
+
299
+ /**
300
+ * The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
301
+ * hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
302
+ * (`law-1`, `law-4`, `process-1`, `process-5` in `js/hosts/hooks.mjs` and the OpenCode guard
303
+ * plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
304
+ * number-keyed — and this pin is what turns a renumber that moves one of these rules into a
305
+ * doctor failure instead of a gate silently guarding its neighbour.
306
+ */
307
+ export const HOOK_PINNED = {
308
+ 'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
309
+ 'persist-insight': 'process 1', 'consent-before-push': 'process 5',
310
+ };
281
311
 
282
312
  /**
283
313
  * The LEAN block grammar, and the heading split, both duplicated from `js/build/render.mjs`.
284
314
  *
285
- * Deliberately duplicated, the way `DOCTRINE_HEADING_RE` above is: both are module-private
315
+ * Deliberately duplicated rather than imported: both are module-private
286
316
  * there, and exporting them so one gate could borrow them would widen the renderer's surface
287
317
  * for a reader that only needs to know the shape. Two literals, one paragraph apart from the
288
318
  * rule they encode, is cheaper than a seam.
@@ -377,48 +407,84 @@ export function leanBlockProblems() {
377
407
  * ⚠ THE VOCABULARY GATES ARE HERE BECAUSE `themeParityProblems` CANNOT SEE THEM. That check is
378
408
  * presence-only and SYMMETRIC over the union of every theme's keys: a key missing from all
379
409
  * fourteen is in parity, and a key whose VALUE breaks a parser is not its business at all. So
380
- * `DOC_*` coverage, the `LEX_I..LEX_XI` equality and the single-word tier noun each need a gate
410
+ * `DOC_*` coverage, the `LEX_*` equality and the single-word tier noun each need a gate
381
411
  * that reads the SOURCE and asks what the source requires.
382
412
  */
383
413
  export function constitutionProblems() {
384
414
  const problems = [];
385
- const dir = path.join(SRC, 'doctrines');
386
415
 
387
- // ---- the packs themselves: present, contiguous from 1, and filed where they say they are.
388
- const rules = new Map(); // 'craft.1' -> title-less marker
389
- for (const pack of PACK_ORDER) {
390
- const file = path.join(dir, `${pack}.md`);
416
+ // ---- every rule declares an id, every id is well-formed and unique, none is retired.
417
+ //
418
+ // Numbers are positional now (`ruleCanon`), so contiguity and filing cannot go wrong; what can
419
+ // is the declaration itself. A `### ` line in a canon file that is not `{{KIND:id}} Name`
420
+ // renders nowhere as a rule — no number, no title — and a duplicated id makes every citation
421
+ // of it ambiguous.
422
+ for (const [kind, pack, file] of canonFiles(SRC)) {
423
+ const rel = path.relative(SRC, file).split(path.sep).join('/');
391
424
  let text;
392
425
  try { text = readText(file); } catch (e) {
393
- problems.push(`[authoring] doctrines/${pack}.md is named in PACK_ORDER but unreadable: `
394
- + `${e.message} — the build ships the whole catalogue, so a missing pack file breaks `
395
- + 'every citation into it, active or not');
426
+ problems.push(`[authoring] ${rel} is ${pack ? 'named in PACK_ORDER' : 'the canon'} but `
427
+ + `unreadable: ${e.message} — every citation into it breaks`);
396
428
  continue;
397
429
  }
398
- const found = [...text.matchAll(DOCTRINE_HEADING_RE)];
399
- if (!found.length) {
400
- problems.push(`[authoring] doctrines/${pack}.md carries no '### {{DOCTRINE}} ${pack} <n>' `
401
- + 'heading — the pack would render as an empty section');
402
- continue;
403
- }
404
- found.forEach(([, named, n], i) => {
405
- if (named !== pack) {
406
- problems.push(`[authoring] doctrines/${pack}.md holds a rule addressed to '${named} `
407
- + `${n}' — a rule's pack is read from its heading, so this one is unreachable at `
408
- + `'${pack}.${n}' and shadows whatever ${named}.md numbers ${n}`);
430
+ let count = 0;
431
+ for (const line of text.split('\n').filter((l) => l.startsWith('### '))) {
432
+ const m = DECL_LINE_RE.exec(line);
433
+ if (!m || m[1] !== kind) {
434
+ problems.push(`[authoring] ${rel}: '${line}' is not a rule declaration — a rule heading `
435
+ + `here reads '### {{${kind}:<id>}} <Principle Name>'; without an id it has no number, `
436
+ + 'no title and no way to be cited');
437
+ continue;
409
438
  }
410
- if (Number(n) !== i + 1) {
411
- problems.push(`[authoring] doctrines/${pack}.md rule ${n} is at position ${i + 1} — `
412
- + 'pack ids must run contiguously from 1, or a citation addresses a gap');
439
+ count += 1;
440
+ if (!RULE_ID_RE.test(m[2])) {
441
+ problems.push(`[authoring] ${rel}: rule id '${m[2]}' is not kebab-case (a-z, 0-9, '-')`);
413
442
  }
414
- rules.set(`${named}.${Number(n)}`, true);
415
- });
443
+ }
444
+ if (!count) {
445
+ const why = pack ? 'the pack would render as an empty section' : 'there would be no invariants';
446
+ problems.push(`[authoring] ${rel} declares no rule — ${why}`);
447
+ }
448
+ }
449
+ const canon = ruleCanon(SRC);
450
+ const seenIds = new Map();
451
+ const seenNames = new Map();
452
+ for (const r of canon.rules) {
453
+ const where = r.kind === 'LAW' ? `law ${r.label}` : `doctrine ${r.label}`;
454
+ if (seenIds.has(r.id)) {
455
+ problems.push(`[authoring] rule id '${r.id}' is declared twice (${seenIds.get(r.id)} and `
456
+ + `${where}) — ids are unique across laws and doctrines, or a citation names two rules`);
457
+ } else seenIds.set(r.id, where);
458
+ const lower = r.name.toLowerCase();
459
+ if (seenNames.has(lower)) {
460
+ problems.push(`[authoring] principle name '${r.name}' is declared twice (${seenNames.get(lower)} `
461
+ + `and ${where}) — a citation renders as the name, so two rules would read as one`);
462
+ } else seenNames.set(lower, where);
463
+ const retiredAs = RETIRED_RULE_IDS[r.id];
464
+ if (retiredAs && retiredAs.liveAs !== r.kind) {
465
+ problems.push(`[authoring] ${where} reuses the retired id '${r.id}' (${retiredAs.was}, `
466
+ + `${retiredAs.note}) — an id that meant one rule in someone's notes must never come back `
467
+ + 'meaning another; pick a new one');
468
+ }
469
+ }
470
+ const rules = new Map(canon.rules.filter((r) => r.kind === 'DOCTRINE')
471
+ .map((r) => [`${r.pack}.${r.n}`, r]));
472
+
473
+ // ---- the hook-gated rules are still at the address the hooks key them by.
474
+ for (const [id, label] of Object.entries(HOOK_PINNED)) {
475
+ const r = canon.byId.get(id);
476
+ if (!r || r.label !== label) {
477
+ problems.push(`[authoring] the hooks gate '${id}' as ${label} (js/hosts/hooks.mjs ledger keys, `
478
+ + `settings.mjs CONSENT_RULE, the console's HOOK_GATED), but the canon ${r ? `numbers it ${r.label}`
479
+ : 'no longer declares it'} — move those keys with it, or the tool boundary guards a `
480
+ + 'different rule than the prompt names');
481
+ }
416
482
  }
417
483
 
418
484
  // The console's Principle column, keyed off the rules just parsed — so the gate is driven by
419
485
  // the SOURCE and a rule added to a pack file is caught the same day, not when someone
420
486
  // notices a blank description in the browser.
421
- problems.push(...doctrineMetaProblems([...rules.keys()]));
487
+ problems.push(...doctrineMetaProblems([...rules.keys()], [...rules.values()].map((r) => r.id)));
422
488
 
423
489
  // ---- `--exclude-rules` closes against the SAME rules this walk just found.
424
490
  //
@@ -440,41 +506,11 @@ export function constitutionProblems() {
440
506
  + 'no switch for it');
441
507
  }
442
508
 
443
- // ---- the consent gate's address still names the consent rule.
444
- //
445
- // ⚠ THE ONE HARDCODED ADDRESS IN THE CODEBASE, and the one whose drift is silent AND unsafe.
446
- // `js/hosts/settings.mjs` keys the git-gate hooks on `process.5` by literal; a renumber of the
447
- // process pack moves that rule's neighbours under it, and the boundary would then follow
448
- // whatever rule inherited the number while the prompt still said `process 5`. Checked
449
- // against the pack file's own title token rather than against prose, so a reworded rule is
450
- // not a false alarm and a MOVED one is not a silent pass.
451
- const consentTitle = 'DOC_PROCESS_5';
452
- let processText = '';
453
- try { processText = readText(path.join(dir, 'process.md')); } catch { /* reported above */ }
454
- if (processText && !new RegExp(`^### \\{\\{DOCTRINE\\}\\} process 5 — \\{\\{${consentTitle}\\}\\}`,
455
- 'm').test(processText)) {
456
- problems.push('[authoring] js/hosts/settings.mjs gates git commit/push on doctrine \'process 5\', '
457
- + `but doctrines/process.md does not number {${consentTitle}} 5 — the tool boundary and `
458
- + 'the prompt now name different rules');
459
- }
460
-
461
- // ---- every `{{DOCTRINE}} <pack> <n>` anywhere in src/ resolves to a rule that exists.
509
+ // ---- every citation in src/ is by id, and every id resolves.
462
510
  //
463
- // The whole tree, not the template: a skill footer citing `process 9` is as broken as a
464
- // template one, and it is the shape 339 rewired citations could have left behind.
511
+ // The whole tree, not the template: a skill footer citing a rule that does not exist is as
512
+ // broken as a template one, and it renders into every bundle.
465
513
  const cited = new Map(); // 'process.5' -> [rel, …]
466
- // ⚠ A RETIRED LAW KEEPS ITS NUMBER, SO A CITATION OF IT STILL RESOLVES — to "Retired". Laws
467
- // are appended, never renumbered, which is what keeps ~150 cross-references stable; the price
468
- // is that nothing else notices `{{LAW}} IX` in a skill that means "ask first" (it is rigor 5
469
- // now) or `{{LAW}} XI` that means "verify" (folded into III). Read off the canon itself, so a
470
- // law retired tomorrow is covered the same day.
471
- const retired = new Set();
472
- try {
473
- for (const [, num] of readText(path.join(SRC, 'laws', 'universal.md'))
474
- .matchAll(/^### \{\{LAW\}\} ([IVXLCDM]+) —[^\n]*\n<!-- LEAN:begin -->\nRetired\b/gm)) {
475
- retired.add(num);
476
- }
477
- } catch { /* an unreadable canon is reported elsewhere */ }
478
514
  for (const p of rglob(SRC)) {
479
515
  if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
480
516
  let text;
@@ -489,35 +525,33 @@ export function constitutionProblems() {
489
525
  + 'reference a toggleable one, or a pack-off build ships a rule pointing at text '
490
526
  + 'that is not there. State the point directly instead');
491
527
  }
492
- if (rel !== 'laws/universal.md') {
493
- for (const [, num] of text.matchAll(/\{\{LAW\}\}\s+([IVXLCDM]+)\b/g)) {
494
- if (retired.has(num)) {
495
- problems.push(`[authoring] ${rel} cites {{LAW}} ${num}, which is retired — cite the `
496
- + 'law or rule its text now lives in');
528
+ for (const [bare] of text.matchAll(BARE_CITE_RE)) {
529
+ problems.push(`[authoring] ${rel} cites '${bare.replace(/\s+/g, ' ')}' by number — numbers are `
530
+ + 'positional and move when the canon does; cite {{LAW:<id>}} / {{DOCTRINE:<id>}}');
531
+ }
532
+ for (const [whole, kind, id] of text.matchAll(ID_CITE_RE)) {
533
+ const r = canon.byId.get(id);
534
+ if (r && r.kind === kind) {
535
+ if (kind === 'DOCTRINE') {
536
+ const key = `${r.pack}.${r.n}`;
537
+ if (!cited.has(key)) cited.set(key, []);
538
+ cited.get(key).push(rel);
497
539
  }
540
+ continue;
498
541
  }
542
+ const retiredAs = RETIRED_RULE_IDS[id];
543
+ let why = 'which no law or doctrine declares — it would render as the raw token';
544
+ if (r) why = `but '${id}' is a ${r.kind === 'LAW' ? 'law' : 'doctrine rule'} — use {{${r.kind}:${id}}}`;
545
+ else if (retiredAs) {
546
+ why = `a retired id (${retiredAs.was}, ${retiredAs.note}) — cite the rule its text now lives in`;
547
+ }
548
+ problems.push(`[authoring] ${rel} cites ${whole}, ${why}`);
499
549
  }
500
- for (const [, pack, n] of text.matchAll(DOCTRINE_CITE_RE)) {
501
- const key = `${pack}.${Number(n)}`;
502
- if (!cited.has(key)) cited.set(key, []);
503
- cited.get(key).push(rel);
504
- }
505
- }
506
- for (const [key, where] of [...cited].sort()) {
507
- if (rules.has(key)) continue;
508
- problems.push(`[authoring] ${where[0]} cites {{DOCTRINE}} ${key.replace('.', ' ')}, which `
509
- + `no pack file defines${where.length > 1 ? ` (and ${where.length - 1} more)` : ''}`);
510
550
  }
511
551
 
512
- // ---- the vocabulary every theme owes the source.
513
- const wanted = new Set();
514
- for (const pack of PACK_ORDER) {
515
- let text;
516
- try { text = readText(path.join(dir, `${pack}.md`)); } catch { continue; }
517
- for (const [, key] of text.matchAll(DOC_TOKEN_RE)) wanted.add(key);
518
- }
519
- const lexWant = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI']
520
- .map((r) => `LEX_${r}`);
552
+ // ---- the vocabulary every theme owes the source: one title per declared rule, keyed by id.
553
+ const wanted = new Set(canon.rules.filter((r) => r.kind === 'DOCTRINE').map((r) => titleKey(r.kind, r.id)));
554
+ const lexWant = canon.rules.filter((r) => r.kind === 'LAW').map((r) => titleKey(r.kind, r.id));
521
555
  // `_TEMPLATE.json` is included — it is the file `--sync-themes` seeds a new theme from, so a
522
556
  // stale key there propagates into every voice added afterwards. `themeFiles()` excludes it.
523
557
  const files = [...themeFiles(), path.join(THEMES, '_TEMPLATE.json')];
@@ -532,13 +566,13 @@ export function constitutionProblems() {
532
566
  const keys = Object.keys(theme);
533
567
  for (const key of [...wanted].sort()) {
534
568
  if (!Object.hasOwn(theme, key)) {
535
- problems.push(`[authoring] ${name} has no {${key}} — a doctrine file names it, and `
569
+ problems.push(`[authoring] ${name} has no {${key}} — a doctrine rule declares it, and `
536
570
  + 'theme parity cannot see a key that is missing from every theme at once');
537
571
  }
538
572
  }
539
573
  for (const key of keys.filter((k) => k.startsWith('DOC_')).sort()) {
540
574
  if (!wanted.has(key)) {
541
- problems.push(`[authoring] ${name} defines {${key}}, which no doctrine file uses — a `
575
+ problems.push(`[authoring] ${name} defines {${key}}, which no doctrine rule declares — a `
542
576
  + 'dead voice key ships a title for a rule that does not exist');
543
577
  }
544
578
  }
@@ -549,8 +583,8 @@ export function constitutionProblems() {
549
583
  const stale = lex.filter((k) => !lexWant.includes(k));
550
584
  const missing = lexWant.filter((k) => !lex.includes(k));
551
585
  for (const k of stale) {
552
- problems.push(`[authoring] ${name} still defines {${k}} — no law heading carries this `
553
- + 'numeral and nothing renders this key, so it is a title for a rule that no longer exists');
586
+ problems.push(`[authoring] ${name} still defines {${k}} — no law declares this id and `
587
+ + 'nothing renders this key, so it is a title for a rule that no longer exists');
554
588
  }
555
589
  for (const k of missing) {
556
590
  problems.push(`[authoring] ${name} has no {${k}} — one of the invariants would `
@@ -670,9 +704,10 @@ export function countTableProblems() {
670
704
  problems.push(`[authoring] SKILL_CLASS lists '${stale}' but no skills/${stale}.md exists`);
671
705
  }
672
706
 
673
- const lawsMd = path.join(SRC, 'laws', 'universal.md');
674
- const lawNums = isFile(lawsMd)
675
- ? [...readText(lawsMd).matchAll(LAW_HEADING_RE)].map((m) => m[1]) : [];
707
+ // The numerals the laws RENDER with — positional, off the canon — because LAW_CLASS and the
708
+ // console's LAW_META are keyed the way the rendered catalogue numbers them.
709
+ const lawRules = ruleCanon(SRC).rules.filter((r) => r.kind === 'LAW');
710
+ const lawNums = lawRules.map((r) => r.label);
676
711
  for (const num of lawNums) {
677
712
  if (!has(LAW_CLASS, num)) {
678
713
  problems.push(`[authoring] laws/universal.md rule ${num} has no class in LAW_CLASS`);
@@ -686,7 +721,7 @@ export function countTableProblems() {
686
721
  + `class ${formatRepr(LAW_CLASSES)}`);
687
722
  }
688
723
  }
689
- problems.push(...lawMetaProblems(lawNums, LAW_CLASS, LAW_CLASSES));
724
+ problems.push(...lawMetaProblems(lawNums, LAW_CLASS, LAW_CLASSES, lawRules.map((r) => r.id)));
690
725
 
691
726
  // WHAT SHIPS, NOT WHAT IS FLAT. A folder skill (`src/skills/<name>/SKILL.md`, vendored with
692
727
  // its scripts or references) ships exactly like a flat one, so the badge, the README list and
@@ -868,6 +903,7 @@ export function authoringProblems() {
868
903
  problems.push(...registryProblems());
869
904
  problems.push(...secretProblems());
870
905
  problems.push(...vendorPinProblems());
906
+ problems.push(...folderSkillProblems());
871
907
  problems.push(...constitutionProblems());
872
908
  problems.push(...leanBlockProblems());
873
909
  problems.push(...countTableProblems());
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The checks over the REPO's own records — the registry, committed secrets, the hook shim
3
- * and the vendored-skill pins.
3
+ * and the vendored skill folders (their pins and their SKILL.md frontmatter).
4
4
  *
5
5
  * Same contract as `checks-build.mjs`: an array of problem strings, empty when clean, one
6
6
  * planted fault per check in the gate. This is the group that reads what the repo SAYS about
@@ -240,6 +240,50 @@ export function vendorPinProblems() {
240
240
  return problems;
241
241
  }
242
242
 
243
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
244
+
245
+ /**
246
+ * Every vendored skill folder's `SKILL.md` opens with a frontmatter whose `name` IS the
247
+ * folder and which carries a `description`.
248
+ *
249
+ * A flat skill gets that frontmatter from `writeNativeLayer`; a folder is copied through as
250
+ * written, so its own SKILL.md is all a host has to register it from. Two shipped broken:
251
+ * daydream had no frontmatter at all and react-view-transitions named itself after its
252
+ * upstream repo — the Agent Skills format requires the name to match the folder. Claude Code
253
+ * tolerated both, so nothing showed; a stricter host is free not to register them. Only the
254
+ * two keys every host reads are checked, at the top level of the block (an indented
255
+ * `name:` under `metadata:` does not count).
256
+ */
257
+ export function folderSkillProblems() {
258
+ const problems = [];
259
+ for (const name of VENDORED_SKILL_DIRS) {
260
+ const folder = path.join(SRC, 'skills', name);
261
+ if (!isDir(folder)) continue; // vendorPinProblems reports the missing folder
262
+ let text;
263
+ try { text = readText(path.join(folder, 'SKILL.md')); } catch {
264
+ problems.push(`[authoring] skills/${name}/ has no SKILL.md — no host can load it`);
265
+ continue;
266
+ }
267
+ const fm = FRONTMATTER_RE.exec(text);
268
+ if (!fm) {
269
+ problems.push(`[authoring] skills/${name}/SKILL.md opens with no frontmatter — a host `
270
+ + 'registers a skill from its name and description');
271
+ continue;
272
+ }
273
+ const declared = /^name:[ \t]*["']?([^"'\r\n]*?)["']?[ \t]*$/m.exec(fm[1]);
274
+ if (!declared) {
275
+ problems.push(`[authoring] skills/${name}/SKILL.md frontmatter has no 'name'`);
276
+ } else if (declared[1] !== name) {
277
+ problems.push(`[authoring] skills/${name}/SKILL.md is named '${declared[1]}' — the name `
278
+ + 'must match the folder');
279
+ }
280
+ if (!/^description:[ \t]*\S/m.test(fm[1])) {
281
+ problems.push(`[authoring] skills/${name}/SKILL.md frontmatter has no 'description'`);
282
+ }
283
+ }
284
+ return problems;
285
+ }
286
+
243
287
 
244
288
  /**
245
289
  * `js/README.md` describes every module under `js/`, and only modules that exist.