geneseed 3.12.0 → 3.14.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 (240) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +1 -1
  5. package/adapters/claude-code/README.md +1 -1
  6. package/adapters/openclaude/README.md +1 -1
  7. package/adapters/opencode/README.md +3 -3
  8. package/adapters/opencode/plugins/geneseed-learn.js +10 -1
  9. package/bin/geneseed-cli.mjs +10 -14
  10. package/bin/geneseed-hook.mjs +22 -15
  11. package/docs/README.md +41 -22
  12. package/docs/concepts/agents.md +3 -1
  13. package/docs/concepts/collaboration.md +3 -1
  14. package/docs/concepts/exclusions.md +4 -2
  15. package/docs/concepts/footprint.md +3 -1
  16. package/docs/concepts/foreman-mode.md +3 -1
  17. package/docs/concepts/hook-gates.md +61 -0
  18. package/docs/concepts/hooks.md +8 -56
  19. package/docs/concepts/host-matrix.md +38 -0
  20. package/docs/concepts/hosts.md +6 -32
  21. package/docs/concepts/loop-branch.md +66 -0
  22. package/docs/concepts/loop-bricks.md +48 -0
  23. package/docs/concepts/loop-contracts.md +33 -0
  24. package/docs/concepts/loop-gates.md +30 -0
  25. package/docs/concepts/loop-notes.md +43 -0
  26. package/docs/concepts/loop-risk.md +36 -0
  27. package/docs/concepts/loop-templates.md +50 -0
  28. package/docs/concepts/loops.md +24 -116
  29. package/docs/concepts/lsp.md +2 -0
  30. package/docs/concepts/memory.md +3 -1
  31. package/docs/concepts/notebook.md +3 -1
  32. package/docs/concepts/rules.md +4 -2
  33. package/docs/concepts/skills.md +3 -1
  34. package/docs/concepts/themes.md +3 -1
  35. package/docs/extending.md +28 -13
  36. package/docs/guides/autostart.md +3 -1
  37. package/docs/guides/choose-your-setup.md +5 -80
  38. package/docs/guides/headless.md +5 -3
  39. package/docs/guides/install-agent-md.md +37 -0
  40. package/docs/guides/install-bob.md +20 -0
  41. package/docs/guides/install-claude-code.md +22 -0
  42. package/docs/guides/install-clone.md +34 -0
  43. package/docs/guides/install-openclaude.md +25 -0
  44. package/docs/guides/install-opencode.md +35 -0
  45. package/docs/guides/install-scripted.md +21 -0
  46. package/docs/guides/install.md +9 -118
  47. package/docs/guides/loop-finish.md +54 -0
  48. package/docs/guides/loop-running.md +66 -0
  49. package/docs/guides/loop-which-template.md +35 -0
  50. package/docs/guides/mcp-filesystem.md +41 -0
  51. package/docs/guides/mcp-gitlab.md +66 -0
  52. package/docs/guides/mcp-markitdown.md +36 -0
  53. package/docs/guides/mcp.md +8 -137
  54. package/docs/guides/migrate.md +3 -1
  55. package/docs/guides/project-context.md +4 -2
  56. package/docs/guides/read-non-markdown.md +17 -0
  57. package/docs/guides/run-a-loop.md +64 -0
  58. package/docs/guides/run-anywhere.md +2 -0
  59. package/docs/guides/setup-doctrines.md +55 -0
  60. package/docs/guides/setup-footprint.md +48 -0
  61. package/docs/guides/uninstall.md +3 -1
  62. package/docs/guides/upgrade.md +3 -1
  63. package/docs/guides/verify.md +3 -1
  64. package/docs/guides/wiki.md +3 -1
  65. package/docs/guides/worktree.md +3 -1
  66. package/docs/reference/about.md +3 -1
  67. package/docs/reference/cli.md +2 -0
  68. package/docs/reference/env-context.md +40 -0
  69. package/docs/reference/environment.md +10 -59
  70. package/docs/reference/glossary.md +3 -1
  71. package/docs/reference/loop-actions.md +20 -0
  72. package/docs/reference/opencode-plugins.md +14 -118
  73. package/docs/reference/plugin-activity.md +20 -0
  74. package/docs/reference/plugin-context.md +27 -0
  75. package/docs/reference/plugin-guard.md +27 -0
  76. package/docs/reference/plugin-learn.md +27 -0
  77. package/docs/reference/plugin-notify.md +20 -0
  78. package/docs/reference/plugin-ponytail.md +19 -0
  79. package/docs/reference/plugin-workflow.md +25 -0
  80. package/docs/reference/troubleshoot-doctor.md +50 -0
  81. package/docs/reference/troubleshoot-hooks.md +99 -0
  82. package/docs/reference/troubleshoot-install.md +108 -0
  83. package/docs/reference/troubleshoot-tools.md +56 -0
  84. package/docs/reference/troubleshoot-update.md +23 -0
  85. package/docs/reference/troubleshoot-web.md +66 -0
  86. package/docs/reference/troubleshooting.md +13 -337
  87. package/docs/understand/a-day.md +3 -1
  88. package/docs/understand/enforced-vs-asked.md +3 -1
  89. package/docs/understand/harness.md +2 -0
  90. package/docs/understand/machine-bob.md +17 -0
  91. package/docs/understand/machine-claude.md +35 -0
  92. package/docs/understand/machine-opencode.md +36 -0
  93. package/docs/understand/on-your-machine.md +12 -55
  94. package/docs/understand/take-it-out.md +13 -0
  95. package/docs/web-ui.md +35 -15
  96. package/harness.config.json +1 -1
  97. package/js/README.md +30 -13
  98. package/js/build/args.mjs +374 -0
  99. package/js/build/driver.mjs +137 -553
  100. package/js/build/emit-claude.mjs +2 -4
  101. package/js/build/generate.mjs +6 -3
  102. package/js/build/render.mjs +5 -9
  103. package/js/build/source.mjs +3 -11
  104. package/js/cli-table.json +27 -1
  105. package/js/hosts/hooks-context.mjs +431 -0
  106. package/js/hosts/hooks-learn.mjs +275 -0
  107. package/js/hosts/hooks-prims.mjs +108 -0
  108. package/js/hosts/hooks.mjs +50 -977
  109. package/js/hosts/hosts.mjs +12 -4
  110. package/js/hosts/installs.mjs +5 -6
  111. package/js/hosts/mcp.mjs +13 -49
  112. package/js/hosts/memory-files.mjs +181 -0
  113. package/js/hosts/native.mjs +43 -5
  114. package/js/hosts/settings.mjs +135 -414
  115. package/js/hosts/shim.mjs +373 -0
  116. package/js/inspect/checks-authoring.mjs +21 -828
  117. package/js/inspect/checks-build.mjs +32 -4
  118. package/js/inspect/checks-constitution.mjs +493 -0
  119. package/js/inspect/checks-counts.mjs +242 -0
  120. package/js/inspect/checks-repo.mjs +17 -8
  121. package/js/inspect/diff.mjs +4 -1
  122. package/js/inspect/doctor.mjs +2 -2
  123. package/js/inspect/inventory.mjs +4 -4
  124. package/js/inspect/scan.mjs +1 -1
  125. package/js/inspect/status.mjs +1 -1
  126. package/js/inspect/validate.mjs +48 -35
  127. package/js/loop/catalog.mjs +17 -4
  128. package/js/loop/cli.mjs +52 -16
  129. package/js/loop/graph.mjs +19 -1
  130. package/js/loop/registry.mjs +191 -0
  131. package/js/loop/score.mjs +46 -7
  132. package/js/loop/state.mjs +157 -19
  133. package/js/maintain/memory.mjs +1 -1
  134. package/js/maintain/migrate.mjs +18 -30
  135. package/js/maintain/setup.mjs +1 -1
  136. package/js/maintain/uninstall.mjs +47 -88
  137. package/js/ui/cli.mjs +12 -12
  138. package/js/web/actions.mjs +39 -4
  139. package/js/web/api.mjs +34 -21
  140. package/js/web/docs.mjs +87 -78
  141. package/js/web/routes.mjs +5 -1
  142. package/package.json +1 -1
  143. package/src/agents/explorer.md +2 -2
  144. package/src/agents/researcher.md +3 -3
  145. package/src/bricks/README.md +3 -1
  146. package/src/bricks/adr-challenge.md +20 -0
  147. package/src/bricks/adr-draft.md +15 -0
  148. package/src/bricks/characterize.md +17 -0
  149. package/src/bricks/ci-fix.md +2 -1
  150. package/src/bricks/ci-triage.md +20 -0
  151. package/src/bricks/compat-check.md +16 -0
  152. package/src/bricks/done-check.md +16 -0
  153. package/src/bricks/fitness-define.md +22 -0
  154. package/src/bricks/flake-check.md +17 -0
  155. package/src/bricks/flake-repro.md +18 -0
  156. package/src/bricks/identify.md +6 -3
  157. package/src/bricks/migration-plan.md +21 -0
  158. package/src/bricks/mutation-check.md +18 -0
  159. package/src/bricks/plan.md +6 -6
  160. package/src/bricks/red-test.md +16 -0
  161. package/src/bricks/review.md +5 -3
  162. package/src/bricks/spec.md +18 -0
  163. package/src/bricks/upgrade-scout.md +16 -0
  164. package/src/loops/api-endpoint.json +31 -0
  165. package/src/loops/architecture-decision.json +22 -0
  166. package/src/loops/bugfix.json +1 -0
  167. package/src/loops/ci-repair.json +19 -0
  168. package/src/loops/deps-upgrade.json +29 -0
  169. package/src/loops/enforce-architecture-rule.json +28 -0
  170. package/src/loops/feature.json +8 -5
  171. package/src/loops/fix-flaky-tests.json +30 -0
  172. package/src/loops/legacy-refactor.json +26 -0
  173. package/src/loops/legacy-tests.json +29 -0
  174. package/src/loops/refactor.json +5 -1
  175. package/src/loops/remove-dead-code.json +30 -0
  176. package/src/loops/spec-first-feature.json +29 -0
  177. package/src/loops/tdd.json +25 -0
  178. package/src/loops/update-contract.json +33 -0
  179. package/src/skills/brick-forge.md +3 -1
  180. package/src/skills/bruno-collection-generator.md +3 -3
  181. package/src/skills/bruno-test-writer.md +3 -3
  182. package/src/skills/ci-fix.md +3 -3
  183. package/src/skills/consolidate-memory.md +4 -4
  184. package/src/skills/council.md +1 -1
  185. package/src/skills/fresh-eyes.md +1 -1
  186. package/src/skills/git-archaeology.md +1 -1
  187. package/src/skills/git-rescue.md +4 -3
  188. package/src/skills/handoff.md +1 -1
  189. package/src/skills/herdr.md +1 -1
  190. package/src/skills/learn-mode.md +2 -2
  191. package/src/skills/loop.md +22 -10
  192. package/src/skills/opencode-theme.md +3 -3
  193. package/src/skills/ponytail.md +1 -1
  194. package/src/skills/profile.md +3 -4
  195. package/src/skills/prose.md +2 -3
  196. package/src/skills/quiz.md +1 -1
  197. package/src/skills/react-view-transitions/SKILL.md +1 -1
  198. package/src/skills/react-view-transitions/VENDOR.md +8 -3
  199. package/src/skills/research.md +3 -3
  200. package/src/skills/review-response.md +2 -2
  201. package/src/skills/rule.md +3 -3
  202. package/src/skills/security-audit.md +4 -4
  203. package/src/skills/teach.md +1 -1
  204. package/src/skills/wiki.md +3 -3
  205. package/src/skills/workflow.md +4 -4
  206. package/src/skills/worktree.md +2 -2
  207. package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
  208. package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
  209. package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
  210. package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
  211. package/web/dist/assets/{GreenhouseView-C6ibc8IE.js → GreenhouseView-6KDjX8da.js} +1 -1
  212. package/web/dist/assets/{Installs-DRYjYnxs.js → Installs-MWuhNjSm.js} +2 -2
  213. package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
  214. package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
  215. package/web/dist/assets/Library-DOkO0TtB.js +1 -0
  216. package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
  217. package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-X2a3qvOh.js} +14 -14
  218. package/web/dist/assets/{OperatorHudView-Bs7paWmh.js → OperatorHudView-BPP1dtLz.js} +1 -1
  219. package/web/dist/assets/{Personal-BY5n4gTw.js → Personal-26VPYkK0.js} +1 -1
  220. package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
  221. package/web/dist/assets/{usePoll-O0sJEw1Y.js → activity-Dr7ZMBI3.js} +1 -1
  222. package/web/dist/assets/{api-CyMRbU2x.js → api-D_ixdyEY.js} +1 -1
  223. package/web/dist/assets/drawable-zVVDbsyw.js +1 -0
  224. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  225. package/web/dist/assets/index-KHt3wE_K.js +2 -0
  226. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  227. package/web/dist/index.html +3 -3
  228. package/web/src/pages/Laws.jsx +0 -1
  229. package/web/dist/assets/Activity-GaaZWXTY.js +0 -1
  230. package/web/dist/assets/ActivityDetail-4t7d45Du.js +0 -1
  231. package/web/dist/assets/Dashboard-CYn7Tp16.js +0 -2
  232. package/web/dist/assets/Docs-BeFKLg2T.js +0 -5
  233. package/web/dist/assets/JournalView-1HbU1mfg.js +0 -1
  234. package/web/dist/assets/Laws-DFoG8JWB.js +0 -3
  235. package/web/dist/assets/Library-DDjgNwz_.js +0 -1
  236. package/web/dist/assets/Loops-D9VEoO0F.js +0 -1
  237. package/web/dist/assets/Tabs-ho-EYD8i.js +0 -1
  238. package/web/dist/assets/drawable-CLS9drMb.js +0 -1
  239. package/web/dist/assets/index-CX_hXMi5.css +0 -1
  240. package/web/dist/assets/index-Cjr7XCA3.js +0 -2
