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/docs/web-ui.md CHANGED
@@ -111,27 +111,47 @@ streams the output of background jobs and keeps their history across reloads.
111
111
  **Promote to rule** control turns a recurring lesson into a trial rule in
112
112
  `user-rules.md` — provenance recorded, the source fact deleted so it isn't loaded
113
113
  twice — landing you on the Rules page.
114
- - **Loops** (`#/loops`) — the loop catalogue `geneseed loop` runs from, read-only.
115
- **Templates** draws the selected template as a ring: the iteration turning clockwise from
116
- its head, each inner retry loop hung outside on its head node, the ⛨ *validate* gate the
117
- engine adds before the first mutate step, setup entering from the left and `done → close`
118
- leaving from the head; a card per brick sits under it, and clicking a node scrolls to its
119
- card. A graph a ring cannot draw cleanly (more than eight iteration steps, a node in more
120
- than three loops) falls back to left-to-right boxes. **Bricks** lists every brick with its
121
- origin (shipped / global / project), what it overrides, why it is unavailable, and its
122
- source. **Active** (live runs) is announced for v2 and disabled. Reads `GET /api/loops`,
123
- resolved from the selected install's root, so a project install shows its
124
- `.geneseed/` bricks and templates.
114
+ - **Loops** (`#/loops`) — the loop catalogue `geneseed loop` runs from, read-only, laid out
115
+ like the Library: a rail of sections (**Templates**, **Bricks**, **Active**, each with its
116
+ count; `#/loops/<section>/<item>` opens an entry), with the selected section's groups
117
+ nested under it in the rail (template categories, brick origins, run statuses — pick one to
118
+ narrow the list), a filterable list, and a reader.
119
+ **Templates** are grouped by their `category` (architecture, tests, development,
120
+ refactoring, day-to-day, then *Other*), a *human gate* pill on any that stops for you. The
121
+ reader draws the selected template as a ring: the iteration turning clockwise from its head,
122
+ each inner retry loop hung outside on its head node, the ⛨ *validate* gate the engine adds
123
+ before the first mutate step, setup entering from the left and `done → close` leaving from
124
+ the head; its `rules` follow, then a compact row per brick (effect, agent or skill, outcomes,
125
+ human gate), and clicking a node scrolls to and lights its row. A graph a ring cannot draw
126
+ cleanly (more than eight iteration steps, a node in more than three loops) falls back to
127
+ left-to-right boxes. **Bricks** are grouped by origin (project / global / shipped), pilled
128
+ for *mutate*, *human gate*, what they override and *unavailable*; the reader shows the
129
+ frontmatter facts (outcomes as pills, availability with its reason), then the body rendered as
130
+ markdown, with a **View source** toggle for the file as parsed, frontmatter and all. Templates and
131
+ Bricks read `GET /api/loops`, resolved from the selected install's root, so a project
132
+ install shows its `.geneseed/` bricks and templates. **Active** is every loop
133
+ `geneseed loop init` registered on this machine (`GET /api/loops/active`, polled every
134
+ 5 s), grouped awaiting, running, done/stopped, finished/unreadable; the reader shows its
135
+ branch, `iteration N / max`, current node and a preset picker (`POST /api/loops/preset`,
136
+ the page's only write), what an awaiting loop waits on (launch / declared / actual / a
137
+ human gate, with its node) — the answer is given in the agent's session, never here — and
138
+ its ring with the current node filled, with its iteration history under it.
125
139
  - **Profile** (`#/profile`) — `PROFILE.md` beside the deployed AGENT.md: who you are and
126
140
  how you like to work. Opens rendered; **Edit** is a whole-file editor. The save carries
127
141
  the fingerprint it loaded — if an agent session changed the file first, your edit stays
128
142
  on screen and a second Save replaces the newer version.
129
143
  - **Skills** (`#/skills`) and **Agents** (`#/agents`) — the deployed catalogue, each entry
130
- with its purpose line and full spec.
144
+ with its purpose line and full spec. While Skills is selected, its skill types sit nested
145
+ under it in the rail, each with its count, above a thin bar of their shares; pick one to
146
+ narrow the list.
131
147
  - **Docs** (`#/docs`) — rendered documentation: markdown pages, concept pages, a CLI
