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 +14 -13
- package/CHANGELOG.md +32 -0
- package/README.md +92 -74
- package/agents/goal.md +1 -1
- package/commands/goal.md +6 -5
- package/package.json +17 -5
- package/plugins/goal-guard/config.js +6 -3
- package/plugins/goal-guard/guard.js +0 -1
- package/plugins/goal-guard/sidebar-data.js +4 -5
- package/plugins/goal-guard/summary.js +52 -19
- package/plugins/goal-guard/tools.js +8 -0
- package/plugins/goal-sidebar.tsx +164 -0
- package/scripts/install.mjs +23 -6
- package/plugins/goal-sidebar.js +0 -147
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.
|
|
19
|
-
`{ tui }` plugin module that renders
|
|
20
|
-
|
|
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
|
|
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.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
`
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`
|
|
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
|
|
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)
|
|
8
8
|
[](package.json)
|
|
9
9
|
|
|
10
|
-
Strict Goal Mode for OpenCode: a primary `goal` agent,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
+
npx opencode-goal-mode --global
|
|
17
21
|
```
|
|
18
22
|
|
|
19
|
-
|
|
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
|
-
<
|
|
22
|
-
|
|
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
|
-
|
|
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
|
+

|
|
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,
|
|
50
|
-
|
|
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.
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
178
|
-
|
|
179
|
-
- **
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
- **
|
|
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`
|
|
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
|
|
192
|
-
|
|
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
|
|
200
|
-
**session** view (not the home screen
|
|
201
|
-
|
|
202
|
-
[visual test](tools/visual-test/README.md) (`npm run test:visual
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
284
|
-
| `sidebarColor` / `GOAL_GUARD_SIDEBAR_COLOR` | `#FFD700` |
|
|
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` |
|
|
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
|
-
##
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
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.
|
|
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": {
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (
|
|
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 "
|
|
25
|
-
export const NO_GOAL = Object.freeze({ state: "none", goal: "",
|
|
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
|
|
29
|
-
* three-way `state
|
|
30
|
-
* - `
|
|
31
|
-
* - `
|
|
32
|
-
* - `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/scripts/install.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 });
|
package/plugins/goal-sidebar.js
DELETED
|
@@ -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 };
|