geneseed 3.12.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +1 -1
  5. package/adapters/claude-code/README.md +1 -1
  6. package/adapters/openclaude/README.md +1 -1
  7. package/adapters/opencode/README.md +3 -3
  8. package/adapters/opencode/plugins/geneseed-learn.js +10 -1
  9. package/bin/geneseed-cli.mjs +10 -14
  10. package/bin/geneseed-hook.mjs +22 -15
  11. package/docs/README.md +41 -22
  12. package/docs/concepts/agents.md +3 -1
  13. package/docs/concepts/collaboration.md +3 -1
  14. package/docs/concepts/exclusions.md +4 -2
  15. package/docs/concepts/footprint.md +3 -1
  16. package/docs/concepts/foreman-mode.md +3 -1
  17. package/docs/concepts/hook-gates.md +61 -0
  18. package/docs/concepts/hooks.md +8 -56
  19. package/docs/concepts/host-matrix.md +38 -0
  20. package/docs/concepts/hosts.md +6 -32
  21. package/docs/concepts/loop-branch.md +66 -0
  22. package/docs/concepts/loop-bricks.md +48 -0
  23. package/docs/concepts/loop-contracts.md +33 -0
  24. package/docs/concepts/loop-gates.md +30 -0
  25. package/docs/concepts/loop-notes.md +43 -0
  26. package/docs/concepts/loop-risk.md +36 -0
  27. package/docs/concepts/loop-templates.md +50 -0
  28. package/docs/concepts/loops.md +24 -116
  29. package/docs/concepts/lsp.md +2 -0
  30. package/docs/concepts/memory.md +3 -1
  31. package/docs/concepts/notebook.md +3 -1
  32. package/docs/concepts/rules.md +4 -2
  33. package/docs/concepts/skills.md +3 -1
  34. package/docs/concepts/themes.md +3 -1
  35. package/docs/extending.md +28 -13
  36. package/docs/guides/autostart.md +3 -1
  37. package/docs/guides/choose-your-setup.md +5 -80
  38. package/docs/guides/headless.md +5 -3
  39. package/docs/guides/install-agent-md.md +37 -0
  40. package/docs/guides/install-bob.md +20 -0
  41. package/docs/guides/install-claude-code.md +22 -0
  42. package/docs/guides/install-clone.md +34 -0
  43. package/docs/guides/install-openclaude.md +25 -0
  44. package/docs/guides/install-opencode.md +35 -0
  45. package/docs/guides/install-scripted.md +21 -0
  46. package/docs/guides/install.md +9 -118
  47. package/docs/guides/loop-finish.md +54 -0
  48. package/docs/guides/loop-running.md +66 -0
  49. package/docs/guides/loop-which-template.md +35 -0
  50. package/docs/guides/mcp-filesystem.md +41 -0
  51. package/docs/guides/mcp-gitlab.md +66 -0
  52. package/docs/guides/mcp-markitdown.md +36 -0
  53. package/docs/guides/mcp.md +8 -137
  54. package/docs/guides/migrate.md +3 -1
  55. package/docs/guides/project-context.md +4 -2
  56. package/docs/guides/read-non-markdown.md +17 -0
  57. package/docs/guides/run-a-loop.md +64 -0
  58. package/docs/guides/run-anywhere.md +2 -0
  59. package/docs/guides/setup-doctrines.md +55 -0
  60. package/docs/guides/setup-footprint.md +48 -0
  61. package/docs/guides/uninstall.md +3 -1
  62. package/docs/guides/upgrade.md +3 -1
  63. package/docs/guides/verify.md +3 -1
  64. package/docs/guides/wiki.md +3 -1
  65. package/docs/guides/worktree.md +3 -1
  66. package/docs/reference/about.md +3 -1
  67. package/docs/reference/cli.md +2 -0
  68. package/docs/reference/env-context.md +40 -0
  69. package/docs/reference/environment.md +10 -59
  70. package/docs/reference/glossary.md +3 -1
  71. package/docs/reference/loop-actions.md +20 -0
  72. package/docs/reference/opencode-plugins.md +14 -118
  73. package/docs/reference/plugin-activity.md +20 -0
  74. package/docs/reference/plugin-context.md +27 -0
  75. package/docs/reference/plugin-guard.md +27 -0
  76. package/docs/reference/plugin-learn.md +27 -0
  77. package/docs/reference/plugin-notify.md +20 -0
  78. package/docs/reference/plugin-ponytail.md +19 -0
  79. package/docs/reference/plugin-workflow.md +25 -0
  80. package/docs/reference/troubleshoot-doctor.md +50 -0
  81. package/docs/reference/troubleshoot-hooks.md +99 -0
  82. package/docs/reference/troubleshoot-install.md +108 -0
  83. package/docs/reference/troubleshoot-tools.md +56 -0
  84. package/docs/reference/troubleshoot-update.md +23 -0
  85. package/docs/reference/troubleshoot-web.md +66 -0
  86. package/docs/reference/troubleshooting.md +13 -337
  87. package/docs/understand/a-day.md +3 -1
  88. package/docs/understand/enforced-vs-asked.md +3 -1
  89. package/docs/understand/harness.md +2 -0
  90. package/docs/understand/machine-bob.md +17 -0
  91. package/docs/understand/machine-claude.md +35 -0
  92. package/docs/understand/machine-opencode.md +36 -0
  93. package/docs/understand/on-your-machine.md +12 -55
  94. package/docs/understand/take-it-out.md +13 -0
  95. package/docs/web-ui.md +35 -15
  96. package/harness.config.json +1 -1
  97. package/js/README.md +30 -13
  98. package/js/build/args.mjs +374 -0
  99. package/js/build/driver.mjs +137 -553
  100. package/js/build/emit-claude.mjs +2 -4
  101. package/js/build/generate.mjs +6 -3
  102. package/js/build/render.mjs +5 -9
  103. package/js/build/source.mjs +3 -11
  104. package/js/cli-table.json +27 -1
  105. package/js/hosts/hooks-context.mjs +431 -0
  106. package/js/hosts/hooks-learn.mjs +275 -0
  107. package/js/hosts/hooks-prims.mjs +108 -0
  108. package/js/hosts/hooks.mjs +50 -977
  109. package/js/hosts/hosts.mjs +12 -4
  110. package/js/hosts/installs.mjs +5 -6
  111. package/js/hosts/mcp.mjs +13 -49
  112. package/js/hosts/memory-files.mjs +181 -0
  113. package/js/hosts/native.mjs +43 -5
  114. package/js/hosts/settings.mjs +135 -414
  115. package/js/hosts/shim.mjs +373 -0
  116. package/js/inspect/checks-authoring.mjs +21 -828
  117. package/js/inspect/checks-build.mjs +32 -4
  118. package/js/inspect/checks-constitution.mjs +493 -0
  119. package/js/inspect/checks-counts.mjs +242 -0
  120. package/js/inspect/checks-repo.mjs +17 -8
  121. package/js/inspect/diff.mjs +4 -1
  122. package/js/inspect/doctor.mjs +2 -2
  123. package/js/inspect/inventory.mjs +4 -4
  124. package/js/inspect/scan.mjs +1 -1
  125. package/js/inspect/status.mjs +1 -1
  126. package/js/inspect/validate.mjs +48 -35
  127. package/js/loop/catalog.mjs +17 -4
  128. package/js/loop/cli.mjs +52 -16
  129. package/js/loop/graph.mjs +19 -1
  130. package/js/loop/registry.mjs +191 -0
  131. package/js/loop/score.mjs +46 -7
  132. package/js/loop/state.mjs +157 -19
  133. package/js/maintain/memory.mjs +1 -1
  134. package/js/maintain/migrate.mjs +18 -30
  135. package/js/maintain/setup.mjs +1 -1
  136. package/js/maintain/uninstall.mjs +47 -88
  137. package/js/ui/cli.mjs +12 -12
  138. package/js/web/actions.mjs +39 -4
  139. package/js/web/api.mjs +34 -21
  140. package/js/web/docs.mjs +87 -78
  141. package/js/web/routes.mjs +5 -1
  142. package/package.json +1 -1
  143. package/src/agents/explorer.md +2 -2
  144. package/src/agents/researcher.md +3 -3
  145. package/src/bricks/README.md +3 -1
  146. package/src/bricks/adr-challenge.md +20 -0
  147. package/src/bricks/adr-draft.md +15 -0
  148. package/src/bricks/characterize.md +17 -0
  149. package/src/bricks/ci-fix.md +2 -1
  150. package/src/bricks/ci-triage.md +20 -0
  151. package/src/bricks/compat-check.md +16 -0
  152. package/src/bricks/done-check.md +16 -0
  153. package/src/bricks/fitness-define.md +22 -0
  154. package/src/bricks/flake-check.md +17 -0
  155. package/src/bricks/flake-repro.md +18 -0
  156. package/src/bricks/identify.md +6 -3
  157. package/src/bricks/migration-plan.md +21 -0
  158. package/src/bricks/mutation-check.md +18 -0
  159. package/src/bricks/plan.md +6 -6
  160. package/src/bricks/red-test.md +16 -0
  161. package/src/bricks/review.md +5 -3
  162. package/src/bricks/spec.md +18 -0
  163. package/src/bricks/upgrade-scout.md +16 -0
  164. package/src/loops/api-endpoint.json +31 -0
  165. package/src/loops/architecture-decision.json +22 -0
  166. package/src/loops/bugfix.json +1 -0
  167. package/src/loops/ci-repair.json +19 -0
  168. package/src/loops/deps-upgrade.json +29 -0
  169. package/src/loops/enforce-architecture-rule.json +28 -0
  170. package/src/loops/feature.json +8 -5
  171. package/src/loops/fix-flaky-tests.json +30 -0
  172. package/src/loops/legacy-refactor.json +26 -0
  173. package/src/loops/legacy-tests.json +29 -0
  174. package/src/loops/refactor.json +5 -1
  175. package/src/loops/remove-dead-code.json +30 -0
  176. package/src/loops/spec-first-feature.json +29 -0
  177. package/src/loops/tdd.json +25 -0
  178. package/src/loops/update-contract.json +33 -0
  179. package/src/skills/brick-forge.md +3 -1
  180. package/src/skills/bruno-collection-generator.md +3 -3
  181. package/src/skills/bruno-test-writer.md +3 -3
  182. package/src/skills/ci-fix.md +3 -3
  183. package/src/skills/consolidate-memory.md +4 -4
  184. package/src/skills/council.md +1 -1
  185. package/src/skills/fresh-eyes.md +1 -1
  186. package/src/skills/git-archaeology.md +1 -1
  187. package/src/skills/git-rescue.md +4 -3
  188. package/src/skills/handoff.md +1 -1
  189. package/src/skills/herdr.md +1 -1
  190. package/src/skills/learn-mode.md +2 -2
  191. package/src/skills/loop.md +22 -10
  192. package/src/skills/opencode-theme.md +3 -3
  193. package/src/skills/ponytail.md +1 -1
  194. package/src/skills/profile.md +3 -4
  195. package/src/skills/prose.md +2 -3
  196. package/src/skills/quiz.md +1 -1
  197. package/src/skills/react-view-transitions/SKILL.md +1 -1
  198. package/src/skills/react-view-transitions/VENDOR.md +8 -3
  199. package/src/skills/research.md +3 -3
  200. package/src/skills/review-response.md +2 -2
  201. package/src/skills/rule.md +3 -3
  202. package/src/skills/security-audit.md +4 -4
  203. package/src/skills/teach.md +1 -1
  204. package/src/skills/wiki.md +3 -3
  205. package/src/skills/workflow.md +4 -4
  206. package/src/skills/worktree.md +2 -2
  207. package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
  208. package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
  209. package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
  210. package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
  211. package/web/dist/assets/{GreenhouseView-C6ibc8IE.js → GreenhouseView-6KDjX8da.js} +1 -1
  212. package/web/dist/assets/{Installs-DRYjYnxs.js → Installs-MWuhNjSm.js} +2 -2
  213. package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
  214. package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
  215. package/web/dist/assets/Library-DOkO0TtB.js +1 -0
  216. package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
  217. package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-X2a3qvOh.js} +14 -14
  218. package/web/dist/assets/{OperatorHudView-Bs7paWmh.js → OperatorHudView-BPP1dtLz.js} +1 -1
  219. package/web/dist/assets/{Personal-BY5n4gTw.js → Personal-26VPYkK0.js} +1 -1
  220. package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
  221. package/web/dist/assets/{usePoll-O0sJEw1Y.js → activity-Dr7ZMBI3.js} +1 -1
  222. package/web/dist/assets/{api-CyMRbU2x.js → api-D_ixdyEY.js} +1 -1
  223. package/web/dist/assets/drawable-zVVDbsyw.js +1 -0
  224. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  225. package/web/dist/assets/index-KHt3wE_K.js +2 -0
  226. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  227. package/web/dist/index.html +3 -3
  228. package/web/src/pages/Laws.jsx +0 -1
  229. package/web/dist/assets/Activity-GaaZWXTY.js +0 -1
  230. package/web/dist/assets/ActivityDetail-4t7d45Du.js +0 -1
  231. package/web/dist/assets/Dashboard-CYn7Tp16.js +0 -2
  232. package/web/dist/assets/Docs-BeFKLg2T.js +0 -5
  233. package/web/dist/assets/JournalView-1HbU1mfg.js +0 -1
  234. package/web/dist/assets/Laws-DFoG8JWB.js +0 -3
  235. package/web/dist/assets/Library-DDjgNwz_.js +0 -1
  236. package/web/dist/assets/Loops-D9VEoO0F.js +0 -1
  237. package/web/dist/assets/Tabs-ho-EYD8i.js +0 -1
  238. package/web/dist/assets/drawable-CLS9drMb.js +0 -1
  239. package/web/dist/assets/index-CX_hXMi5.css +0 -1
  240. package/web/dist/assets/index-Cjr7XCA3.js +0 -2
