opencode-goal-mode 0.4.2 → 0.4.4

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,57 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.4
4
+
5
+ ### Sidebar todos and gates stay correct and up to date
6
+
7
+ - **Acceptance-criterion todos now check off.** They were matched against the
8
+ *display-clipped* criterion text, so any criterion longer than the sidebar width
9
+ never showed as done even with exact matching evidence. Matching now uses the full
10
+ criterion text (clipping is display-only). Verified live in the OpenCode TUI.
11
+ - **The Goal section refreshes promptly.** It now updates on OpenCode activity
12
+ events (`message.part.updated`, …) — the same mechanism the reference TUI plugin
13
+ uses — in addition to the polling fallback, and forces a repaint on each refresh,
14
+ so gates and todos track the goal's real state as reviewers pass and evidence is
15
+ recorded instead of going stale.
16
+
17
+ ### Docs
18
+
19
+ - The README preview is now a real TUI screenshot (`docs/sidebar-preview.png`).
20
+
21
+ ## v0.4.3
22
+
23
+ ### Build mode no longer behaves like a goal
24
+
25
+ - Switching a session from the `goal` agent to Build (or any non-goal agent) now
26
+ **deactivates** it: `state.active` tracks the current agent (`isPrimaryAgent`),
27
+ instead of latching `true` forever. The sidebar also gates on the session's live
28
+ current agent (its latest message), so when you switch to Build the Goal section
29
+ disappears and OpenCode's native todos return — and a Build session can no longer
30
+ invoke the `goal-*` subagents or have its completion claims policed.
31
+ - Goal **worker** subagents (e.g. `goal-implementer`) are no longer activated by
32
+ their own edits, so Goal completion enforcement is never injected into a worker's
33
+ prompt. Bookkeeping runs only for active goal sessions and review subagents.
34
+
35
+ ### `npm install -g` now updates everything, automatically
36
+
37
+ - A global-install `postinstall` runs the installer for you — it copies the
38
+ components into `~/.config/opencode`, registers the Goal sidebar, and clears
39
+ OpenCode's stale plugin cache — so `npm install -g opencode-goal-mode` **alone**
40
+ fully installs or upgrades Goal Mode and the new version actually loads on the
41
+ next restart. It runs only for global installs, never for repo/dev/dependency
42
+ installs, and never fails the npm install (it prints a hint if it can't finish).
43
+
44
+ ### Fixes
45
+
46
+ - **CI green again:** the headless visual test no longer requires
47
+ `@opentui/solid/jsx-dev-runtime` (the CI visual job was failing).
48
+ - Hardened `gatePassedFresh` against partial/legacy snapshots so the TUI never
49
+ silently fails to render an active goal.
50
+ - Compaction context is only added for active goal sessions (no stray Goal state in
51
+ a Build session's compaction summary).
52
+ - Docs: corrected the sidebar description (separate gate/status lines; the label is
53
+ `GOAL`, not "Goal todos").
54
+
3
55
  ## v0.4.2
4
56
 
5
57
  ### The sidebar Goal section now actually renders in the live TUI
package/README.md CHANGED
@@ -60,7 +60,7 @@ config, including `.opencode/tui.json`. See [Installer options](#installer-optio
60
60
  [![license](https://img.shields.io/npm/l/opencode-goal-mode?color=2da44e)](LICENSE)
61
61
  [![node](https://img.shields.io/node/v/opencode-goal-mode?color=2da44e)](package.json)
62
62
 
63
- ![OpenCode Goal Mode sidebar todo section](docs/sidebar-demo.svg)
63
+ ![OpenCode Goal Mode sidebar preview](docs/sidebar-preview.png)
64
64
 
65
65
  <sub>↑ In goal mode, the Goal plugin takes over the sidebar todo section with a
66
66
  structured, evidence-aware Goal todo list — a bold `GOAL` label, then the goal
@@ -244,9 +244,11 @@ enforcement and writes its state to disk, and an experimental TUI plugin
244
244
  slot, stacked on separate lines, each in its own colour so it never reads as one
245
245
  run of text:
246
246
  - a bold **`GOAL`** label (yellow while running, red when done);
247
- - the short goal title;
248
- - a `passing/total gates · status` line (lifecycle only no "changes pending"
249
- noise; pending work shows as a todo row instead);
247
+ - the short goal title (white);
248
+ - the gate count `passing/total gates` (cyan), on its own line;
249
+ - the lifecycle status (orange) on its own line — `in progress`, or
250
+ `completed · N review cycles`. No "changes pending" noise; pending work shows
251
+ as a todo row instead;
250
252
  - structured todo rows derived from real guard state: one per acceptance
251
253
  criterion (✓ when fresh evidence covers it), a re-verify row when the tree
252
254
  changed, and one row per still-missing review gate by friendly name
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "plugins/goal-sidebar.tsx",
@@ -25,6 +25,7 @@
25
25
  "plugins/",
26
26
  "research/",
27
27
  "scripts/install.mjs",
28
+ "scripts/postinstall.mjs",
28
29
  "ARCHITECTURE.md",
29
30
  "CHANGELOG.md",
30
31
  "LICENSE",
@@ -51,7 +52,8 @@
51
52
  "ci": "npm run validate && npm run audit",
52
53
  "prepublishOnly": "npm run ci && npm run publish:check",
53
54
  "install:local": "node scripts/install.mjs",
54
- "install:global": "node scripts/install.mjs --global"
55
+ "install:global": "node scripts/install.mjs --global",
56
+ "postinstall": "node scripts/postinstall.mjs"
55
57
  },
56
58
  "keywords": [
57
59
  "opencode",
@@ -71,7 +71,7 @@ export function requiredGates(state, config) {
71
71
 
72
72
  /** A gate is satisfied when its latest verdict is PASS and newer than the last edit. */
73
73
  export function gatePassedFresh(state, agent) {
74
- const v = state.latestVerdict[agent];
74
+ const v = state.latestVerdict?.[agent];
75
75
  if (!v || v.verdict !== "PASS") return false;
76
76
  return v.seq > (state.lastEditSeq || 0);
77
77
  }
@@ -93,7 +93,10 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
93
93
  try {
94
94
  if (!inp?.sessionID) return;
95
95
  const state = store.stateFor(inp.sessionID);
96
- if (isPrimaryAgent(inp.agent)) state.active = true;
96
+ // `active` reflects whether this session is CURRENTLY a Goal session. Switching
97
+ // the session's agent to Build (or anything non-goal) must deactivate it, or
98
+ // the sidebar/guard would keep treating an explicit Build session as a goal.
99
+ if (inp.agent) state.active = isPrimaryAgent(inp.agent);
97
100
  const text = partsText(out?.parts);
98
101
  if (text && state.active) {
99
102
  // Accumulate goal text (bounded) so contextual gates can be derived.
@@ -115,7 +118,10 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
115
118
  if (!normalized) return;
116
119
  const state = store.stateFor(normalized);
117
120
  state.currentAgent = inp.agent;
118
- if (isPrimaryAgent(inp.agent)) state.active = true;
121
+ // Track the current mode: a session is active (a goal) only while its agent
122
+ // is the goal primary. Switching to Build/Plan/etc. deactivates it so the
123
+ // Goal sidebar and enforcement stop treating it as a goal.
124
+ if (inp.agent) state.active = isPrimaryAgent(inp.agent);
119
125
  } catch {
120
126
  /* ignore */
121
127
  }
@@ -178,13 +184,14 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
178
184
  async "tool.execute.after"(inp, out) {
179
185
  try {
180
186
  const state = store.stateFor(inp?.sessionID);
181
- // Goal bookkeeping is GOAL-ONLY. A Build/Plan/custom session must never have
182
- // its edits, mutations, verification, or verdicts recorded as goal state
183
- // otherwise the guard would treat a non-Goal message/task as if it were a
184
- // goal. Only an active Goal session (or a goal-namespace subagent's own
185
- // session, for verdict capture) is tracked. Destructive-command blocking is
186
- // handled in tool.execute.before and still applies in every mode.
187
- if (!state.active && !isGoalAgent(state.currentAgent)) return;
187
+ // Goal bookkeeping runs only for an active Goal session, or for a REVIEW
188
+ // subagent's own child session (so the agent-path can capture its verdict).
189
+ // It must NOT run for a Build/Plan/custom session, nor for a non-review goal
190
+ // WORKER child session (e.g. goal-implementer/goal-explorer) otherwise that
191
+ // worker's edits would mark it dirty and activate it, leaking goal completion
192
+ // enforcement into a worker's prompt. Destructive-command blocking lives in
193
+ // tool.execute.before and still applies in every mode.
194
+ if (!state.active && !isReviewAgent(state.currentAgent)) return;
188
195
  const tool = inp?.tool;
189
196
  const isReviewing = isReviewAgent(state.currentAgent);
190
197
 
@@ -278,6 +285,7 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
278
285
  try {
279
286
  if (!inp?.sessionID || !out || !Array.isArray(out.context)) return;
280
287
  const state = store.stateFor(inp.sessionID);
288
+ if (!state.active) return; // only preserve goal state for active Goal sessions
281
289
  out.context.push(
282
290
  `Goal Guard state: ${summarizeState(state, config)}. Preserve Goal Contract, Verification Ledger, ` +
283
291
  `Review Ledger, Reviewer Memory, review cycle count, dirty state, and open findings across compaction.`,
@@ -54,9 +54,12 @@ function sidebarTodos(state, required, missing) {
54
54
  const criteria = Array.isArray(state?.contract?.acceptanceCriteria) ? state.contract.acceptanceCriteria : [];
55
55
  const items = [];
56
56
  for (const criterion of criteria.slice(0, 4)) {
57
- const text = clip(criterion, 52);
58
- if (!text) continue;
59
- items.push({ status: criterionEvidenceFresh(state, text) ? "done" : "todo", text });
57
+ // Match evidence against the FULL criterion text; clip only for display. (Clipping
58
+ // before matching meant any criterion longer than the display width never checked
59
+ // off the recorded evidence carries the full text.)
60
+ const full = String(criterion || "").replace(/\s+/g, " ").trim();
61
+ if (!full) continue;
62
+ items.push({ status: criterionEvidenceFresh(state, full) ? "done" : "todo", text: clip(full, 52) });
60
63
  }
61
64
  if (state?.dirty) items.push({ status: "todo", text: "Re-verify & re-review after recent edits" });
62
65
  // One row per missing/stale review gate, by friendly name — more scannable than a
@@ -35,6 +35,7 @@ const META_COLOR = "#8BE9FD"; // gates line (running) — cyan accent
35
35
  const STATUS_COLOR = "#FFB86C"; // status line (running) — orange, distinct from the cyan gates line
36
36
  const TODO_DONE_COLOR = "#50FA7B"; // ✓ done todo rows — green
37
37
  const POLL_MS = 1500;
38
+ const GOAL_AGENT = "goal"; // the primary Goal agent id (mirrors agents.js PRIMARY_AGENT)
38
39
  const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
39
40
 
40
41
  function resolveOptions(options, env) {
@@ -127,8 +128,33 @@ const tui = async (api, options) => {
127
128
  slots: {
128
129
  sidebar_content(_ctx, props) {
129
130
  if (!props?.session_id) return undefined;
131
+ // The session's CURRENT agent, from its latest message (mirrors the
132
+ // reference OpenCode TUI plugin). This is the authoritative, immediate
133
+ // signal of whether the session is in Goal mode right now — so when the
134
+ // user switches to Build (or any non-goal agent) the Goal section vanishes
135
+ // and OpenCode's native todos return, without waiting for persisted state.
136
+ const currentAgent = () => {
137
+ try {
138
+ const msgs = api?.state?.session?.messages?.(props.session_id);
139
+ if (Array.isArray(msgs)) {
140
+ for (let i = msgs.length - 1; i >= 0; i--) {
141
+ const a = msgs[i] && msgs[i].agent;
142
+ if (a) return String(a).toLowerCase();
143
+ }
144
+ }
145
+ } catch {
146
+ /* messages API unavailable — fall back to persisted goal state */
147
+ }
148
+ return undefined;
149
+ };
130
150
  const read = () => {
131
151
  try {
152
+ const agent = currentAgent();
153
+ // Render only in Goal mode. "goal" and its `goal-*` subagents count as
154
+ // Goal mode (so the section doesn't flicker out while reviewers run);
155
+ // any other primary agent (build/plan/custom) renders nothing here.
156
+ const goalMode = !agent || agent === GOAL_AGENT || agent.startsWith(`${GOAL_AGENT}-`);
157
+ if (!goalMode) return NO_GOAL;
132
158
  return readModel(worktrees, props?.session_id) || NO_GOAL;
133
159
  } catch {
134
160
  return NO_GOAL;
@@ -141,9 +167,42 @@ const tui = async (api, options) => {
141
167
  // Returning undefined at mount (the old behavior) meant the poll never
142
168
  // ran and the Goal section never showed even once a goal existed.
143
169
  const first = read();
144
- const [model, setModel] = createSignal(first);
145
- const timer = setInterval(() => setModel(read()), POLL_MS);
170
+ // equals:false → every refresh re-notifies, so a changed snapshot always
171
+ // repaints (gates/todos stay live even when the new object compares equal).
172
+ const [model, setModel] = createSignal(first, { equals: false });
173
+ const refresh = () => setModel(read());
174
+ // Refresh on OpenCode activity. Tool calls (verdicts, evidence, edits)
175
+ // change the gates/todos and emit message-part events, so subscribing here
176
+ // keeps the section up to date promptly — this is the mechanism the
177
+ // reference OpenCode TUI plugin uses. The interval is a fallback for any
178
+ // quiet period or runtime where the event bus is unavailable.
179
+ const offs = [];
180
+ try {
181
+ const bus = api && api.event;
182
+ if (bus && typeof bus.on === "function") {
183
+ for (const ev of ["message.part.updated", "message.updated", "session.idle"]) {
184
+ try {
185
+ const off = bus.on(ev, refresh);
186
+ if (typeof off === "function") offs.push(off);
187
+ } catch {
188
+ /* unknown event type on this OpenCode build — skip it */
189
+ }
190
+ }
191
+ }
192
+ } catch {
193
+ /* no event bus — rely on the interval */
194
+ }
195
+ const timer = setInterval(refresh, POLL_MS);
146
196
  onCleanup(() => clearInterval(timer));
197
+ onCleanup(() => {
198
+ for (const off of offs) {
199
+ try {
200
+ off();
201
+ } catch {
202
+ /* ignore */
203
+ }
204
+ }
205
+ });
147
206
  // First-display rainbow: starts the moment a goal FIRST appears. If a goal
148
207
  // is already present at mount it starts immediately; otherwise the effect
149
208
  // fires when the goal later appears (the common case — the goal is set
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Auto-setup on a GLOBAL install.
4
+ *
5
+ * When a user runs `npm install -g opencode-goal-mode` (a fresh install OR an
6
+ * upgrade), this runs the installer for them so that, with a single command, the
7
+ * package's components are (re)copied into `~/.config/opencode`, the Goal sidebar
8
+ * is registered in `tui.json`, and OpenCode's stale plugin cache is cleared so the
9
+ * just-installed version actually loads. That makes `npm install -g` "really
10
+ * update" — no separate `opencode-goal-mode --global` step required.
11
+ *
12
+ * Safety:
13
+ * - Runs ONLY for global installs (npm sets `npm_config_global`). Repo development
14
+ * (`npm ci` / `npm install`) and installs as a project dependency are no-ops, so
15
+ * this never writes to a user's config when it shouldn't.
16
+ * - Best-effort: it NEVER fails the npm install. Any problem prints a one-line
17
+ * hint to run `opencode-goal-mode --global` manually and exits 0.
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ import { fileURLToPath } from "node:url";
21
+ import { join, dirname } from "node:path";
22
+
23
+ function isGlobalInstall() {
24
+ const g = process.env.npm_config_global;
25
+ return g === "true" || g === "1" || g === true;
26
+ }
27
+
28
+ if (!isGlobalInstall()) {
29
+ // Local/dev/dependency install — do not touch the user's OpenCode config.
30
+ process.exit(0);
31
+ }
32
+
33
+ try {
34
+ const installer = join(dirname(fileURLToPath(import.meta.url)), "install.mjs");
35
+ console.log("opencode-goal-mode: global install detected — updating ~/.config/opencode…");
36
+ const res = spawnSync(process.execPath, [installer, "--global"], { stdio: "inherit" });
37
+ // spawnSync does not throw on a non-zero exit (e.g. the installer refused to
38
+ // overwrite a file you edited). Surface a clear, actionable hint instead of
39
+ // leaving only the child's raw error.
40
+ if (res.error || res.status !== 0) {
41
+ console.warn(
42
+ "opencode-goal-mode: auto-setup didn't fully complete. " +
43
+ "Run `opencode-goal-mode --global` manually (add --force to replace files you've edited), then restart OpenCode.",
44
+ );
45
+ }
46
+ } catch (err) {
47
+ console.warn(
48
+ `opencode-goal-mode: auto-setup skipped (${(err && err.message) || err}). ` +
49
+ "Run `opencode-goal-mode --global` to finish, then restart OpenCode.",
50
+ );
51
+ }
52
+ // Never fail `npm install`, regardless of the installer's outcome.
53
+ process.exit(0);