geneseed 3.10.0 → 3.12.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 (227) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/DESIGN.md +1 -1
  3. package/README.md +27 -45
  4. package/SHIPPED.md +2 -2
  5. package/adapters/bob/README.md +1 -1
  6. package/adapters/claude-code/README.md +17 -12
  7. package/adapters/openclaude/README.md +1 -1
  8. package/adapters/opencode/README.md +4 -4
  9. package/adapters/opencode/plugins/geneseed-guard.js +157 -2
  10. package/bin/build-driver.mjs +2 -0
  11. package/bin/geneseed-cli.mjs +21 -0
  12. package/bin/geneseed-hook.mjs +2 -0
  13. package/docs/README.md +42 -0
  14. package/docs/_groups.json +18 -0
  15. package/docs/concepts/agents.md +44 -0
  16. package/docs/concepts/collaboration.md +62 -0
  17. package/docs/concepts/exclusions.md +55 -0
  18. package/docs/concepts/footprint.md +52 -0
  19. package/docs/concepts/foreman-mode.md +69 -0
  20. package/docs/concepts/hooks.md +101 -0
  21. package/docs/concepts/hosts.md +55 -0
  22. package/docs/concepts/loops.md +165 -0
  23. package/docs/concepts/lsp.md +50 -0
  24. package/docs/{web → concepts}/memory.md +2 -2
  25. package/docs/{web → concepts}/notebook.md +2 -2
  26. package/docs/concepts/rules.md +52 -0
  27. package/docs/concepts/skills.md +49 -0
  28. package/docs/concepts/themes.md +50 -0
  29. package/docs/extending.md +32 -21
  30. package/docs/guides/autostart.md +70 -0
  31. package/docs/guides/choose-your-setup.md +134 -0
  32. package/docs/guides/headless.md +22 -0
  33. package/docs/guides/install.md +161 -0
  34. package/docs/guides/mcp.md +195 -0
  35. package/docs/guides/migrate.md +41 -0
  36. package/docs/guides/project-context.md +87 -0
  37. package/docs/guides/run-anywhere.md +45 -0
  38. package/docs/guides/uninstall.md +51 -0
  39. package/docs/guides/upgrade.md +65 -0
  40. package/docs/guides/verify.md +65 -0
  41. package/docs/guides/wiki.md +69 -0
  42. package/docs/guides/worktree.md +47 -0
  43. package/docs/reference/about.md +7 -0
  44. package/docs/reference/cli.md +7 -0
  45. package/docs/reference/environment.md +99 -0
  46. package/docs/reference/glossary.md +37 -0
  47. package/docs/reference/opencode-plugins.md +133 -0
  48. package/docs/reference/troubleshooting.md +350 -0
  49. package/docs/understand/a-day.md +77 -0
  50. package/docs/understand/enforced-vs-asked.md +64 -0
  51. package/docs/understand/harness.md +36 -0
  52. package/docs/understand/on-your-machine.md +76 -0
  53. package/docs/web-ui.md +13 -2
  54. package/harness.config.json +1 -1
  55. package/js/README.md +27 -4
  56. package/js/build/driver.mjs +14 -8
  57. package/js/build/generate.mjs +22 -7
  58. package/js/build/render.mjs +5 -0
  59. package/js/build/source.mjs +11 -1
  60. package/js/cli-table.json +236 -1
  61. package/js/hosts/gitref.mjs +99 -0
  62. package/js/hosts/hooks.mjs +88 -1
  63. package/js/hosts/installs.mjs +21 -0
  64. package/js/hosts/mcp.mjs +1 -1
  65. package/js/hosts/settings.mjs +13 -1
  66. package/js/inspect/checks-authoring.mjs +15 -3
  67. package/js/inspect/diff.mjs +3 -0
  68. package/js/inspect/doctor.mjs +3 -2
  69. package/js/inspect/inventory.mjs +2 -1
  70. package/js/inspect/registry.mjs +20 -3
  71. package/js/inspect/status.mjs +8 -3
  72. package/js/lib/node-floor.mjs +30 -0
  73. package/js/loop/catalog.mjs +116 -0
  74. package/js/loop/cli.mjs +151 -0
  75. package/js/loop/graph.mjs +145 -0
  76. package/js/loop/score.mjs +143 -0
  77. package/js/loop/state.mjs +322 -0
  78. package/js/maintain/migrate.mjs +4 -2
  79. package/js/maintain/setup.mjs +109 -21
  80. package/js/maintain/update.mjs +4 -15
  81. package/js/ui/cli.mjs +2 -2
  82. package/js/web/actions.mjs +9 -3
  83. package/js/web/api.mjs +41 -2
  84. package/js/web/daemon.mjs +94 -4
  85. package/js/web/docs.mjs +149 -65
  86. package/js/web/handler.mjs +1 -0
  87. package/js/web/jobs.mjs +3 -1
  88. package/js/web/server.mjs +10 -5
  89. package/package.json +6 -4
  90. package/registry.json +21 -0
  91. package/src/AGENT.md.tmpl +6 -0
  92. package/src/bricks/README.md +32 -0
  93. package/src/bricks/_template.md +32 -0
  94. package/src/bricks/apply.md +17 -0
  95. package/src/bricks/baseline-green.md +17 -0
  96. package/src/bricks/bruno-test.md +16 -0
  97. package/src/bricks/ci-fix.md +17 -0
  98. package/src/bricks/deps-audit.md +14 -0
  99. package/src/bricks/docs-update.md +15 -0
  100. package/src/bricks/identify.md +20 -0
  101. package/src/bricks/lint.md +14 -0
  102. package/src/bricks/plan.md +17 -0
  103. package/src/bricks/reproduce.md +16 -0
  104. package/src/bricks/review.md +17 -0
  105. package/src/bricks/security-scan.md +17 -0
  106. package/src/bricks/test.md +14 -0
  107. package/src/doctrines/process.md +11 -8
  108. package/src/loops/bugfix.json +22 -0
  109. package/src/loops/feature.json +21 -0
  110. package/src/loops/refactor.json +20 -0
  111. package/src/postures/mentor.md +1 -0
  112. package/src/skills/brick-forge.md +46 -0
  113. package/src/skills/geneseed.md +1 -1
  114. package/src/skills/learn-mode.md +89 -0
  115. package/src/skills/loop.md +99 -0
  116. package/src/skills/pipeline.md +2 -2
  117. package/src/skills/quiz.md +2 -1
  118. package/src/skills/teach.md +2 -1
  119. package/themes/_TEMPLATE.json +3 -0
  120. package/themes/biker.json +3 -0
  121. package/themes/commentator.json +3 -0
  122. package/themes/cyberpunk.json +3 -0
  123. package/themes/gamer.json +3 -0
  124. package/themes/imperial.json +3 -0
  125. package/themes/joker.json +3 -0
  126. package/themes/marvin.json +3 -0
  127. package/themes/mean.json +3 -0
  128. package/themes/military.json +3 -0
  129. package/themes/neutral.json +3 -0
  130. package/themes/pirate.json +3 -0
  131. package/themes/sports.json +3 -0
  132. package/themes/verstappen.json +3 -0
  133. package/themes/wizard.json +3 -0
  134. package/web/dist/assets/Activity-GaaZWXTY.js +1 -0
  135. package/web/dist/assets/ActivityDetail-4t7d45Du.js +1 -0
  136. package/web/dist/assets/Dashboard-CYn7Tp16.js +2 -0
  137. package/web/dist/assets/Docs-BeFKLg2T.js +5 -0
  138. package/web/dist/assets/{GreenhouseView-D0cbCJ5m.js → GreenhouseView-C6ibc8IE.js} +1 -1
  139. package/web/dist/assets/Installs-DRYjYnxs.js +2 -0
  140. package/web/dist/assets/JournalView-1HbU1mfg.js +1 -0
  141. package/web/dist/assets/Laws-DFoG8JWB.js +3 -0
  142. package/web/dist/assets/Library-DDjgNwz_.js +1 -0
  143. package/web/dist/assets/Loops-D9VEoO0F.js +1 -0
  144. package/web/dist/assets/{Markdown-CkFyfgNf.js → Markdown-qjM2_ofS.js} +5 -5
  145. package/web/dist/assets/{OperatorHudView-CiAkAkQL.js → OperatorHudView-Bs7paWmh.js} +1 -1
  146. package/web/dist/assets/Personal-BY5n4gTw.js +1 -0
  147. package/web/dist/assets/Tabs-ho-EYD8i.js +1 -0
  148. package/web/dist/assets/api-CyMRbU2x.js +1 -0
  149. package/web/dist/assets/drawable-CLS9drMb.js +1 -0
  150. package/web/dist/assets/index-CX_hXMi5.css +1 -0
  151. package/web/dist/assets/index-Cjr7XCA3.js +2 -0
  152. package/web/dist/index.html +3 -2
  153. package/QUICKSTART.md +0 -50
  154. package/SETUP.md +0 -1148
  155. package/docs/web/_groups.json +0 -45
  156. package/docs/web/about.md +0 -6
  157. package/docs/web/adapters-claude-code.md +0 -8
  158. package/docs/web/adapters-opencode-loads.md +0 -8
  159. package/docs/web/adapters-opencode-spec.md +0 -8
  160. package/docs/web/adapters-opencode.md +0 -8
  161. package/docs/web/agents.md +0 -8
  162. package/docs/web/authoring.md +0 -11
  163. package/docs/web/autostart.md +0 -9
  164. package/docs/web/claude-hooks.md +0 -37
  165. package/docs/web/cli.md +0 -6
  166. package/docs/web/collaboration.md +0 -27
  167. package/docs/web/configure-wiki.md +0 -9
  168. package/docs/web/design.md +0 -7
  169. package/docs/web/env-knobs.md +0 -9
  170. package/docs/web/exclusions.md +0 -26
  171. package/docs/web/first-session.md +0 -28
  172. package/docs/web/footprint.md +0 -38
  173. package/docs/web/foreman-mode.md +0 -33
  174. package/docs/web/glossary.md +0 -6
  175. package/docs/web/harnesses.md +0 -30
  176. package/docs/web/headless.md +0 -9
  177. package/docs/web/ingest-docs.md +0 -9
  178. package/docs/web/install-paths.md +0 -9
  179. package/docs/web/install-quick.md +0 -35
  180. package/docs/web/lsp-overview.md +0 -37
  181. package/docs/web/mcp-claude-code.md +0 -10
  182. package/docs/web/mcp-filesystem.md +0 -9
  183. package/docs/web/mcp-gitlab.md +0 -9
  184. package/docs/web/mcp-markitdown.md +0 -9
  185. package/docs/web/mcp-overview.md +0 -34
  186. package/docs/web/mcp-trouble.md +0 -9
  187. package/docs/web/mcp-verify.md +0 -9
  188. package/docs/web/migrate.md +0 -39
  189. package/docs/web/model.md +0 -20
  190. package/docs/web/pipelines.md +0 -24
  191. package/docs/web/plugin-activity.md +0 -23
  192. package/docs/web/plugin-context.md +0 -26
  193. package/docs/web/plugin-guard.md +0 -24
  194. package/docs/web/plugin-learn.md +0 -27
  195. package/docs/web/plugin-notify.md +0 -24
  196. package/docs/web/plugin-ponytail.md +0 -23
  197. package/docs/web/plugin-setup.md +0 -7
  198. package/docs/web/plugin-workflow.md +0 -25
  199. package/docs/web/plugins.md +0 -17
  200. package/docs/web/project-context.md +0 -9
  201. package/docs/web/rules.md +0 -30
  202. package/docs/web/run-anywhere.md +0 -9
  203. package/docs/web/self-improve.md +0 -8
  204. package/docs/web/setup-choices.md +0 -64
  205. package/docs/web/skills.md +0 -8
  206. package/docs/web/themes-author.md +0 -20
  207. package/docs/web/themes.md +0 -9
  208. package/docs/web/token-footprint.md +0 -7
  209. package/docs/web/trouble.md +0 -38
  210. package/docs/web/upgrade.md +0 -9
  211. package/docs/web/verify.md +0 -30
  212. package/docs/web/wiki.md +0 -7
  213. package/docs/web/worktree.md +0 -10
  214. package/docs/wiki.md +0 -43
  215. package/web/dist/assets/Activity-BjVp522q.js +0 -1
  216. package/web/dist/assets/ActivityDetail-8ep1bspW.js +0 -1
  217. package/web/dist/assets/Dashboard-OkC_PCH6.js +0 -2
  218. package/web/dist/assets/Docs-CKPWz00o.js +0 -2
  219. package/web/dist/assets/Installs-BtGGdoTt.js +0 -2
  220. package/web/dist/assets/JournalView-Ckl4dmzR.js +0 -1
  221. package/web/dist/assets/Laws-CLpdRRk8.js +0 -3
  222. package/web/dist/assets/Library-wPPuKQrI.js +0 -1
  223. package/web/dist/assets/Personal-D8CnDo9v.js +0 -1
  224. package/web/dist/assets/Tabs-B4HNrrCs.js +0 -1
  225. package/web/dist/assets/drawable-BiwaOo6x.js +0 -1
  226. package/web/dist/assets/index-C1yil5j1.js +0 -2
  227. package/web/dist/assets/index-CDPDk2vj.css +0 -1