package/js/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,36 @@ 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. `name` is how a
295
+ * host-only passage's visible label spells the host. One row per host: the family table, the
296
+ * host list and both marker patterns below are read off it.
297
+ */
298
+ const DOC_HOSTS = {
299
+ opencode: { family: 'opencode', name: 'OpenCode' },
300
+ claude: { family: 'claude', name: 'Claude Code' },
301
+ openclaude: { family: 'claude', name: 'OpenClaude' },
302
+ bob: { family: 'claude', name: 'IBM Bob' },
303
+ };
304
+ export const HOST_FAMILY = Object.fromEntries(
305
+ Object.entries(DOC_HOSTS).map(([id, h]) => [id, h.family]));
306
+ export const HARNESSES = Object.keys(DOC_HOSTS);
307
+ const anyOf = (words) => `(?:${words.join('|')})`;
308
+ /**
309
+ * A page or block tagged `tag` shows to `host`. Untagged content shows to every host. One tag
310
+ * names a host or a family; a LIST names hosts exactly, no family widening, because picking
311
+ * some hosts of a family is the only thing a list says that one tag cannot:
312
+ * `["claude", "openclaude"]` is Claude Code and OpenClaude, not Bob.
313
+ */
314
+ export const harnessShows = (tag, host) => (Array.isArray(tag)
315
+ ? tag.includes(host)
316
+ : !tag || tag === host || tag === HOST_FAMILY[host]);
317
+ /** A block's tag: `claude` alone, or `claude,openclaude` — the list, read as above. */
318
+ const harnessTag = (raw) => (raw.includes(',') ? raw.split(/\s*,\s*/) : raw);
319
+ const HOST_ID = anyOf(HARNESSES);
320
+ const HARNESS_OPEN_RE = new RegExp(String.raw`^\s*<!--\s*harness:(${HOST_ID}(?:\s*,\s*${HOST_ID})*)\s*-->\s*$`);
264
321
  const HARNESS_CLOSE_RE = /^\s*<!--\s*\/harness\s*-->\s*$/;
