geneseed 3.11.0 → 3.13.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 (265) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +5 -5
  3. package/SHIPPED.md +2 -2
  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-guard.js +157 -2
  9. package/bin/geneseed-cli.mjs +4 -0
  10. package/docs/README.md +41 -22
  11. package/docs/concepts/agents.md +3 -1
  12. package/docs/concepts/collaboration.md +3 -1
  13. package/docs/concepts/exclusions.md +4 -2
  14. package/docs/concepts/footprint.md +3 -1
  15. package/docs/concepts/foreman-mode.md +4 -2
  16. package/docs/concepts/hook-gates.md +61 -0
  17. package/docs/concepts/hooks.md +8 -56
  18. package/docs/concepts/host-matrix.md +38 -0
  19. package/docs/concepts/hosts.md +6 -32
  20. package/docs/concepts/loop-branch.md +66 -0
  21. package/docs/concepts/loop-bricks.md +48 -0
  22. package/docs/concepts/loop-contracts.md +33 -0
  23. package/docs/concepts/loop-gates.md +30 -0
  24. package/docs/concepts/loop-notes.md +43 -0
  25. package/docs/concepts/loop-risk.md +36 -0
  26. package/docs/concepts/loop-templates.md +50 -0
  27. package/docs/concepts/loops.md +73 -0
  28. package/docs/concepts/lsp.md +2 -0
  29. package/docs/concepts/memory.md +3 -1
  30. package/docs/concepts/notebook.md +3 -1
  31. package/docs/concepts/rules.md +4 -2
  32. package/docs/concepts/skills.md +3 -1
  33. package/docs/concepts/themes.md +3 -1
  34. package/docs/extending.md +28 -8
  35. package/docs/guides/autostart.md +3 -1
  36. package/docs/guides/choose-your-setup.md +5 -66
  37. package/docs/guides/headless.md +5 -3
  38. package/docs/guides/install-agent-md.md +37 -0
  39. package/docs/guides/install-bob.md +20 -0
  40. package/docs/guides/install-claude-code.md +22 -0
  41. package/docs/guides/install-clone.md +34 -0
  42. package/docs/guides/install-openclaude.md +25 -0
  43. package/docs/guides/install-opencode.md +35 -0
  44. package/docs/guides/install-scripted.md +21 -0
  45. package/docs/guides/install.md +9 -118
  46. package/docs/guides/loop-finish.md +54 -0
  47. package/docs/guides/loop-running.md +66 -0
  48. package/docs/guides/loop-which-template.md +35 -0
  49. package/docs/guides/mcp-filesystem.md +41 -0
  50. package/docs/guides/mcp-gitlab.md +66 -0
  51. package/docs/guides/mcp-markitdown.md +36 -0
  52. package/docs/guides/mcp.md +8 -137
  53. package/docs/guides/migrate.md +3 -1
  54. package/docs/guides/project-context.md +4 -2
  55. package/docs/guides/read-non-markdown.md +17 -0
  56. package/docs/guides/run-a-loop.md +64 -0
  57. package/docs/guides/run-anywhere.md +2 -0
  58. package/docs/guides/setup-doctrines.md +55 -0
  59. package/docs/guides/setup-footprint.md +48 -0
  60. package/docs/guides/uninstall.md +3 -1
  61. package/docs/guides/upgrade.md +3 -1
  62. package/docs/guides/verify.md +3 -1
  63. package/docs/guides/wiki.md +3 -1
  64. package/docs/guides/worktree.md +3 -1
  65. package/docs/reference/about.md +3 -1
  66. package/docs/reference/cli.md +2 -0
  67. package/docs/reference/env-context.md +40 -0
  68. package/docs/reference/environment.md +10 -59
  69. package/docs/reference/glossary.md +7 -1
  70. package/docs/reference/loop-actions.md +20 -0
  71. package/docs/reference/opencode-plugins.md +14 -116
  72. package/docs/reference/plugin-activity.md +20 -0
  73. package/docs/reference/plugin-context.md +27 -0
  74. package/docs/reference/plugin-guard.md +27 -0
  75. package/docs/reference/plugin-learn.md +27 -0
  76. package/docs/reference/plugin-notify.md +20 -0
  77. package/docs/reference/plugin-ponytail.md +19 -0
  78. package/docs/reference/plugin-workflow.md +25 -0
  79. package/docs/reference/troubleshoot-doctor.md +50 -0
  80. package/docs/reference/troubleshoot-hooks.md +99 -0
  81. package/docs/reference/troubleshoot-install.md +108 -0
  82. package/docs/reference/troubleshoot-tools.md +56 -0
  83. package/docs/reference/troubleshoot-update.md +23 -0
  84. package/docs/reference/troubleshoot-web.md +66 -0
  85. package/docs/reference/troubleshooting.md +13 -337
  86. package/docs/understand/a-day.md +3 -1
  87. package/docs/understand/enforced-vs-asked.md +3 -1
  88. package/docs/understand/harness.md +2 -0
  89. package/docs/understand/machine-bob.md +17 -0
  90. package/docs/understand/machine-claude.md +35 -0
  91. package/docs/understand/machine-opencode.md +36 -0
  92. package/docs/understand/on-your-machine.md +12 -55
  93. package/docs/understand/take-it-out.md +13 -0
  94. package/docs/web-ui.md +36 -5
  95. package/harness.config.json +1 -1
  96. package/js/README.md +32 -2
  97. package/js/build/driver.mjs +14 -8
  98. package/js/build/generate.mjs +10 -6
  99. package/js/build/render.mjs +5 -0
  100. package/js/build/source.mjs +5 -1
  101. package/js/cli-table.json +261 -0
  102. package/js/hosts/gitref.mjs +99 -0
  103. package/js/hosts/hooks.mjs +88 -1
  104. package/js/hosts/installs.mjs +21 -0
  105. package/js/hosts/mcp.mjs +1 -1
  106. package/js/hosts/native.mjs +43 -5
  107. package/js/inspect/checks-authoring.mjs +10 -0
  108. package/js/inspect/checks-build.mjs +28 -0
  109. package/js/inspect/diff.mjs +3 -0
  110. package/js/inspect/doctor.mjs +2 -1
  111. package/js/inspect/inventory.mjs +1 -0
  112. package/js/inspect/status.mjs +1 -1
  113. package/js/loop/catalog.mjs +127 -0
  114. package/js/loop/cli.mjs +173 -0
  115. package/js/loop/graph.mjs +163 -0
  116. package/js/loop/registry.mjs +196 -0
  117. package/js/loop/score.mjs +182 -0
  118. package/js/loop/state.mjs +436 -0
  119. package/js/maintain/migrate.mjs +4 -2
  120. package/js/maintain/setup.mjs +1 -1
  121. package/js/web/actions.mjs +34 -3
  122. package/js/web/api.mjs +51 -2
  123. package/js/web/daemon.mjs +43 -5
  124. package/js/web/docs.mjs +64 -12
  125. package/js/web/handler.mjs +1 -0
  126. package/js/web/jobs.mjs +3 -1
  127. package/js/web/routes.mjs +5 -1
  128. package/js/web/server.mjs +5 -3
  129. package/package.json +1 -1
  130. package/registry.json +14 -0
  131. package/src/AGENT.md.tmpl +2 -0
  132. package/src/agents/explorer.md +2 -2
  133. package/src/agents/researcher.md +3 -3
  134. package/src/bricks/README.md +34 -0
  135. package/src/bricks/_template.md +32 -0
  136. package/src/bricks/adr-challenge.md +20 -0
  137. package/src/bricks/adr-draft.md +15 -0
  138. package/src/bricks/apply.md +17 -0
  139. package/src/bricks/baseline-green.md +17 -0
  140. package/src/bricks/bruno-test.md +16 -0
  141. package/src/bricks/characterize.md +17 -0
  142. package/src/bricks/ci-fix.md +18 -0
  143. package/src/bricks/ci-triage.md +20 -0
  144. package/src/bricks/compat-check.md +16 -0
  145. package/src/bricks/deps-audit.md +14 -0
  146. package/src/bricks/docs-update.md +15 -0
  147. package/src/bricks/done-check.md +16 -0
  148. package/src/bricks/fitness-define.md +22 -0
  149. package/src/bricks/flake-check.md +17 -0
  150. package/src/bricks/flake-repro.md +18 -0
  151. package/src/bricks/identify.md +23 -0
  152. package/src/bricks/lint.md +14 -0
  153. package/src/bricks/migration-plan.md +21 -0
  154. package/src/bricks/mutation-check.md +18 -0
  155. package/src/bricks/plan.md +17 -0
  156. package/src/bricks/red-test.md +16 -0
  157. package/src/bricks/reproduce.md +16 -0
  158. package/src/bricks/review.md +19 -0
  159. package/src/bricks/security-scan.md +17 -0
  160. package/src/bricks/spec.md +18 -0
  161. package/src/bricks/test.md +14 -0
  162. package/src/bricks/upgrade-scout.md +16 -0
  163. package/src/doctrines/process.md +11 -8
  164. package/src/loops/api-endpoint.json +31 -0
  165. package/src/loops/architecture-decision.json +22 -0
  166. package/src/loops/bugfix.json +23 -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 +24 -0
  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 +24 -0
  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 +48 -0
  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/geneseed.md +1 -1
  187. package/src/skills/git-archaeology.md +1 -1
  188. package/src/skills/git-rescue.md +4 -3
  189. package/src/skills/handoff.md +1 -1
  190. package/src/skills/herdr.md +1 -1
  191. package/src/skills/learn-mode.md +2 -2
  192. package/src/skills/loop.md +111 -0
  193. package/src/skills/opencode-theme.md +3 -3
  194. package/src/skills/ponytail.md +1 -1
  195. package/src/skills/profile.md +3 -4
  196. package/src/skills/prose.md +2 -3
  197. package/src/skills/quiz.md +1 -1
  198. package/src/skills/react-view-transitions/SKILL.md +1 -1
  199. package/src/skills/react-view-transitions/VENDOR.md +8 -3
  200. package/src/skills/research.md +3 -3
  201. package/src/skills/review-response.md +2 -2
  202. package/src/skills/rule.md +3 -3
  203. package/src/skills/security-audit.md +4 -4
  204. package/src/skills/teach.md +1 -1
  205. package/src/skills/wiki.md +3 -3
  206. package/src/skills/workflow.md +4 -4
  207. package/src/skills/worktree.md +2 -2
  208. package/themes/_TEMPLATE.json +2 -0
  209. package/themes/biker.json +2 -0
  210. package/themes/commentator.json +2 -0
  211. package/themes/cyberpunk.json +2 -0
  212. package/themes/gamer.json +2 -0
  213. package/themes/imperial.json +2 -0
  214. package/themes/joker.json +2 -0
  215. package/themes/marvin.json +2 -0
  216. package/themes/mean.json +2 -0
  217. package/themes/military.json +2 -0
  218. package/themes/neutral.json +2 -0
  219. package/themes/pirate.json +2 -0
  220. package/themes/sports.json +2 -0
  221. package/themes/verstappen.json +2 -0
  222. package/themes/wizard.json +2 -0
  223. package/web/dist/assets/Activity-Ca2Aruu7.js +1 -0
  224. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +1 -0
  225. package/web/dist/assets/Dashboard-BuMq46aT.js +2 -0
  226. package/web/dist/assets/Docs-BzccCllG.js +5 -0
  227. package/web/dist/assets/FilterInput-CBcJBQPI.js +1 -0
  228. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +2 -0
  229. package/web/dist/assets/Installs-DMFJvq7s.js +2 -0
  230. package/web/dist/assets/JournalView-o1rmd1_1.js +1 -0
  231. package/web/dist/assets/Laws-BANxq_I_.js +3 -0
  232. package/web/dist/assets/Library-DW4x0fbh.js +1 -0
  233. package/web/dist/assets/Loops-DEBibbBP.js +2 -0
  234. package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-CdTX0kLe.js} +14 -14
  235. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +2 -0
  236. package/web/dist/assets/Personal-6uMJvyBG.js +1 -0
  237. package/web/dist/assets/Tabs-BubnyZhF.js +1 -0
  238. package/web/dist/assets/activity-CObqyxkL.js +1 -0
  239. package/web/dist/assets/{api-Dc8f3ca-.js → api-D_ixdyEY.js} +1 -1
  240. package/web/dist/assets/drawable-C_x5xncq.js +1 -0
  241. package/web/dist/assets/index-CMPgezFm.js +2 -0
  242. package/web/dist/assets/index-DMsT4Aa6.css +1 -0
  243. package/web/dist/assets/{react-B9Z_tXRV.js → react-Bbzhmq4v.js} +1 -1
  244. package/web/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  245. package/web/dist/assets/sections-Dg23Hel5.js +1 -0
  246. package/web/dist/assets/usePoll-BOqeunzF.js +1 -0
  247. package/web/dist/index.html +6 -5
  248. package/web/dist/assets/Activity-C52LEb-2.js +0 -1
  249. package/web/dist/assets/ActivityDetail-CjNLRvXg.js +0 -1
  250. package/web/dist/assets/Dashboard-B6_sSCHi.js +0 -2
  251. package/web/dist/assets/Docs-Dj7BmCSA.js +0 -5
  252. package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
  253. package/web/dist/assets/GreenhouseView-BALwjQsA.js +0 -2
  254. package/web/dist/assets/Installs-BbffDQm3.js +0 -2
  255. package/web/dist/assets/JournalView-JpAQ8Hqe.js +0 -1
  256. package/web/dist/assets/Laws-DK7eCWSm.js +0 -3
  257. package/web/dist/assets/Library-CoamHelE.js +0 -1
  258. package/web/dist/assets/OperatorHudView-DVx1HlBx.js +0 -2
  259. package/web/dist/assets/Personal-eaXb1zcj.js +0 -1
  260. package/web/dist/assets/Tabs-BmGtt8E2.js +0 -1
  261. package/web/dist/assets/drawable-Du_PZrQj.js +0 -1
  262. package/web/dist/assets/index-Bz_KMRDH.css +0 -1
  263. package/web/dist/assets/index-CNFH7o55.js +0 -2
  264. package/web/dist/assets/sections-GalMS0Zv.js +0 -1
  265. package/web/dist/assets/usePoll-O0sJEw1Y.js +0 -1
