opencode-goal-mode 0.3.0 → 0.3.1
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 +9 -1
- package/CHANGELOG.md +13 -0
- package/README.md +10 -7
- package/package.json +2 -1
- package/plugins/goal-guard/config.js +3 -0
- package/plugins/goal-guard/sidebar-data.js +7 -5
- package/plugins/goal-guard/summary.js +10 -6
- package/plugins/goal-sidebar.js +17 -14
package/ARCHITECTURE.md
CHANGED
|
@@ -170,7 +170,9 @@ hooks still load.
|
|
|
170
170
|
`{ tui }` module, mutually exclusive with `{ server }`). It registers a
|
|
171
171
|
`sidebar_content` slot via `api.slots.register({ slots: { sidebar_content } })`
|
|
172
172
|
and renders, in the configured colour (`#FFD700` by default), the short goal
|
|
173
|
-
label plus a `passing/total gates · dirty/ready` line.
|
|
173
|
+
label plus a `passing/total gates · dirty/ready` line. It renders
|
|
174
|
+
unconditionally: when a task is running with no goal set, it shows a muted grey
|
|
175
|
+
`No goal` (`sidebarView` returns `{ hasGoal: false }`) rather than a blank slot.
|
|
174
176
|
|
|
175
177
|
It is *paired* with the server plugin only through the persisted state file:
|
|
176
178
|
`sidebar-data.js` recomputes the same `stateBaseDir`/`projectKey` path the guard
|
|
@@ -182,6 +184,12 @@ error degrades to rendering nothing — it can never break the TUI. The server p
|
|
|
182
184
|
also emits review-verdict and completion-unlock toasts (`toastOnReview`) so review
|
|
183
185
|
progress is visible even without the banner.
|
|
184
186
|
|
|
187
|
+
The JSX renderer is verified headlessly with `@opentui/solid`'s `testRender` in
|
|
188
|
+
`tools/visual-test/sidebar-visual.jsx` (`npm run test:visual`, needs Bun + the
|
|
189
|
+
OpenTUI stack): it asserts the rendered text, the exact foreground colours, and
|
|
190
|
+
the bold attribute for goal / "No goal" / ready states. That tool is excluded from
|
|
191
|
+
the npm package and from `node --test`/CI.
|
|
192
|
+
|
|
185
193
|
## Configuration
|
|
186
194
|
|
|
187
195
|
`config.js` merges, in increasing precedence: built-in defaults, environment
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.3.1
|
|
4
|
+
|
|
5
|
+
- Sidebar: when a task is running but no goal is set, show a clean grey `No goal`
|
|
6
|
+
(nothing else) instead of a blank/absent banner. New `sidebarMutedColor` option
|
|
7
|
+
(`GOAL_GUARD_SIDEBAR_MUTED_COLOR`, default `#808080`).
|
|
8
|
+
- `summary.sidebarView` now always returns a model (`{ hasGoal: false }` vs
|
|
9
|
+
`{ hasGoal: true, … }`) so the sidebar renders unconditionally.
|
|
10
|
+
- Add a headless visual test (`npm run test:visual`, `tools/visual-test/`) that
|
|
11
|
+
renders the real component with @opentui/solid and asserts text + exact colours
|
|
12
|
+
+ bold attributes across goal / no-goal / ready / custom-colour / truncation /
|
|
13
|
+
disabled / no-API / resize scenarios. Excluded from the npm package and CI.
|
|
14
|
+
- Hardened the sidebar projection against malformed/partial persisted state.
|
|
15
|
+
|
|
3
16
|
## v0.3.0
|
|
4
17
|
|
|
5
18
|
- Honest benchmarks: add an EXTERNAL corpus of 704 real third-party commands from
|
package/README.md
CHANGED
|
@@ -127,13 +127,15 @@ enforcement and writes its state to disk, and an experimental TUI plugin
|
|
|
127
127
|
|
|
128
128
|
- **Sidebar goal banner (experimental).** The current goal renders in shining
|
|
129
129
|
yellow in the sidebar (`sidebar_content` slot), with a `passing/total gates ·
|
|
130
|
-
dirty/ready` status line, and updates as reviews land.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
dirty/ready` status line, and updates as reviews land. When a task is running
|
|
131
|
+
but **no goal is set**, it shows a clean grey `No goal` and nothing else. It
|
|
132
|
+
requires a TUI-plugin-capable OpenCode (one exposing `api.slots.register`); on
|
|
133
|
+
any older runtime it silently no-ops, so it can never break your TUI. Set
|
|
134
|
+
`sidebarBanner: false` (or `GOAL_GUARD_SIDEBAR_BANNER=0`) to disable,
|
|
135
|
+
`sidebarColor` to recolour the goal, or `sidebarMutedColor` for the "No goal"
|
|
136
|
+
line. It is rendered-and-asserted headlessly by the
|
|
137
|
+
[visual test](tools/visual-test/README.md) (`npm run test:visual`); still worth
|
|
138
|
+
a glance in your own TUI.
|
|
137
139
|
- **Toasts.** Review verdicts and completion-unlock events surface as toasts
|
|
138
140
|
(`toastOnReview`), and blocked destructive commands / premature completions
|
|
139
141
|
toast as before (`toastOnBlock`).
|
|
@@ -202,6 +204,7 @@ Or via environment variables (`GOAL_GUARD_*`):
|
|
|
202
204
|
| `toastOnReview` / `GOAL_GUARD_TOAST_ON_REVIEW` | `true` | Toast on each review verdict and when completion unlocks. |
|
|
203
205
|
| `sidebarBanner` / `GOAL_GUARD_SIDEBAR_BANNER` | `true` | Show the experimental yellow goal banner in the TUI sidebar. |
|
|
204
206
|
| `sidebarColor` / `GOAL_GUARD_SIDEBAR_COLOR` | `#FFD700` | Foreground colour of the sidebar goal banner. |
|
|
207
|
+
| `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Colour of the muted "No goal" line when no goal is set. |
|
|
205
208
|
|
|
206
209
|
## Custom tools
|
|
207
210
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-mode",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"test:unit": "node --test tests/state.test.mjs tests/gates.test.mjs tests/verdicts.test.mjs tests/config.test.mjs tests/persistence.test.mjs",
|
|
32
32
|
"test:agents": "node --test tests/agents.test.mjs tests/commands.test.mjs",
|
|
33
33
|
"test:install": "node --test tests/install.test.mjs",
|
|
34
|
+
"test:visual": "bun tools/visual-test/sidebar-visual.jsx",
|
|
34
35
|
"bench": "node benchmarks/run.mjs",
|
|
35
36
|
"bench:external": "node benchmarks/external.mjs",
|
|
36
37
|
"bench:corpus": "node benchmarks/build-external-corpus.mjs",
|
|
@@ -32,6 +32,8 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
32
32
|
sidebarBanner: true,
|
|
33
33
|
/** Foreground colour (hex) for the sidebar goal banner. */
|
|
34
34
|
sidebarColor: "#FFD700",
|
|
35
|
+
/** Foreground colour (hex) for the muted "No goal" sidebar line. */
|
|
36
|
+
sidebarMutedColor: "#808080",
|
|
35
37
|
/** Phrase that, at the start of an assistant message, claims completion. */
|
|
36
38
|
completionMarker: "Goal Completed",
|
|
37
39
|
/** Replacement marker when completion is blocked. */
|
|
@@ -68,6 +70,7 @@ function fromEnv(env) {
|
|
|
68
70
|
GOAL_GUARD_TOAST_ON_REVIEW: ["toastOnReview", coerceBool],
|
|
69
71
|
GOAL_GUARD_SIDEBAR_BANNER: ["sidebarBanner", coerceBool],
|
|
70
72
|
GOAL_GUARD_SIDEBAR_COLOR: ["sidebarColor", (v) => (v == null ? undefined : String(v))],
|
|
73
|
+
GOAL_GUARD_SIDEBAR_MUTED_COLOR: ["sidebarMutedColor", (v) => (v == null ? undefined : String(v))],
|
|
71
74
|
};
|
|
72
75
|
for (const [key, [field, coerce]] of Object.entries(map)) {
|
|
73
76
|
if (env[key] !== undefined) out[field] = coerce(env[key], DEFAULT_CONFIG[field]);
|
|
@@ -13,7 +13,7 @@ import { readFileSync } from "node:fs";
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { stateBaseDir, projectKey } from "./persistence.js";
|
|
15
15
|
import { DEFAULT_CONFIG } from "./config.js";
|
|
16
|
-
import { sidebarView } from "./summary.js";
|
|
16
|
+
import { sidebarView, NO_GOAL } from "./summary.js";
|
|
17
17
|
|
|
18
18
|
/** Absolute path of the guard's state file for a given worktree. */
|
|
19
19
|
export function sidebarStateFile(worktree, env = process.env) {
|
|
@@ -49,8 +49,10 @@ export function pickSession(snapshot, sessionId) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* Build the sidebar banner model for a worktree
|
|
53
|
-
*
|
|
52
|
+
* Build the sidebar banner model for a worktree. ALWAYS returns an object so the
|
|
53
|
+
* sidebar renders unconditionally: `{ hasGoal: false }` when there is no state,
|
|
54
|
+
* no active session, or no goal (render a muted "No goal"); otherwise
|
|
55
|
+
* `{ hasGoal: true, goal, status, … }` (see summary.sidebarView).
|
|
54
56
|
*
|
|
55
57
|
* @param {object} opts
|
|
56
58
|
* @param {string} opts.worktree Project worktree root (same key the guard uses).
|
|
@@ -63,9 +65,9 @@ export function readSidebarModel({ worktree, sessionId, config = DEFAULT_CONFIG,
|
|
|
63
65
|
try {
|
|
64
66
|
snapshot = JSON.parse(readFileSync(sidebarStateFile(worktree, env), "utf8"));
|
|
65
67
|
} catch {
|
|
66
|
-
return
|
|
68
|
+
return NO_GOAL; // no state yet, or unreadable.
|
|
67
69
|
}
|
|
68
70
|
const record = pickSession(snapshot, sessionId);
|
|
69
|
-
if (!record) return
|
|
71
|
+
if (!record) return NO_GOAL;
|
|
70
72
|
return sidebarView(record, config);
|
|
71
73
|
}
|
|
@@ -21,21 +21,25 @@ export function shortGoalLabel(state, max = 80) {
|
|
|
21
21
|
return `${base.slice(0, max - 1).trimEnd()}…`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Sentinel for "a task is running but no goal is set" — the sidebar shows a muted "No goal". */
|
|
25
|
+
export const NO_GOAL = Object.freeze({ hasGoal: false });
|
|
26
|
+
|
|
24
27
|
/**
|
|
25
|
-
* Compact projection for the TUI sidebar banner
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* Compact projection for the TUI sidebar banner. ALWAYS returns an object so the
|
|
29
|
+
* sidebar can render unconditionally:
|
|
30
|
+
* - `{ hasGoal: false }` when no active goal is set (render a muted "No goal").
|
|
31
|
+
* - `{ hasGoal: true, goal, status, … }` when a goal is active (render in colour).
|
|
28
32
|
*/
|
|
29
33
|
export function sidebarView(state, config) {
|
|
30
|
-
if (!state || !state.active) return
|
|
34
|
+
if (!state || !state.active) return NO_GOAL;
|
|
31
35
|
const goal = shortGoalLabel(state);
|
|
32
|
-
if (!goal) return
|
|
36
|
+
if (!goal) return NO_GOAL;
|
|
33
37
|
const required = requiredGates(state, config);
|
|
34
38
|
const missing = missingGates(state, config);
|
|
35
39
|
const passing = required.length - missing.length;
|
|
36
40
|
const allowed = required.length > 0 && missing.length === 0 && !state.dirty;
|
|
37
41
|
const status = `${passing}/${required.length} gates` + (state.dirty ? " · dirty" : "") + (allowed ? " · ready" : "");
|
|
38
|
-
return { goal, status, allowed, reviewCycles: state.reviewCycles, passing, required: required.length, dirty: Boolean(state.dirty) };
|
|
42
|
+
return { hasGoal: true, goal, status, allowed, reviewCycles: state.reviewCycles, passing, required: required.length, dirty: Boolean(state.dirty) };
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export function summarizeState(state, config) {
|
package/plugins/goal-sidebar.js
CHANGED
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { createSignal, onCleanup, Show } from "solid-js";
|
|
31
|
-
import { sidebarView } from "./goal-guard/summary.js";
|
|
31
|
+
import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
|
|
32
32
|
import { DEFAULT_CONFIG } from "./goal-guard/config.js";
|
|
33
33
|
|
|
34
34
|
const DEFAULT_COLOR = "#FFD700"; // shining yellow
|
|
35
|
+
const DEFAULT_MUTED = "#808080"; // clean grey for "No goal"
|
|
35
36
|
const POLL_MS = 1500;
|
|
36
37
|
|
|
37
38
|
function resolveOptions(options, env) {
|
|
@@ -41,7 +42,8 @@ function resolveOptions(options, env) {
|
|
|
41
42
|
const disabled =
|
|
42
43
|
enabledOpt === false || enabledEnv === "0" || enabledEnv === "false" || enabledEnv === "off";
|
|
43
44
|
const color = options?.sidebarColor || e.GOAL_GUARD_SIDEBAR_COLOR || DEFAULT_COLOR;
|
|
44
|
-
|
|
45
|
+
const muted = options?.sidebarMutedColor || e.GOAL_GUARD_SIDEBAR_MUTED_COLOR || DEFAULT_MUTED;
|
|
46
|
+
return { enabled: !disabled, color, muted };
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
/**
|
|
@@ -82,14 +84,14 @@ function pickSession(snapshot, sessionId) {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function readModel(worktree, sessionId) {
|
|
85
|
-
const snapshot = readSnapshot(worktree);
|
|
86
|
-
if (!snapshot) return null;
|
|
87
|
-
const record = pickSession(snapshot, sessionId);
|
|
88
|
-
if (!record) return null;
|
|
89
87
|
try {
|
|
88
|
+
const snapshot = readSnapshot(worktree);
|
|
89
|
+
if (!snapshot) return NO_GOAL;
|
|
90
|
+
const record = pickSession(snapshot, sessionId);
|
|
91
|
+
if (!record) return NO_GOAL;
|
|
90
92
|
return sidebarView(record, DEFAULT_CONFIG);
|
|
91
93
|
} catch {
|
|
92
|
-
return
|
|
94
|
+
return NO_GOAL;
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -98,7 +100,7 @@ export const id = "goal-mode-sidebar";
|
|
|
98
100
|
/** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
|
|
99
101
|
export const tui = async (api, options) => {
|
|
100
102
|
try {
|
|
101
|
-
const { enabled, color } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
|
|
103
|
+
const { enabled, color, muted } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
|
|
102
104
|
if (!enabled) return;
|
|
103
105
|
if (!api?.slots?.register) return; // runtime without the slot API → no-op.
|
|
104
106
|
|
|
@@ -110,25 +112,26 @@ export const tui = async (api, options) => {
|
|
|
110
112
|
sidebar_content(_ctx, props) {
|
|
111
113
|
const read = () => {
|
|
112
114
|
try {
|
|
113
|
-
return readModel(worktree, props?.session_id);
|
|
115
|
+
return readModel(worktree, props?.session_id) || NO_GOAL;
|
|
114
116
|
} catch {
|
|
115
|
-
return
|
|
117
|
+
return NO_GOAL;
|
|
116
118
|
}
|
|
117
119
|
};
|
|
118
120
|
const [model, setModel] = createSignal(read());
|
|
119
121
|
const timer = setInterval(() => setModel(read()), POLL_MS);
|
|
120
122
|
onCleanup(() => clearInterval(timer));
|
|
123
|
+
// Always render: a muted "No goal" when none is set, the goal in colour otherwise.
|
|
121
124
|
return (
|
|
122
|
-
<
|
|
123
|
-
<
|
|
125
|
+
<box flexDirection="column">
|
|
126
|
+
<Show when={model().hasGoal} fallback={<text fg={muted}>No goal</text>}>
|
|
124
127
|
<text fg={color}>
|
|
125
128
|
{"◆ "}
|
|
126
129
|
<b>GOAL</b>
|
|
127
130
|
{` ${model().goal}`}
|
|
128
131
|
</text>
|
|
129
132
|
<text fg={color}>{model().status}</text>
|
|
130
|
-
</
|
|
131
|
-
</
|
|
133
|
+
</Show>
|
|
134
|
+
</box>
|
|
132
135
|
);
|
|
133
136
|
},
|
|
134
137
|
},
|