265
322
  /**
266
323
  * The cheap presence test for the early-out. It must never be NARROWER than the open
@@ -272,15 +329,17 @@ const HARNESS_HINT_RE = /<!--\s*harness:/;
272
329
  export function normHarness(value, state) {
273
330
  const v = (value || '').trim().toLowerCase();
274
331
  if (HARNESSES.includes(v)) return v;
275
- return /^(claude|openclaude)/.test(String(state.emit || '')) ? 'claude' : 'opencode';
332
+ // Emit names start with their host id (`claude-global`, `bob`, …); `files` reads OpenCode's.
333
+ const emit = String(state.emit || '');
334
+ return HARNESSES.find((h) => emit.startsWith(h)) || 'opencode';
276
335
  }
277
336
 
278
337
  /**
279
338
  * Every open has a matching close, with no nesting.
280
339
  *
281
- * A marker inside a ``` fence is example text, not a marker (the same rule `sliceSection`
282
- * uses). Its purpose is to FAIL OPEN: an unbalanced marker leaves the body untouched, so a
283
- * typo can never blank the rest of a page.
340
+ * A marker inside a ``` fence is example text, not a marker (the same rule the frontend's
341
+ * outline uses). Its purpose is to FAIL OPEN: an unbalanced marker leaves the body untouched,
342
+ * so a typo can never blank the rest of a page.
284
343
  *
285
344
  * EXPORTED FOR THE AUTHORING GATE, in the shape `installAgentEntryOf` established: failing
286
345
  * open means a typo is INVISIBLE in the rendered output — the page simply shows everything,
@@ -309,7 +368,8 @@ export function harnessBlocksBalanced(lines) {
309
368
  * the marker; the console has already filtered the block to the reader's host, so there the
310
369
  * label only repeats what the host selector says, and it goes with the marker.
311
370
  */
