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
@@ -47,31 +47,28 @@ import {
47
47
  } from 'node:fs';
48
48
  import path from 'node:path';
49
49
 
50
- import { hookRunnerEntry } from '../build/driver.mjs';
50
+ import { hookRunnerEntry } from '../hosts/shim.mjs';
51
51
  import { confirm } from './setup.mjs';
52
52
  import {
53
53
  claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
54
  installState,
55
- registeredTargets, readMaybe, DISABLED_STASH,
55
+ registeredTargets, DISABLED_STASH,
56
56
  } from '../hosts/installs.mjs';
57
57
  import {
58
- GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
58
+ CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
59
59
  } from '../hosts/hosts.mjs';
60
- import { mcpCommented } from '../hosts/mcp.mjs';
60
+ import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
61
61
  import {
62
- managedBlockRead, managedBlockRemove, managedBlockWrite,
63
- mergeClaudeSettings, opencodeTarget, readJsonc,
62
+ atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
63
+ loadJsonObject, mergeClaudeSettings, opencodeTarget,
64
64
  settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes, unwireClaudeSettings,
65
65
  } from '../hosts/settings.mjs';
66
66
  import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
67
- import { indexOfDeepEqual, jsonDumps, jsonDumpsIndent, deepEquals } from '../lib/json.mjs';
67
+ import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
68
68
  import { comparePaths, isAbsolutePath, within } from '../lib/paths.mjs';
69
69
 
70
70
  const hostSpec = (host) => HOSTS.find((h) => h.host === host);
71
71
 
72
- /** The Claude-STYLE hosts — one manifest shape, one reversal. Spelled once. */
73
- const CLAUDE_STYLE = ['claude', 'bob', 'openclaude'];
74
-
75
72
  /**
76
73
  * `shutil.rmtree(p, ignore_errors=True)`.
77
74
  *
@@ -154,11 +151,15 @@ function pruneAncestors(start, stop) {
154
151
  * failure strings) differ in the root and in the label, which are the two arguments. The
155
152
  * `victim.is_file()` guard is load-bearing rather than defensive: a manifest entry naming a
156
153
  * directory is SKIPPED, not recursed into, and `removed` counts only what actually went.
154
+ *
155
+ * `ownedWithin` first, the same containment boundary deactivate draws: the manifest is a file
156
+ * on disk, and without it an `owned` entry of `../../.bashrc` was unlinked — and the prune
157
+ * then climbed from ITS parent, which never meets `base`, removing empty directories as it went.
157
158
  */
