opencode-goal-mode 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.2
4
+
5
+ ### The sidebar Goal section now actually renders in the live TUI
6
+
7
+ - **Root-cause fix.** The `sidebar_content` slot bailed at mount with
8
+ `return undefined` whenever no goal existed *yet* — but the goal is normally set
9
+ *after* the sidebar mounts, so the polling component never started and the Goal
10
+ section never appeared. The slot now **always mounts a reactive, polling
11
+ component** and reveals the section (via `<Show>`) the moment the goal is
12
+ recorded. Verified by driving the real OpenCode TUI in a PTY.
13
+ - **Stale plugin cache.** OpenCode caches TUI plugins under
14
+ `~/.cache/opencode/packages/<name>@<spec>/` and never re-checks npm, so upgrades
15
+ kept loading the *old* sidebar build. The installer now clears that cache on
16
+ install and uninstall, so a restart picks up the installed version.
17
+ - The first-display rainbow now triggers when the goal first appears (not at
18
+ mount, when there may be no goal yet).
19
+ - The sidebar resolves state under both the worktree and directory path keys, so a
20
+ path-key mismatch can't hide an active goal.
21
+
22
+ ### Sidebar layout
23
+
24
+ - The gate count and the lifecycle status are now on **separate lines**, each in
25
+ its own colour (GOAL = yellow, title = white, gates = cyan, status = orange).
26
+
27
+ ### Native todos are replaced in goal mode
28
+
29
+ - The `goal` agent no longer uses the native `todowrite` tool (it is disabled in
30
+ Goal Mode). Because OpenCode renders native todos as their own sidebar slot, the
31
+ only way to replace them is to stop producing them — so in a goal session the
32
+ native todo list stays empty and the structured Goal-owned section is what shows.
33
+ Build and every other mode keep their native todos.
34
+
3
35
  ## v0.4.1
4
36
 
5
37
  ### Restructured Goal sidebar todo section
