opencode-goal-mode 0.3.11 → 0.4.1
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/ARCHITECTURE.md +15 -10
- package/CHANGELOG.md +80 -0
- package/README.md +76 -42
- package/docs/sidebar-demo.svg +48 -24
- package/package.json +1 -1
- package/plugins/goal-guard/config.js +3 -0
- package/plugins/goal-guard/guard.js +36 -1
- package/plugins/goal-guard/sidebar-data.js +11 -16
- package/plugins/goal-guard/summary.js +44 -19
- package/plugins/goal-guard/tools.js +3 -0
- package/plugins/goal-sidebar.tsx +52 -46
- package/scripts/install.mjs +13 -2
package/ARCHITECTURE.md
CHANGED
|
@@ -66,7 +66,7 @@ Verified against `@opencode-ai/plugin@1.15.13` source.
|
|
|
66
66
|
| `chat.message` | Capture the user's goal text (drives contextual review gates). |
|
|
67
67
|
| `chat.params` | Track the current agent; activate goal sessions. |
|
|
68
68
|
| `experimental.chat.system.transform` | Inject the live Goal Guard state block. |
|
|
69
|
-
| `tool.execute.before` | Block destructive / remote-exec bash by throwing. |
|
|
69
|
+
| `tool.execute.before` | Block destructive / remote-exec bash, and block non-Goal agents from invoking `goal-*` subagents, by throwing. |
|
|
70
70
|
| `tool.execute.after` | Record edits, verification, mutations, and review verdicts. |
|
|
71
71
|
| `experimental.text.complete` | Rewrite premature `Goal Completed` claims. |
|
|
72
72
|
| `experimental.session.compacting` | Preserve guard state across compaction. |
|
|
@@ -166,18 +166,23 @@ hooks still load.
|
|
|
166
166
|
## TUI companion (experimental)
|
|
167
167
|
|
|
168
168
|
`plugins/goal-sidebar.tsx` is a TUI plugin module — default-exporting `{ id, tui }`
|
|
169
|
-
— distinct from the server plugin. It
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
169
|
+
— distinct from the server plugin. It registers a `sidebar_content` slot via
|
|
170
|
+
`api.slots.register({ slots: { sidebar_content } })` (matching the canonical
|
|
171
|
+
OpenCode TUI-plugin pattern), and the slot renders content **only** for the active
|
|
172
|
+
session, and **only** when that exact session is an active Goal session. It is
|
|
173
|
+
keyed strictly by `props.session_id`: there is no most-recently-touched global
|
|
174
|
+
fallback, so a Build (or any non-Goal) session in the same worktree never inherits
|
|
175
|
+
another session's goal — it renders nothing and keeps OpenCode's native todo
|
|
176
|
+
section. When it does render, it shows the short goal label, gate/status line, and
|
|
177
|
+
structured Goal todos derived from acceptance criteria, evidence freshness, dirty
|
|
178
|
+
state, and missing gates, starting with a brief per-line rainbow foreground effect
|
|
179
|
+
and then settling to the configured running colour (`#FFD700` by default; red when
|
|
180
|
+
done).
|
|
177
181
|
|
|
178
182
|
It is *paired* with the server plugin only through the persisted state file:
|
|
179
183
|
`sidebar-data.js` recomputes the same `stateBaseDir`/`projectKey` path the guard
|
|
180
|
-
writes to and projects the
|
|
184
|
+
writes to and projects the requested session via `summary.sidebarView` (the same
|
|
185
|
+
per-session rule, so the Node tests and the real component agree). That keeps
|
|
181
186
|
the pure projection logic Node-testable (`tests/sidebar.test.mjs`) even though the
|
|
182
187
|
JSX renderer itself can only run inside OpenCode's (Bun) TUI runtime. Everything
|
|
183
188
|
in the `tui` entry is wrapped so a missing slot API, missing JSX runtime, or read
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.1
|
|
4
|
+
|
|
5
|
+
### Restructured Goal sidebar todo section
|
|
6
|
+
|
|
7
|
+
- The sidebar Goal section is now a proper stacked, multi-colour layout instead of
|
|
8
|
+
one run of text: a bold **`GOAL`** label on its own line (yellow while running,
|
|
9
|
+
red when done), then the goal title, then a `passing/total gates · status` line —
|
|
10
|
+
each line in its own highlight colour (GOAL yellow, title white, status cyan) so
|
|
11
|
+
they never blend together. It opens with a first-display per-line rainbow, then
|
|
12
|
+
settles.
|
|
13
|
+
- Removed the noisy `· changes pending` suffix from the status line; pending work
|
|
14
|
+
now surfaces as a structured todo row instead.
|
|
15
|
+
- Better, more structured todos: one row per acceptance criterion (✓ when fresh
|
|
16
|
+
evidence covers it), a re-verify row when the tree changed, and one row per
|
|
17
|
+
still-missing review gate by friendly name (e.g. "Pass Security Reviewer").
|
|
18
|
+
- In a goal session the section takes over the sidebar `sidebar_content` slot;
|
|
19
|
+
because OpenCode renders the native todo list as that slot's fallback, it
|
|
20
|
+
replaces the native list on builds that use replace/single-winner slot mode and
|
|
21
|
+
sits alongside it otherwise. Non-goal and no-goal sessions render nothing, so
|
|
22
|
+
the native todo list stays in place.
|
|
23
|
+
|
|
24
|
+
### Build mode is never treated as a goal
|
|
25
|
+
|
|
26
|
+
- Hardened the guard so a Build/Plan/custom session never accumulates goal state.
|
|
27
|
+
`tool.execute.after` bookkeeping (dirty flag, edits, verification, verdicts) now
|
|
28
|
+
runs only for active Goal sessions (and goal-namespace subagent sessions for
|
|
29
|
+
verdict capture). Destructive-command blocking still applies in every mode.
|
|
30
|
+
|
|
31
|
+
### Installer
|
|
32
|
+
|
|
33
|
+
- `--global` now resolves the home directory via `$HOME` and falls back to the OS
|
|
34
|
+
home dir, so it works in shells/containers where `$HOME` is unset.
|
|
35
|
+
- Verified the full install → idempotent re-run → conflict-protection → uninstall
|
|
36
|
+
lifecycle end-to-end on macOS (Node 24) and in clean Linux containers (Apple
|
|
37
|
+
`container`, Node 20 and Node 24) using the real `npm install -g <tarball>` path.
|
|
38
|
+
- Moved **Install** to the top of the README and documented dry-run/uninstall.
|
|
39
|
+
|
|
40
|
+
## v0.4.0
|
|
41
|
+
|
|
42
|
+
### Goal-only subagents
|
|
43
|
+
|
|
44
|
+
- The `goal-*` specialist subagents are now mechanically locked to Goal Mode.
|
|
45
|
+
OpenCode resolves subagents globally, so a Build, Plan, or custom agent could
|
|
46
|
+
previously invoke a Goal reviewer directly. The guard now blocks any `task` call
|
|
47
|
+
targeting a `goal-*` subagent unless it comes from an active Goal session, and a
|
|
48
|
+
poach attempt never turns the calling session into a Goal. General-purpose
|
|
49
|
+
subagents (`explore`/`general`/`scout`) are unaffected. New `restrictSubagents` /
|
|
50
|
+
`GOAL_GUARD_RESTRICT_SUBAGENTS` option (default on) toggles it.
|
|
51
|
+
|
|
52
|
+
### Per-session sidebar isolation (not global)
|
|
53
|
+
|
|
54
|
+
- The TUI Goal todo section is now strictly per-session. Both the live component
|
|
55
|
+
(`goal-sidebar.tsx`) and the Node-testable projection (`sidebar-data.js`) resolve
|
|
56
|
+
state by the exact `props.session_id` and only when that session is an active Goal
|
|
57
|
+
session — the "most-recently-touched active session" global fallback is gone in
|
|
58
|
+
both. A Build (or any non-Goal) session in the same worktree can no longer inherit
|
|
59
|
+
a sibling session's goal.
|
|
60
|
+
- The slot now always registers and decides per-session inside the render (matching
|
|
61
|
+
the canonical OpenCode TUI-plugin pattern) instead of conditionally registering.
|
|
62
|
+
- Added node + headless-visual coverage proving two active goals in one worktree
|
|
63
|
+
each render only their own goal.
|
|
64
|
+
|
|
65
|
+
### Goal-mode-only tools
|
|
66
|
+
|
|
67
|
+
- Every `goal_*` tool is Goal-mode-only: non-Goal/Build sessions get a clear refusal
|
|
68
|
+
instead of any Goal status, evidence map, memory, contract, evidence, or reset.
|
|
69
|
+
|
|
70
|
+
### Documentation & visuals (accuracy pass)
|
|
71
|
+
|
|
72
|
+
- Corrected the sidebar wording from "replaces the native todo area" to "adds a
|
|
73
|
+
Goal-owned todo section" (the slot contributes content; it does not replace native
|
|
74
|
+
todos), and removed the stale "waits to register the slot" description.
|
|
75
|
+
- Regenerated the README hero image (`docs/sidebar-demo.svg`) to match what actually
|
|
76
|
+
renders: a bold `Goal todos` label (no orb), the first-display per-line rainbow,
|
|
77
|
+
the running/done colour states, and the native-todo-stays behavior. The old image
|
|
78
|
+
showed a removed `◆ GOAL` orb and a removed grey "No goal" state.
|
|
79
|
+
- Made the benchmark "remaining misses" description accurate (all are plain `rm`
|
|
80
|
+
without `-r`/`-f`, intentionally permitted) and documented Goal-only subagents and
|
|
81
|
+
`restrictSubagents` in the README config table and ARCHITECTURE hook table.
|
|
82
|
+
|
|
3
83
|
## v0.3.11
|
|
4
84
|
|
|
5
85
|
- Fixed Goal sidebar/status isolation so an explicit Build or other non-Goal
|
package/README.md
CHANGED
|
@@ -1,40 +1,38 @@
|
|
|
1
1
|
# OpenCode Goal Mode
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/opencode-goal-mode)
|
|
4
|
-
[](https://www.npmjs.com/package/opencode-goal-mode)
|
|
5
|
-
[](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml)
|
|
6
|
-
[](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml)
|
|
7
|
-
[](LICENSE)
|
|
8
|
-
[](package.json)
|
|
9
|
-
|
|
10
3
|
Strict Goal Mode for OpenCode: a primary `goal` agent, specialized review
|
|
11
4
|
subagents, slash commands, a `goal-guard` plugin that enforces review discipline
|
|
12
|
-
and blocks destructive shell commands, and a
|
|
13
|
-
TUI sidebar.
|
|
5
|
+
and blocks destructive shell commands, and a structured Goal-owned todo section
|
|
6
|
+
in the TUI sidebar.
|
|
14
7
|
|
|
15
8
|
## Install
|
|
16
9
|
|
|
17
|
-
**One command
|
|
10
|
+
**One command.** Needs [Node](https://nodejs.org) 20.11+ and a working
|
|
11
|
+
[OpenCode](https://opencode.ai). Works on macOS and Linux:
|
|
18
12
|
|
|
19
13
|
```bash
|
|
20
14
|
npm install -g opencode-goal-mode && opencode-goal-mode --global
|
|
21
15
|
```
|
|
22
16
|
|
|
23
|
-
Then **restart OpenCode**. That's the whole install
|
|
17
|
+
Then **restart OpenCode**. That's the whole install — it copies the Goal agent,
|
|
24
18
|
review subagents, slash commands, and guard plugin into `~/.config/opencode`, and
|
|
25
19
|
merge-safely registers the Goal todo sidebar in `~/.config/opencode/tui.json`.
|
|
26
20
|
In the agent picker you'll see only the **`goal`** agent; reviewers are subagents
|
|
27
|
-
it drives automatically. The
|
|
28
|
-
|
|
21
|
+
it drives automatically. The install is **idempotent** (re-run it to upgrade in
|
|
22
|
+
place), never touches files you've edited, and `--uninstall` removes exactly what
|
|
23
|
+
it added. Goal Mode inherits your existing OpenCode model/provider.
|
|
29
24
|
|
|
30
25
|
<details>
|
|
31
26
|
<summary>Other ways to install</summary>
|
|
32
27
|
|
|
33
28
|
```bash
|
|
34
|
-
# Global npm install, then run the installer
|
|
29
|
+
# Global npm install, then run the installer separately
|
|
35
30
|
npm install -g opencode-goal-mode
|
|
36
31
|
opencode-goal-mode --global # alias of opencode-goal-mode-install
|
|
37
32
|
|
|
33
|
+
# Preview first, then install (no writes on --dry-run)
|
|
34
|
+
opencode-goal-mode --global --dry-run
|
|
35
|
+
|
|
38
36
|
# Temporary npx install (server-side components work; for the TUI sidebar,
|
|
39
37
|
# prefer the global install above so OpenCode can resolve the package later)
|
|
40
38
|
npx opencode-goal-mode --global
|
|
@@ -42,6 +40,9 @@ npx opencode-goal-mode --global
|
|
|
42
40
|
# Into a single project (writes ./.opencode, including ./.opencode/tui.json)
|
|
43
41
|
npx opencode-goal-mode
|
|
44
42
|
|
|
43
|
+
# Clean removal of everything it installed (incl. its tui.json entry)
|
|
44
|
+
opencode-goal-mode --global --uninstall
|
|
45
|
+
|
|
45
46
|
# From source
|
|
46
47
|
git clone https://github.com/devinoldenburg/opencode-goal-mode
|
|
47
48
|
cd opencode-goal-mode && npm ci && npm run install:global
|
|
@@ -49,16 +50,23 @@ cd opencode-goal-mode && npm ci && npm run install:global
|
|
|
49
50
|
|
|
50
51
|
Use global install for normal daily use. Use project install only when you want
|
|
51
52
|
Goal Mode scoped to one repo and your OpenCode build reads project `.opencode`
|
|
52
|
-
config, including `.opencode/tui.json`.
|
|
53
|
-
`--uninstall` removes only what it installed (and its `tui.json` entry), leaving
|
|
54
|
-
your edits untouched. See [Installer options](#installer-options).
|
|
53
|
+
config, including `.opencode/tui.json`. See [Installer options](#installer-options).
|
|
55
54
|
</details>
|
|
56
55
|
|
|
56
|
+
[](https://www.npmjs.com/package/opencode-goal-mode)
|
|
57
|
+
[](https://www.npmjs.com/package/opencode-goal-mode)
|
|
58
|
+
[](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml)
|
|
59
|
+
[](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml)
|
|
60
|
+
[](LICENSE)
|
|
61
|
+
[](package.json)
|
|
62
|
+
|
|
57
63
|

|
|
58
64
|
|
|
59
|
-
<sub>↑ In
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
<sub>↑ In goal mode, the Goal plugin takes over the sidebar todo section with a
|
|
66
|
+
structured, evidence-aware Goal todo list — a bold `GOAL` label, then the goal
|
|
67
|
+
title, gate progress, and per-acceptance/gate todo rows, each on its own line in
|
|
68
|
+
its own colour, with a first-display rainbow. Build and every other mode keep
|
|
69
|
+
OpenCode's native todo section — see [TUI integration](#tui-integration).</sub>
|
|
62
70
|
|
|
63
71
|
**[Quick start](#quick-start) · [Why it's different](#why-its-different) · [Benchmarks](#benchmarks-honest-edition) · [TUI integration](#tui-integration) · [Configuration](#configuration) · [Releasing](#releasing) · [Architecture](ARCHITECTURE.md)**
|
|
64
72
|
|
|
@@ -140,10 +148,13 @@ Reproduce with `npm run bench` or `node benchmarks/external.mjs`.
|
|
|
140
148
|
|
|
141
149
|
Honest caveats, because the point of this rewrite was to stop overclaiming:
|
|
142
150
|
|
|
143
|
-
- The
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
151
|
+
- The 7 remaining "misses" are all plain `rm` invocations without `-r`/`-f`
|
|
152
|
+
(single- or multi-target, a few with `-i`/`-v`/`-d`), which the guard
|
|
153
|
+
**intentionally permits**: bare `rm` is extremely common, so the guard marks it
|
|
154
|
+
dirty but lets the host's own `rm *` permission decide, while still blocking the
|
|
155
|
+
irreversible forms (`rm -r`/`rm -f`, wildcard/root, `$(rm …)`, `bash -c`,
|
|
156
|
+
`/bin/rm`, interpreters, etc.). Under a strict every-`rm`-is-destructive
|
|
157
|
+
labeling those count against it.
|
|
147
158
|
- The single counted false positive (`git filter-repo …`) actually *is* a
|
|
148
159
|
history-rewriting command, so the real-world false-positive rate is effectively
|
|
149
160
|
zero. `node benchmarks/external.mjs --json` lists every miss and false positive
|
|
@@ -182,7 +193,8 @@ second) — negligible for a per-tool-call guard:
|
|
|
182
193
|
discovery, verification planning, and reviews to subagents. **`goal` is the only
|
|
183
194
|
user-selectable agent** — every specialist (security, diff, verifier, …) is a
|
|
184
195
|
`mode: subagent` that the Goal agent invokes via the task tool; the user never
|
|
185
|
-
picks one directly
|
|
196
|
+
picks one directly, and the guard blocks any other agent from invoking them (see
|
|
197
|
+
**Goal-only subagents** below). They surface with friendly names (e.g. "Security Reviewer",
|
|
186
198
|
"API Reviewer") rather than raw ids.
|
|
187
199
|
- Strict review gates for prompt compliance, diff review, verification, security,
|
|
188
200
|
UX, operations, data, API, performance, tests, docs, quality, and final audit.
|
|
@@ -197,6 +209,12 @@ second) — negligible for a per-tool-call guard:
|
|
|
197
209
|
`Goal Not Completed` with the exact missing review gates.
|
|
198
210
|
- **Contextual gating**: the goal text and changed files determine which
|
|
199
211
|
specialist reviewers are required.
|
|
212
|
+
- **Goal-only subagents**: the `goal-*` specialist subagents are mechanically
|
|
213
|
+
locked to Goal Mode. OpenCode resolves subagents globally, so the guard blocks
|
|
214
|
+
any Build, Plan, or custom agent that tries to invoke a `goal-*` reviewer via
|
|
215
|
+
the task tool — they run only under the Goal agent (toggle with
|
|
216
|
+
`restrictSubagents`). General-purpose subagents (`explore`/`general`/`scout`)
|
|
217
|
+
are never restricted.
|
|
200
218
|
- **Reviewer Memory**: blocking reviewer findings are carried across cycles,
|
|
201
219
|
surfaced in status/system context, and marked resolved by fresh PASS verdicts.
|
|
202
220
|
- **Disk persistence**: review ledgers and Reviewer Memory survive OpenCode restarts.
|
|
@@ -208,9 +226,10 @@ second) — negligible for a per-tool-call guard:
|
|
|
208
226
|
reviewer's friendly name, and a single "completion unlocked" toast the moment
|
|
209
227
|
the last required gate clears.
|
|
210
228
|
- An **experimental** companion TUI plugin (`plugins/goal-sidebar.tsx`) that, in
|
|
211
|
-
Goal sessions only,
|
|
212
|
-
evidence-aware todo
|
|
213
|
-
|
|
229
|
+
Goal sessions only, takes over the sidebar todo area with a structured,
|
|
230
|
+
evidence-aware Goal todo list (`GOAL` label, goal title, gate progress, and
|
|
231
|
+
todo rows — each on its own line in its own colour). It shows a first-display
|
|
232
|
+
rainbow, then normal goal colours. See [TUI integration](#tui-integration).
|
|
214
233
|
- A test suite validating the analyzer, plugin hooks, state store, install
|
|
215
234
|
safety, and config compatibility.
|
|
216
235
|
|
|
@@ -220,26 +239,40 @@ Goal Mode is a **plugin pair**: the server-side `goal-guard` plugin owns
|
|
|
220
239
|
enforcement and writes its state to disk, and an experimental TUI plugin
|
|
221
240
|
(`plugins/goal-sidebar.tsx`) reads that same state to render a live todo section.
|
|
222
241
|
|
|
223
|
-
- **Goal-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
-
|
|
230
|
-
|
|
231
|
-
-
|
|
232
|
-
|
|
242
|
+
- **Goal-owned todo section.** In a `goal` session with a goal set, the Goal
|
|
243
|
+
plugin renders its own structured todo section into the sidebar's `sidebar_content`
|
|
244
|
+
slot, stacked on separate lines, each in its own colour so it never reads as one
|
|
245
|
+
run of text:
|
|
246
|
+
- a bold **`GOAL`** label (yellow while running, red when done);
|
|
247
|
+
- the short goal title;
|
|
248
|
+
- a `passing/total gates · status` line (lifecycle only — no "changes pending"
|
|
249
|
+
noise; pending work shows as a todo row instead);
|
|
250
|
+
- structured todo rows derived from real guard state: one per acceptance
|
|
251
|
+
criterion (✓ when fresh evidence covers it), a re-verify row when the tree
|
|
252
|
+
changed, and one row per still-missing review gate by friendly name
|
|
253
|
+
(e.g. "Pass Security Reviewer").
|
|
254
|
+
|
|
255
|
+
It opens with a first-display rainbow (`sidebarRainbowMs`) so the takeover is
|
|
256
|
+
visible, then settles to the lifecycle colours (running → yellow label; done →
|
|
257
|
+
red). Because OpenCode renders the native todo list as that slot's *fallback*,
|
|
258
|
+
on builds that render `sidebar_content` in replace/single-winner mode the Goal
|
|
259
|
+
section **replaces** the native todo list while a goal is active; in append mode
|
|
260
|
+
it sits alongside it. In every case:
|
|
261
|
+
- **no render** — Build and every non-Goal mode (and a Goal session before a
|
|
262
|
+
goal is set) render nothing here, so OpenCode's native todo section stays in
|
|
263
|
+
the same position. The section is scoped to the session that owns the goal: a
|
|
264
|
+
Build session in the same worktree never inherits another session's goal.
|
|
233
265
|
|
|
234
266
|
Toggle/recolour with `sidebarBanner`, `sidebarColor` (running), `sidebarDoneColor`
|
|
235
267
|
(done), `sidebarMutedColor`, `sidebarRainbowMs`, or the `GOAL_GUARD_SIDEBAR_*`
|
|
236
268
|
env vars.
|
|
237
269
|
|
|
238
270
|
**How it loads — important.** TUI plugins are **not** loaded from the `plugins/`
|
|
239
|
-
dir; OpenCode loads them from `tui.json`. The Goal sidebar
|
|
240
|
-
`sidebar_content` slot
|
|
241
|
-
|
|
242
|
-
|
|
271
|
+
dir; OpenCode loads them from `tui.json`. The Goal sidebar registers a
|
|
272
|
+
`sidebar_content` slot that renders content **only** for the active session when
|
|
273
|
+
that session is a Goal session; for any other session it renders nothing, so
|
|
274
|
+
non-Goal modes keep their native todo section. With `--global`, the installer
|
|
275
|
+
writes `~/.config/opencode/tui.json` for you (merge-safe):
|
|
243
276
|
|
|
244
277
|
```json
|
|
245
278
|
{ "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
|
|
@@ -299,6 +332,7 @@ Or via environment variables (`GOAL_GUARD_*`):
|
|
|
299
332
|
| `injectSystemState` / `GOAL_GUARD_INJECT_SYSTEM_STATE` | `true` | Inject live state into the prompt. |
|
|
300
333
|
| `persist` / `GOAL_GUARD_PERSIST` | `true` | Persist state under the XDG state dir. |
|
|
301
334
|
| `contextualGates` / `GOAL_GUARD_CONTEXTUAL_GATES` | `true` | Require specialist gates by goal keywords. |
|
|
335
|
+
| `restrictSubagents` / `GOAL_GUARD_RESTRICT_SUBAGENTS` | `true` | Block non-Goal agents from invoking the `goal-*` subagents via the task tool. |
|
|
302
336
|
| `maxSessions` / `GOAL_GUARD_MAX_SESSIONS` | `200` | Session cache size. |
|
|
303
337
|
| `sessionTtlMs` / `GOAL_GUARD_SESSION_TTL_MS` | `86400000` | Idle session TTL. |
|
|
304
338
|
| `toastOnBlock` / `GOAL_GUARD_TOAST_ON_BLOCK` | `true` | Toast when something is blocked. |
|
package/docs/sidebar-demo.svg
CHANGED
|
@@ -1,54 +1,78 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="452" viewBox="0 0 1000 452" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" role="img" aria-label="OpenCode Goal Mode sidebar: in a goal session the plugin renders a stacked Goal todo section — a bold GOAL label, the goal title, a gates/status line, and per-gate todo rows — each on its own line in its own colour, opening with a first-display rainbow then settling (GOAL yellow, title white, status cyan) and turning red when done. Build and other modes render no Goal section and keep OpenCode's native todo list.">
|
|
2
2
|
<defs>
|
|
3
3
|
<style>
|
|
4
4
|
.win { fill: #0d1117; stroke: #30363d; stroke-width: 1; }
|
|
5
5
|
.bar { fill: #161b22; }
|
|
6
6
|
.dot { stroke-width: 0; }
|
|
7
7
|
.title { fill: #8b949e; font-size: 12px; }
|
|
8
|
-
.
|
|
9
|
-
.
|
|
10
|
-
.
|
|
11
|
-
.
|
|
12
|
-
.muted { fill: #808080; font-size: 13px; }
|
|
8
|
+
.hdr { fill: #8b949e; font-size: 11px; letter-spacing: 0.5px; }
|
|
9
|
+
.lbl { font-size: 13px; font-weight: 700; }
|
|
10
|
+
.ln { font-size: 13px; }
|
|
11
|
+
.sm { font-size: 12px; }
|
|
13
12
|
.chat { fill: #c9d1d9; font-size: 12px; }
|
|
14
13
|
.dim { fill: #6e7681; font-size: 12px; }
|
|
15
14
|
.ok { fill: #2da44e; font-size: 12px; }
|
|
16
15
|
.div { stroke: #30363d; stroke-width: 1; }
|
|
16
|
+
/* first-display rainbow — successive hues per line (RAINBOW[index]) */
|
|
17
|
+
.r0 { fill: #FF5555; } .r1 { fill: #FFAA00; } .r2 { fill: #FFFF55; } .r3 { fill: #55FF55; }
|
|
18
|
+
/* settled running palette */
|
|
19
|
+
.label { fill: #FFD700; } /* GOAL — yellow */
|
|
20
|
+
.gtitle { fill: #FFFFFF; } /* goal title — bright white */
|
|
21
|
+
.meta { fill: #8BE9FD; } /* gates · status — cyan */
|
|
22
|
+
.todo { fill: #808080; } /* pending todo rows — grey */
|
|
23
|
+
.done { fill: #FF5555; } /* done state — red */
|
|
24
|
+
.check { fill: #50FA7B; } /* ✓ done rows — green */
|
|
17
25
|
</style>
|
|
18
26
|
</defs>
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
<rect class="
|
|
22
|
-
<rect class="bar" x="1" y="
|
|
23
|
-
<rect class="bar" x="1" y="20" width="758" height="11"/>
|
|
28
|
+
<rect class="win" x="1" y="1" width="998" height="450" rx="8"/>
|
|
29
|
+
<rect class="bar" x="1" y="1" width="998" height="30" rx="8"/>
|
|
30
|
+
<rect class="bar" x="1" y="20" width="998" height="11"/>
|
|
24
31
|
<circle class="dot" cx="20" cy="16" r="5" fill="#ff5f56"/>
|
|
25
32
|
<circle class="dot" cx="38" cy="16" r="5" fill="#ffbd2e"/>
|
|
26
33
|
<circle class="dot" cx="56" cy="16" r="5" fill="#27c93f"/>
|
|
27
34
|
<text class="title" x="86" y="20">opencode — goal mode</text>
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
<line class="div" x1="470" y1="31" x2="470" y2="279"/>
|
|
36
|
+
<line class="div" x1="470" y1="31" x2="470" y2="451"/>
|
|
31
37
|
|
|
32
38
|
<!-- chat pane (left) -->
|
|
33
|
-
<text class="dim"
|
|
39
|
+
<text class="dim" x="20" y="58">▌ goal</text>
|
|
34
40
|
<text class="chat" x="20" y="82">▸ Goal Contract recorded (4 acceptance criteria)</text>
|
|
35
41
|
<text class="chat" x="20" y="104">▸ implementing… running verification</text>
|
|
36
42
|
<text class="ok" x="20" y="126">✓ Security Reviewer → PASS</text>
|
|
37
43
|
<text class="ok" x="20" y="148">✓ Verifier → PASS</text>
|
|
38
44
|
<text class="dim" x="20" y="170">▸ Diff Reviewer running…</text>
|
|
45
|
+
<text class="dim" x="20" y="212">Build / Plan / custom agents cannot</text>
|
|
46
|
+
<text class="dim" x="20" y="230">invoke the goal-* reviewers, and never</text>
|
|
47
|
+
<text class="dim" x="20" y="248">get a Goal section in their sidebar.</text>
|
|
39
48
|
|
|
40
|
-
<!--
|
|
41
|
-
<text class="
|
|
42
|
-
<line class="div" x1="490" y1="
|
|
49
|
+
<!-- Panel 1: running, first-display rainbow -->
|
|
50
|
+
<text class="hdr" x="490" y="52">GOAL SESSION · running · first-display rainbow</text>
|
|
51
|
+
<line class="div" x1="490" y1="60" x2="980" y2="60"/>
|
|
52
|
+
<text class="lbl r0" x="490" y="80">GOAL</text>
|
|
53
|
+
<text class="ln r1" x="490" y="98">Ship the OAuth refactor</text>
|
|
54
|
+
<text class="sm r2" x="490" y="116">0/5 gates · in progress</text>
|
|
55
|
+
<text class="sm r3" x="490" y="134">□ Pass Prompt Auditor</text>
|
|
43
56
|
|
|
44
|
-
<!--
|
|
45
|
-
<text
|
|
46
|
-
<
|
|
47
|
-
<text class="
|
|
57
|
+
<!-- Panel 2: running, settled (distinct colours per line) -->
|
|
58
|
+
<text class="hdr" x="490" y="166">GOAL SESSION · running · settled colours</text>
|
|
59
|
+
<line class="div" x1="490" y1="174" x2="980" y2="174"/>
|
|
60
|
+
<text class="lbl label" x="490" y="194">GOAL</text>
|
|
61
|
+
<text class="ln gtitle" x="490" y="212">Ship the OAuth refactor</text>
|
|
62
|
+
<text class="sm meta" x="490" y="230">3/5 gates · in progress</text>
|
|
63
|
+
<text class="sm todo" x="490" y="248">□ Pass Security Reviewer</text>
|
|
48
64
|
|
|
49
|
-
|
|
65
|
+
<!-- Panel 3: done (red) -->
|
|
66
|
+
<text class="hdr" x="490" y="280">GOAL SESSION · done</text>
|
|
67
|
+
<line class="div" x1="490" y1="288" x2="980" y2="288"/>
|
|
68
|
+
<text class="lbl done" x="490" y="308">GOAL</text>
|
|
69
|
+
<text class="ln done" x="490" y="326">Fix the parser bug</text>
|
|
70
|
+
<text class="sm done" x="490" y="344">5/5 gates · completed · 2 review cycles</text>
|
|
71
|
+
<text class="sm check" x="490" y="362">✓ All acceptance criteria covered</text>
|
|
50
72
|
|
|
51
|
-
<!-- no
|
|
52
|
-
<text class="
|
|
53
|
-
<
|
|
73
|
+
<!-- Panel 4: build / other mode → native todos, no Goal section -->
|
|
74
|
+
<text class="hdr" x="490" y="394">BUILD / OTHER MODE</text>
|
|
75
|
+
<line class="div" x1="490" y1="402" x2="980" y2="402"/>
|
|
76
|
+
<text class="todo" x="490" y="422" font-size="12">▢ OpenCode's native todo section</text>
|
|
77
|
+
<text class="dim" x="490" y="440">(no Goal section is rendered here)</text>
|
|
54
78
|
</svg>
|
package/package.json
CHANGED
|
@@ -20,6 +20,8 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
20
20
|
persist: true,
|
|
21
21
|
/** Require the contextual specialist gates derived from goal text / changed files. */
|
|
22
22
|
contextualGates: true,
|
|
23
|
+
/** Block non-Goal agents from invoking the goal-* subagents via the task tool. */
|
|
24
|
+
restrictSubagents: true,
|
|
23
25
|
/** Maximum tracked sessions before LRU eviction. */
|
|
24
26
|
maxSessions: 200,
|
|
25
27
|
/** Idle TTL (ms) after which a session's state may be dropped. 0 disables TTL. */
|
|
@@ -68,6 +70,7 @@ function fromEnv(env) {
|
|
|
68
70
|
GOAL_GUARD_INJECT_SYSTEM_STATE: ["injectSystemState", coerceBool],
|
|
69
71
|
GOAL_GUARD_PERSIST: ["persist", coerceBool],
|
|
70
72
|
GOAL_GUARD_CONTEXTUAL_GATES: ["contextualGates", coerceBool],
|
|
73
|
+
GOAL_GUARD_RESTRICT_SUBAGENTS: ["restrictSubagents", coerceBool],
|
|
71
74
|
GOAL_GUARD_MAX_SESSIONS: ["maxSessions", coerceInt],
|
|
72
75
|
GOAL_GUARD_SESSION_TTL_MS: ["sessionTtlMs", coerceInt],
|
|
73
76
|
GOAL_GUARD_TOAST_ON_BLOCK: ["toastOnBlock", coerceBool],
|
|
@@ -23,7 +23,7 @@ import { createStore, createState } from "./state.js";
|
|
|
23
23
|
import { createPersistence } from "./persistence.js";
|
|
24
24
|
import { createLogger } from "./logger.js";
|
|
25
25
|
import { analyzeCommand, looksLikeDestructiveBash, looksLikeMutatingBash, isVerification } from "./shell.js";
|
|
26
|
-
import { isPrimaryAgent, isReviewAgent, CYCLE_CLOSING_AGENT, prettyAgentName } from "./agents.js";
|
|
26
|
+
import { isPrimaryAgent, isReviewAgent, isGoalAgent, CYCLE_CLOSING_AGENT, prettyAgentName } from "./agents.js";
|
|
27
27
|
import { textOf, parseVerdict, recordVerdict } from "./verdicts.js";
|
|
28
28
|
import { completionAllowed, missingGates, refreshStickyGates } from "./gates.js";
|
|
29
29
|
import { evaluateCompletionClaim } from "./completion.js";
|
|
@@ -41,6 +41,11 @@ function commandOf(input, output) {
|
|
|
41
41
|
return String(output?.args?.command ?? input?.args?.command ?? "");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The subagent a `task` call targets (args live on the output in tool.execute.before). */
|
|
45
|
+
function taskTarget(input, output) {
|
|
46
|
+
return String(output?.args?.subagent_type ?? input?.args?.subagent_type ?? output?.args?.agent ?? input?.args?.agent ?? "").trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
function partsText(parts) {
|
|
45
50
|
if (!Array.isArray(parts)) return "";
|
|
46
51
|
return parts
|
|
@@ -130,6 +135,29 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
|
|
|
130
135
|
|
|
131
136
|
async "tool.execute.before"(inp, out) {
|
|
132
137
|
const state = store.stateFor(inp?.sessionID);
|
|
138
|
+
|
|
139
|
+
// The goal-* subagents belong to Goal Mode. OpenCode resolves subagents
|
|
140
|
+
// globally, so without this a Build/Plan/custom agent could invoke a Goal
|
|
141
|
+
// reviewer directly. Only a Goal session (the `goal` primary, or a session
|
|
142
|
+
// the guard has already marked active) may spawn them. Non-goal targets
|
|
143
|
+
// (explore/general/scout) are never restricted.
|
|
144
|
+
if (inp?.tool === "task" && config.restrictSubagents) {
|
|
145
|
+
const target = taskTarget(inp, out);
|
|
146
|
+
if (target && isGoalAgent(target)) {
|
|
147
|
+
const caller = state.currentAgent;
|
|
148
|
+
const callerIsGoal = isPrimaryAgent(caller) || state.active;
|
|
149
|
+
if (!callerIsGoal) {
|
|
150
|
+
state.dirtyReasons.push(`blocked non-Goal invocation of subagent ${target}`);
|
|
151
|
+
if (config.toastOnBlock) logger.toast(`Goal Guard blocked ${prettyAgentName(target)} (Goal-only subagent)`, "error");
|
|
152
|
+
persist();
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Goal Guard: "${target}" is a Goal Mode subagent and can only be invoked by the Goal agent. ` +
|
|
155
|
+
`The "${caller || "current"}" agent cannot call it — start with /goal or switch to the goal agent.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
if (inp?.tool === "bash") {
|
|
134
162
|
const command = commandOf(inp, out);
|
|
135
163
|
const analysis = analyzeCommand(command);
|
|
@@ -150,6 +178,13 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
|
|
|
150
178
|
async "tool.execute.after"(inp, out) {
|
|
151
179
|
try {
|
|
152
180
|
const state = store.stateFor(inp?.sessionID);
|
|
181
|
+
// Goal bookkeeping is GOAL-ONLY. A Build/Plan/custom session must never have
|
|
182
|
+
// its edits, mutations, verification, or verdicts recorded as goal state —
|
|
183
|
+
// otherwise the guard would treat a non-Goal message/task as if it were a
|
|
184
|
+
// goal. Only an active Goal session (or a goal-namespace subagent's own
|
|
185
|
+
// session, for verdict capture) is tracked. Destructive-command blocking is
|
|
186
|
+
// handled in tool.execute.before and still applies in every mode.
|
|
187
|
+
if (!state.active && !isGoalAgent(state.currentAgent)) return;
|
|
153
188
|
const tool = inp?.tool;
|
|
154
189
|
const isReviewing = isReviewAgent(state.currentAgent);
|
|
155
190
|
|
|
@@ -30,25 +30,20 @@ function normalize(record) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* Resolve the guard state for EXACTLY this session id, and only when it is an
|
|
34
|
+
* active Goal session. There is deliberately NO "most-recently-touched" global
|
|
35
|
+
* fallback: a Build or other session in the same worktree must never inherit a
|
|
36
|
+
* Goal from a sibling session. This mirrors goal-sidebar.tsx's pickSession so the
|
|
37
|
+
* Node-testable projection and the real TUI component behave identically.
|
|
37
38
|
*/
|
|
38
39
|
export function pickSession(snapshot, sessionId) {
|
|
39
|
-
if (!snapshot || !Array.isArray(snapshot.sessions)) return null;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const direct = records.find(([key, st]) => key === sessionId && st.active);
|
|
45
|
-
if (direct) return direct[1];
|
|
46
|
-
return null;
|
|
40
|
+
if (!snapshot || !Array.isArray(snapshot.sessions) || !sessionId) return null;
|
|
41
|
+
for (const entry of snapshot.sessions) {
|
|
42
|
+
if (!Array.isArray(entry) || entry.length !== 2) continue;
|
|
43
|
+
const [key, st] = entry;
|
|
44
|
+
if (key === sessionId && st && typeof st === "object" && st.active) return normalize(st);
|
|
47
45
|
}
|
|
48
|
-
|
|
49
|
-
if (active.length === 0) return null;
|
|
50
|
-
active.sort((a, b) => (b[1].touchedAt || 0) - (a[1].touchedAt || 0));
|
|
51
|
-
return active[0][1];
|
|
46
|
+
return null;
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
/**
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { requiredGates, missingGates, gatePassedFresh } from "./gates.js";
|
|
7
|
+
import { prettyAgentName } from "./agents.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* A short, single-line label for the current goal.
|
|
@@ -35,33 +36,55 @@ function criterionEvidenceFresh(state, criterion) {
|
|
|
35
36
|
return entries.some((entry) => evidenceMatchesCriterion(entry, criterion) && evidenceFresh(entry, state));
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function clip(text, max) {
|
|
40
|
+
const s = String(text || "").replace(/\s+/g, " ").trim();
|
|
41
|
+
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Structured, ordered todo rows for the sidebar. Each row is a concrete, checkable
|
|
46
|
+
* step toward Goal completion — acceptance criteria first (✓ when fresh evidence
|
|
47
|
+
* covers them), then a re-verify nudge if the tree changed, then ONE row per still-
|
|
48
|
+
* missing review gate (friendly name, e.g. "Pass Security Reviewer"). This is the
|
|
49
|
+
* Goal plugin's own todo system: it is derived from real guard state (contract,
|
|
50
|
+
* evidence freshness, dirty flag, required gates), not transcript memory, so it
|
|
51
|
+
* tracks completion truthfully and updates as gates clear.
|
|
52
|
+
*/
|
|
38
53
|
function sidebarTodos(state, required, missing) {
|
|
39
54
|
const criteria = Array.isArray(state?.contract?.acceptanceCriteria) ? state.contract.acceptanceCriteria : [];
|
|
40
55
|
const items = [];
|
|
41
|
-
for (const criterion of criteria.slice(0,
|
|
42
|
-
const text =
|
|
56
|
+
for (const criterion of criteria.slice(0, 4)) {
|
|
57
|
+
const text = clip(criterion, 52);
|
|
43
58
|
if (!text) continue;
|
|
44
|
-
items.push({
|
|
45
|
-
status: criterionEvidenceFresh(state, text) ? "done" : "todo",
|
|
46
|
-
text: text.length <= 58 ? text : `${text.slice(0, 57).trimEnd()}…`,
|
|
47
|
-
});
|
|
59
|
+
items.push({ status: criterionEvidenceFresh(state, text) ? "done" : "todo", text });
|
|
48
60
|
}
|
|
49
|
-
if (state?.dirty) items.push({ status: "todo", text: "
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
if (state?.dirty) items.push({ status: "todo", text: "Re-verify & re-review after recent edits" });
|
|
62
|
+
// One row per missing/stale review gate, by friendly name — more scannable than a
|
|
63
|
+
// comma-joined id list and it shows exactly which reviewer is still owed.
|
|
64
|
+
for (const gate of missing.slice(0, 4)) {
|
|
65
|
+
items.push({ status: "todo", text: `Pass ${prettyAgentName(gate)}` });
|
|
66
|
+
}
|
|
67
|
+
if (items.length === 0 && required.length > 0) {
|
|
68
|
+
items.push({ status: "todo", text: "Record the Goal Contract & acceptance criteria" });
|
|
69
|
+
}
|
|
70
|
+
return items.slice(0, 8);
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
/**
|
|
56
74
|
* Compact projection for the TUI sidebar todo section. ALWAYS returns an object with a
|
|
57
|
-
* three-way `state`, plus
|
|
58
|
-
*
|
|
59
|
-
* - `
|
|
60
|
-
* - `
|
|
61
|
-
*
|
|
75
|
+
* three-way `state`, plus lines that stack vertically in the sidebar — each rendered
|
|
76
|
+
* on its OWN line in a distinct colour so it never reads as one run of text:
|
|
77
|
+
* - `label` → line 1: the fixed "GOAL" header (yellow when running, red when done).
|
|
78
|
+
* - `goal` → line 2: the short AI goal title.
|
|
79
|
+
* - `gates` → line 3: gate count + lifecycle, e.g. "3/5 gates · in progress" or
|
|
80
|
+
* "5/5 gates · completed · 2 review cycles". No "changes pending"
|
|
81
|
+
* noise — pending work surfaces as a structured todo row instead.
|
|
82
|
+
* - `todos` → following lines: structured acceptance/verification/gate todos.
|
|
62
83
|
* State drives colour: "running" = rainbow first, then yellow; "done" = red;
|
|
63
|
-
* "none" = render nothing so non-Goal
|
|
84
|
+
* "none" = render nothing so non-Goal / no-goal sessions keep the native todo section.
|
|
64
85
|
*/
|
|
86
|
+
export const GOAL_LABEL = "GOAL";
|
|
87
|
+
|
|
65
88
|
export function sidebarView(state, config) {
|
|
66
89
|
if (!state || !state.active) return NO_GOAL;
|
|
67
90
|
const goal = shortGoalLabel(state);
|
|
@@ -76,10 +99,11 @@ export function sidebarView(state, config) {
|
|
|
76
99
|
if (done) {
|
|
77
100
|
return {
|
|
78
101
|
state: "done",
|
|
102
|
+
label: GOAL_LABEL,
|
|
79
103
|
goal,
|
|
80
104
|
gates,
|
|
81
105
|
status: `completed · ${cycles} review cycle${cycles === 1 ? "" : "s"}`,
|
|
82
|
-
todoTitle:
|
|
106
|
+
todoTitle: GOAL_LABEL,
|
|
83
107
|
todos: todos.length ? todos.map((item) => ({ ...item, status: "done" })) : [{ status: "done", text: "All Goal completion gates are clear" }],
|
|
84
108
|
passing,
|
|
85
109
|
required: required.length,
|
|
@@ -88,10 +112,11 @@ export function sidebarView(state, config) {
|
|
|
88
112
|
}
|
|
89
113
|
return {
|
|
90
114
|
state: "running",
|
|
115
|
+
label: GOAL_LABEL,
|
|
91
116
|
goal,
|
|
92
117
|
gates,
|
|
93
|
-
status:
|
|
94
|
-
todoTitle:
|
|
118
|
+
status: "in progress",
|
|
119
|
+
todoTitle: GOAL_LABEL,
|
|
95
120
|
todos,
|
|
96
121
|
passing,
|
|
97
122
|
required: required.length,
|
|
@@ -50,6 +50,7 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
50
50
|
args: {},
|
|
51
51
|
async execute(_args, ctx) {
|
|
52
52
|
const state = store.stateFor(ctx.sessionID);
|
|
53
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
53
54
|
const report = statusReport(state, config);
|
|
54
55
|
const goal = report.goal ? `“${report.goal}” — ` : "";
|
|
55
56
|
return {
|
|
@@ -73,6 +74,7 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
73
74
|
args: {},
|
|
74
75
|
async execute(_args, ctx) {
|
|
75
76
|
const state = store.stateFor(ctx.sessionID);
|
|
77
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
76
78
|
const report = evidenceMapReport(state, config);
|
|
77
79
|
const covered = report.criteria.filter((item) => item.status === "covered").length;
|
|
78
80
|
return {
|
|
@@ -90,6 +92,7 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
90
92
|
args: {},
|
|
91
93
|
async execute(_args, ctx) {
|
|
92
94
|
const state = store.stateFor(ctx.sessionID);
|
|
95
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
93
96
|
const report = reviewerMemoryReport(state);
|
|
94
97
|
return {
|
|
95
98
|
title: `Reviewer Memory: ${report.open.length} open findings`,
|
package/plugins/goal-sidebar.tsx
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Goal Mode — TUI sidebar todo section.
|
|
4
4
|
*
|
|
5
|
-
* In Goal agent sessions this
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* In Goal agent sessions this renders a Goal-owned, evidence-aware todo section
|
|
6
|
+
* into the sidebar_content slot (GOAL label, goal title, gate/status line, and
|
|
7
|
+
* structured todo rows). OpenCode renders the native todo list as that slot's
|
|
8
|
+
* fallback, so in replace/single-winner slot mode this REPLACES the native todos
|
|
9
|
+
* while a goal is active; rendering nothing (non-Goal or no-goal sessions) brings
|
|
10
|
+
* the native todos back. The section is strictly per-session: it is keyed by
|
|
11
|
+
* props.session_id, so a Build session in the same worktree never inherits
|
|
12
|
+
* another session's goal.
|
|
8
13
|
*
|
|
9
14
|
* How OpenCode loads this: TUI plugins are listed in `~/.config/opencode/tui.json`
|
|
10
15
|
* (NOT the plugins/ dir) and resolved via the package's `exports["./tui"]`. The
|
|
@@ -22,9 +27,12 @@ import { createSignal, onCleanup, For, Show } from "solid-js";
|
|
|
22
27
|
import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
|
|
23
28
|
import { DEFAULT_CONFIG } from "./goal-guard/config.js";
|
|
24
29
|
|
|
25
|
-
const DEFAULT_COLOR = "#FFD700"; // running — yellow
|
|
30
|
+
const DEFAULT_COLOR = "#FFD700"; // running — GOAL label, yellow
|
|
26
31
|
const DEFAULT_DONE = "#FF5555"; // done — red
|
|
27
|
-
const DEFAULT_MUTED = "#808080"; //
|
|
32
|
+
const DEFAULT_MUTED = "#808080"; // pending todo rows — grey
|
|
33
|
+
const TITLE_COLOR = "#FFFFFF"; // goal title line (running) — bright, distinct from the yellow GOAL label
|
|
34
|
+
const META_COLOR = "#8BE9FD"; // gates · status line (running) — cyan accent
|
|
35
|
+
const TODO_DONE_COLOR = "#50FA7B"; // ✓ done todo rows — green
|
|
28
36
|
const POLL_MS = 1500;
|
|
29
37
|
const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
|
|
30
38
|
|
|
@@ -60,21 +68,21 @@ function readSnapshot(worktree) {
|
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the guard state for EXACTLY this session id, and only when it is an
|
|
73
|
+
* active Goal session. There is deliberately NO "most-recently-touched" global
|
|
74
|
+
* fallback: a Build or other session in the same worktree must never inherit a
|
|
75
|
+
* Goal from a sibling session. (Mirrors the reference OpenCode TUI plugin's
|
|
76
|
+
* explicit per-session rule — do not fall back to the latest state.)
|
|
77
|
+
*/
|
|
64
78
|
function pickSession(snapshot, sessionId) {
|
|
65
|
-
if (!snapshot || !Array.isArray(snapshot.sessions)) return null;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const direct = records.find(([key, st]) => key === sessionId && st.active);
|
|
71
|
-
if (direct) return direct[1];
|
|
72
|
-
return null;
|
|
79
|
+
if (!snapshot || !Array.isArray(snapshot.sessions) || !sessionId) return null;
|
|
80
|
+
for (const entry of snapshot.sessions) {
|
|
81
|
+
if (!Array.isArray(entry) || entry.length !== 2) continue;
|
|
82
|
+
const [key, st] = entry;
|
|
83
|
+
if (key === sessionId && st && typeof st === "object" && st.active) return st;
|
|
73
84
|
}
|
|
74
|
-
|
|
75
|
-
if (active.length === 0) return null;
|
|
76
|
-
active.sort((a, b) => (b[1].touchedAt || 0) - (a[1].touchedAt || 0));
|
|
77
|
-
return active[0][1];
|
|
85
|
+
return null;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
function readModel(worktree, sessionId) {
|
|
@@ -94,17 +102,13 @@ const id = "goal-mode-sidebar";
|
|
|
94
102
|
/** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
|
|
95
103
|
const tui = async (api, options) => {
|
|
96
104
|
try {
|
|
97
|
-
const { enabled, color, doneColor, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
|
|
105
|
+
const { enabled, color, doneColor, muted, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
|
|
98
106
|
if (!enabled) return;
|
|
99
107
|
if (!api?.slots?.register) return; // runtime without the slot API → no-op.
|
|
100
108
|
|
|
101
109
|
const worktree = api.state?.path?.worktree || api.state?.path?.directory;
|
|
102
110
|
|
|
103
|
-
|
|
104
|
-
const register = () => {
|
|
105
|
-
if (registered) return;
|
|
106
|
-
registered = true;
|
|
107
|
-
api.slots.register({
|
|
111
|
+
api.slots.register({
|
|
108
112
|
order: 50,
|
|
109
113
|
slots: {
|
|
110
114
|
sidebar_content(_ctx, props) {
|
|
@@ -124,38 +128,40 @@ const tui = async (api, options) => {
|
|
|
124
128
|
const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
|
|
125
129
|
onCleanup(() => clearInterval(timer));
|
|
126
130
|
onCleanup(() => clearTimeout(rainbowTimer));
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
//
|
|
131
|
+
const isRainbow = () => rainbow() && model().state === "running";
|
|
132
|
+
// Settled (post-rainbow) colour for each header line. When done, every
|
|
133
|
+
// line is red; while running each line gets its OWN highlight colour so
|
|
134
|
+
// the GOAL label, the goal title, and the status never read as one text.
|
|
135
|
+
const settled = (kind) => {
|
|
136
|
+
if (model().state === "done") return doneColor;
|
|
137
|
+
if (kind === "label") return color; // GOAL — yellow
|
|
138
|
+
if (kind === "title") return TITLE_COLOR; // goal title — bright white
|
|
139
|
+
return META_COLOR; // gates · status — cyan
|
|
140
|
+
};
|
|
141
|
+
const lineColor = (index, kind) => (isRainbow() ? RAINBOW[index % RAINBOW.length] : settled(kind));
|
|
142
|
+
const todoColor = (index, item) => {
|
|
143
|
+
if (isRainbow()) return RAINBOW[index % RAINBOW.length];
|
|
144
|
+
if (item.status === "done") return TODO_DONE_COLOR;
|
|
145
|
+
return model().state === "done" ? doneColor : muted;
|
|
146
|
+
};
|
|
147
|
+
// Goal sessions render a Goal-owned todo section (GOAL label, then the goal
|
|
148
|
+
// title, status, and structured todos — each on its own line). Non-Goal /
|
|
149
|
+
// no-goal sessions returned undefined above, so native todos remain.
|
|
130
150
|
return (
|
|
131
151
|
<Show when={model().state !== "none"}>
|
|
132
152
|
<box flexDirection="column" paddingTop={1}>
|
|
133
|
-
<text fg={lineColor(0)}>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
</text>
|
|
137
|
-
<text fg={lineColor(1)}>{`${model().gates} · ${model().status}`}</text>
|
|
153
|
+
<text fg={lineColor(0, "label")}><b>{model().label || "GOAL"}</b></text>
|
|
154
|
+
<text fg={lineColor(1, "title")}>{model().goal}</text>
|
|
155
|
+
<text fg={lineColor(2, "meta")}>{`${model().gates} · ${model().status}`}</text>
|
|
138
156
|
<For each={model().todos || []}>
|
|
139
|
-
{(item, index) => <text fg={
|
|
157
|
+
{(item, index) => <text fg={todoColor(index() + 3, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
|
|
140
158
|
</For>
|
|
141
159
|
</box>
|
|
142
160
|
</Show>
|
|
143
161
|
);
|
|
144
162
|
},
|
|
145
163
|
},
|
|
146
|
-
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
if (readModel(worktree).state !== "none") {
|
|
150
|
-
register();
|
|
151
|
-
} else {
|
|
152
|
-
const registrationTimer = setInterval(() => {
|
|
153
|
-
if (readModel(worktree).state !== "none") {
|
|
154
|
-
clearInterval(registrationTimer);
|
|
155
|
-
register();
|
|
156
|
-
}
|
|
157
|
-
}, POLL_MS);
|
|
158
|
-
}
|
|
164
|
+
});
|
|
159
165
|
} catch {
|
|
160
166
|
/* TUI runtime missing or API drift — render nothing rather than crash. */
|
|
161
167
|
}
|
package/scripts/install.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { join, resolve, relative, dirname } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
|
+
import { homedir } from "node:os";
|
|
18
19
|
import { parseArgs } from "node:util";
|
|
19
20
|
|
|
20
21
|
const { values } = parseArgs({
|
|
@@ -67,8 +68,18 @@ if (values.global && values.target) {
|
|
|
67
68
|
function resolveTarget() {
|
|
68
69
|
if (values.target) return resolve(String(values.target));
|
|
69
70
|
if (values.global) {
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
// OpenCode reads global config from ~/.config/opencode. Resolve home from $HOME,
|
|
72
|
+
// falling back to the OS home dir (homedir() works where $HOME is unset, e.g.
|
|
73
|
+
// some CI/container shells) so --global doesn't fail in those environments.
|
|
74
|
+
let home = process.env.HOME;
|
|
75
|
+
if (!home) {
|
|
76
|
+
try {
|
|
77
|
+
home = homedir();
|
|
78
|
+
} catch {
|
|
79
|
+
home = "";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!home) throw new Error("Cannot resolve a home directory for --global install. Pass --target <dir> instead.");
|
|
72
83
|
return join(home, ".config", "opencode");
|
|
73
84
|
}
|
|
74
85
|
return resolve(process.cwd(), ".opencode");
|