@@ -56,6 +56,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
56
56
  import {
57
57
  GATE_LEDGER, resolveMemoryDir, resolvePath, sovereignBypass,
58
58
  } from './hosts.mjs';
59
+ import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
59
60
 
60
61
  const ROOT = path.resolve(import.meta.dirname, '../..');
61
62
 
@@ -599,6 +600,88 @@ const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
599
600
  const DESTRUCTIVE_GIT_RE =
600
601
  /\bgit\b[^\n]*\b(?:reset\s+--hard|clean\s+-[a-zA-Z]*f|branch\s+-D|checkout\s+--\s|push\s+[^\n]*--force)/;
601
602
 
603
+ // `loopExempt` — a WHITELIST, not the blacklist this replaced. A false ask costs one prompt; a
604
+ // false allow publishes. So the exemption holds only when the ENTIRE command is built from
605
+ // forms read in full below, on a branch a loop was actually launched on (LOOP.md's state
606
+ // marker, left by `geneseed loop init` — not just a name starting with `loop/`, which anyone
607
+ // can type). `currentBranch`/`gitRootOf`/`loopLaunched` live in `./gitref.mjs` — the one
608
+ // fs-only reader this file shares with `js/loop/cli.mjs`'s commit-message writer.
609
+
610
+ // No newline, backtick, or `$(` substitution anywhere — these can smuggle a second command (or
611
+ // a shared-branch target) past every check below, so the command is refused before it is even
612
+ // split. `<`/`>` are refused too: a redirect can both hide a second effect and, as the review
613
+ // found, trail off the end of an otherwise-whitelisted push (`HEAD:main>/dev/null`). A single
614
+ // `&` — not doubled into `&&` — is refused too: a shell runs `a & b` as TWO commands just like
615
+ // `a && b`, and the review found it unparsed by the splitter below, which only recognised the
616
+ // doubled form. `(?<!&)&(?!&)` matches a lone `&` without also matching either half of `&&`.
617
+ const UNSAFE_CHARS_RE = /[\n`<>]|\$\(|(?<!&)&(?!&)/;
618
+
619
+ // The only three git shapes a loop's own automation ever needs: adding unquoted paths,
620
+ // committing via `-F`/`--file` (so the message text, which may contain anything, never has to
621
+ // be quoted on this command line at all — see `js/loop/cli.mjs`'s LOOP_COMMIT_MSG), and
622
+ // read-only inspection. The token class `[\w./:@^~=+,-]` excludes quotes, braces, `!`, `*`,
623
+ // `$` and `&` — a brace or glob expansion (`{a,b}`, `*`) is exactly how a shell can turn one
624
+ // whitelisted-looking token into several unknown ones, so no pattern's path/arg tokens may
625
+ // contain them. The SAME class is the `-F`/`--file` path, not the looser `[^\s'"]+` this once
626
+ // was: with that loose class, `git commit -F {m,--amend}` brace-expands to `git commit -F m
627
+ // --amend` and `git commit -F m*` glob-expands to whatever matches — either turns a commit of
628
+ // the loop's own message file into an amend of the last one. `ARG_RE` already accepts a drive
629
+ // letter (`C:/…`) via its `:`, so tightening it here costs nothing the loop's own writer uses.
630
+ const ARG_RE = '[\\w./:@^~=+,-]+';
631
+ const SEG_ADD_RE = new RegExp(`^git\\s+add(\\s+${ARG_RE})*$`);
632
+ const SEG_COMMIT_RE = new RegExp(`^git\\s+commit(\\s+-q)?\\s+(-F\\s+|--file[=\\s])${ARG_RE}(\\s+-q)?$`);
633
+ const SEG_READONLY_RE = new RegExp(`^git\\s+(status|diff|log|rev-parse|show)(\\s+${ARG_RE})*$`);
634
+
635
+ /**
636
+ * `git push`, exactly: `[-u|--set-upstream] <remote> HEAD:<branch>` or
637
+ * `HEAD:refs/heads/<branch>` — the ONLY exempt form. Bare `git push`, a remote alone, a plain
638
+ * branch name, or bare `HEAD` are no longer enough: every one of those is redirectable by
639
+ * `push.default`/`remote.*.push`/an upstream config this read cannot see, which is exactly how
640
+ * the review's re-review turned "looks like it only pushes `loop/x`" into "pushes wherever the
641
+ * repo's config says". An EXPLICIT `HEAD:<ref>` refspec is immune to both — git ignores
642
+ * `push.default` once a refspec is given, and a mirror/`+`/`:`-prefixed form refuses to
643
+ * coexist with one. The remote's first character must be alphanumeric so a flag (`-f`,
644
+ * `--all`, `--delete`, …) can never be read as a remote name.
645
+ */
646
+ function pushSegmentOk(seg, branch) {
647
+ const m = /^git\s+push(?:\s+(.*))?$/.exec(seg);
648
+ if (!m) return false;
649
+ const rest = (m[1] || '').trim();
650
+ if (!rest) return false;
651
+ const tokens = rest.split(/\s+/);
652
+ let i = 0;
653
+ if (tokens[i] === '-u' || tokens[i] === '--set-upstream') i += 1;
654
+ const remote = tokens[i];
655
+ if (remote === undefined || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(remote)) return false;
656
+ i += 1;
657
+ const ref = tokens[i];
658
+ i += 1;
659
+ if (ref === undefined || i !== tokens.length) return false;
660
+ return ref === `HEAD:${branch}` || ref === `HEAD:refs/heads/${branch}`;
661
+ }
662
+
663
+ /**
664
+ * Consent Before Push's loop/* exemption: true only when EVERY clause holds —
665
+ * (1) the command carries none of the characters that could smuggle a second effect past this
666
+ * read, (2)-(3) every `&&`/`;`/`||`/`|`-separated segment is one of the three whitelisted git
667
+ * shapes above, with every push segment also passing `pushSegmentOk`, and (4) the branch
668
+ * checked out in `cwd` starts with `loop/` AND its git root carries a launched LOOP.md. Splitting
669
+ * on the separators without respecting quotes is deliberate, not an oversight: (1) and the
670
+ * per-segment whitelist already refuse any segment a quoted separator could produce, because a
671
+ * quote character fails every one of the three segment patterns.
672
+ */
673
+ export function loopExempt(command, cwd) {
674
+ if (typeof command !== 'string' || UNSAFE_CHARS_RE.test(command)) return false;
675
+ const branch = currentBranch(cwd);
676
+ if (!branch || !branch.startsWith('loop/')) return false;
677
+ const root = gitRootOf(cwd);
678
+ if (!root || !loopLaunched(root)) return false;
679
+ const segments = command.split(/&&|\|\||[;|]/).map((s) => s.trim()).filter(Boolean);
680
+ if (!segments.length) return false;
681
+ return segments.every((seg) => SEG_ADD_RE.test(seg) || SEG_COMMIT_RE.test(seg)
682
+ || SEG_READONLY_RE.test(seg) || pushSegmentOk(seg, branch));
683
+ }
684
+
602
685
  /** The `permissionDecision: "ask"` document, in Python's compact `json.dumps` spelling. */
603
686
  function askDecision(reason) {
604
687
  return `${jsonDumpsCompact({
@@ -739,8 +822,12 @@ function gitDecide(args, payload) {
739
822
  + 'discarding git act needs confirmation bound to this specific command');
740
823
  }
741
824
  // `--no-consent`: the process pack (or process 5 alone) is off, so the commit/push ask has
742
- // no rule behind it — but Law IV above is universal and has already run.
825
+ // no rule behind it — but Law IV above is universal and has already run. Checked before
826
+ // `loopExempt` so a non-git command, or one with consent off, never pays for the whitelist.
743
827
  if (args.noConsent || !GIT_GATE_RE.test(command)) return 0;
828
+ // Consent Before Push: a loop launched on its own `loop/*` branch is a named batch for a
829
+ // fixed set of commit/push forms on that branch only — never a merge into anything shared.
830
+ if (loopExempt(command, (payload && payload.cwd) || process.cwd())) return 0;
744
831
  return ask(args, 'git-gate', 'process-5', 'Geneseed (Consent Before Push) \u2014 every git '
745
832
  + 'commit/push needs explicit approval; to see the change first, use the explain-changes skill');
746
833
  }
@@ -230,6 +230,27 @@ function leadOfDir(d, names) {
230
230
  export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
231
231
  export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
232
232
 
233
+ /**
234
+ * The loop skill's default trust preset, read back off a deployed install — `null` when no
235
+ * loop skill is deployed there.
236
+ *
237
+ * CARRIED BY THE SKILL, NOT BY A CARRIER: `--trust` is the one build axis with zero always-on
238
+ * footprint, so it is not in AGENT.md to be found. The skill renders a literal
239
+ * `Default trust preset: **<Label>**` line, and this scans the places an emit lands it: the
240
+ * native layer (`skills/loop/SKILL.md`, in the root itself for a global install and under the
241
+ * host's `projectMarker` for a project one) and the plain bundle (`skills/loop.md`).
242
+ */
243
+ const TRUST_RE = /^Default trust preset: \*\*(Prudent|Balanced|Aggressive)\*\*/m;
244
+ export function trustOfDir(d) {
245
+ for (const base of ['', ...HOSTS.map((h) => h.projectMarker)]) {
246
+ for (const rel of [['skills', 'loop', 'SKILL.md'], ['skills', 'loop.md']]) {
247
+ const m = TRUST_RE.exec(readMaybe(path.join(d, base, ...rel)) ?? '');
248
+ if (m) return m[1].toLowerCase();
249
+ }
250
+ }
251
+ return null;
252
+ }
253
+
233
254
  /** The `Active packs:` marker line the template emits, in one of the five carriers. */
234
255
  const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
235
256
 
package/js/hosts/mcp.mjs CHANGED
@@ -68,7 +68,7 @@ export const MCP_PRESETS = {
68
68
  + 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
69
69
  + 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
70
70
  + 'copy this block by hand under a new name and point it at the other URL — '
71
- + 'SETUP.md has the pattern.',
71
+ + 'docs/guides/mcp.md has the pattern.',
72
72
  block: {
73
73
  type: 'local',
74
74
  command: ['npx', '-y', '@zereight/mcp-gitlab'],
@@ -483,6 +483,18 @@ export function hookShimPath(platform = process.platform) {
483
483
  return path.join(shimHome(), ...shimRel(platform));
484
484
  }
485
485
 
486
+ /**
487
+ * The paths the machine's hook shim names that are gone — `[]` when the shim is absent or live.
488
+ *
489
+ * Cheap on purpose: `status`, the doctor and EVERY CLI run call it (the run-time warning in
490
+ * `bin/geneseed-cli.mjs`), because a dead shim disables every hook of every Claude-shaped
491
+ * install and nothing else would say so. An absent shim is not dead — a checkout that has never
492
+ * emitted owns none — and an unreadable one is reported by the doctor, not here.
493
+ */
494
+ export function shimDead() {
495
+ try { return shimDeadPaths(readText(hookShimPath())); } catch { return []; }
496
+ }
497
+
486
498
  /**
487
499
  * `_build_settings._hook_shim_body`, with the two volatile values INJECTED.
488
500
  *
@@ -988,7 +1000,7 @@ export function migrateShape(commands, shimBody) {
988
1000
  /**
989
1001
  * `_build_settings._autostart_paths` — where a hand-written web-daemon autostart entry lives.
990
1002
  *
991
- * NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — `SETUP.md` tells the user to create them
1003
+ * NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — the install guide tells the user to create them
992
1004
  * by hand, and no .py/.mjs/.sh/.cmd in the tree contains `vbs`, `LaunchAgents` or `plist`. So
993
1005
  * `migrate` REPORTS a stale one and never rewrites it, on the rule `settingsIntegrityCheck`
994
1006
  * already states: an entry the manifest does not claim is "possibly user-authored; left alone".
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import path from 'node:path';
15
15
  import {
16
- CONFIG, PACK_ORDER, PLUGIN_SRC, RETIRED_RULE_IDS, ROOT, RULE_ID_RE, SRC, THEMES, canonFiles,
16
+ CONFIG, DOCS, DOC_FOLDERS, PACK_ORDER, PLUGIN_SRC, RETIRED_RULE_IDS, ROOT, RULE_ID_RE, SRC, THEMES, canonFiles,
17
17
  knownRuleIds, ruleCanon, titleKey,
18
18
  } from '../build/source.mjs';
19
19
  import { themeFiles } from '../hosts/installs.mjs';
@@ -25,6 +25,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
25
25
  import {
26
26
  folderSkillProblems, registryProblems, secretProblems, vendorPinProblems,
27
27
  } from './checks-repo.mjs';
28
+ import { catalogProblems } from '../loop/catalog.mjs';
28
29
  import { LAW_CLASS, LAW_CLASSES, SKILL_CLASS } from './inventory.mjs';
29
30
  import { NOTE, globSorted, has, isDir, isFile, rglob, srcStems } from './scan.mjs';
30
31
  import { spawnSync } from 'node:child_process';
@@ -763,7 +764,7 @@ export function countTableProblems() {
763
764
 
764
765
  // WHERE THE ONBOARDING COPY LIVES, AND WHY THIS READ MOVED. It used to open the one module
765
766
  // that held the web console's onboarding prose. That prose has since moved into
766
- // `docs/web/*.md`, and the counts in it render from `{N_LAWS}` / `{N_AGENTS}` / `{N_SKILLS}`
767
+ // `docs/<folder>/*.md`, and the counts in it render from `{N_LAWS}` / `{N_AGENTS}` / `{N_SKILLS}`
767
768
  // — so the read was still succeeding against a file the sentences had left, all three arms
768
769
  // below scored zero, and the check looked healthy while gating nothing. A templated count
769
770
  // cannot drift; what these arms still catch is a maintainer typing the NUMBER into a page
@@ -773,7 +774,9 @@ export function countTableProblems() {
773
774
  // byte moves. `web` stays fail-soft — a missing docs tree is not an authoring fault.
774
775
  let web = '';
775
776
  try {
776
- web = globSorted(path.join(ROOT, 'docs', 'web'), (n) => n.endsWith('.md'))
777
+ web = DOC_FOLDERS
778
+ .flatMap((f) => (isDir(path.join(DOCS, f))
779
+ ? globSorted(path.join(DOCS, f), (n) => n.endsWith('.md')) : []))
777
780
  .map(readText).join('\n');
778
781
  } catch { web = ''; }
779
782
  // ⚠ NOT FAIL-SOFT, UNLIKE `web` ABOVE, AND THE ASYMMETRY IS THE POINT. A missing `docs/web`
@@ -834,6 +837,15 @@ function loadLearnPromptHead() {
834
837
  + 'When in doubt, output exactly: NOTHING.';
835
838
  }
836
839
 
840
+ /**
841
+ * The shipped loop catalogue parses and every shipped template passes the engine's own graph
842
+ * rules. Global and project bricks are deliberately out of scope: doctor judges what this
843
+ * checkout ships, not what a user wrote in their config.
844
+ */
845
+ export function loopProblems() {
846
+ return catalogProblems({ projectRoot: null, globalLevel: false });
847
+ }
848
+
837
849
  /**
838
850
  * `_harness_build._authoring_problems` — author-time gates on the source specs and plugins.
839
851
  *
@@ -46,6 +46,7 @@ import {
46
46
  } from '../hosts/hosts.mjs';
47
47
  import {
48
48
  EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
49
+ trustOfDir,
49
50
  readJsonMaybe, readMaybe, themeOfDir,
50
51
  } from '../hosts/installs.mjs';
51
52
  import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
@@ -175,6 +176,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
175
176
  // `null` (no marker) means unknown and renders at all four, which `doctrinesForBuild`
176
177
  // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
177
178
  posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
179
+ // The loop skill's preset, or its `Default trust preset:` line reads as edited.
180
+ trust: trustOfDir(dir),
178
181
  }));
179
182
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
180
183
  // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
@@ -36,7 +36,7 @@ import { resolvePath } from '../hosts/hosts.mjs';
36
36
  import { installedDefaults, themeFiles } from '../hosts/installs.mjs';
37
37
  import { validateIsVendored } from '../hosts/native.mjs';
38
38
  import { printOut } from '../lib/fs.mjs';
39
- import { authoringProblems } from './checks-authoring.mjs';
39
+ import { authoringProblems, loopProblems } from './checks-authoring.mjs';
40
40
  import {
41
41
  checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
42
42
  } from './checks-build.mjs';
@@ -217,6 +217,7 @@ export function doctorCollect({
217
217
  problems = problems.concat(ran('parity', 'Theme parity', themeParityProblems()));
218
218
  problems = problems.concat(ran('colors', 'Colour themes', colorThemeProblems()));
219
219
  problems = problems.concat(ran('authoring', 'Authoring gates', authoringProblems()));
220
+ problems = problems.concat(ran('loops', 'Loop catalogue', loopProblems()));
220
221
  problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
221
222
  problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
222
223
  // P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
@@ -283,7 +284,7 @@ export function cmdDoctor(args) {
283
284
  // (`js/build/generate.mjs`), which forwards `--theme` and nothing else. So the front door
284
285
  // answered `unrecognized arguments: --sync-themes` to anyone who followed doctor's own
285
286
  // advice, for as long as the premise went unchecked. The flag belongs to the GENERATOR,
286
- // whose binary is `geneseed-build` — which is what `README.md` and `SETUP.md` have said
287
+ // whose binary is `geneseed-build` — which is what `README.md` and the install guide have said
287
288
  // all along. A hint is a command; if it is not runnable it is decoration.
288
289
  printOut(' tip: a theme is missing a key another theme defines — run '
289
290
  + '`geneseed-build --sync-themes` to fill it from _TEMPLATE.json, '
@@ -91,6 +91,7 @@ export const LAW_CLASSES = ['security', 'process', 'verify', 'craft', 'context',
91
91
  export const SKILL_CLASS = {
92
92
  brainstorm: 'design', plan: 'design', council: 'design',
93
93
  workflow: 'design', 'parallel-agents': 'design', pipeline: 'design',
94
+ loop: 'design', 'brick-forge': 'design',
94
95
  'codebase-design': 'design', 'domain-modeling': 'design', wayfinder: 'design',
95
96
  tickets: 'design',
96
97
  develop: 'build', refactor: 'build', debug: 'build', migrate: 'build',
@@ -108,7 +109,7 @@ export const SKILL_CLASS = {
108
109
  // `understand` before, which made that class a grab-bag.
109
110
  wiki: 'harness', geneseed: 'harness', rule: 'harness', profile: 'harness',
110
111
  herdr: 'harness', 'skill-forge': 'harness', 'consolidate-memory': 'harness',
111
- teach: 'learn', quiz: 'learn',
112
+ teach: 'learn', quiz: 'learn', 'learn-mode': 'learn',
112
113
  };
113
114
 
114
115
  /** `k in dict` — own keys only, where `k in obj` also finds `Object.prototype` members. */
@@ -17,10 +17,10 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync } from 'n
17
17
  import path from 'node:path';
18
18
  import os from 'node:os';
19
19
 
20
- import { expanduser } from '../hosts/hosts.mjs';
20
+ import { expanduser, resolvePath } from '../hosts/hosts.mjs';
21
21
  import { writeText, isFile, isDir } from '../lib/fs.mjs';
22
22
  import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
23
- import { toPlatformPath } from '../lib/paths.mjs';
23
+ import { toPlatformPath, within } from '../lib/paths.mjs';
24
24
 
25
25
  /** `_install_registry._path()` — `$XDG_CONFIG_HOME/geneseed/installs.json`. */
26
26
  function registryPath() {
@@ -28,6 +28,21 @@ function registryPath() {
28
28
  return path.join(base, 'geneseed', 'installs.json');
29
29
  }
30
30
 
31
+ /**
32
+ * Is `root` a scratch install — inside the OS temp dir while the registry is not?
33
+ *
34
+ * Agent sessions emit into scratchpads under `%TEMP%`; recorded, each one is rebuilt by every
35
+ * `rebuild-all` and listed by every `status` for as long as the folder survives. The second
36
+ * clause is what keeps every test and golden cell meaningful: their registry lives in a
37
+ * sandbox under the temp dir too, so their temp roots are not scratch RELATIVE TO IT.
38
+ * `within` is case-insensitive on win32, so `C:\Users\X\AppData\Local\Temp` and its
39
+ * lower-cased spelling are one directory.
40
+ */
41
+ export function isScratchRoot(root, registryFile) {
42
+ const tmp = resolvePath(os.tmpdir());
43
+ return within(resolvePath(root), tmp) && !within(resolvePath(registryFile), tmp);
44
+ }
45
+
31
46
  function registryLoad() {
32
47
  try {
33
48
  const data = parseJson(readFileSync(registryPath(), 'utf8'));
@@ -59,6 +74,7 @@ export function registryRecord(dir) {
59
74
  try {
60
75
  let root;
61
76
  try { root = realpathSync.native(dir); } catch { root = path.resolve(dir); }
77
+ if (isScratchRoot(root, registryPath())) return;
62
78
  const cur = registryLoad();
63
79
  if (!cur.includes(root)) registrySave([...cur, root]);
64
80
  } catch { /* a registry hiccup must never fail a build */ }
@@ -75,6 +91,7 @@ export function registryRecord(dir) {
75
91
  */
76
92
  export function registryRoots() {
77
93
  const original = registryLoad();
94
+ const file = registryPath();
78
95
  const out = [];
79
96
  const kept = [];
80
97
  const seen = new Set();
@@ -96,7 +113,7 @@ export function registryRoots() {
96
113
  let key;
97
114
  try { key = existsSync(root) ? realpathSync.native(root) : root; } catch { key = root; }
98
115
  if (seen.has(key)) continue;
99
- if (isDir(root) && isFile(path.join(root, '.geneseed-emit'))) {
116
+ if (isDir(root) && isFile(path.join(root, '.geneseed-emit')) && !isScratchRoot(root, file)) {
100
117
  seen.add(key);
101
118
  kept.push(root);
102
119
  out.push(root);
@@ -73,6 +73,7 @@ import {
73
73
  defaultTheme, installedDefaults, installTargets, manifestIsClaude, readJsonMaybe, readMaybe,
74
74
  } from '../hosts/installs.mjs';
75
75
  import { installProfile, rebuildCommand } from '../build/generate.mjs';
76
+ import { shimDead } from '../hosts/settings.mjs';
76
77
  import { printOut } from '../lib/fs.mjs';
77
78
  import { codePointLength, padEndToWidth } from '../lib/text.mjs';
78
79
 
@@ -208,7 +209,9 @@ export function gateSummary(cfgDirs) {
208
209
  total += 1;
209
210
  }
210
211
  }
211
- return { standing_down: standingDown, asks, total };
212
+ // `dead` is the machine shim's missing targets: every gate below is armed on paper and never
213
+ // runs, so it outranks `armed` in the row.
214
+ return { standing_down: standingDown, asks, total, dead: shimDead() };
212
215
  }
213
216
 
214
217
  /** `_harness_status._status_data`. */
@@ -350,7 +353,9 @@ export function statusLines(d, color = false) {
350
353
  // panels carry no `gates` key and must render unchanged.
351
354
  if (d.gates) {
352
355
  const g = d.gates;
353
- const armed = g.standing_down.length
356
+ const armed = g.dead?.length
357
+ ? `DEAD — hook shim points at ${g.dead.join(', ')}; run: geneseed rebuild-all`
358
+ : g.standing_down.length
354
359
  ? `STANDING DOWN for this cwd (excludes.json in ${g.standing_down.join(', ')})`
355
360
  : 'armed';
356
361
  const byRule = Object.entries(g.asks).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
@@ -367,7 +372,7 @@ export function statusLines(d, color = false) {
367
372
  }
368
373
  const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
369
374
  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}`,
375
+ rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
371
376
  packs, excl].join(` ${DOT} `) + where]);
372
377
  }
373
378
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The Node floor — the FIRST import of all three binaries, so an old Node gets one sentence
3
+ * instead of a stack trace from whichever API it lacks.
4
+ *
5
+ * `engines` in package.json only makes npm warn, and `npx geneseed`, a global install and the
6
+ * hook shim all run the binaries directly. ESM links the whole graph before any body runs, so
7
+ * this only helps while every file still PARSES on the old Node; it runs before any of them
8
+ * executes. The hook exits 0: a hook must never block a tool call, and the message on stderr
9
+ * is what the host surfaces. Nothing here may print to stdout — the hook's decision channel.
10
+ */
11
+ import path from 'node:path';
12
+
13
+ export const NODE_FLOOR = '22.3.0';
14
+
15
+ /** Numeric, not string, comparison: '22.10.0' is above '22.3.0'. */
16
+ export function belowFloor(version, floor = NODE_FLOOR) {
17
+ const v = version.replace(/^v/, '').split('.').map(Number);
18
+ const f = floor.split('.').map(Number);
19
+ for (let i = 0; i < 3; i += 1) {
20
+ if ((v[i] || 0) !== f[i]) return (v[i] || 0) < f[i];
21
+ }
22
+ return false;
23
+ }
24
+
25
+ if (belowFloor(process.versions.node)) {
26
+ process.stderr.write(`geneseed needs Node 22.3 or newer; this is Node ${process.versions.node}. `
27
+ + 'Install a current Node from https://nodejs.org (or your package manager), then run the '
28
+ + 'command again.\n');
29
+ process.exit(path.basename(process.argv[1] || '').startsWith('geneseed-hook') ? 0 : 1);
30
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Where bricks and loop templates come from, and which one wins.
3
+ *
4
+ * THREE ORIGINS, ONE LADDER. Shipped (this package's own `src/`), global (the user's, beside
5
+ * `installs.json` under XDG) and project (`<repo>/.geneseed/`, committed, shared with a team).
6
+ * A later origin replaces an earlier one BY NAME — that is the whole point: a team adapts the
7
+ * shipped `reproduce` to its stack without copying every template that uses it. The cost is
8
+ * that a fixed shipped brick stays masked by the override, so the override is reported
9
+ * (`overridden`), never silent.
10
+ *
11
+ * WHY SHIPPED BRICKS ARE READ FROM `src/`, NOT FROM AN INSTALL. The model never reads a brick
12
+ * file: `geneseed loop next` hands it the brick's prompt. So the CLI reads its own source and no
13
+ * host emit has to learn a new directory — four hosts get the catalogue for free and cannot drift.
14
+ *
15
+ * Templates are parsed with JSON.parse on purpose: they are read, never written back into a
16
+ * user's file, so the int/float distinction `parseJson` preserves has nowhere to matter.
17
+ */
18
+ import { readdirSync } from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { readText, isFile, isDir } from '../lib/fs.mjs';
22
+ import { frontmatter } from '../hosts/hooks.mjs';
23
+ import { SRC } from '../build/source.mjs';
24
+ import { checkGraph } from './graph.mjs';
25
+
26
+ export function userLoopsDir() {
27
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
28
+ return path.join(base, 'geneseed');
29
+ }
30
+
31
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/;
32
+
33
+ export function parseBrick(text, origin) {
34
+ const [fm, body] = frontmatter(text);
35
+ const get = (k) => (fm.get(k) ?? '').trim();
36
+ const name = get('name');
37
+ const problems = [];
38
+ if (!KEBAB.test(name)) problems.push(`name ${JSON.stringify(name)} must be kebab-case`);
39
+ const description = get('description');
40
+ if (!description) problems.push(`${name}: description is empty`);
41
+ const effect = get('effect');
42
+ if (effect !== 'read' && effect !== 'mutate') {
43
+ problems.push(`${name}: effect must be read or mutate, not ${JSON.stringify(effect)}`);
44
+ }
45
+ const agent = get('agent') || null;
46
+ const skill = get('skill') || null;
47
+ if (!agent === !skill) problems.push(`${name}: exactly one of agent or skill`);
48
+ const outcomes = get('outcomes').split(',').map((s) => s.trim()).filter(Boolean);
49
+ if (!outcomes.length) problems.push(`${name}: outcomes is empty`);
50
+ return {
51
+ brick: { name, description, effect, agent, skill, outcomes, body: body.trim(), origin },
52
+ problems,
53
+ };
54
+ }
55
+
56
+ const listFiles = (dir, ext) => (isDir(dir)
57
+ ? readdirSync(dir).filter((n) => n.endsWith(ext) && !n.startsWith('_') && n !== 'README.md').sort()
58
+ : []);
59
+
60
+ function availability(brick, srcRoot) {
61
+ if (brick.agent && !isFile(path.join(srcRoot, 'agents', `${brick.agent}.md`))) {
62
+ return { available: false, reason: `agent ${brick.agent} is not shipped` };
63
+ }
64
+ if (brick.skill && !isFile(path.join(srcRoot, 'skills', `${brick.skill}.md`))
65
+ && !isDir(path.join(srcRoot, 'skills', brick.skill))) {
66
+ return { available: false, reason: `skill ${brick.skill} is not shipped` };
67
+ }
68
+ return { available: true };
69
+ }
70
+
71
+ export function loadCatalog({
72
+ projectRoot = process.cwd(), srcRoot = SRC, globalLevel = true,
73
+ } = {}) {
74
+ const levels = [
75
+ ['shipped', path.join(srcRoot, 'bricks'), path.join(srcRoot, 'loops')],
76
+ ];
77
+ if (globalLevel) {
78
+ levels.push(['global', path.join(userLoopsDir(), 'bricks'), path.join(userLoopsDir(), 'loops')]);
79
+ }
80
+ if (projectRoot) {
81
+ levels.push(['project', path.join(projectRoot, '.geneseed', 'bricks'), path.join(projectRoot, '.geneseed', 'loops')]);
82
+ }
83
+ const bricks = new Map(); const templates = new Map(); const problems = []; const overridden = [];
84
+ for (const [origin, brickDir, loopDir] of levels) {
85
+ for (const f of listFiles(brickDir, '.md')) {
86
+ const { brick, problems: p } = parseBrick(readText(path.join(brickDir, f)), origin);
87
+ problems.push(...p.map((x) => `bricks/${f} (${origin}): ${x}`));
88
+ if (p.length) continue;
89
+ const prior = bricks.get(brick.name);
90
+ if (prior) overridden.push(`${brick.name} (${origin}, overrides ${prior.origin})`);
91
+ bricks.set(brick.name, { ...brick, ...availability(brick, srcRoot) });
92
+ }
93
+ for (const f of listFiles(loopDir, '.json')) {
94
+ let graph;
95
+ try { graph = JSON.parse(readText(path.join(loopDir, f))); } catch {
96
+ problems.push(`loops/${f}: not JSON`); continue;
97
+ }
98
+ const stem = f.slice(0, -'.json'.length);
99
+ if (graph.name !== stem) {
100
+ problems.push(`loops/${f}: name ${JSON.stringify(graph.name)} does not match the file name`);
101
+ continue;
102
+ }
103
+ templates.set(stem, { ...graph, origin });
104
+ }
105
+ }
106
+ return { bricks, templates, problems, overridden: overridden.sort() };
107
+ }
108
+
109
+ export function catalogProblems(opts = {}) {
110
+ const { bricks, templates, problems } = loadCatalog(opts);
111
+ const out = [...problems];
112
+ for (const [name, graph] of [...templates].sort(([a], [b]) => (a < b ? -1 : 1))) {
113
+ out.push(...checkGraph(graph, bricks).map((p) => `loops/${name}: ${p}`));
114
+ }
115
+ return out.sort();
116
+ }