opencode-goal-mode 0.3.6 → 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,37 @@
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
+
15
+ ## v0.3.7
16
+
17
+ - **FIX: the sidebar now actually loads.** OpenCode loads a TUI plugin via the
18
+ package's `exports["./tui"]` subpath (verified against the OpenCode binary), and
19
+ ours had no `exports`, so it silently never loaded. The package now maps
20
+ `"./tui"` (and `main`) to `plugins/goal-sidebar.tsx`, and the entry is shipped as
21
+ `.tsx` so OpenCode transpiles it. Confirmed working in a real OpenCode 1.17.6 TUI.
22
+ - **Sidebar layout, as requested:** three stacked lines — `GOAL <title>`, then the
23
+ gate count (`n/m gates`), then the status (`in progress` / `… · changes pending`
24
+ / `completed · k review cycles`). The leading orb (◆) is removed.
25
+ - **AI-generated goal title:** `goal_contract` takes a short `title` the Goal agent
26
+ writes (the objective, like a session title); the sidebar shows it, falling back
27
+ to the raw goal text until titled.
28
+ - **One-command install:** `npx opencode-goal-mode --global` (new `opencode-goal-mode`
29
+ bin alias). The installer registers the sidebar in `tui.json` (merge-safe) and
30
+ `--uninstall` removes that entry too. Install instructions moved to the top of the
31
+ README.
32
+ - Colours configurable: `sidebarColor` (running), `sidebarDoneColor` (done),
33
+ `sidebarMutedColor` (no goal).
34
+
3
35
  ## v0.3.6
4
36
 
5
37
  - **FIX: the sidebar never loaded.** TUI plugins are loaded from
package/README.md CHANGED
@@ -7,22 +7,55 @@
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, and a `goal-guard` plugin that enforces review
12
- discipline, blocks destructive shell commands, and preserves goal state across
13
- compaction **and** restarts.
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
+ TUI sidebar.
14
+
15
+ ## Install
16
+
17
+ **One command** (recommended; needs [Node](https://nodejs.org) 20.11+ and a working [OpenCode](https://opencode.ai) install):
14
18
 
15
19
  ```bash
16
- npm install -g opencode-goal-mode && opencode-goal-mode-install --global
20
+ npx opencode-goal-mode --global
17
21
  ```
18
22
 
19
- ![OpenCode Goal Mode sidebar banner](docs/sidebar-demo.svg)
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.
20
28
 
21
- <sub>↑ Illustrative mockup of the **experimental** sidebar banner. The enforcement
22
- core (guard + agents) is the verified product; the TUI sidebar is opt-in and its
23
- live render depends on your OpenCode build — see [TUI integration](#tui-integration).</sub>
29
+ <details>
30
+ <summary>Other ways to install</summary>
24
31
 
25
- **[Quick start](#quick-start) · [Install](#install) · [Why it's different](#why-its-different) · [Benchmarks](#benchmarks-honest-edition) · [TUI integration](#tui-integration) · [Configuration](#configuration) · [Releasing](#releasing) · [Architecture](ARCHITECTURE.md)**
32
+ ```bash
33
+ # Global npm install, then run the installer
34
+ npm install -g opencode-goal-mode
35
+ opencode-goal-mode --global # alias of opencode-goal-mode-install
36
+
37
+ # Into a single project (writes ./.opencode, including ./.opencode/tui.json)
38
+ npx opencode-goal-mode
39
+
40
+ # From source
41
+ git clone https://github.com/devinoldenburg/opencode-goal-mode
42
+ cd opencode-goal-mode && npm ci && npm run install:global
43
+ ```
44
+
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).
50
+ </details>
51
+
52
+ ![OpenCode Goal Mode sidebar todo section](docs/sidebar-demo.svg)
53
+
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>
57
+
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)**
26
59
 
27
60
  ## Quick start
28
61
 
@@ -46,8 +79,8 @@ opencode agent list | grep goal
46
79
  **cannot** answer `Goal Completed` until every required review gate passes — the
47
80
  guard rewrites a premature claim to `Goal Not Completed`. Try a destructive
48
81
  command mid-session (e.g. `rm -rf build`) and watch it get blocked. If your
49
- OpenCode build supports TUI plugins, the active goal also appears in the sidebar
50
- 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)).
51
84
 
