Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 0 additions & 69 deletions 2026-06-20-issue-audit.md

This file was deleted.

24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,30 @@ Goal Mode works great with zero configuration. When you want to tune it, set opt
| `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Foreground colour for **pending** Goal todo rows (□ items) while a goal is running. |
| `completionMarker` / `GOAL_GUARD_COMPLETION_MARKER` | `Goal Completed` | Phrase that, at the start of a message, claims completion. |
| `blockedMarker` / `GOAL_GUARD_BLOCKED_MARKER` | `Goal Not Completed` | Replacement written when a completion claim is blocked. |
| `yolo` / `GOAL_GUARD_YOLO` | `false` | **YOLO mode.** Relax the guard so it never blocks/nags for ordinary work — turns off network-exec blocking, completion enforcement, the Goal-only subagent lock, and block toasts. Destructive guarding stays on unless `allowDestructive` is also set. Any key you set explicitly still wins. |
| `allowDestructive` / `GOAL_GUARD_ALLOW_DESTRUCTIVE` | `false` | Turn **off** destructive-command guarding. With `yolo: true` this is "full YOLO" — nothing is blocked and the agent has ALL rights. Works standalone too. Dangerous. |
| `allowCommands` / `GOAL_GUARD_ALLOW_COMMANDS` | `[]` | Custom **allow-list**: a bash command matching ANY of these JS regex patterns is never blocked, whatever the analyzer thinks. Array, or a comma/newline-separated string (env). |
| `extraDestructive` / `GOAL_GUARD_EXTRA_DESTRUCTIVE` | `[]` | Custom **deny-list**: a bash command matching ANY of these JS regex patterns is treated as destructive, extending the built-in analyzer with your own rules. |

### YOLO mode

Every gate is individually tunable, but YOLO is the one-switch escape hatch:

```jsonc
// opencode.json — never blocks or asks for anything (ALL rights):
["./plugins/goal-guard.js", { "yolo": true, "allowDestructive": true }]
```

```bash
# Or via env (e.g. for a throwaway sandbox):
GOAL_GUARD_YOLO=1 GOAL_GUARD_ALLOW_DESTRUCTIVE=1 opencode
```

- `yolo: true` alone → no completion gating, no subagent lock, no network-exec block, no toasts — but a destructive `rm -rf /` is **still** stopped.
- 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.

**Slash commands:** `/goal`, `/goal-contract`, `/goal-review`, `/goal-evidence-map`,
`/goal-status`, `/goal-repair`, `/goal-final`.
Expand Down
50 changes: 50 additions & 0 deletions commands/goal-mode-customize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
description: Customize the OpenCode Goal Mode plugin — guard config, YOLO mode, allow/deny rules, sidebar, then reinstall.
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: ["<js-regex>", …]` — a matching bash command is never blocked, whatever the analyzer thinks.
- **"Also treat these as destructive" →** `extraDestructive: ["<js-regex>", …]`.
- **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.

Additional context / the specific customization the user wants:

