diff --git a/CHANGELOG.md b/CHANGELOG.md index 56511d6..f4316f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ changed, why it matters, and which user-visible guarantee got stronger. Use it a the fastest factual tour of Goal Mode's evolution from prompt discipline into a guard-enforced workflow. +## v0.7.0 + +### Feature: YOLO mode + a fully customizable guard + +Every gate is now tunable from `opencode.json` options or `GOAL_GUARD_*` env vars, +with a one-switch escape hatch: + +- **`yolo`** relaxes the soft gates (network-exec blocking, completion + enforcement, the Goal-only subagent lock, block toasts) while keeping + destructive-command guarding on. +- **`allowDestructive`** turns destructive guarding off too — with `yolo`, that's + *full YOLO*: the guard blocks nothing. It also works standalone. +- **`allowCommands`** (regex allow-list) waves specific commands through whatever + the analyzer thinks; **`extraDestructive`** (regex deny-list) adds your own + destructive rules. Invalid patterns are ignored, never fatal. +- YOLO only relaxes keys you did **not** set explicitly, so any per-key option + still wins. + +### Feature: a customization tool, skill, and command + +- **`goal-config`** (`scripts/goal-config.mjs`, also `opencode-goal-mode-config`): + `list` every key with its default/env/summary, `explain ` with the exact + snippet to set it, `recipes`/`recipe ` for paste-ready presets + (`safe-yolo`, `full-yolo`, `allow-commands`, …), and `effective '' --diff` + to preview the resolved config before you commit. +- A **goal-mode-customization skill** and a **`/goal-mode-customize` command** that + teach the agent the discover → choose → apply → verify loop. Both ship and + install with the package (`skills/` is a component dir) and refresh on update. +- `CONFIG_DOCS` is the single doc source for every key; a test keeps it in lockstep + with `DEFAULT_CONFIG` so docs can't drift. + +### Hardening since v0.6.12 (≈190 issues) + +A large triage-and-fix sweep landed: deeper shell-analyzer coverage and bypass +fixes (container runtimes, interpreter/decoder sinks, fail-*closed* on parser +error), completion-bypass and state-leak fixes in the guard core, Goal Lab +server/web hardening, sidebar and agent-definition cleanups, an atomic installer, +Biome linting, and a CI overhaul (dependency-review fixed, CodeQL +security-extended, release gating + post-publish smoke test, Dependabot). + ## v0.6.12 ### Fix: a failing review could be recorded as PASS (safety-critical) diff --git a/README.md b/README.md index 53dadc5..64a4695 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,15 @@ GOAL_GUARD_YOLO=1 GOAL_GUARD_ALLOW_DESTRUCTIVE=1 opencode - add `allowDestructive: true` → that last guard drops too: full YOLO. - Prefer surgical control? Leave YOLO off and use `allowCommands` (whitelist exactly the commands you want to wave through) and/or `extraDestructive` (block extra ones), e.g. `{ "allowCommands": ["^docker compose ", "^rm -rf \\./tmp/"] }`. -The customization skill (`/goal-mode-customize`, installed alongside the plugin) walks the agent through tuning every one of these. +**Don't guess — use the tool.** `goal-config` (installed as `opencode-goal-mode-config`, or `node scripts/goal-config.mjs` in the repo) lists every key, explains how to set one, ships paste-ready recipes, and previews the resolved config: + +```bash +opencode-goal-mode-config list # every key: default, env var, what it does +opencode-goal-mode-config recipe full-yolo # paste-ready opencode.json snippet +opencode-goal-mode-config effective '{"yolo":true}' --diff # confirm what it resolves to +``` + +The customization skill and the `/goal-mode-customize` command (both installed alongside the plugin) walk the agent through the discover → apply → verify loop on top of this tool. **Slash commands:** `/goal`, `/goal-contract`, `/goal-review`, `/goal-evidence-map`, `/goal-status`, `/goal-repair`, `/goal-final`. diff --git a/commands/goal-mode-customize.md b/commands/goal-mode-customize.md index 4648b3f..34d5f62 100644 --- a/commands/goal-mode-customize.md +++ b/commands/goal-mode-customize.md @@ -1,47 +1,43 @@ --- -description: Customize the OpenCode Goal Mode plugin — guard config, YOLO mode, allow/deny rules, sidebar, then reinstall. +description: Customize the OpenCode Goal Mode guard — YOLO, allow/deny commands, reviews, sidebar — then reinstall + verify. agent: goal --- ## What this command does -`/goal-mode-customize` teaches you to reconfigure the Goal Mode guard for this user/project and apply the change correctly. The whole guard is data-driven: every behavior is a key in `DEFAULT_CONFIG` (`plugins/goal-guard/config.js`), settable three ways (later wins): built-in default → `GOAL_GUARD_*` env var → the plugin `options` object in `opencode.json`. - -### 1. Find the knob - -Read `plugins/goal-guard/config.js` (`DEFAULT_CONFIG`) — it is the single source of truth and each key is documented inline. Common asks: - -- **"Stop nagging / just let it run" →** `yolo: true`. Relaxes the soft gates (network-exec block, completion enforcement, the Goal-only subagent lock, block toasts) but KEEPS destructive guarding. -- **"Let it do literally anything (incl. `rm -rf`)" →** `yolo: true, allowDestructive: true` (full YOLO). `allowDestructive` also works on its own. -- **"Allow these specific commands only" →** `allowCommands: ["", …]` — a matching bash command is never blocked, whatever the analyzer thinks. -- **"Also treat these as destructive" →** `extraDestructive: ["", …]`. -- **Sidebar colours / markers / review timing / session caps →** the corresponding `sidebar*`, `completionMarker`/`blockedMarker`, `review*`, `maxSessions`/`sessionTtlMs` keys. - -YOLO only relaxes keys the user did NOT set explicitly, so a per-key option always wins over YOLO. - -### 2. Apply it - -Edit `opencode.json` so the plugin entry carries the options, e.g.: - -```jsonc -["opencode-goal-mode", { "yolo": true, "allowCommands": ["^docker compose ", "^rm -rf \\./tmp/"] }] -``` - -…or export the env equivalent (`GOAL_GUARD_YOLO=1`, `GOAL_GUARD_ALLOW_COMMANDS="^docker compose ,^rm -rf \./tmp/"`). Lists accept an array or a comma/newline-separated string. An invalid regex is ignored, never fatal. - -### 3. Reinstall + restart (so the change actually loads) - -After editing files in the installed copy or upgrading the package, re-run the installer and clear the TUI cache so the new version loads: - -```bash -opencode-goal-mode --global # idempotent reinstall/update; --force to replace files you edited -``` - -Then fully restart OpenCode. (Global `npm install -g opencode-goal-mode@latest` re-runs this installer automatically via `postinstall`.) - -### 4. Verify - -Confirm the effective config behaves as intended — e.g. a destructive command is allowed under full YOLO, or your `allowCommands` entry passes while others are still blocked. The guard's block error names the offending command and reason. +`/goal-mode-customize` reconfigures the Goal Mode guard for this user/project. The +guard is fully data-driven: every behavior is a key in `DEFAULT_CONFIG` +(`plugins/goal-guard/config.js`), settable two ways (later wins): a `GOAL_GUARD_*` +env var, or the plugin `options` object in `opencode.json`. A bundled tool, +`scripts/goal-config.mjs`, does discovery, preview, and verification — use it. + +### Always follow this loop + +1. **Discover the knob** + ```bash + node scripts/goal-config.mjs list # all keys: type, default, env var, summary + node scripts/goal-config.mjs explain # one key + the exact opencode.json/env snippet + node scripts/goal-config.mjs recipes # presets; `recipe ` prints a paste-ready snippet + ``` + Map the request: "stop asking / just run" → `yolo: true`; "let it do anything incl. `rm -rf`" → `yolo: true, allowDestructive: true`; "allow this command" → `allowCommands: [""]`; "also block these" → `extraDestructive: [...]`; reviews/sidebar/markers/timing → the matching `review*`/`sidebar*`/`completionMarker`/etc. keys. YOLO only relaxes keys the user didn't set explicitly — a per-key option always wins. + +2. **Apply** — edit the user's `opencode.json` so the plugin entry carries the options: + ```jsonc + "plugin": [["opencode-goal-mode", { "yolo": true, "allowCommands": ["^ruff( |$)"] }]] + ``` + (or export the `GOAL_GUARD_*` env equivalents). Lists accept an array or a comma/newline string; an invalid regex is ignored, never fatal. + +3. **Reinstall + restart** so the change loads: + ```bash + opencode-goal-mode --global # idempotent; --force replaces files you edited + ``` + Then fully restart OpenCode. (A global `npm install -g opencode-goal-mode@latest` reinstalls automatically via `postinstall`.) + +4. **Verify** before claiming done: + ```bash + node scripts/goal-config.mjs effective '{"yolo":true,"allowCommands":["^ruff( |$)"]}' --diff + ``` + Confirm the resolved config matches the intent (e.g. a destructive command passes under full YOLO; your `allowCommands` entry passes while others stay blocked). The guard's block error names the offending command and reason. Additional context / the specific customization the user wants: diff --git a/package-lock.json b/package-lock.json index 0eb78ef..2404f34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-goal-mode", - "version": "0.6.12", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-goal-mode", - "version": "0.6.12", + "version": "0.7.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7971025..61d17af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-goal-mode", - "version": "0.6.12", + "version": "0.7.0", "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.", "type": "module", "exports": { @@ -13,7 +13,8 @@ "packageManager": "npm@11.13.0", "bin": { "opencode-goal-mode": "scripts/install.mjs", - "opencode-goal-mode-install": "scripts/install.mjs" + "opencode-goal-mode-install": "scripts/install.mjs", + "opencode-goal-mode-config": "scripts/goal-config.mjs" }, "files": [ "agents/", @@ -25,6 +26,7 @@ "skills/", "scripts/install.mjs", "scripts/postinstall.mjs", + "scripts/goal-config.mjs", "ARCHITECTURE.md", "CHANGELOG.md", "LICENSE", @@ -46,6 +48,7 @@ "bench:corpus": "node benchmarks/build-external-corpus.mjs", "bench:truthfulness": "node benchmarks/truthfulness.mjs", "bench:compare": "node benchmarks/comparison.mjs", + "config": "node scripts/goal-config.mjs", "pack:check": "npm pack --dry-run", "lint": "biome lint plugins/ scripts/ benchmarks/ tests/ tools/", "lint:fix": "biome lint --write plugins/ scripts/ benchmarks/ tests/ tools/", diff --git a/plugins/goal-guard/config.js b/plugins/goal-guard/config.js index 9fb8ae3..555b070 100644 --- a/plugins/goal-guard/config.js +++ b/plugins/goal-guard/config.js @@ -95,6 +95,59 @@ export const DEFAULT_CONFIG = Object.freeze({ extraDestructive: [], }); +/** + * Human-facing metadata for every config key — `{ group, summary }`. The default + * and type come from DEFAULT_CONFIG; the env var name is derived (UPPER_SNAKE, + * `GOAL_GUARD_` prefix). This is the single source the customization tool/skill, + * README, and `/goal-mode-customize` all read, so docs never drift from the keys. + * A test asserts it covers exactly the DEFAULT_CONFIG keys. + */ +export const CONFIG_DOCS = Object.freeze({ + // Command guard + blockDestructive: { group: "Guard", summary: "Block destructive bash (rm -rf, mkfs, disk writes, …) before it runs." }, + blockNetworkExec: { group: "Guard", summary: "Block remote-code-exec pipelines like `curl … | sh`." }, + yolo: { group: "Guard", summary: "YOLO: relax the soft gates (network-exec, completion, subagent lock, toasts); destructive stays on. Only relaxes keys you didn't set explicitly." }, + allowDestructive: { group: "Guard", summary: "Turn OFF destructive guarding. With `yolo` = full YOLO (nothing blocked). Works standalone too." }, + allowCommands: { group: "Guard", summary: "Regex allow-list: a matching bash command is never blocked, whatever the analyzer thinks." }, + extraDestructive: { group: "Guard", summary: "Regex deny-list: a matching bash command is treated as destructive (extends the analyzer)." }, + // Completion enforcement + enforceCompletion: { group: "Completion", summary: "Rewrite a premature `Goal Completed` claim until the required review gates pass." }, + completionMarker: { group: "Completion", summary: "The phrase that, at the start of a message, claims completion." }, + blockedMarker: { group: "Completion", summary: "The replacement written when a completion claim is blocked." }, + // Auto-continue + autoContinue: { group: "Auto-continue", summary: "Auto-push an idle-but-unfinished goal forward instead of stopping." }, + maxAutoContinue: { group: "Auto-continue", summary: "Hard cap on automatic continuations per goal session." }, + abortGraceMs: { group: "Auto-continue", summary: "Grace (ms) before an idle goal auto-continues, so a user cancel is honored first." }, + // Programmatic reviews + programmaticReview: { group: "Reviews", summary: "Have the guard LAUNCH the required reviewers itself on idle (reviews always run, no manual trigger)." }, + reviewTimeoutMs: { group: "Reviews", summary: "Per-reviewer wall-clock cap (ms) for a programmatic review run." }, + reviewPollMs: { group: "Reviews", summary: "Poll cadence (ms) while waiting for a reviewer's verdict." }, + reviewIdleDeferMs: { group: "Reviews", summary: "Delay (ms) after idle before launching reviewers (lets the host settle)." }, + reviewIdleRetryMs: { group: "Reviews", summary: "Backoff (ms) between idle-review retries when the host is still busy." }, + maxReviewIdleRetries: { group: "Reviews", summary: "Max automatic idle-review retries before pausing for manual review." }, + maxReviewCycles: { group: "Reviews", summary: "Hard cap on programmatic review cycles per goal before pausing for you." }, + // Gates & scope + contextualGates: { group: "Gates", summary: "Require specialist reviewer gates derived from goal text / changed files." }, + restrictSubagents: { group: "Gates", summary: "Lock the goal-* subagents to the Goal agent (other agents can't call them)." }, + // State & lifecycle + injectSystemState: { group: "State", summary: "Inject a live Goal Guard state block into the system prompt." }, + persist: { group: "State", summary: "Persist guard state to disk so it survives OpenCode restarts." }, + maxSessions: { group: "State", summary: "Max tracked sessions before LRU eviction." }, + sessionTtlMs: { group: "State", summary: "Idle TTL (ms) after which a session's state may be dropped (0 disables)." }, + // Sidebar / toasts + toastOnBlock: { group: "Notifications", summary: "Emit a TUI toast when something is blocked." }, + toastOnReview: { group: "Notifications", summary: "Emit a TUI toast on each review verdict and when completion unlocks." }, + sidebarBanner: { group: "Sidebar", summary: "Show the live Goal todo section in the TUI sidebar." }, + sidebarColor: { group: "Sidebar", summary: "Foreground colour (hex) of the GOAL label for a running goal." }, + sidebarDoneColor: { group: "Sidebar", summary: "Foreground colour (hex) of a completed goal in the sidebar." }, + sidebarMutedColor: { group: "Sidebar", summary: "Foreground colour (hex) for pending (□) Goal todo rows." }, +}); + +/** The `GOAL_GUARD_*` environment variable name for a config key. */ +export function envVarFor(key) { + return `GOAL_GUARD_${key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase()}`; +} + function coerceBool(value, fallback) { if (value === undefined || value === null) return fallback; if (typeof value === "boolean") return value; diff --git a/scripts/goal-config.mjs b/scripts/goal-config.mjs new file mode 100644 index 0000000..bbb2031 --- /dev/null +++ b/scripts/goal-config.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +/** + * goal-config — inspect and preview Goal Mode guard configuration. + * + * The whole guard is data-driven (see plugins/goal-guard/config.js). This is the + * companion the /goal-mode-customize command and the goal-mode-customization + * skill drive so customizing is concrete and verifiable instead of guesswork. + * + * node scripts/goal-config.mjs list # every key: default, env, summary + * node scripts/goal-config.mjs explain yolo # one key in detail + how to set it + * node scripts/goal-config.mjs recipes # ready-to-paste presets + * node scripts/goal-config.mjs recipe full-yolo # one preset + * node scripts/goal-config.mjs effective '{"yolo":true}' # resolved config for these options + * node scripts/goal-config.mjs effective '{"yolo":true}' --diff # only what differs from default + * + * Read-only: it never writes your opencode.json — it prints exactly what to paste. + */ +import { DEFAULT_CONFIG, CONFIG_DOCS, envVarFor, resolveConfig } from "../plugins/goal-guard/config.js"; + +const PLUGIN = "opencode-goal-mode"; + +function typeOf(key) { + const d = DEFAULT_CONFIG[key]; + if (Array.isArray(d)) return "list"; + return typeof d; // "boolean" | "number" | "string" +} + +function groupsInOrder() { + const seen = []; + for (const key of Object.keys(DEFAULT_CONFIG)) { + const g = CONFIG_DOCS[key]?.group || "Other"; + if (!seen.includes(g)) seen.push(g); + } + return seen; +} + +function fmtDefault(key) { + const d = DEFAULT_CONFIG[key]; + return Array.isArray(d) ? "[]" : JSON.stringify(d); +} + +function cmdList() { + const lines = []; + for (const group of groupsInOrder()) { + lines.push(`\n## ${group}`); + for (const key of Object.keys(DEFAULT_CONFIG)) { + if ((CONFIG_DOCS[key]?.group || "Other") !== group) continue; + lines.push(` ${key} (${typeOf(key)}, default ${fmtDefault(key)}) env ${envVarFor(key)}`); + lines.push(` ${CONFIG_DOCS[key]?.summary || ""}`); + } + } + console.log("Goal Mode config keys — set via opencode.json plugin options OR a GOAL_GUARD_* env var."); + console.log(lines.join("\n")); +} + +function exampleValue(key) { + const t = typeOf(key); + if (t === "boolean") return DEFAULT_CONFIG[key] ? "false" : "true"; + if (t === "number") return String(DEFAULT_CONFIG[key]); + if (t === "list") return '["^docker compose ", "^rm -rf \\\\./tmp/"]'; + return JSON.stringify(DEFAULT_CONFIG[key]); +} + +function cmdExplain(key) { + if (!key || !(key in DEFAULT_CONFIG)) { + console.error(`Unknown key: ${key || "(none)"}. Run \`goal-config list\` to see them all.`); + process.exit(1); + } + const doc = CONFIG_DOCS[key] || {}; + const ex = exampleValue(key); + console.log(`${key}`); + console.log(` group: ${doc.group || "Other"}`); + console.log(` type: ${typeOf(key)}`); + console.log(` default: ${fmtDefault(key)}`); + console.log(` what: ${doc.summary || ""}`); + console.log(`\n Set in opencode.json (plugin options):`); + console.log(` ["${PLUGIN}", { "${key}": ${ex} }]`); + console.log(`\n Or via environment:`); + const envVal = typeOf(key) === "list" ? '"^docker compose ,^rm -rf \\./tmp/"' : (typeOf(key) === "boolean" ? (DEFAULT_CONFIG[key] ? "0" : "1") : ex); + console.log(` ${envVarFor(key)}=${envVal}`); + console.log(`\n Verify: node scripts/goal-config.mjs effective '{"${key}": ${ex}}' --diff`); +} + +const RECIPES = { + "safe-yolo": { + blurb: "Stop the nagging but keep destructive protection (recommended YOLO).", + options: { yolo: true }, + }, + "full-yolo": { + blurb: "ALL rights — the guard blocks nothing. Dangerous; use in throwaway sandboxes.", + options: { yolo: true, allowDestructive: true }, + }, + "allow-commands": { + blurb: "Stay guarded, but wave through specific commands (e.g. your linters/tools).", + options: { allowCommands: ["^ruff( |$)", "^docker compose ", "^rm -rf \\./tmp/"] }, + }, + "block-extra": { + blurb: "Stay guarded AND add your own destructive rules.", + options: { extraDestructive: ["^kubectl delete ", "^terraform destroy"] }, + }, + "quiet": { + blurb: "Keep all enforcement, just silence the TUI toasts.", + options: { toastOnBlock: false, toastOnReview: false }, + }, + "no-auto-review": { + blurb: "Disable the guard launching reviewers itself (you trigger reviews).", + options: { programmaticReview: false }, + }, + strict: { + blurb: "The defaults, spelled out — maximum enforcement.", + options: { yolo: false, allowDestructive: false, blockDestructive: true, blockNetworkExec: true, enforceCompletion: true }, + }, +}; + +function cmdRecipes() { + console.log("Recipes — paste the snippet into your opencode.json plugin array.\n"); + for (const [name, r] of Object.entries(RECIPES)) { + console.log(` ${name.padEnd(14)} ${r.blurb}`); + } + console.log(`\nShow one with its snippet: node scripts/goal-config.mjs recipe `); +} + +function cmdRecipe(name) { + const r = RECIPES[name]; + if (!r) { + console.error(`Unknown recipe: ${name || "(none)"}. Run \`goal-config recipes\`.`); + process.exit(1); + } + console.log(`# ${name}: ${r.blurb}\n`); + console.log(`opencode.json — add to the "plugin" array:`); + console.log(` ["${PLUGIN}", ${JSON.stringify(r.options)}]`); + console.log(`\nVerify what it resolves to:`); + console.log(` node scripts/goal-config.mjs effective '${JSON.stringify(r.options)}' --diff`); +} + +function cmdEffective(optsJson, diffOnly) { + let opts = {}; + if (optsJson) { + try { + opts = JSON.parse(optsJson); + } catch (err) { + console.error(`Invalid JSON for options: ${err.message}`); + process.exit(1); + } + } + const cfg = resolveConfig(opts, process.env); + const keys = Object.keys(DEFAULT_CONFIG).filter((k) => { + if (!diffOnly) return true; + return JSON.stringify(cfg[k]) !== JSON.stringify(DEFAULT_CONFIG[k]); + }); + if (diffOnly && keys.length === 0) { + console.log("No effective change from defaults."); + return; + } + console.log(diffOnly ? "Effective config (changed from default):" : "Effective config:"); + for (const k of keys) console.log(` ${k} = ${JSON.stringify(cfg[k])}`); +} + +const [cmd, ...rest] = process.argv.slice(2); +const diffOnly = rest.includes("--diff"); +const positional = rest.filter((a) => !a.startsWith("--")); + +switch (cmd) { + case "list": + cmdList(); + break; + case "explain": + cmdExplain(positional[0]); + break; + case "recipes": + cmdRecipes(); + break; + case "recipe": + cmdRecipe(positional[0]); + break; + case "effective": + cmdEffective(positional[0], diffOnly); + break; + default: + console.log("Usage: node scripts/goal-config.mjs |recipes|recipe |effective '' [--diff]>"); + if (cmd && cmd !== "--help" && cmd !== "-h") process.exit(1); +} diff --git a/skills/goal-mode-customization/SKILL.md b/skills/goal-mode-customization/SKILL.md index 0955c87..2ce2d41 100644 --- a/skills/goal-mode-customization/SKILL.md +++ b/skills/goal-mode-customization/SKILL.md @@ -1,89 +1,75 @@ --- name: goal-mode-customization -description: Customize the OpenCode Goal Mode guard plugin — its config keys, YOLO mode, allow/deny command rules, sidebar, and the reinstall/update flow. Use when asked to change, relax, tighten, or tune Goal Mode / the goal guard, enable YOLO, allow or block specific commands, or make the plugin do (or stop doing) something. +description: Customize the OpenCode Goal Mode guard — enable/relax YOLO, allow or block specific commands, stop it asking for permission or nagging, retune reviews/sidebar/completion, then apply + verify. Use whenever the user wants Goal Mode (the goal guard) to do, stop doing, allow, block, relax, or tighten anything. --- # Customizing OpenCode Goal Mode -Goal Mode is fully data-driven: every behavior is a key in `DEFAULT_CONFIG` -(`plugins/goal-guard/config.js`). Change behavior by changing config — almost -never by editing guard logic. +The whole guard is **config**, not code. Every behavior is one key in +`DEFAULT_CONFIG` (`plugins/goal-guard/config.js`). You almost never edit guard +logic — you change a key and reinstall. There's a tool that does the discovery, +preview, and verification for you: **`scripts/goal-config.mjs`**. -## Configuration model +## The 4-step loop (always do this) -Precedence (lowest → highest): - -1. **Built-in defaults** — `DEFAULT_CONFIG` in `plugins/goal-guard/config.js`. -2. **Environment** — `GOAL_GUARD_*` variables (e.g. `GOAL_GUARD_YOLO=1`). -3. **Plugin options** — the object in the `opencode.json` plugin entry: - `["opencode-goal-mode", { "yolo": true }]`. - -`config.js` is the single source of truth — read it first; every key is -documented inline. Booleans accept `1/0/true/false/yes/no/on/off`; integer keys -accept plain/decimal/scientific spellings; list keys accept an array or a -comma/newline-separated string. - -## YOLO mode (the escape hatch) - -- `yolo: true` — relax the *soft* gates: turns off network-exec blocking, - completion enforcement, the Goal-only subagent lock, and block toasts. - Destructive-command guarding (e.g. `rm -rf /`) **stays on**. -- `allowDestructive: true` — turn off destructive guarding too. With `yolo` this - is **full YOLO**: the guard blocks nothing, the agent has all rights. It also - works standalone as a direct override of `blockDestructive`. -- **Important:** YOLO only relaxes keys the user did NOT set explicitly, so any - per-key option still wins (e.g. `{ "yolo": true, "blockNetworkExec": true }` - keeps network-exec blocking on). - -```jsonc -// opencode.json — never blocks or asks for anything: -["opencode-goal-mode", { "yolo": true, "allowDestructive": true }] -``` - -## Surgical control (without going full YOLO) - -- `allowCommands: ["", …]` — a bash command matching ANY pattern is - never blocked, regardless of classification. Example: - `{ "allowCommands": ["^docker compose ", "^rm -rf \\./tmp/"] }`. -- `extraDestructive: ["", …]` — a command matching ANY pattern is - treated as destructive, extending the built-in analyzer. Example: - `{ "extraDestructive": ["^kubectl delete ", "^terraform destroy"] }`. - -An invalid regex in either list is skipped, never fatal. +1. **Discover** — list keys or look one up: + ```bash + node scripts/goal-config.mjs list # every key: type, default, env var, what it does + node scripts/goal-config.mjs explain yolo # one key + exactly how to set it + node scripts/goal-config.mjs recipes # ready-made presets + ``` +2. **Choose** the key(s) from the request (table below). +3. **Apply** — add the option to the `plugin` array in the user's `opencode.json` + (the tool prints the exact snippet; `recipe ` gives a paste-ready one): + ```jsonc + "plugin": [ + ["opencode-goal-mode", { "yolo": true, "allowCommands": ["^ruff( |$)"] }] + ] + ``` + Equivalent env vars work too (`GOAL_GUARD_YOLO=1`, …) — `explain` shows both. +4. **Verify** — confirm it resolves the way you intend BEFORE telling the user + it's done: + ```bash + node scripts/goal-config.mjs effective '{"yolo":true,"allowCommands":["^ruff( |$)"]}' --diff + ``` -## Other commonly-tuned keys +## Map the request to a key -| Want to… | Key(s) | +| User says… | Do | | --- | --- | -| Stop auto-continuing idle goals | `autoContinue: false` (or cap with `maxAutoContinue`) | -| Skip programmatic reviews | `programmaticReview: false` | -| Loosen review timing | `reviewTimeoutMs`, `reviewPollMs`, `reviewIdleDeferMs`, `maxReviewCycles` | -| Change completion wording | `completionMarker`, `blockedMarker` | -| Recolour / hide the sidebar | `sidebarColor`, `sidebarDoneColor`, `sidebarMutedColor`, `sidebarBanner` | -| Disable state persistence | `persist: false` | - -The full, authoritative list is `DEFAULT_CONFIG` — prefer it over this table. - -## Applying a change (make it actually load) +| "stop asking me / stop nagging, just run" | `recipe safe-yolo` → `{ "yolo": true }` (destructive still guarded) | +| "let it do anything, even `rm -rf`" | `recipe full-yolo` → `{ "yolo": true, "allowDestructive": true }` | +| "stop blocking *this* command" (e.g. `ruff`, `docker compose`) | `{ "allowCommands": ["^ruff( |$)", "^docker compose "] }` — stays guarded otherwise | +| "also treat *these* as dangerous" | `{ "extraDestructive": ["^kubectl delete ", "^terraform destroy"] }` | +| "a reviewer keeps asking for git/lint" | reviewers already allow read-only `git`/`rg`/`cat`; for an extra tool add it to `allowCommands`, or relax that reviewer's own `permission.bash` in `agents/.md` | +| "reviews don't run by themselves" | they should — `programmaticReview: true` is the default and the guard launches reviewers on idle. If a user disabled it, re-enable; otherwise it's a reinstall/restart issue (below) | +| "stop auto-continuing" | `{ "autoContinue": false }` | +| "quieter / no toasts" | `recipe quiet` → `{ "toastOnBlock": false, "toastOnReview": false }` | +| recolour/hide sidebar, change markers, review timing, session caps | the `sidebar*`, `completionMarker`/`blockedMarker`, `review*`, `maxSessions`/`sessionTtlMs` keys — `list` shows them | + +**YOLO precedence rule:** `yolo` only relaxes keys the user did NOT set +explicitly, so a per-key option always wins (`{ "yolo": true, "blockNetworkExec": true }` +keeps network-exec blocking). `allowDestructive` is the one switch YOLO leaves on +by default — set it to go fully unguarded. + +## Apply the change so it actually loads + +Editing files in the *installed* copy, or upgrading the package, requires a +reinstall + restart — the guard is copied into `~/.config/opencode` and OpenCode +caches TUI plugins: + +```bash +opencode-goal-mode --global # idempotent reinstall/update (--force replaces files you edited) +``` -1. Edit `opencode.json` (or set the `GOAL_GUARD_*` env var). -2. If you edited the **installed** copy or upgraded the package, re-run the - installer so the new version is copied in and OpenCode's plugin cache is - cleared: - ```bash - opencode-goal-mode --global # idempotent; --force replaces files you edited - ``` - A global `npm install -g opencode-goal-mode@latest` runs this installer - automatically (via `postinstall`), so an upgrade is always a full reinstall. -3. **Fully restart OpenCode** — TUI plugins are cached. -4. Verify the new behavior (e.g. a destructive command passes under full YOLO, - or your `allowCommands` entry passes while others are still blocked). The - guard's block error names the offending command and the reason. +A global `npm install -g opencode-goal-mode@latest` runs this installer +automatically (via `postinstall`). Then **fully restart OpenCode**. -## When you DO need code, not config +## When you genuinely need code -Only edit guard code for genuinely new behavior. The relevant modules: -`config.js` (keys), `guard.js` (hook wiring + the block decision), `shell.js` -(the command analyzer), `completion.js` (the "Goal Completed" gate), -`gates.js` (which specialist reviewers are required). Add a test in `tests/` -for any behavioral change and keep `npm test` + `npm run lint` green. +Only for new behavior, not tuning. Touch points: `config.js` (add a key + +its `CONFIG_DOCS` entry + env mapping), `guard.js` (hook wiring / the block +decision), `shell.js` (command analyzer), `completion.js` (the completion gate), +`gates.js` (which reviewers are required). Add a `tests/` test for any behavior +change and keep `npm test` + `npm run lint` green. New config keys are picked up +automatically by `goal-config.mjs` (the no-drift test enforces a doc entry). diff --git a/tests/goal-config.test.mjs b/tests/goal-config.test.mjs new file mode 100644 index 0000000..8fa1bea --- /dev/null +++ b/tests/goal-config.test.mjs @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { DEFAULT_CONFIG, CONFIG_DOCS, envVarFor } from "../plugins/goal-guard/config.js"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const cli = (...args) => spawnSync("node", [join(root, "scripts", "goal-config.mjs"), ...args], { cwd: root, encoding: "utf8" }); + +test("CONFIG_DOCS documents exactly the DEFAULT_CONFIG keys (no drift)", () => { + const cfgKeys = Object.keys(DEFAULT_CONFIG).sort(); + const docKeys = Object.keys(CONFIG_DOCS).sort(); + assert.deepEqual(docKeys, cfgKeys, "every config key must have a doc entry and vice versa"); + for (const k of cfgKeys) { + assert.ok(CONFIG_DOCS[k].group, `${k} missing group`); + assert.ok(CONFIG_DOCS[k].summary, `${k} missing summary`); + } +}); + +test("envVarFor matches the documented GOAL_GUARD_* convention", () => { + assert.equal(envVarFor("blockDestructive"), "GOAL_GUARD_BLOCK_DESTRUCTIVE"); + assert.equal(envVarFor("sessionTtlMs"), "GOAL_GUARD_SESSION_TTL_MS"); + assert.equal(envVarFor("allowCommands"), "GOAL_GUARD_ALLOW_COMMANDS"); +}); + +test("goal-config list mentions every config key", () => { + const r = cli("list"); + assert.equal(r.status, 0, r.stderr); + for (const k of Object.keys(DEFAULT_CONFIG)) { + assert.ok(r.stdout.includes(k), `list output missing ${k}`); + } +}); + +test("goal-config explain shows how to set + verify a key", () => { + const r = cli("explain", "yolo"); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /opencode-goal-mode/); + assert.match(r.stdout, /GOAL_GUARD_YOLO/); + assert.match(r.stdout, /effective/); +}); + +test("goal-config explain rejects an unknown key", () => { + const r = cli("explain", "nope-not-a-key"); + assert.notEqual(r.status, 0); +}); + +test("goal-config recipe full-yolo emits a paste-ready snippet", () => { + const r = cli("recipe", "full-yolo"); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /"yolo":true/); + assert.match(r.stdout, /"allowDestructive":true/); +}); + +test("goal-config effective --diff resolves full YOLO to all gates off", () => { + const r = cli("effective", '{"yolo":true,"allowDestructive":true}', "--diff"); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /blockDestructive = false/); + assert.match(r.stdout, /enforceCompletion = false/); +}); + +test("goal-config effective rejects malformed JSON", () => { + const r = cli("effective", "{not json"); + assert.notEqual(r.status, 0); +});