312
- const HOST_LABEL_RE = /^\s*\*\((OpenCode|Claude Code) only\)\*\s*$/;
371
+ const HOST_NAME = anyOf(Object.values(DOC_HOSTS).map((h) => h.name));
372
+ const HOST_LABEL_RE = new RegExp(String.raw`^\s*\*\(${HOST_NAME}(?: and ${HOST_NAME})* only\)\*\s*$`);
313
373
 
314
374
  export function stripHarnessBlocks(body, harnessName) {
315
375
  if (!HARNESS_HINT_RE.test(body)) return body;
@@ -329,7 +389,7 @@ export function stripHarnessBlocks(body, harnessName) {
329
389
  }
330
390
  if (!inFence) {
331
391
  const m = HARNESS_OPEN_RE.exec(line);
332
- if (m) { keep = m[1] === harnessName; afterOpen = true; continue; }
392
+ if (m) { keep = harnessShows(harnessTag(m[1]), harnessName); afterOpen = true; continue; }
333
393
  if (HARNESS_CLOSE_RE.test(line)) { keep = true; continue; }
334
394
  if (labelSlot && HOST_LABEL_RE.test(line)) continue;
335
395
  }
@@ -353,23 +413,21 @@ function splitLines(s) {
353
413
  function visibleGroups(harnessName) {
354
414
  const groups = [];
355
415
  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);
416
+ if (!harnessShows(g.harness, harnessName)) continue;
417
+ const pages = g.pages.filter((p) => harnessShows(p.harness, harnessName));
358
418
  if (pages.length) groups.push({ ...g, pages });
359
419
  }
360
420
  return groups;
361
421
  }
362
422
 
363
- // ---- slugs and section slicing ----------------------------------------------------------
423
+ // ---- heading slugs -----------------------------------------------------------------------
364
424
 