132
- reference (generated from the harness argument parser), and a glossary, grouped into
133
- Get started / Core concepts / How-to / MCP servers / Plugins / Reference / Deeper. Filtered
134
- for one host family (OpenCode, or Claude Code — which Bob and OpenClaude share); with no
148
+ reference (generated from the harness argument parser), and a glossary, in the Library's
149
+ three panes: the parts (Understand / Guides / Concepts / Reference) with their page counts,
150
+ the open part's sections nested under it in the rail (pick one to narrow the list), the
151
+ part's pages under their sections with a filter, and the page with its breadcrumb,
152
+ outline and Previous / Next within the section. Filtered for one host (OpenCode, Claude
153
+ Code, Bob or OpenClaude; Claude-family pages show to all three Claude-engine hosts, a page
154
+ tagged with a list of hosts only to those); with no
135
155
  choice stored it follows the deployed install.
136
156
 
137
157
  ### 🩺 Care
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "3.12.0"
5
+ "version": "3.13.0"
6
6
  }
package/js/README.md CHANGED
@@ -140,12 +140,20 @@ transition, score and stop is computed here.
140
140
  | `catalog.mjs` | `loadCatalog` — bricks and loop templates from shipped/global/project origins, later overrides earlier BY NAME |
141
141
  | `state.mjs` | The engine itself: `initState`, `nextStep`, `scoreDeclared`/`scoreDiff`, `recordOutcome`, `decideAwaiting`, `renderLoopFile`/`parseLoopFile` |
142
142
  | `cli.mjs` | `geneseed loop check\|init\|next\|score\|record\|decide` — the only face either a model or a human reaches this engine through |
143
+ | `registry.mjs` | `loops.json` under XDG — the launched-loop identity list `loop init` records into and the Active tab's `activeLoops`/`setLoopPreset` read and rewrite |
143
144
 
144
145
  **Before editing:** `score.mjs` is imported by the build driver (the `--trust` choices), so it must
145
146
  stay free of every import but node builtins and `graph.mjs`. `state.mjs` excludes `LOOP.md` itself
146
- from every score and every read-brick porcelain check — it is the engine's own bookkeeping, never
147
- part of the work being scored. `cli.mjs` may not spawn: git output is piped in on stdin by the
148
- model, never read by this entry.
147
+ (and its own atomic-write sibling, `LOOP.md.tmp`) from every score and every read-brick porcelain
148
+ check — it is the engine's own bookkeeping, never part of the work being scored. Every LOOP.md
149
+ write goes through `state.mjs`'s `writeLoopFile`, never a bare `writeText`, so a killed process
150
+ cannot leave a torn file. `cli.mjs` may not spawn: git output is piped in on stdin by the model,
151
+ never read by this entry. `registry.mjs` swallows its own errors on `recordLoop`/`activeLoops` on
152
+ purpose, like `js/inspect/registry.mjs` — a registry hiccup must never fail `loop init` or a
153
+ read — but `setLoopPreset` does not: a preset change the caller asked for must be reported when it
154
+ fails. `activeLoops` saves the registry back only when the kept set actually changed (a pruned
155
+ root, a loop newly seen finished or seen running again) — a routine poll from the Active tab must
156
+ never be a write.
149
157
 
150
158
  ## `js/maintain/` — lifecycle
151
159
 
package/js/cli-table.json CHANGED
@@ -734,6 +734,19 @@
734
734
  "type": null,
735
735
  "hidden": false
736
736
  },