52
85
  That's it. Everything below is detail.
53
86
 
@@ -131,7 +164,12 @@ second) — negligible for a per-tool-call guard:
131
164
  ## Requirements
132
165
 
133
166
  - Node.js 20.11 or newer.
134
- - 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.
135
173
 
136
174
  ## What it adds
137
175
 
@@ -164,9 +202,10 @@ second) — negligible for a per-tool-call guard:
164
202
  - **TUI toasts**: a toast on each review verdict (PASS/FAIL), with the
165
203
  reviewer's friendly name, and a single "completion unlocked" toast the moment
166
204
  the last required gate clears.
167
- - An **experimental** companion TUI plugin (`plugins/goal-sidebar.js`) that shows
168
- the active goal as a shining-yellow banner in the sidebar with a compact gate
169
- 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).
170
209
  - A test suite validating the analyzer, plugin hooks, state store, install
171
210
  safety, and config compatibility.
172
211
 
@@ -174,84 +213,62 @@ second) — negligible for a per-tool-call guard:
174
213
 
175
214
  Goal Mode is a **plugin pair**: the server-side `goal-guard` plugin owns
176
215
  enforcement and writes its state to disk, and an experimental TUI plugin
177
- (`plugins/goal-sidebar.js`) reads that same state to render a live banner.
178
-
179
- - **Sidebar goal banner.** In the sidebar's content area, under the session
180
- title/context, it shows the current goal with generated status text, colour-coded
181
- by lifecycle:
182
- - **yellow** a goal is set and running (`◆ GOAL …` + `in progress · N/M gates`);
183
- - **red** the goal is done (all required gates pass, tree clean: `✓ GOAL …` +
184
- `completed · N/M gates passed · K review cycles`);
185
- - **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.
186
228
 
187
229
  Toggle/recolour with `sidebarBanner`, `sidebarColor` (running), `sidebarDoneColor`
188
- (done), `sidebarMutedColor` (no goal), or the `GOAL_GUARD_SIDEBAR_*` env vars.
230
+ (done), `sidebarMutedColor`, `sidebarRainbowMs`, or the `GOAL_GUARD_SIDEBAR_*`
231
+ env vars.
189
232
 
190
233
  **How it loads — important.** TUI plugins are **not** loaded from the `plugins/`
191
- dir; OpenCode loads them from `~/.config/opencode/tui.json`. The installer writes
192
- 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):
193
238
 
194
239
  ```json
195
240
  { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
196
241
  ```
197
242
 
