opencode-goal-mode 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.1
4
+
5
+ ### Restructured Goal sidebar todo section
6
+
7
+ - The sidebar Goal section is now a proper stacked, multi-colour layout instead of
8
+ one run of text: a bold **`GOAL`** label on its own line (yellow while running,
9
+ red when done), then the goal title, then a `passing/total gates · status` line —
10
+ each line in its own highlight colour (GOAL yellow, title white, status cyan) so
11
+ they never blend together. It opens with a first-display per-line rainbow, then
12
+ settles.
13
+ - Removed the noisy `· changes pending` suffix from the status line; pending work
14
+ now surfaces as a structured todo row instead.
15
+ - Better, more structured todos: one row per acceptance criterion (✓ when fresh
16
+ evidence covers it), a re-verify row when the tree changed, and one row per
17
+ still-missing review gate by friendly name (e.g. "Pass Security Reviewer").
18
+ - In a goal session the section takes over the sidebar `sidebar_content` slot;
19
+ because OpenCode renders the native todo list as that slot's fallback, it
20
+ replaces the native list on builds that use replace/single-winner slot mode and
21
+ sits alongside it otherwise. Non-goal and no-goal sessions render nothing, so
22
+ the native todo list stays in place.
23
+
24
+ ### Build mode is never treated as a goal
25
+
26
+ - Hardened the guard so a Build/Plan/custom session never accumulates goal state.
27
+ `tool.execute.after` bookkeeping (dirty flag, edits, verification, verdicts) now
28
+ runs only for active Goal sessions (and goal-namespace subagent sessions for
29
+ verdict capture). Destructive-command blocking still applies in every mode.
30
+
31
+ ### Installer
32
+
33
+ - `--global` now resolves the home directory via `$HOME` and falls back to the OS
34
+ home dir, so it works in shells/containers where `$HOME` is unset.
35
+ - Verified the full install → idempotent re-run → conflict-protection → uninstall
36
+ lifecycle end-to-end on macOS (Node 24) and in clean Linux containers (Apple
37
+ `container`, Node 20 and Node 24) using the real `npm install -g <tarball>` path.
38
+ - Moved **Install** to the top of the README and documented dry-run/uninstall.
39
+
3
40
  ## v0.4.0
4
41
 
5
42
  ### 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_*`
@@ -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.1",
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
@@ -24,9 +27,12 @@ import { createSignal, 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 · status line (running) — cyan accent
35
+ const TODO_DONE_COLOR = "#50FA7B"; // ✓ done todo rows — green
30
36
  const POLL_MS = 1500;
31
37
  const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
32
38
 
@@ -96,7 +102,7 @@ const id = "goal-mode-sidebar";
96
102
  /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
97
103
  const tui = async (api, options) => {
98
104
  try {
99
- const { enabled, color, doneColor, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
105
+ const { enabled, color, doneColor, muted, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
100
106
  if (!enabled) return;
101
107
  if (!api?.slots?.register) return; // runtime without the slot API → no-op.
102
108
 
@@ -122,19 +128,33 @@ const tui = async (api, options) => {
122
128
  const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
123
129
  onCleanup(() => clearInterval(timer));
124
130
  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.
131
+ const isRainbow = () => rainbow() && model().state === "running";
132
+ // Settled (post-rainbow) colour for each header line. When done, every
133
+ // line is red; while running each line gets its OWN highlight colour so
134
+ // the GOAL label, the goal title, and the status never read as one text.
135
+ const settled = (kind) => {
136
+ if (model().state === "done") return doneColor;
137
+ if (kind === "label") return color; // GOAL — yellow
138
+ if (kind === "title") return TITLE_COLOR; // goal title — bright white
139
+ return META_COLOR; // gates · status — cyan
140
+ };
141
+ const lineColor = (index, kind) => (isRainbow() ? RAINBOW[index % RAINBOW.length] : settled(kind));
142
+ const todoColor = (index, item) => {
143
+ if (isRainbow()) return RAINBOW[index % RAINBOW.length];
144
+ if (item.status === "done") return TODO_DONE_COLOR;
145
+ return model().state === "done" ? doneColor : muted;
146
+ };
147
+ // Goal sessions render a Goal-owned todo section (GOAL label, then the goal
148
+ // title, status, and structured todos — each on its own line). Non-Goal /
149
+ // no-goal sessions returned undefined above, so native todos remain.
128
150
  return (
129
151
  <Show when={model().state !== "none"}>
130
152
  <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>
153
+ <text fg={lineColor(0, "label")}><b>{model().label || "GOAL"}</b></text>
154
+ <text fg={lineColor(1, "title")}>{model().goal}</text>
155
+ <text fg={lineColor(2, "meta")}>{`${model().gates} · ${model().status}`}</text>
136
156
  <For each={model().todos || []}>
137
- {(item, index) => <text fg={lineColor(index() + 2)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
157
+ {(item, index) => <text fg={todoColor(index() + 3, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
138
158
  </For>
139
159
  </box>
140
160
  </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");