365
425
  const SLUG_STRIP_RE = new RegExp(`[^a-z0-9${WHITESPACE}-]`, 'g');
366
426
  const SLUG_WS_RE = new RegExp(`[${WHITESPACE}]+`, 'g');
367
427
  const SLUG_DASH_RE = /-+/g;
368
- const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
369
428
 
370
429
  /**
371
- * The same rules the frontend's `slug()` uses, so a registry `anchor` written against a
372
- * heading matches the id the renderer assigns.
430
+ * The same rules the frontend's `slug()` uses for the id it gives a rendered heading.
373
431
  *
374
432
  * `WHITESPACE` and `stripWhitespace` rather than `\s` and `trim()` — see `WHITESPACE`'s own
375
433
  * docblock for the measured set of characters where this matters.
@@ -380,45 +438,6 @@ export function slugifyHeading(text) {
380
438
  return s.replace(/^-+|-+$/g, '');
381
439
  }
382
440
 
383
- /**
384
- * The body trimmed to one heading's section.
385
- *
386
- * Returns `[body, ok]`; `ok === false` (with the ORIGINAL body) when the anchor is
387
- * missing, so the caller falls back to the whole document. An H1 slice stops at the first
388
- * H2 — `max(level, 2)` — so it captures an intro paragraph rather than the whole file.
389
- */
390
- export function sliceSection(body, anchor) {
391
- const lines = splitLines(body);
392
- let start = -1;
393
- let startLevel = 0;
394
- let inFence = false;
395
- for (let i = 0; i < lines.length; i += 1) {
396
- const ln = lines[i];
397
- if (ln.startsWith('```')) { inFence = !inFence; continue; }
398
- if (inFence) continue;
399
- const m = HEADING_RE.exec(ln);
400
- if (!m) continue;
401
- if (slugifyHeading(m[2]) === anchor) {
402
- start = i;
403
- startLevel = Math.max(m[1].length, 2);
404
- break;
405
- }
406
- }
407
- if (start < 0) return [body, false];
408
- const out = [lines[start]];
409
- inFence = false;
410
- for (let j = start + 1; j < lines.length; j += 1) {
411
- const ln = lines[j];
412
- if (ln.startsWith('```')) { inFence = !inFence; out.push(ln); continue; }
413
- if (inFence) { out.push(ln); continue; }
414
- const m = HEADING_RE.exec(ln);
415
- if (m && m[1].length <= startLevel) break;
416
- out.push(ln);
417
- }
418
- while (out.length && out[out.length - 1].trim() === '') out.pop();
419
- return [`${out.join('\n')}\n`, true];
420
- }
421
-
422
441
  // ---- the endpoints -----------------------------------------------------------------------
423
442
 
