geneseed 3.13.0 → 3.15.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 (131) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +45 -10
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,99 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [3.15.0] — 2026-10-05
12
+
13
+ **Upgrading:** re-emit (`geneseed rebuild-all`) so OpenCode installs get the hardened plugins and
14
+ the corrected command layer; then `geneseed web restart` (and a hard reload).
15
+
16
+ ### Security
17
+ - **OpenCode notify plugin** — a session title containing a typographic quote (`’`) could close
18
+ the PowerShell string and run code; title and body now travel through environment variables,
19
+ and the notifier no longer opens a visible console window.
20
+ - **OpenCode guard** — `rm -rf /*`, `rm -rf ~/*`, `rm -rf -- /` and `rm -r -f /` are blocked
21
+ (only bare `rm -rf /`, which GNU rm already refuses, was caught before).
22
+ - **Memory names** — a model-written memory named `MEMORY`/`README`, or differing only in case
23
+ from an existing fact, is refused instead of overwriting the index or another fact (Claude
24
+ `learn` hook and OpenCode learn plugin); the console's delete endpoint compares the reserved
25
+ names case-insensitively.
26
+ - **Web console** — POST requests are checked for Host and token before the body is read, and
27
+ bodies over 1 MiB are refused; `/api/item/config/<name>` serves only the known config files.
28
+ - **No cwd lookups on Windows** — `git`, `java`, `taskkill` and `powershell` are resolved through
29
+ PATH only, never the current directory, so a binary planted in an untrusted clone can't run.
30
+ - **`.geneseed-theme`** — a path-like marker value is refused instead of loading an arbitrary
31
+ `.json` file as a theme.
32
+
33
+ ### Fixed
34
+ - **`geneseed upgrade` kept your settings** — it re-rendered an OpenCode install with only its
35
+ theme and emit, resetting footprint, posture, mode, doctrine packs and excluded rules to the
36
+ defaults; it now reads the install's own values before pulling. The doctor rollback uses
37
+ `reset --keep` (no longer discards edits made during the doctor run), a deleted upstream is
38
+ reported instead of "already up to date", and `bootstrap` exits 1 when the update failed.
39
+ - OpenCode: `/code-review` is emitted again (the skill had been renamed); the command and
40
+ orchestrator files no longer overwrite — and then uninstall — a file you already had; the
41
+ learn plugin no longer exports non-functions; the context cache is per project; same-day
42
+ workflow runs no longer collide, and kept worktrees live under `.geneseed/worktrees/`.
43
+ - `agent-overrides.json` values that are not plain strings or numbers are skipped with a warning
44
+ instead of crashing the emit or injecting frontmatter; Claude-family hosts warn about the keys
45
+ they ignore.
46
+ - In a repo with both `.opencode/` and `.claude/`, each install reads its own loop trust preset.
47
+ - Web console: Restart/update from a foreground `geneseed web` no longer orphans a daemon, the
48
+ page reloads against the new server, shutdown/restart wait for running jobs, `web stop` no
49
+ longer forgets a daemon that is still running, finished jobs are trimmed from memory, the Diff
50
+ page explains project installs, and job polling survives a dropped request.
51
+ - `geneseed link` no longer deletes a `geneseed` it didn't create; the PATH check and quoting
52
+ are exact.
53
+ - Exclusions follow one rule on every host: `..` and symlinks are resolved on both sides, so an
54
+ entry spelled through a link excludes the real folder and `exclude remove` can take back any
55
+ entry that excludes.
56
+ - `geneseed setup` no longer crashes when the deployed theme is one this checkout doesn't ship.
57
+
58
+ ### Changed
59
+ - `uninstall` loads 14 modules instead of 43 (prompts moved to `js/lib/prompt.mjs`); `update`
60
+ 27 instead of 52; the web daemon 40 instead of 51.
61
+ - Web server split along its seams (`routes.mjs`, `catalog.mjs`, `state.mjs`, `user-files.mjs`),
62
+ removing two import cycles; dead TUI helpers, undriven probe rows and stale comment pointers
63
+ to the former Python suite removed.
64
+
65
+ ## [3.14.0] — 2026-10-04
66
+
67
+ **Upgrading:** re-emit (`geneseed rebuild-all`) so installed hooks pick up the gate fixes and the
68
+ per-verb hook modules; then `geneseed web restart`.
69
+
70
+ ### Security
71
+ - **Sealed Secrets gate** — `sk-ant-` keys containing `_` (base64url, most real keys) no longer
72
+ slip past; `MultiEdit` (`edits[].new_string`) and `NotebookEdit` (`notebook_path`/`new_source`)
73
+ are now scanned like `Write`/`Edit`.
74
+ - **Destructive-git gate** — catches `git push -f`, `git push origin +main`, `git reset -q --hard`
75
+ and `git clean -d -f` (flags anywhere after the verb); `LAW_IV_BASH` mirrors it.
76
+ - **Memory names** — a model-written memory `name:` that is not a plain slug is refused instead
77
+ of becoming a path outside the memory dir (Claude `learn` hook and the OpenCode learn plugin).
78
+ - **Uninstall** — manifest paths are contained to the install dir before unlinking (as deactivate
79
+ already did); empty-directory pruning stops at the base.
80
+
81
+ ### Fixed
82
+ - `--exclude-rules` is carried through every re-render: `geneseed diff` no longer reports drift on
83
+ an install with excluded rules, console Restore and `geneseed migrate` no longer re-admit them,
84
+ and `validate` honours every render axis. Unknown ids in `harness.config.json` now warn and are
85
+ dropped instead of breaking every later rebuild.
86
+ - Claude `settings.json` that is not a JSON object, or unreadable, is refused instead of
87
+ overwritten; every JSON config write is atomic.
88
+ - Loop: a malformed `record --card` is refused before it can wedge the loop; `check` resolves
89
+ bricks from the git root; `next` no longer rewrites `LOOP.md`; a clean tree is recognised.
90
+ - Console: the Docs host selector follows the deployed install instead of sticking to OpenCode;
91
+ the About page shows the installed and source fingerprints; the outline strips inline markdown.
92
+ - `theme --transparent-only` names the file it wrote; `validate --help` prints its own usage.
93
+ - Doctor's module-map check keys by folder, so twin module names can no longer hide a missing row.
94
+ - `learn` reports on stderr when it cannot start the model CLI.
95
+
96
+ ### Changed
97
+ - The hook binary loads one verb's module per call (`hooks.mjs` gates, `hooks-context.mjs`,
98
+ `hooks-learn.mjs`): gate cold import ~16 → ~10 ms, on every tool call. The memory-file helpers
99
+ live in the no-spawn `memory-files.mjs`, so the CLI no longer reaches `child_process`.
100
+ - Internal splits: `js/hosts/shim.mjs`, `js/build/args.mjs`, `checks-constitution.mjs`,
101
+ `checks-counts.mjs`; one JSON-object reader and one `CLAUDE_STYLE` host list; dead code and
102
+ Python-era comments removed.
103
+
11
104
  ## [3.13.0] — 2026-10-04
