geneseed 3.7.0 → 3.9.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 (194) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/DESIGN.md +13 -10
  3. package/README.md +5 -5
  4. package/SETUP.md +2 -2
  5. package/SHIPPED.md +3 -3
  6. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
  7. package/adapters/opencode/README.md +3 -3
  8. package/adapters/opencode/plugins/geneseed-guard.js +9 -9
  9. package/adapters/opencode/plugins/geneseed-workflow.js +1 -1
  10. package/adapters/opencode/workflows/_runtime.js +2 -2
  11. package/adapters/opencode/workflows/dispatch.js +1 -1
  12. package/docs/extending.md +72 -40
  13. package/docs/web/harnesses.md +2 -2
  14. package/docs/web/plugin-guard.md +2 -2
  15. package/docs/web/rules.md +1 -1
  16. package/docs/web/themes-author.md +2 -2
  17. package/docs/web-ui.md +1 -1
  18. package/harness.config.json +1 -1
  19. package/js/README.md +4 -3
  20. package/js/build/driver.mjs +14 -5
  21. package/js/build/generate.mjs +90 -41
  22. package/js/build/render.mjs +6 -3
  23. package/js/build/source.mjs +105 -1
  24. package/js/build/stubs.mjs +7 -2
  25. package/js/cli-table.json +154 -5
  26. package/js/hosts/hooks.mjs +5 -5
  27. package/js/hosts/native.mjs +1 -1
  28. package/js/inspect/checks-authoring.mjs +149 -116
  29. package/js/inspect/inventory.mjs +1 -2
  30. package/js/inspect/status.mjs +40 -4
  31. package/js/ui/tui.mjs +1 -1
  32. package/js/web/api.mjs +6 -1
  33. package/package.json +1 -1
  34. package/registry.json +7 -0
  35. package/src/AGENT.md.tmpl +19 -15
  36. package/src/agents/_template.md +3 -3
  37. package/src/agents/advocate.md +3 -3
  38. package/src/agents/architect.md +3 -3
  39. package/src/agents/developer.md +4 -4
  40. package/src/agents/docs.md +3 -3
  41. package/src/agents/empiricist.md +4 -4
  42. package/src/agents/explorer.md +4 -4
  43. package/src/agents/framer.md +3 -3
  44. package/src/agents/historian.md +3 -3
  45. package/src/agents/operator.md +3 -3
  46. package/src/agents/pragmatist.md +3 -3
  47. package/src/agents/researcher.md +7 -7
  48. package/src/agents/reviewer.md +3 -3
  49. package/src/agents/security.md +3 -3
  50. package/src/agents/skeptic.md +3 -3
  51. package/src/agents/steward.md +3 -3
  52. package/src/agents/tester.md +3 -3
  53. package/src/agents/user-advocate.md +3 -3
  54. package/src/agents/visionary.md +3 -3
  55. package/src/doctrines/README.md +4 -3
  56. package/src/doctrines/comms.md +4 -4
  57. package/src/doctrines/craft.md +20 -20
  58. package/src/doctrines/ops.md +19 -19
  59. package/src/doctrines/process.md +20 -20
  60. package/src/doctrines/rigor.md +22 -22
  61. package/src/laws/universal.md +23 -44
  62. package/src/memory/README.md +3 -3
  63. package/src/modes/foreman.md +1 -1
  64. package/src/notebook/README.md +1 -1
  65. package/src/ontology/universal.md +7 -9
  66. package/src/postures/artisan.md +2 -2
  67. package/src/skills/_self-improvement.md +4 -4
  68. package/src/skills/_template.md +1 -1
  69. package/src/skills/brainstorm.md +3 -3
  70. package/src/skills/bruno-collection-generator.md +6 -6
  71. package/src/skills/bruno-test-writer.md +5 -5
  72. package/src/skills/ci-fix.md +4 -4
  73. package/src/skills/commit.md +11 -10
  74. package/src/skills/consolidate-memory.md +4 -4
  75. package/src/skills/council.md +2 -2
  76. package/src/skills/debug.md +4 -4
  77. package/src/skills/deps-audit.md +3 -3
  78. package/src/skills/develop.md +7 -7
  79. package/src/skills/document-project.md +6 -6
  80. package/src/skills/explain-changes/SKILL.md +69 -0
  81. package/src/skills/explain-changes/VENDOR.md +20 -0
  82. package/src/skills/explain-changes/scripts/render_changes.mjs +558 -0
  83. package/src/skills/forge-mcp.md +4 -4
  84. package/src/skills/fresh-eyes.md +4 -4
  85. package/src/skills/frontend-design.md +1 -1
  86. package/src/skills/geneseed.md +9 -8
  87. package/src/skills/git-archaeology.md +5 -5
  88. package/src/skills/git-rescue.md +7 -7
  89. package/src/skills/handoff.md +3 -3
  90. package/src/skills/ingest.md +5 -5
  91. package/src/skills/migrate.md +8 -8
  92. package/src/skills/parallel-agents.md +2 -2
  93. package/src/skills/pipeline.md +2 -2
  94. package/src/skills/plan.md +3 -3
  95. package/src/skills/ponytail.md +2 -2
  96. package/src/skills/profile.md +2 -2
  97. package/src/skills/prose.md +1 -1
  98. package/src/skills/prototype.md +1 -1
  99. package/src/skills/refactor.md +3 -3
  100. package/src/skills/release.md +5 -5
  101. package/src/skills/repo-map.md +4 -4
  102. package/src/skills/research.md +9 -9
  103. package/src/skills/review-response.md +4 -4
  104. package/src/skills/rule.md +3 -3
  105. package/src/skills/security-audit.md +5 -5
  106. package/src/skills/ship.md +5 -5
  107. package/src/skills/skill-forge.md +4 -4
  108. package/src/skills/wiki.md +5 -5
  109. package/src/skills/workflow.md +2 -3
  110. package/src/skills/worktree.md +4 -4
  111. package/themes/_TEMPLATE.json +38 -40
  112. package/themes/biker.json +38 -40
  113. package/themes/commentator.json +38 -40
  114. package/themes/cyberpunk.json +38 -40
  115. package/themes/gamer.json +38 -40
  116. package/themes/imperial.json +38 -40
  117. package/themes/joker.json +38 -40
  118. package/themes/marvin.json +38 -40
  119. package/themes/mean.json +38 -40
  120. package/themes/military.json +38 -40
  121. package/themes/neutral.json +38 -40
  122. package/themes/pirate.json +38 -40
  123. package/themes/sports.json +38 -40
  124. package/themes/verstappen.json +38 -40
  125. package/themes/wizard.json +38 -40
  126. package/web/dist/assets/Activity-DAzV-qQj.js +1 -0
  127. package/web/dist/assets/ActivityDetail-CwEwMtKl.js +1 -0
  128. package/web/dist/assets/Dashboard-C2ELBW-X.js +2 -0
  129. package/web/dist/assets/{Docs-DrJNDPwI.js → Docs-iZrvQP8J.js} +2 -2
  130. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  131. package/web/dist/assets/GreenhouseView-Bq1QmaGO.js +2 -0
  132. package/web/dist/assets/Installs-BiqR8xdO.js +2 -0
  133. package/web/dist/assets/JournalView-6wilG5Zd.js +1 -0
  134. package/web/dist/assets/Laws-D8YXD_XE.js +3 -0
  135. package/web/dist/assets/Library-DwuTZ2-L.js +1 -0
  136. package/web/dist/assets/{Markdown-BQAvN8xx.js → Markdown-CkFyfgNf.js} +14 -14
  137. package/web/dist/assets/OperatorHudView-B-HVBqfY.js +2 -0
  138. package/web/dist/assets/Personal-BbUTmZQR.js +1 -0
  139. package/web/dist/assets/Tabs-_pjUFIEI.js +1 -0
  140. package/web/dist/assets/drawable-CoIulEH2.js +1 -0
  141. package/web/dist/assets/geist-mono-latin-400-normal-DTRLJnHl.woff2 +0 -0
  142. package/web/dist/assets/geist-mono-latin-500-normal-YINYabwD.woff2 +0 -0
  143. package/web/dist/assets/index-BMwSp8BP.js +2 -0
  144. package/web/dist/assets/index-CDPDk2vj.css +1 -0
  145. package/web/dist/assets/{react-DbkvxWG5.js → react-B9Z_tXRV.js} +1 -1
  146. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  147. package/web/dist/assets/usePoll-O0sJEw1Y.js +1 -0
  148. package/web/dist/index.html +4 -4
  149. package/web/src/pages/Laws.jsx +616 -361
  150. package/web/dist/assets/Activity-CH-7cPIM.js +0 -1
  151. package/web/dist/assets/ActivityDetail-DZvz0xS5.js +0 -1
  152. package/web/dist/assets/CatalogRow-DftXpZHY.js +0 -1
  153. package/web/dist/assets/Diff-DvwdG0L9.js +0 -2
  154. package/web/dist/assets/Doctor-OTmwmYZg.js +0 -1
  155. package/web/dist/assets/FilterInput-BakJLvaV.js +0 -1
  156. package/web/dist/assets/GreenhouseView-XxrAdriX.js +0 -2
  157. package/web/dist/assets/Harness-C_LYNk5c.js +0 -1
  158. package/web/dist/assets/JournalView-BcuyeEaR.js +0 -1
  159. package/web/dist/assets/Laws-CWHrTKY4.js +0 -3
  160. package/web/dist/assets/Library-CA9IHghn.js +0 -1
  161. package/web/dist/assets/OperatorHudView-BMRY1Tt4.js +0 -2
  162. package/web/dist/assets/Profile-wceET1JY.js +0 -1
  163. package/web/dist/assets/Rules-BEXvY4Tx.js +0 -1
  164. package/web/dist/assets/Settings-DS2zF0Ez.js +0 -1
  165. package/web/dist/assets/Skills-DahlOJuI.js +0 -1
  166. package/web/dist/assets/StatusBadge-Cg6j-NJL.js +0 -1
  167. package/web/dist/assets/activity-iWaWVtsR.js +0 -1
  168. package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
  169. package/web/dist/assets/drawable-BTHjTzF7.js +0 -1
  170. package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
  171. package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
  172. package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
  173. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  174. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  175. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  176. package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
  177. package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
  178. package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
  179. package/web/dist/assets/index-AN5Emnrg.js +0 -2
  180. package/web/dist/assets/index-Ruh93A3A.css +0 -1
  181. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  182. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  183. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  184. package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  185. package/web/dist/assets/lawCats-Btjp3Hk_.js +0 -1
  186. package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
  187. package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
  188. package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
  189. package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
  190. package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
  191. package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
  192. package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
  193. package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
  194. package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +0 -1
