opencode-goal-mode 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +14 -13
- package/CHANGELOG.md +12 -0
- package/README.md +68 -44
- package/package.json +10 -4
- 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 +31 -4
- package/plugins/goal-sidebar.tsx +63 -38
- package/scripts/install.mjs +23 -6
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,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Installer docs and `--help` now put the one-command `npx opencode-goal-mode --global`
|
|
6
|
+
flow first, clarify global vs project targets, and document the merge-safe
|
|
7
|
+
`tui.json` registration/uninstall behavior.
|
|
8
|
+
- The TUI companion now renders a Goal-owned, structured todo section only for
|
|
9
|
+
active Goal sessions, with a first-display rainbow effect before returning to
|
|
10
|
+
normal lifecycle colours. Non-Goal modes render nothing from the Goal plugin so
|
|
11
|
+
OpenCode's native todo section remains in place.
|
|
12
|
+
- Destructive-command blocking no longer activates Goal enforcement for Build or
|
|
13
|
+
other non-Goal sessions, preventing non-Goal tasks from being classified as goals.
|
|
14
|
+
|
|
3
15
|
## v0.3.7
|
|
4
16
|
|
|
5
17
|
- **FIX: the sidebar now actually loads.** OpenCode loads a TUI plugin via the
|
package/README.md
CHANGED
|
@@ -7,23 +7,24 @@
|
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](package.json)
|
|
9
9
|
|
|
10
|
-
Strict Goal Mode for OpenCode: a primary `goal` agent,
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
Strict Goal Mode for OpenCode: a primary `goal` agent, specialized review
|
|
11
|
+
subagents, slash commands, a `goal-guard` plugin that enforces review discipline
|
|
12
|
+
and blocks destructive shell commands, and a live Goal-owned todo section in the
|
|
13
13
|
TUI sidebar.
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
|
-
**One command** (needs [Node](https://nodejs.org) 20.11+ and [OpenCode](https://opencode.ai)):
|
|
17
|
+
**One command** (recommended; needs [Node](https://nodejs.org) 20.11+ and a working [OpenCode](https://opencode.ai) install):
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
npx opencode-goal-mode --global
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Then **restart OpenCode**. That's the whole install
|
|
24
|
-
review subagents, slash commands, and
|
|
25
|
-
|
|
26
|
-
you'll see only the **`goal`** agent
|
|
23
|
+
Then **restart OpenCode**. That's the whole install: it copies the Goal agent,
|
|
24
|
+
review subagents, slash commands, and guard plugin into `~/.config/opencode`, and
|
|
25
|
+
merge-safely registers the Goal todo sidebar in `~/.config/opencode/tui.json`.
|
|
26
|
+
In the agent picker you'll see only the **`goal`** agent; reviewers are subagents
|
|
27
|
+
it drives automatically. Goal Mode inherits your existing OpenCode model/provider.
|
|
27
28
|
|
|
28
29
|
<details>
|
|
29
30
|
<summary>Other ways to install</summary>
|
|
@@ -33,7 +34,7 @@ you'll see only the **`goal`** agent (the reviewers are subagents it drives).
|
|
|
33
34
|
npm install -g opencode-goal-mode
|
|
34
35
|
opencode-goal-mode --global # alias of opencode-goal-mode-install
|
|
35
36
|
|
|
36
|
-
# Into a single project (writes ./.opencode
|
|
37
|
+
# Into a single project (writes ./.opencode, including ./.opencode/tui.json)
|
|
37
38
|
npx opencode-goal-mode
|
|
38
39
|
|
|
39
40
|
# From source
|
|
@@ -41,14 +42,18 @@ git clone https://github.com/devinoldenburg/opencode-goal-mode
|
|
|
41
42
|
cd opencode-goal-mode && npm ci && npm run install:global
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
Use global install for normal daily use. Use project install only when you want
|
|
46
|
+
Goal Mode scoped to one repo and your OpenCode build reads project `.opencode`
|
|
47
|
+
config, including `.opencode/tui.json`. `--dry-run` previews changes;
|
|
48
|
+
`--uninstall` removes only what it installed (and its `tui.json` entry), leaving
|
|
49
|
+
your edits untouched. See [Installer options](#installer-options).
|
|
46
50
|
</details>
|
|
47
51
|
|
|
48
|
-

|
|
49
53
|
|
|
50
|
-
<sub>↑
|
|
51
|
-
goal
|
|
54
|
+
<sub>↑ In Goal mode, the sidebar todo slot becomes a Goal-owned todo section with
|
|
55
|
+
a first-display rainbow effect, then normal goal colours. Build and other modes
|
|
56
|
+
keep OpenCode's native todo section — see [TUI integration](#tui-integration).</sub>
|
|
52
57
|
|
|
53
58
|
**[Quick start](#quick-start) · [Why it's different](#why-its-different) · [Benchmarks](#benchmarks-honest-edition) · [TUI integration](#tui-integration) · [Configuration](#configuration) · [Releasing](#releasing) · [Architecture](ARCHITECTURE.md)**
|
|
54
59
|
|
|
@@ -74,8 +79,8 @@ opencode agent list | grep goal
|
|
|
74
79
|
**cannot** answer `Goal Completed` until every required review gate passes — the
|
|
75
80
|
guard rewrites a premature claim to `Goal Not Completed`. Try a destructive
|
|
76
81
|
command mid-session (e.g. `rm -rf build`) and watch it get blocked. If your
|
|
77
|
-
OpenCode build supports TUI plugins,
|
|
78
|
-
|
|
82
|
+
OpenCode build supports TUI plugins, Goal sessions also get the Goal-owned
|
|
83
|
+
sidebar todo section (experimental — see [TUI integration](#tui-integration)).
|
|
79
84
|
|
|
80
85
|
That's it. Everything below is detail.
|
|
81
86
|
|
|
@@ -159,7 +164,12 @@ second) — negligible for a per-tool-call guard:
|
|
|
159
164
|
## Requirements
|
|
160
165
|
|
|
161
166
|
- Node.js 20.11 or newer.
|
|
162
|
-
- OpenCode configured to load local agents, commands, and plugins.
|
|
167
|
+
- OpenCode configured to load local agents, commands, and plugins. The package is
|
|
168
|
+
tested against `@opencode-ai/plugin` 1.17.6 and declares compatibility with the
|
|
169
|
+
1.15+ plugin hook surface used here; newer OpenCode builds that change plugin
|
|
170
|
+
or TUI slot APIs may need a package update.
|
|
171
|
+
- A working OpenCode provider/model; Goal Mode does not configure API keys or
|
|
172
|
+
choose a model for you.
|
|
163
173
|
|
|
164
174
|
## What it adds
|
|
165
175
|
|
|
@@ -192,9 +202,10 @@ second) — negligible for a per-tool-call guard:
|
|
|
192
202
|
- **TUI toasts**: a toast on each review verdict (PASS/FAIL), with the
|
|
193
203
|
reviewer's friendly name, and a single "completion unlocked" toast the moment
|
|
194
204
|
the last required gate clears.
|
|
195
|
-
- An **experimental** companion TUI plugin (`plugins/goal-sidebar.
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
- An **experimental** companion TUI plugin (`plugins/goal-sidebar.tsx`) that, in
|
|
206
|
+
Goal sessions only, replaces the native todo sidebar area with a Goal-owned,
|
|
207
|
+
evidence-aware todo section. It shows a brief rainbow effect the first time it
|
|
208
|
+
appears, then normal goal colours. See [TUI integration](#tui-integration).
|
|
198
209
|
- A test suite validating the analyzer, plugin hooks, state store, install
|
|
199
210
|
safety, and config compatibility.
|
|
200
211
|
|
|
@@ -202,32 +213,38 @@ second) — negligible for a per-tool-call guard:
|
|
|
202
213
|
|
|
203
214
|
Goal Mode is a **plugin pair**: the server-side `goal-guard` plugin owns
|
|
204
215
|
enforcement and writes its state to disk, and an experimental TUI plugin
|
|
205
|
-
(`plugins/goal-sidebar.
|
|
206
|
-
|
|
207
|
-
- **
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
- **
|
|
216
|
+
(`plugins/goal-sidebar.tsx`) reads that same state to render a live todo section.
|
|
217
|
+
|
|
218
|
+
- **Goal-mode todo replacement.** In a `goal` session, the sidebar content/todo
|
|
219
|
+
area is replaced by a Goal-owned todo section: short goal title, gate progress,
|
|
220
|
+
lifecycle status, and structured todo rows derived from acceptance criteria,
|
|
221
|
+
evidence freshness, dirty state, and missing review gates. It starts with a
|
|
222
|
+
brief rainbow foreground effect (`sidebarRainbowMs`) so the replacement is
|
|
223
|
+
visible, then returns to the normal lifecycle colours:
|
|
224
|
+
- **yellow** — a goal is set and running;
|
|
225
|
+
- **red** — the goal is done (all required gates pass and the tree is clean);
|
|
226
|
+
- **no render** — Build and every non-Goal mode keep OpenCode's native todo
|
|
227
|
+
section in the same sidebar position instead of being classified as a goal.
|
|
214
228
|
|
|
215
229
|
Toggle/recolour with `sidebarBanner`, `sidebarColor` (running), `sidebarDoneColor`
|
|
216
|
-
(done), `sidebarMutedColor`
|
|
230
|
+
(done), `sidebarMutedColor`, `sidebarRainbowMs`, or the `GOAL_GUARD_SIDEBAR_*`
|
|
231
|
+
env vars.
|
|
217
232
|
|
|
218
233
|
**How it loads — important.** TUI plugins are **not** loaded from the `plugins/`
|
|
219
|
-
dir; OpenCode loads them from
|
|
220
|
-
|
|
234
|
+
dir; OpenCode loads them from `tui.json`. The Goal sidebar waits to register its
|
|
235
|
+
`sidebar_content` slot until a real Goal session exists, so non-Goal modes do not
|
|
236
|
+
get a blank replacement slot. With `--global`, the installer writes
|
|
237
|
+
`~/.config/opencode/tui.json` for you (merge-safe):
|
|
221
238
|
|
|
222
239
|
```json
|
|
223
240
|
{ "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
|
|
224
241
|
```
|
|
225
242
|
|
|
226
243
|
Restart OpenCode after install so it picks up the TUI plugin (it resolves the
|
|
227
|
-
package and provides the `@opentui/solid` runtime). The
|
|
228
|
-
**session** view (not the home screen
|
|
229
|
-
|
|
230
|
-
[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
|
|
231
248
|
enforcement core is a separate server plugin and works regardless of the sidebar.
|
|
232
249
|
- **Toasts.** Review verdicts and completion-unlock events surface as toasts
|
|
233
250
|
(`toastOnReview`), and blocked destructive commands / premature completions
|
|
@@ -236,16 +253,22 @@ enforcement and writes its state to disk, and an experimental TUI plugin
|
|
|
236
253
|
## Installer options
|
|
237
254
|
|
|
238
255
|
```bash
|
|
256
|
+
npx opencode-goal-mode --global --dry-run
|
|
257
|
+
npx opencode-goal-mode --global
|
|
258
|
+
opencode-goal-mode-install --global --uninstall
|
|
239
259
|
node scripts/install.mjs --dry-run
|
|
240
260
|
node scripts/install.mjs --target /path/to/opencode-config
|
|
241
261
|
node scripts/install.mjs --global --force
|
|
242
262
|
node scripts/install.mjs --global --uninstall
|
|
243
263
|
```
|
|
244
264
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
265
|
+
Default target rules are simple: `--global` writes to `~/.config/opencode`; no
|
|
266
|
+
flag writes to `./.opencode`; `--target` writes to exactly the directory you pass.
|
|
267
|
+
In every target, the installer copies only `agents/`, `commands/`, `plugins/`,
|
|
268
|
+
writes `.goal-mode-manifest.json`, and merge-safely adds `opencode-goal-mode` to
|
|
269
|
+
`tui.json` in that same target. On upgrade it replaces files it owns but refuses
|
|
270
|
+
to clobber files you have locally modified unless `--force` is passed.
|
|
271
|
+
`--uninstall` removes only owned files and removes only its own `tui.json` entry.
|
|
249
272
|
|
|
250
273
|
## Configuration
|
|
251
274
|
|
|
@@ -274,10 +297,11 @@ Or via environment variables (`GOAL_GUARD_*`):
|
|
|
274
297
|
| `sessionTtlMs` / `GOAL_GUARD_SESSION_TTL_MS` | `86400000` | Idle session TTL. |
|
|
275
298
|
| `toastOnBlock` / `GOAL_GUARD_TOAST_ON_BLOCK` | `true` | Toast when something is blocked. |
|
|
276
299
|
| `toastOnReview` / `GOAL_GUARD_TOAST_ON_REVIEW` | `true` | Toast on each review verdict and when completion unlocks. |
|
|
277
|
-
| `sidebarBanner` / `GOAL_GUARD_SIDEBAR_BANNER` | `true` | Show the experimental
|
|
278
|
-
| `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. |
|
|
279
302
|
| `sidebarDoneColor` / `GOAL_GUARD_SIDEBAR_DONE_COLOR` | `#FF5555` | Colour of a **done** goal in the sidebar (red). |
|
|
280
|
-
| `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` |
|
|
303
|
+
| `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Reserved muted colour for no-goal projections. |
|
|
304
|
+
| `sidebarRainbowMs` / `GOAL_GUARD_SIDEBAR_RAINBOW_MS` | `4500` | First-display rainbow duration for the Goal todo section. |
|
|
281
305
|
|
|
282
306
|
## Custom tools
|
|
283
307
|
|
|
@@ -297,7 +321,7 @@ criterion against recorded evidence, reviewer status, gaps, and the next
|
|
|
297
321
|
required action. The command is backed by the `goal_evidence_map` tool, so it
|
|
298
322
|
uses persisted Goal Guard state rather than relying on transcript memory.
|
|
299
323
|
|
|
300
|
-
##
|
|
324
|
+
## Contributor validation
|
|
301
325
|
|
|
302
326
|
```bash
|
|
303
327
|
npm test
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-mode",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "plugins/goal-sidebar.tsx",
|
|
@@ -99,8 +99,14 @@
|
|
|
99
99
|
"solid-js": "*"
|
|
100
100
|
},
|
|
101
101
|
"peerDependenciesMeta": {
|
|
102
|
-
"@opencode-ai/plugin": {
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
"@opencode-ai/plugin": {
|
|
103
|
+
"optional": true
|
|
104
|
+
},
|
|
105
|
+
"@opentui/solid": {
|
|
106
|
+
"optional": true
|
|
107
|
+
},
|
|
108
|
+
"solid-js": {
|
|
109
|
+
"optional": true
|
|
110
|
+
}
|
|
105
111
|
}
|
|
106
112
|
}
|
|
@@ -28,14 +28,16 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
28
28
|
toastOnBlock: true,
|
|
29
29
|
/** Emit a TUI toast when a review gate records a PASS/FAIL, and when completion unlocks. */
|
|
30
30
|
toastOnReview: true,
|
|
31
|
-
/** Show the experimental
|
|
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).
|
|
@@ -27,18 +27,40 @@ export function shortGoalLabel(state, max = 80) {
|
|
|
27
27
|
return `${base.slice(0, max - 1).trimEnd()}…`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Sentinel for "
|
|
30
|
+
/** Sentinel for "no active Goal session" — the TUI plugin renders nothing so native todos remain. */
|
|
31
31
|
export const NO_GOAL = Object.freeze({ state: "none", goal: "", gates: "", status: "" });
|
|
32
32
|
|
|
33
|
+
function criterionEvidenceFresh(state, criterion) {
|
|
34
|
+
const entries = Array.isArray(state.evidence) ? state.evidence : [];
|
|
35
|
+
return entries.some((entry) => evidenceMatchesCriterion(entry, criterion) && evidenceFresh(entry, state));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sidebarTodos(state, required, missing) {
|
|
39
|
+
const criteria = Array.isArray(state?.contract?.acceptanceCriteria) ? state.contract.acceptanceCriteria : [];
|
|
40
|
+
const items = [];
|
|
41
|
+
for (const criterion of criteria.slice(0, 5)) {
|
|
42
|
+
const text = String(criterion || "").replace(/\s+/g, " ").trim();
|
|
43
|
+
if (!text) continue;
|
|
44
|
+
items.push({
|
|
45
|
+
status: criterionEvidenceFresh(state, text) ? "done" : "todo",
|
|
46
|
+
text: text.length <= 58 ? text : `${text.slice(0, 57).trimEnd()}…`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (state?.dirty) items.push({ status: "todo", text: "Rerun verification and reviews after latest changes" });
|
|
50
|
+
if (missing.length > 0) items.push({ status: "todo", text: `Clear review gates: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? "…" : ""}` });
|
|
51
|
+
if (items.length === 0 && required.length > 0) items.push({ status: "todo", text: "Record Goal Contract acceptance criteria" });
|
|
52
|
+
return items.slice(0, 7);
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
/**
|
|
34
|
-
* Compact projection for the TUI sidebar
|
|
56
|
+
* Compact projection for the TUI sidebar todo section. ALWAYS returns an object with a
|
|
35
57
|
* three-way `state`, plus three lines that stack vertically in the sidebar:
|
|
36
58
|
* - `goal` → line 1: the short AI goal title.
|
|
37
59
|
* - `gates` → line 2: the gate count, e.g. "0/7 gates".
|
|
38
60
|
* - `status` → line 3: the lifecycle status, e.g. "in progress · changes pending"
|
|
39
61
|
* or "completed · 2 review cycles".
|
|
40
|
-
* State drives colour: "running" =
|
|
41
|
-
*
|
|
62
|
+
* State drives colour: "running" = rainbow first, then yellow; "done" = red;
|
|
63
|
+
* "none" = render nothing so non-Goal modes keep the native todo section.
|
|
42
64
|
*/
|
|
43
65
|
export function sidebarView(state, config) {
|
|
44
66
|
if (!state || !state.active) return NO_GOAL;
|
|
@@ -49,6 +71,7 @@ export function sidebarView(state, config) {
|
|
|
49
71
|
const passing = required.length - missing.length;
|
|
50
72
|
const cycles = Number(state.reviewCycles) || 0;
|
|
51
73
|
const gates = `${passing}/${required.length} gates`;
|
|
74
|
+
const todos = sidebarTodos(state, required, missing);
|
|
52
75
|
const done = required.length > 0 && missing.length === 0 && !state.dirty;
|
|
53
76
|
if (done) {
|
|
54
77
|
return {
|
|
@@ -56,6 +79,8 @@ export function sidebarView(state, config) {
|
|
|
56
79
|
goal,
|
|
57
80
|
gates,
|
|
58
81
|
status: `completed · ${cycles} review cycle${cycles === 1 ? "" : "s"}`,
|
|
82
|
+
todoTitle: "Goal todos",
|
|
83
|
+
todos: todos.length ? todos.map((item) => ({ ...item, status: "done" })) : [{ status: "done", text: "All Goal completion gates are clear" }],
|
|
59
84
|
passing,
|
|
60
85
|
required: required.length,
|
|
61
86
|
reviewCycles: cycles,
|
|
@@ -66,6 +91,8 @@ export function sidebarView(state, config) {
|
|
|
66
91
|
goal,
|
|
67
92
|
gates,
|
|
68
93
|
status: `in progress${state.dirty ? " · changes pending" : ""}`,
|
|
94
|
+
todoTitle: "Goal todos",
|
|
95
|
+
todos,
|
|
69
96
|
passing,
|
|
70
97
|
required: required.length,
|
|
71
98
|
reviewCycles: cycles,
|
package/plugins/goal-sidebar.tsx
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
/**
|
|
3
|
-
* Goal Mode — TUI sidebar
|
|
3
|
+
* Goal Mode — TUI sidebar todo section.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 3. `<status>` (in progress / changes pending / completed …)
|
|
9
|
-
* Colour tracks lifecycle: yellow while running, red when done, grey
|
|
10
|
-
* "No goal available" when a task has no goal set.
|
|
5
|
+
* In Goal agent sessions this replaces the native-looking todo area with a
|
|
6
|
+
* Goal-owned, evidence-aware todo section. Non-Goal sessions render nothing here
|
|
7
|
+
* so Build and other modes keep OpenCode's normal todo section in the same slot.
|
|
11
8
|
*
|
|
12
9
|
* How OpenCode loads this: TUI plugins are listed in `~/.config/opencode/tui.json`
|
|
13
10
|
* (NOT the plugins/ dir) and resolved via the package's `exports["./tui"]`. The
|
|
@@ -21,7 +18,7 @@
|
|
|
21
18
|
* the Node test suite.
|
|
22
19
|
*/
|
|
23
20
|
|
|
24
|
-
import { createSignal, onCleanup, Show } from "solid-js";
|
|
21
|
+
import { createSignal, onCleanup, For, Show } from "solid-js";
|
|
25
22
|
import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
|
|
26
23
|
import { DEFAULT_CONFIG } from "./goal-guard/config.js";
|
|
27
24
|
|
|
@@ -29,6 +26,7 @@ const DEFAULT_COLOR = "#FFD700"; // running — yellow
|
|
|
29
26
|
const DEFAULT_DONE = "#FF5555"; // done — red
|
|
30
27
|
const DEFAULT_MUTED = "#808080"; // no goal — grey
|
|
31
28
|
const POLL_MS = 1500;
|
|
29
|
+
const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
|
|
32
30
|
|
|
33
31
|
function resolveOptions(options, env) {
|
|
34
32
|
const e = env || {};
|
|
@@ -41,6 +39,7 @@ function resolveOptions(options, env) {
|
|
|
41
39
|
color: options?.sidebarColor || e.GOAL_GUARD_SIDEBAR_COLOR || DEFAULT_COLOR,
|
|
42
40
|
doneColor: options?.sidebarDoneColor || e.GOAL_GUARD_SIDEBAR_DONE_COLOR || DEFAULT_DONE,
|
|
43
41
|
muted: options?.sidebarMutedColor || e.GOAL_GUARD_SIDEBAR_MUTED_COLOR || DEFAULT_MUTED,
|
|
42
|
+
rainbowMs: Number(options?.sidebarRainbowMs ?? e.GOAL_GUARD_SIDEBAR_RAINBOW_MS ?? 4500),
|
|
44
43
|
};
|
|
45
44
|
}
|
|
46
45
|
|
|
@@ -70,6 +69,7 @@ function pickSession(snapshot, sessionId) {
|
|
|
70
69
|
if (sessionId) {
|
|
71
70
|
const direct = records.find(([key, st]) => key === sessionId && st.active);
|
|
72
71
|
if (direct) return direct[1];
|
|
72
|
+
return null;
|
|
73
73
|
}
|
|
74
74
|
const active = records.filter(([, st]) => st.active);
|
|
75
75
|
if (active.length === 0) return null;
|
|
@@ -94,43 +94,68 @@ const id = "goal-mode-sidebar";
|
|
|
94
94
|
/** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
|
|
95
95
|
const tui = async (api, options) => {
|
|
96
96
|
try {
|
|
97
|
-
const { enabled, color, doneColor,
|
|
97
|
+
const { enabled, color, doneColor, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
|
|
98
98
|
if (!enabled) return;
|
|
99
99
|
if (!api?.slots?.register) return; // runtime without the slot API → no-op.
|
|
100
100
|
|
|
101
101
|
const worktree = api.state?.path?.worktree || api.state?.path?.directory;
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
103
|
+
let registered = false;
|
|
104
|
+
const register = () => {
|
|
105
|
+
if (registered) return;
|
|
106
|
+
registered = true;
|
|
107
|
+
api.slots.register({
|
|
108
|
+
order: 50,
|
|
109
|
+
slots: {
|
|
110
|
+
sidebar_content(_ctx, props) {
|
|
111
|
+
if (!props?.session_id) return undefined;
|
|
112
|
+
const read = () => {
|
|
113
|
+
try {
|
|
114
|
+
return readModel(worktree, props?.session_id) || NO_GOAL;
|
|
115
|
+
} catch {
|
|
116
|
+
return NO_GOAL;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const initial = read();
|
|
120
|
+
if (initial.state === "none") return undefined;
|
|
121
|
+
const [model, setModel] = createSignal(initial);
|
|
122
|
+
const [rainbow, setRainbow] = createSignal((rainbowMs || 0) > 0);
|
|
123
|
+
const timer = setInterval(() => setModel(read()), POLL_MS);
|
|
124
|
+
const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
|
|
125
|
+
onCleanup(() => clearInterval(timer));
|
|
126
|
+
onCleanup(() => clearTimeout(rainbowTimer));
|
|
127
|
+
const fg = () => (model().state === "done" ? doneColor : color);
|
|
128
|
+
const lineColor = (index = 0) => (rainbow() && model().state === "running" ? RAINBOW[index % RAINBOW.length] : fg());
|
|
129
|
+
// Goal sessions render a Goal-owned todo section; non-Goal sessions return undefined so native todos remain.
|
|
130
|
+
return (
|
|
131
|
+
<Show when={model().state !== "none"}>
|
|
132
|
+
<box flexDirection="column" paddingTop={1}>
|
|
133
|
+
<text fg={lineColor(0)}>
|
|
134
|
+
<b>{model().todoTitle || "Goal todos"}</b>
|
|
135
|
+
{` ${model().goal}`}
|
|
136
|
+
</text>
|
|
137
|
+
<text fg={lineColor(1)}>{`${model().gates} · ${model().status}`}</text>
|
|
138
|
+
<For each={model().todos || []}>
|
|
139
|
+
{(item, index) => <text fg={lineColor(index() + 2)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
|
|
140
|
+
</For>
|
|
141
|
+
</box>
|
|
128
142
|
</Show>
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
);
|
|
144
|
+
},
|
|
131
145
|
},
|
|
132
|
-
}
|
|
133
|
-
}
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (readModel(worktree).state !== "none") {
|
|
150
|
+
register();
|
|
151
|
+
} else {
|
|
152
|
+
const registrationTimer = setInterval(() => {
|
|
153
|
+
if (readModel(worktree).state !== "none") {
|
|
154
|
+
clearInterval(registrationTimer);
|
|
155
|
+
register();
|
|
156
|
+
}
|
|
157
|
+
}, POLL_MS);
|
|
158
|
+
}
|
|
134
159
|
} catch {
|
|
135
160
|
/* TUI runtime missing or API drift — render nothing rather than crash. */
|
|
136
161
|
}
|
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 });
|