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/state.mjs CHANGED
@@ -21,14 +21,31 @@
21
21
  * action — is excluded from every score and every read-brick porcelain check: it is never part
22
22
  * of the work being scored, and including it would make every action's own bookkeeping look
23
23
  * like an undeclared file touch.
24
+ *
25
+ * `writeLoopFile` IS THE ONLY WRITER. Every call site that persists LOOP.md — `cli.mjs`'s
26
+ * `withState` and `init`, and `registry.mjs`'s `setLoopPreset` — writes through it rather than
27
+ * through a bare `writeText(file, renderLoopFile(state))`, so a process killed mid-write can
28
+ * never leave a torn, half-written LOOP.md for the next `loop next`/`score`/`record` to choke
29
+ * on. `LOOP_FILE_TMP` sits BESIDE `LOOP_FILE` in the worktree, not inside the gitdir: the gitdir
30
+ * is resolved by `gitDirOf`, which can THROW on a malformed worktree link (X6 in `cli.mjs`), and
31
+ * every one of these three call sites must still be able to rewrite LOOP.md when that resolution
32
+ * fails or was never attempted (`setLoopPreset` has no gitdir concept at all). A temp file beside
33
+ * LOOP.md needs no new failure mode to reason about instead — it is exposed to `git status`, but
34
+ * only for the instant between the write and the rename (ordinarily unobservable; a crash in
35
+ * that window is the one case it lingers), and `LOOP_FILE_TMP` joins `LOOP_FILE` in both
36
+ * `filterPorcelain` and `scoreDiff`'s exclusion below for exactly that case: a lingering
37
+ * `LOOP.md.tmp` is the engine's own bookkeeping too, never scored work.
24
38
  */
39
+ import { renameSync, unlinkSync } from 'node:fs';
40
+ import { writeText } from '../lib/fs.mjs';
25
41
  import { declaredRisk, actualRisk, decide, worst, PRESETS, DEFAULT_PRESET } from './score.mjs';
26
42
  import { iterationLoop, ENGINE_MAX_ITERATIONS } from './graph.mjs';
27
43
 
28
44
  const BEGIN = '<!-- loop-state:begin -->';
29
45
  const END = '<!-- loop-state:end -->';
30
- export const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
46
+ const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
31
47
  export const LOOP_FILE = 'LOOP.md';
48
+ const LOOP_FILE_TMP = `${LOOP_FILE}.tmp`;
32
49
  const slash = (p) => String(p).replaceAll('\\', '/');
33
50
 
