geneseed 3.12.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 (218) hide show
  1. package/CHANGELOG.md +121 -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/docs/README.md +41 -22
  9. package/docs/concepts/agents.md +3 -1
  10. package/docs/concepts/collaboration.md +3 -1
  11. package/docs/concepts/exclusions.md +4 -2
  12. package/docs/concepts/footprint.md +3 -1
  13. package/docs/concepts/foreman-mode.md +3 -1
  14. package/docs/concepts/hook-gates.md +61 -0
  15. package/docs/concepts/hooks.md +8 -56
  16. package/docs/concepts/host-matrix.md +38 -0
  17. package/docs/concepts/hosts.md +6 -32
  18. package/docs/concepts/loop-branch.md +66 -0
  19. package/docs/concepts/loop-bricks.md +48 -0
  20. package/docs/concepts/loop-contracts.md +33 -0
  21. package/docs/concepts/loop-gates.md +30 -0
  22. package/docs/concepts/loop-notes.md +43 -0
  23. package/docs/concepts/loop-risk.md +36 -0
  24. package/docs/concepts/loop-templates.md +50 -0
  25. package/docs/concepts/loops.md +24 -116
  26. package/docs/concepts/lsp.md +2 -0
  27. package/docs/concepts/memory.md +3 -1
  28. package/docs/concepts/notebook.md +3 -1
  29. package/docs/concepts/rules.md +4 -2
  30. package/docs/concepts/skills.md +3 -1
  31. package/docs/concepts/themes.md +3 -1
  32. package/docs/extending.md +23 -8
  33. package/docs/guides/autostart.md +3 -1
  34. package/docs/guides/choose-your-setup.md +5 -80
  35. package/docs/guides/headless.md +5 -3
  36. package/docs/guides/install-agent-md.md +37 -0
  37. package/docs/guides/install-bob.md +20 -0
  38. package/docs/guides/install-claude-code.md +22 -0
  39. package/docs/guides/install-clone.md +34 -0
  40. package/docs/guides/install-openclaude.md +25 -0
  41. package/docs/guides/install-opencode.md +35 -0
  42. package/docs/guides/install-scripted.md +21 -0
  43. package/docs/guides/install.md +9 -118
  44. package/docs/guides/loop-finish.md +54 -0
  45. package/docs/guides/loop-running.md +66 -0
  46. package/docs/guides/loop-which-template.md +35 -0
  47. package/docs/guides/mcp-filesystem.md +41 -0
  48. package/docs/guides/mcp-gitlab.md +66 -0
  49. package/docs/guides/mcp-markitdown.md +36 -0
  50. package/docs/guides/mcp.md +8 -137
  51. package/docs/guides/migrate.md +3 -1
  52. package/docs/guides/project-context.md +4 -2
  53. package/docs/guides/read-non-markdown.md +17 -0
  54. package/docs/guides/run-a-loop.md +64 -0
  55. package/docs/guides/run-anywhere.md +2 -0
  56. package/docs/guides/setup-doctrines.md +55 -0
  57. package/docs/guides/setup-footprint.md +48 -0
  58. package/docs/guides/uninstall.md +3 -1
  59. package/docs/guides/upgrade.md +3 -1
  60. package/docs/guides/verify.md +3 -1
  61. package/docs/guides/wiki.md +3 -1
  62. package/docs/guides/worktree.md +3 -1
  63. package/docs/reference/about.md +3 -1
  64. package/docs/reference/cli.md +2 -0
  65. package/docs/reference/env-context.md +40 -0
  66. package/docs/reference/environment.md +10 -59
  67. package/docs/reference/glossary.md +3 -1
  68. package/docs/reference/loop-actions.md +20 -0
  69. package/docs/reference/opencode-plugins.md +14 -118
  70. package/docs/reference/plugin-activity.md +20 -0
  71. package/docs/reference/plugin-context.md +27 -0
  72. package/docs/reference/plugin-guard.md +27 -0
  73. package/docs/reference/plugin-learn.md +27 -0
  74. package/docs/reference/plugin-notify.md +20 -0
  75. package/docs/reference/plugin-ponytail.md +19 -0
  76. package/docs/reference/plugin-workflow.md +25 -0
  77. package/docs/reference/troubleshoot-doctor.md +50 -0
  78. package/docs/reference/troubleshoot-hooks.md +99 -0
  79. package/docs/reference/troubleshoot-install.md +108 -0
  80. package/docs/reference/troubleshoot-tools.md +56 -0
  81. package/docs/reference/troubleshoot-update.md +23 -0
  82. package/docs/reference/troubleshoot-web.md +66 -0
  83. package/docs/reference/troubleshooting.md +13 -337
  84. package/docs/understand/a-day.md +3 -1
  85. package/docs/understand/enforced-vs-asked.md +3 -1
  86. package/docs/understand/harness.md +2 -0
  87. package/docs/understand/machine-bob.md +17 -0
  88. package/docs/understand/machine-claude.md +35 -0
  89. package/docs/understand/machine-opencode.md +36 -0
  90. package/docs/understand/on-your-machine.md +12 -55
  91. package/docs/understand/take-it-out.md +13 -0
  92. package/docs/web-ui.md +35 -15
  93. package/harness.config.json +1 -1
  94. package/js/README.md +11 -3
  95. package/js/cli-table.json +27 -1
  96. package/js/hosts/mcp.mjs +1 -1
  97. package/js/hosts/native.mjs +43 -5
  98. package/js/inspect/checks-build.mjs +28 -0
  99. package/js/loop/catalog.mjs +12 -1
  100. package/js/loop/cli.mjs +28 -6
  101. package/js/loop/graph.mjs +18 -0
  102. package/js/loop/registry.mjs +196 -0
  103. package/js/loop/score.mjs +45 -6
  104. package/js/loop/state.mjs +125 -11
  105. package/js/maintain/setup.mjs +1 -1
  106. package/js/web/actions.mjs +25 -0
  107. package/js/web/api.mjs +13 -0
  108. package/js/web/docs.mjs +64 -12
  109. package/js/web/routes.mjs +5 -1
  110. package/package.json +1 -1
  111. package/src/agents/explorer.md +2 -2
  112. package/src/agents/researcher.md +3 -3
  113. package/src/bricks/README.md +3 -1
  114. package/src/bricks/adr-challenge.md +20 -0
  115. package/src/bricks/adr-draft.md +15 -0
  116. package/src/bricks/characterize.md +17 -0
  117. package/src/bricks/ci-fix.md +2 -1
  118. package/src/bricks/ci-triage.md +20 -0
  119. package/src/bricks/compat-check.md +16 -0
  120. package/src/bricks/done-check.md +16 -0
  121. package/src/bricks/fitness-define.md +22 -0
  122. package/src/bricks/flake-check.md +17 -0
  123. package/src/bricks/flake-repro.md +18 -0
  124. package/src/bricks/identify.md +6 -3
  125. package/src/bricks/migration-plan.md +21 -0
  126. package/src/bricks/mutation-check.md +18 -0
  127. package/src/bricks/plan.md +6 -6
  128. package/src/bricks/red-test.md +16 -0
  129. package/src/bricks/review.md +5 -3
  130. package/src/bricks/spec.md +18 -0
  131. package/src/bricks/upgrade-scout.md +16 -0
  132. package/src/loops/api-endpoint.json +31 -0
  133. package/src/loops/architecture-decision.json +22 -0
  134. package/src/loops/bugfix.json +1 -0
  135. package/src/loops/ci-repair.json +19 -0
  136. package/src/loops/deps-upgrade.json +29 -0
  137. package/src/loops/enforce-architecture-rule.json +28 -0
  138. package/src/loops/feature.json +8 -5
  139. package/src/loops/fix-flaky-tests.json +30 -0
  140. package/src/loops/legacy-refactor.json +26 -0
  141. package/src/loops/legacy-tests.json +29 -0
  142. package/src/loops/refactor.json +5 -1
  143. package/src/loops/remove-dead-code.json +30 -0
  144. package/src/loops/spec-first-feature.json +29 -0
  145. package/src/loops/tdd.json +25 -0
  146. package/src/loops/update-contract.json +33 -0
  147. package/src/skills/brick-forge.md +3 -1
  148. package/src/skills/bruno-collection-generator.md +3 -3
  149. package/src/skills/bruno-test-writer.md +3 -3
  150. package/src/skills/ci-fix.md +3 -3
  151. package/src/skills/consolidate-memory.md +4 -4
  152. package/src/skills/council.md +1 -1
  153. package/src/skills/fresh-eyes.md +1 -1
  154. package/src/skills/git-archaeology.md +1 -1
  155. package/src/skills/git-rescue.md +4 -3
  156. package/src/skills/handoff.md +1 -1
  157. package/src/skills/herdr.md +1 -1
  158. package/src/skills/learn-mode.md +2 -2
  159. package/src/skills/loop.md +22 -10
  160. package/src/skills/opencode-theme.md +3 -3
  161. package/src/skills/ponytail.md +1 -1
  162. package/src/skills/profile.md +3 -4
  163. package/src/skills/prose.md +2 -3
  164. package/src/skills/quiz.md +1 -1
  165. package/src/skills/react-view-transitions/SKILL.md +1 -1
  166. package/src/skills/react-view-transitions/VENDOR.md +8 -3
  167. package/src/skills/research.md +3 -3
  168. package/src/skills/review-response.md +2 -2
  169. package/src/skills/rule.md +3 -3
  170. package/src/skills/security-audit.md +4 -4
  171. package/src/skills/teach.md +1 -1
  172. package/src/skills/wiki.md +3 -3
  173. package/src/skills/workflow.md +4 -4
  174. package/src/skills/worktree.md +2 -2
  175. package/web/dist/assets/Activity-Ca2Aruu7.js +1 -0
  176. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +1 -0
  177. package/web/dist/assets/Dashboard-BuMq46aT.js +2 -0
  178. package/web/dist/assets/Docs-BzccCllG.js +5 -0
  179. package/web/dist/assets/FilterInput-CBcJBQPI.js +1 -0
  180. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +2 -0
  181. package/web/dist/assets/Installs-DMFJvq7s.js +2 -0
  182. package/web/dist/assets/JournalView-o1rmd1_1.js +1 -0
  183. package/web/dist/assets/Laws-BANxq_I_.js +3 -0
  184. package/web/dist/assets/Library-DW4x0fbh.js +1 -0
  185. package/web/dist/assets/Loops-DEBibbBP.js +2 -0
  186. package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-CdTX0kLe.js} +14 -14
  187. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +2 -0
  188. package/web/dist/assets/Personal-6uMJvyBG.js +1 -0
  189. package/web/dist/assets/Tabs-BubnyZhF.js +1 -0
  190. package/web/dist/assets/activity-CObqyxkL.js +1 -0
  191. package/web/dist/assets/{api-CyMRbU2x.js → api-D_ixdyEY.js} +1 -1
  192. package/web/dist/assets/drawable-C_x5xncq.js +1 -0
  193. package/web/dist/assets/index-CMPgezFm.js +2 -0
  194. package/web/dist/assets/index-DMsT4Aa6.css +1 -0
  195. package/web/dist/assets/{react-B9Z_tXRV.js → react-Bbzhmq4v.js} +1 -1
  196. package/web/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  197. package/web/dist/assets/sections-Dg23Hel5.js +1 -0
  198. package/web/dist/assets/usePoll-BOqeunzF.js +1 -0
  199. package/web/dist/index.html +6 -5
  200. package/web/dist/assets/Activity-GaaZWXTY.js +0 -1
  201. package/web/dist/assets/ActivityDetail-4t7d45Du.js +0 -1
  202. package/web/dist/assets/Dashboard-CYn7Tp16.js +0 -2
  203. package/web/dist/assets/Docs-BeFKLg2T.js +0 -5
  204. package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
  205. package/web/dist/assets/GreenhouseView-C6ibc8IE.js +0 -2
  206. package/web/dist/assets/Installs-DRYjYnxs.js +0 -2
  207. package/web/dist/assets/JournalView-1HbU1mfg.js +0 -1
  208. package/web/dist/assets/Laws-DFoG8JWB.js +0 -3
  209. package/web/dist/assets/Library-DDjgNwz_.js +0 -1
  210. package/web/dist/assets/Loops-D9VEoO0F.js +0 -1
  211. package/web/dist/assets/OperatorHudView-Bs7paWmh.js +0 -2
  212. package/web/dist/assets/Personal-BY5n4gTw.js +0 -1
  213. package/web/dist/assets/Tabs-ho-EYD8i.js +0 -1
  214. package/web/dist/assets/drawable-CLS9drMb.js +0 -1
  215. package/web/dist/assets/index-CX_hXMi5.css +0 -1
  216. package/web/dist/assets/index-Cjr7XCA3.js +0 -2
  217. package/web/dist/assets/sections-GalMS0Zv.js +0 -1
  218. package/web/dist/assets/usePoll-O0sJEw1Y.js +0 -1