@@ -228,14 +228,22 @@ export function openclaudeConfigDir() {
228
228
  * block`). It is a column and not a literal in the message for the reason every other column
229
229
  * here is one — Bob answers `AGENTS.md` while Claude answers `CLAUDE.md`, and
230
230
  * a host added later must not need the message edited to stay true.
231
+ *
232
+ * `family` joined in 2026-10: `'claude'` for every host emitted through the Claude-shaped
233
+ * engine — one manifest shape, one `settings.json` hook wiring, one strict-JSON `mcpServers`
234
+ * config, one reversal. The list had been spelled out by hand in ten places, one of which
235
+ * ignored the named constant right above it; a fifth host is now one row here.
231
236
  */
232
237
  export const HOSTS = [
233
- { host: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
234
- { host: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
235
- { host: 'bob', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
236
- { host: 'openclaude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md' },
238
+ { host: 'opencode', family: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
239
+ { host: 'claude', family: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
240
+ { host: 'bob', family: 'claude', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
241
+ { host: 'openclaude', family: 'claude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md' },
237
242
  ];
238
243
 
244
+ /** The Claude-STYLE hosts (`family: 'claude'`), in `HOSTS` order. Test with `.includes(host)`. */
245
+ export const CLAUDE_STYLE = HOSTS.filter((h) => h.family === 'claude').map((h) => h.host);
246
+
239
247
  /**
240
248
  * `_build_global.host_catalogs_natively` — does `host` list every skill and agent to the
241
249
  * model by itself?
@@ -36,10 +36,10 @@ import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
38
  import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
39
- import { GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
39
+ import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
40
40
  import { registryRoots } from '../inspect/registry.mjs';
41
41
  import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
42
- import { formatRepr } from '../lib/json.mjs';
42
+ import { formatRepr, isDict } from '../lib/json.mjs';
43
43
  import { comparePaths } from '../lib/paths.mjs';
44
44
 
45
45
  /**
@@ -394,7 +394,7 @@ export function excludedRulesOfDir(d) {
394
394
  /** `_harness_mcp._claude_read_manifest`. */
395
395
  export function claudeReadManifest(cfgDir) {
396
396
  const data = readJsonMaybe(path.join(cfgDir, GLOBAL_MANIFEST));
397
- return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
397
+ return isDict(data) ? data : {};
398
398
  }
399
399
 
400
400
  /**
@@ -403,8 +403,7 @@ export function claudeReadManifest(cfgDir) {
403
403
  * Claude-style reversal.
404
404
  */
405
405
  export function manifestIsClaude(cfgDir) {
406
- const mg = claudeReadManifest(cfgDir).managed;
407
- return typeof mg === 'object' && mg !== null && !Array.isArray(mg);
406
+ return isDict(claudeReadManifest(cfgDir).managed);
408
407
  }
409
408
 
410
409
  /** `_harness_mcp._EMIT_HOST_SCOPE` — a marker's emit name fixes (host, scope). */
@@ -454,7 +453,7 @@ export function installKind(root) {
454
453
 
455
454
  /** `_harness_mcp._install_state` — 'active' | 'disabled' | 'absent'. */
456
455
  export function installState(root, host = 'opencode', scope = 'global') {
457
- if (['claude', 'bob', 'openclaude'].includes(host)) return claudeState(root, scope, host);
456
+ if (CLAUDE_STYLE.includes(host)) return claudeState(root, scope, host);
458
457
  if (isDir(path.join(root, DISABLED_STASH))) return 'disabled';
459
458
  return installKind(root) !== null ? 'active' : 'absent';
460
459
  }
package/js/hosts/mcp.mjs CHANGED
@@ -11,12 +11,6 @@
11
11
  * `_mcp_install_targets`, `_mcp_known_names` and `_mcp_meta` are 168 lines of Python, all
12
12
  * new. The endpoints on top of them are the small half.
13
13
  *
14
- * AND `settings.mjs`'s ATOMIC WRITE IS NOT THIS ONE. `atomicWriteJson` is
15
- * `_build_settings._atomic_write_json`; `_mcp_save` is a different function with a different
16
- * temp suffix (`.tmp`, not `.geneseed-tmp`) that also creates the parent directory. Reusing
17
- * the first would be right until the moment a write fails, which is the moment the temp
18
- * file's name becomes the only evidence of what happened.
19
- *
20
14
  * THE ROUND TRIP IS THE HAZARD, and it is why every read here goes through `parseJson`.
21
15
  * `_mcp_save` rewrites the WHOLE config, not the servers map — and for a Claude global
22
16
  * install that config is `~/.claude.json`, which holds projects and history far beyond MCP
@@ -28,23 +22,23 @@
28
22
  * the wrapper rather than testing `typeof v === 'object'`. Python's `isinstance(x, dict)` is
29
23
  * false for a float; the JS twin has to be false for its wrapper.
30
24
  */
31
- import { existsSync, mkdirSync, renameSync } from 'node:fs';
25
+ import { existsSync } from 'node:fs';
32
26
  import os from 'node:os';
33
27
  import path from 'node:path';
34
28
 
35
- import { bobConfigDir, resolvePath } from './hosts.mjs';
29
+ import { CLAUDE_STYLE, bobConfigDir, resolvePath } from './hosts.mjs';
36
30
  import { installState, installTargets } from './installs.mjs';
37
- import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
38
- import { isDict, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
31
+ import { printOut, printErr } from '../lib/fs.mjs';
32
+ import { isDict } from '../lib/json.mjs';
39
33
  import { padEndToWidth } from '../lib/text.mjs';
40
- import { opencodeTarget, readJsonc } from './settings.mjs';
34
+ import { atomicWriteJson, loadJsonObject, opencodeTarget } from './settings.mjs';
41
35
 
42
36
 
43
37
  /** `dict.get(key, default)`. */
44
38
  const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[key] : dflt);
45
39
 
46
40
  /** The hosts whose MCP config is strict JSON under `mcpServers`, with no `enabled` flag. */
47
- const FLAGLESS = ['claude', 'bob', 'openclaude'];
41
+ const FLAGLESS = CLAUDE_STYLE;
48
42
 
49
43
  /**
50
44
  * `_harness_mcp._MCP_PRESETS` — the ready-to-wire servers the MCP screen can toggle.
@@ -68,7 +62,7 @@ export const MCP_PRESETS = {
68
62
  + 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
69
63
  + 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
70
64
  + 'copy this block by hand under a new name and point it at the other URL — '
71
- + 'docs/guides/mcp.md has the pattern.',
65
+ + 'docs/guides/mcp-gitlab.md has the pattern.',
72
66
  block: {
73
67
  type: 'local',
74
68
  command: ['npx', '-y', '@zereight/mcp-gitlab'],
@@ -175,49 +169,19 @@ export function mcpSetEnabled(config, name, enabled, host = 'opencode') {
175
169
  * version of this note said otherwise, and `generate.test.mjs` pins the string-aware case.)
176
170
  */
177
171
  export function mcpLoad(p, host = 'opencode') {
178
- if (!existsSync(p)) return {};
179
- let text;
180
- try {
181
- text = readText(p);
182
- } catch {
183
- return {};
184
- }
185
- if (FLAGLESS.includes(host)) {
186
- let data;
187
- try {
188
- data = parseJson(text);
189
- } catch {
190
- return {};
191
- }
192
- return isDict(data) ? data : {};
193
- }
194
- const [data] = readJsonc(text);
195
- return isDict(data) ? data : {};
172
+ const { state, data } = loadJsonObject(p, { strict: FLAGLESS.includes(host) });
173
+ return state === 'ok' ? data : {};
196
174
  }
197
175
 
198
176
  /** `_mcp_commented` — an existing `.jsonc` that carries comments, which must not be rewritten. */
199
177
  export function mcpCommented(p) {
200
- if (path.extname(p) !== '.jsonc' || !existsSync(p)) return false;
201
- try {
202
- const [, had] = readJsonc(readText(p));
203
- return had;
204
- } catch {
205
- return false;
206
- }
178
+ if (path.extname(p) !== '.jsonc') return false;
179
+ return loadJsonObject(p).hadComments;
207
180
  }
208
181
 
209
- /**
210
- * `_mcp_save` — pretty JSON, written ATOMICALLY through a sibling temp file.
211
- *
212
- * Not `atomicWriteJson`: that is `_build_settings._atomic_write_json`, whose temp file is
213
- * named `.geneseed-tmp` and which does not create the parent directory. Two Python
214
- * functions, two twins.
215
- */
182
+ /** `_mcp_save` — pretty JSON, written ATOMICALLY. The one writer, under its MCP name. */
216
183
  export function mcpSave(p, config) {
217
- mkdirSync(path.dirname(p), { recursive: true });
218
- const tmp = `${p}.tmp`;
219
- writeText(tmp, `${jsonDumpsIndent(config)}\n`);
220
- renameSync(tmp, p);
184
+ atomicWriteJson(p, config);
221
185
  }
222
186
 
223
187
  /**
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The memory store's file format — fact files with frontmatter, the `MEMORY.md` index, the
3
+ * per-agent lesson files — read and written without spawning anything.
4
+ *
5
+ * SPLIT FROM `learn` FOR THE CLI's SAKE. `learn` spawns the model CLI, and the web console
6
+ * (`js/web/api.mjs`, `js/web/actions.mjs`), `geneseed memory` (`js/maintain/memory.mjs`) and the
7
+ * loop catalogue (`js/loop/catalog.mjs`) imported the hook module only for `frontmatter` and
8
+ * `memoryDropIndex` — which dragged `node:child_process` into `bin/geneseed-cli.mjs`'s closure
9
+ * and made the hook module a row in `tests/unit/hook_cli.test.mjs`'s spawn allow-list for a
10
+ * spawn the CLI never makes. Everything here is fs-only; `hooks-learn.mjs` is the one spawner.
11
+ */
12
+ import { existsSync, mkdirSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { readText, writeText } from '../lib/fs.mjs';
15
+ import { normcase } from '../lib/paths.mjs';
16
+ import { isFile, listDir, sortPaths, splitLines, splitWords } from './hooks-prims.mjs';
17
+
18
+ const FRONTMATTER_RE = /^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
19
+ const FILE_SEP_RE = /^---FILE---[^\S\n]*$/m;
20
+ // A memory `name:` becomes a FILENAME and the model writes it, so it is a plain slug or
21
+ // nothing — no separator, no `..`, no drive. Looser than `hooks-learn.mjs`'s AGENT_NAME_RE on
22
+ // purpose: a memory slug is a phrase (case and length vary), and a refused one is a fact
23
+ // silently lost.
24
+ const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
25
+ const MAX_AGENT_BULLETS = 100;
26
+
27
+ /** `append_agent_lesson` — one dated bullet, capped at the newest MAX_AGENT_BULLETS. */
28
+ export function appendAgentLesson(memDir, agent, lesson) {
29
+ const d = path.join(memDir, 'agents');
30
+ mkdirSync(d, { recursive: true });
31
+ const f = path.join(d, `${agent}.md`);
32
+ let bullets = [];
33
+ if (existsSync(f)) bullets = splitLines(readText(f)).filter((l) => l.startsWith('- '));
34
+ const now = new Date();
35
+ const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-`
36
+ + `${String(now.getDate()).padStart(2, '0')}`;
37
+ bullets.push(`- ${day}: ${splitWords(lesson).join(' ')}`);
38
+ bullets = bullets.slice(-MAX_AGENT_BULLETS);
39
+ writeText(f, `# ${agent} \u2014 lessons\n${bullets.join('\n')}\n`);
40
+ return f;
41
+ }
42
+
43
+ /**
44
+ * `_frontmatter` — leading YAML-ish frontmatter as a flat map, plus the body.
45
+ *
46
+ * Exported since P6b, whose `memoryItems` reads `name` and `description` off a fact file
47
+ * exactly as `_web_catalog._memory_items` does. That makes the web its SECOND owner, which
48
+ * this port treats as a coverage event rather than a tidiness one — the fields it produces
49
+ * are unreachable from a P6b cell (only the COUNT is consumed) and P6c's catalog cells are
50
+ * what gate them.
51
+ */
52
+ export function frontmatter(md) {
53
+ const m = FRONTMATTER_RE.exec(md);
54
+ if (!m) return [new Map(), md];
55
+ const fm = new Map();
56
+ for (const line of splitLines(m[1])) {
57
+ const at = line.indexOf(':');
58
+ if (at >= 0) {
59
+ // `str.strip('"')` strips the character from BOTH ends, however many there are.
60
+ fm.set(line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"+|"+$/g, ''));
61
+ }
62
+ }
63
+ return [fm, m[2]];
64
+ }
65
+
66
+ const fmGet = (fm, key) => (fm.has(key) ? fm.get(key) : '');
67
+
68
+ /** `Path.stem` for a filename. */
69
+ const stem = (name) => path.basename(name, path.extname(name));
70
+
71
+ /** `mem_dir.glob("*.md")` — case-INSENSITIVE on Windows, as pathlib's glob is. */
72
+ function globMd(dir) {
73
+ return listDir(dir).filter((n) => normcase(n).endsWith('.md')
74
+ && isFile(path.join(dir, n)));
75
+ }
76
+
77
+ /** `_existing_slugs` — slugs already stored, so learn never re-emits a known fact. */
78
+ export function existingSlugs(memDir) {
79
+ const skip = new Set(['memory', 'readme']);
80
+ return new Set(globMd(memDir).map(stem).filter((s) => !skip.has(s.toLowerCase())));
81
+ }
82
+
83
+ /**
84
+ * `_write_memories` — split the model output into files, write each NEW one, and append a
85
+ * pointer line to MEMORY.md. `existing` is MUTATED, so a reply carrying the same slug
86
+ * twice writes it once.
87
+ */
88
+ export function writeMemories(modelOutput, memDir, existing) {
89
+ const written = [];
90
+ const indexLines = [];
91
+ for (let chunk of modelOutput.split(FILE_SEP_RE)) {
92
+ chunk = chunk.trim();
93
+ if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
94
+ const [fm] = frontmatter(chunk);
95
+ const name = fmGet(fm, 'name').trim();
96
+ if (!MEMORY_SLUG_RE.test(name) || existing.has(name)) continue;
97
+ writeText(path.join(memDir, `${name}.md`), `${chunk.replace(/\n+$/, '')}\n`);
98
+ existing.add(name);
99
+ written.push(name);
100
+ const desc = fmGet(fm, 'description').trim();
101
+ indexLines.push(`- [${name}](${name}.md)${desc ? ` \u2014 ${desc}` : ''}`);
102
+ }
103
+ if (indexLines.length) {
104
+ const index = path.join(memDir, 'MEMORY.md');
105
+ const current = existsSync(index) ? `${readText(index).replace(/\n+$/, '')}\n`
106
+ : '# Memory Index\n';
107
+ writeText(index, `${current}${indexLines.join('\n')}\n`);
108
+ }
109
+ return written;
110
+ }
111
+
112
+ /**
113
+ * `_harness_tui_views._memory_drop_index` — remove the index line(s) naming `<name>.md`.
114
+ *
115
+ * ITS PYTHON HOME IS THE TUI, and it is here because this is where its SIBLINGS' twins are:
116
+ * `appendAgentLesson` writes the index lines this deletes and `consolidateMemory` rebuilds
117
+ * them, so the three rules about MEMORY.md's format live together rather than one of them
118
+ * living beside a web endpoint. P7 ports the TUI's memory view onto this same function.
119
+ *
120
+ * The P6f plan asked whether P5's `learn` had already brought it across. It had not —
121
+ * nothing in `js/` referenced it — which is the third time a plan's "already ported" column
122
+ * was answered by reading rather than by trusting.
123
+ *
124
+ * SUBSTRING, NOT PARSE, exactly as the reference is: any line containing `(<name>.md)` goes,
125
+ * so a hand-written index in a different shape still loses its entry. And the file is only
126
+ * REWRITTEN when something actually changed, which keeps a delete of an unindexed fact from
127
+ * touching the user's mtime.
128
+ */
129
+ export function memoryDropIndex(memDir, name) {
130
+ const idx = path.join(memDir, 'MEMORY.md');
131
+ let lines;
132
+ try {
133
+ lines = splitLines(readText(idx));
134
+ } catch {
135
+ return; // the reference's `except OSError: return`
136
+ }
137
+ const keep = lines.filter((ln) => !ln.includes(`(${name}.md)`));
138
+ if (keep.length === lines.length) return;
139
+ try {
140
+ writeText(idx, `${keep.join('\n')}\n`);
141
+ } catch { /* the reference swallows OSError here too */ }
142
+ }
143
+
144
+ /**
145
+ * `consolidate_memory` — rebuild MEMORY.md from the fact files actually on disk: index
146
+ * orphans, prune dead lines, report duplicate descriptions (never auto-merge — nuance is
147
+ * the user's to keep).
148
+ */
149
+ export function consolidateMemory(memDir) {
150
+ const skip = new Set(['memory', 'readme']);
151
+ const facts = new Map();
152
+ for (const name of sortPaths(globMd(memDir))) {
153
+ const s = stem(name);
154
+ if (skip.has(s.toLowerCase())) continue;
155
+ try {
156
+ facts.set(s, fmGet(frontmatter(readText(path.join(memDir, name)))[0], 'description').trim());
157
+ } catch { continue; }
158
+ }
159
+ const index = path.join(memDir, 'MEMORY.md');
160
+ const oldSlugs = new Set();
161
+ if (existsSync(index)) {
162
+ for (const line of splitLines(readText(index))) {
163
+ const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
164
+ if (m) oldSlugs.add(m[1]);
165
+ }
166
+ }
167
+ const lines = ['# Memory Index', ''];
168
+ for (const [slug, desc] of facts) {
169
+ lines.push(`- [${slug}](${slug}.md)${desc ? ` \u2014 ${desc}` : ''}`);
170
+ }
171
+ writeText(index, `${lines.join('\n')}\n`);
172
+ const seen = new Map();
173
+ const dups = [];
174
+ for (const [slug, desc] of facts) {
175
+ if (desc && seen.has(desc)) dups.push([seen.get(desc), slug]);
176
+ if (!seen.has(desc)) seen.set(desc, slug);
177
+ }
178
+ const added = [...facts.keys()].filter((s) => !oldSlugs.has(s)).sort();
179
+ const pruned = [...oldSlugs].filter((s) => !facts.has(s)).sort();
180
+ return { added, pruned, duplicates: dups };
181
+ }
@@ -149,12 +149,50 @@ function triggerOf(text) {
149
149
  * 900-char cap the catalogue weighed 22k chars (~5.6k tokens) across 51 skills, three times
150
150
  * the figure the footprint page quoted. The first sentence says WHEN; the later sentences
151
151
  * disambiguate, and the body — read as soon as the skill is chosen — still carries them.
152
- * Cut at the last sentence end inside the cap, never mid-word.
152
+ * Cut at the last sentence end inside the cap, never mid-word. The `…` fallback (no sentence
153
+ * end inside the cap) is a truncated routing signal: `checkBuild` reports it as a doctor
154
+ * failure, so the cure is a shorter first trigger sentence in the source, never a wider cap.
153
155
  */
154
156
  const DESCRIPTION_CAP = 320;
155
157
  function skillDescription(text) {
156
- const desc = descOf(text);
157
- const trig = triggerOf(text);
158
+ return withWhen(descOf(text), triggerOf(text));
159
+ }
160
+
161
+ /**
162
+ * The `## When to dispatch` section's FIRST bullet, flattened like `triggerOf` — the agent's
163
+ * WHEN. Claude Code delegates on the subagent `description:` alone, and a purpose line
164
+ * ("Writes, runs, and diagnoses tests.") says what an agent does but never when to hand it
165
+ * work; the dispatch list carried that, in a body the router never reads.
166
+ */
167
+ function dispatchOf(text) {
168
+ const lines = text.split('\n');
169
+ const start = lines.findIndex((ln) => /^##\s+When to dispatch\b/.test(ln.trim()));
170
+ if (start < 0) return '';
171
+ const para = [];
172
+ for (let i = start + 1; i < lines.length; i += 1) {
173
+ const s = lines[i].trim();
174
+ if (!para.length) {
175
+ if (s.startsWith('- ')) para.push(s.slice(2));
176
+ else if (s.startsWith('#')) break;
177
+ continue;
178
+ }
179
+ if (s === '' || s.startsWith('- ') || s.startsWith('#')) break;
180
+ para.push(s);
181
+ }
182
+ return para.join(' ')
183
+ .replace(HTML_COMMENT_RE, '')
184
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
185
+ .replace(/\s+/g, ' ')
186
+ .trim();
187
+ }
188
+
189
+ /** The emitted `description:` of an agent — purpose + its first dispatch condition. */
190
+ export function agentDescription(text) {
191
+ return withWhen(descOf(text), dispatchOf(text));
192
+ }
193
+
194
+ /** Purpose + ` Use when: ` + the first sentence of `trig`, capped — see `skillDescription`. */
195
+ function withWhen(desc, trig) {
158
196
  if (!trig) return desc;
159
197
  const first = trig.match(/^.*?[.;](?=\s|$)/);
160
198
  const full = `${desc} Use when: ${first ? first[0] : trig}`;
@@ -302,7 +340,7 @@ const WEBFETCH_MARKER = '<!-- webfetch: allow -->';
302
340
  * Bash keeps the unmarked line byte-identical to what it was before the webfetch marker existed.
303
341
  */
304
342
  function claudeAgentFrontmatter(stem, text, overrides) {
305
- const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
343
+ const fm = [`name: ${stem}`, `description: ${jsonDumps(agentDescription(text))}`];
306
344
  const ov = agentOverride(overrides, stem);
307
345
  if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
308
346
  if (isReadonly(text)) {
@@ -316,7 +354,7 @@ function claudeAgentFrontmatter(stem, text, overrides) {
316
354
 
317
355
  /** `_build_emit._opencode_agent_frontmatter`. */
318
356
  function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
319
- const fm = [`description: ${jsonDumps(descOf(text))}`, 'mode: subagent'];
357
+ const fm = [`description: ${jsonDumps(agentDescription(text))}`, 'mode: subagent'];
320
358
  fm.push(`color: ${agentColor(stem, theme)}`);
321
359
  pushOverrideLines(fm, agentOverride(overrides, stem));
322
360
  if (isReadonly(text)) {