34
51
  export function renderLoopFile(state) {
@@ -38,6 +55,41 @@ export function renderLoopFile(state) {
38
55
  + `${BEGIN}\n\`\`\`json\n${JSON.stringify(state, null, 2)}\n\`\`\`\n${END}\n`;
39
56
  }
40
57
 
58
+ const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
59
+
60
+ /** Synchronous back-off between rename attempts — `Atomics.wait` on a throwaway
61
+ * `SharedArrayBuffer`, the stdlib's own blocking sleep with no dependency. */
62
+ function backoff(ms) {
63
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
64
+ }
65
+
66
+ /**
67
+ * Rename `tmp` to `file`, retrying up to 3 attempts total on a transient Windows lock
68
+ * (EPERM/EACCES/EBUSY — an AV scanner or editor briefly holding the handle open) with a short
69
+ * back-off between attempts. A non-retryable error, or the 3rd retryable failure, removes the
70
+ * temp file (best-effort — a cleanup failure must not mask the real error) and rethrows.
71
+ * `renameFn` is an injectable seam for tests; production never passes it.
72
+ */
73
+ export function renameWithRetry(tmp, file, renameFn = renameSync) {
74
+ for (let attempt = 1; ; attempt += 1) {
75
+ try {
76
+ renameFn(tmp, file);
77
+ return;
78
+ } catch (e) {
79
+ if (attempt < 3 && RETRYABLE_RENAME_CODES.has(e.code)) { backoff(25 * attempt); continue; }
80
+ try { unlinkSync(tmp); } catch { /* best-effort cleanup; the rename error is what matters */ }
81
+ throw e;
82
+ }
83
+ }
84
+ }
85
+
86
+ /** `<file>.tmp` then `renameWithRetry` — the atomic write every LOOP.md persist goes through. */
87
+ export function writeLoopFile(file, state) {
88
+ const tmp = `${file}.tmp`;
89
+ writeText(tmp, renderLoopFile(state));
90
+ renameWithRetry(tmp, file);
91
+ }
92
+
41
93
  export function parseLoopFile(text) {
42
94
  const t = String(text).replace(/\r\n/g, '\n');
43
95
  const a = t.indexOf(BEGIN); const b = t.lastIndexOf(END);
@@ -49,16 +101,21 @@ export function parseLoopFile(text) {
49
101
  const head = (state) => iterationLoop(state.graph).nodes[0];
50
102
  const fmt = (n) => Number(n).toFixed(1);
51
103
 
52
- export function initState({ title, requirement, graph, preset = DEFAULT_PRESET }) {
53
- if (!PRESETS[preset]) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
104
+ /**
105
+ * `contracts`: extra globs (`loop init --contracts`), appended after the graph's own. Every
106
+ * field the engine ever writes is listed here, `gateAmends` included (`decideGate` still reads
107
+ * it with `?.`, for a LOOP.md written before it was listed).
108
+ */
109
+ export function initState({ title, requirement, graph, preset = DEFAULT_PRESET, contracts = [] }) {
110
+ if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
54
111
  const loop = iterationLoop(graph);
55
112
  const state = {
56
- v: 1, title, requirement, preset, contracts: [], graph, node: graph.start,
113
+ v: 1, title, requirement, preset, contracts: [...(graph.contracts ?? []), ...contracts], graph, node: graph.start,
57
114
  status: 'running', awaiting: null, reason: null,
58
115
  iteration: loop.nodes.includes(graph.start) ? 1 : 0,
59
116
  card: null, validated: false, pendingVerify: false, closing: false, counters: {},
60
117
  resplit: false, emptyStreak: 0, snapshot: '', visited: [], tests: 'n/a', current: {},
61
- notes: [], history: [],
118
+ notes: [], history: [], gateAmends: {},
62
119
  };
63
120
  if (preset === 'prudent') { state.status = 'awaiting'; state.awaiting = { kind: 'launch' }; }
64
121
  return state;
@@ -74,12 +131,14 @@ function resetUnit(state) {
74
131
  state.current = {}; state.tests = 'n/a';
75
132
  }
76
133
 
134
+ /** The soft-decision iterations of `state.history` — what the end-of-run summary and the
135
+ * registry's Active-tab row both call `review`, so the two read the same fields. */
136
+ export function reviewOf(state) {
137
+ return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
138
+ }
139
+
77
140
  function summary(state) {
78
- return {
79
- iterations: state.history.length,
80
- review: state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent })),
81
- notes: state.notes,
82
- };
141
+ return { iterations: state.history.length, review: reviewOf(state), notes: state.notes };
83
142
  }
84
143
 
85
144
  export function trailers(state) {
@@ -93,6 +152,7 @@ export function trailers(state) {
93
152
  `Loop-Threshold: ${state.preset} ${fmt(silent)}/${fmt(soft)}`,
94
153
  `Loop-Decision: ${state.current.decision ?? 'silent'}${state.current.amended ? ' (amended)' : ''}`,
95
154
  `Loop-Tests: ${state.tests}`,
155
+ ...(state.current.gates ? [`Loop-Gates: ${state.current.gates.join(',')}`] : []),
96
156
  ].join('\n');
97
157
  }
98
158
 
@@ -102,6 +162,7 @@ function finishUnit(state, committed) {
102
162
  iteration: state.iteration, bricks: [...new Set(state.visited)],
103
163
  declared: state.current.declared ?? 0, actual: state.current.actual ?? state.current.declared ?? 0,
104
164
  decision: state.current.decision ?? 'silent', tests: state.tests, intent: state.card?.intent ?? '',
165
+ ...(state.current.gates ? { gates: state.current.gates } : {}),
105
166
  });
106
167
  }
107
168
  // The unit is committed (or was empty): the tree is clean again, so the read-brick check