package/js/loop/score.mjs CHANGED
@@ -104,23 +104,60 @@ export function parseNumstat(text) {
104
104
 
105
105
  const slash = (p) => p.replaceAll('\\', '/');
106
106
 
107
- export function actualRisk(declared, files, { writeSet = [], contracts = [], overrides = {} } = {}) {
107
+ // The one glob dialect of the loop — a template's `contracts` and `ignoreDeletions`, and LOOP.md's
108
+ // own `contracts`. `*` and `?` stay inside a path segment; `**` crosses segments, and `**/` may
109
+ // match no directory at all, so `**/yarn.lock` covers the root one too. Both sides are
110
+ // slash-normalised first, so a backslash is a separator, never an escape. Hand-rolled because
111
+ // this module may import nothing but node builtins (see the docblock above); a plain path is a
112
+ // glob that matches only itself, so exact-path contracts keep working.
113
+ // A leading `./` or `/` is dropped on both sides: every path here is repo-relative.
114
+ // ponytail: runs of `**/` and `*` collapse first, so stacked wildcards cannot compound; what
115
+ // remains is the regex engine's backtracking over several separate `**` (O(n^k) on a near miss).
116
+ // Patterns come from the template author or the user, never from untrusted input — a linear
117
+ // matcher is the upgrade if that ever changes.
118
+ const globPath = (p) => slash(String(p)).replace(/^(?:\.?\/)+/, '');
119
+
120
+ export function globMatch(pattern, file) {
121
+ const g = globPath(pattern).replace(/\*{3,}/g, '**').replace(/(?:\*\*\/){2,}/g, '**/');
122
+ let re = '';
123
+ for (let i = 0; i < g.length; i += 1) {
124
+ const c = g[i];
125
+ if (c === '*' && g[i + 1] === '*') {
126
+ i += 1;
127
+ if (g[i + 1] === '/') { i += 1; re += '(?:.*/)?'; } else re += '.*';
128
+ } else if (c === '*') re += '[^/]*';
129
+ else if (c === '?') re += '[^/]';
130
+ else re += c.replace(/[.+^${}()|[\]]/g, '\\$&');
131
+ }
132
+ return new RegExp(`^${re}$`).test(globPath(file));
133
+ }
134
+
135
+ const matchesAny = (globs, file) => globs.some((g) => globMatch(g, file));
136
+
137
+ export function actualRisk(declared, files, {
138
+ writeSet = [], contracts = [], ignoreDeletions = [], overrides = {},
139
+ } = {}) {
108
140
  const w = { ...WEIGHTS, ...overrides };
109
141
  let score = declared;
110
142
  const reasons = [];
143
+ // A write-set entry is a glob too (a plain path matches itself): a tool's generated store,
144
+ // ArchUnit's archunit_store/**, cannot be named file by file before the tool writes it. Only an
145
+ // entry with a literal segment globs: a wildcard-only one (`**`, `**/*.java`) would declare the
146
+ // whole repo, so it stays exact-match — matching nothing, the diff fails closed.
111
147
  const inSet = new Set(writeSet.map(slash));
112
- const outside = files.filter((f) => !inSet.has(slash(f.file)));
148
+ const globs = writeSet.filter((g) => globPath(g).split('/').some((seg) => seg && !/[*?]/.test(seg)));
149
+ const outside = files.filter((f) => !inSet.has(slash(f.file)) && !matchesAny(globs, f.file));
113
150
  if (outside.length) {
114
151
  score = Math.max(score, 0.8);
115
152
  reasons.push(`outside the write set: ${outside.map((f) => slash(f.file)).join(', ')}`);
116
153
  }
117
- const deleted = files.reduce((n, f) => n + f.deleted, 0);
154
+ // ignoreDeletions only leaves the deletion count: the write-set check above still saw the file.
155
+ const deleted = files.filter((f) => !matchesAny(ignoreDeletions, f.file)).reduce((n, f) => n + f.deleted, 0);
118
156
  if (deleted > DELETION_LINES) {
119
157
  score = Math.max(score, w.delete);
120
158
  reasons.push(`${deleted} lines deleted`);
121
159
  }
122
- const contractSet = new Set(contracts.map(slash));
123
- const hit = files.filter((f) => contractSet.has(slash(f.file)));
160
+ const hit = files.filter((f) => matchesAny(contracts, f.file));
124
161
  if (hit.length) {
125
162
  score = Math.max(score, w.api);
126
163
  reasons.push(`contract files: ${hit.map((f) => slash(f.file)).join(', ')}`);
@@ -129,8 +166,10 @@ export function actualRisk(declared, files, { writeSet = [], contracts = [], ove
129
166
  }
130
167
 
131
168
  export function decide(score, preset) {
169
+ if (!Object.hasOwn(PRESETS, preset)) {
170
+ throw new Error(`unknown preset ${JSON.stringify(preset)} — one of ${Object.keys(PRESETS).join(', ')}`);
171
+ }
132
172
  const t = PRESETS[preset];
133
- if (!t) throw new Error(`unknown preset ${JSON.stringify(preset)} — one of ${Object.keys(PRESETS).join(', ')}`);
134
173
  if (score <= t[0]) return 'silent';
135
174
  if (score <= t[1]) return 'soft';
136
175
  return 'blocking';
package/js/loop/state.mjs CHANGED
@@ -21,7 +21,23 @@
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
 
@@ -29,6 +45,7 @@ const BEGIN = '<!-- loop-state:begin -->';
29
45
  const END = '<!-- loop-state:end -->';
30
46
  export const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
31
47
  export const LOOP_FILE = 'LOOP.md';
48
+ export 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,11 +101,12 @@ 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
+ /** `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)}`);
54
107
  const loop = iterationLoop(graph);
55
108
  const state = {
56
- v: 1, title, requirement, preset, contracts: [], graph, node: graph.start,
109
+ v: 1, title, requirement, preset, contracts: [...(graph.contracts ?? []), ...contracts], graph, node: graph.start,
57
110
  status: 'running', awaiting: null, reason: null,
58
111
  iteration: loop.nodes.includes(graph.start) ? 1 : 0,
59
112
  card: null, validated: false, pendingVerify: false, closing: false, counters: {},
@@ -93,6 +146,7 @@ export function trailers(state) {
93
146
  `Loop-Threshold: ${state.preset} ${fmt(silent)}/${fmt(soft)}`,
94
147
  `Loop-Decision: ${state.current.decision ?? 'silent'}${state.current.amended ? ' (amended)' : ''}`,
95
148
  `Loop-Tests: ${state.tests}`,
149
+ ...(state.current.gates ? [`Loop-Gates: ${state.current.gates.join(',')}`] : []),
96
150
  ].join('\n');
97
151
  }
98
152
 
@@ -102,6 +156,7 @@ function finishUnit(state, committed) {
102
156
  iteration: state.iteration, bricks: [...new Set(state.visited)],
103
157
  declared: state.current.declared ?? 0, actual: state.current.actual ?? state.current.declared ?? 0,
104
158
  decision: state.current.decision ?? 'silent', tests: state.tests, intent: state.card?.intent ?? '',
159
+ ...(state.current.gates ? { gates: state.current.gates } : {}),
105
160
  });
106
161
  }
107
162
  // The unit is committed (or was empty): the tree is clean again, so the read-brick check
@@ -139,6 +194,10 @@ export function nextStep(state, bricks) {
139
194
  node: state.node, iteration: state.iteration, effect: brick.effect, agent: brick.agent,
140
195
  skill: brick.skill, outcomes: brick.outcomes, prompt: brick.body, card: state.card,
141
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 } : {}),
142
201
  };
143
202
  }
144
203
 
@@ -170,9 +229,9 @@ export function scoreDiff(state, files) {
170
229
  assertRunning(state);
171
230
  if (!state.pendingVerify) throw new Error('no iteration to verify: `geneseed loop next` has not asked for one');
172
231
  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');
232
+ throw new Error('LOOP.md: contracts must be a list of file paths or globs');
174
233
  }
175
- const scored = files.filter((f) => slash(f.file) !== LOOP_FILE);
234
+ const scored = files.filter((f) => slash(f.file) !== LOOP_FILE && slash(f.file) !== LOOP_FILE_TMP);
176
235
  if (!scored.length) {
177
236
  // A closing unit (reached $close with a mutate already visited) finishes as done even
178
237
  // when this is its second empty diff in a row: there is nothing left to retry into.
@@ -184,7 +243,8 @@ export function scoreDiff(state, files) {
184
243
  }
185
244
  state.emptyStreak = 0;
186
245
  const { score, reasons } = actualRisk(state.current.declared ?? 0, scored, {
187
- writeSet: state.card?.writeSet ?? [], contracts: state.contracts ?? [], overrides: state.graph.weights,
246
+ writeSet: state.card?.writeSet ?? [], contracts: state.contracts ?? [],
247
+ ignoreDeletions: state.graph.ignoreDeletions ?? [], overrides: state.graph.weights,
188
248
  });
189
249
  const decision = decide(score, state.preset);
190
250
  state.current.actual = score;
@@ -223,12 +283,13 @@ function allowedEntries(graph, v) {
223
283
  }
224
284
 
225
285
  // 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).
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).
227
288
  function filterPorcelain(porcelain) {
228
289
  const lines = porcelain.split(/\r?\n/).filter((line) => {
229
290
  if (!line) return false;
230
291
  const path = line.slice(3).replace(/^"|"$/g, '');
231
- return slash(path) !== LOOP_FILE;
292
+ return slash(path) !== LOOP_FILE && slash(path) !== LOOP_FILE_TMP;
232
293
  });
233
294
  return lines.join('\n').trimEnd();
234
295
  }
@@ -239,7 +300,7 @@ function isSetupUnit(state) {
239
300
  return state.iteration === 0 && !iterationLoop(state.graph).nodes.includes(state.graph.start);
240
301
  }
241
302
 
242
- export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null } = {}) {
303
+ export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null, note = '' } = {}) {
243
304
  assertRunning(state);
244
305
  if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
245
306
  const from = state.node;
@@ -258,11 +319,33 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
258
319
  if (filteredPorcelain !== null) state.snapshot = filteredPorcelain;
259
320
  if (card) state.card = card;
260
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}`);
261
328
  state.visited.push(from);
262
329
 
263
330
  const edge = state.graph.edges.find((e) => e.from === from && e.on === outcome);
264
331
  if (!edge) throw new Error(`no edge from ${from} on ${outcome}`);
265
- if (edge.to === '$stop') return stop(state, `${from} reported ${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}`);
266
349
  const mutated = state.visited.some((n) => bricks.get(n)?.effect === 'mutate');
267
350
  if (edge.to === '$close') {
268
351
  if (mutated) { state.closing = true; state.pendingVerify = true; return { verify: true }; }
@@ -288,8 +371,10 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
288
371
  export function decideAwaiting(state, bricks, verdict, note = '') {
289
372
  if (state.status !== 'awaiting') throw new Error('nothing is awaiting a decision');
290
373
  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;
374
+ const held = state.awaiting;
375
+ const { kind } = held;
292
376
  state.awaiting = null; state.status = 'running';
377
+ if (kind === 'gate') return decideGate(state, bricks, verdict, note, held);
293
378
  if (note) state.notes.push(`iteration ${state.iteration} (${kind}, ${verdict}): ${note}`);
294
379
  if (kind === 'launch') {
295
380
  return verdict === 'ok' ? { resumed: true } : stop(state, `graph ${verdict === 'no' ? 'rejected' : 'to amend'}${note ? `: ${note}` : ''}`);
@@ -320,3 +405,32 @@ export function decideAwaiting(state, bricks, verdict, note = '') {
320
405
  resetUnit(state); state.node = head(state); state.snapshot = '';
321
406
  return { discard: true };
322
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
+ }
@@ -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') {
@@ -40,6 +40,7 @@ import { withStdoutSwallowed } from '../inspect/diff.mjs';
40
40
  import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
41
41
  import { setupBuildArgs } from '../build/generate.mjs';
42
42
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
43
+ import { setLoopPreset } from '../loop/registry.mjs';
43
44
  import { frontmatter, memoryDropIndex } from '../hosts/hooks.mjs';
44
45
  import {
45
46
  HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
@@ -577,6 +578,30 @@ export function apiExcludesMutate(state, body) {
577
578
  return action === 'add' ? excludeAdd(p) : excludeRemove(p);
578
579
  }
579
580
 
581
+ // ---- loop preset picker ---------------------------------------------------------------------
582
+
583
+ /**
584
+ * The Active tab's one write: rewrite a registered worktree's `LOOP.md` `preset` field.
585
+ * `setLoopPreset` throws for both refusals the brief requires `POST /api/loops/preset` to make —
586
+ * a `root` the registry does not know (no arbitrary path writes) and a `preset` outside
587
+ * `PRESETS` — and this is where those two throws are turned into the `NotFound` convention
588
+ * every other mutating route here already answers in, so the handler's catch (`4xx`, not the
589
+ * 500 a bare `Error` would fall to) covers this route the same way it covers `apiRulesMutate`'s
590
+ * unknown id. Anything else `setLoopPreset` throws — a corrupt `LOOP.md` that fails to
591
+ * parse, say — is not one of those two refusals and is rethrown as-is, so it falls through to
592
+ * the handler's 500 instead of being misreported as "not found".
593
+ */
594
+ export function apiLoopsPresetMutate(state, body) {
595
+ const root = strOr(bget(body, 'root'));
596
+ const preset = strOr(bget(body, 'preset'));
597
+ try {
598
+ return { ok: true, loop: setLoopPreset(root, preset) };
599
+ } catch (e) {
600
+ if (e.message === 'unknown loop' || e.message.startsWith('unknown preset ')) throw new NotFound(e.message);
601
+ throw e;
602
+ }
603
+ }
604
+
580
605
  // ---- MCP ----------------------------------------------------------------------------------
581
606
 
582
607
  export function apiMcp() {
package/js/web/api.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { frontmatter } from '../hosts/hooks.mjs';
28
28
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
29
29
  import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
30
+ import { activeLoops } from '../loop/registry.mjs';
30
31
  import {
31
32
  SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
32
33
  } from '../inspect/inventory.mjs';
@@ -968,6 +969,17 @@ export function apiLoops(state) {
968
969
  };
969
970
  }
970
971
 
972
+ /**
973
+ * The Active tab's read: every worktree `geneseed loop init` has registered, with its live
974
+ * `LOOP.md` state re-read on every call (`activeLoops` itself, not a cached copy — the registry
975
+ * stores identity only, see `js/loop/registry.mjs`). Takes no `state`-derived filtering: the
976
+ * registry is machine-wide, not scoped to this install's root, so a loop launched from a
977
+ * different project still shows up here.
978
+ */
979
+ export function apiLoopsActive() {
980
+ return { loops: activeLoops() };
981
+ }
982
+
971
983
  /** The source's release label, or null when it cannot be read. */
972
984
  function releaseLabel() {
973
985
  const v = sourceReleaseVersion({ config: CONFIG });
@@ -1018,4 +1030,5 @@ export const STATE_ROUTES = {
1018
1030
  '/api/rules': apiRules,
1019
1031
  '/api/mcp': apiMcp,
1020
1032
  '/api/loops': apiLoops,
1033
+ '/api/loops/active': apiLoopsActive,
1021
1034
  };
package/js/web/docs.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * THE `?harness=` QUERY PARAM IS the Docs selector, and it is the ONLY input to these
15
15
  * endpoints that is not the checkout itself — the one thing a test can vary. Every
16
- * filtering rule below is exercised by sending both values over one page.
16
+ * filtering rule below is exercised by sending each host over one page.
17
17
  */
18
18
  import { readdirSync } from 'node:fs';
19
19
  import path from 'node:path';
@@ -105,7 +105,7 @@ export function docGroups() {
105
105
  if (group === undefined) continue;
106
106
  const order = Object.hasOwn(meta, 'order') ? meta.order : 0;
107
107
  delete meta.order;
108
- const page = { id, rel, ...meta };
108
+ const page = { id, rel, ...meta, section: sectionOf(meta) };
109
109
  if (body.trim()) page.body = body.replace(/\n+$/, '');
110
110
  group.pages.push([order, page]);
111
111
  }
@@ -113,15 +113,44 @@ export function docGroups() {
113
113
  for (const g of groups) {
114
114
  const entry = byId.get(g.id);
115
115
  // STABLE sort: equal orders keep filename order.
116
- entry.pages = entry.pages
116
+ entry.pages = bySection(entry.pages
117
117
  .map((pair, i) => [pair[0], i, pair[1]])
118
118
  .sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]))
119
- .map((t) => t[2]);
119
+ .map((t) => t[2]));
120
120
  out.push(entry);
121
121
  }
122
122
  return out;
123
123
  }
124
124
 
125
+ /** The heading a page without a `section:` is listed under, inside its part. */
126
+ export const GENERAL_SECTION = 'General';
127
+
128
+ /**
129
+ * A page's `section:` frontmatter, or `General`. Anything but a non-empty string falls back
130
+ * rather than throwing — `tests/unit/docs_tree.test.mjs` is where a bad value goes red.
131
+ */
132
+ export function sectionOf(meta) {
133
+ const s = meta.section;
134
+ return typeof s === 'string' && s.trim() ? s.trim() : GENERAL_SECTION;
135
+ }
136
+
137
+ /**
138
+ * Pages already in `order:` order, regrouped so each section's pages sit together.
139
+ *
140
+ * SECTION ORDER IS THE SMALLEST `order:` AMONG ITS PAGES — which, on a list already sorted by
141
+ * order, is simply the order in which sections first appear. Within a section the pages keep
142
+ * their `order:` order. One rule for the list, the breadcrumb and prev/next alike, so the
143
+ * console never shows two orders for one part.
144
+ */
145
+ export function bySection(pages) {
146
+ const runs = new Map();
147
+ for (const p of pages) {
148
+ if (!runs.has(p.section)) runs.set(p.section, []);
149
+ runs.get(p.section).push(p);
150
+ }
151
+ return [...runs.values()].flat();
152
+ }
153
+
125
154
  // ---- markdown tables as data ------------------------------------------------------------
126
155
 
127
156
  /** One table row's cells; a `\|` inside a cell is kept as a literal bar. */
@@ -259,8 +288,27 @@ function subCounts(state, body) {
259
288
 
260
289
  // ---- harness filtering -----------------------------------------------------------------
261
290
 
262
- const HARNESSES = ['opencode', 'claude'];
263
- const HARNESS_OPEN_RE = /^\s*<!--\s*harness:(opencode|claude)\s*-->\s*$/;
291
+ /**
292
+ * The Docs selector names a HOST; a tag names a host or a FAMILY. Bob and OpenClaude emit
293
+ * through the Claude engine, so a `claude` tag (page or block) is the Claude family and shows
294
+ * to all three of them, while `bob` / `openclaude` narrow to the one host. The same mapping as
295
+ * the console's host table (`web/src/lib/hosts.js`, its `docs` column).
296
+ */
297
+ export const HOST_FAMILY = { opencode: 'opencode', claude: 'claude', openclaude: 'claude', bob: 'claude' };
298
+ export const HARNESSES = Object.keys(HOST_FAMILY);
299
+ /**
300
+ * A page or block tagged `tag` shows to `host`. Untagged content shows to every host. One tag
301
+ * names a host or a family; a LIST names hosts exactly, no family widening, because picking
302
+ * some hosts of a family is the only thing a list says that one tag cannot:
303
+ * `["claude", "openclaude"]` is Claude Code and OpenClaude, not Bob.
304
+ */
305
+ export const harnessShows = (tag, host) => (Array.isArray(tag)
306
+ ? tag.includes(host)
307
+ : !tag || tag === host || tag === HOST_FAMILY[host]);
308
+ /** A block's tag: `claude` alone, or `claude,openclaude` — the list, read as above. */
309
+ const harnessTag = (raw) => (raw.includes(',') ? raw.split(/\s*,\s*/) : raw);
310
+ const HOST_ID = '(?:opencode|claude|openclaude|bob)';
311
+ const HARNESS_OPEN_RE = new RegExp(String.raw`^\s*<!--\s*harness:(${HOST_ID}(?:\s*,\s*${HOST_ID})*)\s*-->\s*$`);
264
312
  const HARNESS_CLOSE_RE = /^\s*<!--\s*\/harness\s*-->\s*$/;
265
313
  /**
266
314
  * The cheap presence test for the early-out. It must never be NARROWER than the open
@@ -272,7 +320,9 @@ const HARNESS_HINT_RE = /<!--\s*harness:/;
272
320
  export function normHarness(value, state) {
273
321
  const v = (value || '').trim().toLowerCase();
274
322
  if (HARNESSES.includes(v)) return v;
275
- return /^(claude|openclaude)/.test(String(state.emit || '')) ? 'claude' : 'opencode';
323
+ // Emit names start with their host id (`claude-global`, `bob`, …); `files` reads OpenCode's.
324
+ const emit = String(state.emit || '');
325
+ return HARNESSES.find((h) => emit.startsWith(h)) || 'opencode';
276
326
  }
277
327
 
278
328
  /**
@@ -309,7 +359,8 @@ export function harnessBlocksBalanced(lines) {
309
359
  * the marker; the console has already filtered the block to the reader's host, so there the
310
360
  * label only repeats what the host selector says, and it goes with the marker.
311
361
  */
312
- const HOST_LABEL_RE = /^\s*\*\((OpenCode|Claude Code) only\)\*\s*$/;
362
+ const HOST_NAME = '(?:OpenCode|Claude Code|OpenClaude|IBM Bob)';
363
+ const HOST_LABEL_RE = new RegExp(String.raw`^\s*\*\(${HOST_NAME}(?: and ${HOST_NAME})* only\)\*\s*$`);
313
364
 
314
365
  export function stripHarnessBlocks(body, harnessName) {
315
366
  if (!HARNESS_HINT_RE.test(body)) return body;
@@ -329,7 +380,7 @@ export function stripHarnessBlocks(body, harnessName) {
329
380
  }
330
381
  if (!inFence) {
331
382
  const m = HARNESS_OPEN_RE.exec(line);
332
- if (m) { keep = m[1] === harnessName; afterOpen = true; continue; }
383
+ if (m) { keep = harnessShows(harnessTag(m[1]), harnessName); afterOpen = true; continue; }
333
384
  if (HARNESS_CLOSE_RE.test(line)) { keep = true; continue; }
334
385
  if (labelSlot && HOST_LABEL_RE.test(line)) continue;
335
386
  }
@@ -353,8 +404,8 @@ function splitLines(s) {
353
404
  function visibleGroups(harnessName) {
354
405
  const groups = [];
355
406
  for (const g of docGroups()) {
356
- if (g.harness && g.harness !== harnessName) continue;
357
- const pages = g.pages.filter((p) => !p.harness || p.harness === harnessName);
407
+ if (!harnessShows(g.harness, harnessName)) continue;
408
+ const pages = g.pages.filter((p) => harnessShows(p.harness, harnessName));
358
409
  if (pages.length) groups.push({ ...g, pages });
359
410
  }
360
411
  return groups;
@@ -510,7 +561,8 @@ export function apiDocs(state, harnessName = null) {
510
561
  const groups = visibleGroups(hn).map((g) => ({
511
562
  id: g.id,
512
563
  label: g.label,
513
- pages: g.pages.map((p) => ({ id: p.id, title: p.title, kind: p.kind })),
564
+ pages: g.pages.map((p) => ({ id: p.id, title: p.title, kind: p.kind, section: p.section,
565
+ ...(typeof p.description === 'string' ? { description: p.description } : {}) })),
514
566
  }));
515
567
  return { groups, harness: hn };
516
568
  }
package/js/web/routes.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * expose X?", which has nothing to do with sockets, daemons or gzip.
16
16
  */
17
17
  import { formatValue, isTruthy, parseJson } from '../lib/json.mjs';
18
- import { apiExcludesMutate, apiInstallToggle, apiMcpToggle, apiMemoryDelete, apiProfileSave, apiRulesMutate, apiRulesPromote, apiSelectView } from './actions.mjs';
18
+ import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcpToggle, apiMemoryDelete, apiProfileSave, apiRulesMutate, apiRulesPromote, apiSelectView } from './actions.mjs';
19
19
  import { apiActivityToggle } from './activity.mjs';
20
20
 
21
21
  /**
@@ -45,6 +45,10 @@ export const POST_ROUTES = new Map([
45
45
  ['/api/rules', [apiRulesMutate, true]],
46
46
  ['/api/rules/promote', [apiRulesPromote, true]],
47
47
  ['/api/profile', [apiProfileSave, true]],
48
+ // No `ok: false` arm: success returns `{ok: true, loop}`, and both refusals (unknown root,
49
+ // unknown preset) throw `NotFound`, which `doPost`'s catch answers 404 before this column is
50
+ // ever consulted — the same shape as `/api/memory/delete` and `/api/activity` above.
51
+ ['/api/loops/preset', [apiLoopsPresetMutate, false]],
48
52
  ]);
49
53
 
50
54
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "3.12.0",
4
+ "version": "3.13.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
@@ -3,8 +3,8 @@
3
3
  > {{DESC_EXPLORER}}
4
4
 
5
5
  ## When to dispatch
6
- - A question needs sweeping many files or directories, but you only want the
7
- conclusion — not the file contents in your context.
6
+ - A question needs sweeping many files or directories, but only the conclusion
7
+ belongs in the main context — not the file contents.
8
8
  - Locating where something lives, how a subsystem fits together, or gathering
9
9
  facts scattered across the repo.
10
10
  - Your main context is small and the expensive reading should happen elsewhere.
@@ -3,9 +3,9 @@
3
3
  > {{DESC_RESEARCHER}}
4
4
 
5
5
  ## When to dispatch
6
- - A question needs answers from *outside* the repository — the open web, a
7
- vendor's docs, an upgrade guide, a specification — and you want the verified
8
- findings, not the pages, in your context ({{DOCTRINE:context-economy}}).
6
+ - A question needs verified answers from *outside* the repository. The open web, a
7
+ vendor's docs, an upgrade guide, a specification: the main context gets the verified
8
+ findings, not the pages ({{DOCTRINE:context-economy}}).
9
9
  - The research {{SKILL}} is running and the host can run subagents: this seat
10
10
  does the fetching and cross-checking so the main context stays small.
11
11
  - A migrate, forge-mcp, or ingest run needs a spec or changelog read before
@@ -29,4 +29,6 @@ override rather than hiding it.
29
29
  Start from `_template.md`, or ask the `brick-forge` skill to draft one for an agent or skill you
30
30
  already have. Keep the body to 4-10 lines of direct instruction, and end it with how the model
31
31
  should choose among the outcomes you declared — that sentence is what turns a vague brief into a
32
- loop that actually terminates.
32
+ loop that actually terminates. If the brick finds something the next brick needs, write it to a
33
+ file in the OS temp directory and report that file's path with the outcome — the loop skill
34
+ records it as a note.
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: adr-challenge
3
+ description: Attack the ADR this iteration drafted; the user reviews the ADR that survives.
4
+ effect: read
5
+ agent: skeptic
6
+ gate: human
7
+ gateOn: pass
8
+ outcomes: pass, fail
9
+ ---
10
+ Read the ADR this iteration wrote as a skeptic reading it cold. Is a decision driver missing? Is
11
+ a rejected option strawmanned? Is a consequence — cost, risk, the way back — left unstated? Does
12
+ it contradict an accepted ADR in the same folder, or the code as it stands? A gate amendment in
13
+ LOOP.md's notes for this ADR is a finding: report `fail` until the ADR answers it.
14
+
15
+ Never edit any file — findings go back to `adr-draft`, which revises the ADR.
16
+
17
+ Report `fail` only on findings that would change the decision or its consequences, never on
18
+ wording or style: write each one to a file in the OS temp directory and report that file's path
19
+ with the outcome. Report `pass` otherwise — the user then reviews the ADR before the loop
20
+ continues: write its path and the decision in one line to a temp file and report that path.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: adr-draft
3
+ description: Write or revise one architecture decision record for the card's decision.
4
+ effect: mutate
5
+ agent: architect
6
+ outcomes: pass
7
+ ---
8
+ Write or revise the ADR file the card's write set names, in Nygard form: Status: Proposed,
9
+ Context, Decision, Consequences — the costs as well as the gains — and at least two options
10
+ considered, each with why it lost. Touch only that file; never change code.
11
+
12
+ If LOOP.md's notes carry `adr-challenge` findings or a gate amendment for this ADR, answer each
13
+ one: change the decision, or record it under Consequences.
14
+
15
+ Report `pass` once the ADR is written.