198
243
  Restart OpenCode after install so it picks up the TUI plugin (it resolves the
199
- package and provides the `@opentui/solid` runtime). The banner appears in a
200
- **session** view (not the home screen). The three states are rendered and
201
- asserted text + exact colours — by a real headless OpenTUI renderer in the
202
- [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
203
248
  enforcement core is a separate server plugin and works regardless of the sidebar.
204
249
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
205
250
  (`toastOnReview`), and blocked destructive commands / premature completions
206
251
  toast as before (`toastOnBlock`).
207
252
 
208
- ## Install
209
-
210
- ### From npm (recommended)
211
-
212
- ```bash
213
- npm install -g opencode-goal-mode
214
- opencode-goal-mode-install --global # installs into ~/.config/opencode
215
- ```
216
-
217
- Then restart OpenCode (it loads agents, commands, and plugins at startup). In the
218
- agent picker you will see **only the `goal` agent** — the specialist reviewers are
219
- subagents the Goal agent drives for you; they are never selectable by the user.
220
-
221
- Install into a single project instead of globally:
222
-
223
- ```bash
224
- npm install -D opencode-goal-mode
225
- npx opencode-goal-mode-install # writes to ./.opencode
226
- ```
227
-
228
- Upgrade later by re-running the same install command after `npm install -g
229
- opencode-goal-mode@latest`; the installer replaces only the files it owns and
230
- leaves your local edits alone (see [Installer options](#installer-options)).
231
-
232
- ### From source
233
-
234
- ```bash
235
- git clone https://github.com/devinoldenburg/opencode-goal-mode
236
- cd opencode-goal-mode
237
- npm ci
238
- npm run validate
239
- npm run install:global # or: npm run install:local
240
- ```
241
-
242
253
  ## Installer options
243
254
 
244
255
  ```bash
256
+ npx opencode-goal-mode --global --dry-run
257
+ npx opencode-goal-mode --global
258
+ opencode-goal-mode-install --global --uninstall
245
259
  node scripts/install.mjs --dry-run
246
260
  node scripts/install.mjs --target /path/to/opencode-config
247
261
  node scripts/install.mjs --global --force
248
262
  node scripts/install.mjs --global --uninstall
249
263
  ```
250
264
 
251
- The installer records a manifest of the files it writes. On upgrade it replaces
252
- files it owns but refuses to clobber files you have locally modified unless
253
- `--force` is passed. `--uninstall` removes only the files it installed and leaves
254
- 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.
255
272
 
256
273
  ## Configuration
257
274
 
@@ -280,10 +297,11 @@ Or via environment variables (`GOAL_GUARD_*`):
280
297
  | `sessionTtlMs` / `GOAL_GUARD_SESSION_TTL_MS` | `86400000` | Idle session TTL. |
281
298
  | `toastOnBlock` / `GOAL_GUARD_TOAST_ON_BLOCK` | `true` | Toast when something is blocked. |
282
299
  | `toastOnReview` / `GOAL_GUARD_TOAST_ON_REVIEW` | `true` | Toast on each review verdict and when completion unlocks. |
283
- | `sidebarBanner` / `GOAL_GUARD_SIDEBAR_BANNER` | `true` | Show the experimental yellow goal banner in the TUI sidebar. |
284
- | `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. |
285
302
  | `sidebarDoneColor` / `GOAL_GUARD_SIDEBAR_DONE_COLOR` | `#FF5555` | Colour of a **done** goal in the sidebar (red). |
286
- | `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. |
287
305
 
288
306
  ## Custom tools
289
307
 
@@ -303,7 +321,7 @@ criterion against recorded evidence, reviewer status, gaps, and the next
303
321
  required action. The command is backed by the `goal_evidence_map` tool, so it
304
322
  uses persisted Goal Guard state rather than relying on transcript memory.
305
323
 
306
- ## Validation
324
+ ## Contributor validation
307
325
 
308
326
  ```bash
309
327
  npm test
package/agents/goal.md CHANGED
@@ -85,7 +85,7 @@ Required internal artifacts:
85
85
 
86
86
  Guard tools (provided by the goal-guard plugin):
87
87
 
88
- - Call `goal_contract` once the Goal Contract is settled. This activates strict enforcement and tells the guard which specialist review gates your goal requires (security, data, api, perf, etc., inferred from the contract text).
88
+ - Call `goal_contract` once the Goal Contract is settled. Always include a concise `title` (max ~8 words, no trailing punctuation) that captures what the user ultimately wants — phrased like a session title but about the objective (e.g. "Rate-limit the login endpoint", "Migrate auth to JWT"). This title is shown live in the TUI sidebar, so make it specific and readable. Recording the contract activates strict enforcement and tells the guard which specialist review gates your goal requires (security, data, api, perf, etc., inferred from the contract text).
89
89
  - Call `goal_evidence` after each meaningful verification run to record the command and result in the Verification Ledger.
90
90
  - Call `goal_status` whenever you are unsure what the guard currently requires; it returns the authoritative list of passing, missing, and stale gates and whether completion is allowed. Trust it over your own recollection.
91
91
  - The guard injects a live state block into your context each turn and will rewrite a premature `Goal Completed` into `Goal Not Completed` with the missing gates. Use `goal_status` to avoid that rather than guessing.
package/commands/goal.md CHANGED
@@ -11,11 +11,12 @@ $ARGUMENTS
11
11
 
12
12
  Run this sequence:
13
13
 
14
- 1. **Seed the contract first.** Call the `goal_contract` tool with the original
15
- request, explicit/inferred requirements, non-goals, and concrete acceptance
16
- criteria. This activates enforcement, fixes the required specialist review
17
- gates, and lights up the goal banner in the sidebar. Ask only essential
18
- clarifying questions before recording it.
14
+ 1. **Seed the contract first.** Call the `goal_contract` tool with a concise
15
+ `title` (≤8 words, what the user ultimately wants — like a session title for
16
+ the objective), the original request, explicit/inferred requirements,
17
+ non-goals, and concrete acceptance criteria. This activates enforcement, fixes
18
+ the required specialist review gates, and shows the `title` live in the sidebar
19
+ goal banner. Ask only essential clarifying questions before recording it.
19
20
  2. Delegate discovery and research to subagents; implement in the main agent.
20
21
  3. Verify, and record each verification with the `goal_evidence` tool so it maps
21
22
  to your acceptance criteria.
package/package.json CHANGED
@@ -1,14 +1,20 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
- "main": "plugins/goal-sidebar.js",
6
+ "main": "plugins/goal-sidebar.tsx",
7
+ "exports": {
8
+ ".": "./plugins/goal-sidebar.tsx",
9
+ "./tui": "./plugins/goal-sidebar.tsx",
10
+ "./package.json": "./package.json"
11
+ },
7
12
  "engines": {
8
13
  "node": ">=20.11"
9
14
  },
10
15
  "packageManager": "npm@10",
11
16
  "bin": {
17
+ "opencode-goal-mode": "scripts/install.mjs",
12
18
  "opencode-goal-mode-install": "scripts/install.mjs"
13
19
  },
14
20
  "files": [
@@ -93,8 +99,14 @@
93
99
  "solid-js": "*"
94
100
  },
95
101
  "peerDependenciesMeta": {
96
- "@opencode-ai/plugin": { "optional": true },
97
- "@opentui/solid": { "optional": true },
98
- "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
+ }
99
111
  }
100
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).
@@ -6,12 +6,18 @@
6
6
  import { requiredGates, missingGates, gatePassedFresh } from "./gates.js";
7
7
 
8
8
  /**
9
- * A short, single-line human label for the current goal — preferring the
10
- * recorded Goal Contract's original request, falling back to the captured goal
11
- * text. Collapses whitespace and truncates to `max` chars for compact display
12
- * (status reports, the TUI sidebar banner).
9
+ * A short, single-line label for the current goal.
10
+ *
11
+ * Prefers `contract.title` a concise, AI-generated summary of the objective
12
+ * (what the user wants), written by the Goal agent when it records the contract
13
+ * via `goal_contract` (think "session title", but the goal/objective). Falls back
14
+ * to the contract's original request, then the captured goal text, so something
15
+ * sensible still shows before the agent has titled the goal. Collapses whitespace
16
+ * and truncates to `max` chars for the compact sidebar.
13
17
  */
14
18
  export function shortGoalLabel(state, max = 80) {
19
+ const title = String(state?.contract?.title || "").replace(/\s+/g, " ").trim();
20
+ if (title) return title.length <= max ? title : `${title.slice(0, max - 1).trimEnd()}…`;
15
21
  const raw = String(state?.contract?.original || state?.goalText || "").replace(/\s+/g, " ").trim();
16
22
  if (!raw) return "";
17
23
  // Prefer the first sentence/clause if it is reasonably short.
@@ -21,18 +27,40 @@ export function shortGoalLabel(state, max = 80) {
21
27
  return `${base.slice(0, max - 1).trimEnd()}…`;
22
28
  }
23
29
 
24
- /** Sentinel for "a task is running but no goal is set" — the sidebar shows a muted "No goal available". */
25
- export const NO_GOAL = Object.freeze({ state: "none", goal: "", detail: "" });
30
+ /** Sentinel for "no active Goal session" — the TUI plugin renders nothing so native todos remain. */
31
+ export const NO_GOAL = Object.freeze({ state: "none", goal: "", gates: "", status: "" });
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
+ }
26
54
 
27
55
  /**
28
- * Compact projection for the TUI sidebar banner. ALWAYS returns an object with a
29
- * three-way `state` so the sidebar renders unconditionally:
30
- * - `state: "none"` no active goal: grey "No goal available".
31
- * - `state: "running"` goal in progress: yellow, with a generated status line.
32
- * - `state: "done"` goal complete (all required gates pass, tree clean):
33
- * red, with a generated completion line.
34
- * `goal` is the short goal label; `detail` is generated descriptive text derived
35
- * from the current goal's gate/cycle/dirty state.
56
+ * 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".
62
+ * State drives colour: "running" = rainbow first, then yellow; "done" = red;
63
+ * "none" = render nothing so non-Goal modes keep the native todo section.
36
64
  */
37
65
  export function sidebarView(state, config) {
38
66
  if (!state || !state.active) return NO_GOAL;
@@ -42,24 +70,29 @@ export function sidebarView(state, config) {
42
70
  const missing = missingGates(state, config);
43
71
  const passing = required.length - missing.length;
44
72
  const cycles = Number(state.reviewCycles) || 0;
73
+ const gates = `${passing}/${required.length} gates`;
74
+ const todos = sidebarTodos(state, required, missing);
45
75
  const done = required.length > 0 && missing.length === 0 && !state.dirty;
46
76
  if (done) {
47
77
  return {
48
78
  state: "done",
49
79
  goal,
50
- detail: `completed · ${passing}/${required.length} gates passed · ${cycles} review cycle${cycles === 1 ? "" : "s"}`,
80
+ gates,
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" }],
51
84
  passing,
52
85
  required: required.length,
53
86
  reviewCycles: cycles,
54
87
  };
55
88
  }
56
- const bits = [`${passing}/${required.length} gates`];
57
- if (state.dirty) bits.push("changes pending");
58
- if (cycles) bits.push(`cycle ${cycles}`);
59
89
  return {
60
90
  state: "running",
61
91
  goal,
62
- detail: `in progress · ${bits.join(" · ")}`,
92
+ gates,
93
+ status: `in progress${state.dirty ? " · changes pending" : ""}`,
94
+ todoTitle: "Goal todos",
95
+ todos,
63
96
  passing,
64
97
  required: required.length,
65
98
  reviewCycles: cycles,
@@ -92,6 +92,13 @@ export function createGoalTools({ store, config, persist }) {
92
92
  "inferred requirements, non-goals, and acceptance criteria). Establishing a contract " +
93
93
  "activates strict goal enforcement and drives which specialist review gates are required.",
94
94
  args: {
95
+ title: s
96
+ .string()
97
+ .describe(
98
+ "A short (max ~8 words) human-friendly title summarizing the GOAL/objective — what the user " +
99
+ "ultimately wants, phrased like a session title but about the outcome. Shown in the TUI sidebar. " +
100
+ "e.g. 'Rate-limit the login endpoint', 'Migrate auth to JWT'. No trailing punctuation.",
101
+ ),
95
102
  original: s.string().describe("The original user request, verbatim or faithfully summarized."),
96
103
  requirements: s.array(s.string()).optional().describe("Explicit requirements stated by the user."),
97
104
  inferred: s.array(s.string()).optional().describe("Reasonable inferred requirements."),
@@ -104,6 +111,7 @@ export function createGoalTools({ store, config, persist }) {
104
111
  const state = store.stateFor(ctx.sessionID);
105
112
  state.active = true;
106
113
  state.contract = {
114
+ title: String(args.title || "").replace(/\s+/g, " ").trim(),
107
115
  original: String(args.original || ""),
108
116
  requirements: args.requirements || [],
109
117
  inferred: args.inferred || [],
@@ -0,0 +1,164 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * Goal Mode — TUI sidebar todo section.
4
+ *
5
+ * In Goal agent sessions this replaces the native-looking todo area with a
6
+ * Goal-owned, evidence-aware todo section. Non-Goal sessions render nothing here
7
+ * so Build and other modes keep OpenCode's normal todo section in the same slot.
8
+ *
9
+ * How OpenCode loads this: TUI plugins are listed in `~/.config/opencode/tui.json`
10
+ * (NOT the plugins/ dir) and resolved via the package's `exports["./tui"]`. The
11
+ * installer writes tui.json (`plugin: ["opencode-goal-mode"]`); package.json maps
12
+ * `./tui` → this file; OpenCode supplies the `@opentui/solid` + `solid-js` runtime
13
+ * (declared as peer deps). The pure projection (`summary.sidebarView`) is shared
14
+ * with the server plugin and unit-tested via goal-guard/sidebar-data.js.
15
+ *
16
+ * Runtime notes: single `export default { id, tui }`; node built-ins are require()d
17
+ * lazily (the Bun TUI runtime rejects top-level node: imports). Never imported by
18
+ * the Node test suite.
19
+ */
20
+
21
+ import { createSignal, onCleanup, For, Show } from "solid-js";
22
+ import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
23
+ import { DEFAULT_CONFIG } from "./goal-guard/config.js";
24
+
25
+ const DEFAULT_COLOR = "#FFD700"; // running — yellow
26
+ const DEFAULT_DONE = "#FF5555"; // done — red
27
+ const DEFAULT_MUTED = "#808080"; // no goal — grey
28
+ const POLL_MS = 1500;
29
+ const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
30
+
31
+ function resolveOptions(options, env) {
32
+ const e = env || {};
33
+ const enabledOpt = options?.sidebarBanner;
34
+ const enabledEnv = e.GOAL_GUARD_SIDEBAR_BANNER;
35
+ const disabled =
36
+ enabledOpt === false || enabledEnv === "0" || enabledEnv === "false" || enabledEnv === "off";
37
+ return {
38
+ enabled: !disabled,
39
+ color: options?.sidebarColor || e.GOAL_GUARD_SIDEBAR_COLOR || DEFAULT_COLOR,
40
+ doneColor: options?.sidebarDoneColor || e.GOAL_GUARD_SIDEBAR_DONE_COLOR || DEFAULT_DONE,
41
+ muted: options?.sidebarMutedColor || e.GOAL_GUARD_SIDEBAR_MUTED_COLOR || DEFAULT_MUTED,
42
+ rainbowMs: Number(options?.sidebarRainbowMs ?? e.GOAL_GUARD_SIDEBAR_RAINBOW_MS ?? 4500),
43
+ };
44
+ }
45
+
46
+ /** Read the guard's persisted snapshot for a worktree (path logic mirrors persistence.js). */
47
+ function readSnapshot(worktree) {
48
+ try {
49
+ const fs = require("node:fs");
50
+ const path = require("node:path");
51
+ const os = require("node:os");
52
+ const crypto = require("node:crypto");
53
+ const xdg = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.trim();
54
+ const base = xdg || path.join(os.homedir(), ".local", "state");
55
+ const key = crypto.createHash("sha256").update(String(worktree || "default")).digest("hex").slice(0, 16);
56
+ const file = path.join(base, "opencode", "goal-guard", `${key}.json`);
57
+ return JSON.parse(fs.readFileSync(file, "utf8"));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** Most-recently-touched active session, preferring an explicit active sessionId. */
64
+ function pickSession(snapshot, sessionId) {
65
+ if (!snapshot || !Array.isArray(snapshot.sessions)) return null;
66
+ const records = snapshot.sessions
67
+ .filter((e) => Array.isArray(e) && e.length === 2)
68
+ .map(([key, st]) => [key, st && typeof st === "object" ? st : {}]);
69
+ if (sessionId) {
70
+ const direct = records.find(([key, st]) => key === sessionId && st.active);
71
+ if (direct) return direct[1];
72
+ return null;
73
+ }
74
+ const active = records.filter(([, st]) => st.active);
75
+ if (active.length === 0) return null;
76
+ active.sort((a, b) => (b[1].touchedAt || 0) - (a[1].touchedAt || 0));
77
+ return active[0][1];
78
+ }
79
+
80
+ function readModel(worktree, sessionId) {
81
+ try {
82
+ const snapshot = readSnapshot(worktree);
83
+ if (!snapshot) return NO_GOAL;
84
+ const record = pickSession(snapshot, sessionId);
85
+ if (!record) return NO_GOAL;
86
+ return sidebarView(record, DEFAULT_CONFIG);
87
+ } catch {
88
+ return NO_GOAL;
89
+ }
90
+ }
91
+
92
+ const id = "goal-mode-sidebar";
93
+
94
+ /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
95
+ const tui = async (api, options) => {
96
+ try {
97
+ const { enabled, color, doneColor, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
98
+ if (!enabled) return;
99
+ if (!api?.slots?.register) return; // runtime without the slot API → no-op.
100
+
101
+ const worktree = api.state?.path?.worktree || api.state?.path?.directory;
102
+
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>
142
+ </Show>
143
+ );
144
+ },
145
+ },
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
+ }
159
+ } catch {
160
+ /* TUI runtime missing or API drift — render nothing rather than crash. */
161
+ }
162
+ };
163
+
164
+ export default { id, tui };
@@ -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 });
@@ -1,147 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- /**
3
- * Goal Mode — TUI sidebar goal banner.
4
- *
5
- * This is a TUI plugin module (companion to the server-side goal-guard plugin).
6
- * It renders, in the sidebar's content area, the current goal with generated
7
- * status text and a colour that tracks the goal's lifecycle:
8
- * - RUNNING (goal set, in progress) → yellow
9
- * - DONE (all required gates pass, clean) → red
10
- * - NONE (a task is running, no goal set) → grey "No goal available"
11
- *
12
- * IMPORTANT — how OpenCode loads this: TUI plugins are NOT loaded from the
13
- * regular `plugin` array / plugins dir (that is server plugins). They are listed
14
- * in `~/.config/opencode/tui.json`:
15
- * { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
16
- * The installer writes that automatically. OpenCode then loads this module
17
- * (the package `main`) and provides the `@opentui/solid` + `solid-js` runtime
18
- * (declared here as peer deps), so its slot shares OpenCode's renderer.
19
- *
20
- * Runtime constraints (from working OpenCode TUI plugins):
21
- * - the module exports a single `export default { id, tui }`;
22
- * - the Bun TUI runtime does not support top-level ESM imports of Node built-ins,
23
- * so node:fs/path/os/crypto are require()d lazily inside functions;
24
- * - it is never imported by the Node test suite (the pure projection it uses,
25
- * summary.sidebarView, is tested via goal-guard/sidebar-data.js).
26
- */
27
-
28
- import { createSignal, onCleanup, Show } from "solid-js";
29
- import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
30
- import { DEFAULT_CONFIG } from "./goal-guard/config.js";
31
-
32
- const DEFAULT_COLOR = "#FFD700"; // shining yellow — running
33
- const DEFAULT_DONE = "#FF5555"; // red — completed
34
- const DEFAULT_MUTED = "#808080"; // grey — no goal
35
- const POLL_MS = 1500;
36
-
37
- function resolveOptions(options, env) {
38
- const e = env || {};
39
- const enabledOpt = options?.sidebarBanner;
40
- const enabledEnv = e.GOAL_GUARD_SIDEBAR_BANNER;
41
- const disabled =
42
- enabledOpt === false || enabledEnv === "0" || enabledEnv === "false" || enabledEnv === "off";
43
- return {
44
- enabled: !disabled,
45
- color: options?.sidebarColor || e.GOAL_GUARD_SIDEBAR_COLOR || DEFAULT_COLOR,
46
- doneColor: options?.sidebarDoneColor || e.GOAL_GUARD_SIDEBAR_DONE_COLOR || DEFAULT_DONE,
47
- muted: options?.sidebarMutedColor || e.GOAL_GUARD_SIDEBAR_MUTED_COLOR || DEFAULT_MUTED,
48
- };
49
- }
50
-
51
- /** Read the guard's persisted snapshot for a worktree (path logic mirrors persistence.js). */
52
- function readSnapshot(worktree) {
53
- try {
54
- const fs = require("node:fs");
55
- const path = require("node:path");
56
- const os = require("node:os");
57
- const crypto = require("node:crypto");
58
- const xdg = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.trim();
59
- const base = xdg || path.join(os.homedir(), ".local", "state");
60
- const key = crypto.createHash("sha256").update(String(worktree || "default")).digest("hex").slice(0, 16);
61
- const file = path.join(base, "opencode", "goal-guard", `${key}.json`);
62
- return JSON.parse(fs.readFileSync(file, "utf8"));
63
- } catch {
64
- return null;
65
- }
66
- }
67
-
68
- /** Most-recently-touched active session, preferring an explicit active sessionId. */
69
- function pickSession(snapshot, sessionId) {
70
- if (!snapshot || !Array.isArray(snapshot.sessions)) return null;
71
- const records = snapshot.sessions
72
- .filter((e) => Array.isArray(e) && e.length === 2)
73
- .map(([key, st]) => [key, st && typeof st === "object" ? st : {}]);
74
- if (sessionId) {
75
- const direct = records.find(([key, st]) => key === sessionId && st.active);
76
- if (direct) return direct[1];
77
- }
78
- const active = records.filter(([, st]) => st.active);
79
- if (active.length === 0) return null;
80
- active.sort((a, b) => (b[1].touchedAt || 0) - (a[1].touchedAt || 0));
81
- return active[0][1];
82
- }
83
-
84
- function readModel(worktree, sessionId) {
85
- try {
86
- const snapshot = readSnapshot(worktree);
87
- if (!snapshot) return NO_GOAL;
88
- const record = pickSession(snapshot, sessionId);
89
- if (!record) return NO_GOAL;
90
- return sidebarView(record, DEFAULT_CONFIG);
91
- } catch {
92
- return NO_GOAL;
93
- }
94
- }
95
-
96
- const id = "goal-mode-sidebar";
97
-
98
- /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
99
- const tui = async (api, options) => {
100
- try {
101
- const { enabled, color, doneColor, muted } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
102
- if (!enabled) return;
103
- if (!api?.slots?.register) return; // runtime without the slot API → no-op.
104
-
105
- const worktree = api.state?.path?.worktree || api.state?.path?.directory;
106
-
107
- api.slots.register({
108
- order: 50,
109
- slots: {
110
- sidebar_content(_ctx, props) {
111
- const read = () => {
112
- try {
113
- return readModel(worktree, props?.session_id) || NO_GOAL;
114
- } catch {
115
- return NO_GOAL;
116
- }
117
- };
118
- const [model, setModel] = createSignal(read());
119
- const timer = setInterval(() => setModel(read()), POLL_MS);
120
- onCleanup(() => clearInterval(timer));
121
- const fg = () => (model().state === "done" ? doneColor : color);
122
- // Always render: grey "No goal available" when none, else the goal in
123
- // yellow (running) / red (done) with generated status text.
124
- return (
125
- <box flexDirection="column" paddingTop={1}>
126
- <Show
127
- when={model().state !== "none"}
128
- fallback={<text fg={muted}>No goal available</text>}
129
- >
130
- <text fg={fg()}>
131
- {model().state === "done" ? "✓ " : "◆ "}
132
- <b>GOAL</b>
133
- {` ${model().goal}`}
134
- </text>
135
- <text fg={fg()}>{model().detail}</text>
136
- </Show>
137
- </box>
138
- );
139
- },
140
- },
141
- });
142
- } catch {
143
- /* TUI runtime missing or API drift — render nothing rather than crash. */
144
- }
145
- };
146
-
147
- export default { id, tui };