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
package/js/loop/cli.mjs CHANGED
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * NO SPAWN. The CLI's closure may not start processes, so git is the model's job: it pipes
7
7
  * `git diff --cached --numstat` into `score --diff` and `git status --porcelain` into `record`.
8
- * stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang.
8
+ * stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang —
9
+ * and a TTY reads as `null`, "no porcelain given", never as `''`: an empty pipe is a CLEAN
10
+ * tree, and a clean tree is exactly the snapshot a read brick's check must compare against.
9
11
  *
10
12
  * NO `--file`. `state.mjs`'s `LOOP_FILE` constant is the ROOT-RELATIVE path the engine filters
11
13
  * out of every diff and porcelain check, so LOOP.md has to live at the git root of the worktree
@@ -22,11 +24,12 @@ import { parseNumstat, DEFAULT_PRESET } from './score.mjs';
22
24
  import { gitRootOf, gitDirOf, currentBranch } from '../hosts/gitref.mjs';
23
25
  import {
24
26
  initState, nextStep, scoreDeclared, scoreDiff, recordOutcome, decideAwaiting,
25
- renderLoopFile, parseLoopFile, LOOP_FILE,
27
+ writeLoopFile, parseLoopFile, LOOP_FILE,
26
28
  } from './state.mjs';
29
+ import { recordLoop } from './registry.mjs';
27
30
 
28
31
  const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
29
- const stdin = () => (process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
32
+ const stdin = () => (process.stdin.isTTY ? null : readFileSync(0, 'utf8'));
30
33
  const list = (s) => (s ? String(s).split(',').map((x) => x.trim()).filter(Boolean) : null);
31
34
 
32
35
  function requireRoot() {
@@ -57,14 +60,17 @@ function writeCommitMessage(root, gitdir, result) {
57
60
  return { ...result, message_file: file.replaceAll('\\', '/') };
58
61
  }
59
62
 
60
- function withState(fn) {
63
+ /** `write: false` for a read-only action (`next`): rewriting LOOP.md there would change
64
+ * nothing, and would race the Active tab's preset picker (`registry.mjs`'s `setLoopPreset`) —
65
+ * a preset it saved between this read and the rewrite would be silently undone. */
66
+ function withState(fn, { write = true } = {}) {
61
67
  const root = requireRoot();
62
68
  const file = path.join(root, LOOP_FILE);
63
69
  if (!isFile(file)) throw new Error(`${file} does not exist — run \`geneseed loop init\` first`);
64
70
  const state = parseLoopFile(readText(file));
65
71
  const { bricks } = loadCatalog({ projectRoot: root });
66
72
  const result = fn(state, bricks);
67
- writeText(file, renderLoopFile(state));
73
+ if (write) writeLoopFile(file, state);
68
74
  return result;
69
75
  }
70
76
 
@@ -78,26 +84,30 @@ function resolveGraph(spec, catalog) {
78
84
 
79
85
  const ACTIONS = {
80
86
  check(args) {
87
+ // The project level of the catalogue is `<git root>/.geneseed/`, as for `init` and `next`,
88
+ // so a check run from a subdirectory sees the same bricks and templates they will.
89
+ const root = gitRootOf(process.cwd()) ?? process.cwd();
81
90
  if (args.brick) {
82
91
  const { problems } = parseBrick(readText(path.resolve(args.brick)), 'checked');
83
92
  return { ok: !problems.length, problems };
84
93
  }
85
94
  if (args.graph) {
86
- const catalog = loadCatalog({ projectRoot: process.cwd() });
95
+ const catalog = loadCatalog({ projectRoot: root });
87
96
  const problems = checkGraph(resolveGraph(args.graph, catalog), catalog.bricks);
88
97
  return { ok: !problems.length, problems };
89
98
  }
90
99
  // The bare check doubles as the catalogue LISTING the loop skill composes a free graph
91
100
  // from — frontmatter only: a brick's body reaches the model through `next`, for the node
92
101
  // the engine chose, never as a menu.
93
- const problems = catalogProblems({ projectRoot: process.cwd() });
94
- const { bricks, templates, overridden } = loadCatalog({ projectRoot: process.cwd() });
102
+ const catalog = loadCatalog({ projectRoot: root });
103
+ const problems = catalogProblems({ projectRoot: root }, catalog);
104
+ const { bricks, templates, overridden } = catalog;
95
105
  const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
96
106
  return {
97
107
  ok: !problems.length, problems,
98
108
  templates: [...templates.keys()].sort(byName),
99
109
  bricks: [...bricks.values()].sort((a, b) => byName(a.name, b.name))
100
- .map(({ body, available, reason, ...b }) => ({ ...b, available, ...(reason ? { reason } : {}) })),
110
+ .map(({ body, reason, ...b }) => ({ ...b, ...(reason ? { reason } : {}) })),
101
111
  overridden,
102
112
  };
103
113
  },
@@ -110,18 +120,24 @@ const ACTIONS = {
110
120
  const graph = resolveGraph(args.graph, catalog);
111
121
  const problems = checkGraph(graph, catalog.bricks);
112
122
  if (problems.length) return { error: 'the graph does not pass loop check', problems };
113
- const state = initState({ title: args.title, requirement: args.requirement, graph, preset: args.preset || DEFAULT_PRESET });
114
- writeText(file, renderLoopFile(state));
123
+ const state = initState({
124
+ title: args.title, requirement: args.requirement, graph, preset: args.preset || DEFAULT_PRESET,
125
+ contracts: list(args.contracts) ?? [],
126
+ });
127
+ writeLoopFile(file, state);
128
+ // Best-effort: the registry is how the Active tab discovers this loop, but a registry
129
+ // hiccup must never fail the init the loop itself just succeeded at.
130
+ recordLoop({ root, branch: currentBranch(root), title: args.title });
115
131
  return { file, graph: graph.name, preset: state.preset, status: state.status };
116
132
  },
117
- next: () => withState((s, b) => nextStep(s, b)),
133
+ next: () => withState((s, b) => nextStep(s, b), { write: false }),
118
134
  score(args) {
119
135
  if (args.diff) {
120
136
  // X6: resolve (and validate) the gitdir BEFORE withState runs the state change, so a
121
137
  // malformed `.git` file errors without advancing the loop.
122
138
  const root = requireRoot();
123
139
  const gitdir = gitDirOf(root);
124
- return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin()))));
140
+ return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin() ?? ''))));
125
141
  }
