opencode-goal-mode 0.3.7 → 0.3.8

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
@@ -15,9 +15,9 @@ configuration directory:
15
15
  — a runtime guard that enforces review discipline, blocks destructive shell
16
16
  commands, preserves state across compaction and restarts, and exposes
17
17
  first-class `goal_*` tools.
18
- 4. **An experimental TUI companion** (`plugins/goal-sidebar.js`) — a separate
19
- `{ tui }` plugin module that renders the active goal as a yellow sidebar
20
- banner. It is *paired* with the server plugin purely through the on-disk state
18
+ 4. **An experimental TUI companion** (`plugins/goal-sidebar.tsx`) — a separate
19
+ `{ tui }` plugin module that renders Goal sessions as a Goal-owned sidebar
20
+ todo section. It is *paired* with the server plugin purely through the on-disk state
21
21
  snapshot (no extra IPC) and no-ops on any runtime without the slot API.
22
22
 
23
23
  This document focuses on the plugin, where the engineering lives.
@@ -54,7 +54,7 @@ as plugins. Each module is independently unit-tested.
54
54
  | `goal-guard/system.js` | Live state block injected into the system prompt. |
55
55
  | `goal-guard/summary.js` | Status/evidence projections, the short goal label, and the sidebar view. |
56
56
  | `goal-guard/tools.js` | The `goal_status` / `goal_evidence_map` / `goal_reviewer_memory` / `goal_contract` / `goal_evidence` / `goal_reset` tools. |
57
- | `goal-guard/sidebar-data.js` | Pure reader that projects the persisted snapshot into the sidebar banner model. |
57
+ | `goal-guard/sidebar-data.js` | Pure reader that projects the persisted snapshot into the sidebar todo model. |
58
58
  | `goal-guard/logger.js` | Best-effort logging/toasts over the OpenCode client. |
59
59
 
60
60
  ## Hooks used
@@ -165,14 +165,15 @@ hooks still load.
165
165
 
166
166
  ## TUI companion (experimental)
167
167
 
168
- `plugins/goal-sidebar.js` is a TUI plugin module — `export const tui = async (api)
169
- => …` — distinct from the server plugin (`@opencode-ai/plugin` types it as a
170
- `{ tui }` module, mutually exclusive with `{ server }`). It registers a
171
- `sidebar_content` slot via `api.slots.register({ slots: { sidebar_content } })`
172
- and renders, in the configured colour (`#FFD700` by default), the short goal
173
- label plus a `passing/total gates · dirty/ready` line. It renders
174
- unconditionally: when a task is running with no goal set, it shows a muted grey
175
- `No goal` (`sidebarView` returns `{ hasGoal: false }`) rather than a blank slot.
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.
176
177
 
177
178
  It is *paired* with the server plugin only through the persisted state file:
178
179
  `sidebar-data.js` recomputes the same `stateBaseDir`/`projectKey` path the guard
@@ -187,7 +188,7 @@ progress is visible even without the banner.
187
188
  The JSX renderer is verified headlessly with `@opentui/solid`'s `testRender` in
188
189
  `tools/visual-test/sidebar-visual.jsx` (`npm run test:visual`, needs Bun + the
189
190
  OpenTUI stack): it asserts the rendered text, the exact foreground colours, and
190
- the bold attribute for goal / "No goal" / ready states. That tool is excluded from
191
+ the bold attribute for Goal todo / done / native-todo-preserved states. That tool is excluded from
191
192
  the npm package and from `node --test`/CI.
192
193
 
193
194
  ## Configuration
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Installer docs and `--help` now put the one-command `npx opencode-goal-mode --global`
6
+ flow first, clarify global vs project targets, and document the merge-safe
7
+ `tui.json` registration/uninstall behavior.
8
+ - The TUI companion now renders a Goal-owned, structured todo section only for
9
+ active Goal sessions, with a first-display rainbow effect before returning to
10
+ normal lifecycle colours. Non-Goal modes render nothing from the Goal plugin so
11
+ OpenCode's native todo section remains in place.
12
+ - Destructive-command blocking no longer activates Goal enforcement for Build or
13
+ other non-Goal sessions, preventing non-Goal tasks from being classified as goals.
14
+
3
15
  ## v0.3.7
4
16
 
5
17
  - **FIX: the sidebar now actually loads.** OpenCode loads a TUI plugin via the