12
105
 
13
106
  **Upgrading:** re-emit (`geneseed rebuild-all`) so the installed `loop` skill knows the new
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **3.13.0**.
23
+ > string. Current label: **3.15.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -52,7 +52,7 @@
52
52
  // opencode-global` and `--emit opencode` both place it for you. Keep ONE copy:
53
53
  // OpenCode dedups plugins by npm name+version only, so two local copies both load.
54
54
 
55
- import { promises as fs } from "node:fs"
55
+ import { promises as fs, realpathSync } from "node:fs"
56
56
  import * as path from "node:path"
57
57
  import { fileURLToPath } from "node:url"
58
58
  import { homedir } from "node:os"
@@ -73,6 +73,16 @@ const norm = (p) => {
73
73
  raw = raw === "~" ? homedir() : path.join(homedir(), raw.slice(2))
74
74
  }
75
75
  let s = path.resolve(raw)
76
+ // Symlinks and junctions followed through the deepest existing ancestor (the rest appended),
77
+ // the rule js/hosts/hosts.mjs resolvePath applies: an exclude spelled through a link matches
78
+ // on every host, not only on Claude/Bob.
79
+ for (let cur = s, tail = []; ;) {
80
+ try { s = path.join(realpathSync.native(cur), ...tail); break } catch { /* walk up */ }
81
+ const up = path.dirname(cur)
82
+ if (up === cur) break
83
+ tail.unshift(path.basename(cur))
84
+ cur = up
85
+ }
76
86
  return process.platform === "win32" ? s.toLowerCase() : s