737
+ {
738
+ "names": ["--contracts"],
739
+ "dest": "contracts",
740
+ "metavar": null,
741
+ "help": "comma-separated contract globs appended to the template's own (init)",
742
+ "choices": null,
743
+ "default": null,
744
+ "required": false,
745
+ "nargs": null,
746
+ "is_flag": false,
747
+ "type": null,
748
+ "hidden": false
749
+ },
737
750
  {
738
751
  "names": ["--declared"],
739
752
  "dest": "declared",
@@ -846,7 +859,20 @@
846
859
  "names": ["--note"],
847
860
  "dest": "note",
848
861
  "metavar": null,
849
- "help": "a note attached to the verdict, written into LOOP.md",
862
+ "help": "a note attached to a record or a decide verdict, written into LOOP.md so it survives the unit",
863
+ "choices": null,
864
+ "default": null,
865
+ "required": false,
866
+ "nargs": null,
867
+ "is_flag": false,
868
+ "type": null,
869
+ "hidden": false
870
+ },
871
+ {
872
+ "names": ["--note-file"],
873
+ "dest": "note_file",
874
+ "metavar": null,
875
+ "help": "record's note read from this file instead of the command line (CRLF folded, trimmed) — for a multi-line note or one that mentions git commit/push",
850
876
  "choices": null,
851
877
  "default": null,
852
878
  "required": false,
package/js/hosts/mcp.mjs CHANGED
@@ -68,7 +68,7 @@ export const MCP_PRESETS = {
68
68
  + 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
69
69
  + 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
70
70
  + 'copy this block by hand under a new name and point it at the other URL — '
71
- + 'docs/guides/mcp.md has the pattern.',
71
+ + 'docs/guides/mcp-gitlab.md has the pattern.',
72
72
  block: {
73
73
  type: 'local',
74
74
  command: ['npx', '-y', '@zereight/mcp-gitlab'],
@@ -149,12 +149,50 @@ function triggerOf(text) {
149
149
  * 900-char cap the catalogue weighed 22k chars (~5.6k tokens) across 51 skills, three times
150
150
  * the figure the footprint page quoted. The first sentence says WHEN; the later sentences
151
151
  * disambiguate, and the body — read as soon as the skill is chosen — still carries them.
152
- * Cut at the last sentence end inside the cap, never mid-word.
152
+ * Cut at the last sentence end inside the cap, never mid-word. The `…` fallback (no sentence
153
+ * end inside the cap) is a truncated routing signal: `checkBuild` reports it as a doctor
154
+ * failure, so the cure is a shorter first trigger sentence in the source, never a wider cap.
153
155
  */
154
156
  const DESCRIPTION_CAP = 320;
155
157
  function skillDescription(text) {
156
- const desc = descOf(text);
157
- const trig = triggerOf(text);
158
+ return withWhen(descOf(text), triggerOf(text));
159
+ }
160
+
161
+ /**
162
+ * The `## When to dispatch` section's FIRST bullet, flattened like `triggerOf` — the agent's
163
+ * WHEN. Claude Code delegates on the subagent `description:` alone, and a purpose line
164
+ * ("Writes, runs, and diagnoses tests.") says what an agent does but never when to hand it
165
+ * work; the dispatch list carried that, in a body the router never reads.
166
+ */
167
+ function dispatchOf(text) {
168
+ const lines = text.split('\n');
169
+ const start = lines.findIndex((ln) => /^##\s+When to dispatch\b/.test(ln.trim()));
170
+ if (start < 0) return '';
171
+ const para = [];
172
+ for (let i = start + 1; i < lines.length; i += 1) {
173
+ const s = lines[i].trim();
174
+ if (!para.length) {
175
+ if (s.startsWith('- ')) para.push(s.slice(2));
176
+ else if (s.startsWith('#')) break;
177
+ continue;
178
+ }
179
+ if (s === '' || s.startsWith('- ') || s.startsWith('#')) break;
180
+ para.push(s);
181
+ }
182
+ return para.join(' ')
183
+ .replace(HTML_COMMENT_RE, '')
184
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
185
+ .replace(/\s+/g, ' ')
186
+ .trim();
187
+ }
188
+
189
+ /** The emitted `description:` of an agent — purpose + its first dispatch condition. */
190
+ export function agentDescription(text) {
191
+ return withWhen(descOf(text), dispatchOf(text));
192
+ }
193
+
194
+ /** Purpose + ` Use when: ` + the first sentence of `trig`, capped — see `skillDescription`. */
195
+ function withWhen(desc, trig) {
158
196
  if (!trig) return desc;
159
197
  const first = trig.match(/^.*?[.;](?=\s|$)/);
160
198
  const full = `${desc} Use when: ${first ? first[0] : trig}`;
@@ -302,7 +340,7 @@ const WEBFETCH_MARKER = '<!-- webfetch: allow -->';
302
340
  * Bash keeps the unmarked line byte-identical to what it was before the webfetch marker existed.
303
341
  */
304
342
  function claudeAgentFrontmatter(stem, text, overrides) {
305
- const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
343
+ const fm = [`name: ${stem}`, `description: ${jsonDumps(agentDescription(text))}`];
306
344
  const ov = agentOverride(overrides, stem);
307
345
  if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
308
346
  if (isReadonly(text)) {
@@ -316,7 +354,7 @@ function claudeAgentFrontmatter(stem, text, overrides) {
316
354
 
317
355
  /** `_build_emit._opencode_agent_frontmatter`. */
318
356
  function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
319
- const fm = [`description: ${jsonDumps(descOf(text))}`, 'mode: subagent'];
357
+ const fm = [`description: ${jsonDumps(agentDescription(text))}`, 'mode: subagent'];
320
358
  fm.push(`color: ${agentColor(stem, theme)}`);
321
359
  pushOverrideLines(fm, agentOverride(overrides, stem));
322
360
  if (isReadonly(text)) {
@@ -81,6 +81,34 @@ export function checkBuild(themeName, out, isVendored = isVendoredPath) {
81
81
  problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
82
82
  }
83
83
  for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
84
+ for (const p of descriptionProblems(text, rel)) problems.push(`[${themeName}] ${p}`);
85
+ }
86
+ return problems;
87
+ }
88
+
89
+ /**
90
+ * The `description:` of an emitted native skill or agent, held to Anthropic's skill-authoring
91
+ * rules — it is the ONLY text a host routes on, so a defect here is a skill that silently
92
+ * never fires. Three arms: a `…` ending is `withWhen`'s mid-sentence fallback (the trigger
93
+ * was cut, cure it with a shorter first trigger sentence); an XML-looking tag is rejected
94
+ * outright by the Agent Skills spec; a first/second-person pronoun is the point-of-view drift
95
+ * the spec warns degrades discovery, since the text is injected into the system prompt.
96
+ * The pronoun arm reads only the `Use when:` half, and skips quoted user phrases ("teach me
97
+ * X" is what the user types, not the description's voice): the purpose half is the theme's
98
+ * persona copy ("Make your own OpenCode theme"), authored per theme on purpose.
99
+ * Files without frontmatter (every bundle `.md`, AGENT.md) carry no description and pass.
100
+ */
101
+ const DESC_LINE_RE = /^---\n(?:.*\n)*?description: (".*")\n(?:.*\n)*?---\n/;
102
+ export function descriptionProblems(text, rel) {
103
+ const m = text.match(DESC_LINE_RE);
104
+ if (!m) return [];
105
+ const desc = parseJson(m[1]);
106
+ const problems = [];
107
+ if (desc.endsWith('…')) problems.push(`description cut mid-sentence in ${rel} — shorten the first trigger sentence`);
108
+ if (/<\/?[A-Za-z][^>]*>/.test(desc)) problems.push(`description carries an XML-like tag in ${rel}`);
109
+ const when = desc.split(' Use when: ')[1] ?? '';
110
+ if (/\b(I|I'm|me|my|you|you're|your)\b/i.test(when.replace(/"[^"]*"|\bI\/O\b/g, ''))) {
111
+ problems.push(`description's "Use when" is not in the third person in ${rel}`);
84
112
  }
85
113
  return problems;
86
114
  }
@@ -47,8 +47,19 @@ export function parseBrick(text, origin) {
47
47
  if (!agent === !skill) problems.push(`${name}: exactly one of agent or skill`);
48
48
  const outcomes = get('outcomes').split(',').map((s) => s.trim()).filter(Boolean);
49
49
  if (!outcomes.length) problems.push(`${name}: outcomes is empty`);
50
+ // `gate: human` holds the transition after this brick until the user answers (state.mjs).
51
+ // Present on the brick only when declared, so an ungated brick's shape is unchanged.
52
+ const gate = get('gate');
53
+ if (gate && gate !== 'human') problems.push(`${name}: gate must be human, not ${JSON.stringify(gate)}`);
54
+ // `gateOn` narrows the gate to the outcomes it lists; any other outcome follows its edge ungated.
55
+ const gateOn = get('gateOn').split(',').map((s) => s.trim()).filter(Boolean);
56
+ if (gateOn.length && gate !== 'human') problems.push(`${name}: gateOn needs gate: human`);
57
+ for (const o of gateOn) if (!outcomes.includes(o)) problems.push(`${name}: gateOn ${o} is not one of its outcomes`);
50
58
  return {
51
- brick: { name, description, effect, agent, skill, outcomes, body: body.trim(), origin },
59
+ brick: {
60
+ name, description, effect, agent, skill, outcomes, ...(gate ? { gate } : {}),
61
+ ...(gateOn.length ? { gateOn } : {}), body: body.trim(), origin,
62
+ },
52
63
  problems,
53
64
  };
54
65
  }
package/js/loop/cli.mjs CHANGED
@@ -22,8 +22,9 @@ import { parseNumstat, DEFAULT_PRESET } from './score.mjs';
22
22
  import { gitRootOf, gitDirOf, currentBranch } from '../hosts/gitref.mjs';
23
23
  import {
24
24
  initState, nextStep, scoreDeclared, scoreDiff, recordOutcome, decideAwaiting,
25
- renderLoopFile, parseLoopFile, LOOP_FILE,
25
+ writeLoopFile, parseLoopFile, LOOP_FILE,
26
26
  } from './state.mjs';
27
+ import { recordLoop } from './registry.mjs';
27
28
 
28
29
  const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
29
30
  const stdin = () => (process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
@@ -64,7 +65,7 @@ function withState(fn) {
64
65
  const state = parseLoopFile(readText(file));
65
66
  const { bricks } = loadCatalog({ projectRoot: root });
66
67
  const result = fn(state, bricks);
67
- writeText(file, renderLoopFile(state));
68
+ writeLoopFile(file, state);
68
69
  return result;
69
70
  }
70
71
 
@@ -110,8 +111,14 @@ const ACTIONS = {
110
111
  const graph = resolveGraph(args.graph, catalog);
111
112
  const problems = checkGraph(graph, catalog.bricks);
112
113
  if (problems.length) return { error: 'the graph does not pass loop check', problems };
113
- const state = initState({ title: args.title, requirement: args.requirement, graph, preset: args.preset || DEFAULT_PRESET });
114
- writeText(file, renderLoopFile(state));
114
+ const state = initState({
115
+ title: args.title, requirement: args.requirement, graph, preset: args.preset || DEFAULT_PRESET,
116
+ contracts: list(args.contracts) ?? [],
117
+ });
118
+ writeLoopFile(file, state);
119
+ // Best-effort: the registry is how the Active tab discovers this loop, but a registry
120
+ // hiccup must never fail the init the loop itself just succeeded at.
121
+ recordLoop({ root, branch: currentBranch(root), title: args.title });
115
122
  return { file, graph: graph.name, preset: state.preset, status: state.status };
116
123
  },
117
124
  next: () => withState((s, b) => nextStep(s, b)),
@@ -130,9 +137,24 @@ const ACTIONS = {
130
137
  },
131
138
  record(args) {
132
139
  if (!args.outcome) throw new Error('record needs --outcome');
140
+ if (args.note && args.noteFile) throw new Error('record takes --note or --note-file, not both');
141
+ let note = args.note ?? '';
142
+ if (args.noteFile) {
143
+ // A note too long for a shell argument, or one that mentions `git … commit`/`push` (and
144
+ // would otherwise sit inside the command line GIT_GATE_RE matches whole), goes through a
145
+ // file instead — never through the shell at all. CRLF-folded and trimmed the same way the
146
+ // porcelain stdin below is, so a Windows-authored file round-trips identically.
147
+ let raw;
148
+ try { raw = readText(path.resolve(args.noteFile)); } catch (e) {
149
+ throw new Error(`cannot read --note-file ${args.noteFile}: ${e.message}`, { cause: e });
150
+ }
151
+ note = raw.replace(/\r\n/g, '\n').trimEnd();
152
+ }
133
153
  const card = args.card ? JSON.parse(args.card) : null;
134
- const raw = stdin();
135
- return withState((s, b) => recordOutcome(s, b, args.outcome, { card, porcelain: raw ? raw.replace(/\r\n/g, '\n').trimEnd() : null }));
154
+ const porcelain = stdin();
155
+ return withState((s, b) => recordOutcome(s, b, args.outcome, {
156
+ card, porcelain: porcelain ? porcelain.replace(/\r\n/g, '\n').trimEnd() : null, note,
157
+ }));
136
158
  },
137
159
  decide(args) {
138
160
  // X6: same ordering as `score --diff` above.
package/js/loop/graph.mjs CHANGED
@@ -23,6 +23,8 @@ import { WEIGHTS } from './score.mjs';
23
23
 
24
24
  export const TERMINALS = new Set(['$close', '$stop']);
25
25
  export const ENGINE_MAX_ITERATIONS = 20;
26
+ /** A template's optional `category`, in the order the console's Loops list shows them. */
27
+ export const CATEGORIES = ['architecture', 'tests', 'development', 'refactoring', 'day-to-day'];
26
28
 
27
29
  export function iterationLoop(graph) {
28
30
  return (graph.loops ?? []).find((l) => l.iteration === true);
@@ -141,5 +143,21 @@ export function checkGraph(graph, bricks) {
141
143
  for (const k of Object.keys(graph.weights ?? {})) {
142
144
  if (!Object.hasOwn(WEIGHTS, k)) problems.push(`weights: ${k} is not an action`);
143
145
  }
146
+ // optional glob lists (score.mjs's `globMatch` reads them)
147
+ for (const k of ['contracts', 'ignoreDeletions']) {
148
+ const v = graph[k];
149
+ if (v !== undefined && !(Array.isArray(v) && v.every((g) => typeof g === 'string' && g.trim()))) {
150
+ problems.push(`${k}: must be a list of non-empty glob strings`);
151
+ }
152
+ }
153
+ // optional instruction list every brick receives through `loop next`
154
+ const rules = graph.rules;
155
+ if (rules !== undefined && !(Array.isArray(rules) && rules.every((r) => typeof r === 'string' && r.trim()))) {
156
+ problems.push('rules: must be a list of non-empty strings');
157
+ }
158
+ // optional catalogue shelf: the console groups templates by it, an absent one under OTHER
159
+ if (graph.category !== undefined && !CATEGORIES.includes(graph.category)) {
160
+ problems.push(`category: must be one of ${CATEGORIES.join(', ')}`);
161
+ }
144
162
  return problems;
145
163
  }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The loop registry — `<userLoopsDir()>/loops.json` — the list of worktrees `geneseed loop init`
3
+ * has launched a loop in. `LOOP.md` stays the only source of loop STATE; this file stores
4
+ * identity only (root, branch, title, started, finishedAt), so the Active tab can discover a
5
+ * loop without scanning every worktree on the machine.
6
+ *
7
+ * WRITER CONVENTIONS, mirrored from `js/inspect/registry.mjs` (`installs.json`'s own registry)
8
+ * on purpose rather than invented fresh: `jsonDumpsIndent` because these rows are absolute
9
+ * paths and a username carrying an accent writes one here (ensure_ascii matters); a
10
+ * `<file>.tmp` + `renameSync` so a crash mid-write cannot leave `loops.json` half-written where
11
+ * the NEXT read would see valid-but-truncated JSON instead of either the old file or the new
12
+ * one; and every public function swallows its own errors — a registry hiccup must never fail
13
+ * `loop init`, and a corrupt `loops.json` must read back as empty rather than throw. The one
14
+ * difference from `installs.json`: this file keys rows by object (`{root, branch, title,
15
+ * started, finishedAt}`), not by a bare path, because identity alone is not enough to render a
16
+ * card.
17
+ *
18
+ * NO `lastSeen`. The Active tab POLLS `activeLoops` on an interval, and a field refreshed on
19
+ * every read would make every poll a write — fine for `installs.json` (`record` fires once per
20
+ * emit), wrong here (the page's own `usePoll` fires every few seconds, for as long as the tab
21
+ * is open, on a machine that may be a laptop on battery). So `activeLoops` saves the registry
22
+ * back ONLY when the kept set actually differs from what it loaded — a root pruned, a loop
23
+ * newly seen finished, or a loop seen running again after having been finished — never merely
24
+ * to record that a poll happened. `finishedAt` replaces `lastSeen` for the one thing a
25
+ * timestamp was doing here: it is set ONCE, the first time a row is found WITHOUT a LOOP.md
26
+ * (the loop finished), and the 7-day prune window counts from there — not from a last-poll time
27
+ * nothing refreshes any more. A loop seen running again afterwards (the same worktree
28
+ * re-`init`ed) clears it, so a stale finished-window never survives into a loop that is running
29
+ * again.
30
+ *
31
+ * `recordLoop` and `activeLoops`/`setLoopPreset` never call each other's normalisation by
32
+ * accident: both resolve a root through `realpathSync.native` (falling back to `path.resolve`
33
+ * for a root that no longer exists) and compare with `normcase` — the same case-folding
34
+ * `js/lib/paths.mjs` uses everywhere a Windows path is compared — so a loop launched from a
35
+ * differently-cased spelling of the same worktree is still one row, not two.
36
+ */
37
+ import { readFileSync, mkdirSync, realpathSync, renameSync } from 'node:fs';
38
+ import path from 'node:path';
39
+
40
+ import { userLoopsDir } from './catalog.mjs';
41
+ import { writeText, readText, isDir, isFile } from '../lib/fs.mjs';
42
+ import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
43
+ import { normcase } from '../lib/paths.mjs';
44
+ import { parseLoopFile, writeLoopFile, LOOP_FILE } from './state.mjs';
45
+ import { PRESETS } from './score.mjs';
46
+
47
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
48
+
49
+ export function registryPath() {
50
+ return path.join(userLoopsDir(), 'loops.json');
51
+ }
52
+
53
+ function load() {
54
+ try {
55
+ const data = parseJson(readFileSync(registryPath(), 'utf8'));
56
+ return Array.isArray(data?.loops) ? data.loops.filter((l) => l && typeof l.root === 'string') : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ function save(loops) {
63
+ try {
64
+ const file = registryPath();
65
+ mkdirSync(path.dirname(file), { recursive: true });
66
+ const tmp = `${file}.tmp`;
67
+ writeText(tmp, `${jsonDumpsIndent({ loops })}\n`);
68
+ renameSync(tmp, file);
69
+ } catch {
70
+ /* best-effort: a registry hiccup must never fail init or a read */
71
+ }
72
+ }
73
+
74
+ function normalize(dir) {
75
+ try { return realpathSync.native(dir); } catch { return path.resolve(dir); }
76
+ }
77
+
78
+ const sameRoot = (a, b) => normcase(a) === normcase(b);
79
+
80
+ /** The soft-decision iterations of `state.history` — the same shape `state.mjs`'s own
81
+ * end-of-run `summary()` reports, so a card and a finished-loop review read the same fields. */
82
+ function reviewOf(state) {
83
+ return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
84
+ }
85
+
86
+ function runningRow(entry, state) {
87
+ return {
88
+ root: entry.root, branch: entry.branch, title: entry.title, started: entry.started,
89
+ status: state.status, iteration: state.iteration, node: state.node, preset: state.preset,
90
+ threshold: PRESETS[state.preset], current: state.current, history: state.history,
91
+ review: reviewOf(state), reason: state.reason, awaiting: state.awaiting, graph: state.graph,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Upsert `root`'s identity row. Called by `geneseed loop init` right after `LOOP.md` is
97
+ * written — `started` is set once, on insert, and never moved by a later call. An update never
98
+ * touches `finishedAt` either way: `activeLoops` is the only writer of that field, and it will
99
+ * clear it on the very next poll once it sees LOOP.md present again. Never throws: an `init`
100
+ * must succeed even when the registry itself cannot be written.
101
+ */
102
+ export function recordLoop({ root, branch, title, now = new Date() }) {
103
+ try {
104
+ const key = normalize(root);
105
+ const loops = load();
106
+ const idx = loops.findIndex((l) => sameRoot(l.root, key));
107
+ if (idx >= 0) {
108
+ loops[idx] = { ...loops[idx], root: key, branch, title };
109
+ } else {
110
+ loops.push({ root: key, branch, title, started: now.toISOString() });
111
+ }
112
+ save(loops);
113
+ } catch {
114
+ /* a registry hiccup must never fail loop init */
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The rows the Active tab renders, read fresh off every registered worktree's `LOOP.md` —
120
+ * never off the registry's own stale copy of a loop's state. Four outcomes per row; the
121
+ * write-back at the end fires ONLY when one of the first three actually changed something
122
+ * (see the module docblock on why a routine poll must not be a write):
123
+ * - the worktree root no longer exists: dropped, silently (a removed clone or worktree);
124
+ * - `<root>/LOOP.md` exists but fails to parse (corrupt, or written mid-crash before this
125
+ * module's own atomic writer existed): the entry is KEPT exactly as read — dropping it here
126
+ * would be indistinguishable from a removed worktree, and a card the user can still see
127
+ * beats one that silently vanishes — and the row reports `status: 'unreadable'`;
128
+ * - `<root>/LOOP.md` exists and parses: the row carries the state fields the page needs, and
129
+ * if the entry still carried a `finishedAt` from a previous finish it is cleared (the loop
130
+ * is running again — that counts as a change, and is saved);
131
+ * - `LOOP.md` is gone (the loop finished): the first time this is seen, `finishedAt` is set
132
+ * to `now` and saved; after that it is read-only here until `now` is more than 7 days past
133
+ * that frozen `finishedAt`, at which point the row is dropped (and the drop is saved).
134
+ */
135
+ export function activeLoops({ now = new Date() } = {}) {
136
+ const loops = load();
137
+ const kept = [];
138
+ const rows = [];
139
+ let changed = false;
140
+ for (const entry of loops) {
141
+ if (!isDir(entry.root)) { changed = true; continue; }
142
+ const file = path.join(entry.root, LOOP_FILE);
143
+ const identity = { root: entry.root, branch: entry.branch, title: entry.title, started: entry.started };
144
+ if (isFile(file)) {
145
+ let state;
146
+ try { state = parseLoopFile(readText(file)); } catch {
147
+ kept.push(entry);
148
+ rows.push({ ...identity, status: 'unreadable' });
149
+ continue;
150
+ }
151
+ let next = entry;
152
+ if (entry.finishedAt !== undefined) {
153
+ next = { ...entry }; delete next.finishedAt; changed = true;
154
+ }
155
+ kept.push(next);
156
+ rows.push(runningRow(next, state));
157
+ continue;
158
+ }
159
+ if (entry.finishedAt === undefined) {
160
+ const next = { ...entry, finishedAt: now.toISOString() };
161
+ changed = true;
162
+ kept.push(next);
163
+ rows.push({ ...identity, status: 'finished' });
164
+ continue;
165
+ }
166
+ const age = now.getTime() - new Date(entry.finishedAt).getTime();
167
+ if (age > SEVEN_DAYS_MS) { changed = true; continue; }
168
+ kept.push(entry);
169
+ rows.push({ ...identity, status: 'finished' });
170
+ }
171
+ if (changed) save(kept);
172
+ return rows;
173
+ }
174
+
175
+ /**
176
+ * Rewrite `root`'s `LOOP.md` `preset` field and nothing else — the per-loop preset picker on
177
+ * the Active tab. Refuses a `root` the registry does not know about (no arbitrary path
178
+ * writes — `POST /api/loops/preset`'s whole safety argument) and a `preset` outside `PRESETS`,
179
+ * each with its own message so the web action can tell the two refusals apart. Unlike
180
+ * `recordLoop`/`activeLoops`, this one DOES throw: a preset change the caller asked for and did
181
+ * not happen must be reported, not swallowed. Writes through `writeLoopFile` (atomic, like
182
+ * every other LOOP.md persist — see `state.mjs`'s docblock).
183
+ */
184
+ export function setLoopPreset(root, preset) {
185
+ if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
186
+ const key = normalize(root);
187
+ const loops = load();
188
+ const entry = loops.find((l) => sameRoot(l.root, key));
189
+ if (!entry || !isDir(entry.root)) throw new Error('unknown loop');
190
+ const file = path.join(entry.root, LOOP_FILE);
191
+ if (!isFile(file)) throw new Error('unknown loop');
192
+ const state = parseLoopFile(readText(file));
193
+ state.preset = preset;
194
+ writeLoopFile(file, state);
195
+ return runningRow(entry, state);
196
+ }