package/README.md CHANGED
@@ -7,23 +7,24 @@
7
7
  [![license](https://img.shields.io/npm/l/opencode-goal-mode?color=2da44e)](LICENSE)
8
8
  [![node](https://img.shields.io/node/v/opencode-goal-mode?color=2da44e)](package.json)
9
9
 
10
- Strict Goal Mode for OpenCode: a primary `goal` agent, a matrix of specialized
11
- review subagents, slash commands, a `goal-guard` plugin that enforces review
12
- discipline and blocks destructive shell commands, and a live goal banner in the
10
+ Strict Goal Mode for OpenCode: a primary `goal` agent, specialized review
11
+ 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
13
  TUI sidebar.
14
14
 
15
15
  ## Install
16
16
 
17
- **One command** (needs [Node](https://nodejs.org) 20.11+ and [OpenCode](https://opencode.ai)):
17
+ **One command** (recommended; needs [Node](https://nodejs.org) 20.11+ and a working [OpenCode](https://opencode.ai) install):
18
18
 
19
19
  ```bash
20
20
  npx opencode-goal-mode --global
21
21
  ```
22
22
 
23
- Then **restart OpenCode**. That's the whole install it copies the Goal agent,
24
- review subagents, slash commands, and the guard plugin into `~/.config/opencode`,
25
- and registers the sidebar in `~/.config/opencode/tui.json`. In the agent picker
26
- you'll see only the **`goal`** agent (the reviewers are subagents it drives).
23
+ Then **restart OpenCode**. That's the whole install: it copies the Goal agent,
24
+ review subagents, slash commands, and guard plugin into `~/.config/opencode`, and
25
+ merge-safely registers the Goal todo sidebar in `~/.config/opencode/tui.json`.
26
+ In the agent picker you'll see only the **`goal`** agent; reviewers are subagents
27
+ it drives automatically. Goal Mode inherits your existing OpenCode model/provider.
27
28
 
28
29
  <details>
29
30
  <summary>Other ways to install</summary>
@@ -33,7 +34,7 @@ you'll see only the **`goal`** agent (the reviewers are subagents it drives).
33
34
  npm install -g opencode-goal-mode
34
35
  opencode-goal-mode --global # alias of opencode-goal-mode-install
35
36
 
36
- # Into a single project (writes ./.opencode + ./tui.json)
37
+ # Into a single project (writes ./.opencode, including ./.opencode/tui.json)
37
38
  npx opencode-goal-mode
38
39
 
39
40
  # From source
@@ -41,14 +42,18 @@ git clone https://github.com/devinoldenburg/opencode-goal-mode
41
42
  cd opencode-goal-mode && npm ci && npm run install:global
42
43
  ```
43
44
 
44
- `--dry-run` previews changes; `--uninstall` removes only what it installed (and its
45
- tui.json entry), leaving your edits untouched. See [Installer options](#installer-options).
45
+ Use global install for normal daily use. Use project install only when you want
46
+ Goal Mode scoped to one repo and your OpenCode build reads project `.opencode`
47
+ config, including `.opencode/tui.json`. `--dry-run` previews changes;
48
+ `--uninstall` removes only what it installed (and its `tui.json` entry), leaving
49
+ your edits untouched. See [Installer options](#installer-options).
46
50
  </details>
47
51
 
48
- ![OpenCode Goal Mode sidebar banner](docs/sidebar-demo.svg)
52
+ ![OpenCode Goal Mode sidebar todo section](docs/sidebar-demo.svg)
49
53
 
50
- <sub>↑ The sidebar goal banner: yellow while a goal runs, red when done, grey "No
51
- goal available" otherwise see [TUI integration](#tui-integration).</sub>
54
+ <sub>↑ In Goal mode, the sidebar todo slot becomes a Goal-owned todo section with
55
+ a first-display rainbow effect, then normal goal colours. Build and other modes
56
+ keep OpenCode's native todo section — see [TUI integration](#tui-integration).</sub>
52
57
 
53
58
  **[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)**
54
59
 
@@ -74,8 +79,8 @@ opencode agent list | grep goal
74
79
  **cannot** answer `Goal Completed` until every required review gate passes — the
75
80
  guard rewrites a premature claim to `Goal Not Completed`. Try a destructive
76
81
  command mid-session (e.g. `rm -rf build`) and watch it get blocked. If your
77
- OpenCode build supports TUI plugins, the active goal also appears in the sidebar
78
- in yellow (experimental — see [TUI integration](#tui-integration)).
82
+ OpenCode build supports TUI plugins, Goal sessions also get the Goal-owned
83
+ sidebar todo section (experimental — see [TUI integration](#tui-integration)).
79
84
 
80
85
  That's it. Everything below is detail.
81
86
 
@@ -159,7 +164,12 @@ second) — negligible for a per-tool-call guard:
159
164
  ## Requirements
160
165
 
161
166
  - Node.js 20.11 or newer.
162
- - OpenCode configured to load local agents, commands, and plugins.
167
+ - OpenCode configured to load local agents, commands, and plugins. The package is
168
+ tested against `@opencode-ai/plugin` 1.17.6 and declares compatibility with the
169
+ 1.15+ plugin hook surface used here; newer OpenCode builds that change plugin
170
+ or TUI slot APIs may need a package update.
171
+ - A working OpenCode provider/model; Goal Mode does not configure API keys or
172
+ choose a model for you.
163
173
 
164
174
  ## What it adds
165
175
 
@@ -192,9 +202,10 @@ second) — negligible for a per-tool-call guard:
192
202
  - **TUI toasts**: a toast on each review verdict (PASS/FAIL), with the
193
203
  reviewer's friendly name, and a single "completion unlocked" toast the moment
194
204
  the last required gate clears.
195
- - An **experimental** companion TUI plugin (`plugins/goal-sidebar.js`) that shows
196
- the active goal as a shining-yellow banner in the sidebar with a compact gate
197
- status line. See [TUI integration](#tui-integration).
205
+ - An **experimental** companion TUI plugin (`plugins/goal-sidebar.tsx`) that, in
206
+ Goal sessions only, replaces the native todo sidebar area with a Goal-owned,
207
+ evidence-aware todo section. It shows a brief rainbow effect the first time it
208
+ appears, then normal goal colours. See [TUI integration](#tui-integration).
198
209
  - A test suite validating the analyzer, plugin hooks, state store, install
199
210
  safety, and config compatibility.
200
211
 
@@ -202,32 +213,38 @@ second) — negligible for a per-tool-call guard:
202
213
 
203
214
  Goal Mode is a **plugin pair**: the server-side `goal-guard` plugin owns
204
215
  enforcement and writes its state to disk, and an experimental TUI plugin
205
- (`plugins/goal-sidebar.js`) reads that same state to render a live banner.
206
-
207
- - **Sidebar goal banner.** In the sidebar's content area, under the session
208
- title/context, it shows the current goal with generated status text, colour-coded
209
- by lifecycle:
210
- - **yellow** a goal is set and running (`◆ GOAL …` + `in progress · N/M gates`);
211
- - **red** the goal is done (all required gates pass, tree clean: `✓ GOAL …` +
212
- `completed · N/M gates passed · K review cycles`);
213
- - **grey** — a task is running with no goal set (`No goal available`).
216
+ (`plugins/goal-sidebar.tsx`) reads that same state to render a live todo section.
217
+
218
+ - **Goal-mode todo replacement.** In a `goal` session, the sidebar content/todo
219
+ area is replaced by a Goal-owned todo section: short goal title, gate progress,
220
+ lifecycle status, and structured todo rows derived from acceptance criteria,
221
+ evidence freshness, dirty state, and missing review gates. It starts with a
222
+ brief rainbow foreground effect (`sidebarRainbowMs`) so the replacement is
223
+ visible, then returns to the normal lifecycle colours:
224
+ - **yellow** — a goal is set and running;
225
+ - **red** — the goal is done (all required gates pass and the tree is clean);
226
+ - **no render** — Build and every non-Goal mode keep OpenCode's native todo
227
+ section in the same sidebar position instead of being classified as a goal.
214
228
 
215
229
  Toggle/recolour with `sidebarBanner`, `sidebarColor` (running), `sidebarDoneColor`
216
- (done), `sidebarMutedColor` (no goal), or the `GOAL_GUARD_SIDEBAR_*` env vars.
230
+ (done), `sidebarMutedColor`, `sidebarRainbowMs`, or the `GOAL_GUARD_SIDEBAR_*`
231
+ env vars.
217
232
 
218
233
  **How it loads — important.** TUI plugins are **not** loaded from the `plugins/`
219
- dir; OpenCode loads them from `~/.config/opencode/tui.json`. The installer writes
220
- that for you (merge-safe):
234
+ dir; OpenCode loads them from `tui.json`. The Goal sidebar waits to register its
235
+ `sidebar_content` slot until a real Goal session exists, so non-Goal modes do not
236
+ get a blank replacement slot. With `--global`, the installer writes
237
+ `~/.config/opencode/tui.json` for you (merge-safe):
221
238
 
222
239
  ```json
223
240
  { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
224
241
  ```
225
242
 
226
243
  Restart OpenCode after install so it picks up the TUI plugin (it resolves the
227
- package and provides the `@opentui/solid` runtime). The banner appears in a
228
- **session** view (not the home screen). The three states are rendered and
229
- asserted text + exact colours — by a real headless OpenTUI renderer in the
230
- [visual test](tools/visual-test/README.md) (`npm run test:visual`, 18/18). The
244
+ package and provides the `@opentui/solid` runtime). The Goal todo section appears
245
+ in a **Goal session** view (not the home screen and not Build mode). The visual
246
+ harness renders it with a headless OpenTUI renderer in
247
+ [visual test](tools/visual-test/README.md) (`npm run test:visual`). The
231
248
  enforcement core is a separate server plugin and works regardless of the sidebar.
232
249
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
233
250
  (`toastOnReview`), and blocked destructive commands / premature completions
@@ -236,16 +253,22 @@ enforcement and writes its state to disk, and an experimental TUI plugin
236
253
  ## Installer options
237
254
 
238
255
  ```bash
256
+ npx opencode-goal-mode --global --dry-run
257
+ npx opencode-goal-mode --global
258
+ opencode-goal-mode-install --global --uninstall
239
259
  node scripts/install.mjs --dry-run
240
260
  node scripts/install.mjs --target /path/to/opencode-config
241
261
  node scripts/install.mjs --global --force
242
262
  node scripts/install.mjs --global --uninstall
243
263
  ```
244
264
 
245
- The installer records a manifest of the files it writes. On upgrade it replaces
246
- files it owns but refuses to clobber files you have locally modified unless
247
- `--force` is passed. `--uninstall` removes only the files it installed and leaves
248
- your local edits in place.
265
+ Default target rules are simple: `--global` writes to `~/.config/opencode`; no
266
+ flag writes to `./.opencode`; `--target` writes to exactly the directory you pass.
267
+ In every target, the installer copies only `agents/`, `commands/`, `plugins/`,
268
+ writes `.goal-mode-manifest.json`, and merge-safely adds `opencode-goal-mode` to
269
+ `tui.json` in that same target. On upgrade it replaces files it owns but refuses
270
+ to clobber files you have locally modified unless `--force` is passed.
271
+ `--uninstall` removes only owned files and removes only its own `tui.json` entry.
249
272
 
250
273
  ## Configuration
251
274
 
@@ -274,10 +297,11 @@ Or via environment variables (`GOAL_GUARD_*`):
274
297
  | `sessionTtlMs` / `GOAL_GUARD_SESSION_TTL_MS` | `86400000` | Idle session TTL. |
275
298
  | `toastOnBlock` / `GOAL_GUARD_TOAST_ON_BLOCK` | `true` | Toast when something is blocked. |
276
299
  | `toastOnReview` / `GOAL_GUARD_TOAST_ON_REVIEW` | `true` | Toast on each review verdict and when completion unlocks. |
277
- | `sidebarBanner` / `GOAL_GUARD_SIDEBAR_BANNER` | `true` | Show the experimental yellow goal banner in the TUI sidebar. |
278
- | `sidebarColor` / `GOAL_GUARD_SIDEBAR_COLOR` | `#FFD700` | Colour of a **running** goal in the sidebar (yellow). |
300
+ | `sidebarBanner` / `GOAL_GUARD_SIDEBAR_BANNER` | `true` | Show the experimental Goal todo section in the TUI sidebar. |
301
+ | `sidebarColor` / `GOAL_GUARD_SIDEBAR_COLOR` | `#FFD700` | Normal colour of a **running** goal after the first-show rainbow. |
279
302
  | `sidebarDoneColor` / `GOAL_GUARD_SIDEBAR_DONE_COLOR` | `#FF5555` | Colour of a **done** goal in the sidebar (red). |
280
- | `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Colour of the "No goal available" line (grey). |
303
+ | `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Reserved muted colour for no-goal projections. |
304
+ | `sidebarRainbowMs` / `GOAL_GUARD_SIDEBAR_RAINBOW_MS` | `4500` | First-display rainbow duration for the Goal todo section. |
281
305
 
282
306
  ## Custom tools
283
307
 
@@ -297,7 +321,7 @@ criterion against recorded evidence, reviewer status, gaps, and the next
297
321
  required action. The command is backed by the `goal_evidence_map` tool, so it
298
322
  uses persisted Goal Guard state rather than relying on transcript memory.
299
323
 
300
- ## Validation
324
+ ## Contributor validation
301
325
 
302
326
  ```bash
303
327
  npm test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "plugins/goal-sidebar.tsx",
@@ -99,8 +99,14 @@
99
99
  "solid-js": "*"
100
100
  },
101
101
  "peerDependenciesMeta": {
102
- "@opencode-ai/plugin": { "optional": true },
103
- "@opentui/solid": { "optional": true },
104
- "solid-js": { "optional": true }
102
+ "@opencode-ai/plugin": {
103
+ "optional": true
104
+ },
105
+ "@opentui/solid": {
106
+ "optional": true
107
+ },
108
+ "solid-js": {
109
+ "optional": true
110
+ }
105
111
  }
106
112
  }
@@ -28,14 +28,16 @@ export const DEFAULT_CONFIG = Object.freeze({
28
28
  toastOnBlock: true,
29
29
  /** Emit a TUI toast when a review gate records a PASS/FAIL, and when completion unlocks. */
30
30
  toastOnReview: true,
31
- /** Show the experimental yellow goal banner in the TUI sidebar (TUI-plugin-capable OpenCode only). */
31
+ /** Show the experimental goal todo section in the TUI sidebar (TUI-plugin-capable OpenCode only). */
32
32
  sidebarBanner: true,
33
- /** Foreground colour (hex) for the sidebar goal banner. */
33
+ /** Foreground colour (hex) for the sidebar goal todo section after the first-show rainbow. */
34
34
  sidebarColor: "#FFD700",
35
35
  /** Foreground colour (hex) for a completed goal in the sidebar (running → done turns yellow → red). */
36
36
  sidebarDoneColor: "#FF5555",
37
- /** Foreground colour (hex) for the muted "No goal available" sidebar line. */
37
+ /** Reserved muted foreground colour for no-goal projections. */
38
38
  sidebarMutedColor: "#808080",
39
+ /** Duration for the first-display rainbow effect on the Goal todo section. */
40
+ sidebarRainbowMs: 4500,
39
41
  /** Phrase that, at the start of an assistant message, claims completion. */
40
42
  completionMarker: "Goal Completed",
41
43
  /** Replacement marker when completion is blocked. */
@@ -74,6 +76,7 @@ function fromEnv(env) {
74
76
  GOAL_GUARD_SIDEBAR_COLOR: ["sidebarColor", (v) => (v == null ? undefined : String(v))],
75
77
  GOAL_GUARD_SIDEBAR_DONE_COLOR: ["sidebarDoneColor", (v) => (v == null ? undefined : String(v))],
76
78
  GOAL_GUARD_SIDEBAR_MUTED_COLOR: ["sidebarMutedColor", (v) => (v == null ? undefined : String(v))],
79
+ GOAL_GUARD_SIDEBAR_RAINBOW_MS: ["sidebarRainbowMs", coerceInt],
77
80
  };
78
81
  for (const [key, [field, coerce]] of Object.entries(map)) {
79
82
  if (env[key] !== undefined) out[field] = coerce(env[key], DEFAULT_CONFIG[field]);
@@ -136,7 +136,6 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
136
136
  const blockDestructive = config.blockDestructive && analysis.destructive;
137
137
  const blockNetwork = config.blockNetworkExec && analysis.networkExec;
138
138
  if (blockDestructive || blockNetwork) {
139
- state.active = true;
140
139
  state.dirtyReasons.push(`blocked risky bash: ${analysis.reasons.join("; ") || "destructive"}`);
141
140
  if (config.toastOnBlock) logger.toast("Goal Guard blocked a destructive command", "error");
142
141
  persist();
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Read-only projection of persisted guard state for the TUI sidebar banner.
2
+ * Read-only projection of persisted guard state for the TUI sidebar todo section.
3
3
  *
4
4
  * The sidebar plugin runs in OpenCode's TUI process, separate from the server
5
5
  * plugin that owns the live store. The two are paired through the same on-disk
@@ -49,10 +49,9 @@ export function pickSession(snapshot, sessionId) {
49
49
  }
50
50
 
51
51
  /**
52
- * Build the sidebar banner model for a worktree. ALWAYS returns an object so the
53
- * sidebar renders unconditionally: `{ hasGoal: false }` when there is no state,
54
- * no active session, or no goal (render a muted "No goal"); otherwise
55
- * `{ hasGoal: true, goal, status, … }` (see summary.sidebarView).
52
+ * Build the sidebar todo model for a worktree. ALWAYS returns an object: `state:
53
+ * "none"` when there is no Goal session (render nothing and keep native todos), otherwise
54
+ * `state: "running"|"done", goal, status, todos, …` (see summary.sidebarView).
56
55
  *
57
56
  * @param {object} opts
58
57
  * @param {string} opts.worktree Project worktree root (same key the guard uses).
@@ -27,18 +27,40 @@ export function shortGoalLabel(state, max = 80) {
27
27
  return `${base.slice(0, max - 1).trimEnd()}…`;
28
28
  }
29
29
 
30
- /** Sentinel for "a task is running but no goal is set" — the sidebar shows a muted "No goal available". */
30
+ /** Sentinel for "no active Goal session" — the TUI plugin renders nothing so native todos remain. */
31
31
  export const NO_GOAL = Object.freeze({ state: "none", goal: "", gates: "", status: "" });
32
32
 
33
+ function criterionEvidenceFresh(state, criterion) {
34
+ const entries = Array.isArray(state.evidence) ? state.evidence : [];
35
+ return entries.some((entry) => evidenceMatchesCriterion(entry, criterion) && evidenceFresh(entry, state));
36
+ }
37
+
38
+ function sidebarTodos(state, required, missing) {
39
+ const criteria = Array.isArray(state?.contract?.acceptanceCriteria) ? state.contract.acceptanceCriteria : [];
40
+ const items = [];
41
+ for (const criterion of criteria.slice(0, 5)) {
42
+ const text = String(criterion || "").replace(/\s+/g, " ").trim();
43
+ 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
+ });
48
+ }
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);
53
+ }
54
+
33
55
  /**
34
- * Compact projection for the TUI sidebar banner. ALWAYS returns an object with a
56
+ * Compact projection for the TUI sidebar todo section. ALWAYS returns an object with a
35
57
  * three-way `state`, plus three lines that stack vertically in the sidebar:
36
58
  * - `goal` → line 1: the short AI goal title.
37
59
  * - `gates` → line 2: the gate count, e.g. "0/7 gates".
38
60
  * - `status` → line 3: the lifecycle status, e.g. "in progress · changes pending"
39
61
  * or "completed · 2 review cycles".
40
- * State drives colour: "running" = yellow, "done" = red, "none" = grey
41
- * ("No goal available").
62
+ * State drives colour: "running" = rainbow first, then yellow; "done" = red;
63
+ * "none" = render nothing so non-Goal modes keep the native todo section.
42
64
  */
43
65
  export function sidebarView(state, config) {
44
66
  if (!state || !state.active) return NO_GOAL;
@@ -49,6 +71,7 @@ export function sidebarView(state, config) {
49
71
  const passing = required.length - missing.length;
50
72
  const cycles = Number(state.reviewCycles) || 0;
51
73
  const gates = `${passing}/${required.length} gates`;
74
+ const todos = sidebarTodos(state, required, missing);
52
75
  const done = required.length > 0 && missing.length === 0 && !state.dirty;
53
76
  if (done) {
54
77
  return {
@@ -56,6 +79,8 @@ export function sidebarView(state, config) {
56
79
  goal,
57
80
  gates,
58
81
  status: `completed · ${cycles} review cycle${cycles === 1 ? "" : "s"}`,
82
+ todoTitle: "Goal todos",
83
+ todos: todos.length ? todos.map((item) => ({ ...item, status: "done" })) : [{ status: "done", text: "All Goal completion gates are clear" }],
59
84
  passing,
60
85
  required: required.length,
61
86
  reviewCycles: cycles,
@@ -66,6 +91,8 @@ export function sidebarView(state, config) {
66
91
  goal,
67
92
  gates,
68
93
  status: `in progress${state.dirty ? " · changes pending" : ""}`,
94
+ todoTitle: "Goal todos",
95
+ todos,
69
96
  passing,
70
97
  required: required.length,
71
98
  reviewCycles: cycles,
@@ -1,13 +1,10 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  /**
3
- * Goal Mode — TUI sidebar goal banner.
3
+ * Goal Mode — TUI sidebar todo section.
4
4
  *
5
- * Renders, in the sidebar's content area, the current goal as three stacked lines:
6
- * 1. `GOAL <short AI title>` (the objective, generated by the Goal agent)
7
- * 2. `<n>/<m> gates` (review-gate progress)
8
- * 3. `<status>` (in progress / changes pending / completed …)
9
- * Colour tracks lifecycle: yellow while running, red when done, grey
10
- * "No goal available" when a task has no goal set.
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.
11
8
  *
12
9
  * How OpenCode loads this: TUI plugins are listed in `~/.config/opencode/tui.json`
13
10
  * (NOT the plugins/ dir) and resolved via the package's `exports["./tui"]`. The
@@ -21,7 +18,7 @@
21
18
  * the Node test suite.
22
19
  */
23
20
 
24
- import { createSignal, onCleanup, Show } from "solid-js";
21
+ import { createSignal, onCleanup, For, Show } from "solid-js";
25
22
  import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
26
23
  import { DEFAULT_CONFIG } from "./goal-guard/config.js";
27
24
 
@@ -29,6 +26,7 @@ const DEFAULT_COLOR = "#FFD700"; // running — yellow
29
26
  const DEFAULT_DONE = "#FF5555"; // done — red
30
27
  const DEFAULT_MUTED = "#808080"; // no goal — grey
31
28
  const POLL_MS = 1500;
29
+ const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
32
30
 
33
31
  function resolveOptions(options, env) {
34
32
  const e = env || {};
@@ -41,6 +39,7 @@ function resolveOptions(options, env) {
41
39
  color: options?.sidebarColor || e.GOAL_GUARD_SIDEBAR_COLOR || DEFAULT_COLOR,
42
40
  doneColor: options?.sidebarDoneColor || e.GOAL_GUARD_SIDEBAR_DONE_COLOR || DEFAULT_DONE,
43
41
  muted: options?.sidebarMutedColor || e.GOAL_GUARD_SIDEBAR_MUTED_COLOR || DEFAULT_MUTED,
42
+ rainbowMs: Number(options?.sidebarRainbowMs ?? e.GOAL_GUARD_SIDEBAR_RAINBOW_MS ?? 4500),
44
43
  };
45
44
  }
46
45
 
@@ -70,6 +69,7 @@ function pickSession(snapshot, sessionId) {
70
69
  if (sessionId) {
71
70
  const direct = records.find(([key, st]) => key === sessionId && st.active);
72
71
  if (direct) return direct[1];
72
+ return null;
73
73
  }
74
74
  const active = records.filter(([, st]) => st.active);
75
75
  if (active.length === 0) return null;
@@ -94,43 +94,68 @@ const id = "goal-mode-sidebar";
94
94
  /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
95
95
  const tui = async (api, options) => {
96
96
  try {
97
- const { enabled, color, doneColor, muted } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
97
+ const { enabled, color, doneColor, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
98
98
  if (!enabled) return;
99
99
  if (!api?.slots?.register) return; // runtime without the slot API → no-op.
100
100
 
101
101
  const worktree = api.state?.path?.worktree || api.state?.path?.directory;
102
102
 
103
- api.slots.register({
104
- order: 50,
105
- slots: {
106
- sidebar_content(_ctx, props) {
107
- const read = () => {
108
- try {
109
- return readModel(worktree, props?.session_id) || NO_GOAL;
110
- } catch {
111
- return NO_GOAL;
112
- }
113
- };
114
- const [model, setModel] = createSignal(read());
115
- const timer = setInterval(() => setModel(read()), POLL_MS);
116
- onCleanup(() => clearInterval(timer));
117
- const fg = () => (model().state === "done" ? doneColor : color);
118
- // Three stacked lines: GOAL+title, gates, status — or grey "No goal available".
119
- return (
120
- <box flexDirection="column" paddingTop={1}>
121
- <Show when={model().state !== "none"} fallback={<text fg={muted}>No goal available</text>}>
122
- <text fg={fg()}>
123
- <b>GOAL</b>
124
- {` ${model().goal}`}
125
- </text>
126
- <text fg={fg()}>{model().gates}</text>
127
- <text fg={fg()}>{model().status}</text>
103
+ let registered = false;
104
+ const register = () => {
105
+ if (registered) return;
106
+ registered = true;
107
+ api.slots.register({
108
+ order: 50,
109
+ slots: {
110
+ sidebar_content(_ctx, props) {
111
+ if (!props?.session_id) return undefined;
112
+ const read = () => {
113
+ try {
114
+ return readModel(worktree, props?.session_id) || NO_GOAL;
115
+ } catch {
116
+ return NO_GOAL;
117
+ }
118
+ };
119
+ const initial = read();
120
+ if (initial.state === "none") return undefined;
121
+ const [model, setModel] = createSignal(initial);
122
+ const [rainbow, setRainbow] = createSignal((rainbowMs || 0) > 0);
123
+ const timer = setInterval(() => setModel(read()), POLL_MS);
124
+ const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
125
+ onCleanup(() => clearInterval(timer));
126
+ onCleanup(() => clearTimeout(rainbowTimer));
127
+ const fg = () => (model().state === "done" ? doneColor : color);
128
+ const lineColor = (index = 0) => (rainbow() && model().state === "running" ? RAINBOW[index % RAINBOW.length] : fg());
129
+ // Goal sessions render a Goal-owned todo section; non-Goal sessions return undefined so native todos remain.
130
+ return (
131
+ <Show when={model().state !== "none"}>
132
+ <box flexDirection="column" paddingTop={1}>
133
+ <text fg={lineColor(0)}>
134
+ <b>{model().todoTitle || "Goal todos"}</b>
135
+ {` ${model().goal}`}
136
+ </text>
137
+ <text fg={lineColor(1)}>{`${model().gates} · ${model().status}`}</text>
138
+ <For each={model().todos || []}>
139
+ {(item, index) => <text fg={lineColor(index() + 2)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
140
+ </For>
141
+ </box>
128
142
  </Show>
129
- </box>
130
- );
143
+ );
144
+ },
131
145
  },
132
- },
133
- });
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
+ }
134
159
  } catch {
135
160
  /* TUI runtime missing or API drift — render nothing rather than crash. */
136
161
  }
@@ -40,11 +40,13 @@ if (values.help) {
40
40
  console.log(`Install or remove OpenCode Goal Mode components.
41
41
 
42
42
  Usage:
43
+ npx opencode-goal-mode --global
44
+ opencode-goal-mode-install --global
43
45
  node scripts/install.mjs [--global | --target <dir>] [--force] [--dry-run]
44
46
  node scripts/install.mjs --uninstall [--global | --target <dir>] [--dry-run]
45
47
 
46
48
  Options:
47
- --global Install into ~/.config/opencode.
49
+ --global Install into ~/.config/opencode (recommended for everyday use).
48
50
  --target DIR Install into a specific OpenCode config directory.
49
51
  --force Replace destination files even if locally modified.
50
52
  --uninstall Remove files this installer previously wrote (per manifest).
@@ -53,7 +55,8 @@ Options:
53
55
 
54
56
  The installer records a manifest of the files it writes so that a later
55
57
  upgrade can distinguish files it owns (safe to replace) from files you have
56
- locally customized (left untouched unless --force).`);
58
+ locally customized (left untouched unless --force). It also merge-safely adds
59
+ the Goal sidebar plugin to <target>/tui.json; --uninstall removes that entry.`);
57
60
  process.exit(0);
58
61
  }
59
62
 
@@ -75,6 +78,15 @@ function fileHash(path) {
75
78
  return createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16);
76
79
  }
77
80
 
81
+ function safeTargetPath(rel) {
82
+ const dest = resolve(target, rel);
83
+ const back = relative(target, dest);
84
+ if (back === "" || back.startsWith("..") || resolve(back) === back) {
85
+ throw new Error(`Refusing to operate outside target: ${rel}`);
86
+ }
87
+ return dest;
88
+ }
89
+
78
90
  /** Recursively list regular files under a directory, returning paths relative to
79
91
  * `base`. Uses lstat and skips symlinks so the installer only copies files it can
80
92
  * reason about (no following links outside the package tree). */
@@ -140,8 +152,13 @@ function ensureTuiPlugin(remove = false) {
140
152
  try {
141
153
  const existing = JSON.parse(readFileSync(tuiPath, "utf8"));
142
154
  if (existing && typeof existing === "object") data = existing;
143
- } catch {
144
- /* missing or invalid → start fresh */
155
+ } catch (err) {
156
+ if (existsSync(tuiPath)) {
157
+ if (!values.force) throw new Error(`Refusing to replace invalid ${tuiPath}. Fix it or rerun with --force.`);
158
+ const backupPath = `${tuiPath}.goal-mode-backup`;
159
+ if (!values["dry-run"]) copyFileSync(tuiPath, backupPath);
160
+ console.log(`${values["dry-run"] ? "Would back up" : "Backed up"} invalid ${tuiPath} to ${backupPath}`);
161
+ }
145
162
  }
146
163
  if (!Array.isArray(data.plugin)) data.plugin = [];
147
164
  const has = data.plugin.includes(TUI_PLUGIN_SPEC);
@@ -164,7 +181,7 @@ if (values.uninstall) {
164
181
  const removed = [];
165
182
  const kept = [];
166
183
  for (const [rel, hash] of Object.entries(manifest.files)) {
167
- const dest = join(target, rel);
184
+ const dest = safeTargetPath(rel);
168
185
  if (!existsSync(dest)) continue;
169
186
  if (fileHash(dest) === hash) {
170
187
  if (!values["dry-run"]) rmSync(dest, { force: true });
@@ -242,7 +259,7 @@ for (const dir of COMPONENT_DIRS) {
242
259
  // plugin split into modules), but only if the user hasn't modified them.
243
260
  for (const [relKey, oldHash] of Object.entries(manifest.files)) {
244
261
  if (newManifestFiles[relKey] !== undefined) continue;
245
- const dest = join(target, relKey);
262
+ const dest = safeTargetPath(relKey);
246
263
  if (!existsSync(dest)) continue;
247
264
  if (fileHash(dest) === oldHash) {
248
265
  if (!values["dry-run"]) rmSync(dest, { force: true });