77
87
  }
78
88
  async function sovereignBypass(cwd) {
@@ -580,7 +590,7 @@ async function walkWikiDir(dir, depth, acc) {
580
590
  // entry on a large vault would otherwise inject thousands of listing lines. Beyond
581
591
  // the cap the listing truncates with a visible count and the agent explores the
582
592
  // folders on demand instead.
583
- const WIKI_LAZY_LIMIT = Number(process.env.GENESEED_WIKI_LAZY_LIMIT || 200)
593
+ const WIKI_LAZY_LIMIT = envNum("GENESEED_WIKI_LAZY_LIMIT", 200)
584
594
 
585
595
  // Parse wiki.jsonc into renderable wikis: [{ name, root, desc, conventions, inbox,
586
596
  // protected, eager:[{rel,abs,desc}], lazy:[...], truncated }]. Entry paths resolve
@@ -765,16 +775,19 @@ async function resolveBlock(root) {
765
775
  return { block: await buildBlock(sets, wikis, commands, await sessionFiles(harness)), src }
766
776
  }
767
777
 
768
- // Per-process cache of the rendered block text for the transform path (root is fixed
769
- // per plugin instance). Recomputed at most once per TTL so doc edits are picked up.
770
- let _blockCache = { text: null, at: 0 }
778
+ // Per-INSTANCE cache of the rendered block text for the transform path: the caller owns
779
+ // `cache`, one per plugin instance, because root is fixed per instance but one OpenCode
780
+ // process can serve several directories — a module-level cache served project A's
781
+ // context into B for a whole TTL. Recomputed at most once per TTL so doc edits are
782
+ // picked up.
771
783
  const BLOCK_TTL_MS = 30000
772
- async function cachedBlockText(root) {
784
+ async function cachedBlockText(root, cache) {
773
785
  const now = Date.now()
774
- if (_blockCache.text !== null && now - _blockCache.at < BLOCK_TTL_MS) return _blockCache.text
786
+ if (cache.text !== null && now - cache.at < BLOCK_TTL_MS) return cache.text
775
787
  let text = ""
776
788
  try { const { block } = await resolveBlock(root); text = block?.text || "" } catch {}
777
- _blockCache = { text, at: now }
789
+ cache.text = text
790
+ cache.at = now
778
791
  return text
779
792
  }
780
793
 
@@ -797,6 +810,7 @@ export const GeneseedContext = async (ctx) => {
797
810
  const { client } = ctx
798
811
  const done = new Set()
799
812
  const root = repoRoot(ctx)
813
+ const blockCache = { text: null, at: 0 }
800
814
 
801
815
  // Visible delivery — post the block as a noReply session message. Used when
802
816
  // GENESEED_CONTEXT_VISIBLE forces it, and by the transform fallback below.
@@ -927,7 +941,7 @@ export const GeneseedContext = async (ctx) => {
927
941
  const present = output.messages.some((m) =>
928
942
  (m?.parts ?? []).some((p) => typeof p?.text === "string" && p.text.includes(MARKER)))
929
943
  if (present) return
930
- const cached = await cachedBlockText(root)
944
+ const cached = await cachedBlockText(root, blockCache)
931
945
  if (!cached) return
932
946
  // Model line is request-specific (kept out of the cache): prefer the live
933
947
  // transcript on this request, fall back to $GENESEED_MODEL.
@@ -31,7 +31,7 @@
31
31
  // fires `permission.ask` simply never reaches this hook — the static ask still fires, so the
32
32
  // failure mode is one extra prompt, never a silent allow.
33
33
 
34
- import { promises as fs, existsSync, statSync, lstatSync, readFileSync, openSync, readSync, closeSync } from "node:fs"
34
+ import { promises as fs, realpathSync, existsSync, statSync, lstatSync, readFileSync, openSync, readSync, closeSync } from "node:fs"
35
35
  import * as path from "node:path"
36
36
  import { fileURLToPath } from "node:url"
37
37
  import { homedir } from "node:os"
@@ -50,6 +50,16 @@ const norm = (p) => {
50
50
  raw = raw === "~" ? homedir() : path.join(homedir(), raw.slice(2))
51
51
  }
52
52
  let s = path.resolve(raw)
53
+ // Symlinks and junctions followed through the deepest existing ancestor (the rest appended),
54
+ // the rule js/hosts/hosts.mjs resolvePath applies: an exclude spelled through a link matches
55
+ // on every host, not only on Claude/Bob.
56
+ for (let cur = s, tail = []; ;) {
57
+ try { s = path.join(realpathSync.native(cur), ...tail); break } catch { /* walk up */ }
58
+ const up = path.dirname(cur)
59
+ if (up === cur) break
60
+ tail.unshift(path.basename(cur))
61
+ cur = up
62
+ }
53
63
  return process.platform === "win32" ? s.toLowerCase() : s
54
64
  }
55
65
  async function sovereignBypass(cwd) {
@@ -119,8 +129,10 @@ const SECRET_WARN_RE = [/(^|\/)\.env(\.[\w.-]+)?$/i]
119
129
  // Catastrophic, effectively irreversible shell → BLOCK. Both Unix and Windows shells,
120
130
  // since OpenCode runs natively on Windows and the agent may emit cmd / PowerShell.
121
131
  const SHELL_BLOCK_RE = [
122
- /\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(--no-preserve-root\s+)?\/(\s|$)/, // rm -rf /
123
- /\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+~(\/)?(\s|$)/, // rm -rf ~
132
+ // rm -rf /, ~, /*, ~/* — flags may be split (-r -f), long (--recursive), or end with
133
+ // `--`; the lookahead insists one of them recurses. The target must be the root or
134
+ // home itself (optionally globbed), so `rm -rf /tmp/x` and `rm -rf ./build` pass.
135
+ /\brm\s+(?=(?:(?:-[a-zA-Z]+|--[a-z-]*)\s+)*?(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)\s)(?:(?:-[a-zA-Z]+|--[a-z-]*)\s+)+(?:\/|~\/?)\*?(\s|$)/,
124
136
  /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, // fork bomb
125
137
  /\bmkfs\.\w+\s+\/dev\//, // format a device
126
138
  /\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk)/, // dd over a raw disk
@@ -409,7 +421,7 @@ export const GeneseedGuard = async (ctx) => {
409
421
  return {
410
422
  "tool.execute.before": async (input, output) => {
411
423
  if (OFF) return
412
- if (await sovereignBypass(process.cwd())) return
424
+ if (await sovereignBypass(root())) return
413
425
  const tool = (input?.tool || input?.name || "").toLowerCase()
414
426
  const args = output?.args || input?.args || {}
415
427
  // `rule` is the ledger key, spelled as js/hosts/hooks.mjs spells it (`law-1`,
@@ -29,10 +29,11 @@
29
29
  // 3. ./memory or ./Harness/memory (when the bundle is inside the project)
30
30
  // If none resolve, it logs once and does nothing — it never blocks a session.
31
31
  //
32
- // It is intentionally conservative: trivial sessions are skipped, each session is
33
- // processed at most once per process, and any error is swallowed.
32
+ // It is intentionally conservative: trivial sessions are skipped, one session never
33
+ // has two distils in flight at once (a continued session re-distils after its next
34
+ // quiet window — see TIMING), and any error is swallowed.
34
35
 
35
- import { promises as fs } from "node:fs"
36
+ import { promises as fs, realpathSync } from "node:fs"
36
37
  import * as path from "node:path"
37
38
  import { fileURLToPath } from "node:url"
38
39
  import { homedir } from "node:os"
@@ -54,6 +55,16 @@ const norm = (p) => {
54
55
  raw = raw === "~" ? homedir() : path.join(homedir(), raw.slice(2))
55
56
  }
56
57
  let s = path.resolve(raw)
58
+ // Symlinks and junctions followed through the deepest existing ancestor (the rest appended),
59
+ // the rule js/hosts/hosts.mjs resolvePath applies: an exclude spelled through a link matches
60
+ // on every host, not only on Claude/Bob.
61
+ for (let cur = s, tail = []; ;) {
62
+ try { s = path.join(realpathSync.native(cur), ...tail); break } catch { /* walk up */ }
63
+ const up = path.dirname(cur)
64
+ if (up === cur) break
65
+ tail.unshift(path.basename(cur))
66
+ cur = up
67
+ }
57
68
  return process.platform === "win32" ? s.toLowerCase() : s
58
69
  }
59
70
  async function sovereignBypass(cwd) {
@@ -155,19 +166,20 @@ const AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,40}$/
155
166
  const MAX_AGENT_BULLETS = 100 // hard cap, oldest dropped; no pruning heuristics
156
167
 
157
168
  // SINGLE SOURCE — the harness extracts this exact literal (see LEARN_PROMPT_HEAD).
158
- export const AGENT_LESSON_PROMPT = `The notes below are one subagent dispatch ("agent run").
169
+ // Not exported: OpenCode calls every export as a plugin factory, and a string is not one.
170
+ const AGENT_LESSON_PROMPT = `The notes below are one subagent dispatch ("agent run").
159
171
  Output AT MOST ONE line: a durable lesson about how this agent should operate on a
160
172
  FUTURE, unrelated dispatch (a boundary that proved wrong, an input it always needs,
161
173
  a method that worked). Task residue ("fixed X", file names, ticket detail) is NOT a
162
174
  lesson. If there is none — the overwhelmingly common case — output exactly: NOTHING.`
163
175
 
164
- export function resolveAgentName(meta) {
176
+ function resolveAgentName(meta) {
165
177
  const raw = meta?.agent ?? meta?.agentName ?? meta?.agentID ?? null
166
178
  const name = typeof raw === "string" ? raw.trim().toLowerCase() : null
167
179
  return name && AGENT_NAME_RE.test(name) ? name : null
168
180
  }
169
181
 
170
- export async function appendAgentLesson(memDir, agent, lesson) {
182
+ async function appendAgentLesson(memDir, agent, lesson) {
171
183
  const dir = path.join(memDir, "agents")
172
184
  await fs.mkdir(dir, { recursive: true })
173
185
  const file = path.join(dir, `${agent}.md`)
@@ -211,8 +223,12 @@ function envModel() {
211
223
  return null
212
224
  }
213
225
 
226
+ // Stems that name the store's own files, never a fact: `MEMORY` is the index, so a model
227
+ // that emitted `name: MEMORY` would overwrite it.
228
+ const RESERVED_STEMS = new Set(["memory", "readme"])
229
+
214
230
  async function existingSlugs(memDir) {
215
- const skip = new Set(["memory", "readme"])
231
+ const skip = RESERVED_STEMS
216
232
  try {
217
233
  const entries = await fs.readdir(memDir)
218
234
  return new Set(
@@ -251,7 +267,15 @@ function parseFrontmatter(chunk) {
251
267
  return fm
252
268
  }
253
269
 
270
+ // A memory `name:` becomes a FILENAME and the model writes it, so it is a plain slug or
271
+ // nothing — no separator, no `..`, no drive. The same rule as MEMORY_SLUG_RE in
272
+ // js/hosts/hooks.mjs, inlined because this file ships standalone.
273
+ const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/
274
+
275
+ // Compared lowercase: on a case-insensitive filesystem (Windows, macOS) `User-Prefs`
276
+ // and `user-prefs` are the same file, so a case variant would overwrite a stored fact.
254
277
  async function writeMemories(output, memDir, existing) {
278
+ const taken = new Set([...existing].map((s) => s.toLowerCase()))
255
279
  const written = []
256
280
  const indexLines = []
257
281
  for (let chunk of output.split(/^---FILE---\s*$/m)) {
@@ -259,9 +283,11 @@ async function writeMemories(output, memDir, existing) {
259
283
  if (!chunk || chunk.toUpperCase() === "NOTHING") continue
260
284
  const fm = parseFrontmatter(chunk)
261
285
  const name = (fm.name || "").trim()
262
- if (!name || existing.has(name)) continue
286
+ const key = name.toLowerCase()
287
+ if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || taken.has(key)) continue
263
288
  await fs.writeFile(path.join(memDir, `${name}.md`), chunk.replace(/\n+$/, "") + "\n", "utf8")
264
289
  existing.add(name)
290
+ taken.add(key)
265
291
  written.push(name)
266
292
  const desc = (fm.description || "").trim()
267
293
  indexLines.push(`- [${name}](${name}.md)` + (desc ? ` — ${desc}` : ""))
@@ -275,14 +301,19 @@ async function writeMemories(output, memDir, existing) {
275
301
  return written
276
302
  }
277
303
 
278
- export const GeneseedLearn = async ({ client }) => {
304
+ export const GeneseedLearn = async (ctx) => {
305
+ const { client } = ctx ?? {}
279
306
  const ours = new Set() // throwaway distil sessions — never mine our own output
280
307
  const timers = new Map() // sid -> debounce timer, re-armed on each idle
281
308
  const inFlight = new Set() // sid currently being distilled (guards against overlap)
282
309
  let warnedNoDir = false
283
310
 
311
+ // The session root, as guard and context use it — not process.cwd(), which is the
312
+ // directory OpenCode was launched from and need not be the project being served.
313
+ const root = ctx?.worktree || ctx?.directory || process.cwd()
314
+
284
315
  async function runDistill(sid) {
285
- if (await sovereignBypass(process.cwd())) return
316
+ if (await sovereignBypass(root)) return
286
317
  if (inFlight.has(sid)) return // a run is already underway; the next idle re-arms
287
318
  inFlight.add(sid)
288
319
  try {
@@ -393,4 +424,8 @@ export const GeneseedLearn = async ({ client }) => {
393
424
  }
394
425
  }
395
426
 
427
+ // OpenCode treats every export as a plugin and rejects non-functions; hang the test
428
+ // helpers off the factory instead — reachable via import, invisible to the loader.
429
+ Object.assign(GeneseedLearn, { writeMemories, resolveAgentName, appendAgentLesson })
430
+
396
431
  export default GeneseedLearn
@@ -27,6 +27,7 @@
27
27
  // glob), exactly like the context, learn, guard, and workflow plugins.
28
28
 
29
29
  import { spawn } from "node:child_process"
30
+ import path from "node:path"
30
31
 
31
32
  const MODE = (process.env.GENESEED_NOTIFY || "on").toLowerCase()
32
33
  const OFF = ["off", "0", "false", "no"].includes(MODE)
@@ -67,11 +68,41 @@ function shouldNotify({ now, lastUserMs, parentID, title, minMs }) {
67
68
 
68
69
  // AppleScript double-quoted string literal.
69
70
  function asStr(s) { return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"' }
70
- // PowerShell single-quoted string literal (escape ' by doubling).
71
- function psStr(s) { return "'" + String(s).replace(/'/g, "''") + "'" }
72
71
 
73
- function spawnDetached(cmd, args) {
74
- const child = spawn(cmd, args, { stdio: "ignore", detached: true })
72
+ // The notifier invocation for a platform, as data. Exported (via the factory) for tests.
73
+ // Windows passes title and body through the environment, never through the -Command
74
+ // text: PowerShell treats U+2018-U+201B as quote characters too, so escaping ASCII '
75
+ // alone let a model-generated session title like x’;Start-Process calc;’ close the
76
+ // literal and run code. An env var is read as a value and never parsed as script.
77
+ function notifyCommand(platform, title, body) {
78
+ if (platform === "darwin") {
79
+ return { cmd: "osascript", args: ["-e", `display notification ${asStr(body)} with title ${asStr(title)}`] }
80
+ }
81
+ if (platform === "win32") {
82
+ const script =
83
+ "Add-Type -AssemblyName System.Windows.Forms;" +
84
+ "Add-Type -AssemblyName System.Drawing;" +
85
+ "$n=New-Object System.Windows.Forms.NotifyIcon;" +
86
+ "$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;" +
87
+ "$n.ShowBalloonTip(5000,$env:GS_T,$env:GS_B,[System.Windows.Forms.ToolTipIcon]::Info);" +
88
+ "Start-Sleep -Seconds 6;$n.Dispose()"
89
+ return {
90
+ // By absolute path: a bare "powershell" resolves against the cwd first on Windows.
91
+ cmd: path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
92
+ args: ["-NoProfile", "-NonInteractive", "-Command", script],
93
+ env: { GS_T: String(title), GS_B: String(body) },
94
+ }
95
+ }
96
+ return { cmd: "notify-send", args: [title, body] }
97
+ }
98
+
99
+ // windowsHide: without it a detached PowerShell opens a visible console window that
100
+ // sits on screen for the six seconds the balloon needs.
101
+ function spawnDetached({ cmd, args, env }) {
102
+ const child = spawn(cmd, args, {
103
+ stdio: "ignore", detached: true, windowsHide: true,
104
+ env: env ? { ...process.env, ...env } : process.env,
105
+ })
75
106
  child.on("error", (e) => log(`spawn ${cmd} failed: ${e?.message ?? e}`)) // e.g. notifier not installed
76
107
  child.unref()
77
108
  }
@@ -79,20 +110,7 @@ function spawnDetached(cmd, args) {
79
110
  // Best-effort native notification; every failure is swallowed.
80
111
  function deliver(title, body) {
81
112
  try {
82
- if (process.platform === "darwin") {
83
- spawnDetached("osascript", ["-e", `display notification ${asStr(body)} with title ${asStr(title)}`])
84
- } else if (process.platform === "win32") {
85
- const script =
86
- "Add-Type -AssemblyName System.Windows.Forms;" +
87
- "Add-Type -AssemblyName System.Drawing;" +
88
- "$n=New-Object System.Windows.Forms.NotifyIcon;" +
89
- "$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;" +
90
- `$n.ShowBalloonTip(5000,${psStr(title)},${psStr(body)},[System.Windows.Forms.ToolTipIcon]::Info);` +
91
- "Start-Sleep -Seconds 6;$n.Dispose()"
92
- spawnDetached("powershell", ["-NoProfile", "-NonInteractive", "-Command", script])
93
- } else {
94
- spawnDetached("notify-send", [title, body])
95
- }
113
+ spawnDetached(notifyCommand(process.platform, title, body))
96
114
  } catch (err) {
97
115
  log(`deliver failed: ${err?.message ?? err}`)
98
116
  }
@@ -137,6 +155,6 @@ export const GeneseedNotify = async ({ client }) => {
137
155
  // ponytail: OpenCode treats every export as a plugin and rejects non-functions; a
138
156
  // bare helper export crashes startup or logs "not a function". Hang the test helpers
139
157
  // off the factory instead — reachable via `import`, invisible to the loader.
140
- Object.assign(GeneseedNotify, { lastUserMs, shouldNotify })
158
+ Object.assign(GeneseedNotify, { lastUserMs, shouldNotify, notifyCommand })
141
159
 
142
160
  export default GeneseedNotify
@@ -14,7 +14,9 @@
14
14
  // written to `.geneseed/workflow-runs/<runId>.log`. Every failure is contained and
15
15
  // reported, never thrown into the session.
16
16
  //
17
- // Quiet by default; GENESEED_DEBUG=1 logs to stderr. GENESEED_WORKFLOWS_DIR overrides
17
+ // One stderr line at load (the banner below — it is how a restart shows the tool
18
+ // registered, or warns that it could not); everything else is quiet unless
19
+ // GENESEED_DEBUG=1. GENESEED_WORKFLOWS_DIR overrides
18
20
  // where saved workflows are loaded from.
19
21
 
20
22
  import { execFile } from "node:child_process"
@@ -16,6 +16,7 @@
16
16
  // authoring convention — imported modules cannot have their globals shadowed — not a
17
17
  // hard sandbox guard. See docs/specs/2026-06-09-opencode-workflow-primitive.md.
18
18
 
19
+ import { promises as fs } from "node:fs"
19
20
  import * as os from "node:os"
20
21
  import * as path from "node:path"
21
22
 
@@ -189,14 +190,23 @@ async function runChildSession(client, { prompt, agent, model, title, directory
189
190
  // the runtime never merges, commits, or deletes work. `git(argv, cwd)` is injected by the
190
191
  // plugin so this file stays pure and testable. The worktree has no untracked files from
191
192
  // 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
+ // Worktrees live under <root>/.geneseed/worktrees/ beside the run traces, NOT in the OS
195
+ // temp dir: a kept worktree is unmerged work, and temp cleaners delete it. The state dir
196
+ // gets a `*` .gitignore of its own (never overwriting one already there), so neither the
197
+ // worktrees nor the traces show up as untracked files in the project.
198
+ const fullSlug = (t) => String(t).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
199
+ const slug = (t) => fullSlug(t).slice(0, 32) || "agent"
193
200
 
194
201
  export async function isolate(git, root, name) {
195
202
  if (typeof git !== "function") throw new Error("worktree isolation unavailable — no git executor")
196
203
  if (!root) throw new Error("worktree isolation unavailable — unknown working dir")
197
204
  const base = (await git(["rev-parse", "HEAD"], root)).trim()
198
205
  const branch = `geneseed-wf/${name}`
199
- const dir = path.join(os.tmpdir(), "geneseed-wf", name)
206
+ const state = path.join(root, ".geneseed")
207
+ await fs.mkdir(path.join(state, "worktrees"), { recursive: true })
208
+ await fs.writeFile(path.join(state, ".gitignore"), "*\n", { flag: "wx" }).catch(() => {})
209
+ const dir = path.join(state, "worktrees", name)
200
210
  await git(["worktree", "add", "-b", branch, dir, base], root)
201
211
  return { dir, branch, base }
202
212
  }
@@ -224,7 +234,10 @@ export function overlaps(worktrees) {
224
234
  export function createRuntime(ctx) {
225
235
  const { client, directory, worktree, log: sink, git } = ctx
226
236
  const root = worktree || directory
227
- const runTag = slug(ctx.runId || "run")
237
+ // The WHOLE run id, never truncated: it ends in the run's time, and cutting it to the
238
+ // label slug's 32 characters left two same-day runs of a long-named workflow with one
239
+ // tag — the second run's `worktree add -b` then hit the first run's kept branch.
240
+ const runTag = fullSlug(ctx.runId || "run") || "run"
228
241
  const worktrees = []
229
242
  const args = ctx.args || {}
230
243
  const cap = ctx.concurrency || concurrencyCap()