@@ -139,6 +200,10 @@ export function nextStep(state, bricks) {
139
200
  node: state.node, iteration: state.iteration, effect: brick.effect, agent: brick.agent,
140
201
  skill: brick.skill, outcomes: brick.outcomes, prompt: brick.body, card: state.card,
141
202
  notes: state.notes, validate: brick.effect === 'mutate' && !state.validated,
203
+ // The template's own rules (a cited proof before a deletion, no retries as a flake fix) are
204
+ // its `rules` list; every brick gets them, not only the head that reads LOOP.md. Never the
205
+ // description: that is the catalogue blurb, and sending it would dilute the rules.
206
+ ...(state.graph.rules?.length ? { rules: state.graph.rules } : {}),
142
207
  };
143
208
  }
144
209
 
@@ -170,9 +235,9 @@ export function scoreDiff(state, files) {
170
235
  assertRunning(state);
171
236
  if (!state.pendingVerify) throw new Error('no iteration to verify: `geneseed loop next` has not asked for one');
172
237
  if (!Array.isArray(state.contracts) || state.contracts.some((c) => typeof c !== 'string')) {
173
- throw new Error('LOOP.md: contracts must be a list of file paths');
238
+ throw new Error('LOOP.md: contracts must be a list of file paths or globs');
174
239
  }
175
- const scored = files.filter((f) => slash(f.file) !== LOOP_FILE);
240
+ const scored = files.filter((f) => slash(f.file) !== LOOP_FILE && slash(f.file) !== LOOP_FILE_TMP);
176
241
  if (!scored.length) {
177
242
  // A closing unit (reached $close with a mutate already visited) finishes as done even
178
243
  // when this is its second empty diff in a row: there is nothing left to retry into.
@@ -184,7 +249,8 @@ export function scoreDiff(state, files) {
184
249
  }
185
250
  state.emptyStreak = 0;
186
251
  const { score, reasons } = actualRisk(state.current.declared ?? 0, scored, {
187
- writeSet: state.card?.writeSet ?? [], contracts: state.contracts ?? [], overrides: state.graph.weights,
252
+ writeSet: state.card?.writeSet ?? [], contracts: state.contracts ?? [],
253
+ ignoreDeletions: state.graph.ignoreDeletions ?? [], overrides: state.graph.weights,
188
254
  });
189
255
  const decision = decide(score, state.preset);
190
256
  state.current.actual = score;
@@ -223,25 +289,44 @@ function allowedEntries(graph, v) {
223
289
  }
224
290
 
225
291
  // Porcelain lines are `XY <path>`; strip the 2-char status + space, slash-normalise, drop
226
- // LOOP.md rows (the engine rewrites it on every action — it is never part of the scored work).
292
+ // LOOP.md rows (the engine rewrites it on every action — it is never part of the scored work)
293
+ // and a lingering LOOP.md.tmp (the atomic writer's own sibling — see `writeLoopFile`'s docblock).
227
294
  function filterPorcelain(porcelain) {
228
295
  const lines = porcelain.split(/\r?\n/).filter((line) => {
229
296
  if (!line) return false;
230
297
  const path = line.slice(3).replace(/^"|"$/g, '');
231
- return slash(path) !== LOOP_FILE;
298
+ return slash(path) !== LOOP_FILE && slash(path) !== LOOP_FILE_TMP;
232
299
  });
233
300
  return lines.join('\n').trimEnd();
234
301
  }
235
302
 
236
303
  // Setup work (iteration 0, graph.start outside the iteration loop) is its own unit; rejecting
237
304
  // it has nowhere to retry back into, so it stops the loop instead of looping to the head.
305
+ // Iteration 0 alone says so: `initState` starts at 0 only when graph.start is outside the
306
+ // iteration loop, and `iteration` only ever counts up from there.
238
307
  function isSetupUnit(state) {
239
- return state.iteration === 0 && !iterationLoop(state.graph).nodes.includes(state.graph.start);
308
+ return state.iteration === 0;
240
309
  }
241
310
 
242
- export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null } = {}) {
311
+ const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
312
+
313
+ // A card arrives from the model as JSON (`record --card`), so its shape is checked before
314
+ // anything is stored: a string `writeSet` stored here only throws later, inside `score --diff`,
315
+ // with `pendingVerify` already set — and `score --declared` refuses while that is set, so
316
+ // nothing could get the loop moving again.
317
+ function cardProblem(card) {
318
+ if (typeof card !== 'object' || Array.isArray(card)) return 'the card must be a JSON object';
319
+ if (!isStringList(card.actions)) return "the card's actions must be a list of strings";
320
+ if (!isStringList(card.writeSet)) return "the card's writeSet must be a list of strings";
321
+ if (card.intent != null && typeof card.intent !== 'string') return "the card's intent must be a string";
322
+ return null;
323
+ }
324
+
325
+ export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null, note = '' } = {}) {
243
326
  assertRunning(state);
244
327
  if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
328
+ const problem = card === null ? null : cardProblem(card);
329
+ if (problem) throw new Error(problem);
245
330
  const from = state.node;
246
331
  const brick = bricks.get(from);
247
332
  if (!brick) throw new Error(`no brick named ${from} in the catalogue`);
@@ -258,11 +343,33 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
258
343
  if (filteredPorcelain !== null) state.snapshot = filteredPorcelain;
259
344
  if (card) state.card = card;
260
345
  if (from === 'test') state.tests = outcome;
346
+ // A finding a brick needs to survive the unit (the ordered plan `plan` produced, `review`'s
347
+ // `fail` findings) — pushed before the transition below, so it lands in `notes` even when
348
+ // this call closes the unit, re-splits, or stops the loop. `notes` is never reset mid-run
349
+ // (only `exhaust`'s own push and `decideAwaiting` add to it otherwise), so it survives
350
+ // `resetUnit`/`finishUnit` — which a card does not: `resetUnit` clears it.
351
+ if (note) state.notes.push(`iteration ${state.iteration} (${from}): ${note}`);
261
352
  state.visited.push(from);
262
353
 
263
354
  const edge = state.graph.edges.find((e) => e.from === from && e.on === outcome);
264
355
  if (!edge) throw new Error(`no edge from ${from} on ${outcome}`);
265
- if (edge.to === '$stop') return stop(state, `${from} reported ${outcome}`);
356
+ if (brick.gate === 'human' && (!brick.gateOn || brick.gateOn.includes(outcome))) {
357
+ state.status = 'awaiting';
358
+ state.awaiting = { kind: 'gate', node: from, outcome, ...(note ? { note } : {}) };
359
+ return { awaiting: state.awaiting };
360
+ }
361
+ return transition(state, bricks, from, edge);
362
+ }
363
+
364
+ /**
365
+ * Follow `edge` out of `from` — the second half of `recordOutcome`, and what `decide ok` runs on
366
+ * a held gate. THE GATE HOLDS THE WHOLE TRANSITION: whatever the edge would do (ask for the diff
367
+ * score, close the unit, stop, count a re-entry and exhaust a ring) happens only here, after the
368
+ * user's `ok` — never before it, so nothing is scored, committed, re-split or stopped by the
369
+ * transition while the gate waits. A held `$stop` edge still waits: the user may `amend` it.
370
+ */
371
+ function transition(state, bricks, from, edge) {
372
+ if (edge.to === '$stop') return stop(state, `${from} reported ${edge.on}`);
266
373
  const mutated = state.visited.some((n) => bricks.get(n)?.effect === 'mutate');
267
374
  if (edge.to === '$close') {
268
375
  if (mutated) { state.closing = true; state.pendingVerify = true; return { verify: true }; }
@@ -288,8 +395,10 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
288
395
  export function decideAwaiting(state, bricks, verdict, note = '') {
289
396
  if (state.status !== 'awaiting') throw new Error('nothing is awaiting a decision');
290
397
  if (!['ok', 'no', 'amend'].includes(verdict)) throw new Error(`verdict must be ok, no or amend, not ${JSON.stringify(verdict)}`);
291
- const { kind } = state.awaiting;
398
+ const held = state.awaiting;
399
+ const { kind } = held;
292
400
  state.awaiting = null; state.status = 'running';
401
+ if (kind === 'gate') return decideGate(state, bricks, verdict, note, held);
293
402
  if (note) state.notes.push(`iteration ${state.iteration} (${kind}, ${verdict}): ${note}`);
294
403
  if (kind === 'launch') {
295
404
  return verdict === 'ok' ? { resumed: true } : stop(state, `graph ${verdict === 'no' ? 'rejected' : 'to amend'}${note ? `: ${note}` : ''}`);
@@ -320,3 +429,32 @@ export function decideAwaiting(state, bricks, verdict, note = '') {
320
429
  resetUnit(state); state.node = head(state); state.snapshot = '';
321
430
  return { discard: true };
322
431
  }
432
+
433
+ const GATE_MAX_AMENDS = 3;
434
+
435
+ /**
436
+ * The answer to a held `gate: human` transition. Every verdict leaves a note — with or without
437
+ * the user's words — so the run's notes say each gate was answered and how. `ok` follows the held
438
+ * edge and lists the node under the unit's `Loop-Gates`; `no` stops; `amend` re-runs the same
439
+ * node with the note in `notes` (its `validated` untouched — a mutate gate need not re-score —
440
+ * and its `counters` reset),
441
+ * `GATE_MAX_AMENDS` times per node per iteration (`gateAmends` is keyed `node@iteration` and never
442
+ * reset, so the next iteration's card — a new ADR, say — gets its own budget), and the next
443
+ * one stops: three rounds of review that did not converge are a conversation, not a loop.
444
+ */
445
+ function decideGate(state, bricks, verdict, note, { node, outcome }) {
446
+ state.notes.push(`iteration ${state.iteration} (gate at ${node}, ${verdict})${note ? `: ${note}` : ''}`);
447
+ if (verdict === 'no') return stop(state, `gate rejected at ${node}${note ? `: ${note}` : ''}`);
448
+ if (verdict === 'amend') {
449
+ const key = `${node}@${state.iteration}`;
450
+ state.gateAmends = { ...state.gateAmends, [key]: (state.gateAmends?.[key] ?? 0) + 1 };
451
+ if (state.gateAmends[key] > GATE_MAX_AMENDS) return stop(state, `gate amended ${GATE_MAX_AMENDS} times at ${node}`);
452
+ // As an `actual` amend: the amendment changes what the unit does, so the ring budget
453
+ // restarts; the amend cap above still bounds the unit.
454
+ state.counters = {};
455
+ state.node = node;
456
+ return { resumed: true };
457
+ }
458
+ state.current.gates = [...new Set([...(state.current.gates ?? []), node])]; // once each, as Loop-Bricks
459
+ return transition(state, bricks, node, state.graph.edges.find((e) => e.from === node && e.on === outcome));
460
+ }
@@ -30,7 +30,7 @@
30
30
  import { readdirSync, unlinkSync } from 'node:fs';
31
31
  import path from 'node:path';
32
32
 
33
- import { memoryDropIndex } from '../hosts/hooks.mjs';
33
+ import { memoryDropIndex } from '../hosts/memory-files.mjs';
34
34
  import { resolveMemoryDir } from '../hosts/hosts.mjs';
35
35
  import { printOut, printErr, isFile } from '../lib/fs.mjs';
36
36
 
@@ -35,17 +35,14 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:
35
35
  import path from 'node:path';
36
36
 
37
37
  import { main as driverMain } from '../build/driver.mjs';
38
- import { PACK_ORDER, ROOT } from '../build/source.mjs';
38
+ import { ROOT } from '../build/source.mjs';
39
+ import { CLAUDE_STYLE } from '../hosts/hosts.mjs';
40
+ import { EMIT_HOST_SCOPE, installState, installTargets, readMaybe } from '../hosts/installs.mjs';
41
+ import { installProfile } from '../build/generate.mjs';
39
42
  import {
40
- EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
41
- footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
42
- trustOfDir,
43
- } from '../hosts/installs.mjs';
44
- import { DEFAULT_EMIT, setupBuildArgs } from '../build/generate.mjs';
45
- import { DEFAULT_PRESET } from '../loop/score.mjs';
46
- import {
47
- autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
48
- } from '../hosts/settings.mjs';
43
+ autostartPaths, autostartStale, hookShimPath, migrateShape, shimHome,
44
+ } from '../hosts/shim.mjs';
45
+ import { loadJsonObject } from '../hosts/settings.mjs';
49
46
  import { writeText, printOut, printErr } from '../lib/fs.mjs';
50
47
 
51
48
  /** Every emit name the generator answers to — the set an unrecognised marker is NOT in. */
@@ -61,21 +58,20 @@ const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
61
58
  * host-parity rule asks for.
62
59
  */
63
60
  function hookSettingsFile(root, host, scope) {
64
- if (!['claude', 'bob', 'openclaude'].includes(host)) return null;
61
+ if (!CLAUDE_STYLE.includes(host)) return null;
65
62
  const name = scope === 'project' && host !== 'bob' ? 'settings.local.json' : 'settings.json';
66
63
  return path.join(root, name);
67
64
  }
68
65
 
69
66
  /** Every hook command string in a settings file, or [] when it cannot be read or parsed. */
70
67
  function hookCommandsOf(file) {
71
- // `readJsonc`, NOT `JSON.parse`. A hand-edited settings.json with comments and a trailing
72
- // comma is the one shape no machine in this project writes, and strict JSON rejects it —
68
+ // `loadJsonObject` (JSONC), NOT `JSON.parse`. A hand-edited settings.json with comments and a
69
+ // trailing comma is the one shape no machine in this project writes, and strict JSON rejects it —
73
70
  // which would classify a wired install as `none` and make `migrate` skip the machine
74
71
  // silently. `mergeClaudeSettings` already reads through comments for exactly this reason.
75
72
  // Found by the JSONC corpus fixture; a generated fixture would have hidden it.
76
- let loaded;
77
- try { [loaded] = readJsonc(readFileSync(file, 'utf8')); } catch { return []; }
78
- if (!loaded || typeof loaded !== 'object') return [];
73
+ const { state, data: loaded } = loadJsonObject(file);
74
+ if (state !== 'ok') return [];
79
75
  const hooks = loaded.hooks;
80
76
  if (!hooks || typeof hooks !== 'object') return [];
81
77
  const out = [];
@@ -210,21 +206,13 @@ export function cmdMigrate(args = {}) {
210
206
  try { rmSync(stash, { recursive: true, force: true }); } catch { /* nothing to undo */ }
211
207
  };
212
208
 
213
- // Step 3 — re-emit, in each install's OWN six values. Same reads as `cmdRebuildAll`, and
214
- // the pack selection is the sixth: leaving it out re-emitted a narrowed install at whatever
215
- // `harness.config.json` said, which WIDENS a constitution its owner had cut down (and, the
216
- // other way, can strip the consent gate). `null` — a pre-marker carrier, which on a MIGRATION
217
- // is the common case and not the rare one — resolves to ALL packs through
218
- // `doctrinesForBuild`, never to `harness.config.json`, which is what makes it fail-closed.
209
+ // Step 3 — re-emit, in each install's OWN values: `installProfile`, the one reading
210
+ // `rebuild-all` and `status` share. This loop hand-copied it once and drifted twice — it
211
+ // passed the excluded rules as `null` (re-admitting every rule an owner switched off,
212
+ // `process 5` among them) and dropped `--config-dir`, so a global found through
213
+ // `CLAUDE_CONFIG_DIR`/`OPENCODE_CONFIG_DIR` re-emitted into the DEFAULT dir instead.
219
214
  for (const r of rows) {
220
- const marker = r.marker && (EMIT_HOST_SCOPE.get(r.marker) ?? ['', ''])[0] === r.host
221
- ? r.marker : '';
222
- const emit = marker || DEFAULT_EMIT.get(`${r.host} ${r.scope}`) || 'opencode-global';
223
- const theme = themeOfDir(r.root) || defaultTheme();
224
- const out = r.scope === 'global' ? null : r.root;
225
- const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
226
- postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
227
- doctrinesForBuild(r.root), PACK_ORDER, null, trustOfDir(r.root) || DEFAULT_PRESET);
215
+ const { emit, theme, argv } = installProfile(r.host, r.scope, r.root);
228
216
  const label = `${r.host}:${r.scope} (${r.root})`;
229
217
  printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
230
218
  let rc = 1;
@@ -519,7 +519,7 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
519
519
  lines.push(['ok', `${f.hookGroups} hook groups wired in ${f.settings}`]);
520
520
  if (!process.env.GENESEED_LLM) {
521
521
  lines.push(['info', 'memory learning is off until you set GENESEED_LLM '
522
- + '(see docs/reference/environment.md)']);
522
+ + '(see docs/reference/env-context.md)']);
523
523
  }
524
524
  }
525
525
  if (emit === 'files') {