@@ -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';
@@ -47,7 +48,11 @@ export function romanToInt(num) {
47
48
  }
48
49
 
49
50
  /**
50
- * `_harness_build._LAW_META_ROW` — `<arabic>: ['<class>', '<principle>'],` in the web page.
51
+ * `_harness_build._LAW_META_ROW` — `<arabic>: ['<class>', '<principle>', '<id>'],` in the web page.
52
+ *
53
+ * The third field PINS the row to a rule id. The key is the number the catalogue renders, and a
54
+ * number is only a position: remove a law and every later row describes its predecessor's
55
+ * neighbour, with every class still plausible. The pin is what makes that a doctor failure.
51
56
  *
52
57
  * Either quote style, because a principle carrying an apostrophe is double-quoted. Every
53
58
  * `\s*` spans newlines and the trailing comma before `]` is optional, so a row a prettier has
@@ -57,7 +62,8 @@ export function romanToInt(num) {
57
62
  * `(?m)` is the `m` flag; JS has no inline form. The `.` inside the captures must NOT match a
58
63
  * newline (Python has no `re.S` here), which is JS's default.
59
64
  */
60
- const LAW_META_ROW = /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s*,?\s*\]\s*,?[^\S\n]*$/gm;
65
+ const LAW_META_ROW =
66
+ /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s*(?:,\s*(['"])(.*?)\6\s*)?,?\s*\]\s*,?[^\S\n]*$/gm;
61
67
 
62
68
  /**
63
69
  * `_harness_build._law_meta_problems` — the web Laws ledger's per-rule copy stays complete.
@@ -67,7 +73,7 @@ const LAW_META_ROW = /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s
67
73
  * description and the wrong class chip, while everything upstream is fine. That is exactly
68
74
  * how two laws shipped description-less.
69
75
  */
70
- export function lawMetaProblems(lawNums, lawClass, lawClasses) {
76
+ export function lawMetaProblems(lawNums, lawClass, lawClasses, lawIds = null) {
71
77
  const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
72
78
  let text;
73
79
  try { text = readText(page); } catch (e) {
@@ -80,11 +86,11 @@ export function lawMetaProblems(lawNums, lawClass, lawClasses) {
80
86
  }
81
87
  const meta = new Map();
82
88
  for (const m of block[1].matchAll(LAW_META_ROW)) {
83
- meta.set(Number(m[1]), [m[3], m[5]]);
89
+ meta.set(Number(m[1]), [m[3], m[5], m[7]]);
84
90
  }
85
91
  const problems = [];
86
92
  const seen = new Set();
87
- for (const roman of lawNums) {
93
+ for (const [i, roman] of lawNums.entries()) {
88
94
  const n = romanToInt(roman);
89
95
  seen.add(n);
90
96
  if (!meta.has(n)) {
@@ -93,7 +99,12 @@ export function lawMetaProblems(lawNums, lawClass, lawClasses) {
93
99
  + 'render it with a blank Principle');
94
100
  continue;
95
101
  }
96
- const [klass, principle] = meta.get(n);
102
+ const [klass, principle, pin] = meta.get(n);
103
+ if (lawIds && pin !== lawIds[i]) {
104
+ problems.push(`[authoring] LAW_META[${n}] is pinned to '${pin ?? '(no id)'}' but rule ${roman} is `
105
+ + `'${lawIds[i]}' — the rows no longer follow the canon's order, so this principle `
106
+ + 'describes a different law');
107
+ }
97
108
  if (!principle.trim()) {
98
109
  problems.push(`[authoring] LAW_META[${n}] has an empty principle line — rule `
99
110
  + `${roman} would render with a blank description`);
@@ -112,9 +123,9 @@ export function lawMetaProblems(lawNums, lawClass, lawClasses) {
112
123
  return problems;
113
124
  }
114
125
 
115
- /** `LAW_META_ROW`'s twin for a STRING key — `'<pack>.<n>': ['<pack>', '<principle>'],`. */
126
+ /** `LAW_META_ROW`'s twin for a STRING key — `'<pack>.<n>': ['<pack>', '<principle>', '<id>'],`. */
116
127
  const DOCTRINE_META_ROW =
117
- /^[^\S\n]*(['"])([a-z]+\.\d+)\1:\s*\[\s*(['"])(.*?)\3\s*,\s*(['"])(.*?)\5\s*,?\s*\]\s*,?[^\S\n]*$/gm;
128
+ /^[^\S\n]*(['"])([a-z]+\.\d+)\1:\s*\[\s*(['"])(.*?)\3\s*,\s*(['"])(.*?)\5\s*(?:,\s*(['"])(.*?)\7\s*)?,?\s*\]\s*,?[^\S\n]*$/gm;
118
129
 
119
130
  /**
120
131
  * `lawMetaProblems`' doctrine half — the console's per-rule Principle column stays complete.
@@ -130,7 +141,7 @@ const DOCTRINE_META_ROW =
130
141
  * error a six-way vocabulary cannot: a row pasted from the pack above it, keeping the class of
131
142
  * the pack it came from, which would colour the row and label its chip with the wrong pack.
132
143
  */
133
- export function doctrineMetaProblems(addrs) {
144
+ export function doctrineMetaProblems(addrs, ids = null) {
134
145
  const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
135
146
  let text;
136
147
  try { text = readText(page); } catch (e) {
@@ -142,17 +153,21 @@ export function doctrineMetaProblems(addrs) {
142
153
  + "console's doctrine rows would have no gate on their Principle column"];
143
154
  }
144
155
  const meta = new Map();
145
- for (const m of block[1].matchAll(DOCTRINE_META_ROW)) meta.set(m[2], [m[4], m[6]]);
156
+ for (const m of block[1].matchAll(DOCTRINE_META_ROW)) meta.set(m[2], [m[4], m[6], m[8]]);
146
157
  const problems = [];
147
158
  const seen = new Set();
148
- for (const addr of addrs) {
159
+ for (const [i, addr] of addrs.entries()) {
149
160
  seen.add(addr);
150
161
  if (!meta.has(addr)) {
151
162
  problems.push(`[authoring] doctrine rule ${addr} has no row in DOCTRINE_META `
152
163
  + '(web/src/pages/Laws.jsx) — the console would render it with a blank Principle');
153
164
  continue;
154
165
  }
155
- const [klass, principle] = meta.get(addr);
166
+ const [klass, principle, pin] = meta.get(addr);
167
+ if (ids && pin !== ids[i]) {
168
+ problems.push(`[authoring] DOCTRINE_META['${addr}'] is pinned to '${pin ?? '(no id)'}' but `
169
+ + `that address is '${ids[i]}' now — the row describes a different rule`);
170
+ }
156
171
  if (!principle.trim()) {
157
172
  problems.push(`[authoring] DOCTRINE_META['${addr}'] has an empty principle line — that `
158
173
  + 'rule would render with a blank description');
@@ -267,22 +282,35 @@ export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '
267
282
 
268
283
  /** `_build_render._TMPL_SPEC_RE`, as the count gate spells it. */
269
284
  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;
285
+ // A rule is DECLARED by its heading and CITED by its id — see `ruleCanon` in
286
+ // `js/build/source.mjs`. The gates below read the RAW source, because the render resolves ids and
287
+ // a bad one would already be gone from what it hands back.
288
+ const DECL_LINE_RE = /^### \{\{(LAW|DOCTRINE):([^}]*)\}\}[ \t]+\S/;
289
+ const ID_CITE_RE = /\{\{(LAW|DOCTRINE):([^}]*)\}\}/g;
290
+ // The pre-id spellings. Either one in src/ is a citation that names a POSITION, which is exactly
291
+ // what stops being true the next time the canon moves.
292
+ const BARE_CITE_RE = /\{\{LAW\}\}\s+[IVXLCDM]+\b|\{\{DOCTRINE\}\}\s+[a-z]+\s+\d+\b/g;
293
+ // `{{DOCTRINE}}` or `{{DOCTRINE:` and not `DOCTRINE`: `{{DOCTRINES}}` is the SECTION noun and is
294
+ // legal everywhere, including in an invariant that points at the tier as a whole.
295
+ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
296
+
297
+ /**
298
+ * The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
299
+ * hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
300
+ * (`law-1`, `law-4`, `process-1`, `process-5` in `js/hosts/hooks.mjs` and the OpenCode guard
301
+ * plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
302
+ * number-keyed — and this pin is what turns a renumber that moves one of these rules into a
303
+ * doctor failure instead of a gate silently guarding its neighbour.
304
+ */
305
+ export const HOOK_PINNED = {
306
+ 'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
307
+ 'persist-insight': 'process 1', 'consent-before-push': 'process 5',
308
+ };
281
309
 
282
310
  /**
283
311
  * The LEAN block grammar, and the heading split, both duplicated from `js/build/render.mjs`.
284
312
  *
285
- * Deliberately duplicated, the way `DOCTRINE_HEADING_RE` above is: both are module-private
313
+ * Deliberately duplicated rather than imported: both are module-private
286
314
  * there, and exporting them so one gate could borrow them would widen the renderer's surface
287
315
  * for a reader that only needs to know the shape. Two literals, one paragraph apart from the
288
316
  * rule they encode, is cheaper than a seam.
@@ -377,48 +405,84 @@ export function leanBlockProblems() {
377
405
  * ⚠ THE VOCABULARY GATES ARE HERE BECAUSE `themeParityProblems` CANNOT SEE THEM. That check is
378
406
  * presence-only and SYMMETRIC over the union of every theme's keys: a key missing from all
379
407
  * 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
408
+ * `DOC_*` coverage, the `LEX_*` equality and the single-word tier noun each need a gate
381
409
  * that reads the SOURCE and asks what the source requires.
382
410
  */
383
411
  export function constitutionProblems() {
384
412
  const problems = [];
385
- const dir = path.join(SRC, 'doctrines');
386
413
 
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`);
414
+ // ---- every rule declares an id, every id is well-formed and unique, none is retired.
415
+ //
416
+ // Numbers are positional now (`ruleCanon`), so contiguity and filing cannot go wrong; what can
417
+ // is the declaration itself. A `### ` line in a canon file that is not `{{KIND:id}} Name`
418
+ // renders nowhere as a rule — no number, no title — and a duplicated id makes every citation
419
+ // of it ambiguous.
420
+ for (const [kind, pack, file] of canonFiles(SRC)) {
421
+ const rel = path.relative(SRC, file).split(path.sep).join('/');
391
422
  let text;
392
423
  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');
396
- continue;
397
- }
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');
424
+ problems.push(`[authoring] ${rel} is ${pack ? 'named in PACK_ORDER' : 'the canon'} but `
425
+ + `unreadable: ${e.message} — every citation into it breaks`);
402
426
  continue;
403
427
  }
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}`);
428
+ let count = 0;
429
+ for (const line of text.split('\n').filter((l) => l.startsWith('### '))) {
430
+ const m = DECL_LINE_RE.exec(line);
431
+ if (!m || m[1] !== kind) {
432
+ problems.push(`[authoring] ${rel}: '${line}' is not a rule declaration — a rule heading `
433
+ + `here reads '### {{${kind}:<id>}} <Principle Name>'; without an id it has no number, `
434
+ + 'no title and no way to be cited');
435
+ continue;
409
436
  }
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');
437
+ count += 1;
438
+ if (!RULE_ID_RE.test(m[2])) {
439
+ problems.push(`[authoring] ${rel}: rule id '${m[2]}' is not kebab-case (a-z, 0-9, '-')`);
413
440
  }
414
- rules.set(`${named}.${Number(n)}`, true);
415
- });
441
+ }
442
+ if (!count) {
443
+ const why = pack ? 'the pack would render as an empty section' : 'there would be no invariants';
444
+ problems.push(`[authoring] ${rel} declares no rule — ${why}`);
445
+ }
446
+ }
447
+ const canon = ruleCanon(SRC);
448
+ const seenIds = new Map();
449
+ const seenNames = new Map();
450
+ for (const r of canon.rules) {
451
+ const where = r.kind === 'LAW' ? `law ${r.label}` : `doctrine ${r.label}`;
452
+ if (seenIds.has(r.id)) {
453
+ problems.push(`[authoring] rule id '${r.id}' is declared twice (${seenIds.get(r.id)} and `
454
+ + `${where}) — ids are unique across laws and doctrines, or a citation names two rules`);
455
+ } else seenIds.set(r.id, where);
456
+ const lower = r.name.toLowerCase();
457
+ if (seenNames.has(lower)) {
458
+ problems.push(`[authoring] principle name '${r.name}' is declared twice (${seenNames.get(lower)} `
459
+ + `and ${where}) — a citation renders as the name, so two rules would read as one`);
460
+ } else seenNames.set(lower, where);
461
+ const retiredAs = RETIRED_RULE_IDS[r.id];
462
+ if (retiredAs && retiredAs.liveAs !== r.kind) {
463
+ problems.push(`[authoring] ${where} reuses the retired id '${r.id}' (${retiredAs.was}, `
464
+ + `${retiredAs.note}) — an id that meant one rule in someone's notes must never come back `
465
+ + 'meaning another; pick a new one');
466
+ }
467
+ }
468
+ const rules = new Map(canon.rules.filter((r) => r.kind === 'DOCTRINE')
469
+ .map((r) => [`${r.pack}.${r.n}`, r]));
470
+
471
+ // ---- the hook-gated rules are still at the address the hooks key them by.
472
+ for (const [id, label] of Object.entries(HOOK_PINNED)) {
473
+ const r = canon.byId.get(id);
474
+ if (!r || r.label !== label) {
475
+ problems.push(`[authoring] the hooks gate '${id}' as ${label} (js/hosts/hooks.mjs ledger keys, `
476
+ + `settings.mjs CONSENT_RULE, the console's HOOK_GATED), but the canon ${r ? `numbers it ${r.label}`
477
+ : 'no longer declares it'} — move those keys with it, or the tool boundary guards a `
478
+ + 'different rule than the prompt names');
479
+ }
416
480
  }
417
481
 
418
482
  // The console's Principle column, keyed off the rules just parsed — so the gate is driven by
419
483
  // the SOURCE and a rule added to a pack file is caught the same day, not when someone
420
484
  // notices a blank description in the browser.
421
- problems.push(...doctrineMetaProblems([...rules.keys()]));
485
+ problems.push(...doctrineMetaProblems([...rules.keys()], [...rules.values()].map((r) => r.id)));
422
486
 
423
487
  // ---- `--exclude-rules` closes against the SAME rules this walk just found.
424
488
  //
@@ -440,41 +504,11 @@ export function constitutionProblems() {
440
504
  + 'no switch for it');
441
505
  }
442
506
 
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.
507
+ // ---- every citation in src/ is by id, and every id resolves.
462
508
  //
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.
509
+ // The whole tree, not the template: a skill footer citing a rule that does not exist is as
510
+ // broken as a template one, and it renders into every bundle.
465
511
  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
512
  for (const p of rglob(SRC)) {
479
513
  if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
480
514
  let text;
@@ -489,35 +523,33 @@ export function constitutionProblems() {
489
523
  + 'reference a toggleable one, or a pack-off build ships a rule pointing at text '
490
524
  + 'that is not there. State the point directly instead');
491
525
  }
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');
526
+ for (const [bare] of text.matchAll(BARE_CITE_RE)) {
527
+ problems.push(`[authoring] ${rel} cites '${bare.replace(/\s+/g, ' ')}' by number — numbers are `
528
+ + 'positional and move when the canon does; cite {{LAW:<id>}} / {{DOCTRINE:<id>}}');
529
+ }
530
+ for (const [whole, kind, id] of text.matchAll(ID_CITE_RE)) {
531
+ const r = canon.byId.get(id);
532
+ if (r && r.kind === kind) {
533
+ if (kind === 'DOCTRINE') {
534
+ const key = `${r.pack}.${r.n}`;
535
+ if (!cited.has(key)) cited.set(key, []);
536
+ cited.get(key).push(rel);
497
537
  }
538
+ continue;
498
539
  }
540
+ const retiredAs = RETIRED_RULE_IDS[id];
541
+ let why = 'which no law or doctrine declares — it would render as the raw token';
542
+ if (r) why = `but '${id}' is a ${r.kind === 'LAW' ? 'law' : 'doctrine rule'} — use {{${r.kind}:${id}}}`;
543
+ else if (retiredAs) {
544
+ why = `a retired id (${retiredAs.was}, ${retiredAs.note}) — cite the rule its text now lives in`;
545
+ }
546
+ problems.push(`[authoring] ${rel} cites ${whole}, ${why}`);
499
547
  }
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
548
  }
511
549
 
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}`);
550
+ // ---- the vocabulary every theme owes the source: one title per declared rule, keyed by id.
551
+ const wanted = new Set(canon.rules.filter((r) => r.kind === 'DOCTRINE').map((r) => titleKey(r.kind, r.id)));
552
+ const lexWant = canon.rules.filter((r) => r.kind === 'LAW').map((r) => titleKey(r.kind, r.id));
521
553
  // `_TEMPLATE.json` is included — it is the file `--sync-themes` seeds a new theme from, so a
522
554
  // stale key there propagates into every voice added afterwards. `themeFiles()` excludes it.
523
555
  const files = [...themeFiles(), path.join(THEMES, '_TEMPLATE.json')];
@@ -532,13 +564,13 @@ export function constitutionProblems() {
532
564
  const keys = Object.keys(theme);
533
565
  for (const key of [...wanted].sort()) {
534
566
  if (!Object.hasOwn(theme, key)) {
535
- problems.push(`[authoring] ${name} has no {${key}} — a doctrine file names it, and `
567
+ problems.push(`[authoring] ${name} has no {${key}} — a doctrine rule declares it, and `
536
568
  + 'theme parity cannot see a key that is missing from every theme at once');
537
569
  }
538
570
  }
539
571
  for (const key of keys.filter((k) => k.startsWith('DOC_')).sort()) {
540
572
  if (!wanted.has(key)) {
541
- problems.push(`[authoring] ${name} defines {${key}}, which no doctrine file uses — a `
573
+ problems.push(`[authoring] ${name} defines {${key}}, which no doctrine rule declares — a `
542
574
  + 'dead voice key ships a title for a rule that does not exist');
543
575
  }
544
576
  }
@@ -549,8 +581,8 @@ export function constitutionProblems() {
549
581
  const stale = lex.filter((k) => !lexWant.includes(k));
550
582
  const missing = lexWant.filter((k) => !lex.includes(k));
551
583
  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');
584
+ problems.push(`[authoring] ${name} still defines {${k}} — no law declares this id and `
585
+ + 'nothing renders this key, so it is a title for a rule that no longer exists');
554
586
  }
555
587
  for (const k of missing) {
556
588
  problems.push(`[authoring] ${name} has no {${k}} — one of the invariants would `
@@ -670,9 +702,10 @@ export function countTableProblems() {
670
702
  problems.push(`[authoring] SKILL_CLASS lists '${stale}' but no skills/${stale}.md exists`);
671
703
  }
672
704
 
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]) : [];
705
+ // The numerals the laws RENDER with — positional, off the canon — because LAW_CLASS and the
706
+ // console's LAW_META are keyed the way the rendered catalogue numbers them.
707
+ const lawRules = ruleCanon(SRC).rules.filter((r) => r.kind === 'LAW');
708
+ const lawNums = lawRules.map((r) => r.label);
676
709
  for (const num of lawNums) {
677
710
  if (!has(LAW_CLASS, num)) {
678
711
  problems.push(`[authoring] laws/universal.md rule ${num} has no class in LAW_CLASS`);
@@ -686,7 +719,7 @@ export function countTableProblems() {
686
719
  + `class ${formatRepr(LAW_CLASSES)}`);
687
720
  }
688
721
  }
689
- problems.push(...lawMetaProblems(lawNums, LAW_CLASS, LAW_CLASSES));
722
+ problems.push(...lawMetaProblems(lawNums, LAW_CLASS, LAW_CLASSES, lawRules.map((r) => r.id)));
690
723
 
691
724
  // WHAT SHIPS, NOT WHAT IS FLAT. A folder skill (`src/skills/<name>/SKILL.md`, vendored with
692
725
  // its scripts or references) ships exactly like a flat one, so the badge, the README list and
@@ -70,8 +70,7 @@ const PACK_LEAD_RE = /^\*\*(.+?)\*\*\s+[—-]\s+(.+?)\s*$/;
70
70
  /** `_harness_tui.LAW_CLASS` — each rule's class, keyed by Roman numeral. */
71
71
  export const LAW_CLASS = {
72
72
  I: 'security', II: 'process', III: 'verify', IV: 'security', V: 'verify',
73
- VI: 'security', VII: 'security', VIII: 'craft', IX: 'security', X: 'verify',
74
- XI: 'verify',
73
+ VI: 'security', VII: 'security', VIII: 'craft', IX: 'verify',
75
74
  };
76
75
 
77
76
  /** `_harness_tui.ENTITY_STATUSES` — the lifecycle statuses `registry.json` may carry. */
@@ -70,8 +70,9 @@ import {
70
70
  // re-exported below because `tests/fixtures/pure_probe.mjs` names this module for two of them
71
71
  // and a corpus that followed the code to its new file would stop testing the caller's view.
72
72
  import {
73
- defaultTheme, installedDefaults, manifestIsClaude, readJsonMaybe, readMaybe,
73
+ defaultTheme, installedDefaults, installTargets, manifestIsClaude, readJsonMaybe, readMaybe,
74
74
  } from '../hosts/installs.mjs';
75
+ import { installProfile, rebuildCommand } from '../build/generate.mjs';
75
76
  import { printOut } from '../lib/fs.mjs';
76
77
  import { codePointLength, padEndToWidth } from '../lib/text.mjs';
77
78
 
@@ -262,6 +263,20 @@ export function statusData() {
262
263
  agent_md: agentMd ? String(agentMd) : null,
263
264
  agent_md_present: Boolean(agentMd && existsSync(agentMd)),
264
265
  gates: gateSummary([...(cfgDir ? [cfgDir] : []), ...otherCfg]),
266
+ // EVERY install, not just the one `installedDefaults` settles on — and read through the
267
+ // same `installProfile` `rebuild-all` uses, so the settings shown are the ones a rebuild
268
+ // keeps. `rebuild` is the pasteable command an agent edits one flag of. A broken install
269
+ // must not sink the panel, so it is reported rather than thrown. `absent` slots are
270
+ // filtered out here: an absent slot has no settings, so a row of defaults for it reads as
271
+ // an install that does not exist — noise in the panel and misinformation in `--json`.
272
+ installs: installTargets().map(([host, scope, root]) => {
273
+ try {
274
+ const p = installProfile(host, scope, root);
275
+ return { ...p, rebuild: rebuildCommand(p.argv) };
276
+ } catch (e) {
277
+ return { host, scope, root, state: 'unreadable', error: String(e?.message ?? e) };
278
+ }
279
+ }).filter((p) => p.state !== 'absent'),
265
280
  };
266
281
  }
267
282
 
@@ -343,6 +358,18 @@ export function statusLines(d, color = false) {
343
358
  rows.push(['gates', `${armed} ${DOT} ${g.total} ask${g.total === 1 ? '' : 's'}`
344
359
  + (byRule ? ` (${byRule})` : '')]);
345
360
  }
361
+ // Conditional for the recording reason again: no recorded panel carries `installs`.
362
+ for (const p of d.installs ?? []) {
363
+ const where = p.scope === 'project' ? ` (${p.root})` : '';
364
+ if (p.state === 'unreadable') {
365
+ rows.push([`${p.host}:${p.scope}`, `unreadable: ${p.error}${where}`]);
366
+ continue;
367
+ }
368
+ const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
369
+ const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
370
+ rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}`,
371
+ packs, excl].join(` ${DOT} `) + where]);
372
+ }
346
373
 
347
374
  const labelW = Math.max(...rows.map(([k]) => codePointLength(k)));
348
375
  const body = rows.map(([k, v]) => ` ${padEndToWidth(k, labelW)} ${v}`);
@@ -365,8 +392,17 @@ export function statusLines(d, color = false) {
365
392
  return lines;
366
393
  }
367
394
 
368
- /** `_harness_status.cmd_status`. */
369
- export function cmdStatus() {
370
- for (const line of statusLines(statusData(), colorEnabled())) printOut(`${line}\n`);
395
+ /**
396
+ * `_harness_status.cmd_status` — and `--json`, the machine face of the same payload. An agent
397
+ * reading the panel had to scrape box-drawing; the object below is what the panel is drawn
398
+ * FROM, so the two cannot disagree.
399
+ */
400
+ export function cmdStatus(args) {
401
+ const d = statusData();
402
+ if (args?.json) {
403
+ printOut(`${JSON.stringify(d, null, 2)}\n`);
404
+ return 0;
405
+ }
406
+ for (const line of statusLines(d, colorEnabled())) printOut(`${line}\n`);
371
407
  return 0;
372
408
  }
package/js/ui/tui.mjs CHANGED
@@ -347,7 +347,7 @@ export function themePreview(key) {
347
347
  if (data.TAGLINE) lines.push(['dim', data.TAGLINE], ['', '']);
348
348
  if (data.LOADED_SIGIL) lines.push(['ok', data.LOADED_SIGIL], ['', '']);
349
349
  if (data.VOICE) lines.push(['', `Voice — ${data.VOICE}`], ['', '']);
350
- if (data.LEX_I) lines.push(['', `e.g. Rule I — ${data.LEX_I}`]);
350
+ if (data.LEX_SEALED_SECRETS) lines.push(['', `e.g. Rule I — ${data.LEX_SEALED_SECRETS}`]);
351
351
  if (data.BENEDICTION) lines.push(['', ''], ['dim', data.BENEDICTION]);
352
352
  return lines;
353
353
  }
package/js/web/api.mjs CHANGED
@@ -35,6 +35,7 @@ import { readText, withDiscardableStderr, isDir, isFile } from '../lib/fs.mjs';
35
35
  import { comparePaths, normcase, within } from '../lib/paths.mjs';
36
36
  import { stripWhitespace, percentDecode } from '../lib/text.mjs';
37
37
  import { readJsonc } from '../hosts/settings.mjs';
38
+ import { PROFILE_STUB } from '../build/stubs.mjs';
38
39
  import { apiActivity, apiActivityDetail } from './activity.mjs';
39
40
  import { apiMcp, apiRules } from './actions.mjs';
40
41
 
@@ -755,7 +756,11 @@ function apiProfile(state) {
755
756
  const p = profilePath(state);
756
757
  if (!isFile(p)) return { exists: false, path: p, text: '', fingerprint: '' };
757
758
  const text = readMaybe(p) ?? '';
758
- return { exists: true, path: p, text, fingerprint: fingerprint(text) };
759
+ // `seeded`: the file is still the template the build wrote once and never touched since.
760
+ // `readMaybe` normalises line endings, so a CRLF copy (the emit writes the platform's, and
761
+ // an editor may re-save either way) still compares equal to the LF literal.
762
+ const seeded = text === PROFILE_STUB;
763
+ return { exists: true, path: p, text, fingerprint: fingerprint(text), seeded };
759
764
  }
760
765
 
761
766
  /** The dashboard aggregate. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "3.7.0",
4
+ "version": "3.9.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
package/registry.json CHANGED
@@ -463,6 +463,13 @@
463
463
  "added": "2026-07-29",
464
464
  "last_verified": ""
465
465
  },
466
+ "skills/explain-changes": {
467
+ "status": "experimental",
468
+ "version": "1.0.0",
469
+ "owner": "Guillaume Lemer",
470
+ "added": "2026-09-30",
471
+ "last_verified": ""
472
+ },
466
473
  "skills/wayfinder": {
467
474
  "status": "approved",
468
475
  "version": "1.0.0",