opencode-goal-mode 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.2
4
+
5
+ ### The sidebar Goal section now actually renders in the live TUI
6
+
7
+ - **Root-cause fix.** The `sidebar_content` slot bailed at mount with
8
+ `return undefined` whenever no goal existed *yet* — but the goal is normally set
9
+ *after* the sidebar mounts, so the polling component never started and the Goal
10
+ section never appeared. The slot now **always mounts a reactive, polling
11
+ component** and reveals the section (via `<Show>`) the moment the goal is
12
+ recorded. Verified by driving the real OpenCode TUI in a PTY.
13
+ - **Stale plugin cache.** OpenCode caches TUI plugins under
14
+ `~/.cache/opencode/packages/<name>@<spec>/` and never re-checks npm, so upgrades
15
+ kept loading the *old* sidebar build. The installer now clears that cache on
16
+ install and uninstall, so a restart picks up the installed version.
17
+ - The first-display rainbow now triggers when the goal first appears (not at
18
+ mount, when there may be no goal yet).
19
+ - The sidebar resolves state under both the worktree and directory path keys, so a
20
+ path-key mismatch can't hide an active goal.
21
+
22
+ ### Sidebar layout
23
+
24
+ - The gate count and the lifecycle status are now on **separate lines**, each in
25
+ its own colour (GOAL = yellow, title = white, gates = cyan, status = orange).
26
+
27
+ ### Native todos are replaced in goal mode
28
+
29
+ - The `goal` agent no longer uses the native `todowrite` tool (it is disabled in
30
+ Goal Mode). Because OpenCode renders native todos as their own sidebar slot, the
31
+ only way to replace them is to stop producing them — so in a goal session the
32
+ native todo list stays empty and the structured Goal-owned section is what shows.
33
+ Build and every other mode keep their native todos.
34
+
35
+ ## v0.4.1
36
+
37
+ ### Restructured Goal sidebar todo section
38
+
39
+ - The sidebar Goal section is now a proper stacked, multi-colour layout instead of
40
+ one run of text: a bold **`GOAL`** label on its own line (yellow while running,
41
+ red when done), then the goal title, then a `passing/total gates · status` line —
42
+ each line in its own highlight colour (GOAL yellow, title white, status cyan) so
43
+ they never blend together. It opens with a first-display per-line rainbow, then
44
+ settles.
45
+ - Removed the noisy `· changes pending` suffix from the status line; pending work
46
+ now surfaces as a structured todo row instead.
47
+ - Better, more structured todos: one row per acceptance criterion (✓ when fresh
48
+ evidence covers it), a re-verify row when the tree changed, and one row per
49
+ still-missing review gate by friendly name (e.g. "Pass Security Reviewer").
50
+ - In a goal session the section takes over the sidebar `sidebar_content` slot;
51
+ because OpenCode renders the native todo list as that slot's fallback, it
52
+ replaces the native list on builds that use replace/single-winner slot mode and
53
+ sits alongside it otherwise. Non-goal and no-goal sessions render nothing, so
54
+ the native todo list stays in place.
55
+
56
+ ### Build mode is never treated as a goal
57
+
58
+ - Hardened the guard so a Build/Plan/custom session never accumulates goal state.
59
+ `tool.execute.after` bookkeeping (dirty flag, edits, verification, verdicts) now
60
+ runs only for active Goal sessions (and goal-namespace subagent sessions for
61
+ verdict capture). Destructive-command blocking still applies in every mode.
62
+
63
+ ### Installer
64
+
65
+ - `--global` now resolves the home directory via `$HOME` and falls back to the OS
66
+ home dir, so it works in shells/containers where `$HOME` is unset.
67
+ - Verified the full install → idempotent re-run → conflict-protection → uninstall
68
+ lifecycle end-to-end on macOS (Node 24) and in clean Linux containers (Apple
69
+ `container`, Node 20 and Node 24) using the real `npm install -g <tarball>` path.
70
+ - Moved **Install** to the top of the README and documented dry-run/uninstall.
71
+
3
72
  ## v0.4.0
4
73
 
5
74
  ### Goal-only subagents
