geneseed 3.14.0 → 3.16.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 (94) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +45 -11
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  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 +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/docs/concepts/exclusions.md +21 -9
  12. package/docs/guides/install-scripted.md +1 -1
  13. package/docs/guides/project-context.md +1 -1
  14. package/docs/token-footprint.md +10 -7
  15. package/harness.config.json +1 -1
  16. package/js/README.md +9 -5
  17. package/js/build/args.mjs +38 -6
  18. package/js/build/bundle.mjs +10 -10
  19. package/js/build/driver.mjs +16 -14
  20. package/js/build/emit-claude.mjs +3 -2
  21. package/js/build/emit-common.mjs +2 -14
  22. package/js/build/emit-opencode.mjs +12 -8
  23. package/js/build/generate.mjs +26 -16
  24. package/js/build/render.mjs +34 -4
  25. package/js/build/source.mjs +43 -7
  26. package/js/build/stubs.mjs +6 -5
  27. package/js/build/version.mjs +21 -2
  28. package/js/cli-table.json +15 -0
  29. package/js/hosts/hooks-context.mjs +85 -34
  30. package/js/hosts/hooks-prims.mjs +2 -2
  31. package/js/hosts/hosts.mjs +24 -26
  32. package/js/hosts/installs.mjs +58 -16
  33. package/js/hosts/link.mjs +75 -26
  34. package/js/hosts/memory-files.mjs +13 -4
  35. package/js/hosts/native.mjs +106 -48
  36. package/js/hosts/opencode.mjs +46 -36
  37. package/js/hosts/shim.mjs +4 -2
  38. package/js/inspect/diff.mjs +9 -7
  39. package/js/inspect/excludes.mjs +10 -5
  40. package/js/inspect/status.mjs +20 -22
  41. package/js/inspect/validate.mjs +5 -3
  42. package/js/lib/fs.mjs +12 -11
  43. package/js/lib/json.mjs +4 -4
  44. package/js/lib/paths.mjs +27 -33
  45. package/js/lib/proc.mjs +3 -3
  46. package/js/lib/prompt.mjs +132 -0
  47. package/js/lib/text.mjs +18 -4
  48. package/js/lib/udiff.mjs +3 -2
  49. package/js/maintain/memory.mjs +5 -6
  50. package/js/maintain/migrate.mjs +4 -3
  51. package/js/maintain/setup.mjs +20 -135
  52. package/js/maintain/uninstall.mjs +10 -9
  53. package/js/maintain/update.mjs +115 -34
  54. package/js/ui/anim.mjs +8 -12
  55. package/js/ui/cli.mjs +29 -35
  56. package/js/ui/menu.mjs +7 -13
  57. package/js/ui/tui.mjs +14 -123
  58. package/js/web/actions.mjs +87 -389
  59. package/js/web/activity.mjs +1 -1
  60. package/js/web/api.mjs +33 -717
  61. package/js/web/catalog.mjs +527 -0
  62. package/js/web/daemon.mjs +21 -3
  63. package/js/web/handler.mjs +51 -27
  64. package/js/web/jobs.mjs +24 -2
  65. package/js/web/routes.mjs +61 -6
  66. package/js/web/server.mjs +4 -4
  67. package/js/web/state.mjs +138 -0
  68. package/js/web/user-files.mjs +339 -0
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +1 -1
  71. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  72. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  73. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  74. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  75. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  76. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  77. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  78. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  79. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  80. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  81. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  82. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  83. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  84. package/web/dist/assets/api-CxgfYu18.js +1 -0
  85. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  86. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  87. package/web/dist/index.html +2 -2
  88. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  89. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  90. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  91. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  92. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  93. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  94. package/web/dist/assets/index-KHt3wE_K.js +0 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,72 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [3.16.0] — 2026-10-05
