geneseed 4.0.0 → 4.2.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 +66 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/adapters/claude-code/README.md +0 -21
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
- package/adapters/opencode/plugins/geneseed-activity.js +105 -2
- package/adapters/opencode/plugins/geneseed-context.js +96 -1
- package/adapters/opencode/plugins/geneseed-guard.js +64 -1
- package/adapters/opencode/plugins/geneseed-learn.js +127 -14
- package/adapters/opencode/plugins/geneseed-notify.js +46 -1
- package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
- package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
- package/docs/guides/mcp-filesystem.md +2 -2
- package/docs/guides/mcp-gitlab.md +2 -2
- package/docs/guides/mcp-markitdown.md +3 -3
- package/docs/guides/mcp.md +7 -7
- package/docs/guides/project-context.md +1 -1
- package/docs/guides/uninstall.md +2 -0
- package/docs/guides/upgrade.md +2 -0
- package/docs/reference/opencode-plugins.md +9 -0
- package/docs/token-footprint.md +1 -1
- package/docs/understand/take-it-out.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +6 -0
- package/js/build/driver.mjs +47 -11
- package/js/build/emit-claude.mjs +22 -10
- package/js/build/emit-common.mjs +9 -3
- package/js/build/emit-opencode.mjs +18 -11
- package/js/build/source.mjs +1 -0
- package/js/build/version.mjs +10 -3
- package/js/cli-table.json +16 -1
- package/js/hosts/hooks.mjs +4 -0
- package/js/hosts/installs.mjs +1 -1
- package/js/hosts/mcp.mjs +23 -152
- package/js/hosts/native.mjs +38 -6
- package/js/hosts/opencode.mjs +1 -1
- package/js/hosts/owned.mjs +159 -0
- package/js/hosts/settings.mjs +4 -0
- package/js/hosts/usage.mjs +62 -0
- package/js/inspect/checks-repo.mjs +76 -1
- package/js/inspect/cost.mjs +83 -0
- package/js/inspect/doctor.mjs +2 -1
- package/js/inspect/inventory.mjs +1 -1
- package/js/maintain/uninstall.mjs +77 -42
- package/js/web/actions.mjs +15 -77
- package/js/web/api.mjs +74 -4
- package/js/web/gates-api.mjs +44 -0
- package/js/web/handler.mjs +2 -2
- package/js/web/routes.mjs +9 -4
- package/js/web/update-check.mjs +61 -0
- package/js/web/usage-api.mjs +95 -0
- package/package.json +1 -1
- package/registry.json +18 -25
- package/src/AGENT.md.tmpl +0 -1
- package/src/agents/_template.md +2 -1
- package/src/agents/researcher.md +2 -1
- package/src/agents/reviewer.md +9 -4
- package/src/agents/tester.md +3 -3
- package/src/doctrines/rigor.md +8 -7
- package/src/skills/brainstorm/builder-check.md +16 -0
- package/src/skills/brainstorm.md +19 -11
- package/src/skills/daydream/SKILL.md +1 -1
- package/src/skills/daydream/VENDOR.md +4 -0
- package/src/skills/debug/ci.md +4 -4
- package/src/skills/debug.md +4 -4
- package/src/skills/develop.md +15 -4
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/geneseed-code-review.md +13 -3
- package/src/skills/handoff.md +1 -1
- package/src/skills/parallel-agents.md +4 -4
- package/src/skills/pipeline.md +3 -3
- package/src/skills/plan.md +18 -6
- package/src/skills/review-response.md +5 -2
- package/src/skills/security-audit.md +7 -1
- package/src/skills/ship.md +2 -1
- package/src/skills/wayfinder.md +25 -6
- package/src/skills/worktree.md +7 -5
- package/themes/_TEMPLATE.json +0 -1
- package/themes/biker.json +0 -1
- package/themes/commentator.json +0 -1
- package/themes/cyberpunk.json +0 -1
- package/themes/gamer.json +0 -1
- package/themes/imperial.json +0 -1
- package/themes/joker.json +0 -1
- package/themes/marvin.json +0 -1
- package/themes/mean.json +0 -1
- package/themes/military.json +0 -1
- package/themes/neutral.json +0 -1
- package/themes/pirate.json +0 -1
- package/themes/sports.json +0 -1
- package/themes/verstappen.json +0 -1
- package/themes/wizard.json +0 -1
- package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-ClEuDk9q.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-CS9IuJlw.js} +1 -1
- package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
- package/web/dist/assets/Docs-CD20mrVv.js +5 -0
- package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-CL3PubNb.js} +1 -1
- package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-BFLOVV0r.js} +2 -2
- package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-BvsSkjSn.js} +1 -1
- package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-BVoYOkVW.js} +2 -2
- package/web/dist/assets/{Library-CK9HZzjV.js → Library-Bl7xqOlh.js} +1 -1
- package/web/dist/assets/{Loops-BP98SMOH.js → Loops-Cnpep8FX.js} +1 -1
- package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-B0AMVmtV.js} +1 -1
- package/web/dist/assets/{Personal-Bk89f13a.js → Personal-CBg_x2kA.js} +1 -1
- package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-2gX8I69E.js} +1 -1
- package/web/dist/assets/api-Bp5eW_6m.js +1 -0
- package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-KPn0rKI7.js} +1 -1
- package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
- package/web/dist/assets/index-DoD_g2CM.js +2 -0
- package/web/dist/index.html +3 -3
- package/src/skills/herdr.md +0 -154
- package/web/dist/assets/Dashboard-DpHza68-.js +0 -2
- package/web/dist/assets/Docs-Bw_-UK0O.js +0 -5
- package/web/dist/assets/api-CxgfYu18.js +0 -1
- package/web/dist/assets/index-CiXwChvf.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
|
+
## [4.2.0] — 2026-10-14
|
|
12
|
+
|
|
13
|
+
Minor: the web console's Overview becomes a control panel over every installed harness, rebuild, prune and uninstall keep owned files you edited, and skill and subagent calls are counted.
|
|
14
|
+
|
|
15
|
+
### Upgrade notes
|
|
16
|
+
- Run `geneseed rebuild-all` once after updating to wire the usage counter and record the per-file hashes that edit detection reads. An install built before 4.2.0 has no hashes, so until it is rebuilt its owned files are treated as before.
|
|
17
|
+
- Run `geneseed web restart` to load the new console.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **The Overview as a control panel.** The default Overview lists every installed harness on the machine: whether it matches its source, hand edits, its always-on cost against the root-file ceiling, its build time, and Rebuild when it is behind or edited. Below that: what needs attention, then charts of always-on cost by part, which skills and agents fire over 30 days, and gate asks per rule. Hosts with nothing installed are not listed; installing stays on the Installs page. The four older views are still in the Appearance palette. New read endpoints: `GET /api/fleet` (each install with its own sync verdict and always-on cost), `GET /api/usage`, `GET /api/gates` and `GET /api/update`.
|
|
21
|
+
- **Update check.** The console shows "*x.y.z* available" with an Update button only when npm has a release newer than the running source, and "update check unavailable" when the check failed. It asks `registry.npmjs.org` at most once a day (once an hour after a failure, with a 3-second timeout). `GENESEED_NO_UPDATE_CHECK` turns it off.
|
|
22
|
+
- **Usage counting.** Every Skill or subagent call appends one `{day, kind, name}` line to the install's `notebook/usage.jsonl`. It records counts only, never the prompt, arguments or paths, and adds no process and no model tokens. It is on by default. `GENESEED_NO_USAGE` or a `.usage-off` file in the install turns it off. On Claude Code and OpenClaude it is a new `Skill|Agent|Task` hook group with no `onFailure: block`, so a counter can never stop a skill. On Bob, skill detection is best-effort. Lines older than 90 days are pruned.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **Rebuild, prune and uninstall keep owned files you edited.** The manifest now records a hash of every owned file. A rebuild leaves an owned file you edited in place and tells you to delete it if you want Geneseed's copy back, and a prune does not remove it. Uninstall leaves it too, completes around it, and names it; `geneseed uninstall --force` deletes it anyway. The console's Remove does the same and names the files it kept. The hash ignores CRLF vs LF, so a checkout with the other OS's line endings does not count as an edit.
|
|
26
|
+
- **review**: when a spec exists and the diff spans more than a few files, the spec-fidelity pass runs in its own subagent, given only the diff and the spec. The smell baseline gains message chains and refused bequest.
|
|
27
|
+
- **security-audit**: a finding stands only once it is traced from an attacker-controlled source to an unguarded sink through the real callers. Until then it stays a lead. The skill also names three shortcuts that produce false positives.
|
|
28
|
+
- **wayfinder**: each decision ticket has a type (research, prototype, grilling or task) that says how it gets resolved. Research tickets run in parallel when the map is charted, a ticket worked with the user resolves only in that live exchange, and a sweep that finds nothing unclear produces no map.
|
|
29
|
+
- **develop** names three test anti-patterns: implementation-coupled, tautological and horizontal slicing.
|
|
30
|
+
- Nine registry entities move from experimental to approved: researcher, git-history, dependencies, security-audit, worktree, token-report, explain-changes, rule and skill-forge.
|
|
31
|
+
- `doctor` checks that the host matrix and the enforcement table have exactly one column per host.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- A prune no longer follows a manifest path outside the install. A per-repo manifest entry such as `../victim.txt` could delete a file outside the install on re-emit. Prune now applies the same containment check as uninstall.
|
|
35
|
+
- An emit never writes through a symlink planted at an owned path. This covers every path an emit writes, including `rules/geneseed.md`, `.gitignore`, the OpenCode global `AGENT.md` and the version marker.
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- The `herdr` skill. Orca now covers multi-pane and multi-agent work, so a skill limited to a herdr pane only added to the catalogue. It joins `RETIRED_SKILL_IDS`, so an install that excluded it still rebuilds. Skills: 47 → 46.
|
|
40
|
+
|
|
41
|
+
## [4.1.0] — 2026-10-13
|
|
42
|
+
|
|
43
|
+
Minor: the planning, review, debugging and multi-agent skills take the lessons of Superpowers 7.0, and the host canary.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
- **brainstorm** asks which build choices (platform, language, key libraries) you want to own; one the agent makes goes in the decision list as its own call. It sizes the work: a quick task is just done, a small change keeps the confirmed list in chat as its spec, and only a project gets a spec file. A fresh subagent then reads that spec as the person who will build it and asks what they would have to guess, replacing the agent rereading its own spec.
|
|
47
|
+
- **plan**: a step records decisions (files, signatures, the spec's values, the test, the check command), not code. A step is marked done only once its check has run green, with the command on the line. A decision taken without asking gets a `Ruling:` line that is reported at the end, and the mid-run "stop for review" clause is gone. The plan file names its task and its spec, and the spec wins a conflict.
|
|
48
|
+
- **debug**: compare against the nearest working twin, trace a bad value back to where it was born, cite evidence as `file:line`, and probe a secret's presence, never its value. The CI branch diffs against the last green run first and replaces guessed sleeps with bounded waits.
|
|
49
|
+
- **Code review**: where the spec says nothing, a crash, hang or data loss on an input it implies is still blocking. Findings end with a *Set aside* list the caller rules on, and `ship` rules on it before pushing. The base is `origin/<default branch>` after a fetch. The reviewer never moves HEAD and never dispatches another reviewer.
|
|
50
|
+
- **pipeline / parallel-agents**: the developer↔tester loop gets two repair rounds, then one fresh developer, then stops (it allowed five, against `bound-the-loop`). An ambiguity is decided and recorded rather than stalling, small edits of one kind run as one unit, no dispatched agent dispatches its own, and an empty diff counts as a failure. `worktree` never forces a refused removal.
|
|
51
|
+
- The `cover-and-verify` doctrine and `develop` run the whole suite once at the end and name every failure, including ones the change did not cause; per step, only the affected tests still run.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
- The daydream skill's cost line showed a mangled price on Claude Code, which replaces a dollar sign followed by a digit with the skill's arguments.
|
|
55
|
+
- On OpenCode 2, the Activity view now names each tool call. OpenCode 2 sends a tool's name only when its input starts streaming, not with the call itself, so every tool card read "undefined".
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
- A host canary, `node tests/host_canary.mjs`. It installs the real OpenCode 1, OpenCode 2, Claude Code and OpenClaude from npm and points each at a throwaway home holding the Geneseed install. It fails when a host refuses one of our OpenCode plugins, reports one of our Claude settings as invalid, or never runs our session-start hook. It never calls a model and needs no account. It runs in CI every Monday against the latest releases and on every pull request that changes what Geneseed installs, so the next host release that breaks the contract is caught within a week rather than by a user. IBM Bob is not covered, because Bob Shell is not published on npm.
|
|
59
|
+
|
|
60
|
+
## [4.0.2] — 2026-10-12
|
|
61
|
+
|
|
62
|
+
Patch: none of the OpenCode plugins loaded on OpenCode 2, and MCP is read-only.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
- The seven OpenCode plugins now load on OpenCode 2. OpenCode 2 rewrote the plugin API and refused every Geneseed plugin with *"Plugin must export a default definition with an id and an effect or setup function"*. It skipped them without crashing, so the context load, learn, notify, ponytail, activity and the `workflow` tool were all gone, and the guard's safety gates were silently off. Each plugin now carries both APIs in one file and works on OpenCode 1.3.4 or newer and on OpenCode 2. Plugins no longer load on OpenCode older than 1.3.4. On OpenCode 2 a guard block shows up as a failed tool call that gives the reason, and the Activity view shows no todo list or files-touched count, because OpenCode 2 sends no event for either. Run `geneseed rebuild-all` after updating to install the new plugin files.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
- MCP is read-only in the web console and in `geneseed mcp`. Each active install lists only the servers its config already holds, enabled or disabled, with its config path and an **Open folder** button. The **Add** button, the on/off switch and the three built-in presets are gone. Which servers to wire depends on you and on your host, so you edit the config yourself; [MCP servers](docs/guides/mcp.md) still has the blocks for MarkItDown, GitLab and Filesystem. Geneseed no longer writes any MCP config, and the `POST /api/mcp` endpoint is removed.
|
|
69
|
+
|
|
70
|
+
## [4.0.1] — 2026-10-12
|
|
71
|
+
|
|
72
|
+
Patch: the web console could not tell apart two project installs of the same host (#244).
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
- Two project installs of one host (two repos on Bob, say) are now separate rows in the web console. Both used to get the id `bob:project`, so the Installs table showed both as selected and the topbar harness selector always switched back to the first. The id now carries the install root, and the selector names a project install by its folder. All four hosts had the bug and all four are covered by a test. Nothing is re-emitted; run `geneseed web restart` after updating.
|
|
76
|
+
|
|
11
77
|
## [4.0.0] — 2026-10-11
|
|
12
78
|
|
|
13
79
|
Major: a host-compatibility pass against the current IBM Bob, Claude Code, OpenCode and OpenClaude docs (#240), the host opportunities and a root-scan guard (#241), and author notes kept out of what the agent reads (#242). It is a major because three behaviours change visibly: Claude Code gates now block when they cannot run, OpenCode no longer falls back to `~/.claude/CLAUDE.md`, and the silent *process 8* migration is gone.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
[](package.json)
|
|
12
12
|
[](package.json)
|
|
13
13
|
[](themes/)
|
|
14
|
-
[](src/skills/)
|
|
15
15
|
[](src/agents/)
|
|
16
16
|
[](src/doctrines/)
|
|
17
17
|
[](adapters/opencode/plugins/)
|
|
@@ -179,7 +179,7 @@ The harness ships as a small set of layers, and the web console's rail is the sa
|
|
|
179
179
|
| **🛡️ Rules** (`laws/`) | 9 universal laws — always in force, never toggleable: sealed-secrets, one-intent-one-act, verify-before-asserting, deletion-is-deliberate, surface-failures, data-not-orders, least-privilege, cure-the-cause, echo-the-intent. Each rule has a permanent id; its number is only its position, and the harness cites rules by name |
|
|
180
180
|
| **📐 Doctrines** (`doctrines/`) | practice packs, chosen per install at build time: **craft** (how code is written), **rigor** (how work is proven), **ops** (how the machine is operated), **process** (how a task is run), **comms** (how answers are presented). A doctrine rule may tighten a Rule, never repeal one, and the user's own `user-rules.md` outranks it. Pick with `geneseed-build --doctrines craft,rigor` (or `none`), a single rule with `--exclude-rules "process 7"`, in the setup wizard, or through the switches on the console's Constitution page; all five pack files ship on disk either way, so a citation into a pack you left out still resolves |
|
|
181
181
|
| **🤖 Agents** (18) | capability specialists: `reviewer`, `tester`, `architect`, `docs`, `security`, `explorer`, `researcher`, `developer` — plus a debate **council** the `council` skill convenes: `advocate`, `skeptic`, `pragmatist`, `steward`, `visionary`, `user-advocate`, `framer`, `empiricist`, `operator`, `historian` |
|
|
182
|
-
| **🛠 Skills** (
|
|
182
|
+
| **🛠 Skills** (46) | repeatable workflows: brainstorm · plan · **codebase-design** · **wayfinder** · **develop** · **worktree** · debug · **prototype** · refactor · **ponytail** · **forge-mcp** · **bruno** · geneseed-code-review · **fresh-eyes** · **security-audit** · **dependencies** · **review-response** · commit · **ship** · **release** · **git-history** · document-project · **frontend-design** · **prose** · **ingest** · **research** · **teach** · **quiz** · **learn-mode** · handoff · roast-me · **council** · parallel-agents · **workflow** · **wiki** · **geneseed** · **rule** · **profile** · **skill-forge** · **pipeline** · **loop** · **brick-forge** · **daydream** · **react-view-transitions** · **token-report** · **explain-changes** |
|
|
183
183
|
| **🔌 Plugins** (OpenCode) | `geneseed-context` injects project docs *and your machine wiki* every session (and across compaction); `geneseed-learn` distils memory at session end; `geneseed-guard` enforces the safety Laws and protected wiki folders at the tool boundary; `geneseed-workflow` registers the `workflow` tool that runs saved orchestration scripts; `geneseed-notify` sends a native OS notification when a long run finishes; `geneseed-ponytail` holds a minimal-code mode (`/ponytail lite\|full\|ultra\|off`), opt-in, injecting the laziest-that-works ruleset every turn so it doesn't drift; `geneseed-activity` streams what each session is doing to the web console's Activity view |
|
|
184
184
|
| **🧠 Memory** (`memory/`) | one-fact-per-file durable knowledge, indexed by `MEMORY.md` (git-ignored, personal) |
|
|
185
185
|
| **📓 Notebook** (`notebook/`) | the agent's sovereign space — any medium (code, tools, data, notes), self-ruled via a seed-once charter, always git-ignored; only its `.gitignore` is build-asserted |
|
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: **4.
|
|
23
|
+
> string. Current label: **4.2.0**.
|
|
24
24
|
|
|
25
25
|
## Capabilities
|
|
26
26
|
|
|
@@ -29,7 +29,7 @@ Every row below is present in the tree today; "Spec" names the design record.
|
|
|
29
29
|
| Capability | What it is | Spec |
|
|
30
30
|
| --- | --- | --- |
|
|
31
31
|
| **Themed generator** | One neutral `src/` → 14 themed bundles via `geneseed-build` (zero dependencies); structure stays theme-independent | [DESIGN.md](DESIGN.md) |
|
|
32
|
-
| **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 18 agents,
|
|
32
|
+
| **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 18 agents, 46 skills, the memory convention, and the agent's sovereign notebook | `2026-06-11-notebook-sovereign-space` · `2026-06-10-notebook-agent-own-space` |
|
|
33
33
|
| **Three-tier constitution** | The governance surface splits into an always-on **Ontology** (Telos, Evidence, Decisions, Conduct — the Pact absorbed into Telos), the nine always-on **Invariants**, and five toggleable **Doctrine** packs (craft, rigor, ops, process, comms) addressed `<pack> <n>`. Every rule carries a permanent id; its number is its position, computed at render time, and a citation renders as the rule's principle name. Packs are chosen at build time with `--doctrines` or the setup wizard; the whole catalogue ships on disk either way, so a citation into an inactive pack still resolves. Precedence, in order: Ontology + Invariants, then the user's `user-rules.md`, then the active Doctrines, then `PROFILE.md`. `doctor`'s `constitutionProblems` gates rule ids (declared, unique, never a retired one), the per-rule Principle column, every `{{LAW:<id>}}` / `{{DOCTRINE:<id>}}` citation in `src/`, and the rule that an always-on tier may never cite a toggleable one | `2026-08-19-three-tier-constitution-design` |
|
|
34
34
|
| **`doctor`** | One check: unresolved tokens, dead/non-hermetic links, theme-key parity, authoring gates, rendered-bundle drift | `2026-06-07-theme-aware-doctor-diff` |
|
|
35
35
|
| **`diff` + improvements export** | Deployed-vs-source drift report; export an `improvements.md` back-port | `2026-06-12-improvements-export` |
|
|
@@ -44,7 +44,7 @@ Every row below is present in the tree today; "Spec" names the design record.
|
|
|
44
44
|
| **Collaboration layer** | Negotiated postures (peer/mentor/expert/assistant/artisan, chosen at setup, inlined in AGENT.md), the bidirectional Pact, epistemically-typed memory (`force:` + Bridge rule), and a seed-once `PROFILE.md` identity with a web editor tab and a `profile` skill that interviews the user to draft it | `2026-07-12-collaboration-layer` |
|
|
45
45
|
| **Rule front door** | Both durable stores enter through the `rule` skill's fork (*standing rule, or fact to remember?* — always asked, never inferred): the memory branch elicits the binding `force` and shows the file before writing, the rule branch pressure-tests by reformulation and a mandatory counter-example. Doctrine process 1 carries the norm on every host; a `rule-gate` PreToolUse hook (Claude, Bob) and a `geneseed-guard` speed bump (OpenCode) hold it at the tool boundary | `2026-07-30-rule-front-door` |
|
|
46
46
|
| **Entity registry** | `registry.json` records a lifecycle status, version, owner and add-date for every agent and skill in the tree (maintainer-side — never rendered into a bundle, so it costs no session context). `doctor` gates it against `src/` in both directions, sweeps everything that ships for credentials, and holds each vendored skill folder to an immutable upstream commit. The status surfaces as a badge in the web catalog, shown only where it deviates from `approved` | `2026-07-31-entity-registry` |
|
|
47
|
-
| **Node runtime + npm package** | The whole harness runs from Node: every command `js/cli-table.json` declares, all five hook verbs, every web-console endpoint (the OS-native folder picker excepted, and declined on purpose) and both generators. Every commit runs the unit suite, `doctor --all` across every theme × host × footprint, and the generator's two self-comparisons, on Linux and Windows both; the frozen emit/cli/web recordings that also gated it retired on 2026-08-17 (see the 2.0.0 entry in [CHANGELOG.md](CHANGELOG.md)). It ships as the zero-dependency `geneseed` package (`geneseed`, `geneseed-hook`, `geneseed-build`), so `npx geneseed setup` needs no clone and nothing else installed; `geneseed migrate` moves an existing checkout install across. Node is the only runtime it asks for: the `token-report` skill's own script is `token_report.mjs`, gated over seeded transcripts for all four hosts, and `daydream`
|
|
47
|
+
| **Node runtime + npm package** | The whole harness runs from Node: every command `js/cli-table.json` declares, all five hook verbs, every web-console endpoint (the OS-native folder picker excepted, and declined on purpose) and both generators. Every commit runs the unit suite, `doctor --all` across every theme × host × footprint, and the generator's two self-comparisons, on Linux and Windows both; the frozen emit/cli/web recordings that also gated it retired on 2026-08-17 (see the 2.0.0 entry in [CHANGELOG.md](CHANGELOG.md)). It ships as the zero-dependency `geneseed` package (`geneseed`, `geneseed-hook`, `geneseed-build`), so `npx geneseed setup` needs no clone and nothing else installed; `geneseed migrate` moves an existing checkout install across. Node is the only runtime it asks for: the `token-report` skill's own script is `token_report.mjs`, gated over seeded transcripts for all four hosts, and `daydream` calls `node -e` | `2026-08-06-npx-distribution` |
|
|
48
48
|
| **Runtime awareness** | MCP discovery, web research, tool-call batching, review discipline — carried by the doctrine packs (`ops 1` tool discovery, `process 4` read the docs first, `rigor 2`/`rigor 3` honest tests and cover-and-verify), not by the invariants | `2026-06-07-runtime-awareness-and-review-discipline` |
|
|
49
49
|
| **Run from anywhere / uninstall** | `link`/`unlink` onto PATH; global uninstall that keeps the memory store | `2026-06-07-version-and-uninstall` |
|
|
50
50
|
|
|
@@ -92,27 +92,6 @@ hooks or plugins, the AGENT.md prose still carries the rule.
|
|
|
92
92
|
> plugin. A `context.json` still overrides, and `"extend": true` layers a manifest
|
|
93
93
|
> on top of discovery.
|
|
94
94
|
|
|
95
|
-
## Bundled skill — `herdr`
|
|
96
|
-
|
|
97
|
-
The canonical herdr skill lives in the Geneseed source tree at
|
|
98
|
-
[`src/skills/herdr.md`](../../src/skills/herdr.md). The build renders it into
|
|
99
|
-
every emit — `.opencode/skills/herdr/SKILL.md` for OpenCode hosts, the
|
|
100
|
-
portable bundle for everyone else — so a deployed harness picks it up
|
|
101
|
-
automatically alongside every other Geneseed skill.
|
|
102
|
-
|
|
103
|
-
A copy is also kept at
|
|
104
|
-
[`.claude/skills/herdr/SKILL.md`](../../.claude/skills/herdr/SKILL.md) so Claude
|
|
105
|
-
Code running **inside this repo** (i.e. people working on Geneseed itself)
|
|
106
|
-
discovers it directly, since the build doesn't write into `.claude/`. The two
|
|
107
|
-
copies share content; keep them in sync when you edit either.
|
|
108
|
-
|
|
109
|
-
Either way, you only need:
|
|
110
|
-
|
|
111
|
-
- the **`herdr` binary** on your `PATH` (install via [herdr.dev](https://herdr.dev)),
|
|
112
|
-
- the agent launched **inside a herdr pane** — the skill self-gates on
|
|
113
|
-
`HERDR_ENV=1` and stays silent everywhere else, so shipping it is harmless
|
|
114
|
-
for users who don't run herdr.
|
|
115
|
-
|
|
116
95
|
## Other tools
|
|
117
96
|
|
|
118
97
|
This adapter is Claude-Code-specific. On tools without hooks, the harness still
|
|
@@ -64,6 +64,41 @@ export const MyPlugin = async ({ client, $, directory, worktree }) => ({
|
|
|
64
64
|
**without** triggering a reply with `client.session.prompt({ path, body: { noReply: true, parts } })`.
|
|
65
65
|
- **`$`** — a shell executor for running commands.
|
|
66
66
|
|
|
67
|
+
### OpenCode 2 — a different plugin API
|
|
68
|
+
|
|
69
|
+
OpenCode 2 (the `@opencode/cli` package; V1 is still `opencode-ai`) still auto-loads every
|
|
70
|
+
`.js`/`.ts` in `plugins/` (or `plugin/`), but it accepts **only** a default-exported
|
|
71
|
+
definition: `export default { id, setup(ctx) }`. A V1 plugin fails to load with *"Plugin
|
|
72
|
+
must export a default definition with an id and an effect or setup function"*. Each one
|
|
73
|
+
is marked failed on its own and OpenCode carries on, so nothing warns you.
|
|
74
|
+
|
|
75
|
+
V1 (>= 1.3.4) also accepts an object default, `{ id, server }`, and each loader ignores
|
|
76
|
+
the other's key. So every Geneseed plugin exports **one object for both majors**:
|
|
77
|
+
`export default { id: "<file stem>", server: <V1 factory>, setup: <V2 setup> }`. On V1
|
|
78
|
+
older than 1.3.4 the plugins no longer load.
|
|
79
|
+
|
|
80
|
+
`setup(ctx)` gets domains instead of hooks returned in a map. Hooks register by name
|
|
81
|
+
(`ctx.tool.hook("execute.before")`, `ctx.session.hook("context" | "prompt" |
|
|
82
|
+
"compaction")`), tools through `ctx.tool.transform`, and events come from
|
|
83
|
+
`ctx.event.subscribe()` as `{ type, created, data }` (not `{ type, properties }`). What
|
|
84
|
+
changed for each plugin:
|
|
85
|
+
|
|
86
|
+
| V1 | V2 |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `session.idle` | not published by core (deprecated). The turn ends with `session.execution.succeeded` / `failed` / `interrupted` |
|
|
89
|
+
| `experimental.chat.messages.transform` / `chat.system.transform` | `session.hook("context")`: mutable `system` parts and `LLM.Message` instances, on every request |
|
|
90
|
+
| `command.execute.before` | none. A markdown command's expanded template arrives through `session.hook("prompt")` |
|
|
91
|
+
| `client.session.messages` | `ctx.session.context({ sessionID })`: `user` / `assistant` messages after the last compaction |
|
|
92
|
+
| throwaway session + `prompt` to distil | `ctx.generate.text({ prompt, model })`: one stateless call |
|
|
93
|
+
| a hook throws to block a tool | a throw is a **defect that fails the whole step**. The guard renames the call instead (see `geneseed-guard.js`) |
|
|
94
|
+
| `@opencode-ai/plugin` `tool()` helper | `@opencode/plugin`; a plain JSON-schema `input` needs no helper |
|
|
95
|
+
| `todo.updated`, session `summary` diffs | no V2 event. The activity cards show no todos or files on V2 |
|
|
96
|
+
| tool part carries its name | `session.tool.called` has no name; it rides on `session.tool.input.started` |
|
|
97
|
+
| tools `bash`, `apply_patch`, `task` | renamed `shell`, `patch`, `subagent` (the old ids are aliases). The guard's substring classes cover both spellings |
|
|
98
|
+
|
|
99
|
+
None of this is checked by a mock alone: `node tests/host_canary.mjs` starts the real V1 and V2
|
|
100
|
+
and fails on the host's own `failed to load plugin` log line.
|
|
101
|
+
|
|
67
102
|
## 4. Subagents, skills & commands
|
|
68
103
|
|
|
69
104
|
Subdir names are **plural** canonically (`agents/`, `skills/`, `commands/`,
|
|
@@ -425,6 +425,109 @@ export const GeneseedActivity = async () => {
|
|
|
425
425
|
|
|
426
426
|
// ponytail: OpenCode treats every export as a plugin and rejects non-functions; hang
|
|
427
427
|
// the test helpers off the factory instead — reachable via import, invisible to the loader.
|
|
428
|
-
Object.assign(GeneseedActivity, { sidOf, nextStatus, applyEvent, acctTotals, errStr, snip, safeName, activityDir, enabledFromFlag })
|
|
428
|
+
Object.assign(GeneseedActivity, { sidOf, nextStatus, applyEvent, acctTotals, errStr, snip, safeName, activityDir, enabledFromFlag, toV1Events })
|
|
429
429
|
|
|
430
|
-
|
|
430
|
+
// --- OpenCode 2 --------------------------------------------------------------
|
|
431
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
432
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
433
|
+
//
|
|
434
|
+
// V2's event stream is a different vocabulary (`{ type, created, data }`, the turn as an
|
|
435
|
+
// execution, a message as steps, tool calls as called/success/failed), but every fact the
|
|
436
|
+
// cards show is still in it. Rather than a second reducer, `toV1Events` translates each
|
|
437
|
+
// V2 event into the V1 event(s) the handler above already reduces, so both majors write
|
|
438
|
+
// the same files. Not carried: todos (V2 publishes no todo event) and files-touched (the
|
|
439
|
+
// V1 session summary has no V2 event).
|
|
440
|
+
|
|
441
|
+
// A V2 SessionError ({ type, message }) in the V1 error union errStr reads.
|
|
442
|
+
const v1Error = (e) => (e ? { name: e.type, data: { message: e.message || e.type } } : undefined)
|
|
443
|
+
|
|
444
|
+
// V2 event -> the V1 events it means ([] for one the cards ignore). `tools` remembers each
|
|
445
|
+
// running call's name and start (callID -> { name, start }) so its end can say both.
|
|
446
|
+
// Pure but for `tools`; exported for tests.
|
|
447
|
+
function toV1Events(ev, tools) {
|
|
448
|
+
const d = ev?.data ?? {}
|
|
449
|
+
const sessionID = d.sessionID
|
|
450
|
+
if (!sessionID) return []
|
|
451
|
+
const t = typeof ev.created === "number" ? ev.created : Date.now()
|
|
452
|
+
const msg = (info) => ({ type: "message.updated", properties: { info: { role: "assistant", sessionID, ...info } } })
|
|
453
|
+
const part = (p) => ({ type: "message.part.updated", properties: { part: { sessionID, ...p } } })
|
|
454
|
+
switch (ev.type) {
|
|
455
|
+
case "session.created":
|
|
456
|
+
return [{ type: "session.created", properties: { info: {
|
|
457
|
+
id: sessionID, title: d.title, parentID: d.parentID, directory: d.location?.directory } } }]
|
|
458
|
+
case "session.renamed":
|
|
459
|
+
return [{ type: "session.updated", properties: { info: { id: sessionID, title: d.title } } }]
|
|
460
|
+
case "session.deleted":
|
|
461
|
+
return [{ type: "session.deleted", properties: { sessionID } }]
|
|
462
|
+
case "session.execution.started":
|
|
463
|
+
return [{ type: "session.status", properties: { sessionID, status: { type: "busy" } } }]
|
|
464
|
+
case "session.execution.succeeded":
|
|
465
|
+
return [{ type: "session.idle", properties: { sessionID } }]
|
|
466
|
+
case "session.execution.failed":
|
|
467
|
+
return [{ type: "session.error", properties: { sessionID, error: v1Error(d.error) } },
|
|
468
|
+
{ type: "session.idle", properties: { sessionID } }]
|
|
469
|
+
case "session.execution.interrupted":
|
|
470
|
+
return [{ type: "session.status", properties: { sessionID, status: { type: "idle" } } }]
|
|
471
|
+
case "session.retry.scheduled":
|
|
472
|
+
return [{ type: "session.status", properties: { sessionID, status: { type: "retry" } } }]
|
|
473
|
+
case "session.step.started":
|
|
474
|
+
return [msg({ id: d.assistantMessageID, mode: d.agent, modelID: d.model?.id, time: { created: t } })]
|
|
475
|
+
case "session.step.ended":
|
|
476
|
+
return [msg({ id: d.assistantMessageID, cost: d.cost, tokens: d.tokens, time: { completed: t } })]
|
|
477
|
+
case "session.step.failed":
|
|
478
|
+
return [msg({ id: d.assistantMessageID, cost: d.cost, tokens: d.tokens, error: v1Error(d.error), time: { completed: t } })]
|
|
479
|
+
// The name rides only on `input.started`; `called` carries the input but no name.
|
|
480
|
+
case "session.tool.input.started":
|
|
481
|
+
tools.set(d.id, { name: d.name, start: undefined })
|
|
482
|
+
return []
|
|
483
|
+
case "session.tool.called": {
|
|
484
|
+
const name = tools.get(d.id)?.name ?? "tool"
|
|
485
|
+
tools.set(d.id, { name, start: t })
|
|
486
|
+
return [part({ type: "tool", callID: d.id, tool: name, messageID: d.assistantMessageID,
|
|
487
|
+
state: { status: "running", title: name, time: { start: t } } })]
|
|
488
|
+
}
|
|
489
|
+
case "session.tool.success":
|
|
490
|
+
case "session.tool.failed": {
|
|
491
|
+
const call = tools.get(d.id) ?? { name: "tool", start: undefined }
|
|
492
|
+
tools.delete(d.id)
|
|
493
|
+
const failed = ev.type === "session.tool.failed"
|
|
494
|
+
return [part({ type: "tool", callID: d.id, tool: call.name, messageID: d.assistantMessageID,
|
|
495
|
+
state: { status: failed ? "error" : "completed", title: call.name,
|
|
496
|
+
error: failed ? d.error?.message || d.error?.type : undefined, time: { start: call.start, end: t } } })]
|
|
497
|
+
}
|
|
498
|
+
case "session.reasoning.started":
|
|
499
|
+
case "session.reasoning.ended":
|
|
500
|
+
return [part({ type: "reasoning", id: `r:${d.assistantMessageID}:${d.ordinal}`, text: d.text ?? "" })]
|
|
501
|
+
case "session.text.ended":
|
|
502
|
+
return [part({ type: "text", id: `t:${d.assistantMessageID}:${d.ordinal}`, messageID: d.assistantMessageID, text: d.text })]
|
|
503
|
+
case "permission.asked":
|
|
504
|
+
return [{ type: "permission.updated", properties: { sessionID, id: d.id, title: d.message || d.action } }]
|
|
505
|
+
case "permission.replied":
|
|
506
|
+
return [{ type: "permission.replied", properties: { sessionID, permissionID: d.requestID } }]
|
|
507
|
+
default:
|
|
508
|
+
return []
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function setupV2(ctx) {
|
|
513
|
+
const hooks = await GeneseedActivity()
|
|
514
|
+
const tools = new Map()
|
|
515
|
+
// The user's prompt, as V1's chat.message hook received it.
|
|
516
|
+
await ctx.session.hook("prompt", async (event) => {
|
|
517
|
+
try {
|
|
518
|
+
await hooks["chat.message"]({ sessionID: event?.sessionID, messageID: event?.messageID },
|
|
519
|
+
{ parts: [{ type: "text", text: event?.prompt?.text }] })
|
|
520
|
+
} catch (err) { log(`prompt hook failed: ${err?.message ?? err}`) }
|
|
521
|
+
})
|
|
522
|
+
const controller = new AbortController()
|
|
523
|
+
void (async () => {
|
|
524
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
525
|
+
for (const event of toV1Events(ev, tools)) {
|
|
526
|
+
try { await hooks.event({ event }) } catch (err) { log(`event ${event.type} failed: ${err?.message ?? err}`) }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
})().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
|
|
530
|
+
return () => controller.abort()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export default { id: "geneseed-activity", server: GeneseedActivity, setup: setupV2 }
|
|
@@ -989,4 +989,99 @@ export const GeneseedContext = async (ctx) => {
|
|
|
989
989
|
}
|
|
990
990
|
}
|
|
991
991
|
|
|
992
|
-
|
|
992
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
993
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
994
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
995
|
+
//
|
|
996
|
+
// V2 has no `messages.transform`: `session.hook("context")` is its stable per-request
|
|
997
|
+
// equivalent (the outgoing system + messages, mutable), so the invisible delivery needs
|
|
998
|
+
// no fallback detection there. Messages are `LLM.Message` class instances, not V1's
|
|
999
|
+
// `{info, parts}`. A V2 hook that throws is a defect that fails the whole request,
|
|
1000
|
+
// so every callback below swallows its own errors.
|
|
1001
|
+
|
|
1002
|
+
// Build a user message the request will accept: reuse the class of a message already in
|
|
1003
|
+
// it (LLM.Message validates on construction), else a plain object of the same shape.
|
|
1004
|
+
function v2UserMessage(messages, text) {
|
|
1005
|
+
const content = [{ type: "text", text }]
|
|
1006
|
+
const Ctor = messages.find((m) => m && m.constructor && m.constructor !== Object)?.constructor
|
|
1007
|
+
if (Ctor) { try { return new Ctor({ role: "user", content }) } catch {} }
|
|
1008
|
+
return { role: "user", content }
|
|
1009
|
+
}
|
|
1010
|
+
const v2HasMarker = (messages) => messages.some((m) =>
|
|
1011
|
+
(m?.content ?? []).some((p) => typeof p?.text === "string" && p.text.includes(MARKER)))
|
|
1012
|
+
const v2Model = (ref) => (ref?.providerID && ref?.id ? `${ref.providerID}/${ref.id}` : null)
|
|
1013
|
+
|
|
1014
|
+
async function setupV2(ctx) {
|
|
1015
|
+
const root = repoRoot({ worktree: ctx?.location?.project?.directory, directory: ctx?.location?.directory })
|
|
1016
|
+
const blockCache = { text: null, at: 0 }
|
|
1017
|
+
// Same scope rule as V1's skipSession: child/subagent sessions inherit the parent's
|
|
1018
|
+
// context, geneseed-* sessions stay clean. A failed lookup is not cached.
|
|
1019
|
+
const sessionSkip = new Map()
|
|
1020
|
+
async function skipSession(sid) {
|
|
1021
|
+
if (!sid) return false
|
|
1022
|
+
const hit = sessionSkip.get(sid)
|
|
1023
|
+
if (hit !== undefined) return hit
|
|
1024
|
+
try {
|
|
1025
|
+
const info = await ctx.session.get({ sessionID: sid })
|
|
1026
|
+
const skip = Boolean(info?.parentID) || String(info?.title ?? "").startsWith("geneseed-")
|
|
1027
|
+
sessionSkip.set(sid, skip)
|
|
1028
|
+
return skip
|
|
1029
|
+
} catch {
|
|
1030
|
+
return false
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (!VISIBLE) {
|
|
1035
|
+
await ctx.session.hook("context", async (event) => {
|
|
1036
|
+
if (INJECT_OFF) return
|
|
1037
|
+
try {
|
|
1038
|
+
if (await sovereignBypass(root)) return
|
|
1039
|
+
if (!Array.isArray(event?.messages) || v2HasMarker(event.messages)) return
|
|
1040
|
+
if (await skipSession(event.sessionID)) return
|
|
1041
|
+
const cached = await cachedBlockText(root, blockCache)
|
|
1042
|
+
if (!cached) return
|
|
1043
|
+
event.messages.unshift(v2UserMessage(event.messages, withModel(cached, v2Model(event.model) || envModelStr())))
|
|
1044
|
+
log("context hook: injected context invisibly")
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
log(`context hook error: ${err?.message ?? err}`)
|
|
1047
|
+
}
|
|
1048
|
+
})
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Visible delivery (GENESEED_CONTEXT_VISIBLE): one synthetic message when a session is
|
|
1053
|
+
// created, re-pushed into the compaction request so it outlives the summary.
|
|
1054
|
+
await ctx.session.hook("compaction", async (event) => {
|
|
1055
|
+
if (INJECT_OFF) return
|
|
1056
|
+
try {
|
|
1057
|
+
if (await sovereignBypass(root)) return
|
|
1058
|
+
if (!Array.isArray(event?.messages) || await skipSession(event.sessionID)) return
|
|
1059
|
+
const { block } = await resolveBlock(root)
|
|
1060
|
+
if (block) event.messages.unshift(v2UserMessage(event.messages, block.text))
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
log(`compaction error: ${err?.message ?? err}`)
|
|
1063
|
+
}
|
|
1064
|
+
})
|
|
1065
|
+
const controller = new AbortController()
|
|
1066
|
+
void (async () => {
|
|
1067
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
1068
|
+
if (ev?.type !== "session.created" || INJECT_OFF) continue
|
|
1069
|
+
const d = ev.data ?? {}
|
|
1070
|
+
try {
|
|
1071
|
+
if (d.parentID || String(d.title ?? "").startsWith("geneseed-")) continue
|
|
1072
|
+
if (await sovereignBypass(root)) continue
|
|
1073
|
+
const { block } = await resolveBlock(root)
|
|
1074
|
+
if (!block) continue
|
|
1075
|
+
await ctx.session.synthetic({
|
|
1076
|
+
sessionID: d.sessionID, text: withModel(block.text, v2Model(d.model) || envModelStr()),
|
|
1077
|
+
description: "Geneseed project context", resume: false,
|
|
1078
|
+
})
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
log(`visible injection error: ${err?.message ?? err}`)
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
})().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
|
|
1084
|
+
return () => controller.abort()
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
export default { id: "geneseed-context", server: GeneseedContext, setup: setupV2 }
|
|
@@ -546,11 +546,42 @@ function protectedCheck(p) {
|
|
|
546
546
|
// substring stance: over-matching the class is harmless (see WRITE_TOOLS note).
|
|
547
547
|
const WIKI_MUTATE_TOOLS = [...WRITE_TOOLS, "delete", "remove", "rename", "move", "trash"]
|
|
548
548
|
|
|
549
|
+
// Usage counting: the same rules as js/hosts/usage.mjs, inline because a plugin cannot import
|
|
550
|
+
// from the repo (V2 loads every .js in plugins/ as a plugin). One line per skill or subagent
|
|
551
|
+
// call, the day and the name only, under <cfg>/notebook/usage.jsonl. Silent and never throws:
|
|
552
|
+
// a counter must not change, slow or fail a tool call. tests/unit/usage.test.mjs holds this
|
|
553
|
+
// copy and the hook's to one table.
|
|
554
|
+
const USAGE_NAME_RE = /^[A-Za-z0-9][\w.:@/-]{0,99}$/
|
|
555
|
+
function usageOf(tool, args) {
|
|
556
|
+
const t = String(tool || "").toLowerCase()
|
|
557
|
+
const a = args || {}
|
|
558
|
+
let kind = null
|
|
559
|
+
let name = null
|
|
560
|
+
if (!t.startsWith("mcp") && t.includes("skill")) { kind = "skill"; name = a.skill ?? a.name ?? a.skill_name }
|
|
561
|
+
else if (t === "agent" || t === "task") { kind = "agent"; name = a.subagent_type ?? a.agent }
|
|
562
|
+
if (!kind || typeof name !== "string") return null
|
|
563
|
+
name = name.trim().replace(/^\/+/, "")
|
|
564
|
+
return USAGE_NAME_RE.test(name) ? { kind, name } : null
|
|
565
|
+
}
|
|
566
|
+
async function recordUse(tool, args) {
|
|
567
|
+
try {
|
|
568
|
+
if (process.env.GENESEED_NO_USAGE) return
|
|
569
|
+
const use = usageOf(tool, args)
|
|
570
|
+
if (!use) return
|
|
571
|
+
const cfg = path.dirname(PLUGIN_DIR)
|
|
572
|
+
if (existsSync(path.join(cfg, ".usage-off"))) return
|
|
573
|
+
if (!(await isDir(path.join(cfg, "notebook")))) return
|
|
574
|
+
await fs.appendFile(path.join(cfg, "notebook", "usage.jsonl"),
|
|
575
|
+
`${JSON.stringify({ ts: new Date().toISOString().slice(0, 10), ...use })}\n`)
|
|
576
|
+
} catch { /* a counter never fails a tool call */ }
|
|
577
|
+
}
|
|
578
|
+
|
|
549
579
|
export const GeneseedGuard = async (ctx) => {
|
|
550
580
|
// The session's working directory, the same resolution the context plugin uses.
|
|
551
581
|
const root = () => ctx?.worktree || ctx?.directory || process.cwd()
|
|
552
582
|
return {
|
|
553
583
|
"tool.execute.before": async (input, output) => {
|
|
584
|
+
await recordUse(input?.tool || input?.name, output?.args || input?.args)
|
|
554
585
|
if (OFF) return
|
|
555
586
|
if (await sovereignBypass(root())) return
|
|
556
587
|
const tool = (input?.tool || input?.name || "").toLowerCase()
|
|
@@ -633,4 +664,36 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
633
664
|
}
|
|
634
665
|
}
|
|
635
666
|
|
|
636
|
-
|
|
667
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
668
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
669
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
670
|
+
//
|
|
671
|
+
// V2 runs the same gates through `tool.hook("execute.before")`, but cannot block the
|
|
672
|
+
// V1 way: a V2 hook that throws is a defect, which fails the whole agent step instead
|
|
673
|
+
// of the one call (only a `Tool.Error` settles a call cleanly, and a hook cannot raise
|
|
674
|
+
// one). So a deny RENAMES the call: core then finds no tool by that name and settles it
|
|
675
|
+
// with a `Tool.Error` whose text carries the reason — the call never runs, the model sees
|
|
676
|
+
// why, the session continues. The input is emptied as well, for code mode, which runs the
|
|
677
|
+
// original tool whatever the event's name says; every tool this guard gates requires
|
|
678
|
+
// arguments, so `{}` fails validation there before anything executes.
|
|
679
|
+
//
|
|
680
|
+
// `permission.evaluate` (V2's deny hook) was not used: it sees only the action and a
|
|
681
|
+
// resource list (the shell tool's is per sub-command), not the full input the content
|
|
682
|
+
// scan and the patch-path gates need.
|
|
683
|
+
async function setupV2(ctx) {
|
|
684
|
+
const hooks = await GeneseedGuard({
|
|
685
|
+
worktree: ctx?.location?.project?.directory, directory: ctx?.location?.directory,
|
|
686
|
+
})
|
|
687
|
+
await ctx.tool.hook("execute.before", async (event) => {
|
|
688
|
+
try {
|
|
689
|
+
await hooks["tool.execute.before"]({ tool: event.tool }, { args: event.input })
|
|
690
|
+
} catch (err) {
|
|
691
|
+
const why = String(err?.message || "")
|
|
692
|
+
if (!why.startsWith("[geneseed-guard]")) return
|
|
693
|
+
event.tool = why
|
|
694
|
+
event.input = {}
|
|
695
|
+
}
|
|
696
|
+
})
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export default { id: "geneseed-guard", server: GeneseedGuard, setup: setupV2 }
|