package/README.md CHANGED
@@ -1,40 +1,38 @@
1
1
  # OpenCode Goal Mode
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/opencode-goal-mode?color=2da44e&label=npm)](https://www.npmjs.com/package/opencode-goal-mode)
4
- [![npm downloads](https://img.shields.io/npm/dm/opencode-goal-mode?color=2da44e)](https://www.npmjs.com/package/opencode-goal-mode)
5
- [![CI](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml)
6
- [![Release](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml)
7
- [![license](https://img.shields.io/npm/l/opencode-goal-mode?color=2da44e)](LICENSE)
8
- [![node](https://img.shields.io/node/v/opencode-goal-mode?color=2da44e)](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 live Goal-owned todo section in the
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** (recommended; needs [Node](https://nodejs.org) 20.11+ and a working [OpenCode](https://opencode.ai) install):
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: it copies the Goal agent,
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 global install keeps the TUI package resolvable on
28
- future OpenCode starts; Goal Mode inherits your existing OpenCode model/provider.
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`. `--dry-run` previews changes;
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
+ [![npm version](https://img.shields.io/npm/v/opencode-goal-mode?color=2da44e&label=npm)](https://www.npmjs.com/package/opencode-goal-mode)
57
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-goal-mode?color=2da44e)](https://www.npmjs.com/package/opencode-goal-mode)
58
+ [![CI](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml)
59
+ [![Release](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml)
60
+ [![license](https://img.shields.io/npm/l/opencode-goal-mode?color=2da44e)](LICENSE)
61
+ [![node](https://img.shields.io/node/v/opencode-goal-mode?color=2da44e)](package.json)
62
+
57
63
  ![OpenCode Goal Mode sidebar todo section](docs/sidebar-demo.svg)
58
64
 
59
- <sub>↑ In Goal mode, the sidebar todo slot becomes a Goal-owned todo section with
60
- a first-display rainbow effect, then normal goal colours. Build and other modes
61
- keep OpenCode's native todo section see [TUI integration](#tui-integration).</sub>
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
 
@@ -218,9 +226,10 @@ second) — negligible for a per-tool-call guard:
218
226
  reviewer's friendly name, and a single "completion unlocked" toast the moment
219
227
  the last required gate clears.
220
228
  - An **experimental** companion TUI plugin (`plugins/goal-sidebar.tsx`) that, in
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).
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).
224
233
  - A test suite validating the analyzer, plugin hooks, state store, install
225
234
  safety, and config compatibility.
226
235
 
@@ -230,18 +239,29 @@ Goal Mode is a **plugin pair**: the server-side `goal-guard` plugin owns
230
239
  enforcement and writes its state to disk, and an experimental TUI plugin
231
240
  (`plugins/goal-sidebar.tsx`) reads that same state to render a live todo section.
232
241
 
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:
239
- - **yellow** a goal is set and running;
240
- - **red** the goal is done (all required gates pass and the tree is clean);
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.
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.
245
265
 
246
266
  Toggle/recolour with `sidebarBanner`, `sidebarColor` (running), `sidebarDoneColor`
247
267
  (done), `sidebarMutedColor`, `sidebarRainbowMs`, or the `GOAL_GUARD_SIDEBAR_*`
@@ -258,12 +278,18 @@ enforcement and writes its state to disk, and an experimental TUI plugin
258
278
  { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
259
279
  ```
260
280
 
261
- Restart OpenCode after install so it picks up the TUI plugin (it resolves the
262
- package and provides the `@opentui/solid` runtime). The Goal todo section appears
263
- in a **Goal session** view (not the home screen and not Build mode). The visual
264
- harness renders it with a headless OpenTUI renderer in
265
- [visual test](tools/visual-test/README.md) (`npm run test:visual`). The
266
- enforcement core is a separate server plugin and works regardless of the sidebar.
281
+ OpenCode installs the referenced package into its own plugin cache
282
+ (`~/.cache/opencode/packages/`) and provides the `@opentui/solid` + `solid-js`
283
+ runtime to it. It does **not** re-check that cache for newer versions, so the
284
+ installer clears the cached copy on install/uninstall that's why an upgrade
285
+ needs only a restart to load the new sidebar. Restart OpenCode after install. The
286
+ Goal todo section appears in a **Goal session** view (not the home screen and not
287
+ Build mode), and because the Goal agent does its own todo tracking (native
288
+ `todowrite` is disabled in Goal Mode), it replaces — rather than sits beside —
289
+ the native todo list while a goal is active. The visual harness renders the
290
+ component headlessly in [visual test](tools/visual-test/README.md)
291
+ (`npm run test:visual`); the enforcement core is a separate server plugin and
292
+ works regardless of the sidebar.
267
293
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
268
294
  (`toastOnReview`), and blocked destructive commands / premature completions
269
295
  toast as before (`toastOnBlock`).
package/agents/goal.md CHANGED
@@ -28,7 +28,7 @@ permission:
28
28
  "/projects/**": allow
29
29
  "~/.config/opencode/**": allow
30
30
  "~/.local/share/opencode/tool-output/**": allow
31
- todowrite: allow
31
+ todowrite: deny
32
32
  question: allow
33
33
  webfetch: allow
34
34
  websearch: allow
@@ -103,7 +103,7 @@ Operating loop:
103
103
  1. Establish the Goal Contract, constraints, current state, and acceptance criteria.
104
104
  2. If essential information is missing, ask all necessary clarifying questions immediately at the beginning. Do not defer avoidable questions into the build phase.
105
105
  3. Delegate research and discovery before editing. Use subagents to inspect local files, map structures, trace code paths, research docs, identify verification commands, and gather external web evidence.
106
- 4. Create and maintain a todo list for any non-trivial goal. Keep exactly one active item while working.
106
+ 4. Track progress through the Goal Contract acceptance criteria and the guard's evidence/gate state, not the native todo tool. Goal Mode owns the sidebar todo section: it derives a live, structured todo list from the acceptance criteria (checked off as you record evidence), dirty state, and outstanding review gates. Do not use `todowrite` (it is disabled in Goal Mode so the native todo list never competes with the Goal-owned section); call `goal_status`/`goal_evidence_map` when you need the current checklist.
107
107
  5. Implement the goal yourself in the main agent unless a bounded implementation subtask is explicitly safer to delegate.
108
108
  6. Run or delegate relevant checks, tests, builds, linters, typechecks, previews, or manual verification planning.
109
109
  7. When you believe the goal is finished, immediately run a strict review cycle before telling the user. The review must compare the original prompt and Goal Contract against the actual result.
@@ -1,28 +1,31 @@
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.">
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
- .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; }
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; }
12
12
  .chat { fill: #c9d1d9; font-size: 12px; }
13
13
  .dim { fill: #6e7681; font-size: 12px; }
14
14
  .ok { fill: #2da44e; font-size: 12px; }
15
- .nat { fill: #8b949e; font-size: 12px; }
16
15
  .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; }
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 */
21
25
  </style>
22
26
  </defs>
23
27
 
24
- <!-- window -->
25
- <rect class="win" x="1" y="1" width="998" height="358" rx="8"/>
28
+ <rect class="win" x="1" y="1" width="998" height="450" rx="8"/>
26
29
  <rect class="bar" x="1" y="1" width="998" height="30" rx="8"/>
27
30
  <rect class="bar" x="1" y="20" width="998" height="11"/>
28
31
  <circle class="dot" cx="20" cy="16" r="5" fill="#ff5f56"/>
@@ -30,8 +33,7 @@
30
33
  <circle class="dot" cx="56" cy="16" r="5" fill="#27c93f"/>
31
34
  <text class="title" x="86" y="20">opencode — goal mode</text>
32
35
 
33
- <!-- vertical divider between chat and sidebar -->
34
- <line class="div" x1="470" y1="31" x2="470" y2="359"/>
36
+ <line class="div" x1="470" y1="31" x2="470" y2="451"/>
35
37
 
36
38
  <!-- chat pane (left) -->
37
39
  <text class="dim" x="20" y="58">▌ goal</text>
@@ -40,30 +42,37 @@
40
42
  <text class="ok" x="20" y="126">✓ Security Reviewer → PASS</text>
41
43
  <text class="ok" x="20" y="148">✓ Verifier → PASS</text>
42
44
  <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>
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>
46
48
 
47
- <!-- sidebar pane (right) -->
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>
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>
56
56
 
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>
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>
63
64
 
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>
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>
72
+
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>
69
78
  </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "plugins/goal-sidebar.tsx",
@@ -178,6 +178,13 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
178
178
  async "tool.execute.after"(inp, out) {
179
179
  try {
180
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;
181
188
  const tool = inp?.tool;
182
189
  const isReviewing = isReviewAgent(state.currentAgent);
183
190
 
@@ -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, 5)) {
42
- const text = String(criterion || "").replace(/\s+/g, " ").trim();
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: "Rerun verification and reviews after latest changes" });
50
- if (missing.length > 0) items.push({ status: "todo", text: `Clear review gates: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? "…" : ""}` });
51
- if (items.length === 0 && required.length > 0) items.push({ status: "todo", text: "Record Goal Contract acceptance criteria" });
52
- return items.slice(0, 7);
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 three lines that stack vertically in the sidebar:
58
- * - `goal` → line 1: the short AI goal title.
59
- * - `gates` → line 2: the gate count, e.g. "0/7 gates".
60
- * - `status` → line 3: the lifecycle status, e.g. "in progress · changes pending"
61
- * or "completed · 2 review cycles".
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 modes keep the native todo section.
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: "Goal todos",
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: `in progress${state.dirty ? " · changes pending" : ""}`,
94
- todoTitle: "Goal todos",
118
+ status: "in progress",
119
+ todoTitle: GOAL_LABEL,
95
120
  todos,
96
121
  passing,
97
122
  required: required.length,
@@ -2,11 +2,14 @@
2
2
  /**
3
3
  * Goal Mode — TUI sidebar todo section.
4
4
  *
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.
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.
10
13
  *
11
14
  * How OpenCode loads this: TUI plugins are listed in `~/.config/opencode/tui.json`
12
15
  * (NOT the plugins/ dir) and resolved via the package's `exports["./tui"]`. The
@@ -20,13 +23,17 @@
20
23
  * the Node test suite.
21
24
  */
22
25
 
23
- import { createSignal, onCleanup, For, Show } from "solid-js";
26
+ import { createSignal, createEffect, onCleanup, For, Show } from "solid-js";
24
27
  import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
25
28
  import { DEFAULT_CONFIG } from "./goal-guard/config.js";
26
29
 
27
- const DEFAULT_COLOR = "#FFD700"; // running — yellow
30
+ const DEFAULT_COLOR = "#FFD700"; // running — GOAL label, yellow
28
31
  const DEFAULT_DONE = "#FF5555"; // done — red
29
- const DEFAULT_MUTED = "#808080"; // no goal — grey
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 line (running) — cyan accent
35
+ const STATUS_COLOR = "#FFB86C"; // status line (running) — orange, distinct from the cyan gates line
36
+ const TODO_DONE_COLOR = "#50FA7B"; // ✓ done todo rows — green
30
37
  const POLL_MS = 1500;
31
38
  const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
32
39
 
@@ -79,16 +86,27 @@ function pickSession(snapshot, sessionId) {
79
86
  return null;
80
87
  }
81
88
 
82
- function readModel(worktree, sessionId) {
83
- try {
84
- const snapshot = readSnapshot(worktree);
85
- if (!snapshot) return NO_GOAL;
86
- const record = pickSession(snapshot, sessionId);
87
- if (!record) return NO_GOAL;
88
- return sidebarView(record, DEFAULT_CONFIG);
89
- } catch {
90
- return NO_GOAL;
89
+ /**
90
+ * Resolve the sidebar model for a session, trying each candidate worktree key in
91
+ * turn. The guard persists keyed by `worktree || directory`; the TUI may surface
92
+ * either path, so we try both (worktree first) rather than risk a key mismatch
93
+ * that would hide an active goal and leave the native todos showing.
94
+ */
95
+ function readModel(worktrees, sessionId) {
96
+ const keys = (Array.isArray(worktrees) ? worktrees : [worktrees]).filter(Boolean);
97
+ for (const wt of keys) {
98
+ try {
99
+ const snapshot = readSnapshot(wt);
100
+ if (!snapshot) continue;
101
+ const record = pickSession(snapshot, sessionId);
102
+ if (!record) continue;
103
+ const view = sidebarView(record, DEFAULT_CONFIG);
104
+ if (view && view.state !== "none") return view;
105
+ } catch {
106
+ /* try the next candidate */
107
+ }
91
108
  }
109
+ return NO_GOAL;
92
110
  }
93
111
 
94
112
  const id = "goal-mode-sidebar";
@@ -96,11 +114,13 @@ const id = "goal-mode-sidebar";
96
114
  /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
97
115
  const tui = async (api, options) => {
98
116
  try {
99
- const { enabled, color, doneColor, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
117
+ const { enabled, color, doneColor, muted, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
100
118
  if (!enabled) return;
101
119
  if (!api?.slots?.register) return; // runtime without the slot API → no-op.
102
120
 
103
- const worktree = api.state?.path?.worktree || api.state?.path?.directory;
121
+ // The guard keys persisted state by worktree (falling back to directory).
122
+ // Surface both so a path-key mismatch can't hide an active goal.
123
+ const worktrees = [api.state?.path?.worktree, api.state?.path?.directory];
104
124
 
105
125
  api.slots.register({
106
126
  order: 50,
@@ -109,32 +129,69 @@ const tui = async (api, options) => {
109
129
  if (!props?.session_id) return undefined;
110
130
  const read = () => {
111
131
  try {
112
- return readModel(worktree, props?.session_id) || NO_GOAL;
132
+ return readModel(worktrees, props?.session_id) || NO_GOAL;
113
133
  } catch {
114
134
  return NO_GOAL;
115
135
  }
116
136
  };
117
- const initial = read();
118
- if (initial.state === "none") return undefined;
119
- const [model, setModel] = createSignal(initial);
120
- const [rainbow, setRainbow] = createSignal((rainbowMs || 0) > 0);
137
+ // ALWAYS mount a reactive, polling component — do NOT bail when there is
138
+ // no goal yet. The goal is normally recorded AFTER the sidebar mounts
139
+ // (the user opens the session, then states the goal), so the slot must
140
+ // keep polling and let <Show> reveal the section when the goal appears.
141
+ // Returning undefined at mount (the old behavior) meant the poll never
142
+ // ran and the Goal section never showed even once a goal existed.
143
+ const first = read();
144
+ const [model, setModel] = createSignal(first);
121
145
  const timer = setInterval(() => setModel(read()), POLL_MS);
122
- const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
123
146
  onCleanup(() => clearInterval(timer));
147
+ // First-display rainbow: starts the moment a goal FIRST appears. If a goal
148
+ // is already present at mount it starts immediately; otherwise the effect
149
+ // fires when the goal later appears (the common case — the goal is set
150
+ // after the sidebar mounts). Either way it settles after rainbowMs.
151
+ const [rainbow, setRainbow] = createSignal(false);
152
+ let rainbowStarted = false;
153
+ let rainbowTimer;
154
+ const startRainbow = () => {
155
+ if (rainbowStarted || (rainbowMs || 0) <= 0) return;
156
+ rainbowStarted = true;
157
+ setRainbow(true);
158
+ rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs));
159
+ };
160
+ if (first.state !== "none") startRainbow();
161
+ createEffect(() => {
162
+ if (model().state !== "none") startRainbow();
163
+ });
124
164
  onCleanup(() => clearTimeout(rainbowTimer));
125
- const fg = () => (model().state === "done" ? doneColor : color);
126
- const lineColor = (index = 0) => (rainbow() && model().state === "running" ? RAINBOW[index % RAINBOW.length] : fg());
127
- // Goal sessions render a Goal-owned todo section; non-Goal sessions return undefined so native todos remain.
165
+ const isRainbow = () => rainbow() && model().state === "running";
166
+ // Settled (post-rainbow) colour for each header line. When done, every
167
+ // line is red; while running each line gets its OWN highlight colour so
168
+ // the GOAL label, the goal title, and the status never read as one text.
169
+ const settled = (kind) => {
170
+ if (model().state === "done") return doneColor;
171
+ if (kind === "label") return color; // GOAL — yellow
172
+ if (kind === "title") return TITLE_COLOR; // goal title — bright white
173
+ if (kind === "gates") return META_COLOR; // gate count — cyan
174
+ return STATUS_COLOR; // lifecycle status — orange
175
+ };
176
+ const lineColor = (index, kind) => (isRainbow() ? RAINBOW[index % RAINBOW.length] : settled(kind));
177
+ const todoColor = (index, item) => {
178
+ if (isRainbow()) return RAINBOW[index % RAINBOW.length];
179
+ if (item.status === "done") return TODO_DONE_COLOR;
180
+ return model().state === "done" ? doneColor : muted;
181
+ };
182
+ // Goal sessions render a Goal-owned todo section — GOAL label, goal title,
183
+ // gate count, lifecycle status, then structured todos — EACH on its own
184
+ // line in its own colour. Non-Goal / no-goal sessions returned undefined
185
+ // above, so the native todo section shows instead.
128
186
  return (
129
187
  <Show when={model().state !== "none"}>
130
188
  <box flexDirection="column" paddingTop={1}>
131
- <text fg={lineColor(0)}>
132
- <b>{model().todoTitle || "Goal todos"}</b>
133
- {` ${model().goal}`}
134
- </text>
135
- <text fg={lineColor(1)}>{`${model().gates} · ${model().status}`}</text>
189
+ <text fg={lineColor(0, "label")}><b>{model().label || "GOAL"}</b></text>
190
+ <text fg={lineColor(1, "title")}>{model().goal}</text>
191
+ <text fg={lineColor(2, "gates")}>{model().gates}</text>
192
+ <text fg={lineColor(3, "status")}>{model().status}</text>
136
193
  <For each={model().todos || []}>
137
- {(item, index) => <text fg={lineColor(index() + 2)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
194
+ {(item, index) => <text fg={todoColor(index() + 4, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
138
195
  </For>
139
196
  </box>
140
197
  </Show>
@@ -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
- const home = process.env.HOME;
71
- if (!home) throw new Error("Cannot resolve HOME for --global install");
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");
@@ -172,6 +183,35 @@ function ensureTuiPlugin(remove = false) {
172
183
  return true;
173
184
  }
174
185
 
186
+ /**
187
+ * OpenCode caches TUI plugins under `~/.cache/opencode/packages/<name>@<spec>/`
188
+ * and does NOT re-check npm for a newer version, so after an upgrade it keeps
189
+ * loading the OLD sidebar build. Removing our cache entries forces OpenCode to
190
+ * re-fetch the just-installed version on its next start. Returns the dirs cleared.
191
+ */
192
+ function refreshTuiPluginCache() {
193
+ const base = (process.env.XDG_CACHE_HOME && process.env.XDG_CACHE_HOME.trim()) || join(homedir() || "", ".cache");
194
+ const pkgRoot = join(base, "opencode", "packages");
195
+ if (!existsSync(pkgRoot)) return [];
196
+ let entries;
197
+ try {
198
+ entries = readdirSync(pkgRoot);
199
+ } catch {
200
+ return [];
201
+ }
202
+ const ours = entries
203
+ .filter((name) => name === pkg.name || name.startsWith(`${pkg.name}@`))
204
+ .map((name) => join(pkgRoot, name));
205
+ for (const dir of ours) {
206
+ try {
207
+ if (!values["dry-run"]) rmSync(dir, { recursive: true, force: true });
208
+ } catch {
209
+ /* best-effort */
210
+ }
211
+ }
212
+ return ours;
213
+ }
214
+
175
215
  // ---------------------------------------------------------------------------
176
216
  // Uninstall
177
217
  // ---------------------------------------------------------------------------
@@ -194,6 +234,8 @@ if (values.uninstall) {
194
234
  const tuiRemoved = ensureTuiPlugin(true);
195
235
  if (!values["dry-run"]) pruneEmptyDirs(target, Object.keys(manifest.files));
196
236
  if (tuiRemoved) console.log(`${values["dry-run"] ? "Would remove" : "Removed"} the sidebar entry from ${join(target, "tui.json")}`);
237
+ const cachedCleared = refreshTuiPluginCache();
238
+ if (cachedCleared.length) console.log(`${values["dry-run"] ? "Would clear" : "Cleared"} OpenCode's cached TUI plugin (${cachedCleared.length}).`);
197
239
  const verb = values["dry-run"] ? "Would remove" : "Removed";
198
240
  console.log(`${verb} ${removed.length} Goal Mode files from ${target}.`);
199
241
  if (kept.length) {
@@ -291,4 +333,8 @@ console.log(
291
333
  `Files copied: ${summary.copied.length}; unchanged: ${summary.unchanged.length}; pruned: ${summary.pruned.length}`,
292
334
  );
293
335
  if (tuiAdded) console.log(`Registered the experimental sidebar in ${join(target, "tui.json")}`);
336
+ const cacheCleared = refreshTuiPluginCache();
337
+ if (cacheCleared.length) {
338
+ console.log(`${values["dry-run"] ? "Would clear" : "Cleared"} OpenCode's stale TUI plugin cache so the sidebar reloads at the installed version.`);
339
+ }
294
340
  console.log("Restart OpenCode for agents, commands, and plugins to load.");