12
+
13
+ **Upgrading:** re-emit (`geneseed rebuild-all`) so the context hook stays under Claude Code's output cap. To drop skills you never use, rebuild an install with `--exclude-skills <name,…>` (see [Exclusions](docs/concepts/exclusions.md)).
14
+
15
+ ### Added
16
+
17
+ - `--exclude-skills <name,…>` leaves skills you never use out of an install, on every host: their files, their catalogue rows and any OpenCode command that runs them. The install records the list on an `Excluded skills:` line, and `upgrade`, `rebuild-all`, `diff`, `setup` and the web console keep it. Unknown skills are refused, and so are the ten the harness links to by path. `status` shows `skills off: …`.
18
+
19
+ ### Fixed
20
+
21
+ - The context hook (Claude Code, Bob, OpenClaude) now keeps its whole output under 9,000 characters. Claude Code shows the model only a preview of a hook output past ~10k, so a doc-heavy repo lost its context silently — this repo's was 25.6 KB, now 2.5 KB. Eager docs that do not fit are listed lazy with the reason; the lazy listing folds three or more docs in one folder into `dir/ — N docs` and ends in a count if it is still too long. The OpenCode plugin folds its project listing the same way.
22
+
23
+ ## [3.15.0] — 2026-10-05
24
+
25
+ **Upgrading:** re-emit (`geneseed rebuild-all`) so OpenCode installs get the hardened plugins and
26
+ the corrected command layer; then `geneseed web restart` (and a hard reload).
27
+
28
+ ### Security
29
+ - **OpenCode notify plugin** — a session title containing a typographic quote (`’`) could close
30
+ the PowerShell string and run code; title and body now travel through environment variables,
31
+ and the notifier no longer opens a visible console window.
32
+ - **OpenCode guard** — `rm -rf /*`, `rm -rf ~/*`, `rm -rf -- /` and `rm -r -f /` are blocked
33
+ (only bare `rm -rf /`, which GNU rm already refuses, was caught before).
34
+ - **Memory names** — a model-written memory named `MEMORY`/`README`, or differing only in case
35
+ from an existing fact, is refused instead of overwriting the index or another fact (Claude
36
+ `learn` hook and OpenCode learn plugin); the console's delete endpoint compares the reserved
37
+ names case-insensitively.
38
+ - **Web console** — POST requests are checked for Host and token before the body is read, and
39
+ bodies over 1 MiB are refused; `/api/item/config/<name>` serves only the known config files.
40
+ - **No cwd lookups on Windows** — `git`, `java`, `taskkill` and `powershell` are resolved through
41
+ PATH only, never the current directory, so a binary planted in an untrusted clone can't run.
42
+ - **`.geneseed-theme`** — a path-like marker value is refused instead of loading an arbitrary
43
+ `.json` file as a theme.
44
+
45
+ ### Fixed
46
+ - **`geneseed upgrade` kept your settings** — it re-rendered an OpenCode install with only its
47
+ theme and emit, resetting footprint, posture, mode, doctrine packs and excluded rules to the
48
+ defaults; it now reads the install's own values before pulling. The doctor rollback uses
49
+ `reset --keep` (no longer discards edits made during the doctor run), a deleted upstream is
50
+ reported instead of "already up to date", and `bootstrap` exits 1 when the update failed.
51
+ - OpenCode: `/code-review` is emitted again (the skill had been renamed); the command and
52
+ orchestrator files no longer overwrite — and then uninstall — a file you already had; the
53
+ learn plugin no longer exports non-functions; the context cache is per project; same-day
54
+ workflow runs no longer collide, and kept worktrees live under `.geneseed/worktrees/`.
55
+ - `agent-overrides.json` values that are not plain strings or numbers are skipped with a warning
56
+ instead of crashing the emit or injecting frontmatter; Claude-family hosts warn about the keys
57
+ they ignore.
58
+ - In a repo with both `.opencode/` and `.claude/`, each install reads its own loop trust preset.
59
+ - Web console: Restart/update from a foreground `geneseed web` no longer orphans a daemon, the
60
+ page reloads against the new server, shutdown/restart wait for running jobs, `web stop` no
61
+ longer forgets a daemon that is still running, finished jobs are trimmed from memory, the Diff
62
+ page explains project installs, and job polling survives a dropped request.
63
+ - `geneseed link` no longer deletes a `geneseed` it didn't create; the PATH check and quoting
64
+ are exact.
65
+ - Exclusions follow one rule on every host: `..` and symlinks are resolved on both sides, so an
66
+ entry spelled through a link excludes the real folder and `exclude remove` can take back any
67
+ entry that excludes.
68
+ - `geneseed setup` no longer crashes when the deployed theme is one this checkout doesn't ship.
69
+
70
+ ### Changed
71
+ - `uninstall` loads 14 modules instead of 43 (prompts moved to `js/lib/prompt.mjs`); `update`
72
+ 27 instead of 52; the web daemon 40 instead of 51.
73
+ - Web server split along its seams (`routes.mjs`, `catalog.mjs`, `state.mjs`, `user-files.mjs`),
74
+ removing two import cycles; dead TUI helpers, undriven probe rows and stale comment pointers
75
+ to the former Python suite removed.
76
+
11
77
  ## [3.14.0] — 2026-10-04
