geneseed 3.3.0 → 3.5.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 (231) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/DESIGN.md +11 -14
  3. package/QUICKSTART.md +3 -1
  4. package/README.md +53 -44
  5. package/SETUP.md +25 -27
  6. package/SHIPPED.md +26 -32
  7. package/adapters/bob/README.md +12 -9
  8. package/adapters/copilot/README.md +28 -26
  9. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
  10. package/adapters/opencode/README.md +8 -4
  11. package/adapters/opencode/plugins/geneseed-context.js +5 -3
  12. package/adapters/opencode/plugins/geneseed-workflow.js +30 -3
  13. package/adapters/opencode/workflows/_runtime.js +97 -7
  14. package/bin/build-driver.mjs +5 -1248
  15. package/bin/geneseed-cli.mjs +11 -6
  16. package/bin/geneseed-hook.mjs +4 -2
  17. package/docs/candidate-hosts.md +1 -1
  18. package/docs/design-history.md +37 -37
  19. package/docs/extending.md +49 -27
  20. package/docs/token-footprint.md +52 -26
  21. package/docs/web/agents.md +1 -1
  22. package/docs/web/claude-hooks.md +3 -2
  23. package/docs/web/collaboration.md +2 -2
  24. package/docs/web/first-session.md +2 -2
  25. package/docs/web/footprint.md +2 -2
  26. package/docs/web/harnesses.md +1 -1
  27. package/docs/web/model.md +1 -1
  28. package/docs/web/plugin-workflow.md +1 -0
  29. package/docs/web/rules.md +3 -3
  30. package/docs/web/setup-choices.md +2 -2
  31. package/docs/web/skills.md +1 -1
  32. package/docs/web/themes-author.md +1 -1
  33. package/docs/web-ui.md +18 -10
  34. package/harness.config.json +1 -1
  35. package/js/README.md +6 -5
  36. package/js/build/driver.mjs +1191 -0
  37. package/js/build/emit-claude.mjs +17 -10
  38. package/js/build/emit-common.mjs +4 -4
  39. package/js/build/generate.mjs +1 -1
  40. package/js/build/render.mjs +48 -44
  41. package/js/build/stubs.mjs +1 -1
  42. package/js/build/themes.mjs +4 -4
  43. package/js/build/version.mjs +12 -0
  44. package/js/cli-table.json +21 -6
  45. package/js/hosts/hooks.mjs +89 -74
  46. package/js/hosts/hosts.mjs +19 -2
  47. package/js/hosts/mcp.mjs +14 -20
  48. package/js/hosts/native.mjs +85 -25
  49. package/js/hosts/opencode.mjs +14 -0
  50. package/js/hosts/settings.mjs +52 -104
  51. package/js/inspect/checks-authoring.mjs +117 -3
  52. package/js/inspect/diff.mjs +1 -1
  53. package/js/inspect/doctor.mjs +1 -1
  54. package/js/inspect/excludes.mjs +1 -1
  55. package/js/inspect/inventory.mjs +12 -7
  56. package/js/inspect/scan.mjs +3 -14
  57. package/js/inspect/status.mjs +2 -2
  58. package/js/inspect/validate.mjs +1 -1
  59. package/js/lib/fs.mjs +23 -8
  60. package/js/lib/json.mjs +7 -1
  61. package/js/lib/proc.mjs +6 -4
  62. package/js/maintain/migrate.mjs +1 -1
  63. package/js/maintain/setup.mjs +1 -1
  64. package/js/maintain/uninstall.mjs +17 -15
  65. package/js/ui/cli.mjs +19 -0
  66. package/js/ui/menu.mjs +1 -1
  67. package/js/web/actions.mjs +4 -4
  68. package/js/web/activity.mjs +4 -4
  69. package/js/web/api.mjs +14 -11
  70. package/js/web/daemon.mjs +1 -1
  71. package/js/web/docs.mjs +12 -12
  72. package/js/web/handler.mjs +12 -5
  73. package/js/web/jobs.mjs +1 -1
  74. package/package.json +1 -1
  75. package/registry.json +86 -79
  76. package/src/AGENT.md.tmpl +83 -48
  77. package/src/agents/architect.md +4 -1
  78. package/src/agents/developer.md +2 -2
  79. package/src/agents/explorer.md +2 -0
  80. package/src/agents/operator.md +13 -0
  81. package/src/agents/pragmatist.md +12 -0
  82. package/src/agents/researcher.md +72 -0
  83. package/src/agents/reviewer.md +5 -2
  84. package/src/agents/security.md +7 -1
  85. package/src/agents/skeptic.md +2 -1
  86. package/src/agents/tester.md +5 -1
  87. package/src/agents/user-advocate.md +13 -0
  88. package/src/doctrines/README.md +26 -3
  89. package/src/doctrines/craft.md +78 -5
  90. package/src/doctrines/ops.md +62 -0
  91. package/src/doctrines/process.md +91 -4
  92. package/src/doctrines/rigor.md +41 -1
  93. package/src/laws/universal.md +27 -21
  94. package/src/memory/README.md +4 -2
  95. package/src/modes/foreman.md +5 -5
  96. package/src/notebook/README.md +2 -1
  97. package/src/ontology/universal.md +71 -67
  98. package/src/postures/peer.md +3 -0
  99. package/src/skills/_self-improvement.md +19 -0
  100. package/src/skills/_template.md +1 -12
  101. package/src/skills/brainstorm.md +37 -19
  102. package/src/skills/bruno-collection-generator.md +2 -13
  103. package/src/skills/bruno-test-writer.md +3 -14
  104. package/src/skills/ci-fix.md +49 -0
  105. package/src/skills/codebase-design.md +1 -12
  106. package/src/skills/commit.md +1 -12
  107. package/src/skills/consolidate-memory.md +50 -0
  108. package/src/skills/council.md +1 -12
  109. package/src/skills/daydream/LICENSE +21 -0
  110. package/src/skills/daydream/VENDOR.md +3 -1
  111. package/src/skills/debug.md +1 -12
  112. package/src/skills/deps-audit.md +46 -0
  113. package/src/skills/develop.md +38 -20
  114. package/src/skills/document-project.md +1 -12
  115. package/src/skills/domain-modeling.md +1 -12
  116. package/src/skills/forge-mcp.md +1 -12
  117. package/src/skills/fresh-eyes.md +1 -12
  118. package/src/skills/frontend-design.md +1 -12
  119. package/src/skills/geneseed-code-review.md +7 -14
  120. package/src/skills/geneseed.md +1 -12
  121. package/src/skills/git-archaeology.md +1 -12
  122. package/src/skills/git-rescue.md +1 -12
  123. package/src/skills/handoff.md +1 -12
  124. package/src/skills/herdr.md +1 -12
  125. package/src/skills/ingest.md +1 -12
  126. package/src/skills/migrate.md +1 -12
  127. package/src/skills/opencode-theme.md +1 -12
  128. package/src/skills/parallel-agents.md +4 -15
  129. package/src/skills/pipeline.md +6 -17
  130. package/src/skills/plan.md +1 -12
  131. package/src/skills/ponytail.md +1 -12
  132. package/src/skills/profile.md +1 -12
  133. package/src/skills/prose.md +1 -12
  134. package/src/skills/prototype.md +1 -12
  135. package/src/skills/quiz.md +43 -0
  136. package/src/skills/react-view-transitions/VENDOR.md +7 -1
  137. package/src/skills/refactor.md +1 -12
  138. package/src/skills/release.md +1 -12
  139. package/src/skills/repo-map.md +1 -12
  140. package/src/skills/research.md +5 -13
  141. package/src/skills/review-response.md +1 -12
  142. package/src/skills/roast-me.md +1 -12
  143. package/src/skills/rule.md +1 -12
  144. package/src/skills/security-audit.md +51 -0
  145. package/src/skills/ship.md +1 -12
  146. package/src/skills/skill-forge.md +59 -0
  147. package/src/skills/teach.md +50 -0
  148. package/src/skills/tickets.md +1 -12
  149. package/src/skills/wayfinder.md +1 -12
  150. package/src/skills/wiki.md +1 -12
  151. package/src/skills/workflow.md +82 -26
  152. package/src/skills/worktree.md +52 -0
  153. package/themes/_TEMPLATE.json +15 -12
  154. package/themes/biker.json +14 -11
  155. package/themes/commentator.json +14 -11
  156. package/themes/cyberpunk.json +14 -11
  157. package/themes/gamer.json +14 -11
  158. package/themes/imperial.json +14 -11
  159. package/themes/joker.json +14 -11
  160. package/themes/marvin.json +14 -11
  161. package/themes/mean.json +14 -11
  162. package/themes/military.json +14 -11
  163. package/themes/neutral.json +15 -12
  164. package/themes/opencode/README.md +2 -2
  165. package/themes/pirate.json +14 -11
  166. package/themes/sports.json +14 -11
  167. package/themes/verstappen.json +14 -11
  168. package/themes/wizard.json +14 -11
  169. package/web/dist/assets/Activity-Flgf4kHy.js +1 -0
  170. package/web/dist/assets/ActivityDetail-DIpr85nh.js +1 -0
  171. package/web/dist/assets/CatalogRow-CjyLjaLb.js +1 -0
  172. package/web/dist/assets/Diff-Deisdha8.js +2 -0
  173. package/web/dist/assets/Docs-DGUdY4l4.js +2 -0
  174. package/web/dist/assets/Doctor-F74lJ69P.js +1 -0
  175. package/web/dist/assets/FilterInput-BakJLvaV.js +1 -0
  176. package/web/dist/assets/GreenhouseView-D072lBiD.js +2 -0
  177. package/web/dist/assets/Harness-DNRP2Va7.js +1 -0
  178. package/web/dist/assets/JournalView-DWFqZ0Iw.js +1 -0
  179. package/web/dist/assets/Laws-BN6g3HU7.js +3 -0
  180. package/web/dist/assets/Library-CDOv4emM.js +1 -0
  181. package/web/dist/assets/Markdown-BQAvN8xx.js +38 -0
  182. package/web/dist/assets/OperatorHudView-CwNIUVX8.js +2 -0
  183. package/web/dist/assets/Profile-CpP9BTne.js +1 -0
  184. package/web/dist/assets/Rules-DFXsUCB7.js +1 -0
  185. package/web/dist/assets/Settings-BqZVUtJ5.js +1 -0
  186. package/web/dist/assets/Skills-Cu6kBgfF.js +1 -0
  187. package/web/dist/assets/StatusBadge-Cg6j-NJL.js +1 -0
  188. package/web/dist/assets/activity-iWaWVtsR.js +1 -0
  189. package/web/dist/assets/drawable-BgfrEBer.js +1 -0
  190. package/web/dist/assets/index-CAqrWhKl.js +2 -0
  191. package/web/dist/assets/index-Ruh93A3A.css +1 -0
  192. package/web/dist/assets/jobBuckets-Dbq8ZlWW.js +1 -0
  193. package/web/dist/assets/lawCats-BLgxl151.js +1 -0
  194. package/web/dist/assets/react-DbkvxWG5.js +8 -0
  195. package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +1 -0
  196. package/web/dist/index.html +4 -3
  197. package/web/src/pages/Laws.jsx +22 -8
  198. package/src/skills/clarify.md +0 -28
  199. package/src/skills/crash-course.md +0 -29
  200. package/src/skills/decode.md +0 -29
  201. package/src/skills/drill.md +0 -29
  202. package/src/skills/feynman.md +0 -27
  203. package/src/skills/gap-detector.md +0 -27
  204. package/src/skills/learning-path.md +0 -27
  205. package/src/skills/react-view-transitions/README.md +0 -42
  206. package/src/skills/tdd.md +0 -31
  207. package/web/dist/assets/Activity-BfVcLrda.js +0 -1
  208. package/web/dist/assets/ActivityDetail-CQXccGPl.js +0 -1
  209. package/web/dist/assets/CatalogRow-DALEQImZ.js +0 -1
  210. package/web/dist/assets/Diff-BIjxXx46.js +0 -5
  211. package/web/dist/assets/Doctor-CJLbgsrw.js +0 -1
  212. package/web/dist/assets/FilterInput-CkUCk_lY.js +0 -1
  213. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +0 -2
  214. package/web/dist/assets/Harness-Dm5CCU-v.js +0 -1
  215. package/web/dist/assets/JournalView-Cpu2eI4s.js +0 -1
  216. package/web/dist/assets/Laws-DDDVFuQz.js +0 -3
  217. package/web/dist/assets/Library-Dao3T9na.js +0 -1
  218. package/web/dist/assets/Markdown-C__nuWX5.js +0 -46
  219. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +0 -2
  220. package/web/dist/assets/Profile-BlXtUlh2.js +0 -1
  221. package/web/dist/assets/Rules-DIre6gxR.js +0 -1
  222. package/web/dist/assets/Skills-rY13ZPhM.js +0 -1
  223. package/web/dist/assets/StatusBadge-wW_RS_my.js +0 -1
  224. package/web/dist/assets/activity-DpYPKNrA.js +0 -1
  225. package/web/dist/assets/index-BEZvPvpF.js +0 -1
  226. package/web/dist/assets/index-DNQE5ydy.js +0 -10
  227. package/web/dist/assets/index-DzdQjvM1.css +0 -1
  228. package/web/dist/assets/index-PmQGRS1X.js +0 -2
  229. package/web/dist/assets/jobBuckets-DfTUJUxR.js +0 -1
  230. package/web/dist/assets/lawCats-DUS9UTH4.js +0 -1
  231. package/web/dist/assets/react-DOpiTn5v.js +0 -32