package/README.md CHANGED
@@ -278,12 +278,18 @@ enforcement and writes its state to disk, and an experimental TUI plugin
278
278
  { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
279
279
  ```
280
280
 
281
- Restart OpenCode after install so it picks up the TUI plugin (it resolves the
282
- package and provides the `@opentui/solid` runtime). The Goal todo section appears
283
- in a **Goal session** view (not the home screen and not Build mode). The visual
284
- harness renders it with a headless OpenTUI renderer in
285
- [visual test](tools/visual-test/README.md) (`npm run test:visual`). The
286
- enforcement core is a separate server plugin and works regardless of the sidebar.
281
+ OpenCode installs the referenced package into its own plugin cache
282
+ (`~/.cache/opencode/packages/`) and provides the `@opentui/solid` + `solid-js`
283
+ runtime to it. It does **not** re-check that cache for newer versions, so the
284
+ installer clears the cached copy on install/uninstall that's why an upgrade
285
+ needs only a restart to load the new sidebar. Restart OpenCode after install. The
286
+ Goal todo section appears in a **Goal session** view (not the home screen and not
287
+ Build mode), and because the Goal agent does its own todo tracking (native
288
+ `todowrite` is disabled in Goal Mode), it replaces — rather than sits beside —
289
+ the native todo list while a goal is active. The visual harness renders the
290
+ component headlessly in [visual test](tools/visual-test/README.md)
291
+ (`npm run test:visual`); the enforcement core is a separate server plugin and
292
+ works regardless of the sidebar.
287
293
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
288
294
  (`toastOnReview`), and blocked destructive commands / premature completions
289
295
  toast as before (`toastOnBlock`).
package/agents/goal.md CHANGED
@@ -28,7 +28,7 @@ permission:
28
28
  "/projects/**": allow
29
29
  "~/.config/opencode/**": allow
30
30
  "~/.local/share/opencode/tool-output/**": allow
31
- todowrite: allow
31
+ todowrite: deny
32
32
  question: allow
33
33
  webfetch: allow
34
34
  websearch: allow
@@ -103,7 +103,7 @@ Operating loop:
103
103
  1. Establish the Goal Contract, constraints, current state, and acceptance criteria.
104
104
  2. If essential information is missing, ask all necessary clarifying questions immediately at the beginning. Do not defer avoidable questions into the build phase.
105
105
  3. Delegate research and discovery before editing. Use subagents to inspect local files, map structures, trace code paths, research docs, identify verification commands, and gather external web evidence.
106
- 4. Create and maintain a todo list for any non-trivial goal. Keep exactly one active item while working.
106
+ 4. Track progress through the Goal Contract acceptance criteria and the guard's evidence/gate state, not the native todo tool. Goal Mode owns the sidebar todo section: it derives a live, structured todo list from the acceptance criteria (checked off as you record evidence), dirty state, and outstanding review gates. Do not use `todowrite` (it is disabled in Goal Mode so the native todo list never competes with the Goal-owned section); call `goal_status`/`goal_evidence_map` when you need the current checklist.
107
107
  5. Implement the goal yourself in the main agent unless a bounded implementation subtask is explicitly safer to delegate.
108
108
  6. Run or delegate relevant checks, tests, builds, linters, typechecks, previews, or manual verification planning.
109
109
  7. When you believe the goal is finished, immediately run a strict review cycle before telling the user. The review must compare the original prompt and Goal Contract against the actual result.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "plugins/goal-sidebar.tsx",
@@ -23,7 +23,7 @@
23
23
  * the Node test suite.
24
24
  */
25
25
 
26
- import { createSignal, onCleanup, For, Show } from "solid-js";
26
+ import { createSignal, createEffect, onCleanup, For, Show } from "solid-js";
27
27
  import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
28
28
  import { DEFAULT_CONFIG } from "./goal-guard/config.js";
29
29
 
@@ -31,7 +31,8 @@ const DEFAULT_COLOR = "#FFD700"; // running — GOAL label, yellow
31
31
  const DEFAULT_DONE = "#FF5555"; // done — red
32
32
  const DEFAULT_MUTED = "#808080"; // pending todo rows — grey
33
33
  const TITLE_COLOR = "#FFFFFF"; // goal title line (running) — bright, distinct from the yellow GOAL label
34
- const META_COLOR = "#8BE9FD"; // gates · status line (running) — cyan accent
34
+ const META_COLOR = "#8BE9FD"; // gates line (running) — cyan accent
35
+ const STATUS_COLOR = "#FFB86C"; // status line (running) — orange, distinct from the cyan gates line
35
36
  const TODO_DONE_COLOR = "#50FA7B"; // ✓ done todo rows — green
36
37
  const POLL_MS = 1500;
37
38
  const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
@@ -85,16 +86,27 @@ function pickSession(snapshot, sessionId) {
85
86
  return null;
86
87
  }
87
88
 
88
- function readModel(worktree, sessionId) {
89
- try {
90
- const snapshot = readSnapshot(worktree);
91
- if (!snapshot) return NO_GOAL;
92
- const record = pickSession(snapshot, sessionId);
93
- if (!record) return NO_GOAL;
94
- return sidebarView(record, DEFAULT_CONFIG);
95
- } catch {
96
- return NO_GOAL;
89
+ /**
90
+ * Resolve the sidebar model for a session, trying each candidate worktree key in
91
+ * turn. The guard persists keyed by `worktree || directory`; the TUI may surface
92
+ * either path, so we try both (worktree first) rather than risk a key mismatch
93
+ * that would hide an active goal and leave the native todos showing.
94
+ */
95
+ function readModel(worktrees, sessionId) {
96
+ const keys = (Array.isArray(worktrees) ? worktrees : [worktrees]).filter(Boolean);
97
+ for (const wt of keys) {
98
+ try {
99
+ const snapshot = readSnapshot(wt);
100
+ if (!snapshot) continue;
101
+ const record = pickSession(snapshot, sessionId);
102
+ if (!record) continue;
103
+ const view = sidebarView(record, DEFAULT_CONFIG);
104
+ if (view && view.state !== "none") return view;
105
+ } catch {
106
+ /* try the next candidate */
107
+ }
97
108
  }
109
+ return NO_GOAL;
98
110
  }
99
111
 
100
112
  const id = "goal-mode-sidebar";
@@ -106,7 +118,9 @@ const tui = async (api, options) => {
106
118
  if (!enabled) return;
107
119
  if (!api?.slots?.register) return; // runtime without the slot API → no-op.
108
120
 
109
- const worktree = api.state?.path?.worktree || api.state?.path?.directory;
121
+ // The guard keys persisted state by worktree (falling back to directory).
122
+ // Surface both so a path-key mismatch can't hide an active goal.
123
+ const worktrees = [api.state?.path?.worktree, api.state?.path?.directory];
110
124
 
111
125
  api.slots.register({
112
126
  order: 50,
@@ -115,18 +129,38 @@ const tui = async (api, options) => {
115
129
  if (!props?.session_id) return undefined;
116
130
  const read = () => {
117
131
  try {
118
- return readModel(worktree, props?.session_id) || NO_GOAL;
132
+ return readModel(worktrees, props?.session_id) || NO_GOAL;
119
133
  } catch {
120
134
  return NO_GOAL;
121
135
  }
122
136
  };
123
- const initial = read();
124
- if (initial.state === "none") return undefined;
125
- const [model, setModel] = createSignal(initial);
126
- const [rainbow, setRainbow] = createSignal((rainbowMs || 0) > 0);
137
+ // ALWAYS mount a reactive, polling component — do NOT bail when there is
138
+ // no goal yet. The goal is normally recorded AFTER the sidebar mounts
139
+ // (the user opens the session, then states the goal), so the slot must
140
+ // keep polling and let <Show> reveal the section when the goal appears.
141
+ // Returning undefined at mount (the old behavior) meant the poll never
142
+ // ran and the Goal section never showed even once a goal existed.
143
+ const first = read();
144
+ const [model, setModel] = createSignal(first);
127
145
  const timer = setInterval(() => setModel(read()), POLL_MS);
128
- const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
129
146
  onCleanup(() => clearInterval(timer));
147
+ // First-display rainbow: starts the moment a goal FIRST appears. If a goal
148
+ // is already present at mount it starts immediately; otherwise the effect
149
+ // fires when the goal later appears (the common case — the goal is set
150
+ // after the sidebar mounts). Either way it settles after rainbowMs.
151
+ const [rainbow, setRainbow] = createSignal(false);
152
+ let rainbowStarted = false;
153
+ let rainbowTimer;
154
+ const startRainbow = () => {
155
+ if (rainbowStarted || (rainbowMs || 0) <= 0) return;
156
+ rainbowStarted = true;
157
+ setRainbow(true);
158
+ rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs));
159
+ };
160
+ if (first.state !== "none") startRainbow();
161
+ createEffect(() => {
162
+ if (model().state !== "none") startRainbow();
163
+ });
130
164
  onCleanup(() => clearTimeout(rainbowTimer));
131
165
  const isRainbow = () => rainbow() && model().state === "running";
132
166
  // Settled (post-rainbow) colour for each header line. When done, every
@@ -136,7 +170,8 @@ const tui = async (api, options) => {
136
170
  if (model().state === "done") return doneColor;
137
171
  if (kind === "label") return color; // GOAL — yellow
138
172
  if (kind === "title") return TITLE_COLOR; // goal title — bright white
139
- return META_COLOR; // gates · status — cyan
173
+ if (kind === "gates") return META_COLOR; // gate count — cyan
174
+ return STATUS_COLOR; // lifecycle status — orange
140
175
  };
141
176
  const lineColor = (index, kind) => (isRainbow() ? RAINBOW[index % RAINBOW.length] : settled(kind));
142
177
  const todoColor = (index, item) => {
@@ -144,17 +179,19 @@ const tui = async (api, options) => {
144
179
  if (item.status === "done") return TODO_DONE_COLOR;
145
180
  return model().state === "done" ? doneColor : muted;
146
181
  };
147
- // Goal sessions render a Goal-owned todo section (GOAL label, then the goal
148
- // title, status, and structured todos — each on its own line). Non-Goal /
149
- // no-goal sessions returned undefined above, so native todos remain.
182
+ // Goal sessions render a Goal-owned todo section GOAL label, goal title,
183
+ // gate count, lifecycle status, then structured todos — EACH on its own
184
+ // line in its own colour. Non-Goal / no-goal sessions returned undefined
185
+ // above, so the native todo section shows instead.
150
186
  return (
151
187
  <Show when={model().state !== "none"}>
152
188
  <box flexDirection="column" paddingTop={1}>
153
189
  <text fg={lineColor(0, "label")}><b>{model().label || "GOAL"}</b></text>
154
190
  <text fg={lineColor(1, "title")}>{model().goal}</text>
155
- <text fg={lineColor(2, "meta")}>{`${model().gates} · ${model().status}`}</text>
191
+ <text fg={lineColor(2, "gates")}>{model().gates}</text>
192
+ <text fg={lineColor(3, "status")}>{model().status}</text>
156
193
  <For each={model().todos || []}>
157
- {(item, index) => <text fg={todoColor(index() + 3, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
194
+ {(item, index) => <text fg={todoColor(index() + 4, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
158
195
  </For>
159
196
  </box>
160
197
  </Show>
@@ -183,6 +183,35 @@ function ensureTuiPlugin(remove = false) {
183
183
  return true;
184
184
  }
185
185
 
186
+ /**
187
+ * OpenCode caches TUI plugins under `~/.cache/opencode/packages/<name>@<spec>/`
188
+ * and does NOT re-check npm for a newer version, so after an upgrade it keeps
189
+ * loading the OLD sidebar build. Removing our cache entries forces OpenCode to
190
+ * re-fetch the just-installed version on its next start. Returns the dirs cleared.
191
+ */
192
+ function refreshTuiPluginCache() {
193
+ const base = (process.env.XDG_CACHE_HOME && process.env.XDG_CACHE_HOME.trim()) || join(homedir() || "", ".cache");
194
+ const pkgRoot = join(base, "opencode", "packages");
195
+ if (!existsSync(pkgRoot)) return [];
196
+ let entries;
197
+ try {
198
+ entries = readdirSync(pkgRoot);
199
+ } catch {
200
+ return [];
201
+ }
202
+ const ours = entries
203
+ .filter((name) => name === pkg.name || name.startsWith(`${pkg.name}@`))
204
+ .map((name) => join(pkgRoot, name));
205
+ for (const dir of ours) {
206
+ try {
207
+ if (!values["dry-run"]) rmSync(dir, { recursive: true, force: true });
208
+ } catch {
209
+ /* best-effort */
210
+ }
211
+ }
212
+ return ours;
213
+ }
214
+
186
215
  // ---------------------------------------------------------------------------
187
216
  // Uninstall
188
217
  // ---------------------------------------------------------------------------
@@ -205,6 +234,8 @@ if (values.uninstall) {
205
234
  const tuiRemoved = ensureTuiPlugin(true);
206
235
  if (!values["dry-run"]) pruneEmptyDirs(target, Object.keys(manifest.files));
207
236
  if (tuiRemoved) console.log(`${values["dry-run"] ? "Would remove" : "Removed"} the sidebar entry from ${join(target, "tui.json")}`);
237
+ const cachedCleared = refreshTuiPluginCache();
238
+ if (cachedCleared.length) console.log(`${values["dry-run"] ? "Would clear" : "Cleared"} OpenCode's cached TUI plugin (${cachedCleared.length}).`);
208
239
  const verb = values["dry-run"] ? "Would remove" : "Removed";
209
240
  console.log(`${verb} ${removed.length} Goal Mode files from ${target}.`);
210
241
  if (kept.length) {
@@ -302,4 +333,8 @@ console.log(
302
333
  `Files copied: ${summary.copied.length}; unchanged: ${summary.unchanged.length}; pruned: ${summary.pruned.length}`,
303
334
  );
304
335
  if (tuiAdded) console.log(`Registered the experimental sidebar in ${join(target, "tui.json")}`);
336
+ const cacheCleared = refreshTuiPluginCache();
337
+ if (cacheCleared.length) {
338
+ console.log(`${values["dry-run"] ? "Would clear" : "Cleared"} OpenCode's stale TUI plugin cache so the sidebar reloads at the installed version.`);
339
+ }
305
340
  console.log("Restart OpenCode for agents, commands, and plugins to load.");