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,173 @@
1
+ /**
2
+ * `geneseed loop <action>` — the loop engine's only face. Every action prints exactly one JSON
3
+ * object: the loop skill is a model reading stdout, and a JSON error it can quote beats a
4
+ * stack trace it has to interpret. Exit 1 on any refusal so a script can still branch on it.
5
+ *
6
+ * NO SPAWN. The CLI's closure may not start processes, so git is the model's job: it pipes
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.
9
+ *
10
+ * NO `--file`. `state.mjs`'s `LOOP_FILE` constant is the ROOT-RELATIVE path the engine filters
11
+ * out of every diff and porcelain check, so LOOP.md has to live at the git root of the worktree
12
+ * for that filter to mean anything. This entry finds that root itself — walking up from the cwd
13
+ * to the first directory holding `.git` (a directory for a normal clone, a FILE for a worktree
14
+ * or submodule) — rather than taking a path the caller could point anywhere.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { readText, writeText, isFile, printOut } from '../lib/fs.mjs';
19
+ import { loadCatalog, catalogProblems, parseBrick } from './catalog.mjs';
20
+ import { checkGraph } from './graph.mjs';
21
+ import { parseNumstat, DEFAULT_PRESET } from './score.mjs';
22
+ import { gitRootOf, gitDirOf, currentBranch } from '../hosts/gitref.mjs';
23
+ import {
24
+ initState, nextStep, scoreDeclared, scoreDiff, recordOutcome, decideAwaiting,
25
+ writeLoopFile, parseLoopFile, LOOP_FILE,
26
+ } from './state.mjs';
27
+ import { recordLoop } from './registry.mjs';
28
+
29
+ const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
30
+ const stdin = () => (process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
31
+ const list = (s) => (s ? String(s).split(',').map((x) => x.trim()).filter(Boolean) : null);
32
+
33
+ function requireRoot() {
34
+ const root = gitRootOf(process.cwd());
35
+ if (!root) throw new Error('not inside a git repository');
36
+ return root;
37
+ }
38
+
39
+ /**
40
+ * When `score --diff`/`decide` closes a unit (`commit: true`), write the commit message to
41
+ * `<gitdir>/LOOP_COMMIT_MSG` — never tracked, never part of any diff — so the loop's own
42
+ * automation never has to quote a message on a command line (`js/hosts/hooks.mjs`'s
43
+ * `loopExempt` only whitelists `git commit -F <path>`).
44
+ *
45
+ * `gitdir` is RESOLVED BY THE CALLER, before `withState` ran the state change (X6, fix round
46
+ * 2): a malformed `.git` FILE makes `gitDirOf` throw, and that throw must abort the action
47
+ * before anything about the loop's state has moved, not after. Adds `message_file` to the
48
+ * result for the skill to read back, with forward slashes (`C:/...`) even on Windows — Git
49
+ * Bash and git itself both accept that form on every platform, and a backslash-laden path
50
+ * breaks when a later shell command tries to use it unquoted.
51
+ */
52
+ function writeCommitMessage(root, gitdir, result) {
53
+ if (!result || result.commit !== true) return result;
54
+ const branch = currentBranch(root);
55
+ const slug = branch && branch.startsWith('loop/') ? branch.slice('loop/'.length) : 'loop';
56
+ const file = path.join(gitdir, 'LOOP_COMMIT_MSG');
57
+ writeText(file, `loop(${slug}): iteration ${result.iteration} — ${result.intent || 'setup'}\n\n${result.trailers}\n`);
58
+ return { ...result, message_file: file.replaceAll('\\', '/') };
59
+ }
60
+
61
+ function withState(fn) {
62
+ const root = requireRoot();
63
+ const file = path.join(root, LOOP_FILE);
64
+ if (!isFile(file)) throw new Error(`${file} does not exist — run \`geneseed loop init\` first`);
65
+ const state = parseLoopFile(readText(file));
66
+ const { bricks } = loadCatalog({ projectRoot: root });
67
+ const result = fn(state, bricks);
68
+ writeLoopFile(file, state);
69
+ return result;
70
+ }
71
+
72
+ function resolveGraph(spec, catalog) {
73
+ if (spec && spec.endsWith('.json')) return JSON.parse(readText(path.resolve(spec)));
74
+ const g = catalog.templates.get(spec);
75
+ if (!g) throw new Error(`no loop template named ${JSON.stringify(spec)}`);
76
+ const { origin, ...graph } = g;
77
+ return graph;
78
+ }
79
+
80
+ const ACTIONS = {
81
+ check(args) {
82
+ if (args.brick) {
83
+ const { problems } = parseBrick(readText(path.resolve(args.brick)), 'checked');
84
+ return { ok: !problems.length, problems };
85
+ }
86
+ if (args.graph) {
87
+ const catalog = loadCatalog({ projectRoot: process.cwd() });
88
+ const problems = checkGraph(resolveGraph(args.graph, catalog), catalog.bricks);
89
+ return { ok: !problems.length, problems };
90
+ }
91
+ // The bare check doubles as the catalogue LISTING the loop skill composes a free graph
92
+ // from — frontmatter only: a brick's body reaches the model through `next`, for the node
93
+ // the engine chose, never as a menu.
94
+ const problems = catalogProblems({ projectRoot: process.cwd() });
95
+ const { bricks, templates, overridden } = loadCatalog({ projectRoot: process.cwd() });
96
+ const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
97
+ return {
98
+ ok: !problems.length, problems,
99
+ templates: [...templates.keys()].sort(byName),
100
+ bricks: [...bricks.values()].sort((a, b) => byName(a.name, b.name))
101
+ .map(({ body, available, reason, ...b }) => ({ ...b, available, ...(reason ? { reason } : {}) })),
102
+ overridden,
103
+ };
104
+ },
105
+ init(args) {
106
+ const root = requireRoot();
107
+ const file = path.join(root, LOOP_FILE);
108
+ if (isFile(file)) throw new Error(`${file} already exists — a loop is already running here`);
109
+ if (!args.title || !args.requirement || !args.graph) throw new Error('init needs --title, --requirement and --graph');
110
+ const catalog = loadCatalog({ projectRoot: root });
111
+ const graph = resolveGraph(args.graph, catalog);
112
+ const problems = checkGraph(graph, catalog.bricks);
113
+ if (problems.length) return { error: 'the graph does not pass loop check', problems };
114
+ const state = initState({
115
+ title: args.title, requirement: args.requirement, graph, preset: args.preset || DEFAULT_PRESET,
116
+ contracts: list(args.contracts) ?? [],
117
+ });
118
+ writeLoopFile(file, state);
119
+ // Best-effort: the registry is how the Active tab discovers this loop, but a registry
120
+ // hiccup must never fail the init the loop itself just succeeded at.
121
+ recordLoop({ root, branch: currentBranch(root), title: args.title });
122
+ return { file, graph: graph.name, preset: state.preset, status: state.status };
123
+ },
124
+ next: () => withState((s, b) => nextStep(s, b)),
125
+ score(args) {
126
+ if (args.diff) {
127
+ // X6: resolve (and validate) the gitdir BEFORE withState runs the state change, so a
128
+ // malformed `.git` file errors without advancing the loop.
129
+ const root = requireRoot();
130
+ const gitdir = gitDirOf(root);
131
+ return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin()))));
132
+ }
133
+ if (!args.declared) throw new Error('score needs --declared or --diff');
134
+ return withState((s) => scoreDeclared(s, {
135
+ actions: list(args.actions), writeSet: list(args.writeSet), intent: args.intent ?? null,
136
+ }));
137
+ },
138
+ record(args) {
139
+ if (!args.outcome) throw new Error('record needs --outcome');
140
+ if (args.note && args.noteFile) throw new Error('record takes --note or --note-file, not both');
141
+ let note = args.note ?? '';
142
+ if (args.noteFile) {
143
+ // A note too long for a shell argument, or one that mentions `git … commit`/`push` (and
144
+ // would otherwise sit inside the command line GIT_GATE_RE matches whole), goes through a
145
+ // file instead — never through the shell at all. CRLF-folded and trimmed the same way the
146
+ // porcelain stdin below is, so a Windows-authored file round-trips identically.
147
+ let raw;
148
+ try { raw = readText(path.resolve(args.noteFile)); } catch (e) {
149
+ throw new Error(`cannot read --note-file ${args.noteFile}: ${e.message}`, { cause: e });
150
+ }
151
+ note = raw.replace(/\r\n/g, '\n').trimEnd();
152
+ }
153
+ const card = args.card ? JSON.parse(args.card) : null;
154
+ const porcelain = stdin();
155
+ return withState((s, b) => recordOutcome(s, b, args.outcome, {
156
+ card, porcelain: porcelain ? porcelain.replace(/\r\n/g, '\n').trimEnd() : null, note,
157
+ }));
158
+ },
159
+ decide(args) {
160
+ // X6: same ordering as `score --diff` above.
161
+ const root = requireRoot();
162
+ const gitdir = gitDirOf(root);
163
+ return writeCommitMessage(root, gitdir, withState((s, b) => decideAwaiting(s, b, args.verdict, args.note ?? '')));
164
+ },
165
+ };
166
+
167
+ export function cmdLoop(args) {
168
+ try {
169
+ return emit(ACTIONS[args.action](args));
170
+ } catch (e) {
171
+ return emit({ error: e.message });
172
+ }
173
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The rules a loop graph must satisfy before a single node runs. ONE function, three callers:
3
+ * `geneseed loop check`, the doctor over the shipped templates, and `loop init` over a graph the
4
+ * model composed at run time — the composed graph never meets the doctor, so the engine has to
5
+ * carry the same check or free composition would be unchecked composition.
6
+ *
7
+ * WHY EVERY CYCLE NEEDS A DECLARED LOOP. Edges are free, so a graph can loop anywhere. A loop
8
+ * that terminates only because the model eventually reports a different outcome is a loop that
9
+ * may not terminate; a declared `max` is what turns "should stop" into "will stop". The check is
10
+ * structural (strongly connected components), so it needs no simulation and cannot miss a path.
11
+ *
12
+ * Problems are returned in a fixed order — per node, per edge, per outcome, then loops — so a
13
+ * test can write the expected list out instead of sorting it.
14
+ *
15
+ * WHY A CYCLE INSIDE AN ITERATION NEEDS ITS OWN LOOP. The whole-SCC check (rule 4) is satisfied
16
+ * by the iteration loop alone, since every node in an iteration is reachable from every other —
17
+ * but the runtime gives a node outside every *inner* loop exactly one entry per iteration (no
18
+ * re-entry at all), so a cycle that never reaches the iteration head and is not inside its own
19
+ * declared inner loop would hit that "max of 0" path by construction, not by a model's mistake.
20
+ * The static check has to refuse that graph, not just the ones the runtime would loop forever on.
21
+ */
22
+ import { WEIGHTS } from './score.mjs';
23
+
24
+ export const TERMINALS = new Set(['$close', '$stop']);
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'];
28
+
29
+ export function iterationLoop(graph) {
30
+ return (graph.loops ?? []).find((l) => l.iteration === true);
31
+ }
32
+
33
+ /** Tarjan — the strongly connected components of the node graph (terminals excluded). */
34
+ function components(nodes, edges) {
35
+ const succ = new Map(nodes.map((n) => [n, []]));
36
+ for (const e of edges) if (succ.has(e.from) && succ.has(e.to)) succ.get(e.from).push(e.to);
37
+ let index = 0;
38
+ const idx = new Map(); const low = new Map(); const stack = []; const on = new Set(); const out = [];
39
+ const visit = (v) => {
40
+ idx.set(v, index); low.set(v, index); index += 1; stack.push(v); on.add(v);
41
+ for (const w of succ.get(v)) {
42
+ if (!idx.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); }
43
+ else if (on.has(w)) low.set(v, Math.min(low.get(v), idx.get(w)));
44
+ }
45
+ if (low.get(v) === idx.get(v)) {
46
+ const comp = [];
47
+ for (;;) { const w = stack.pop(); on.delete(w); comp.push(w); if (w === v) break; }
48
+ const selfLoop = comp.length === 1 && succ.get(v).includes(v);
49
+ if (comp.length > 1 || selfLoop) out.push(comp.sort());
50
+ }
51
+ };
52
+ for (const n of nodes) if (!idx.has(n)) visit(n);
53
+ return out;
54
+ }
55
+
56
+ export function checkGraph(graph, bricks) {
57
+ const problems = [];
58
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
59
+ const edges = Array.isArray(graph.edges) ? graph.edges : [];
60
+ const loops = Array.isArray(graph.loops) ? graph.loops : [];
61
+ const nodeSet = new Set(nodes);
62
+
63
+ // rule 1 — nodes name available bricks
64
+ for (const n of nodes) {
65
+ const brick = bricks.get(n);
66
+ if (!brick) problems.push(`node ${n}: no brick named ${n}`);
67
+ else if (!brick.available) problems.push(`node ${n}: brick unavailable (${brick.reason})`);
68
+ }
69
+ // rule 1 — edge endpoints; rule 2 — edges use declared outcomes
70
+ for (const e of edges) {
71
+ const label = `edge ${e.from} --${e.on}--> ${e.to}`;
72
+ if (!nodeSet.has(e.from)) { problems.push(`${label}: ${e.from} is not a node`); continue; }
73
+ if (!nodeSet.has(e.to) && !TERMINALS.has(e.to)) {
74
+ problems.push(`${label}: ${e.to} is not a node, $close or $stop`);
75
+ continue;
76
+ }
77
+ const brick = bricks.get(e.from);
78
+ if (brick && !brick.outcomes.includes(e.on)) problems.push(`${label}: ${e.on} is not an outcome of ${e.from}`);
79
+ }
80
+ // rule 2 — exactly one edge per declared outcome (nodes in sorted order)
81
+ for (const n of [...nodes].sort()) {
82
+ const brick = bricks.get(n);
83
+ if (!brick) continue;
84
+ for (const o of brick.outcomes) {
85
+ const count = edges.filter((e) => e.from === n && e.on === o).length;
86
+ if (count === 0) problems.push(`node ${n}: no edge for outcome ${o}`);
87
+ else if (count > 1) problems.push(`node ${n}: ${count} edges for outcome ${o}`);
88
+ }
89
+ }
90
+ // rule 3 — start, and $close reachable
91
+ if (!nodeSet.has(graph.start)) {
92
+ problems.push(`start ${graph.start} is not a node`);
93
+ } else {
94
+ const seen = new Set([graph.start]); const queue = [graph.start];
95
+ while (queue.length) {
96
+ const v = queue.shift();
97
+ for (const e of edges) {
98
+ if (e.from === v && !seen.has(e.to)) { seen.add(e.to); if (nodeSet.has(e.to)) queue.push(e.to); }
99
+ }
100
+ }
101
+ if (!seen.has('$close')) problems.push(`$close is not reachable from ${graph.start}`);
102
+ }
103
+ // rule 5 — loops are well formed
104
+ for (const l of loops) {
105
+ if (!Number.isInteger(l.max) || l.max < 1) problems.push(`loop ${l.name}: max must be a positive integer`);
106
+ else if (l.iteration === true && l.max > ENGINE_MAX_ITERATIONS) {
107
+ problems.push(`loop ${l.name}: max ${l.max} is above the engine ceiling of ${ENGINE_MAX_ITERATIONS}`);
108
+ }
109
+ for (const n of l.nodes ?? []) if (!nodeSet.has(n)) problems.push(`loop ${l.name}: ${n} is not a node`);
110
+ }
111
+ const iterations = loops.filter((l) => l.iteration === true).length;
112
+ if (iterations !== 1) problems.push(`${iterations} loops are marked iteration; exactly one must be`);
113
+ // rule 4 — every cycle is inside a declared loop
114
+ for (const comp of components(nodes, edges)) {
115
+ const covered = loops.some((l) => comp.every((n) => (l.nodes ?? []).includes(n)));
116
+ if (!covered) problems.push(`cycle ${comp.join(', ')} has no declared loop around it`);
117
+ }
118
+ // rule 4b — a cycle that never reaches the iteration head needs its own non-iteration loop
119
+ const iterationLoops = loops.filter((l) => l.iteration === true);
120
+ if (iterationLoops.length === 1) {
121
+ const [iteration] = iterationLoops;
122
+ const head = iteration.nodes?.[0];
123
+ const innerEdges = edges.filter((e) => e.to !== head);
124
+ for (const comp of components(nodes, innerEdges)) {
125
+ const covered = loops.some((l) => l !== iteration && comp.every((n) => (l.nodes ?? []).includes(n)));
126
+ if (!covered) problems.push(`cycle ${comp.join(', ')} inside an iteration has no inner loop around it`);
127
+ }
128
+ // rule 4c — the iteration is entered only at its head: the runtime opens and closes units
129
+ // on re-entering nodes[0], so entering anywhere else runs a unit with no head behind it.
130
+ const inside = new Set(iteration.nodes ?? []);
131
+ if (head !== undefined) {
132
+ if (inside.has(graph.start) && graph.start !== head) {
133
+ problems.push(`start ${graph.start} is inside the iteration but is not its head ${head}`);
134
+ }
135
+ for (const e of edges) {
136
+ if (!inside.has(e.from) && inside.has(e.to) && e.to !== head) {
137
+ problems.push(`edge ${e.from} --${e.on}--> ${e.to} enters the iteration at ${e.to}, not at its head ${head}`);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ // template weight overrides
143
+ for (const k of Object.keys(graph.weights ?? {})) {
144
+ if (!Object.hasOwn(WEIGHTS, k)) problems.push(`weights: ${k} is not an action`);
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
+ }
162
+ return problems;
163
+ }
@@ -0,0 +1,196 @@
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` + `renameSync` so a crash mid-write cannot leave `loops.json` half-written where
11
+ * the NEXT read would see valid-but-truncated JSON instead of either the old file or the new
12
+ * one; and every public function swallows its own errors — a registry hiccup must never fail
13
+ * `loop init`, and a corrupt `loops.json` must read back as empty rather than throw. The one
14
+ * difference from `installs.json`: this file keys rows by object (`{root, branch, title,
15
+ * started, finishedAt}`), not by a bare path, because identity alone is not enough to render a
16
+ * card.
17
+ *
18
+ * NO `lastSeen`. The Active tab POLLS `activeLoops` on an interval, and a field refreshed on
19
+ * every read would make every poll a write — fine for `installs.json` (`record` fires once per
20
+ * emit), wrong here (the page's own `usePoll` fires every few seconds, for as long as the tab
21
+ * is open, on a machine that may be a laptop on battery). So `activeLoops` saves the registry
22
+ * back ONLY when the kept set actually differs from what it loaded — a root pruned, a loop
23
+ * newly seen finished, or a loop seen running again after having been finished — never merely
24
+ * to record that a poll happened. `finishedAt` replaces `lastSeen` for the one thing a
25
+ * timestamp was doing here: it is set ONCE, the first time a row is found WITHOUT a LOOP.md
26
+ * (the loop finished), and the 7-day prune window counts from there — not from a last-poll time
27
+ * nothing refreshes any more. A loop seen running again afterwards (the same worktree
28
+ * re-`init`ed) clears it, so a stale finished-window never survives into a loop that is running
29
+ * again.
30
+ *
31
+ * `recordLoop` and `activeLoops`/`setLoopPreset` never call each other's normalisation by
32
+ * accident: both resolve a root through `realpathSync.native` (falling back to `path.resolve`
33
+ * for a root that no longer exists) and compare with `normcase` — the same case-folding
34
+ * `js/lib/paths.mjs` uses everywhere a Windows path is compared — so a loop launched from a
35
+ * differently-cased spelling of the same worktree is still one row, not two.
36
+ */
37
+ import { readFileSync, mkdirSync, realpathSync, renameSync } from 'node:fs';
38
+ import path from 'node:path';
39
+
40
+ import { userLoopsDir } from './catalog.mjs';
41
+ import { writeText, readText, isDir, isFile } from '../lib/fs.mjs';
42
+ import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
43
+ import { normcase } from '../lib/paths.mjs';
44
+ import { parseLoopFile, writeLoopFile, LOOP_FILE } from './state.mjs';
45
+ import { PRESETS } from './score.mjs';
46
+
47
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
48
+
49
+ export function registryPath() {
50
+ return path.join(userLoopsDir(), 'loops.json');
51
+ }
52
+
53
+ function load() {
54
+ try {
55
+ const data = parseJson(readFileSync(registryPath(), 'utf8'));
56
+ return Array.isArray(data?.loops) ? data.loops.filter((l) => l && typeof l.root === 'string') : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ function save(loops) {
63
+ try {
64
+ const file = registryPath();
65
+ mkdirSync(path.dirname(file), { recursive: true });
66
+ const tmp = `${file}.tmp`;
67
+ writeText(tmp, `${jsonDumpsIndent({ loops })}\n`);
68
+ renameSync(tmp, file);
69
+ } catch {
70
+ /* best-effort: a registry hiccup must never fail init or a read */
71
+ }
72
+ }
73
+
74
+ function normalize(dir) {
75
+ try { return realpathSync.native(dir); } catch { return path.resolve(dir); }
76
+ }
77
+
78
+ const sameRoot = (a, b) => normcase(a) === normcase(b);
79
+
80
+ /** The soft-decision iterations of `state.history` — the same shape `state.mjs`'s own
81
+ * end-of-run `summary()` reports, so a card and a finished-loop review read the same fields. */
82
+ function reviewOf(state) {
83
+ return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
84
+ }
85
+
86
+ function runningRow(entry, state) {
87
+ return {
88
+ root: entry.root, branch: entry.branch, title: entry.title, started: entry.started,
89
+ status: state.status, iteration: state.iteration, node: state.node, preset: state.preset,
90
+ threshold: PRESETS[state.preset], current: state.current, history: state.history,
91
+ review: reviewOf(state), reason: state.reason, awaiting: state.awaiting, graph: state.graph,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Upsert `root`'s identity row. Called by `geneseed loop init` right after `LOOP.md` is
97
+ * written — `started` is set once, on insert, and never moved by a later call. An update never
98
+ * touches `finishedAt` either way: `activeLoops` is the only writer of that field, and it will
99
+ * clear it on the very next poll once it sees LOOP.md present again. Never throws: an `init`
100
+ * must succeed even when the registry itself cannot be written.
101
+ */
102
+ export function recordLoop({ root, branch, title, now = new Date() }) {
103
+ try {
104
+ const key = normalize(root);
105
+ const loops = load();
106
+ const idx = loops.findIndex((l) => sameRoot(l.root, key));
107
+ if (idx >= 0) {
108
+ loops[idx] = { ...loops[idx], root: key, branch, title };
109
+ } else {
110
+ loops.push({ root: key, branch, title, started: now.toISOString() });
111
+ }
112
+ save(loops);
113
+ } catch {
114
+ /* a registry hiccup must never fail loop init */
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The rows the Active tab renders, read fresh off every registered worktree's `LOOP.md` —
120
+ * never off the registry's own stale copy of a loop's state. Four outcomes per row; the
121
+ * write-back at the end fires ONLY when one of the first three actually changed something
122
+ * (see the module docblock on why a routine poll must not be a write):
123
+ * - the worktree root no longer exists: dropped, silently (a removed clone or worktree);
124
+ * - `<root>/LOOP.md` exists but fails to parse (corrupt, or written mid-crash before this
125
+ * module's own atomic writer existed): the entry is KEPT exactly as read — dropping it here
126
+ * would be indistinguishable from a removed worktree, and a card the user can still see
127
+ * beats one that silently vanishes — and the row reports `status: 'unreadable'`;
128
+ * - `<root>/LOOP.md` exists and parses: the row carries the state fields the page needs, and
129
+ * if the entry still carried a `finishedAt` from a previous finish it is cleared (the loop
130
+ * is running again — that counts as a change, and is saved);
131
+ * - `LOOP.md` is gone (the loop finished): the first time this is seen, `finishedAt` is set
132
+ * to `now` and saved; after that it is read-only here until `now` is more than 7 days past
133
+ * that frozen `finishedAt`, at which point the row is dropped (and the drop is saved).
134
+ */
135
+ export function activeLoops({ now = new Date() } = {}) {
136
+ const loops = load();
137
+ const kept = [];
138
+ const rows = [];
139
+ let changed = false;
140
+ for (const entry of loops) {
141
+ if (!isDir(entry.root)) { changed = true; continue; }
142
+ const file = path.join(entry.root, LOOP_FILE);
143
+ const identity = { root: entry.root, branch: entry.branch, title: entry.title, started: entry.started };
144
+ if (isFile(file)) {
145
+ let state;
146
+ try { state = parseLoopFile(readText(file)); } catch {
147
+ kept.push(entry);
148
+ rows.push({ ...identity, status: 'unreadable' });
149
+ continue;
150
+ }
151
+ let next = entry;
152
+ if (entry.finishedAt !== undefined) {
153
+ next = { ...entry }; delete next.finishedAt; changed = true;
154
+ }
155
+ kept.push(next);
156
+ rows.push(runningRow(next, state));
157
+ continue;
158
+ }
159
+ if (entry.finishedAt === undefined) {
160
+ const next = { ...entry, finishedAt: now.toISOString() };
161
+ changed = true;
162
+ kept.push(next);
163
+ rows.push({ ...identity, status: 'finished' });
164
+ continue;
165
+ }
166
+ const age = now.getTime() - new Date(entry.finishedAt).getTime();
167
+ if (age > SEVEN_DAYS_MS) { changed = true; continue; }
168
+ kept.push(entry);
169
+ rows.push({ ...identity, status: 'finished' });
170
+ }
171
+ if (changed) save(kept);
172
+ return rows;
173
+ }
174
+
175
+ /**
176
+ * Rewrite `root`'s `LOOP.md` `preset` field and nothing else — the per-loop preset picker on
177
+ * the Active tab. Refuses a `root` the registry does not know about (no arbitrary path
178
+ * writes — `POST /api/loops/preset`'s whole safety argument) and a `preset` outside `PRESETS`,
179
+ * each with its own message so the web action can tell the two refusals apart. Unlike
180
+ * `recordLoop`/`activeLoops`, this one DOES throw: a preset change the caller asked for and did
181
+ * not happen must be reported, not swallowed. Writes through `writeLoopFile` (atomic, like
182
+ * every other LOOP.md persist — see `state.mjs`'s docblock).
183
+ */
184
+ export function setLoopPreset(root, preset) {
185
+ if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
186
+ const key = normalize(root);
187
+ const loops = load();
188
+ const entry = loops.find((l) => sameRoot(l.root, key));
189
+ if (!entry || !isDir(entry.root)) throw new Error('unknown loop');
190
+ const file = path.join(entry.root, LOOP_FILE);
191
+ if (!isFile(file)) throw new Error('unknown loop');
192
+ const state = parseLoopFile(readText(file));
193
+ state.preset = preset;
194
+ writeLoopFile(file, state);
195
+ return runningRow(entry, state);
196
+ }