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,182 @@
1
+ /**
2
+ * The loop's risk arithmetic — pure, and imported by the build driver (the `--trust` choices),
3
+ * so it must stay free of every import but node builtins: the driver's closure bans
4
+ * child_process outright.
5
+ *
6
+ * WHY MAX AND NOT A SUM. A block that renames ten things and changes one public contract is a
7
+ * contract change; adding the renames up would let ten trivial edits outvote one critical one
8
+ * in the wrong direction — or, with a sum, let a pile of trivia trip a blocking stop that no
9
+ * single edit deserves. One critical action makes the block critical, nothing else does.
10
+ *
11
+ * WHY THE DIFF CAN ONLY RAISE THE SCORE. The declared score is the identify brick's account of
12
+ * what it is about to do; the diff is what it did. The account may be optimistic, never the
13
+ * other way round, so the actual score is max(declared, escalations) — an iteration never earns
14
+ * a lower tier by under-delivering.
15
+ */
16
+ import { Buffer } from 'node:buffer';
17
+
18
+ export const WEIGHTS = Object.freeze({
19
+ format: 0.1, imports: 0.1, rename: 0.2, logic: 0.4, 'new-file': 0.4,
20
+ api: 0.8, delete: 0.8, architecture: 0.9,
21
+ });
22
+
23
+ /** [silent ceiling, soft ceiling] — above the second is blocking. */
24
+ export const PRESETS = Object.freeze({
25
+ prudent: Object.freeze([0.1, 0.3]),
26
+ balanced: Object.freeze([0.2, 0.6]),
27
+ aggressive: Object.freeze([0.4, 0.8]),
28
+ });
29
+ export const DEFAULT_PRESET = 'balanced';
30
+
31
+ /** More deleted lines than this in one iteration counts as a code deletion. */
32
+ export const DELETION_LINES = 20;
33
+
34
+ const RANK = { silent: 0, soft: 1, blocking: 2 };
35
+
36
+ export function declaredRisk(actions, overrides = {}) {
37
+ const w = { ...WEIGHTS, ...overrides };
38
+ let score = 0;
39
+ for (const a of actions) {
40
+ if (!Object.hasOwn(w, a)) {
41
+ throw new Error(`unknown action ${JSON.stringify(a)} — one of ${Object.keys(w).join(', ')}`);
42
+ }
43
+ score = Math.max(score, w[a]);
44
+ }
45
+ return score;
46
+ }
47
+
48
+ const C_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
49
+
50
+ /**
51
+ * Undo git's C-quoting (core.quotepath, on by default): `"caf\303\251.js"` is `café.js`, its
52
+ * octal escapes being the UTF-8 bytes. Decoded here rather than by passing `-c core.quotepath=off`
53
+ * in the skill's verify command, so every caller of `score --diff` is covered, not just the one
54
+ * the skill types.
55
+ */
56
+ function unquote(s) {
57
+ if (s.length < 2 || !s.startsWith('"') || !s.endsWith('"')) return s;
58
+ const body = s.slice(1, -1);
59
+ const bytes = [];
60
+ for (let i = 0; i < body.length; i += 1) {
61
+ if (body[i] !== '\\') { bytes.push(...Buffer.from(body[i])); continue; }
62
+ const octal = /^[0-7]{3}/.exec(body.slice(i + 1));
63
+ if (octal) { bytes.push(Number.parseInt(octal[0], 8)); i += 3; continue; }
64
+ i += 1;
65
+ bytes.push(C_ESCAPES[body[i]] ?? body.charCodeAt(i));
66
+ }
67
+ return Buffer.from(bytes).toString('utf8');
68
+ }
69
+
70
+ /**
71
+ * A numstat path, as [new, old] for a rename or [path] otherwise. Git prints a rename as
72
+ * `pre/{a => b}/post` (either side may be empty, leaving a `//` to collapse), or as `old => new`
73
+ * — always that form, each side quoted, when either side needs quoting.
74
+ */
75
+ function numstatPaths(raw) {
76
+ const brace = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(raw);
77
+ if (brace) {
78
+ const [, pre, a, b, post] = brace;
79
+ const join = (mid) => `${pre}${mid}${post}`.replace(/\/{2,}/g, '/').replace(/^\//, '');
80
+ return [join(b), join(a)];
81
+ }
82
+ const arrow = raw.indexOf(' => ');
83
+ if (arrow >= 0) return [unquote(raw.slice(arrow + 4)), unquote(raw.slice(0, arrow))];
84
+ return [unquote(raw)];
85
+ }
86
+
87
+ /**
88
+ * `git diff --numstat` rows. A binary file reports `-` for both counts; it counts as 0. A rename
89
+ * yields BOTH paths — the new one with the counts, the old one with 0/0 — so a rename out of or
90
+ * into the write set is checked on each side without counting its lines twice.
91
+ */
92
+ export function parseNumstat(text) {
93
+ const rows = [];
94
+ for (const line of String(text).split(/\r?\n/)) {
95
+ const m = /^(\S+)\t(\S+)\t(.+)$/.exec(line);
96
+ if (!m) continue;
97
+ const n = (s) => (s === '-' ? 0 : Number.parseInt(s, 10) || 0);
98
+ const [file, old] = numstatPaths(m[3]);
99
+ rows.push({ file, added: n(m[1]), deleted: n(m[2]) });
100
+ if (old !== undefined) rows.push({ file: old, added: 0, deleted: 0 });
101
+ }
102
+ return rows;
103
+ }
104
+
105
+ const slash = (p) => p.replaceAll('\\', '/');
106
+
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
+ } = {}) {
140
+ const w = { ...WEIGHTS, ...overrides };
141
+ let score = declared;
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.
147
+ const inSet = new Set(writeSet.map(slash));
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));
150
+ if (outside.length) {
151
+ score = Math.max(score, 0.8);
152
+ reasons.push(`outside the write set: ${outside.map((f) => slash(f.file)).join(', ')}`);
153
+ }
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);
156
+ if (deleted > DELETION_LINES) {
157
+ score = Math.max(score, w.delete);
158
+ reasons.push(`${deleted} lines deleted`);
159
+ }
160
+ const hit = files.filter((f) => matchesAny(contracts, f.file));
161
+ if (hit.length) {
162
+ score = Math.max(score, w.api);
163
+ reasons.push(`contract files: ${hit.map((f) => slash(f.file)).join(', ')}`);
164
+ }
165
+ return { score, reasons };
166
+ }
167
+
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
+ }
172
+ const t = PRESETS[preset];
173
+ if (score <= t[0]) return 'silent';
174
+ if (score <= t[1]) return 'soft';
175
+ return 'blocking';
176
+ }
177
+
178
+ export function worst(a, b) {
179
+ if (a === undefined || a === null) return b;
180
+ if (b === undefined || b === null) return a;
181
+ return RANK[a] >= RANK[b] ? a : b;
182
+ }
@@ -0,0 +1,436 @@
1
+ /**
2
+ * The loop engine: a state machine over a brick graph, persisted as a JSON block in LOOP.md.
3
+ *
4
+ * WHY THE MODEL NEVER DECIDES. Every transition, ceiling, score and stop is computed here; the
5
+ * `loop` skill only runs the node it is handed and reports the brick's outcome. That is what
6
+ * makes four hosts behave identically, and what makes a stopped loop explainable afterwards.
7
+ *
8
+ * WHY A BLOCKING STOP ENDS THE RUN. A background task cannot wait for a person. So "blocking"
9
+ * is `status: awaiting` written to LOOP.md and the run ending; the user's answer relaunches it
10
+ * through `decide`. Crash recovery is the same path: relaunch from the branch and LOOP.md.
11
+ *
12
+ * UNITS. Work closes into a commit whenever the graph re-enters the iteration loop's head, so
13
+ * setup work before the loop (a reproduction test) is its own unit — iteration 0 — and never
14
+ * pollutes iteration 1's write set. Every cycle inside a unit is bounded by re-entry counting:
15
+ * `counters` is keyed by node name (not loop name), incremented on every transition into a
16
+ * non-terminal, non-head node, and capped at `1 + the largest max of a non-iteration loop
17
+ * containing that node` (or just `1` — no re-entry at all — when no such loop covers it). That
18
+ * bounds a cycle through a node the model forgot to put in an inner loop (a sibling brick
19
+ * looping back into a retry node it isn't declared inside), not only the loop the author
20
+ * remembered to declare. LOOP.md itself — the engine's own state file, rewritten by every CLI
21
+ * action — is excluded from every score and every read-brick porcelain check: it is never part
22
+ * of the work being scored, and including it would make every action's own bookkeeping look
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.
38
+ */
39
+ import { renameSync, unlinkSync } from 'node:fs';
40
+ import { writeText } from '../lib/fs.mjs';
41
+ import { declaredRisk, actualRisk, decide, worst, PRESETS, DEFAULT_PRESET } from './score.mjs';
42
+ import { iterationLoop, ENGINE_MAX_ITERATIONS } from './graph.mjs';
43
+
44
+ const BEGIN = '<!-- loop-state:begin -->';
45
+ const END = '<!-- loop-state:end -->';
46
+ export const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
47
+ export const LOOP_FILE = 'LOOP.md';
48
+ export const LOOP_FILE_TMP = `${LOOP_FILE}.tmp`;
49
+ const slash = (p) => String(p).replaceAll('\\', '/');
50
+
51
+ export function renderLoopFile(state) {
52
+ return `# Loop — ${state.title}\n\n${state.requirement}\n\n`
53
+ + 'The block below belongs to the engine. You may change `preset` (prudent, balanced, '
54
+ + 'aggressive) or `contracts`: they apply from the next score on.\n\n'
55
+ + `${BEGIN}\n\`\`\`json\n${JSON.stringify(state, null, 2)}\n\`\`\`\n${END}\n`;
56
+ }
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
+
93
+ export function parseLoopFile(text) {
94
+ const t = String(text).replace(/\r\n/g, '\n');
95
+ const a = t.indexOf(BEGIN); const b = t.lastIndexOf(END);
96
+ if (a < 0 || b < a) throw new Error('LOOP.md has no loop-state block');
97
+ const inner = t.slice(a + BEGIN.length, b).replace(/^\s*```json\s*/, '').replace(/\s*```\s*$/, '');
98
+ return JSON.parse(inner);
99
+ }
100
+
101
+ const head = (state) => iterationLoop(state.graph).nodes[0];
102
+ const fmt = (n) => Number(n).toFixed(1);
103
+
104
+ /** `contracts`: extra globs (`loop init --contracts`), appended after the graph's own. */
105
+ export function initState({ title, requirement, graph, preset = DEFAULT_PRESET, contracts = [] }) {
106
+ if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
107
+ const loop = iterationLoop(graph);
108
+ const state = {
109
+ v: 1, title, requirement, preset, contracts: [...(graph.contracts ?? []), ...contracts], graph, node: graph.start,
110
+ status: 'running', awaiting: null, reason: null,
111
+ iteration: loop.nodes.includes(graph.start) ? 1 : 0,
112
+ card: null, validated: false, pendingVerify: false, closing: false, counters: {},
113
+ resplit: false, emptyStreak: 0, snapshot: '', visited: [], tests: 'n/a', current: {},
114
+ notes: [], history: [],
115
+ };
116
+ if (preset === 'prudent') { state.status = 'awaiting'; state.awaiting = { kind: 'launch' }; }
117
+ return state;
118
+ }
119
+
120
+ function stop(state, reason) {
121
+ state.status = 'stopped'; state.reason = reason;
122
+ return { stopped: reason };
123
+ }
124
+
125
+ function resetUnit(state) {
126
+ state.card = null; state.validated = false; state.counters = {}; state.visited = [];
127
+ state.current = {}; state.tests = 'n/a';
128
+ }
129
+
130
+ function summary(state) {
131
+ return {
132
+ iterations: state.history.length,
133
+ review: state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent })),
134
+ notes: state.notes,
135
+ };
136
+ }
137
+
138
+ export function trailers(state) {
139
+ const [silent, soft] = PRESETS[state.preset];
140
+ const declared = state.current.declared ?? 0;
141
+ return [
142
+ `Loop-Iteration: ${state.iteration}`,
143
+ `Loop-Bricks: ${[...new Set(state.visited)].join(',')}`,
144
+ `Loop-Risk-Declared: ${fmt(declared)}`,
145
+ `Loop-Risk-Actual: ${fmt(state.current.actual ?? declared)}`,
146
+ `Loop-Threshold: ${state.preset} ${fmt(silent)}/${fmt(soft)}`,
147
+ `Loop-Decision: ${state.current.decision ?? 'silent'}${state.current.amended ? ' (amended)' : ''}`,
148
+ `Loop-Tests: ${state.tests}`,
149
+ ...(state.current.gates ? [`Loop-Gates: ${state.current.gates.join(',')}`] : []),
150
+ ].join('\n');
151
+ }
152
+
153
+ function finishUnit(state, committed) {
154
+ if (committed) {
155
+ state.history.push({
156
+ iteration: state.iteration, bricks: [...new Set(state.visited)],
157
+ declared: state.current.declared ?? 0, actual: state.current.actual ?? state.current.declared ?? 0,
158
+ decision: state.current.decision ?? 'silent', tests: state.tests, intent: state.card?.intent ?? '',
159
+ ...(state.current.gates ? { gates: state.current.gates } : {}),
160
+ });
161
+ }
162
+ // The unit is committed (or was empty): the tree is clean again, so the read-brick check
163
+ // compares the next node against a clean status, not the last pre-commit one.
164
+ state.pendingVerify = false; state.resplit = false; state.snapshot = '';
165
+ resetUnit(state);
166
+ state.iteration += 1;
167
+ if (state.closing) { state.status = 'done'; state.node = '$close'; return; }
168
+ state.node = head(state);
169
+ const ceiling = Math.min(iterationLoop(state.graph).max, ENGINE_MAX_ITERATIONS);
170
+ if (state.iteration > ceiling) stop(state, `iteration ceiling of ${ceiling} reached`);
171
+ }
172
+
173
+ function commitUnit(state) {
174
+ const t = trailers(state); const iteration = state.iteration;
175
+ // Read before `finishUnit` resets the card for the next unit — the CLI needs it to name
176
+ // the commit message it writes to LOOP_COMMIT_MSG.
177
+ const intent = state.card?.intent ?? null;
178
+ finishUnit(state, true);
179
+ return { commit: true, trailers: t, iteration, intent };
180
+ }
181
+
182
+ function assertRunning(state) {
183
+ if (state.status !== 'running') throw new Error(`the loop is ${state.status}, not running`);
184
+ }
185
+
186
+ export function nextStep(state, bricks) {
187
+ if (state.status === 'awaiting') return { awaiting: state.awaiting, preset: state.preset };
188
+ if (state.status === 'done') return { terminal: '$close', summary: summary(state) };
189
+ if (state.status === 'stopped') return { terminal: '$stop', reason: state.reason, summary: summary(state) };
190
+ if (state.pendingVerify) return { verify: true, iteration: state.iteration, run: VERIFY_COMMAND };
191
+ const brick = bricks.get(state.node);
192
+ if (!brick) throw new Error(`no brick named ${state.node} in the catalogue`);
193
+ return {
194
+ node: state.node, iteration: state.iteration, effect: brick.effect, agent: brick.agent,
195
+ skill: brick.skill, outcomes: brick.outcomes, prompt: brick.body, card: state.card,
196
+ notes: state.notes, validate: brick.effect === 'mutate' && !state.validated,
197
+ // The template's own rules (a cited proof before a deletion, no retries as a flake fix) are
198
+ // its `rules` list; every brick gets them, not only the head that reads LOOP.md. Never the
199
+ // description: that is the catalogue blurb, and sending it would dilute the rules.
200
+ ...(state.graph.rules?.length ? { rules: state.graph.rules } : {}),
201
+ };
202
+ }
203
+
204
+ export function scoreDeclared(state, { actions = null, writeSet = null, intent = null } = {}) {
205
+ assertRunning(state);
206
+ if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
207
+ if (actions) {
208
+ state.card = {
209
+ ...(state.card ?? {}), intent: intent ?? state.card?.intent ?? state.node,
210
+ actions, writeSet: writeSet ?? state.card?.writeSet ?? [],
211
+ };
212
+ }
213
+ if (!state.card?.actions?.length) {
214
+ throw new Error('no declared actions: the identify brick records a card, or pass --actions');
215
+ }
216
+ const score = declaredRisk(state.card.actions, state.graph.weights);
217
+ const decision = decide(score, state.preset);
218
+ state.validated = true;
219
+ state.current.declared = score;
220
+ state.current.decision = worst(state.current.decision, decision);
221
+ if (decision === 'blocking') {
222
+ state.status = 'awaiting';
223
+ state.awaiting = { kind: 'declared', iteration: state.iteration, score, threshold: PRESETS[state.preset], card: state.card };
224
+ }
225
+ return { score, preset: state.preset, threshold: [...PRESETS[state.preset]], decision };
226
+ }
227
+
228
+ export function scoreDiff(state, files) {
229
+ assertRunning(state);
230
+ if (!state.pendingVerify) throw new Error('no iteration to verify: `geneseed loop next` has not asked for one');
231
+ if (!Array.isArray(state.contracts) || state.contracts.some((c) => typeof c !== 'string')) {
232
+ throw new Error('LOOP.md: contracts must be a list of file paths or globs');
233
+ }
234
+ const scored = files.filter((f) => slash(f.file) !== LOOP_FILE && slash(f.file) !== LOOP_FILE_TMP);
235
+ if (!scored.length) {
236
+ // A closing unit (reached $close with a mutate already visited) finishes as done even
237
+ // when this is its second empty diff in a row: there is nothing left to retry into.
238
+ if (state.closing) { finishUnit(state, false); return { empty: true, commit: false }; }
239
+ state.emptyStreak += 1;
240
+ if (state.emptyStreak >= 2) return stop(state, 'two consecutive iterations produced no diff');
241
+ finishUnit(state, false);
242
+ return { empty: true, commit: false };
243
+ }
244
+ state.emptyStreak = 0;
245
+ const { score, reasons } = actualRisk(state.current.declared ?? 0, scored, {
246
+ writeSet: state.card?.writeSet ?? [], contracts: state.contracts ?? [],
247
+ ignoreDeletions: state.graph.ignoreDeletions ?? [], overrides: state.graph.weights,
248
+ });
249
+ const decision = decide(score, state.preset);
250
+ state.current.actual = score;
251
+ state.current.decision = worst(state.current.decision, decision);
252
+ if (decision === 'blocking') {
253
+ state.status = 'awaiting';
254
+ state.awaiting = { kind: 'actual', iteration: state.iteration, score, reasons, threshold: PRESETS[state.preset] };
255
+ return { score, decision, reasons, commit: false };
256
+ }
257
+ return commitUnit(state);
258
+ }
259
+
260
+ function exhaust(state, loop) {
261
+ // Setup has no head to re-split into: the head belongs to the iterations after it.
262
+ if (isSetupUnit(state)) return stop(state, `setup ${loop.name} exhausted its max of ${loop.max}`);
263
+ if (state.resplit) {
264
+ return stop(state, `${loop.name} exhausted its max of ${loop.max} twice in iteration ${state.iteration}`);
265
+ }
266
+ state.notes.push(`iteration ${state.iteration}: ${loop.name} exhausted its max of ${loop.max}; re-split once`);
267
+ resetUnit(state);
268
+ state.resplit = true; state.snapshot = ''; // the skill stashes the attempt: clean tree
269
+ state.node = head(state);
270
+ return { discard: true, resplit: true };
271
+ }
272
+
273
+ // How many times node `v` may be entered in one unit: 1, plus the largest `max` of any
274
+ // non-iteration loop that declares it — or just 1 (no re-entry) when no loop covers it.
275
+ function allowedEntries(graph, v) {
276
+ const iteration = iterationLoop(graph);
277
+ let widest = null;
278
+ for (const m of graph.loops) {
279
+ if (m === iteration || !m.nodes.includes(v)) continue;
280
+ if (!widest || m.max > widest.max) widest = m;
281
+ }
282
+ return { allowed: widest ? 1 + widest.max : 1, loop: widest };
283
+ }
284
+
285
+ // Porcelain lines are `XY <path>`; strip the 2-char status + space, slash-normalise, drop
286
+ // LOOP.md rows (the engine rewrites it on every action — it is never part of the scored work)
287
+ // and a lingering LOOP.md.tmp (the atomic writer's own sibling — see `writeLoopFile`'s docblock).
288
+ function filterPorcelain(porcelain) {
289
+ const lines = porcelain.split(/\r?\n/).filter((line) => {
290
+ if (!line) return false;
291
+ const path = line.slice(3).replace(/^"|"$/g, '');
292
+ return slash(path) !== LOOP_FILE && slash(path) !== LOOP_FILE_TMP;
293
+ });
294
+ return lines.join('\n').trimEnd();
295
+ }
296
+
297
+ // Setup work (iteration 0, graph.start outside the iteration loop) is its own unit; rejecting
298
+ // it has nowhere to retry back into, so it stops the loop instead of looping to the head.
299
+ function isSetupUnit(state) {
300
+ return state.iteration === 0 && !iterationLoop(state.graph).nodes.includes(state.graph.start);
301
+ }
302
+
303
+ export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null, note = '' } = {}) {
304
+ assertRunning(state);
305
+ if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
306
+ const from = state.node;
307
+ const brick = bricks.get(from);
308
+ if (!brick) throw new Error(`no brick named ${from} in the catalogue`);
309
+ if (!brick.outcomes.includes(outcome)) {
310
+ throw new Error(`${from} cannot report ${JSON.stringify(outcome)} — one of ${brick.outcomes.join(', ')}`);
311
+ }
312
+ if (brick.effect === 'mutate' && !state.validated) {
313
+ throw new Error(`${from} modifies code: run \`geneseed loop score --declared\` before it, then record`);
314
+ }
315
+ const filteredPorcelain = porcelain === null ? null : filterPorcelain(porcelain);
316
+ if (brick.effect === 'read' && filteredPorcelain !== null && filteredPorcelain !== state.snapshot) {
317
+ return stop(state, `read-brick-wrote: ${from} changed the working tree`);
318
+ }
319
+ if (filteredPorcelain !== null) state.snapshot = filteredPorcelain;
320
+ if (card) state.card = card;
321
+ if (from === 'test') state.tests = outcome;
322
+ // A finding a brick needs to survive the unit (the ordered plan `plan` produced, `review`'s
323
+ // `fail` findings) — pushed before the transition below, so it lands in `notes` even when
324
+ // this call closes the unit, re-splits, or stops the loop. `notes` is never reset mid-run
325
+ // (only `exhaust`'s own push and `decideAwaiting` add to it otherwise), so it survives
326
+ // `resetUnit`/`finishUnit` the same way the research's fact 1 says a card does not.
327
+ if (note) state.notes.push(`iteration ${state.iteration} (${from}): ${note}`);
328
+ state.visited.push(from);
329
+
330
+ const edge = state.graph.edges.find((e) => e.from === from && e.on === outcome);
331
+ if (!edge) throw new Error(`no edge from ${from} on ${outcome}`);
332
+ if (brick.gate === 'human' && (!brick.gateOn || brick.gateOn.includes(outcome))) {
333
+ state.status = 'awaiting';
334
+ state.awaiting = { kind: 'gate', node: from, outcome, ...(note ? { note } : {}) };
335
+ return { awaiting: state.awaiting };
336
+ }
337
+ return transition(state, bricks, from, edge);
338
+ }
339
+
340
+ /**
341
+ * Follow `edge` out of `from` — the second half of `recordOutcome`, and what `decide ok` runs on
342
+ * a held gate. THE GATE HOLDS THE WHOLE TRANSITION: whatever the edge would do (ask for the diff
343
+ * score, close the unit, stop, count a re-entry and exhaust a ring) happens only here, after the
344
+ * user's `ok` — never before it, so nothing is scored, committed, re-split or stopped by the
345
+ * transition while the gate waits. A held `$stop` edge still waits: the user may `amend` it.
346
+ */
347
+ function transition(state, bricks, from, edge) {
348
+ if (edge.to === '$stop') return stop(state, `${from} reported ${edge.on}`);
349
+ const mutated = state.visited.some((n) => bricks.get(n)?.effect === 'mutate');
350
+ if (edge.to === '$close') {
351
+ if (mutated) { state.closing = true; state.pendingVerify = true; return { verify: true }; }
352
+ state.status = 'done'; state.node = '$close';
353
+ return { done: true };
354
+ }
355
+ const h = head(state);
356
+ if (edge.to === h) {
357
+ if (mutated) { state.pendingVerify = true; state.node = h; return { verify: true }; }
358
+ // A read-only setup brick (no mutate visited) closes without asking for a diff score.
359
+ // `finishUnit` can itself hit the iteration ceiling and stop the loop — report that, not
360
+ // the head it was about to land on.
361
+ finishUnit(state, false);
362
+ return state.status === 'stopped' ? { stopped: state.reason } : { node: state.node };
363
+ }
364
+ state.counters[edge.to] = (state.counters[edge.to] ?? 0) + 1;
365
+ const { allowed, loop } = allowedEntries(state.graph, edge.to);
366
+ if (state.counters[edge.to] > allowed) return exhaust(state, loop ?? { name: edge.to, max: 0 });
367
+ state.node = edge.to;
368
+ return { node: edge.to };
369
+ }
370
+
371
+ export function decideAwaiting(state, bricks, verdict, note = '') {
372
+ if (state.status !== 'awaiting') throw new Error('nothing is awaiting a decision');
373
+ if (!['ok', 'no', 'amend'].includes(verdict)) throw new Error(`verdict must be ok, no or amend, not ${JSON.stringify(verdict)}`);
374
+ const held = state.awaiting;
375
+ const { kind } = held;
376
+ state.awaiting = null; state.status = 'running';
377
+ if (kind === 'gate') return decideGate(state, bricks, verdict, note, held);
378
+ if (note) state.notes.push(`iteration ${state.iteration} (${kind}, ${verdict}): ${note}`);
379
+ if (kind === 'launch') {
380
+ return verdict === 'ok' ? { resumed: true } : stop(state, `graph ${verdict === 'no' ? 'rejected' : 'to amend'}${note ? `: ${note}` : ''}`);
381
+ }
382
+ if (kind === 'declared') {
383
+ if (verdict === 'ok') return { resumed: true };
384
+ if (verdict === 'amend') { state.card = { ...state.card, amend: note }; state.current.amended = true; return { resumed: true }; }
385
+ if (isSetupUnit(state)) return stop(state, `setup rejected${note ? `: ${note}` : ''}`);
386
+ resetUnit(state); state.node = head(state);
387
+ return { dropped: true };
388
+ }
389
+ // kind === 'actual': the diff exists, uncommitted
390
+ if (verdict === 'ok') return commitUnit(state);
391
+ state.pendingVerify = false; state.closing = false;
392
+ if (verdict === 'amend') {
393
+ state.card = { ...state.card, amend: note };
394
+ // The user intervened: the decision stands as blocking, said so, and the retry budget
395
+ // for this unit restarts (reset counters) now that the amendment changes what it does.
396
+ state.current = { declared: state.current.declared, decision: 'blocking', amended: true };
397
+ state.counters = {};
398
+ // A setup unit re-runs its own brick (graph.start), never the iteration's fix brick.
399
+ state.node = isSetupUnit(state)
400
+ ? state.graph.start
401
+ : iterationLoop(state.graph).nodes.find((n) => bricks.get(n)?.effect === 'mutate') ?? head(state);
402
+ return { resumed: true };
403
+ }
404
+ if (isSetupUnit(state)) return stop(state, `setup rejected${note ? `: ${note}` : ''}`);
405
+ resetUnit(state); state.node = head(state); state.snapshot = '';
406
+ return { discard: true };
407
+ }
408
+
409
+ const GATE_MAX_AMENDS = 3;
410
+
411
+ /**
412
+ * The answer to a held `gate: human` transition. Every verdict leaves a note — with or without
413
+ * the user's words — so the run's notes say each gate was answered and how. `ok` follows the held
414
+ * edge and lists the node under the unit's `Loop-Gates`; `no` stops; `amend` re-runs the same
415
+ * node with the note in `notes` (its `validated` untouched — a mutate gate need not re-score —
416
+ * and its `counters` reset),
417
+ * `GATE_MAX_AMENDS` times per node per iteration (`gateAmends` is keyed `node@iteration` and never
418
+ * reset, so the next iteration's card — a new ADR, say — gets its own budget), and the next
419
+ * one stops: three rounds of review that did not converge are a conversation, not a loop.
420
+ */
421
+ function decideGate(state, bricks, verdict, note, { node, outcome }) {
422
+ state.notes.push(`iteration ${state.iteration} (gate at ${node}, ${verdict})${note ? `: ${note}` : ''}`);
423
+ if (verdict === 'no') return stop(state, `gate rejected at ${node}${note ? `: ${note}` : ''}`);
424
+ if (verdict === 'amend') {
425
+ const key = `${node}@${state.iteration}`;
426
+ state.gateAmends = { ...state.gateAmends, [key]: (state.gateAmends?.[key] ?? 0) + 1 };
427
+ if (state.gateAmends[key] > GATE_MAX_AMENDS) return stop(state, `gate amended ${GATE_MAX_AMENDS} times at ${node}`);
428
+ // As an `actual` amend: the amendment changes what the unit does, so the ring budget
429
+ // restarts; the amend cap above still bounds the unit.
430
+ state.counters = {};
431
+ state.node = node;
432
+ return { resumed: true };
433
+ }
434
+ state.current.gates = [...new Set([...(state.current.gates ?? []), node])]; // once each, as Loop-Bricks
435
+ return transition(state, bricks, node, state.graph.edges.find((e) => e.from === node && e.on === outcome));
436
+ }
@@ -35,12 +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 { ROOT } from '../build/source.mjs';
38
+ import { PACK_ORDER, ROOT } from '../build/source.mjs';
39
39
  import {
40
40
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
41
41
  footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
42
+ trustOfDir,
42
43
  } from '../hosts/installs.mjs';
43
44
  import { DEFAULT_EMIT, setupBuildArgs } from '../build/generate.mjs';
45
+ import { DEFAULT_PRESET } from '../loop/score.mjs';
44
46
  import {
45
47
  autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
46
48
  } from '../hosts/settings.mjs';
@@ -222,7 +224,7 @@ export function cmdMigrate(args = {}) {
222
224
  const out = r.scope === 'global' ? null : r.root;
223
225
  const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
224
226
  postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
225
- doctrinesForBuild(r.root));
227
+ doctrinesForBuild(r.root), PACK_ORDER, null, trustOfDir(r.root) || DEFAULT_PRESET);
226
228
  const label = `${r.host}:${r.scope} (${r.root})`;
227
229
  printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
228
230
  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') {