```text
$ARGUMENTS
```
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"docs/",
"plugins/",
"research/",
"skills/",
"scripts/install.mjs",
"scripts/postinstall.mjs",
"ARCHITECTURE.md",
Expand Down Expand Up @@ -55,6 +56,8 @@
"prepublishOnly": "npm run ci && npm run publish:check",
"install:local": "node scripts/install.mjs",
"install:global": "node scripts/install.mjs --global",
"update": "npm install -g opencode-goal-mode@latest",
"reinstall": "node scripts/install.mjs --global --force",
"postinstall": "node scripts/postinstall.mjs"
},
"keywords": [
Expand Down
60 changes: 59 additions & 1 deletion plugins/goal-guard/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ export const DEFAULT_CONFIG = Object.freeze({
completionMarker: "Goal Completed",
/** Replacement marker when completion is blocked. */
blockedMarker: "Goal Not Completed",

// ── YOLO / customization ──────────────────────────────────────────────────
/** YOLO mode: relax the guard so it never blocks or nags for ordinary work —
* turns OFF network-exec blocking, completion enforcement, the Goal-only
* subagent restriction, and block toasts. Destructive-command guarding STAYS
* ON unless `allowDestructive` is also set. Any key you set explicitly (via
* options or env) still wins over YOLO's relaxations, so YOLO is a baseline you
* can override per-key. */
yolo: false,
/** Turn OFF destructive-command guarding. Combined with `yolo: true` this is
* "full YOLO" — the guard blocks nothing and the agent has ALL rights. It also
* works on its own as a direct override. Dangerous; off by default. */
allowDestructive: false,
/** Custom allow-list. A bash command matching ANY of these JS regex patterns is
* never blocked by the guard, regardless of classification — fine-grained YOLO
* without disabling the guard globally. Strings (regex source) or an array. */
allowCommands: [],
/** Custom deny-list. A bash command matching ANY of these JS regex patterns is
* treated as destructive, extending the built-in analyzer with project rules. */
extraDestructive: [],
});

function coerceBool(value, fallback) {
Expand Down Expand Up @@ -110,6 +130,15 @@ function coerceStr(value, fallback) {
return s.trim() === "" ? fallback : s;
}

/** Coerce a list config value: accept an array, or a comma/newline-separated
* string. Entries are trimmed and blanks dropped. Used for the customizable
* allow/deny pattern lists. Always returns a (possibly empty) array. */
function coerceList(value, fallback) {
if (value === undefined || value === null) return fallback;
const raw = Array.isArray(value) ? value : String(value).split(/[\n,]/);
return raw.map((s) => String(s).trim()).filter((s) => s.length > 0);
}

function fromEnv(env) {
const out = {};
const map = {
Expand Down Expand Up @@ -140,6 +169,10 @@ function fromEnv(env) {
GOAL_GUARD_SIDEBAR_MUTED_COLOR: ["sidebarMutedColor", coerceStr],
GOAL_GUARD_COMPLETION_MARKER: ["completionMarker", coerceStr],
GOAL_GUARD_BLOCKED_MARKER: ["blockedMarker", coerceStr],
GOAL_GUARD_YOLO: ["yolo", coerceBool],
GOAL_GUARD_ALLOW_DESTRUCTIVE: ["allowDestructive", coerceBool],
GOAL_GUARD_ALLOW_COMMANDS: ["allowCommands", coerceList],
GOAL_GUARD_EXTRA_DESTRUCTIVE: ["extraDestructive", coerceList],
};
for (const [key, [field, coerce]] of Object.entries(map)) {
if (env[key] !== undefined) out[field] = coerce(env[key], DEFAULT_CONFIG[field]);
Expand All @@ -162,13 +195,38 @@ export function resolveConfig(options, env) {
const opts = options && typeof options === "object" ? options : {};
const merged = { ...DEFAULT_CONFIG, ...envConfig };

// Track which keys the user set EXPLICITLY (env or options). YOLO only relaxes
// keys the user left at their default — an explicit choice always wins.
const explicit = new Set(Object.keys(envConfig));

for (const key of Object.keys(DEFAULT_CONFIG)) {
if (opts[key] === undefined) continue;
explicit.add(key);
const def = DEFAULT_CONFIG[key];
if (typeof def === "boolean") merged[key] = coerceBool(opts[key], merged[key]);
if (Array.isArray(def)) merged[key] = coerceList(opts[key], merged[key]);
else if (typeof def === "boolean") merged[key] = coerceBool(opts[key], merged[key]);
else if (typeof def === "number") merged[key] = coerceInt(opts[key], merged[key]);
else merged[key] = coerceStr(opts[key], merged[key]); // string keys: ignore empty/blank, never inject ""
}

// YOLO mode derivation. `relax` only applies when the user did NOT set the key
// explicitly, so YOLO is an overridable baseline.
const relax = (key, value) => {
if (!explicit.has(key)) merged[key] = value;
};
if (merged.yolo) {
relax("blockNetworkExec", false);
relax("enforceCompletion", false);
relax("restrictSubagents", false);
relax("toastOnBlock", false);
}
// Destructive guarding is the one safety net YOLO keeps by default; turning it
// off is its own opt-in (works with or without yolo) → "full YOLO" = both.
if (merged.allowDestructive) relax("blockDestructive", false);

// Freeze the customizable lists so a frozen config stays effectively immutable.
merged.allowCommands = Object.freeze([...merged.allowCommands]);
merged.extraDestructive = Object.freeze([...merged.extraDestructive]);
// maxSessions must hold at least the goal session AND its reviewer child sessions.
// A value of 0 (or negative) made the store's eviction loop (while (size >=
// maxSessions)) always true, so every stateFor for a different sessionID evicted
Expand Down
38 changes: 36 additions & 2 deletions plugins/goal-guard/guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,32 @@ function partsText(parts) {
.trim();
}

/** Compile a list of regex-source strings to RegExp, skipping any that don't
* compile so one bad user pattern can never break the guard. */
function compileGuardPatterns(list) {
const out = [];
for (const pattern of list || []) {
try {
out.push(new RegExp(pattern));
} catch {
/* ignore an invalid custom pattern */
}
}
return out;
}

/** True if `command` matches any of the compiled patterns. */
function matchesAnyPattern(patterns, command) {
return patterns.some((re) => {
try {
re.lastIndex = 0;
return re.test(command);
} catch {
return false;
}
});
}

/**
* Build a guard instance. Exposed for tests; the default export wraps it.
*
Expand All @@ -94,6 +120,9 @@ function partsText(parts) {
*/
export function createGuard(input = {}, options = {}, overrides = {}) {
const config = overrides.config || resolveConfig(options, overrides.env);
// Pre-compile the customizable allow/deny pattern lists once per guard.
const allowCommandPatterns = compileGuardPatterns(config.allowCommands);
const extraDestructivePatterns = compileGuardPatterns(config.extraDestructive);
const reviewClient = overrides.reviewClient || input.client;

// In-memory only (never persisted): coalesce overlapping idle decisions, detect a
Expand Down Expand Up @@ -548,9 +577,14 @@ export function createGuard(input = {}, options = {}, overrides = {}) {

if (inp?.tool === "bash") {
const command = commandOf(inp, out);
// Custom allow-list: a matching command bypasses the guard entirely — no
// block, no dirty mark — regardless of how the analyzer classifies it.
const allowlisted = allowCommandPatterns.length > 0 && matchesAnyPattern(allowCommandPatterns, command);
const analysis = analyzeCommand(command);
const blockDestructive = config.blockDestructive && analysis.destructive;
const blockNetwork = config.blockNetworkExec && analysis.networkExec;
// Custom deny-list extends the analyzer with project-specific destructive rules.
const destructive = analysis.destructive || (extraDestructivePatterns.length > 0 && matchesAnyPattern(extraDestructivePatterns, command));
const blockDestructive = !allowlisted && config.blockDestructive && destructive;
const blockNetwork = !allowlisted && config.blockNetworkExec && analysis.networkExec;
if (blockDestructive || blockNetwork) {
// Only record the block reason / persist for an ACTIVE goal session.
// Destructive blocking itself applies in EVERY mode (the throw below),
Expand Down
6 changes: 4 additions & 2 deletions scripts/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ try {
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));

/** Component directories installed into an OpenCode config dir. */
const COMPONENT_DIRS = ["agents", "commands", "plugins"];
/** Component directories installed into an OpenCode config dir. `skills/` carries
* the goal-mode-customization skill so it ships with the package and is refreshed
* on every (re)install/update, just like agents/commands/plugins. */
const COMPONENT_DIRS = ["agents", "commands", "plugins", "skills"];
const MANIFEST_NAME = ".goal-mode-manifest.json";

if (values.help) {
Expand Down
Loading
Loading