158
159
  function unlinkOwned(base, owned, label = '') {
159
160
  let removed = 0;
160
161
  const failed = [];
161
- for (const rel of owned) {
162
+ for (const rel of ownedWithin(base, owned)) {
162
163
  const victim = path.join(base, rel);
163
164
  try {
164
165
  if (isFile(victim)) {
@@ -177,6 +178,23 @@ function unlinkOwned(base, owned, label = '') {
177
178
  return [removed, failed];
178
179
  }
179
180
 
181
+ /**
182
+ * The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
183
+ * `owned` goes through (uninstall, both deactivates).
184
+ *
185
+ * `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
186
+ * boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
187
+ * `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
188
+ * `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
189
+ * concatenated it into `<root>/C:/evil.md`, which passes containment and would then be acted
190
+ * on. `path.resolve` has pathlib's semantics for exactly this case, and collapses `..`
191
+ * besides, which is why the guard runs on the resolved path rather than the lexical one.
192
+ */
193
+ function ownedWithin(base, rels) {
194
+ const rroot = resolvePath(base);
195
+ return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
196
+ }
197
+
180
198
  /** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
181
199
  function warnSurvivors(failed) {
182
200
  printErr('[uninstall] WARN: could not remove '
@@ -203,13 +221,9 @@ const REVERSAL_MARKERS = [GLOBAL_MANIFEST, '.geneseed-theme', '.geneseed-emit',
203
221
  */
204
222
  export function unmergeOpencodeJson(p, entry) {
205
223
  const target = opencodeTarget(p);
206
- if (!existsSync(target)) return false;
207
- let cfg;
208
- let hadComments;
209
- const text = readMaybe(target);
210
- if (text === null) return false; // the Python's `except OSError: return False`
211
- [cfg, hadComments] = readJsonc(text);
212
- if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) return false;
224
+ // Absent, unreadable (the Python's `except OSError: return False`) or not an object: decline.
225
+ const { state, data: cfg, hadComments } = loadJsonObject(target);
226
+ if (state !== 'ok') return false;
213
227
  const instr = cfg.instructions;
214
228
  if (!Array.isArray(instr) || !instr.includes(entry)) return false;
215
229
  if (path.extname(target) === '.jsonc' && hadComments) {
@@ -218,7 +232,7 @@ export function unmergeOpencodeJson(p, entry) {
218
232
  return false;
219
233
  }
220
234
  cfg.instructions = instr.filter((i) => i !== entry);
221
- writeText(target, `${jsonDumpsIndent(cfg)}\n`);
235
+ atomicWriteJson(target, cfg);
222
236
  return true;
223
237
  }
224
238
 
@@ -269,8 +283,7 @@ function claudeMdPath(cfg, managed) {
269
283
 
270
284
  /** A manifest's `managed` map, or `{}` — the Python's `isinstance(..., dict)` guard. */
271
285
  function managedOf(man) {
272
- const mg = man.managed;
273
- return typeof mg === 'object' && mg !== null && !Array.isArray(mg) ? mg : {};
286
+ return isDict(man.managed) ? man.managed : {};
274
287
  }
275
288
 
276
289
  /** A manifest's `owned` list, or `[]`. */
@@ -339,22 +352,6 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
339
352
  return { removed, unmerged, archived, failed };
340
353
  }
341
354
 
342
- /**
343
- * `_harness_mcp._mcp_load`, reduced to the OpenCode arm.
344
- *
345
- * The Claude arm (`.mcp.json`, `~/.claude.json`, parsed STRICTLY — see `mcpLoad` in
346
- * js/hosts/mcp.mjs for why) lives with the MCP catalog. `installAgentEntry` is this port's only caller and passes
347
- * no host, so porting the branch would ship an unreachable arm with no cell and no partition
348
- * to be part of — the criterion this port decides keep-vs-delete by.
349
- */
350
- function mcpLoadOpencode(p) {
351
- if (!existsSync(p)) return {};
352
- const text = readMaybe(p);
353
- if (text === null) return {};
354
- const [data] = readJsonc(text);
355
- return typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {};
356
- }
357
-
358
355
  /**
359
356
  * `_harness_mcp._install_agent_entry` — the `instructions` entry to drop.
360
357
  *
@@ -367,7 +364,7 @@ export function installAgentEntry(root, kind) {
367
364
  if (kind === 'global') {
368
365
  return path.join(root, 'AGENT.md').split(path.sep).join('/');
369
366
  }
370
- const cfg = mcpLoadOpencode(opencodeTarget(path.join(root, 'opencode.json')));
367
+ const cfg = mcpLoad(opencodeTarget(path.join(root, 'opencode.json')));
371
368
  return installAgentEntryOf(cfg.instructions);
372
369
  }
373
370
 
@@ -645,22 +642,6 @@ function printOtherHostHits(root, removedHost) {
645
642
  }
646
643
  }
647
644
 
648
- /**
649
- * `_harness_setup._confirm`, and the only interactive read this verb makes.
650
- *
651
- * MOVED TO `js/maintain/setup.mjs` IN P5i, where the Python's own owner is: `_confirm` lives in
652
- * `_harness_setup` beside the `_ask` it is built on, and `setup` is its second caller. The
653
- * body stayed here for one phase because a helper with one caller is not shared yet; the
654
- * rule this port uses is that the SECOND owner is what moves it, and a byte gate is what
655
- * licenses the move.
656
- *
657
- * UNREACHABLE FROM EVERY CELL, restated because moving it does not change that: `cmdUninstall`
658
- * only reaches it when stdin is a TTY, and the harness gives every cell a pipe. What the
659
- * cells DO gate is the branch beside it — the non-interactive refusal — which is why that one
660
- * has an `expect` naming its wording. It is now gated positively too, by the stdin-seeded
661
- * corpus P5i added for the wizard.
662
- */
663
-
664
645
  /**
665
646
  * `_harness_mcp.cmd_uninstall`.
666
647
  *
@@ -690,7 +671,7 @@ export function cmdUninstall(args) {
690
671
  const data = installDataDir(root, host, scope);
691
672
  const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
692
673
  printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
693
- if (['claude', 'bob', 'openclaude'].includes(host)) {
674
+ if (CLAUDE_STYLE.includes(host)) {
694
675
  printOut('[uninstall] removes: agents/, skills/, markers, the '
695
676
  + `${hostSpec(host).agentFile} managed block, and Geneseed's `
696
677
  + 'settings.json hooks/excludes (your own keys/hooks are kept).\n');
@@ -810,17 +791,7 @@ function installRelive(root) {
810
791
  return isFile(path.join(root, 'AGENT.md'));
811
792
  }
812
793
 
813
- /**
814
- * `_harness_mcp._install_move_list` — the rels to move aside, `_within`-guarded.
815
- *
816
- * `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
817
- * boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
818
- * `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
819
- * `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
820
- * concatenated it into `<root>/C:/evil.md`, which passes containment and would then be MOVED.
821
- * `path.resolve` has pathlib's semantics for exactly this case, and collapses `..` besides,
822
- * which is why the guard runs on the resolved path rather than the lexical one.
823
- */
794
+ /** `_harness_mcp._install_move_list` — the rels to move aside, `ownedWithin`-guarded. */
824
795
  function installMoveList(root, kind) {
825
796
  let rels;
826
797
  if (kind === 'project') {
@@ -828,8 +799,7 @@ function installMoveList(root, kind) {
828
799
  } else {
829
800
  rels = ownedOf(claudeReadManifest(root)).filter((r) => r !== VERSION_MARKER);
830
801
  }
831
- const rroot = resolvePath(root);
832
- return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
802
+ return ownedWithin(root, rels);
833
803
  }
834
804
 
835
805
  /**
@@ -844,16 +814,11 @@ function installMoveList(root, kind) {
844
814
  */
845
815
  function installReaddEntry(target, entry) {
846
816
  if (!existsSync(target)) {
847
- mkdirSync(path.dirname(target), { recursive: true });
848
- writeText(target, `${jsonDumpsIndent({
849
- $schema: 'https://opencode.ai/config.json', instructions: [entry],
850
- })}\n`);
817
+ atomicWriteJson(target, { $schema: 'https://opencode.ai/config.json', instructions: [entry] });
851
818
  return true;
852
819
  }
853
- let raw;
854
- try { raw = readText(target); } catch { return false; } // except OSError
855
- const [cfg, hadComments] = readJsonc(raw);
856
- if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) return false;
820
+ const { state, data: cfg, hadComments } = loadJsonObject(target);
821
+ if (state !== 'ok') return false; // unreadable (except OSError), or not an object
857
822
  const instr = Array.isArray(cfg.instructions) ? cfg.instructions : [];
858
823
  if (indexOfDeepEqual(instr, entry) >= 0) return false;
859
824
  if (path.extname(target) === '.jsonc' && hadComments) {
@@ -862,7 +827,7 @@ function installReaddEntry(target, entry) {
862
827
  return false;
863
828
  }
864
829
  cfg.instructions = [...instr, entry];
865
- writeText(target, `${jsonDumpsIndent(cfg)}\n`);
830
+ atomicWriteJson(target, cfg);
866
831
  return true;
867
832
  }
868
833
 
@@ -1067,16 +1032,14 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1067
1032
  const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1068
1033
  : excludedRulesOfDir(cfg);
1069
1034
  // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1070
- const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1035
+ const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1071
1036
  managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg);
1072
1037
  // `and data` — a manifest that did not parse is not one to write back.
1073
1038
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1074
1039
  managed.settings_hooks = claims;
1075
1040
  data.managed = managed;
1076
- const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
1077
1041
  try {
1078
- writeText(tmp, `${jsonDumpsIndent(data)}\n`);
1079
- renameSync(tmp, path.join(cfg, GLOBAL_MANIFEST));
1042
+ atomicWriteJson(path.join(cfg, GLOBAL_MANIFEST), data);
1080
1043
  } catch { /* except OSError: pass */ }
1081
1044
  }
1082
1045
  }
@@ -1093,9 +1056,7 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
1093
1056
  const man = claudeReadManifest(cfg);
1094
1057
  const managed = managedOf(man);
1095
1058
  const stash = path.join(cfg, DISABLED_STASH, host);
1096
- const rroot = resolvePath(cfg);
1097
- const rels = ownedOf(man).filter((r) => r && r !== VERSION_MARKER
1098
- && within(resolvePath(path.resolve(rroot, r)), rroot));
1059
+ const rels = ownedWithin(cfg, ownedOf(man).filter((r) => r !== VERSION_MARKER));
1099
1060
  const { done, failed } = moveAll(cfg, stash, rels);
1100
1061
  if (failed) {
1101
1062
  cleanHostStash(cfg, host);
@@ -1129,9 +1090,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1129
1090
  const stash = path.join(cfg, DISABLED_STASH, host);
1130
1091
  const blockFile = path.join(stash, '_claude_md_block.txt');
1131
1092
  const reliveManifest = claudeReadManifest(cfg);
1132
- // `.get("managed") or {}` — an `or`, not an isinstance guard, and the difference is
1133
- // observable on a manifest whose `managed` is `null`.
1134
- const reliveManaged = reliveManifest.managed || {};
1093
+ const reliveManaged = managedOf(reliveManifest);
1135
1094
  let relive = managedBlockRead(claudeMdPath(cfg, reliveManaged)) !== null;
1136
1095
  // Bob GLOBAL writes no managed AGENTS.md block — its preamble carrier is the owned
1137
1096
  // `rules/geneseed.md`, so a live copy of THAT is the relive signal there. Without this arm
@@ -1149,7 +1108,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1149
1108
  }
1150
1109
  const { leftovers, moved } = restoreAll(stash, cfg, '_claude_md_block.txt');
1151
1110
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
1152
- const managed = claudeReadManifest(cfg).managed || {};
1111
+ const managed = managedOf(claudeReadManifest(cfg));
1153
1112
  // ⚠ THE CARRIER IS RESTORED BEFORE THE HOOKS ARE RE-MERGED, AND THE ORDER IS LOAD-BEARING.
1154
1113
  // `remergeClaudeHooks` reads the install's pack selection back off the carrier, and on a
1155
1114
  // Claude-style host that selection lives inside the MANAGED BLOCK — which the deactivate
package/js/ui/cli.mjs CHANGED
@@ -429,18 +429,6 @@ export function cliCommand(verb) {
429
429
  return (load().commands ?? []).find((c) => c.name === verb) ?? null;
430
430
  }
431
431
 
432
- /**
433
- * `<verb> --help` for whichever binary asked, or `null` for a verb the table does not
434
- * describe — the caller already owns that refusal and its message.
435
- *
436
- * ONE OWNER FOR TWO ENTRIES. `bin/geneseed-hook.mjs` carries the four hook verbs and
437
- * `bin/geneseed-cli.mjs` the rest of the table, and
438
- * `test_the_two_entry_points_carry_disjoint_verb_sets` keeps them from learning each other's
439
- * tables — so the help each prints has to come from here rather than from a copy in each.
440
- *
441
- * CRLF ON WINDOWS for `die`'s reason: argparse writes through a Python text stream, which
442
- * translates, and this text lands in the same terminals and the same pipes as that one.
443
- */
444
432
  /**
445
433
  * `geneseed --help` — every verb with its one-line help, from the same table the per-verb help
446
434
  * reads. `verbs` is the entry's own dispatch list, so the listing can name nothing the entry
@@ -460,6 +448,18 @@ export function printVerbList(prog, verbs, extra = {}) {
460
448
  return 0;
461
449
  }
462
450
 
451
+ /**
452
+ * `<verb> --help` for whichever binary asked, or `null` for a verb the table does not
453
+ * describe — the caller already owns that refusal and its message.
454
+ *
455
+ * ONE OWNER FOR TWO ENTRIES. `bin/geneseed-hook.mjs` carries the four hook verbs and
456
+ * `bin/geneseed-cli.mjs` the rest of the table, and
457
+ * `test_the_two_entry_points_carry_disjoint_verb_sets` keeps them from learning each other's
458
+ * tables — so the help each prints has to come from here rather than from a copy in each.
459
+ *
460
+ * CRLF ON WINDOWS for `die`'s reason: argparse writes through a Python text stream, which
461
+ * translates, and this text lands in the same terminals and the same pipes as that one.
462
+ */
463
463
  export function printHelp(prog, verb) {
464
464
  const cmd = cliCommand(verb);
465
465
  if (cmd === null) return null;
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { spawn } from 'node:child_process';
29
29
  import {
30
- accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync, unlinkSync,
30
+ accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, unlinkSync,
31
31
  } from 'node:fs';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
@@ -40,9 +40,10 @@ import { withStdoutSwallowed } from '../inspect/diff.mjs';
40
40
  import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
41
41
  import { setupBuildArgs } from '../build/generate.mjs';
42
42
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
43
- import { frontmatter, memoryDropIndex } from '../hosts/hooks.mjs';
43
+ import { activeLoops, setLoopPreset } from '../loop/registry.mjs';
44
+ import { frontmatter, memoryDropIndex } from '../hosts/memory-files.mjs';
44
45
  import {
45
- HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
46
+ CLAUDE_STYLE, HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
46
47
  opencodeConfigDir, resolvePath,
47
48
  } from '../hosts/hosts.mjs';
48
49
  import {
@@ -56,6 +57,7 @@ import {
56
57
  import { readText, writeText, isFile, isDir } from '../lib/fs.mjs';
57
58
  import { parseJson, formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
58
59
  import { NO_WINDOW } from '../lib/proc.mjs';
60
+ import { normcase } from '../lib/paths.mjs';
59
61
  import { WHITESPACE, codePointLength, stripWhitespace } from '../lib/text.mjs';
60
62
  import { installDeactivate, installReactivate, installUninstall } from '../maintain/uninstall.mjs';
61
63
  import { splitLines } from '../lib/udiff.mjs';
@@ -577,6 +579,36 @@ export function apiExcludesMutate(state, body) {
577
579
  return action === 'add' ? excludeAdd(p) : excludeRemove(p);
578
580
  }
579
581
 
582
+ // ---- loop preset picker ---------------------------------------------------------------------
583
+
584
+ /**
585
+ * The Active tab's one write: rewrite a registered worktree's `LOOP.md` `preset` field.
586
+ * `POST /api/loops/preset` makes two refusals — a `preset` outside `PRESETS` and a `root` that is
587
+ * not a running registered loop (no arbitrary path writes) — and both are decided HERE, as the
588
+ * `NotFound` every other mutating route answers in, so the handler's catch (`4xx`, not the 500 a
589
+ * bare `Error` would fall to) covers this route the way it covers `apiRulesMutate`'s unknown id.
590
+ *
591
+ * DECIDED, NOT RECOGNISED. This route used to call `setLoopPreset` and sort its throws by their
592
+ * MESSAGE text, so rewording an error in `js/loop/registry.mjs` would have turned a 404 into a
593
+ * 500 with no test of this file noticing. "Running" is `activeLoops`' word for it — registered,
594
+ * still a directory, `LOOP.md` present (a corrupt one included, so that it reaches the parse and
595
+ * fails there) — matched on the root the registry stores, which is the realpath. The preset is
596
+ * checked first: `activeLoops` may prune the registry, and a refused preset must touch nothing
597
+ * (`tests/unit/web_server.test.mjs` probes this route against the real machine's registry).
598
+ * Anything `setLoopPreset` still throws, a corrupt `LOOP.md` above all, is a 500.
599
+ */
600
+ export function apiLoopsPresetMutate(state, body) {
601
+ const root = strOr(bget(body, 'root'));
602
+ const preset = strOr(bget(body, 'preset'));
603
+ if (!Object.hasOwn(PRESETS, preset)) throw new NotFound(`unknown preset ${JSON.stringify(preset)}`);
604
+ let key;
605
+ try { key = realpathSync.native(root); } catch { key = path.resolve(root); }
606
+ const running = root && activeLoops().some((l) => l.status !== 'finished'
607
+ && normcase(l.root) === normcase(key));
608
+ if (!running) throw new NotFound('unknown loop');
609
+ return { ok: true, loop: setLoopPreset(root, preset) };
610
+ }
611
+
580
612
  // ---- MCP ----------------------------------------------------------------------------------
581
613
 
582
614
  export function apiMcp() {
@@ -645,7 +677,7 @@ export function apiMcpToggle(state, body) {
645
677
  return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
646
678
  }
647
679
  let cfg;
648
- if (['claude', 'bob', 'openclaude'].includes(host)) {
680
+ if (CLAUDE_STYLE.includes(host)) {
649
681
  if (isFile(p)) {
650
682
  let parsed;
651
683
  try {
@@ -786,6 +818,9 @@ export function apiRestore(state, files) {
786
818
  // behind it — prompt and boundary disagreeing by a WRITE, the write-side twin of the
787
819
  // read-side hole `diffCollect` had (and the reason the panel offered the file at all).
788
820
  doctrines: doctrinesForBuild(target),
821
+ // The excluded rules for the same boundary reason: without them a restored AGENT.md
822
+ // re-states a rule whose hook gate the install deliberately left unwired.
823
+ excludeRules: excludedRulesOfDir(target),
789
824
  }));
790
825
  for (const raw of (isTruthy(files) ? files : [])) {
791
826
  const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
package/js/web/api.mjs CHANGED
@@ -18,15 +18,16 @@ import { sourceReleaseVersion } from '../hosts/opencode.mjs';
18
18
  import { diffCollect } from '../inspect/diff.mjs';
19
19
  import { doctorCollect } from '../inspect/doctor.mjs';
20
20
  import { excludesSnapshot } from '../inspect/excludes.mjs';
21
- import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
21
+ import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
22
22
  import {
23
23
  doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState, installTargets,
24
24
  installedDefaults, modeOfDir, trustOfDir,
25
25
  postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
26
26
  } from '../hosts/installs.mjs';
27
- import { frontmatter } from '../hosts/hooks.mjs';
27
+ import { frontmatter } from '../hosts/memory-files.mjs';
28
28
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
29
29
  import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
30
+ import { activeLoops } from '../loop/registry.mjs';
30
31
  import {
31
32
  SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
32
33
  } from '../inspect/inventory.mjs';
@@ -98,10 +99,15 @@ export function webState(theme = null, target = null) {
98
99
  st.root = st.target;
99
100
  st.theme = theme || themeOfDir(st.target) || 'neutral';
100
101
  st.emit = installedDefaults().emit || 'opencode-global';
101
- st.footprint = footprintOfDir(st.target); // 'full' when no marker
102
- st.posture = postureOfDir(st.target) || 'peer';
103
- st.mode = modeOfDir(st.target) || 'direct';
104
- st.trust = trustOfDir(st.target) || DEFAULT_PRESET;
102
+ // The install's sigils, each defaulted when its marker is absent (footprint to 'full').
103
+ // Read from the ROOT, where every emit writes them.
104
+ const readMarkers = () => {
105
+ st.footprint = footprintOfDir(st.root);
106
+ st.posture = postureOfDir(st.root) || 'peer';
107
+ st.mode = modeOfDir(st.root) || 'direct';
108
+ st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
109
+ };
110
+ readMarkers();
105
111
 
106
112
  Object.defineProperty(st, 'inventory', {
107
113
  get() {
@@ -155,10 +161,7 @@ export function webState(theme = null, target = null) {
155
161
  st.root = root || target;
156
162
  st.theme = themeOfDir(st.root) || themeOfDir(st.target) || 'neutral';
157
163
  st.emit = st.detectEmit();
158
- st.footprint = footprintOfDir(st.root);
159
- st.posture = postureOfDir(st.root) || 'peer';
160
- st.mode = modeOfDir(st.root) || 'direct';
161
- st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
164
+ readMarkers();
162
165
  st._inv = null;
163
166
  st._doctor = null;
164
167
  };
@@ -167,10 +170,7 @@ export function webState(theme = null, target = null) {
167
170
  st._doctor = null;
168
171
  st.theme = themeOfDir(st.root) || themeOfDir(st.target) || st.theme;
169
172
  st.emit = st.detectEmit() || st.emit;
170
- st.footprint = footprintOfDir(st.root);
171
- st.posture = postureOfDir(st.root) || 'peer';
172
- st.mode = modeOfDir(st.root) || 'direct';
173
- st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
173
+ readMarkers();
174
174
  };
175
175
  return st;
176
176
  }
@@ -731,7 +731,7 @@ export const apiExcludes = () => excludesSnapshot();
731
731
  * nested-marker host cannot silently read the bare root.
732
732
  */
733
733
  export function viewCfg(host, scope, root) {
734
- if (scope === 'project' && ['claude', 'bob', 'openclaude'].includes(host)) {
734
+ if (scope === 'project' && CLAUDE_STYLE.includes(host)) {
735
735
  return path.join(root, HOSTS.find((h) => h.host === host).projectMarker);
736
736
  }
737
737
  return root;
@@ -799,13 +799,11 @@ export function apiOverview(state) {
799
799
  buildEpoch = Math.floor(ms / 1000);
800
800
  }
801
801
  // Which detected install the current view points at, so the dashboard's footprint hero
802
- // can re-emit exactly it. Mirrors `viewCfg`'s rule, spelled out separately here.
802
+ // can re-emit exactly it.
803
803
  let install = null;
804
804
  for (const [host, scope, root] of installTargets()) {
805
- const data = (scope === 'project' && ['claude', 'bob', 'openclaude'].includes(host))
806
- ? path.join(root, HOSTS.find((h) => h.host === host).projectMarker) : root;
807
805
  try {
808
- if (samePath(data, state.target)) {
806
+ if (samePath(viewCfg(host, scope, root), state.target)) {
809
807
  install = { host, scope, path: root, footprint: footprintOfDir(root) };
810
808
  break;
811
809
  }
@@ -953,7 +951,9 @@ export function apiRecent(state) {
953
951
  * layout needs `nodes`, `edges` and `loops` exactly as the engine reads them.
954
952
  */
955
953
  export function apiLoops(state) {
956
- const { bricks, templates, overridden } = loadCatalog({ projectRoot: state.root });
954
+ const opts = { projectRoot: state.root };
955
+ const catalog = loadCatalog(opts);
956
+ const { bricks, templates, overridden } = catalog;
957
957
  const byName = ([a], [b]) => (a < b ? -1 : 1);
958
958
  return {
959
959
  templates: [...templates].sort(byName).map(([name, { origin, ...graph }]) => ({
@@ -964,10 +964,22 @@ export function apiLoops(state) {
964
964
  // A brick or template the catalogue skipped (bad frontmatter, not JSON, misnamed), and a
965
965
  // template that fails the graph rules: the page says so — the same list `loop check`
966
966
  // prints — or a team's broken override would just silently not be there.
967
- problems: catalogProblems({ projectRoot: state.root }),
967
+ // The catalogue already loaded above, not a second read of every brick and template.
968
+ problems: catalogProblems(opts, catalog),
968
969
  };
969
970
  }
970
971
 
972
+ /**
973
+ * The Active tab's read: every worktree `geneseed loop init` has registered, with its live
974
+ * `LOOP.md` state re-read on every call (`activeLoops` itself, not a cached copy — the registry
975
+ * stores identity only, see `js/loop/registry.mjs`). Takes no `state`-derived filtering: the
976
+ * registry is machine-wide, not scoped to this install's root, so a loop launched from a
977
+ * different project still shows up here.
978
+ */
979
+ export function apiLoopsActive() {
980
+ return { loops: activeLoops() };
981
+ }
982
+
971
983
  /** The source's release label, or null when it cannot be read. */
972
984
  function releaseLabel() {
973
985
  const v = sourceReleaseVersion({ config: CONFIG });
@@ -1018,4 +1030,5 @@ export const STATE_ROUTES = {
1018
1030
  '/api/rules': apiRules,
1019
1031
  '/api/mcp': apiMcp,
1020
1032
  '/api/loops': apiLoops,
1033
+ '/api/loops/active': apiLoopsActive,
1021
1034
  };