@@ -0,0 +1,99 @@
1
+ /**
2
+ * js/hosts/gitref.mjs — "what branch, and is a loop running here", read straight off the
3
+ * filesystem. Never spawns git.
4
+ *
5
+ * ONE OWNER, fs/path ONLY, NOTHING ELSE. Before this module, `currentBranch` and the
6
+ * launched-loop evidence reader were duplicated in `js/hosts/hooks.mjs` (the hook's
7
+ * `loopExempt`) and `js/loop/cli.mjs` (the CLI's commit-message writer) — two copies of the
8
+ * same worktree-following walk that could silently drift apart. Both now import this one.
9
+ * `js/hosts/hooks.mjs` is NOT a safe shared home for it: it pulls in `node:child_process`
10
+ * (`spawnSync`, for the destructive-act guard's own concerns) and the hook-dispatch
11
+ * machinery (`jsonDumpsCompact`, the ledger), none of which `js/loop/cli.mjs` — under a hard
12
+ * `child_process` ban like every other closure that feeds `bin/geneseed-cli.mjs` — may ever
13
+ * import. A module that is fs/path only is importable from anywhere.
14
+ *
15
+ * The OpenCode guard plugin (`adapters/opencode/plugins/geneseed-guard.js`) keeps its own
16
+ * standalone copy — it cannot import from `js/` at all — kept in step by hand, commented as
17
+ * this module's twin.
18
+ */
19
+ import { existsSync, statSync, lstatSync, readFileSync, openSync, readSync, closeSync } from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ /**
23
+ * The directory holding `.git` for `cwd` — walking up from `cwd`. A worktree's `.git` is a
24
+ * FILE naming its gitdir elsewhere; LOOP.md always lives beside that FILE, not beside the
25
+ * gitdir it points to, so this is the directory the launched-loop evidence reader looks in.
26
+ * null on anything unexpected.
27
+ */
28
+ export function gitRootOf(cwd) {
29
+ try {
30
+ let dir = path.resolve(cwd);
31
+ for (;;) {
32
+ if (existsSync(path.join(dir, '.git'))) return dir;
33
+ const up = path.dirname(dir);
34
+ if (up === dir) return null;
35
+ dir = up;
36
+ }
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The `.git` DIRECTORY for `root` — itself, or the gitdir a worktree's `.git` FILE names.
44
+ * Throws if `.git` is a file that does not name one: a malformed worktree link is a louder
45
+ * failure than a silent "no gitdir", and every caller here wants to know before it acts on it.
46
+ */
47
+ export function gitDirOf(root) {
48
+ const dotgit = path.join(root, '.git');
49
+ if (statSync(dotgit).isFile()) {
50
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, 'utf8'));
51
+ if (!m) throw new Error(`${dotgit} does not name a gitdir`);
52
+ return path.resolve(root, m[1]);
53
+ }
54
+ return dotgit;
55
+ }
56
+
57
+ /**
58
+ * The branch checked out in `cwd`, read from .git/HEAD without spawning git. null on
59
+ * anything unexpected (detached HEAD, no repo, a malformed worktree link, unreadable) — the
60
+ * caller then falls back to asking, never to allowing.
61
+ */
62
+ export function currentBranch(cwd) {
63
+ try {
64
+ const root = gitRootOf(cwd);
65
+ if (!root) return null;
66
+ const ref = /^ref:\s*refs\/heads\/(.+?)\s*$/m.exec(readFileSync(path.join(gitDirOf(root), 'HEAD'), 'utf8'));
67
+ return ref ? ref[1] : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ const LOOP_STATE_MARKER = '<!-- loop-state:begin -->';
74
+
75
+ /**
76
+ * Whether `<root>/LOOP.md` carries the engine's state marker — `geneseed loop init`'s mark
77
+ * that a loop was actually LAUNCHED here, not merely that the branch is named `loop/...`,
78
+ * which anyone can type. `lstatSync` (never `statSync`, which follows a link) must find an
79
+ * ordinary file: a FIFO would block the read, and a symlink could point anywhere outside the
80
+ * repo — either makes "LOOP.md says so" a claim this reader cannot stand behind. Read at
81
+ * most the first 64 KB: the marker is not on the first line (it follows the title and the
82
+ * requirement), so it must appear within those 64 KB — a huge LOOP.md (notes, history) must
83
+ * never make this check slow.
84
+ */
85
+ export function loopLaunched(root) {
86
+ let fd;
87
+ try {
88
+ const p = path.join(root, 'LOOP.md');
89
+ if (!lstatSync(p).isFile()) return false;
90
+ fd = openSync(p, 'r');
91
+ const buf = Buffer.alloc(65536);
92
+ const n = readSync(fd, buf, 0, buf.length, 0);
93
+ return buf.toString('utf8', 0, n).includes(LOOP_STATE_MARKER);
94
+ } catch {
95
+ return false;
96
+ } finally {
97
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
98
+ }
99
+ }
@@ -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
- + 'docs/guides/mcp.md has the pattern.',
71
+ + 'docs/guides/mcp-gitlab.md has the pattern.',
72
72
  block: {
73
73
  type: 'local',
74
74
  command: ['npx', '-y', '@zereight/mcp-gitlab'],
@@ -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)) {
@@ -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';
@@ -836,6 +837,15 @@ function loadLearnPromptHead() {
836
837
  + 'When in doubt, output exactly: NOTHING.';
837
838
  }
838
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
+
839
849
  /**
840
850
  * `_harness_build._authoring_problems` — author-time gates on the source specs and plugins.
841
851
  *
@@ -81,6 +81,34 @@ export function checkBuild(themeName, out, isVendored = isVendoredPath) {
81
81
  problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
82
82
  }
83
83
  for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
84
+ for (const p of descriptionProblems(text, rel)) problems.push(`[${themeName}] ${p}`);
85
+ }
86
+ return problems;
87
+ }
88
+
89
+ /**
90
+ * The `description:` of an emitted native skill or agent, held to Anthropic's skill-authoring
91
+ * rules — it is the ONLY text a host routes on, so a defect here is a skill that silently
92
+ * never fires. Three arms: a `…` ending is `withWhen`'s mid-sentence fallback (the trigger
93
+ * was cut, cure it with a shorter first trigger sentence); an XML-looking tag is rejected
94
+ * outright by the Agent Skills spec; a first/second-person pronoun is the point-of-view drift
95
+ * the spec warns degrades discovery, since the text is injected into the system prompt.
96
+ * The pronoun arm reads only the `Use when:` half, and skips quoted user phrases ("teach me
97
+ * X" is what the user types, not the description's voice): the purpose half is the theme's
98
+ * persona copy ("Make your own OpenCode theme"), authored per theme on purpose.
99
+ * Files without frontmatter (every bundle `.md`, AGENT.md) carry no description and pass.
100
+ */
101
+ const DESC_LINE_RE = /^---\n(?:.*\n)*?description: (".*")\n(?:.*\n)*?---\n/;
102
+ export function descriptionProblems(text, rel) {
103
+ const m = text.match(DESC_LINE_RE);
104
+ if (!m) return [];
105
+ const desc = parseJson(m[1]);
106
+ const problems = [];
107
+ if (desc.endsWith('…')) problems.push(`description cut mid-sentence in ${rel} — shorten the first trigger sentence`);
108
+ if (/<\/?[A-Za-z][^>]*>/.test(desc)) problems.push(`description carries an XML-like tag in ${rel}`);
109
+ const when = desc.split(' Use when: ')[1] ?? '';
110
+ if (/\b(I|I'm|me|my|you|you're|your)\b/i.test(when.replace(/"[^"]*"|\bI\/O\b/g, ''))) {
111
+ problems.push(`description's "Use when" is not in the third person in ${rel}`);
84
112
  }
85
113
  return problems;
86
114
  }
@@ -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
@@ -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',
@@ -372,7 +372,7 @@ export function statusLines(d, color = false) {
372
372
  }
373
373
  const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
374
374
  const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
375
- 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}`,
376
376
  packs, excl].join(` ${DOT} `) + where]);
377
377
  }
378
378
 
@@ -0,0 +1,127 @@
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
+ // `gate: human` holds the transition after this brick until the user answers (state.mjs).
51
+ // Present on the brick only when declared, so an ungated brick's shape is unchanged.
52
+ const gate = get('gate');
53
+ if (gate && gate !== 'human') problems.push(`${name}: gate must be human, not ${JSON.stringify(gate)}`);
54
+ // `gateOn` narrows the gate to the outcomes it lists; any other outcome follows its edge ungated.
55
+ const gateOn = get('gateOn').split(',').map((s) => s.trim()).filter(Boolean);
56
+ if (gateOn.length && gate !== 'human') problems.push(`${name}: gateOn needs gate: human`);
57
+ for (const o of gateOn) if (!outcomes.includes(o)) problems.push(`${name}: gateOn ${o} is not one of its outcomes`);
58
+ return {
59
+ brick: {
60
+ name, description, effect, agent, skill, outcomes, ...(gate ? { gate } : {}),
61
+ ...(gateOn.length ? { gateOn } : {}), body: body.trim(), origin,
62
+ },
63
+ problems,
64
+ };
65
+ }
66
+
67
+ const listFiles = (dir, ext) => (isDir(dir)
68
+ ? readdirSync(dir).filter((n) => n.endsWith(ext) && !n.startsWith('_') && n !== 'README.md').sort()
69
+ : []);
70
+
71
+ function availability(brick, srcRoot) {
72
+ if (brick.agent && !isFile(path.join(srcRoot, 'agents', `${brick.agent}.md`))) {
73
+ return { available: false, reason: `agent ${brick.agent} is not shipped` };
74
+ }
75
+ if (brick.skill && !isFile(path.join(srcRoot, 'skills', `${brick.skill}.md`))
76
+ && !isDir(path.join(srcRoot, 'skills', brick.skill))) {
77
+ return { available: false, reason: `skill ${brick.skill} is not shipped` };
78
+ }
79
+ return { available: true };
80
+ }
81
+
82
+ export function loadCatalog({
83
+ projectRoot = process.cwd(), srcRoot = SRC, globalLevel = true,
84
+ } = {}) {
85
+ const levels = [
86
+ ['shipped', path.join(srcRoot, 'bricks'), path.join(srcRoot, 'loops')],
87
+ ];
88
+ if (globalLevel) {
89
+ levels.push(['global', path.join(userLoopsDir(), 'bricks'), path.join(userLoopsDir(), 'loops')]);
90
+ }
91
+ if (projectRoot) {
92
+ levels.push(['project', path.join(projectRoot, '.geneseed', 'bricks'), path.join(projectRoot, '.geneseed', 'loops')]);
93
+ }
94
+ const bricks = new Map(); const templates = new Map(); const problems = []; const overridden = [];
95
+ for (const [origin, brickDir, loopDir] of levels) {
96
+ for (const f of listFiles(brickDir, '.md')) {
97
+ const { brick, problems: p } = parseBrick(readText(path.join(brickDir, f)), origin);
98
+ problems.push(...p.map((x) => `bricks/${f} (${origin}): ${x}`));
99
+ if (p.length) continue;
100
+ const prior = bricks.get(brick.name);
101
+ if (prior) overridden.push(`${brick.name} (${origin}, overrides ${prior.origin})`);
102
+ bricks.set(brick.name, { ...brick, ...availability(brick, srcRoot) });
103
+ }
104
+ for (const f of listFiles(loopDir, '.json')) {
105
+ let graph;
106
+ try { graph = JSON.parse(readText(path.join(loopDir, f))); } catch {
107
+ problems.push(`loops/${f}: not JSON`); continue;
108
+ }
109
+ const stem = f.slice(0, -'.json'.length);
110
+ if (graph.name !== stem) {
111
+ problems.push(`loops/${f}: name ${JSON.stringify(graph.name)} does not match the file name`);
112
+ continue;
113
+ }
114
+ templates.set(stem, { ...graph, origin });
115
+ }
116
+ }
117
+ return { bricks, templates, problems, overridden: overridden.sort() };
118
+ }
119
+
120
+ export function catalogProblems(opts = {}) {
121
+ const { bricks, templates, problems } = loadCatalog(opts);
122
+ const out = [...problems];
123
+ for (const [name, graph] of [...templates].sort(([a], [b]) => (a < b ? -1 : 1))) {
124
+ out.push(...checkGraph(graph, bricks).map((p) => `loops/${name}: ${p}`));
125
+ }
126
+ return out.sort();
127
+ }