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.
- package/CHANGELOG.md +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +5 -6
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- 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.
|
|
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 =
|
|
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-
|
|
769
|
-
// per plugin instance
|
|
770
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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(
|
|
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,
|
|
33
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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()
|