424
443
  function findDocPage(pageId) {
@@ -472,23 +491,20 @@ function glossary(state, page) {
472
491
  * what gates the github-shaped deep links in the UI. Both come from `originDisplay()`,
473
492
  * which shells out to `git remote get-url` (the `git` row in `ALLOWED_SPAWNS`).
474
493
  *
475
- * `version` IS NOT WHAT IT LOOKS LIKE, and it is the shape this page has always had rather than
476
- * a shortcut here. It reads a `version` key off the status snapshot and THERE IS NO SUCH KEY —
477
- * the snapshot spells the fingerprints `source_fp` / `installed_fp` / `version_verdict`. So the
478
- * field is `{}`, always, and `sd.version || {}` reproduces the EXPRESSION rather than the
479
- * constant: if the key ever appears, the page gains it without another edit.
494
+ * `version` carries the status snapshot's fingerprints under the names the About page reads.
495
+ * It used to read a `version` key the snapshot never had, so the page's build rows were
496
+ * always empty; the snapshot spells them `installed_fp` / `source_fp` / `version_verdict`.
480
497
  *
481
498
  * THERE IS NO INTERPRETER FIELD, and the absence is deliberate — see `apiSetup` in
482
499
  * `api.mjs`, which spells the same absence for the same reason.
483
- *
484
- * `statusData()` is called for one dead field, and deliberately: this page has always paid that
485
- * cost, and skipping it would answer faster in a way no gate measures and no user asked for.
486
500
  */
487
501
  function about(state) {
488
502
  const sd = statusData();
489
503
  const od = originDisplay();
490
504
  return {
491
- version: sd.version || {},
505
+ version: {
506
+ installed_fp: sd.installed_fp, source_fp: sd.source_fp, verdict: sd.version_verdict,
507
+ },
492
508
  theme: state.theme,
493
509
  emit: state.emit,
494
510
  deployed: deployed(state),
@@ -510,7 +526,8 @@ export function apiDocs(state, harnessName = null) {
510
526
  const groups = visibleGroups(hn).map((g) => ({
511
527
  id: g.id,
512
528
  label: g.label,
513
- pages: g.pages.map((p) => ({ id: p.id, title: p.title, kind: p.kind })),
529
+ pages: g.pages.map((p) => ({ id: p.id, title: p.title, kind: p.kind, section: p.section,
530
+ ...(typeof p.description === 'string' ? { description: p.description } : {}) })),
514
531
  }));
515
532
  return { groups, harness: hn };
516
533
  }
@@ -538,18 +555,10 @@ export function apiDocsPage(state, pageId, harnessName = null) {
538
555
  */
539
556
  const KIND_ROUTES = {
540
557
  markdown: (state, pageId, page, hn) => {
541
- let body = readDocSource(page.source);
542
- let anchor = page.anchor ?? null;
543
- // A successful slice drops the anchor: the heading is already at the top, so the
544
- // client must not also try to scroll to it.
545
- if (anchor && page.slice) {
546
- const [sliced, ok] = sliceSection(body, anchor);
547
- body = sliced;
548
- if (ok) anchor = null;
549
- }
550
- body = rewriteDocLinks(stripHarnessBlocks(body, hn), page.source, idsByRel());
558
+ const body = rewriteDocLinks(stripHarnessBlocks(readDocSource(page.source), hn),
559
+ page.source, idsByRel());
551
560
  return { id: pageId, title: page.title, kind: 'markdown', body,
552
- source: page.source, anchor, links: resolveLinks(state, body) };
561
+ source: page.source, links: resolveLinks(state, body) };
553
562
  },
554
563
  concept: (state, pageId, page, hn) => {
555
564
  const body = rewriteDocLinks(subCounts(state, stripHarnessBlocks(page.body ?? '', hn)),
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.14.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.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: characterize
3
+ description: Pin what legacy code does today with characterization tests, touching tests only.
4
+ effect: mutate
5
+ agent: tester
6
+ outcomes: pass, fail
7
+ ---
8
+ For the card's change point (with no card, the code the requirement names), write tests that pin
9
+ what the code DOES today, not what it should do: assert a value you expect to be wrong, run it,
10
+ and copy the observed value in. Touch only test files — never production code.
11
+
12
+ Pin odd behaviour as it is and name it in the test's title; do not fix it here. If LOOP.md's notes
13
+ list surviving mutants for this card, add the assertions that kill them.
14
+
15
+ Report `pass` once the new tests run green against the unchanged code.
16
+ Report `fail` if the code cannot be put under test without breaking a dependency first — that is
17
+ a refactor in its own right — naming the dependency.
@@ -11,7 +11,8 @@ guess from the check's title, before changing anything.
11
11
  Touch only what that failure requires; this is a repair, not a chance to improve the pipeline
12
12
  along the way.
13
13
 
14
- Report `pass` once the named check is fixed and green.
14
+ Report `pass` once the named check is fixed and green LOCALLY — CI itself is re-read after the
15
+ push, not here.
15
16
 
16
17
  Report `fail` if the check cannot be fixed from this repo alone (an infra outage, a flake with no
17
18
  reproducible cause, a required secret that is missing), naming why.
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: ci-triage
3
+ description: Read the failing CI checks for this commit and pick one code failure to fix, or stop.
4
+ effect: read
5
+ agent: explorer
6
+ outcomes: more, done
7
+ ---
8
+ Key on the commit, not the branch: `gh run list --commit <HEAD sha> --json databaseId,status,conclusion`.
9
+ A commit pushed moments ago may not have a run yet — poll that command for up to ~2 minutes
10
+ before concluding none exists. Wait on a pending run with `gh run watch <id>`, then read
11
+ `gh run view <id> --log-failed` — never guess from a check's title. Classify each failure as
12
+ code, flaky or infra. Never edit any file. `ci-repair` needs CI to run on `loop/*` branches: if
13
+ still no run after that poll, write "CI does not run for <sha>" to a file in the OS temp
14
+ directory and report `done` with that file's path — never as green.
15
+
16
+ Write the classification, one failure per line, to a file in the OS temp directory and report
17
+ that file's path with the outcome. Report `more` with a card for ONE code failure:
18
+ {"intent": "<fix check X: cause>", "writeSet": ["<files the fix touches>"],
19
+ "actions": ["<each of: format, imports, rename, logic, new-file, api, delete, architecture>"]}
20
+ Report `done` when CI is green for HEAD, or only flaky or infra failures remain — naming them.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: compat-check
3
+ description: Confirm consumers of the old and the new interface both still work after this iteration.
4
+ effect: read
5
+ agent: tester
6
+ outcomes: pass, fail
7
+ ---
8
+ Run the tests of every consumer this change can reach — those still on the old shape as well as
9
+ those already moved — and, where the repo records consumer contracts (Pact, Spring Cloud
10
+ Contract), the provider verification against them. In a contract step, where the old shape is
11
+ removed on purpose, check instead that no consumer still uses it. Never edit any file: send
12
+ reports to the OS temp directory.
13
+
14
+ Report `pass` if every consumer works. Report `fail` naming the broken consumer, the check and
15
+ its raw failure, written to a file in the OS temp directory — report that file's path with the
16
+ outcome.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: done-check
3
+ description: Verify the requirement is met — every acceptance criterion proven by a passing test or a stated manual check.
4
+ effect: read
5
+ agent: reviewer
6
+ outcomes: pass, fail
7
+ ---
8
+ Read the requirement's acceptance criteria — `specs/<slug>/spec.md` when the branch has one, else
9
+ the requirement in LOOP.md — and the branch's full diff against its base. Run the suite. For
10
+ every criterion, name the test that proves it, or the manual check (what to do, what to see) when
11
+ no test can. Never edit any file. Judge only the stated criteria — never style or extras.
12
+
13
+ Report `pass` only if every criterion maps to a passing test or a stated manual check.
14
+ Report `fail` otherwise: write each unmet criterion, one per line, with what is missing (no test,
15
+ a failing test, a half-built behaviour) to a file in the OS temp directory and report that file's
16
+ path — the next iteration's `identify` builds from it.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: fitness-define
3
+ description: Encode the requirement's architecture rule as a test, with an allowlist of today's violations.
4
+ effect: mutate
5
+ agent: architect
6
+ gate: human
7
+ outcomes: pass, fail
8
+ ---
9
+ Turn the requirement's architecture rule into an executable test in the suite, with the repo's
10
+ own tools first: ArchUnit for Java (a `FreezingArchRule`, whose violation store is the
11
+ allowlist), then dependency-cruiser, import-linter, eslint-plugin-boundaries or size-limit.
12
+ Record today's violations in an allowlist beside the test so the suite stays green — each later
13
+ iteration removes one entry. Touch only the test, its allowlist and the tool's test-scoped setup,
14
+ and declare the tool's generated store as a glob in the write set (`--write-set '**/archunit_store/**'`
15
+ — single-quote it, the same as any glob in `--write-set`).
16
+
17
+ The user reviews the rule before the loop continues: write the rule in one sentence, the test's
18
+ path, the allowlist's path and its entry count to a file in the OS temp directory and report that
19
+ file's path with the outcome.
20
+
21
+ Report `pass` once the test runs green with the allowlist in place. Report `fail` if the rule
22
+ cannot be checked deterministically, with the note file naming what is ambiguous.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: flake-check
3
+ description: Rerun the card's test until it proves stable — a retry or a sleep is not a fix.
4
+ effect: read
5
+ agent: tester
6
+ outcomes: stable, flaky
7
+ ---
8
+ Rerun the card's test 20 times alone, then 20 with its suite, as `flake-repro` did (Maven: a
9
+ loop of `mvn test -Dtest=<Class>#<method>`, shuffled). Never edit any file.
10
+
11
+ Read this iteration's diff first. Polling on the awaited condition (Awaitility
12
+ `await().atMost(…).until(…)`, `waitFor`) in place of a fixed sleep is a fix; adding a sleep or
13
+ raising an existing timeout is not, and neither is a retry (`rerunFailingTestsCount`,
14
+ `@RetryingTest`, `jest.retryTimes`) — report `flaky` naming it, whatever the runs say.
15
+
16
+ Report `stable` only on 0 failures in all runs. Report `flaky` otherwise, with the failure count
17
+ and one failure's output in a file in the OS temp directory — report that file's path.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: flake-repro
3
+ description: Confirm which suspected tests are flaky by repeated runs, before anything is fixed.
4
+ effect: read
5
+ agent: tester
6
+ outcomes: flaky, stable, broken
7
+ ---
8
+ Run each suspected test the requirement names 20 times (or the N it gives) alone, then 20 with
9
+ its suite in a shuffled order — no code change, no retry option. Maven: loop `mvn test
10
+ -Dtest=<Class>#<method>` (multi-module: add `-pl <module> -Dsurefire.failIfNoSpecifiedTests=false`),
11
+ shuffled with `-Dsurefire.runOrder=random` and, under JUnit 5,
12
+ `-Djunit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random`. Never
13
+ `-Dsurefire.rerunFailingTestsCount`: it hides the failures you count. Never edit any file. Write
14
+ each test as `<test>: <failures>/<runs>` with one failure's output to a file in the OS temp
15
+ directory and report that file's path.
16
+
17
+ Report `broken` if any test fails every run — a bug for `bugfix`, not a flake. Report `flaky` if a
18
+ test failed some runs, listing the confirmed ones. Report `stable` if none failed.
@@ -9,12 +9,15 @@ Read the requirement and the loop's history (LOOP.md, `git log --format=%(traile
9
9
  branch). If the requirement is fully met and the suite is green, report `done`.
10
10
 
11
11
  Otherwise pick the SMALLEST next change that moves the requirement forward and can be tested on
12
- its own. Report `more` with a card, as JSON:
13
- {"intent": "<one line>", "writeSet": ["<every file you expect to touch>"],
12
+ its own — when the branch has `specs/*/spec.md`, its next unfinished task. If the latest note is
13
+ `done-check`'s unmet criteria, report `more` with a card for one of them, never `done`. Report
14
+ `more` with a card, as JSON:
15
+ {"intent": "<one line>", "writeSet": ["<every file ANY brick this iteration writes, tests/docs too>"],
14
16
  "actions": ["<each of: format, imports, rename, logic, new-file, api, delete, architecture>"]}
15
17
 
16
18
  Declare every action honestly: the diff is scored again before commit, and anything outside the
17
19
  write set or under-declared escalates to a blocking stop. If LOOP.md's notes say the previous
18
- attempt was re-split, make this card strictly smaller than that one.
20
+ attempt was re-split, make this card strictly smaller than that one. The card obeys every
21
+ instruction in the template's `rules` (e.g. its intent cites the proof that code is unused).
19
22
 
20
23
  Never edit, create or delete any file: this brick only reads.
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: migration-plan
3
+ description: Plan a breaking interface change as expand, migrate each consumer, contract — for the user to review.
4
+ effect: read
5
+ skill: migrate
6
+ gate: human
7
+ outcomes: pass, fail
8
+ ---
9
+ Following the migrate skill, plan the change as ordered steps: expand (add the new shape beside
10
+ the old), migrate (one consumer or call-site group per step), contract (remove the old shape).
11
+ Every step must leave the system deployable and fit one `identify` card. Name the contract files
12
+ the expand and contract steps touch — API specs, schema migrations, event schemas. Never edit any
13
+ file.
14
+
15
+ The user reviews the plan before the loop continues: write the steps, one per line, then the
16
+ contract files, to a file in the OS temp directory and report that file's path with the outcome.
17
+ Those files belong in LOOP.md's `contracts`, so the expand and contract steps stop for a human:
18
+ the user adds any the template's defaults miss to LOOP.md's `contracts` before answering ok.
19
+
20
+ Report `pass` with the plan. Report `fail` if no backward-compatible path exists, naming what
21
+ forces the break.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: mutation-check
3
+ description: Prove the new tests bite, by mutation testing scoped to the code the card names.
4
+ effect: read
5
+ agent: tester
6
+ outcomes: pass, fail, unavailable
7
+ ---
8
+ Run the project's mutation tool scoped to the production code the card's intent names. Java: PIT
9
+ first — `mvn test-compile org.pitest:pitest-maven:mutationCoverage -DtargetClasses=<fq.Class*>
10
+ -DreportsDirectory=<OS temp dir> -DtimestampedReports=false`, or Gradle's `pitest` task; else
11
+ Stryker, mutmut or cargo-mutants. Never edit any file — reports and caches (.mutmut-cache,
12
+ mutants.out/, .stryker-tmp/) go to the OS temp dir, or are removed.
13
+
14
+ Report `pass` if no surviving mutant sits on a line the new tests target. Report `fail` with each
15
+ survivor as `file:line`, the mutation, and the assertion that would kill it, in a file in the OS
16
+ temp directory — report its path. Report `unavailable` when no tool is configured — the pom lacks
17
+ `pitest-junit5-plugin` under JUnit 5, or Gradle does not apply `info.solidsoft.pitest`: never add
18
+ it to the build; write what you checked to a temp file and report its path.
@@ -8,10 +8,10 @@ outcomes: pass
8
8
  Read the requirement and survey the code it touches. Split the work into an ordered list of
9
9
  small iterations, each one sized to a single `identify` card — one testable change apiece.
10
10
 
11
- Do not edit any code; this brick only plans it. Report `pass` with a card whose intent carries
12
- the ordered list, one iteration per line, so it lands in LOOP.md's notes for `identify` to work
13
- through one at a time:
14
- {"intent": "<iteration 1>\n<iteration 2>\n...", "writeSet": [], "actions": []}
11
+ Do not edit any code; this brick only plans it. `plan` is not an iteration head, so its card is
12
+ never recorded — write the ordered list, one iteration per line, to a file in the OS temp
13
+ directory (never inside the worktree) and report that file's path with the outcome, so it is
14
+ recorded into LOOP.md's notes. Report `pass`.
15
15
 
16
- If the requirement cannot be split — it is already one atomic change — say so in the intent and
17
- let the loop proceed with a single iteration.
16
+ If the requirement cannot be split — it is already one atomic change — say so in the note file
17
+ and let the loop proceed with a single iteration.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: red-test
3
+ description: Write the failing test for the card's intent before any production code changes.
4
+ effect: mutate
5
+ agent: tester
6
+ outcomes: red, green, untestable
7
+ ---
8
+ Write the smallest test that expresses the card's intent at its seam, touching only the test
9
+ files in the card's `writeSet` — never production code; making it pass is `apply`'s job. Run it.
10
+
11
+ Report `red` if it fails FOR THE REASON the intent states — not an import error, a typo, or a
12
+ broken fixture in the test itself.
13
+ Report `green` if it already passes: the behaviour exists, so keep the test as coverage.
14
+ Report `untestable` if no seam lets a test reach the behaviour without changing production code
15
+ first, naming what blocks it. If the card's `writeSet` names no test file, report `untestable`
16
+ and say so.