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 +52 -0
- package/README.md +6 -4
- package/docs/sidebar-preview.png +0 -0
- package/package.json +4 -2
- package/plugins/goal-guard/gates.js +1 -1
- package/plugins/goal-guard/guard.js +17 -9
- package/plugins/goal-guard/summary.js +6 -3
- package/plugins/goal-sidebar.tsx +61 -2
- package/scripts/postinstall.mjs +53 -0
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)
|
|
61
61
|
[](package.json)
|
|
62
62
|
|
|
63
|
-

|
|
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
|
-
-
|
|
249
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
package/plugins/goal-sidebar.tsx
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
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);
|