126
142
  if (!args.declared) throw new Error('score needs --declared or --diff');
127
143
  return withState((s) => scoreDeclared(s, {
@@ -130,9 +146,29 @@ const ACTIONS = {
130
146
  },
131
147
  record(args) {
132
148
  if (!args.outcome) throw new Error('record needs --outcome');
133
- const card = args.card ? JSON.parse(args.card) : null;
134
- const raw = stdin();
135
- return withState((s, b) => recordOutcome(s, b, args.outcome, { card, porcelain: raw ? raw.replace(/\r\n/g, '\n').trimEnd() : null }));
149
+ if (args.note && args.noteFile) throw new Error('record takes --note or --note-file, not both');
150
+ let note = args.note ?? '';
151
+ if (args.noteFile) {
152
+ // A note too long for a shell argument, or one that mentions `git … commit`/`push` (and
153
+ // would otherwise sit inside the command line GIT_GATE_RE matches whole), goes through a
154
+ // file instead — never through the shell at all. CRLF-folded and trimmed the same way the
155
+ // porcelain stdin below is, so a Windows-authored file round-trips identically.
156
+ let raw;
157
+ try { raw = readText(path.resolve(args.noteFile)); } catch (e) {
158
+ throw new Error(`cannot read --note-file ${args.noteFile}: ${e.message}`, { cause: e });
159
+ }
160
+ note = raw.replace(/\r\n/g, '\n').trimEnd();
161
+ }
162
+ let card = null;
163
+ if (args.card) {
164
+ try { card = JSON.parse(args.card); } catch (e) {
165
+ throw new Error(`--card is not valid JSON: ${e.message}`, { cause: e });
166
+ }
167
+ }
168
+ const porcelain = stdin();
169
+ return withState((s, b) => recordOutcome(s, b, args.outcome, {
170
+ card, porcelain: porcelain === null ? null : porcelain.replace(/\r\n/g, '\n').trimEnd(), note,
171
+ }));
136
172
  },
137
173
  decide(args) {
138
174
  // X6: same ordering as `score --diff` above.
package/js/loop/graph.mjs CHANGED
@@ -21,8 +21,10 @@
21
21
  */
22
22
  import { WEIGHTS } from './score.mjs';
23
23
 
24
- export const TERMINALS = new Set(['$close', '$stop']);
24
+ const TERMINALS = new Set(['$close', '$stop']);
25
25
  export const ENGINE_MAX_ITERATIONS = 20;
26
+ /** A template's optional `category`, in the order the console's Loops list shows them. */
27
+ export const CATEGORIES = ['architecture', 'tests', 'development', 'refactoring', 'day-to-day'];
26
28
 
27
29
  export function iterationLoop(graph) {
28
30
  return (graph.loops ?? []).find((l) => l.iteration === true);
@@ -141,5 +143,21 @@ export function checkGraph(graph, bricks) {
141
143
  for (const k of Object.keys(graph.weights ?? {})) {
142
144
  if (!Object.hasOwn(WEIGHTS, k)) problems.push(`weights: ${k} is not an action`);
143
145
  }
146
+ // optional glob lists (score.mjs's `globMatch` reads them)
147
+ for (const k of ['contracts', 'ignoreDeletions']) {
148
+ const v = graph[k];
149
+ if (v !== undefined && !(Array.isArray(v) && v.every((g) => typeof g === 'string' && g.trim()))) {
150
+ problems.push(`${k}: must be a list of non-empty glob strings`);
151
+ }
152
+ }
153
+ // optional instruction list every brick receives through `loop next`
154
+ const rules = graph.rules;
155
+ if (rules !== undefined && !(Array.isArray(rules) && rules.every((r) => typeof r === 'string' && r.trim()))) {
156
+ problems.push('rules: must be a list of non-empty strings');
157
+ }
158
+ // optional catalogue shelf: the console groups templates by it, an absent one under OTHER
159
+ if (graph.category !== undefined && !CATEGORIES.includes(graph.category)) {
160
+ problems.push(`category: must be one of ${CATEGORIES.join(', ')}`);
161
+ }
144
162
  return problems;
145
163
  }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The loop registry — `<userLoopsDir()>/loops.json` — the list of worktrees `geneseed loop init`
3
+ * has launched a loop in. `LOOP.md` stays the only source of loop STATE; this file stores
4
+ * identity only (root, branch, title, started, finishedAt), so the Active tab can discover a
5
+ * loop without scanning every worktree on the machine.
6
+ *
7
+ * WRITER CONVENTIONS, mirrored from `js/inspect/registry.mjs` (`installs.json`'s own registry)
8
+ * on purpose rather than invented fresh: `jsonDumpsIndent` because these rows are absolute
9
+ * paths and a username carrying an accent writes one here (ensure_ascii matters); a
10
+ * `<file>.tmp` + `renameWithRetry` (LOOP.md's own atomic rename, retried on a transient Windows
11
+ * lock) so a crash mid-write cannot leave `loops.json` half-written where
12
+ * the NEXT read would see valid-but-truncated JSON instead of either the old file or the new
13
+ * one; and every public function swallows its own errors — a registry hiccup must never fail
14
+ * `loop init`, and a corrupt `loops.json` must read back as empty rather than throw. The one
15
+ * difference from `installs.json`: this file keys rows by object (`{root, branch, title,
16
+ * started, finishedAt}`), not by a bare path, because identity alone is not enough to render a
17
+ * card.
18
+ *
19
+ * NO `lastSeen`. The Active tab POLLS `activeLoops` on an interval, and a field refreshed on
20
+ * every read would make every poll a write — fine for `installs.json` (`record` fires once per
21
+ * emit), wrong here (the page's own `usePoll` fires every few seconds, for as long as the tab
22
+ * is open, on a machine that may be a laptop on battery). So `activeLoops` saves the registry
23
+ * back ONLY when the kept set actually differs from what it loaded — a root pruned, a loop
24
+ * newly seen finished, or a loop seen running again after having been finished — never merely
25
+ * to record that a poll happened. `finishedAt` replaces `lastSeen` for the one thing a
26
+ * timestamp was doing here: it is set ONCE, the first time a row is found WITHOUT a LOOP.md
27
+ * (the loop finished), and the 7-day prune window counts from there — not from a last-poll time
28
+ * nothing refreshes any more. A loop seen running again afterwards (the same worktree
29
+ * re-`init`ed) clears it, so a stale finished-window never survives into a loop that is running
30
+ * again.
31
+ *
32
+ * `recordLoop` and `setLoopPreset` resolve the root they are given the same way — `normalize`:
33
+ * `realpathSync.native`, falling back to `path.resolve` for a root that no longer exists — and
34
+ * compare it with `normcase`, the same case-folding `js/lib/paths.mjs` uses everywhere a Windows
35
+ * path is compared, so a loop launched from a differently-cased spelling of the same worktree is
36
+ * still one row, not two. `activeLoops` compares nothing: it reads each row's root as stored.
37
+ */
38
+ import { readFileSync, mkdirSync, realpathSync } from 'node:fs';
39
+ import path from 'node:path';
40
+
41
+ import { userLoopsDir } from './catalog.mjs';
42
+ import { writeText, readText, isDir, isFile } from '../lib/fs.mjs';
43
+ import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
44
+ import { normcase } from '../lib/paths.mjs';
45
+ import { parseLoopFile, writeLoopFile, renameWithRetry, reviewOf, LOOP_FILE } from './state.mjs';
46
+ import { PRESETS } from './score.mjs';
47
+
48
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
49
+
50
+ export function registryPath() {
51
+ return path.join(userLoopsDir(), 'loops.json');
52
+ }
53
+
54
+ function load() {
55
+ try {
56
+ const data = parseJson(readFileSync(registryPath(), 'utf8'));
57
+ return Array.isArray(data?.loops) ? data.loops.filter((l) => l && typeof l.root === 'string') : [];
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ function save(loops) {
64
+ try {
65
+ const file = registryPath();
66
+ mkdirSync(path.dirname(file), { recursive: true });
67
+ const tmp = `${file}.tmp`;
68
+ writeText(tmp, `${jsonDumpsIndent({ loops })}\n`);
69
+ renameWithRetry(tmp, file);
70
+ } catch {
71
+ /* best-effort: a registry hiccup must never fail init or a read */
72
+ }
73
+ }
74
+
75
+ function normalize(dir) {
76
+ try { return realpathSync.native(dir); } catch { return path.resolve(dir); }
77
+ }
78
+
79
+ const sameRoot = (a, b) => normcase(a) === normcase(b);
80
+
81
+ function runningRow(entry, state) {
82
+ return {
83
+ root: entry.root, branch: entry.branch, title: entry.title, started: entry.started,
84
+ status: state.status, iteration: state.iteration, node: state.node, preset: state.preset,
85
+ threshold: PRESETS[state.preset], current: state.current, history: state.history,
86
+ review: reviewOf(state), reason: state.reason, awaiting: state.awaiting, graph: state.graph,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Upsert `root`'s identity row. Called by `geneseed loop init` right after `LOOP.md` is
92
+ * written — `started` is set once, on insert, and never moved by a later call. An update never
93
+ * touches `finishedAt` either way: `activeLoops` is the only writer of that field, and it will
94
+ * clear it on the very next poll once it sees LOOP.md present again. Never throws: an `init`
95
+ * must succeed even when the registry itself cannot be written.
96
+ */
97
+ export function recordLoop({ root, branch, title, now = new Date() }) {
98
+ try {
99
+ const key = normalize(root);
100
+ const loops = load();
101
+ const idx = loops.findIndex((l) => sameRoot(l.root, key));
102
+ if (idx >= 0) {
103
+ loops[idx] = { ...loops[idx], root: key, branch, title };
104
+ } else {
105
+ loops.push({ root: key, branch, title, started: now.toISOString() });
106
+ }
107
+ save(loops);
108
+ } catch {
109
+ /* a registry hiccup must never fail loop init */
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The rows the Active tab renders, read fresh off every registered worktree's `LOOP.md` —
115
+ * never off the registry's own stale copy of a loop's state. Four outcomes per row; the
116
+ * write-back at the end fires ONLY when one of the first three actually changed something
117
+ * (see the module docblock on why a routine poll must not be a write):
118
+ * - the worktree root no longer exists: dropped, silently (a removed clone or worktree);
119
+ * - `<root>/LOOP.md` exists but fails to parse (corrupt, or written mid-crash before this
120
+ * module's own atomic writer existed): the entry is KEPT exactly as read — dropping it here
121
+ * would be indistinguishable from a removed worktree, and a card the user can still see
122
+ * beats one that silently vanishes — and the row reports `status: 'unreadable'`;
123
+ * - `<root>/LOOP.md` exists and parses: the row carries the state fields the page needs, and
124
+ * if the entry still carried a `finishedAt` from a previous finish it is cleared (the loop
125
+ * is running again — that counts as a change, and is saved);
126
+ * - `LOOP.md` is gone (the loop finished): the first time this is seen, `finishedAt` is set
127
+ * to `now` and saved; after that it is read-only here until `now` is more than 7 days past
128
+ * that frozen `finishedAt`, at which point the row is dropped (and the drop is saved).
129
+ */
130
+ export function activeLoops({ now = new Date() } = {}) {
131
+ const loops = load();
132
+ const kept = [];
133
+ const rows = [];
134
+ let changed = false;
135
+ for (const entry of loops) {
136
+ if (!isDir(entry.root)) { changed = true; continue; }
137
+ const file = path.join(entry.root, LOOP_FILE);
138
+ const identity = { root: entry.root, branch: entry.branch, title: entry.title, started: entry.started };
139
+ if (isFile(file)) {
140
+ let state;
141
+ try { state = parseLoopFile(readText(file)); } catch {
142
+ kept.push(entry);
143
+ rows.push({ ...identity, status: 'unreadable' });
144
+ continue;
145
+ }
146
+ let next = entry;
147
+ if (entry.finishedAt !== undefined) {
148
+ next = { ...entry }; delete next.finishedAt; changed = true;
149
+ }
150
+ kept.push(next);
151
+ rows.push(runningRow(next, state));
152
+ continue;
153
+ }
154
+ if (entry.finishedAt === undefined) {
155
+ const next = { ...entry, finishedAt: now.toISOString() };
156
+ changed = true;
157
+ kept.push(next);
158
+ rows.push({ ...identity, status: 'finished' });
159
+ continue;
160
+ }
161
+ const age = now.getTime() - new Date(entry.finishedAt).getTime();
162
+ if (age > SEVEN_DAYS_MS) { changed = true; continue; }
163
+ kept.push(entry);
164
+ rows.push({ ...identity, status: 'finished' });
165
+ }
166
+ if (changed) save(kept);
167
+ return rows;
168
+ }
169
+
170
+ /**
171
+ * Rewrite `root`'s `LOOP.md` `preset` field and nothing else — the per-loop preset picker on
172
+ * the Active tab. Refuses a `root` the registry does not know about (no arbitrary path
173
+ * writes — `POST /api/loops/preset`'s whole safety argument) and a `preset` outside `PRESETS`,
174
+ * each with its own message so the web action can tell the two refusals apart. Unlike
175
+ * `recordLoop`/`activeLoops`, this one DOES throw: a preset change the caller asked for and did
176
+ * not happen must be reported, not swallowed. Writes through `writeLoopFile` (atomic, like
177
+ * every other LOOP.md persist — see `state.mjs`'s docblock).
178
+ */
179
+ export function setLoopPreset(root, preset) {
180
+ if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
181
+ const key = normalize(root);
182
+ const loops = load();
183
+ const entry = loops.find((l) => sameRoot(l.root, key));
184
+ if (!entry || !isDir(entry.root)) throw new Error('unknown loop');
185
+ const file = path.join(entry.root, LOOP_FILE);
186
+ if (!isFile(file)) throw new Error('unknown loop');
187
+ const state = parseLoopFile(readText(file));
188
+ state.preset = preset;
189
+ writeLoopFile(file, state);
190
+ return runningRow(entry, state);
191
+ }
package/js/loop/score.mjs CHANGED
@@ -29,7 +29,7 @@ export const PRESETS = Object.freeze({
29
29
  export const DEFAULT_PRESET = 'balanced';
30
30
 
31
31
  /** More deleted lines than this in one iteration counts as a code deletion. */
32
- export const DELETION_LINES = 20;
32
+ const DELETION_LINES = 20;
33
33
 
34
34
  const RANK = { silent: 0, soft: 1, blocking: 2 };
35
35
 
@@ -104,23 +104,60 @@ export function parseNumstat(text) {
104
104
 
105
105
  const slash = (p) => p.replaceAll('\\', '/');
106
106
 
107
- export function actualRisk(declared, files, { writeSet = [], contracts = [], overrides = {} } = {}) {
107
+ // The one glob dialect of the loop — a template's `contracts` and `ignoreDeletions`, and LOOP.md's
108
+ // own `contracts`. `*` and `?` stay inside a path segment; `**` crosses segments, and `**/` may
109
+ // match no directory at all, so `**/yarn.lock` covers the root one too. Both sides are
110
+ // slash-normalised first, so a backslash is a separator, never an escape. Hand-rolled because
111
+ // this module may import nothing but node builtins (see the docblock above); a plain path is a
112
+ // glob that matches only itself, so exact-path contracts keep working.
113
+ // A leading `./` or `/` is dropped on both sides: every path here is repo-relative.
114
+ // ponytail: runs of `**/` and `*` collapse first, so stacked wildcards cannot compound; what
115
+ // remains is the regex engine's backtracking over several separate `**` (O(n^k) on a near miss).
116
+ // Patterns come from the template author or the user, never from untrusted input — a linear
117
+ // matcher is the upgrade if that ever changes.
118
+ const globPath = (p) => slash(String(p)).replace(/^(?:\.?\/)+/, '');
119
+
120
+ export function globMatch(pattern, file) {
121
+ const g = globPath(pattern).replace(/\*{3,}/g, '**').replace(/(?:\*\*\/){2,}/g, '**/');
122
+ let re = '';
123
+ for (let i = 0; i < g.length; i += 1) {
124
+ const c = g[i];
125
+ if (c === '*' && g[i + 1] === '*') {
126
+ i += 1;
127
+ if (g[i + 1] === '/') { i += 1; re += '(?:.*/)?'; } else re += '.*';
128
+ } else if (c === '*') re += '[^/]*';
129
+ else if (c === '?') re += '[^/]';
130
+ else re += c.replace(/[.+^${}()|[\]]/g, '\\$&');
131
+ }
132
+ return new RegExp(`^${re}$`).test(globPath(file));
133
+ }
134
+
135
+ const matchesAny = (globs, file) => globs.some((g) => globMatch(g, file));
136
+
137
+ export function actualRisk(declared, files, {
138
+ writeSet = [], contracts = [], ignoreDeletions = [], overrides = {},
139
+ } = {}) {
108
140
  const w = { ...WEIGHTS, ...overrides };
109
141
  let score = declared;
110
142
  const reasons = [];
143
+ // A write-set entry is a glob too (a plain path matches itself): a tool's generated store,
144
+ // ArchUnit's archunit_store/**, cannot be named file by file before the tool writes it. Only an
145
+ // entry with a literal segment globs: a wildcard-only one (`**`, `**/*.java`) would declare the
146
+ // whole repo, so it stays exact-match — matching nothing, the diff fails closed.
111
147
  const inSet = new Set(writeSet.map(slash));
112
- const outside = files.filter((f) => !inSet.has(slash(f.file)));
148
+ const globs = writeSet.filter((g) => globPath(g).split('/').some((seg) => seg && !/[*?]/.test(seg)));
149
+ const outside = files.filter((f) => !inSet.has(slash(f.file)) && !matchesAny(globs, f.file));
113
150
  if (outside.length) {
114
151
  score = Math.max(score, 0.8);
115
152
  reasons.push(`outside the write set: ${outside.map((f) => slash(f.file)).join(', ')}`);
116
153
  }
117
- const deleted = files.reduce((n, f) => n + f.deleted, 0);
154
+ // ignoreDeletions only leaves the deletion count: the write-set check above still saw the file.
155
+ const deleted = files.filter((f) => !matchesAny(ignoreDeletions, f.file)).reduce((n, f) => n + f.deleted, 0);
118
156
  if (deleted > DELETION_LINES) {
119
157
  score = Math.max(score, w.delete);
120
158
  reasons.push(`${deleted} lines deleted`);
121
159
  }
122
- const contractSet = new Set(contracts.map(slash));
123
- const hit = files.filter((f) => contractSet.has(slash(f.file)));
160
+ const hit = files.filter((f) => matchesAny(contracts, f.file));
124
161
  if (hit.length) {
125
162
  score = Math.max(score, w.api);
126
163
  reasons.push(`contract files: ${hit.map((f) => slash(f.file)).join(', ')}`);
@@ -129,8 +166,10 @@ export function actualRisk(declared, files, { writeSet = [], contracts = [], ove
129
166
  }
130
167
 
131
168
  export function decide(score, preset) {
169
+ if (!Object.hasOwn(PRESETS, preset)) {
170
+ throw new Error(`unknown preset ${JSON.stringify(preset)} — one of ${Object.keys(PRESETS).join(', ')}`);
171
+ }
132
172
  const t = PRESETS[preset];
133
- if (!t) throw new Error(`unknown preset ${JSON.stringify(preset)} — one of ${Object.keys(PRESETS).join(', ')}`);
134
173
  if (score <= t[0]) return 'silent';
135
174
  if (score <= t[1]) return 'soft';
136
175
  return 'blocking';