opencode-goal-mode 0.3.10 → 0.4.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/ARCHITECTURE.md +15 -10
- package/CHANGELOG.md +52 -0
- package/README.md +34 -20
- package/docs/sidebar-demo.svg +37 -22
- package/package.json +1 -1
- package/plugins/goal-guard/config.js +3 -0
- package/plugins/goal-guard/guard.js +29 -1
- package/plugins/goal-guard/sidebar-data.js +11 -13
- package/plugins/goal-guard/tools.js +20 -0
- package/plugins/goal-sidebar.tsx +20 -34
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,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.0
|
|
4
|
+
|
|
5
|
+
### Goal-only subagents
|
|
6
|
+
|
|
7
|
+
- The `goal-*` specialist subagents are now mechanically locked to Goal Mode.
|
|
8
|
+
OpenCode resolves subagents globally, so a Build, Plan, or custom agent could
|
|
9
|
+
previously invoke a Goal reviewer directly. The guard now blocks any `task` call
|
|
10
|
+
targeting a `goal-*` subagent unless it comes from an active Goal session, and a
|
|
11
|
+
poach attempt never turns the calling session into a Goal. General-purpose
|
|
12
|
+
subagents (`explore`/`general`/`scout`) are unaffected. New `restrictSubagents` /
|
|
13
|
+
`GOAL_GUARD_RESTRICT_SUBAGENTS` option (default on) toggles it.
|
|
14
|
+
|
|
15
|
+
### Per-session sidebar isolation (not global)
|
|
16
|
+
|
|
17
|
+
- The TUI Goal todo section is now strictly per-session. Both the live component
|
|
18
|
+
(`goal-sidebar.tsx`) and the Node-testable projection (`sidebar-data.js`) resolve
|
|
19
|
+
state by the exact `props.session_id` and only when that session is an active Goal
|
|
20
|
+
session — the "most-recently-touched active session" global fallback is gone in
|
|
21
|
+
both. A Build (or any non-Goal) session in the same worktree can no longer inherit
|
|
22
|
+
a sibling session's goal.
|
|
23
|
+
- The slot now always registers and decides per-session inside the render (matching
|
|
24
|
+
the canonical OpenCode TUI-plugin pattern) instead of conditionally registering.
|
|
25
|
+
- Added node + headless-visual coverage proving two active goals in one worktree
|
|
26
|
+
each render only their own goal.
|
|
27
|
+
|
|
28
|
+
### Goal-mode-only tools
|
|
29
|
+
|
|
30
|
+
- Every `goal_*` tool is Goal-mode-only: non-Goal/Build sessions get a clear refusal
|
|
31
|
+
instead of any Goal status, evidence map, memory, contract, evidence, or reset.
|
|
32
|
+
|
|
33
|
+
### Documentation & visuals (accuracy pass)
|
|
34
|
+
|
|
35
|
+
- Corrected the sidebar wording from "replaces the native todo area" to "adds a
|
|
36
|
+
Goal-owned todo section" (the slot contributes content; it does not replace native
|
|
37
|
+
todos), and removed the stale "waits to register the slot" description.
|
|
38
|
+
- Regenerated the README hero image (`docs/sidebar-demo.svg`) to match what actually
|
|
39
|
+
renders: a bold `Goal todos` label (no orb), the first-display per-line rainbow,
|
|
40
|
+
the running/done colour states, and the native-todo-stays behavior. The old image
|
|
41
|
+
showed a removed `◆ GOAL` orb and a removed grey "No goal" state.
|
|
42
|
+
- Made the benchmark "remaining misses" description accurate (all are plain `rm`
|
|
43
|
+
without `-r`/`-f`, intentionally permitted) and documented Goal-only subagents and
|
|
44
|
+
`restrictSubagents` in the README config table and ARCHITECTURE hook table.
|
|
45
|
+
|
|
46
|
+
## v0.3.11
|
|
47
|
+
|
|
48
|
+
- Fixed Goal sidebar/status isolation so an explicit Build or other non-Goal
|
|
49
|
+
session never falls back to another active Goal session in the same worktree.
|
|
50
|
+
- Blocked mutating `goal_*` tools from activating Goal Guard state in non-Goal
|
|
51
|
+
sessions; read-only tools remain strictly scoped to the current session.
|
|
52
|
+
- Added regression coverage for mixed Goal/Build persisted snapshots, session-scoped
|
|
53
|
+
status/evidence/memory reads, and Build-mode tool calls.
|
|
54
|
+
|
|
3
55
|
## v0.3.10
|
|
4
56
|
|
|
5
57
|
- Clarified the recommended install command to use a persistent global npm install
|
package/README.md
CHANGED
|
@@ -140,10 +140,13 @@ Reproduce with `npm run bench` or `node benchmarks/external.mjs`.
|
|
|
140
140
|
|
|
141
141
|
Honest caveats, because the point of this rewrite was to stop overclaiming:
|
|
142
142
|
|
|
143
|
-
- The
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
- The 7 remaining "misses" are all plain `rm` invocations without `-r`/`-f`
|
|
144
|
+
(single- or multi-target, a few with `-i`/`-v`/`-d`), which the guard
|
|
145
|
+
**intentionally permits**: bare `rm` is extremely common, so the guard marks it
|
|
146
|
+
dirty but lets the host's own `rm *` permission decide, while still blocking the
|
|
147
|
+
irreversible forms (`rm -r`/`rm -f`, wildcard/root, `$(rm …)`, `bash -c`,
|
|
148
|
+
`/bin/rm`, interpreters, etc.). Under a strict every-`rm`-is-destructive
|
|
149
|
+
labeling those count against it.
|
|
147
150
|
- The single counted false positive (`git filter-repo …`) actually *is* a
|
|
148
151
|
history-rewriting command, so the real-world false-positive rate is effectively
|
|
149
152
|
zero. `node benchmarks/external.mjs --json` lists every miss and false positive
|
|
@@ -182,7 +185,8 @@ second) — negligible for a per-tool-call guard:
|
|
|
182
185
|
discovery, verification planning, and reviews to subagents. **`goal` is the only
|
|
183
186
|
user-selectable agent** — every specialist (security, diff, verifier, …) is a
|
|
184
187
|
`mode: subagent` that the Goal agent invokes via the task tool; the user never
|
|
185
|
-
picks one directly
|
|
188
|
+
picks one directly, and the guard blocks any other agent from invoking them (see
|
|
189
|
+
**Goal-only subagents** below). They surface with friendly names (e.g. "Security Reviewer",
|
|
186
190
|
"API Reviewer") rather than raw ids.
|
|
187
191
|
- Strict review gates for prompt compliance, diff review, verification, security,
|
|
188
192
|
UX, operations, data, API, performance, tests, docs, quality, and final audit.
|
|
@@ -197,6 +201,12 @@ second) — negligible for a per-tool-call guard:
|
|
|
197
201
|
`Goal Not Completed` with the exact missing review gates.
|
|
198
202
|
- **Contextual gating**: the goal text and changed files determine which
|
|
199
203
|
specialist reviewers are required.
|
|
204
|
+
- **Goal-only subagents**: the `goal-*` specialist subagents are mechanically
|
|
205
|
+
locked to Goal Mode. OpenCode resolves subagents globally, so the guard blocks
|
|
206
|
+
any Build, Plan, or custom agent that tries to invoke a `goal-*` reviewer via
|
|
207
|
+
the task tool — they run only under the Goal agent (toggle with
|
|
208
|
+
`restrictSubagents`). General-purpose subagents (`explore`/`general`/`scout`)
|
|
209
|
+
are never restricted.
|
|
200
210
|
- **Reviewer Memory**: blocking reviewer findings are carried across cycles,
|
|
201
211
|
surfaced in status/system context, and marked resolved by fresh PASS verdicts.
|
|
202
212
|
- **Disk persistence**: review ledgers and Reviewer Memory survive OpenCode restarts.
|
|
@@ -208,9 +218,9 @@ second) — negligible for a per-tool-call guard:
|
|
|
208
218
|
reviewer's friendly name, and a single "completion unlocked" toast the moment
|
|
209
219
|
the last required gate clears.
|
|
210
220
|
- An **experimental** companion TUI plugin (`plugins/goal-sidebar.tsx`) that, in
|
|
211
|
-
Goal sessions only,
|
|
212
|
-
|
|
213
|
-
|
|
221
|
+
Goal sessions only, adds a Goal-owned, evidence-aware todo section to the
|
|
222
|
+
sidebar. It shows a brief rainbow effect the first time it appears, then normal
|
|
223
|
+
goal colours. See [TUI integration](#tui-integration).
|
|
214
224
|
- A test suite validating the analyzer, plugin hooks, state store, install
|
|
215
225
|
safety, and config compatibility.
|
|
216
226
|
|
|
@@ -220,26 +230,29 @@ Goal Mode is a **plugin pair**: the server-side `goal-guard` plugin owns
|
|
|
220
230
|
enforcement and writes its state to disk, and an experimental TUI plugin
|
|
221
231
|
(`plugins/goal-sidebar.tsx`) reads that same state to render a live todo section.
|
|
222
232
|
|
|
223
|
-
- **Goal-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
233
|
+
- **Goal-owned todo section.** In a `goal` session, the sidebar gains a Goal-owned
|
|
234
|
+
todo section: short goal title, gate progress, lifecycle status, and structured
|
|
235
|
+
todo rows derived from acceptance criteria, evidence freshness, dirty state, and
|
|
236
|
+
missing review gates. It starts with a brief rainbow foreground effect
|
|
237
|
+
(`sidebarRainbowMs`) so it is immediately visible, then returns to the normal
|
|
238
|
+
lifecycle colours:
|
|
229
239
|
- **yellow** — a goal is set and running;
|
|
230
240
|
- **red** — the goal is done (all required gates pass and the tree is clean);
|
|
231
|
-
- **no render** — Build and every non-Goal mode
|
|
232
|
-
|
|
241
|
+
- **no render** — Build and every non-Goal mode render nothing here, so they
|
|
242
|
+
keep OpenCode's native todo section instead of being classified as a goal. The
|
|
243
|
+
section is scoped to the session that owns the goal: a Build session in the
|
|
244
|
+
same worktree never inherits another session's goal.
|
|
233
245
|
|
|
234
246
|
Toggle/recolour with `sidebarBanner`, `sidebarColor` (running), `sidebarDoneColor`
|
|
235
247
|
(done), `sidebarMutedColor`, `sidebarRainbowMs`, or the `GOAL_GUARD_SIDEBAR_*`
|
|
236
248
|
env vars.
|
|
237
249
|
|
|
238
250
|
**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
|
-
|
|
251
|
+
dir; OpenCode loads them from `tui.json`. The Goal sidebar registers a
|
|
252
|
+
`sidebar_content` slot that renders content **only** for the active session when
|
|
253
|
+
that session is a Goal session; for any other session it renders nothing, so
|
|
254
|
+
non-Goal modes keep their native todo section. With `--global`, the installer
|
|
255
|
+
writes `~/.config/opencode/tui.json` for you (merge-safe):
|
|
243
256
|
|
|
244
257
|
```json
|
|
245
258
|
{ "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
|
|
@@ -299,6 +312,7 @@ Or via environment variables (`GOAL_GUARD_*`):
|
|
|
299
312
|
| `injectSystemState` / `GOAL_GUARD_INJECT_SYSTEM_STATE` | `true` | Inject live state into the prompt. |
|
|
300
313
|
| `persist` / `GOAL_GUARD_PERSIST` | `true` | Persist state under the XDG state dir. |
|
|
301
314
|
| `contextualGates` / `GOAL_GUARD_CONTEXTUAL_GATES` | `true` | Require specialist gates by goal keywords. |
|
|
315
|
+
| `restrictSubagents` / `GOAL_GUARD_RESTRICT_SUBAGENTS` | `true` | Block non-Goal agents from invoking the `goal-*` subagents via the task tool. |
|
|
302
316
|
| `maxSessions` / `GOAL_GUARD_MAX_SESSIONS` | `200` | Session cache size. |
|
|
303
317
|
| `sessionTtlMs` / `GOAL_GUARD_SESSION_TTL_MS` | `86400000` | Idle session TTL. |
|
|
304
318
|
| `toastOnBlock` / `GOAL_GUARD_TOAST_ON_BLOCK` | `true` | Toast when something is blocked. |
|
package/docs/sidebar-demo.svg
CHANGED
|
@@ -1,54 +1,69 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="360" viewBox="0 0 1000 360" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" role="img" aria-label="OpenCode Goal Mode sidebar: a Goal session shows a bold 'Goal todos' section with a first-display rainbow (running), turning red when done, with gate progress and todo rows. 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
|
-
.label { fill: #8b949e; font-size:
|
|
9
|
-
.
|
|
10
|
-
.
|
|
11
|
-
.
|
|
12
|
-
.muted { fill: #808080; font-size: 13px; }
|
|
8
|
+
.label { 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; }
|
|
15
|
+
.nat { fill: #8b949e; font-size: 12px; }
|
|
16
16
|
.div { stroke: #30363d; stroke-width: 1; }
|
|
17
|
+
/* first-display rainbow: the guard colours each sidebar line with a
|
|
18
|
+
successive rainbow hue (RAINBOW[index]) before settling to yellow. */
|
|
19
|
+
.r0 { fill: #FF5555; } .r1 { fill: #FFAA00; } .r2 { fill: #FFFF55; }
|
|
20
|
+
.red { fill: #FF5555; }
|
|
17
21
|
</style>
|
|
18
22
|
</defs>
|
|
19
23
|
|
|
20
24
|
<!-- window -->
|
|
21
|
-
<rect class="win" x="1" y="1" width="
|
|
22
|
-
<rect class="bar" x="1" y="1" width="
|
|
23
|
-
<rect class="bar" x="1" y="20" width="
|
|
25
|
+
<rect class="win" x="1" y="1" width="998" height="358" rx="8"/>
|
|
26
|
+
<rect class="bar" x="1" y="1" width="998" height="30" rx="8"/>
|
|
27
|
+
<rect class="bar" x="1" y="20" width="998" height="11"/>
|
|
24
28
|
<circle class="dot" cx="20" cy="16" r="5" fill="#ff5f56"/>
|
|
25
29
|
<circle class="dot" cx="38" cy="16" r="5" fill="#ffbd2e"/>
|
|
26
30
|
<circle class="dot" cx="56" cy="16" r="5" fill="#27c93f"/>
|
|
27
31
|
<text class="title" x="86" y="20">opencode — goal mode</text>
|
|
28
32
|
|
|
29
33
|
<!-- vertical divider between chat and sidebar -->
|
|
30
|
-
<line class="div" x1="470" y1="31" x2="470" y2="
|
|
34
|
+
<line class="div" x1="470" y1="31" x2="470" y2="359"/>
|
|
31
35
|
|
|
32
36
|
<!-- chat pane (left) -->
|
|
33
|
-
<text class="dim"
|
|
37
|
+
<text class="dim" x="20" y="58">▌ goal</text>
|
|
34
38
|
<text class="chat" x="20" y="82">▸ Goal Contract recorded (4 acceptance criteria)</text>
|
|
35
39
|
<text class="chat" x="20" y="104">▸ implementing… running verification</text>
|
|
36
40
|
<text class="ok" x="20" y="126">✓ Security Reviewer → PASS</text>
|
|
37
41
|
<text class="ok" x="20" y="148">✓ Verifier → PASS</text>
|
|
38
42
|
<text class="dim" x="20" y="170">▸ Diff Reviewer running…</text>
|
|
43
|
+
<text class="dim" x="20" y="206">Build / Plan / custom agents cannot</text>
|
|
44
|
+
<text class="dim" x="20" y="224">invoke the goal-* reviewers — they are</text>
|
|
45
|
+
<text class="dim" x="20" y="242">locked to the Goal agent.</text>
|
|
39
46
|
|
|
40
47
|
<!-- sidebar pane (right) -->
|
|
41
|
-
|
|
42
|
-
<
|
|
48
|
+
<!-- Goal session, RUNNING (first-display rainbow: line 0 red, 1 orange, 2 yellow) -->
|
|
49
|
+
<text class="label" x="490" y="56">GOAL SESSION · running · first-display rainbow</text>
|
|
50
|
+
<line class="div" x1="490" y1="64" x2="980" y2="64"/>
|
|
51
|
+
<text x="490" y="86"><tspan class="lbl r0">Goal todos</tspan><tspan class="ln r0"> Ship the OAuth refactor</tspan></text>
|
|
52
|
+
<text class="sm r1" x="490" y="104">0/5 gates · in progress</text>
|
|
53
|
+
<text class="sm r2" x="490" y="122">□ Clear review gates: goal-prompt-auditor,</text>
|
|
54
|
+
<text class="sm r2" x="490" y="138">goal-reviewer, goal-diff-reviewer…</text>
|
|
55
|
+
<text class="dim" x="490" y="156">↳ then settles to yellow while running</text>
|
|
43
56
|
|
|
44
|
-
<!--
|
|
45
|
-
<text
|
|
46
|
-
<
|
|
47
|
-
<text
|
|
57
|
+
<!-- Goal session, DONE (red) -->
|
|
58
|
+
<text class="label" x="490" y="196">GOAL SESSION · done</text>
|
|
59
|
+
<line class="div" x1="490" y1="204" x2="980" y2="204"/>
|
|
60
|
+
<text x="490" y="226"><tspan class="lbl red">Goal todos</tspan><tspan class="ln red"> Fix the parser bug</tspan></text>
|
|
61
|
+
<text class="sm red" x="490" y="244">5/5 gates · completed · 2 review cycles</text>
|
|
62
|
+
<text class="sm red" x="490" y="262">✓ Record Goal Contract acceptance criteria</text>
|
|
48
63
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<text class="
|
|
53
|
-
<text class="
|
|
64
|
+
<!-- Build / non-Goal: native todos remain (no Goal section, no "No goal") -->
|
|
65
|
+
<text class="label" x="490" y="300">BUILD / OTHER MODE</text>
|
|
66
|
+
<line class="div" x1="490" y1="308" x2="980" y2="308"/>
|
|
67
|
+
<text class="nat" x="490" y="330">▢ OpenCode's native todo section</text>
|
|
68
|
+
<text class="dim" x="490" y="348">(no Goal section is rendered here)</text>
|
|
54
69
|
</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);
|
|
@@ -30,22 +30,20 @@ function normalize(record) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
|
35
38
|
*/
|
|
36
39
|
export function pickSession(snapshot, sessionId) {
|
|
37
|
-
if (!snapshot || !Array.isArray(snapshot.sessions)) return null;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const direct = records.find(([key, st]) => key === sessionId && st.active);
|
|
43
|
-
if (direct) return direct[1];
|
|
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);
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
if (active.length === 0) return null;
|
|
47
|
-
active.sort((a, b) => (b[1].touchedAt || 0) - (a[1].touchedAt || 0));
|
|
48
|
-
return active[0][1];
|
|
46
|
+
return null;
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
/**
|
|
@@ -16,6 +16,7 @@ import { evidenceMapReport, reviewerMemoryReport, statusReport } from "./summary
|
|
|
16
16
|
import { recordEvidence } from "./events.js";
|
|
17
17
|
import { refreshStickyGates } from "./gates.js";
|
|
18
18
|
import { createState } from "./state.js";
|
|
19
|
+
import { isPrimaryAgent } from "./agents.js";
|
|
19
20
|
|
|
20
21
|
const s = tool.schema;
|
|
21
22
|
|
|
@@ -28,6 +29,18 @@ const s = tool.schema;
|
|
|
28
29
|
export function createGoalTools({ store, config, persist }) {
|
|
29
30
|
const save = typeof persist === "function" ? persist : () => {};
|
|
30
31
|
|
|
32
|
+
function requireGoalMode(state) {
|
|
33
|
+
return Boolean(state?.active || isPrimaryAgent(state?.currentAgent));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function goalModeOnlyResult() {
|
|
37
|
+
return {
|
|
38
|
+
title: "Goal Mode required",
|
|
39
|
+
output: "This goal_* tool can only mutate Goal Guard state from an active Goal session. Switch to the `goal` agent or start with /goal.",
|
|
40
|
+
metadata: { blocked: true, reason: "not_goal_mode" },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
return {
|
|
32
45
|
goal_status: tool({
|
|
33
46
|
description:
|
|
@@ -37,6 +50,7 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
37
50
|
args: {},
|
|
38
51
|
async execute(_args, ctx) {
|
|
39
52
|
const state = store.stateFor(ctx.sessionID);
|
|
53
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
40
54
|
const report = statusReport(state, config);
|
|
41
55
|
const goal = report.goal ? `“${report.goal}” — ` : "";
|
|
42
56
|
return {
|
|
@@ -60,6 +74,7 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
60
74
|
args: {},
|
|
61
75
|
async execute(_args, ctx) {
|
|
62
76
|
const state = store.stateFor(ctx.sessionID);
|
|
77
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
63
78
|
const report = evidenceMapReport(state, config);
|
|
64
79
|
const covered = report.criteria.filter((item) => item.status === "covered").length;
|
|
65
80
|
return {
|
|
@@ -77,6 +92,7 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
77
92
|
args: {},
|
|
78
93
|
async execute(_args, ctx) {
|
|
79
94
|
const state = store.stateFor(ctx.sessionID);
|
|
95
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
80
96
|
const report = reviewerMemoryReport(state);
|
|
81
97
|
return {
|
|
82
98
|
title: `Reviewer Memory: ${report.open.length} open findings`,
|
|
@@ -109,6 +125,7 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
109
125
|
},
|
|
110
126
|
async execute(args, ctx) {
|
|
111
127
|
const state = store.stateFor(ctx.sessionID);
|
|
128
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
112
129
|
state.active = true;
|
|
113
130
|
state.contract = {
|
|
114
131
|
title: String(args.title || "").replace(/\s+/g, " ").trim(),
|
|
@@ -145,6 +162,7 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
145
162
|
},
|
|
146
163
|
async execute(args, ctx) {
|
|
147
164
|
const state = store.stateFor(ctx.sessionID);
|
|
165
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
148
166
|
state.active = true;
|
|
149
167
|
recordEvidence(store, state, args.command, args.result, args.criteria);
|
|
150
168
|
save();
|
|
@@ -164,6 +182,8 @@ export function createGoalTools({ store, config, persist }) {
|
|
|
164
182
|
confirm: s.boolean().describe("Must be true to actually reset."),
|
|
165
183
|
},
|
|
166
184
|
async execute(args, ctx) {
|
|
185
|
+
const state = store.stateFor(ctx.sessionID);
|
|
186
|
+
if (!requireGoalMode(state)) return goalModeOnlyResult();
|
|
167
187
|
if (!args.confirm) {
|
|
168
188
|
return { title: "Reset not confirmed", output: "Pass confirm=true to reset Goal Guard state." };
|
|
169
189
|
}
|
package/plugins/goal-sidebar.tsx
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
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 adds a Goal-owned, evidence-aware todo section to
|
|
6
|
+
* the sidebar. Non-Goal sessions render nothing here so Build and other modes
|
|
7
|
+
* keep OpenCode's normal todo section. The section is strictly per-session: it is
|
|
8
|
+
* keyed by props.session_id, so a Build session in the same worktree never
|
|
9
|
+
* inherits another session's goal.
|
|
8
10
|
*
|
|
9
11
|
* How OpenCode loads this: TUI plugins are listed in `~/.config/opencode/tui.json`
|
|
10
12
|
* (NOT the plugins/ dir) and resolved via the package's `exports["./tui"]`. The
|
|
@@ -60,21 +62,21 @@ function readSnapshot(worktree) {
|
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the guard state for EXACTLY this session id, and only when it is an
|
|
67
|
+
* active Goal session. There is deliberately NO "most-recently-touched" global
|
|
68
|
+
* fallback: a Build or other session in the same worktree must never inherit a
|
|
69
|
+
* Goal from a sibling session. (Mirrors the reference OpenCode TUI plugin's
|
|
70
|
+
* explicit per-session rule — do not fall back to the latest state.)
|
|
71
|
+
*/
|
|
64
72
|
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;
|
|
73
|
+
if (!snapshot || !Array.isArray(snapshot.sessions) || !sessionId) return null;
|
|
74
|
+
for (const entry of snapshot.sessions) {
|
|
75
|
+
if (!Array.isArray(entry) || entry.length !== 2) continue;
|
|
76
|
+
const [key, st] = entry;
|
|
77
|
+
if (key === sessionId && st && typeof st === "object" && st.active) return st;
|
|
73
78
|
}
|
|
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];
|
|
79
|
+
return null;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
function readModel(worktree, sessionId) {
|
|
@@ -100,11 +102,7 @@ const tui = async (api, options) => {
|
|
|
100
102
|
|
|
101
103
|
const worktree = api.state?.path?.worktree || api.state?.path?.directory;
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
const register = () => {
|
|
105
|
-
if (registered) return;
|
|
106
|
-
registered = true;
|
|
107
|
-
api.slots.register({
|
|
105
|
+
api.slots.register({
|
|
108
106
|
order: 50,
|
|
109
107
|
slots: {
|
|
110
108
|
sidebar_content(_ctx, props) {
|
|
@@ -143,19 +141,7 @@ const tui = async (api, options) => {
|
|
|
143
141
|
);
|
|
144
142
|
},
|
|
145
143
|
},
|
|
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
|
-
}
|
|
144
|
+
});
|
|
159
145
|
} catch {
|
|
160
146
|
/* TUI runtime missing or API drift — render nothing rather than crash. */
|
|
161
147
|
}
|