@@ -9,8 +9,9 @@ Agent Skills), custom agents are markdown-with-frontmatter, and the repo-root
9
9
  a `host="copilot"` flag. Nothing to install by hand: `geneseed setup` (or
10
10
  `geneseed build --emit copilot` / `--emit copilot-global`) writes everything.
11
11
 
12
- Copilot is a **reduced host**: it has lifecycle hooks, but one command per event and
13
- no "ask the user" tier, so this page is mostly about what those hooks *can* and
12
+ Copilot is a **reduced host** at project scope (no hooks there) and close to Claude
13
+ Code at global scope: its lifecycle hooks have an "ask the user" tier, so the gates
14
+ prompt as they do on Claude. This page is mostly about what those hooks *can* and
14
15
  *cannot* automate and why the harness still holds where they cannot.
15
16
 
16
17
  ## What the emit writes
@@ -23,7 +24,8 @@ no "ask the user" tier, so this page is mostly about what those hooks *can* and
23
24
  - `agents/<name>.agent.md` — Copilot's **custom-agent dialect** (`.agent.md`
24
25
  extension; a `tools:` **allowlist**, not Claude's denylist — a read-only
25
26
  agent lists the tool ids it keeps: `read, search, todo, agent`, plus
26
- `execute` only when the spec opts in with `<!-- bash: allow -->`). Sibling
27
+ `execute` only when the spec opts in with `<!-- bash: allow -->`, `fetch` only
28
+ with `<!-- webfetch: allow -->`). Sibling
27
29
  agent links are rewritten to the `.agent.md` filename.