12
78
 
13
79
  **Upgrading:** re-emit (`geneseed rebuild-all`) so installed hooks pick up the gate fixes and the
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.14.0**.
23
+ > string. Current label: **3.16.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) {
@@ -107,6 +117,8 @@ const MAX_DEPTH = 6
107
117
  // the head of each file (enough for an H1) rather than the whole thing — a large
108
118
  // docs/ tree must not cost one full-file read per entry on every session start.
109
119
  const LAZY_HEADING_LIMIT = envNum("GENESEED_LAZY_HEADINGS", 64)
120
+ // Same threshold as the Claude hook's LAZY_FOLD_AT.
121
+ const LAZY_FOLD_AT = 3
110
122
  const HEADING_SLICE_BYTES = 4096
111
123
 
112
124
  // Quiet by default — OpenCode surfaces a plugin's stderr in the UI (red text). Set
@@ -580,7 +592,7 @@ async function walkWikiDir(dir, depth, acc) {
580
592
  // entry on a large vault would otherwise inject thousands of listing lines. Beyond
581
593
  // the cap the listing truncates with a visible count and the agent explores the
582
594
  // folders on demand instead.
583
- const WIKI_LAZY_LIMIT = Number(process.env.GENESEED_WIKI_LAZY_LIMIT || 200)
595
+ const WIKI_LAZY_LIMIT = envNum("GENESEED_WIKI_LAZY_LIMIT", 200)
584
596
 
585
597
  // Parse wiki.jsonc into renderable wikis: [{ name, root, desc, conventions, inbox,
586
598
  // protected, eager:[{rel,abs,desc}], lazy:[...], truncated }]. Entry paths resolve
@@ -654,7 +666,7 @@ async function wikiSets() {
654
666
  // Render one eager+lazy set into `out`. The budget state (eager bytes, heading
655
667
  // reads) is SHARED across segments — project context and every wiki draw from the
656
668
  // same caps, so the whole block stays bounded no matter how many wikis are declared.
657
- async function renderSet(out, { eager, lazy }, state) {
669
+ async function renderSet(out, { eager, lazy }, state, { fold = false } = {}) {
658
670
  const perFile = EAGER_FILE_KB * 1024
659
671
  const total = EAGER_TOTAL_KB * 1024
660
672
  const demoted = []
@@ -682,8 +694,26 @@ async function renderSet(out, { eager, lazy }, state) {
682
694
  state.injected++
683
695
  }
684
696
 
697
+ // The project listing folds like the Claude hook's (js/hosts/hooks-context.mjs): three or more
698
+ // undescribed docs sharing a parent directory become one `dir/ — N docs` line — a docs/ tree
699
+ // of 186 files was 186 lines on every session. Root files never fold. A wiki listing keeps its
700
+ // per-note headings: a vault is navigated by them, and it carries its own cap.
701
+ const byDir = new Map()
702
+ if (fold) {
703
+ for (const l of lazy) {
704
+ const dir = path.posix.dirname(l.rel.replace(/\\/g, "/"))
705
+ if (!l.desc && dir !== ".") byDir.set(dir, (byDir.get(dir) || 0) + 1)
706
+ }
707
+ }
708
+ const folded = new Set()
685
709
  const lazyLines = []
686
710
  for (const l of lazy) {
711
+ const dir = path.posix.dirname(l.rel.replace(/\\/g, "/"))
712
+ if (!l.desc && byDir.get(dir) >= LAZY_FOLD_AT) {
713
+ if (!folded.has(dir)) lazyLines.push(` - ${dir}/ — ${byDir.get(dir)} docs`)
714
+ folded.add(dir)
715
+ continue
716
+ }
687
717
  let head = l.desc
688
718
  // Only crack open files we have no description for, and cap how many — bounded by
689
719
  // a head-slice read so a big docs/ tree stays cheap on every session start.
@@ -724,7 +754,7 @@ async function buildBlock(sets, wikis = [], commands = [], session = []) {
724
754
  }
725
755
 
726
756
  const proj = []
727
- await renderSet(proj, sets, state)
757
+ await renderSet(proj, sets, state, { fold: true })
728
758
 
729
759
  if (commands.length) {
730
760
  proj.push("--- Project commands (runnable targets discovered in the repo root) ---")
@@ -765,16 +795,19 @@ async function resolveBlock(root) {
765
795
  return { block: await buildBlock(sets, wikis, commands, await sessionFiles(harness)), src }
766
796
  }
767
797
 
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 }
798
+ // Per-INSTANCE cache of the rendered block text for the transform path: the caller owns
799
+ // `cache`, one per plugin instance, because root is fixed per instance but one OpenCode
800
+ // process can serve several directories — a module-level cache served project A's
801
+ // context into B for a whole TTL. Recomputed at most once per TTL so doc edits are
802
+ // picked up.
771
803
  const BLOCK_TTL_MS = 30000
772
- async function cachedBlockText(root) {
804
+ async function cachedBlockText(root, cache) {
773
805
  const now = Date.now()
774
- if (_blockCache.text !== null && now - _blockCache.at < BLOCK_TTL_MS) return _blockCache.text
806
+ if (cache.text !== null && now - cache.at < BLOCK_TTL_MS) return cache.text
775
807
  let text = ""
776
808
  try { const { block } = await resolveBlock(root); text = block?.text || "" } catch {}
777
- _blockCache = { text, at: now }
809
+ cache.text = text
810
+ cache.at = now
778
811
  return text
779
812
  }
780
813
 
@@ -797,6 +830,7 @@ export const GeneseedContext = async (ctx) => {
797
830
  const { client } = ctx
798
831
  const done = new Set()
799
832
  const root = repoRoot(ctx)
833
+ const blockCache = { text: null, at: 0 }
800
834
 
801
835
  // Visible delivery — post the block as a noReply session message. Used when
802
836
  // GENESEED_CONTEXT_VISIBLE forces it, and by the transform fallback below.
@@ -927,7 +961,7 @@ export const GeneseedContext = async (ctx) => {
927
961
  const present = output.messages.some((m) =>
928
962
  (m?.parts ?? []).some((p) => typeof p?.text === "string" && p.text.includes(MARKER)))
929
963
  if (present) return
930
- const cached = await cachedBlockText(root)
964
+ const cached = await cachedBlockText(root, blockCache)
931
965
  if (!cached) return
932
966
  // Model line is request-specific (kept out of the cache): prefer the live
933
967
  // 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(
@@ -256,7 +272,10 @@ function parseFrontmatter(chunk) {
256
272
  // js/hosts/hooks.mjs, inlined because this file ships standalone.
257
273
  const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/
258
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.
259
277
  async function writeMemories(output, memDir, existing) {
278
+ const taken = new Set([...existing].map((s) => s.toLowerCase()))
260
279
  const written = []
261
280
  const indexLines = []
262
281
  for (let chunk of output.split(/^---FILE---\s*$/m)) {
@@ -264,9 +283,11 @@ async function writeMemories(output, memDir, existing) {
264
283
  if (!chunk || chunk.toUpperCase() === "NOTHING") continue
265
284
  const fm = parseFrontmatter(chunk)
266
285
  const name = (fm.name || "").trim()
267
- if (!MEMORY_SLUG_RE.test(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
268
288
  await fs.writeFile(path.join(memDir, `${name}.md`), chunk.replace(/\n+$/, "") + "\n", "utf8")
269
289
  existing.add(name)
290
+ taken.add(key)
270
291
  written.push(name)
271
292
  const desc = (fm.description || "").trim()
272
293
  indexLines.push(`- [${name}](${name}.md)` + (desc ? ` — ${desc}` : ""))
@@ -280,14 +301,19 @@ async function writeMemories(output, memDir, existing) {
280
301
  return written
281
302
  }
282
303
 
283
- export const GeneseedLearn = async ({ client }) => {
304
+ export const GeneseedLearn = async (ctx) => {
305
+ const { client } = ctx ?? {}
284
306
  const ours = new Set() // throwaway distil sessions — never mine our own output
285
307
  const timers = new Map() // sid -> debounce timer, re-armed on each idle
286
308
  const inFlight = new Set() // sid currently being distilled (guards against overlap)
287
309
  let warnedNoDir = false
288
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
+
289
315
  async function runDistill(sid) {
290
- if (await sovereignBypass(process.cwd())) return
316
+ if (await sovereignBypass(root)) return
291
317
  if (inFlight.has(sid)) return // a run is already underway; the next idle re-arms
292
318
  inFlight.add(sid)
293
319
  try {
@@ -400,6 +426,6 @@ export const GeneseedLearn = async ({ client }) => {
400
426
 
401
427
  // OpenCode treats every export as a plugin and rejects non-functions; hang the test
402
428
  // helpers off the factory instead — reachable via import, invisible to the loader.
403
- Object.assign(GeneseedLearn, { writeMemories })
429
+ Object.assign(GeneseedLearn, { writeMemories, resolveAgentName, appendAgentLesson })
404
430
 
405
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()
@@ -8,7 +8,8 @@
8
8
  * specific.
9
9
  *
10
10
  * * GROWING `bin/geneseed-hook.mjs` weakens the gate the design now rests on.
11
- * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` asserts EQUALITY between
11
+ * 'the hook entry carries exactly the verbs the emitter wires' (`tests/unit/hook_cli.test.mjs`)
12
+ * asserts EQUALITY between
12
13
  * that file's table and the hooks `js/hosts/settings.mjs` wires, and P5b made it load-bearing:
13
14
  * the shim is machine-wide (`~/.geneseed/bin/geneseed-hook[.cmd]`, no per-install
14
15
  * component) and last-writer-wins, so a Node emit owns the hooks of installs Python
@@ -23,23 +24,24 @@
23
24
  * no-op and a success are one observation.
24
25
  * * `bin/build-driver.mjs` IS `build.py`'s `main()` — a different Python program from
25
26
  * `rituals/harness.py`, not a different half of the same one. It parses generator FLAGS
26
- * with a partition asserted over them (`test_the_node_driver_classifies_every_emit`),
27
- * `tests/golden.py` drives it 259 times per run, and it is under a hard `child_process`
27
+ * with a partition asserted over them ('the driver classifies every emit',
28
+ * `tests/unit/node_driver.test.mjs`),
29
+ * `tests/golden.mjs` drives it over every emit configuration, and it is under a hard `child_process`
28
30
  * ban that is half the proof it is not a passthrough. `exclude` spawns nothing and would
29
31
  * not have broken that ban — but `web`, `upgrade` and `setup` will, and the ban should
30
32
  * outlive them rather than be dismantled by the first verb that does not need it.
31
33
  *
32
34
  * What this costs is what the P5b handoff predicted: a second acceptance matrix. It is paid
33
- * in `tests/harness_golden.py`, which now carries a `bin` per cell and takes `--new-cli`
34
- * beside `--new` — and it would have been paid identically by the `bin/build-driver.mjs` option,
35
+ * in `tests/golden.mjs --cli` (`tests/helpers/matrix/cli.<platform>.json`), which carries a
36
+ * `bin` per cell — and it would have been paid identically by the `bin/build-driver.mjs` option,
35
37
  * so it is not a discriminator between the two.
36
38
  *
37
39
  * THE VERB SET REFUSES THE REST BY NAME, exactly as the hook entry does. It carries 22 of the
38
40
  * 26 names the reference's parser answered to (25 subparsers, plus the alias) — the other
39
41
  * four are the hook verbs — and, since the three rows at the end of the table below, three
40
- * that the reference never had at all. `test_the_two_entry_points_carry_disjoint_verb_sets`
41
- * keeps the two tables from ever answering the same verb twice, since the shim bakes only one
42
- * of them.
42
+ * that the reference never had at all. 'the two entry points carry disjoint verb sets'
43
+ * (`tests/unit/hook_cli.test.mjs`) keeps the two tables from ever answering the same verb
44
+ * twice, since the shim bakes only one of them.
43
45
  *
44
46
  * P6h MADE THE DISPATCH ASYNCHRONOUS and put `js/web/` on this entry's import graph — LAZILY,
45
47
  * since the slim pass: every command module loads through its verb's thunk, so the graph is
@@ -73,9 +75,9 @@ import { printErr, printOut } from '../js/lib/fs.mjs';
73
75
  * hold: the FUNCTION.
74
76
  *
75
77
  * IT IS STILL A TABLE and still the DISPATCH rather than a declaration beside one.
76
- * `tests/test_hook_cli_parity.py` scrapes `const VERBS = {` for its three matrix gates, and
77
- * `main`'s refusal lists `Object.keys(VERBS)` IN THIS ORDER, which `harness_golden` cells
78
- * assert verbatim — the order is the old table's and is not alphabetical.
78
+ * `tests/unit/hook_cli.test.mjs` scrapes `const VERBS = {` for its matrix gates, and
79
+ * `main`'s refusal lists `Object.keys(VERBS)` IN THIS ORDER (no cell asserts the list
80
+ * verbatim any more) — the order is the old table's and is not alphabetical.
79
81
  *
80
82
  * FLATTENED TO `verb: fn` since Task 5, AND TO `verb: loader` since the lazy pass: every row
81
83
  * is now a thunk that imports the command's module and resolves to its function, so the one
@@ -104,9 +106,9 @@ const VERBS = {
104
106
  // `up.add_parser(..., aliases=["update"])`, reproduced in P8c as a ROW OF ITS OWN rather than
105
107
  // as an `aliases` field on the row above — because a field would be a DECLARATION and the
106
108
  // three matrix gates read this table as the DISPATCH (rule 7, and M23 is where it was
107
- // learned). As a key it is a real verb: `test_every_entry_verb_is_a_real_harness_subcommand`
108
- // reads argparse's aliases out of `harness.py` and finds it, and
109
- // `test_the_matrix_covers_every_verb_it_claims` demands the `update/` cell group that proves
109
+ // learned). As a key it is a real verb: `tests/unit/hook_cli.test.mjs`'s 'every entry verb
110
+ // is a command the driver table dispatches' finds it in `js/cli-table.json`, and 'the matrix
111
+ // covers every verb each entry claims' demands the `update/` cell group that proves
110
112
  // it reaches `cmdUpgrade` — including the theme re-read, which is what separates it from
111
113
  // `sync-self`.
112
114
  //
@@ -129,8 +131,8 @@ const VERBS = {
129
131
  // exit code, and that arm crosses byte for byte. The panel behind it is P7c's;
130
132
  // `js/ui/tui.mjs`'s header argues why this entry falls back to `cmd_tui`'s own
131
133
  // panel-unavailable line rather than inventing a second full-screen UI, and
132
- // `tests/test_tui_boundary.py` asserts that the arm it declares is genuinely unreachable
133
- // here rather than merely untested.
134
+ // `tests/unit/no_panel.test.mjs` asserts that no panel lies behind it — the module paints
135
+ // nothing and refuses on a TTY too — rather than leaving that merely untested.
134
136
  tui: () => import('../js/ui/tui.mjs').then((m) => m.cmdTui),
135
137
  // THE THREE THAT NEVER HAD A PYTHON ORIGINAL, and they are last for that reason rather
136
138
  // than by alphabet: every row above is the twin of a subparser, and these three are not.
@@ -143,9 +145,9 @@ const VERBS = {
143
145
  // this change rather than after it. The recorded help corpus was rendered from a live
144
146
  // argparse object, and there was no subparser to render these from; the acceptance matrix
145
147
  // is a comparison of two implementations, and there is only one. So their gates are
146
- // ABSOLUTE — `tests/snapshot/cli_help.test.mjs` splits the two populations by name and refuses a
147
- // verb that falls into neither, and each has its own unit gate stating what it does rather
148
- // than that it agrees.
148
+ // ABSOLUTE — `tests/unit/hook_cli.test.mjs`'s `NATIVE` list splits the two populations by
149
+ // name and refuses a verb that falls into neither, and each has its own unit gate stating
150
+ // what it does rather than that it agrees.
149
151
  catalog: () => import('../js/build/catalog.mjs').then((m) => m.cmdCatalog),
150
152
  mcp: () => import('../js/hosts/mcp.mjs').then((m) => m.cmdMcp),
151
153
  memory: () => import('../js/maintain/memory.mjs').then((m) => m.cmdMemory),
@@ -173,7 +175,7 @@ function die(code, msg) {
173
175
  *
174
176
  * The WORDING of an argparse failure is deliberately not reproduced — argparse prints a
175
177
  * usage block computed from the whole parser tree, and P5a set the precedent for the hook
176
- * entry: state the fault plainly, gate it absolutely in `test_hook_cli_parity.py`, and keep
178
+ * entry: state the fault plainly, gate it absolutely in `tests/unit/hook_cli.test.mjs`, and keep
177
179
  * it out of the compared matrix. What IS compared is every error the command's own body
178
180
  * raises, `exclude`'s missing-path refusal among them.
179
181
  */