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 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 waits until the persisted state contains an
170
- active Goal session, then registers a `sidebar_content` slot via
171
- `api.slots.register({ slots: { sidebar_content } })` and renders the short goal
172
- label, gate/status line, and structured Goal todos derived from acceptance
173
- criteria, evidence freshness, dirty state, and missing gates. It starts with a
174
- brief rainbow foreground effect, then returns to the configured running colour
175
- (`#FFD700` by default). When there is no active Goal session it does not register
176
- the slot, so Build and other modes keep OpenCode's native todo section in place.
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 active session via `summary.sidebarView`. That keeps
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 ~7 remaining "misses" are almost all un-flagged single-target `rm <file>`,
144
- which the guard **intentionally permits** (plain `rm` is common and the guard
145
- blocks `rm -r`/`rm -f`, `$(rm …)`, `bash -c`, interpreters, etc.). Under a
146
- strict every-`rm`-is-destructive labeling those count against it.
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. They surface with friendly names (e.g. "Security Reviewer",
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, replaces the native todo sidebar area with a Goal-owned,
212
- evidence-aware todo section. It shows a brief rainbow effect the first time it
213
- appears, then normal goal colours. See [TUI integration](#tui-integration).
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-mode todo replacement.** In a `goal` session, the sidebar content/todo
224
- area is replaced by a Goal-owned todo section: short goal title, gate progress,
225
- lifecycle status, and structured todo rows derived from acceptance criteria,
226
- evidence freshness, dirty state, and missing review gates. It starts with a
227
- brief rainbow foreground effect (`sidebarRainbowMs`) so the replacement is
228
- visible, then returns to the normal lifecycle colours:
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 keep OpenCode's native todo
232
- section in the same sidebar position instead of being classified as a goal.
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 waits to register its
240
- `sidebar_content` slot until a real Goal session exists, so non-Goal modes do not
241
- get a blank replacement slot. With `--global`, the installer writes
242
- `~/.config/opencode/tui.json` for you (merge-safe):
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. |
@@ -1,54 +1,69 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" role="img" aria-label="OpenCode Goal Mode sidebar banner: the active goal in yellow with a gate-status line, and a grey No goal state.">
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: 12px; }
9
- .goal { fill: #FFD700; font-size: 13px; }
10
- .goalb { fill: #FFD700; font-size: 13px; font-weight: 700; }
11
- .status { fill: #FFD700; font-size: 12px; }
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="758" height="278" rx="8"/>
22
- <rect class="bar" x="1" y="1" width="758" height="30" rx="8"/>
23
- <rect class="bar" x="1" y="20" width="758" height="11"/>
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="279"/>
34
+ <line class="div" x1="470" y1="31" x2="470" y2="359"/>
31
35
 
32
36
  <!-- chat pane (left) -->
33
- <text class="dim" x="20" y="58">▌ goal</text>
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
- <text class="label" x="490" y="58">SESSION</text>
42
- <line class="div" x1="490" y1="68" x2="740" y2="68"/>
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
- <!-- goal banner (active) -->
45
- <text x="490" y="98"><tspan class="goal">◆ </tspan><tspan class="goalb">GOAL</tspan><tspan class="goal"> Ship the OAuth</tspan></text>
46
- <text class="goal" x="490" y="116">refactor</text>
47
- <text class="status" x="490" y="138">3/5 gates · dirty</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
- <line class="div" x1="490" y1="170" x2="740" y2="170"/>
50
-
51
- <!-- no-goal state -->
52
- <text class="label" x="490" y="196">when no goal is set</text>
53
- <text class="muted" x="490" y="222">No goal</text>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.3.10",
3
+ "version": "0.4.0",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "plugins/goal-sidebar.tsx",
@@ -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
- * Choose which session's goal to show: the most-recently-touched ACTIVE session
34
- * (optionally preferring an explicit sessionId when it is present and active).
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 records = snapshot.sessions
39
- .filter((e) => Array.isArray(e) && e.length === 2)
40
- .map(([key, st]) => [key, normalize(st)]);
41
- if (sessionId) {
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
- const active = records.filter(([, st]) => st.active);
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
  }
@@ -2,9 +2,11 @@
2
2
  /**
3
3
  * Goal Mode — TUI sidebar todo section.
4
4
  *
5
- * In Goal agent sessions this replaces the native-looking todo area with a
6
- * Goal-owned, evidence-aware todo section. Non-Goal sessions render nothing here
7
- * so Build and other modes keep OpenCode's normal todo section in the same slot.
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
- /** Most-recently-touched active session, preferring an explicit active sessionId. */
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 records = snapshot.sessions
67
- .filter((e) => Array.isArray(e) && e.length === 2)
68
- .map(([key, st]) => [key, st && typeof st === "object" ? st : {}]);
69
- if (sessionId) {
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
- const active = records.filter(([, st]) => st.active);
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
- let registered = false;
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
  }