28
30
  - `skills/<name>/SKILL.md` — **byte-identical** to every other host.
29
31
 
@@ -38,42 +40,42 @@ no "ask the user" tier, so this page is mostly about what those hooks *can* and
38
40
  the preamble rides it as a managed block. (Unlike Bob, Copilot *has* a real
39
41
  personal instructions carrier, so no rules-folder workaround is needed.)
40
42
  - `agents/<name>.agent.md` + `skills/` under `~/.copilot`.
41
- - **`settings.json` hooks** — two entries under `hooks`, see below.
43
+ - **`settings.json` hooks** — four entries under `hooks`, see below.
42
44
 
43
- ## Hooks — one command per event, block or warn
45
+ ## Hooks — ask, as on Claude Code
44
46
 
45
47
  The Copilot CLI reads lifecycle hooks from **`~/.copilot/settings.json`** as a map of
46
- event → one `{command}` object (no matcher groups, no list). The global emit wires
47
- two of them, each calling the machine-wide `geneseed-hook` shim with `--host copilot`
48
- so the verdict is spoken in Copilot's dialect:
48
+ event → **array** of `{type: "command", command}` entries
49
+ ([reference](https://docs.github.com/en/copilot/reference/hooks-configuration)). The
50
+ global emit adds one entry to each of four events, each calling the machine-wide
51
+ `geneseed-hook` shim:
49
52
 
50
53
  | event | verb | what it does |
51
54
  | --- | --- | --- |
52
55
  | `sessionStart` | `context` | eager project-context injection, as on Claude Code, returned as `{"additionalContext": …}` |
53
- | `toolCall` | `tool-gate` | the git gate and the rule gate **fused** — one command is all the event allows — dispatched on the payload: a `command` gets the git checks, a path gets the rule checks; the `after` phase is ignored |
56
+ | `preToolUse` | `tool-gate --host copilot` | the git gate and the rule gate **fused** — entries carry no matcher — dispatched on the payload: a `command` gets the git checks, a path gets the rule checks |
57
+ | `agentStop` | `learn` | distils the session transcript (`transcriptPath`) into memory, as Claude's `Stop` |
58
+ | `preCompact` | `learn` | the same, once more before compaction summarises the window away |
54
59
 
55
- Copilot's `toolCall` has **no "ask the user" tier** — its stdout contract is
56
- `{"block": true|false}` — so the verdict tiers are:
60
+ `preToolUse` has an ask tier, so every rule **asks** exactly where it asks on Claude
61
+ Code — Laws I and IV, process 1 and process 5 — answered top-level as
62
+ `{"permissionDecision": "ask", "permissionDecisionReason": …}`. A gate error asks
63
+ too (fail closed). `toolArgs` is read whether Copilot sends it as an object or as a
64
+ JSON string.
57
65
 
58
- - **Laws I and IV block.** A credential-shaped string headed for a tracked file, or
59
- a history-discarding git act (`reset --hard`, `clean -f`, `branch -D`,
60
- `checkout --`, `push --force`), is refused outright with the reason. Same stance
61
- as the OpenCode guard plugin, whose `tool.execute.before` can only allow or throw.
62
- - **Process 1 and process 5 warn.** The two consent rules are the *user's* calls; a
63
- hard block would make Copilot unable to commit at all. They log a `[geneseed] …`
64
- line on stderr (which Copilot records) and allow. A gate error warns the same way
65
- rather than locking every tool call behind a block nobody can clear.
66
+ **Your own hooks on those events stay**: Geneseed's entry goes beside them in the
67
+ array, and uninstall removes exactly the entries it claimed. Comments in the file
68
+ mean it is never rewritten.
66
69
 
67
- **A hook you already set on one of those events is left alone**, and that event is
68
- not claimed — one slot per event means Geneseed could only displace it. Uninstall
69
- removes exactly the hooks it claimed. Comments in the file mean it is never
70
- rewritten.
70
+ **Migration.** Before this, the emit wired a single-object `toolCall` hook — an event
71
+ Copilot no longer fires, so the gates never ran. A re-emit removes those recorded
72
+ entries (your own keys stay) and writes the array shape.
73
+
74
+ **Unverified live.** No Copilot CLI on the authoring machine; this is the documented
75
+ contract.
71
76
 
72
77
  **What is not wired, and why:**
73
78
 
74
- - **no `learn`** — Copilot's `sessionEnd` and `agentStop` payloads carry no
75
- `transcript_path`, so a distiller would have nothing to read. The memory
76
- convention rides the preamble's instructions (the agent writes its own memories);
77
79
  - **no hooks at project scope** — the CLI documents hooks in the personal settings
78
80
  file only, and a machine-absolute command committed into a shared `.github/`
79
81
  would fail on every teammate's machine;
@@ -185,7 +185,7 @@ repo's layout defeats the convention.
185
185
 
186
186
  ### 3.5 Injection block format (mirror `harness.py cmd_context`)
187
187
 
188
- Byte-identical structure to [harness.py:237-263](../../rituals/harness.py) so both
188
+ Byte-identical structure to the retired Python `harness.py` (lines 237-263) so both
189
189
  enforcement paths read the same. Adds a machine marker (first line) for §4.
190
190
 
191
191
  ```
@@ -57,7 +57,8 @@ your-repo/
57
57
  - Read-only agents (their spec says *Read-only*) get an OpenCode `permission` block
58
58
  — `edit: deny`, `webfetch: deny`, and `bash: deny`, so a read-only agent can't
59
59
  mutate via the shell either. One that must run read-only commands (tests, linters,
60
- scanners) declares `<!-- bash: allow -->` in its spec, which gates bash to `ask`
60
+ scanners) declares `<!-- bash: allow -->` in its spec, which gates bash to `ask`;
61
+ `<!-- webfetch: allow -->` (the researcher) re-opens web fetch the same way
61
62
  instead. The rest keep edit access.
62
63
  - OpenCode invokes a subagent via the task tool, e.g. `subagent_type: "reviewer"`.
63
64
  - **Skills are native, not slash commands** — model-invoked via the `skill` tool
@@ -357,7 +358,10 @@ behaviour** — nothing changes the machine's current agent/model unless you opt
357
358
  { "agents": { "reviewer": { "model": "anthropic/claude-haiku-4-5", "temperature": 0.1, "variant": "high", "steps": 20 } } }
358
359
  ```
359
360
  Re-emit to apply. (A future TUI screen will edit this map.) Unlisted keys are omitted,
360
- so the agent inherits OpenCode's defaults.
361
+ so the agent inherits OpenCode's defaults. The stub also carries an inert
362
+ `_recommended.cheap-tier` list — the read-and-summarise seats (explorer, researcher,
363
+ tester, and the council bench) worth routing to a cheaper model; copy the names you
364
+ want into `agents`, nothing applies by itself.
361
365
  - **Agent colours.** Each capability agent is emitted with a `color:` set to an OpenCode
362
366
  *named theme slot*, sourced from the active harness theme's `AGENT_COLORS` map
363
367
  (`themes/_TEMPLATE.json`; the shipped default — architect=`primary`, reviewer=`warning`,
@@ -376,8 +380,8 @@ behaviour** — nothing changes the machine's current agent/model unless you opt
376
380
  `tokyonight`, `rosepine`, `gruvbox`, `nord`, `everforest`, `kanagawa`, `dracula` — each
377
381
  in a **solid** (opaque) and a **transparent** (terminal-background-through) flavour.
378
382
  These are decoupled from the voice theme: any voice theme can run any colour theme.
379
- Authored under `themes/opencode/` (see its README and
380
- [the spec](../../docs/specs/2026-06-17-opencode-color-themes.md)).
383
+ Authored under `themes/opencode/` (see its README; the design record was the
384
+ `2026-06-17-opencode-color-themes` spec).
381
385
  - **Runtime guard plugin** (`geneseed-guard.js`, installed with the others). Enforces
382
386
  the safety Laws at the tool boundary: **blocks** writes to private-key/credential
383
387
  files (Law I), catastrophic shell like `rm -rf /` (Law IV), and any mutation under
@@ -138,10 +138,12 @@ let fallbackVisible = false
138
138
  function log(msg) { if (DEBUG) console.error(`[geneseed-context] ${msg}`) }
139
139
 
140
140
  // ---- convention --------------------------------------------------------------
141
- // Root-level files injected in full. Agent-directed rules + canonical entry docs.
141
+ // Root-level files injected in full. Canonical entry docs + agent-directed rules OpenCode
142
+ // does not read by itself. AGENT.md (opencode.json `instructions`), AGENTS.md and CLAUDE.md
143
+ // (OpenCode's own native roots) are deliberately absent: OpenCode already has them, and
144
+ // injecting them again re-sent the whole harness on every request until 2026-09.
142
145
  const EAGER_ROOT = new Set([
143
- "AGENTS.md", "AGENT.md", "CLAUDE.md", ".cursorrules",
144
- "README.md", "CONTRIBUTING.md", "user-rules.md", "PROFILE.md",
146
+ ".cursorrules", "README.md", "CONTRIBUTING.md", "user-rules.md", "PROFILE.md",
145
147
  ])
146
148
  // Doc trees walked recursively; everything found is lazy (listed, not injected).
147
149
  const LAZY_DIRS = ["docs", "doc", "documentation", "architecture", "adr", "ADR"]
@@ -17,6 +17,7 @@
17
17
  // Quiet by default; GENESEED_DEBUG=1 logs to stderr. GENESEED_WORKFLOWS_DIR overrides
18
18
  // where saved workflows are loaded from.
19
19
 
20
+ import { execFile } from "node:child_process"
20
21
  import { promises as fs } from "node:fs"
21
22
  import * as path from "node:path"
22
23
  import { fileURLToPath, pathToFileURL } from "node:url"
@@ -63,6 +64,17 @@ function nowStamp() {
63
64
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
64
65
  }
65
66
 
67
+ // The runtime's git executor, for `isolation: "worktree"`. argv only, never a shell string:
68
+ // branch and directory names derive from agent labels a script author wrote.
69
+ function git(argv, cwd) {
70
+ return new Promise((resolve, reject) => {
71
+ execFile("git", argv, { cwd, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
72
+ if (err) reject(new Error(`git ${argv[0]}: ${String(stderr || err.message).trim()}`))
73
+ else resolve(String(stdout))
74
+ })
75
+ })
76
+ }
77
+
66
78
  function safeJson(v) {
67
79
  try { return JSON.stringify(v, null, 2) } catch { return String(v) }
68
80
  }
@@ -73,7 +85,22 @@ function summarize(name, result, stats, tracePath) {
73
85
  (stats.phases.length ? `, phases: ${stats.phases.join(" → ")}` : "")
74
86
  let body = safeJson(result)
75
87
  if (body.length > 2000) body = body.slice(0, 2000) + `\n… (truncated — full result in ${tracePath})`
76
- return `${head}\n\nResult:\n${body}\n\nTrace: ${tracePath}`
88
+ return `${head}\n\nResult:\n${body}${isolationReport(stats)}\n\nTrace: ${tracePath}`
89
+ }
90
+
91
+ // Kept worktrees are work awaiting review, and an overlap is a conflict awaiting ONE
92
+ // merger (process 8) — both are said out loud, never left for the trace to hold.
93
+ function isolationReport(stats) {
94
+ const kept = stats.worktrees || []
95
+ if (!kept.length) return ""
96
+ const lines = ["", "", `Worktrees kept (${kept.length}) — review and merge them yourself; nothing was merged:`]
97
+ for (const w of kept) lines.push(`- ${w.label}: ${w.branch} at ${w.dir} — ${w.files.length} file(s)`)
98
+ const over = stats.overlaps || []
99
+ if (over.length) {
100
+ lines.push(`CONFLICTS — ${over.length} file(s) changed by more than one agent; merge these by hand or through one merge agent:`)
101
+ for (const o of over) lines.push(`- ${o.file}: ${o.labels.join(", ")}`)
102
+ }
103
+ return lines.join("\n")
77
104
  }
78
105
 
79
106
  async function writeTrace(rootDir, runId, lines) {
@@ -142,7 +169,7 @@ export const GeneseedWorkflow = async (ctx) => {
142
169
  try { ({ createRuntime } = await import(pathToFileURL(path.join(WORKFLOWS_DIR, "_runtime.js")).href + "?t=" + Date.now())) }
143
170
  catch (e) { return `Workflow runtime unavailable: ${e?.message || e}` }
144
171
 
145
- const rt = createRuntime({ client, directory, worktree, args: normalizeArgs(argv?.args), log: sink })
172
+ const rt = createRuntime({ client, directory, worktree, git, runId, args: normalizeArgs(argv?.args), log: sink })
146
173
  let result, failed = null
147
174
  try { result = await wf.run(rt) }
148
175
  catch (e) { failed = e; sink(`✗ workflow error: ${e?.stack || e?.message || e}`) }
@@ -152,7 +179,7 @@ export const GeneseedWorkflow = async (ctx) => {
152
179
  if (!failed) sink(`# result:\n${safeJson(result)}`)
153
180
  const tracePath = await writeTrace(rootDir, runId, lines)
154
181
 
155
- if (failed) return `Workflow "${name}" failed: ${failed?.message || failed}\nTrace: ${tracePath}`
182
+ if (failed) return `Workflow "${name}" failed: ${failed?.message || failed}${isolationReport(stats)}\nTrace: ${tracePath}`
156
183
  return summarize(name, result, stats, tracePath)
157
184
  }
158
185
 
@@ -17,6 +17,7 @@
17
17
  // hard sandbox guard. See docs/specs/2026-06-09-opencode-workflow-primitive.md.
18
18
 
19
19
  import * as os from "node:os"
20
+ import * as path from "node:path"
20
21
 
21
22
  const DEBUG = !!process.env.GENESEED_DEBUG
22
23
  function dlog(m) { if (DEBUG) console.error(`[geneseed-workflow] ${m}`) }
@@ -150,13 +151,17 @@ function usageFrom(res) {
150
151
  return { input: Number(tok?.input ?? 0) || 0, output: Number(tok?.output ?? 0) || 0 }
151
152
  }
152
153
 
153
- async function runChildSession(client, { prompt, agent, model, title }) {
154
+ async function runChildSession(client, { prompt, agent, model, title, directory }) {
154
155
  if (!client?.session?.create || !client?.session?.prompt) {
155
156
  throw new Error("OpenCode SDK session API unavailable")
156
157
  }
157
158
  let created
158
- try { created = await client.session.create({ body: { title: title || "geneseed-workflow" } }) }
159
- catch { created = await client.session.create() }
159
+ // `query.directory` routes the request to that directory's instance, and the session keeps
160
+ // it — so an isolated child runs in its worktree. Never retried without it: a child that
161
+ // asked for a worktree and silently ran in the shared tree is the failure isolation exists for.
162
+ const q = directory ? { query: { directory } } : {}
163
+ try { created = await client.session.create({ body: { title: title || "geneseed-workflow" }, ...q }) }
164
+ catch (e) { if (directory) throw e; created = await client.session.create() }
160
165
  const sid = sessionId(created)
161
166
  if (!sid) throw new Error("could not create child session")
162
167
  try {
@@ -164,7 +169,7 @@ async function runChildSession(client, { prompt, agent, model, title }) {
164
169
  if (agent) body.agent = agent
165
170
  if (model) body.model = model
166
171
  let res = null
167
- try { res = await client.session.prompt({ path: { id: sid }, body }) }
172
+ try { res = await client.session.prompt({ path: { id: sid }, body, ...q }) }
168
173
  catch (e) { dlog(`prompt failed: ${e?.message || e}`) }
169
174
  const r = unwrap(res)
170
175
  let text = partsText(r?.parts ?? r?.info?.parts ?? [])
@@ -175,9 +180,52 @@ async function runChildSession(client, { prompt, agent, model, title }) {
175
180
  }
176
181
  }
177
182
 
183
+ // ---- worktree isolation ------------------------------------------------------
184
+ // `agent(prompt, { isolation: "worktree" })` — the same option Claude Code's Workflow
185
+ // takes. Parallel editors in one tree lose each other's writes (process 8, one writer per
186
+ // file), so each isolated agent gets its own branch + worktree off the root's HEAD, and
187
+ // the files it changed are recorded for `overlaps()`. A worktree with no change is
188
+ // removed with its branch; one with changes is KEPT for the parent to review and merge —
189
+ // the runtime never merges, commits, or deletes work. `git(argv, cwd)` is injected by the
190
+ // plugin so this file stays pure and testable. The worktree has no untracked files from
191
+ // the root (no node_modules, no .env): an agent that must run the project installs first.
192
+ const slug = (t) => String(t).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32) || "agent"
193
+
194
+ export async function isolate(git, root, name) {
195
+ if (typeof git !== "function") throw new Error("worktree isolation unavailable — no git executor")
196
+ if (!root) throw new Error("worktree isolation unavailable — unknown working dir")
197
+ const base = (await git(["rev-parse", "HEAD"], root)).trim()
198
+ const branch = `geneseed-wf/${name}`
199
+ const dir = path.join(os.tmpdir(), "geneseed-wf", name)
200
+ await git(["worktree", "add", "-b", branch, dir, base], root)
201
+ return { dir, branch, base }
202
+ }
203
+
204
+ export async function changedFiles(git, w) {
205
+ const lines = (out) => String(out).split("\n").map((l) => l.trim()).filter(Boolean)
206
+ const tracked = lines(await git(["diff", "--name-only", w.base], w.dir))
207
+ const untracked = lines(await git(["ls-files", "--others", "--exclude-standard"], w.dir))
208
+ return [...new Set([...tracked, ...untracked])].sort()
209
+ }
210
+
211
+ // Files more than one isolated agent changed — the conflicts a merge would otherwise
212
+ // settle by whoever landed last. [{ file, labels }], sorted by file.
213
+ export function overlaps(worktrees) {
214
+ const by = new Map()
215
+ for (const w of worktrees || []) for (const f of w.files || []) {
216
+ if (!by.has(f)) by.set(f, [])
217
+ by.get(f).push(w.label)
218
+ }
219
+ return [...by].filter(([, l]) => l.length > 1).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
220
+ .map(([file, labels]) => ({ file, labels }))
221
+ }
222
+
178
223
  // ---- the runtime ------------------------------------------------------------
179
224
  export function createRuntime(ctx) {
180
- const { client, directory, worktree, log: sink } = ctx
225
+ const { client, directory, worktree, log: sink, git } = ctx
226
+ const root = worktree || directory
227
+ const runTag = slug(ctx.runId || "run")
228
+ const worktrees = []
181
229
  const args = ctx.args || {}
182
230
  const cap = ctx.concurrency || concurrencyCap()
183
231
  const limit = createLimiter(cap)
@@ -202,9 +250,32 @@ export function createRuntime(ctx) {
202
250
  async function spawn(prompt, opts) {
203
251
  return limit(() => runChildSession(client, {
204
252
  prompt, agent: opts.agent, model: opts.model, title: `geneseed-wf:${opts.label}`,
253
+ directory: opts.directory,
205
254
  }))
206
255
  }
207
256
 
257
+ // Git calls are serialised: concurrent `worktree add -b` calls contend for the same ref
258
+ // and worktree locks, and a lost lock fails an agent that did nothing wrong.
259
+ const gitQ = createLimiter(1)
260
+ const g = typeof git === "function" ? (argv, cwd) => gitQ(() => git(argv, cwd)) : undefined
261
+
262
+ // An isolated agent that changed nothing has its worktree and branch released; one that
263
+ // changed files is KEPT and recorded. A failed release is logged, never thrown.
264
+ async function settle(w, label) {
265
+ let files
266
+ try { files = await changedFiles(g, w) }
267
+ catch (e) { emit(` ⚠ ${label}: could not list changes — worktree kept at ${w.dir}: ${e?.message || e}`); return }
268
+ if (files.length) {
269
+ worktrees.push({ label, dir: w.dir, branch: w.branch, base: w.base, files })
270
+ emit(` ⎇ ${label}: ${files.length} file(s) changed — kept at ${w.dir} (branch ${w.branch})`)
271
+ return
272
+ }
273
+ try {
274
+ await g(["worktree", "remove", w.dir], root)
275
+ await g(["branch", "-D", w.branch], root)
276
+ } catch (e) { emit(` ⚠ ${label}: could not release unchanged worktree — ${e?.message || e}`) }
277
+ }
278
+
208
279
  async function agent(prompt, opts = {}) {
209
280
  if (agentCount >= MAX_AGENTS) throw new Error(`workflow agent cap (${MAX_AGENTS}) exceeded`)
210
281
  if (budgetTotal != null && spent >= budgetTotal) {
@@ -213,8 +284,21 @@ export function createRuntime(ctx) {
213
284
  agentCount++
214
285
  const label = opts.label || String(prompt).slice(0, 40)
215
286
  emit(`· agent: ${label}`)
287
+ if (opts.isolation !== "worktree") return runAgent(prompt, { ...opts, label })
288
+ // The worktree wraps EVERY attempt, schema retries included — a retry that ran in the
289
+ // shared tree would undo the isolation it was asked for.
290
+ let w
291
+ try { w = await isolate(g, root, `${runTag}-${agentCount}-${slug(label)}`) }
292
+ catch (e) { emit(`✗ agent failed: ${label} — ${e?.message || e}`); return null }
293
+ emit(` ⎇ ${label}: worktree ${w.dir}`)
294
+ try { return await runAgent(prompt, { ...opts, label, directory: w.dir }) }
295
+ finally { await settle(w, label) }
296
+ }
297
+
298
+ async function runAgent(prompt, opts) {
299
+ const { label } = opts
216
300
  let out
217
- try { out = await spawn(prompt, { ...opts, label }) }
301
+ try { out = await spawn(prompt, opts) }
218
302
  catch (e) { emit(`✗ agent failed: ${label} — ${e?.message || e}`); return null }
219
303
  spent += out.usage?.output || 0
220
304
  if (!opts.schema) return out.text
@@ -270,7 +354,13 @@ export function createRuntime(ctx) {
270
354
 
271
355
  return {
272
356
  agent, parallel, pipeline, phase, log, budget, args, directory, worktree,
273
- _stats: () => ({ agentCount, spent, cap, phases: [...phases], trace: [...trace] }),
357
+ // Isolated agents that changed something, and the files two or more of them touched.
358
+ worktrees: () => worktrees.map((w) => ({ ...w, files: [...w.files] })),
359
+ overlaps: () => overlaps(worktrees),
360
+ _stats: () => ({
361
+ agentCount, spent, cap, phases: [...phases], trace: [...trace],
362
+ worktrees: worktrees.map((w) => ({ ...w, files: [...w.files] })), overlaps: overlaps(worktrees),
363
+ }),
274
364
  }
275
365
  }
276
366