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
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,32 @@ on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: ${{ matrix.node-version }}

- name: Run checks
run: npm run check

- name: Run package smoke test
run: npm run smoke

- name: Verify package contents
run: npm run pack:check
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

## 0.1.11 — 2026-06-04

- Add `npm run smoke`, a package-export smoke test that exercises the `/goal` command hook without invoking a model.
- Run CI across Node 18, 20, and 22, and wire the package-entry smoke test into the workflow.
- Harden persisted-state loading with schema validation and explicit skipping of malformed goal/result entries.
- Make hook handling more defensive around message payload shapes and `system` block normalization.
- Expand docs around compatibility, release checks, smoke testing, and security reporting fallback.

## 0.1.10 — 2026-05-30

- Fix `experimental.chat.system.transform` to merge the goal continuation block into the primary system entry instead of pushing a separate one. Prevents `"System message must be at the beginning."` errors on strict-template backends (Qwen on vLLM, several Llama.cpp/Mistral templates). See issue #1.
Expand Down
32 changes: 28 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@ Thanks for helping improve `opencode-goal-plugin`.

## Development

Use the pinned Node version when possible:

```sh
nvm use
```

Run the local checks before submitting changes:

```sh
npm test
npm run test:coverage
npm run smoke
npm run check
npm run pack:check
```
Expand All @@ -15,11 +24,27 @@ For behavior changes, add or update tests in `test/goal-plugin.test.js`.

## OpenCode Compatibility

This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage or command behavior:
This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage, command behavior, or system-prompt transforms:

1. Check the current OpenCode plugin and command documentation.
2. Test against a real OpenCode install when possible.
3. Update the README compatibility note if the tested version changes.
2. Run `npm run smoke` to verify the packaged entrypoint and command hook surface.
3. Test against a real OpenCode install when possible.
4. Update the README compatibility snapshot if the tested surface changes.

`npm run smoke` verifies the package export path and `/goal` command hook without invoking a model. It does not replace a real OpenCode smoke test after hook or command behavior changes.

## Release checklist

Before publishing or tagging a release:

- update `CHANGELOG.md`
- run `npm test`
- run `npm run test:coverage`
- run `npm run smoke`
- run `npm run check`
- run `npm run pack:check`
- perform at least one manual OpenCode smoke test if hook behavior changed
- refresh compatibility notes if the tested OpenCode surface changed

## Pull Requests

Expand All @@ -29,4 +54,3 @@ Keep pull requests focused. Include:
- why it changed
- the checks you ran
- any manual OpenCode smoke testing performed

38 changes: 28 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ An experimental session-scoped `/goal` command for [OpenCode](https://opencode.a

Set a goal and the plugin keeps it in context, auto-continues the session whenever the assistant goes idle, and stops when the goal is marked complete, a blocker is reported, or a safety limit is reached.

Compatibility: tested against OpenCode 1.15.11. The plugin relies on experimental OpenCode hooks; pin or re-test against your OpenCode version before using it for unattended long-running work.
Compatibility: this plugin relies on experimental OpenCode hooks. Re-test against the exact OpenCode build and provider/backend stack you plan to use for unattended work.

## Compatibility snapshot

| Surface | Status |
|---|---|
| Node.js | Declared support: `>=18`; CI covers Node 18, 20, and 22 |
| Package entrypoint | `npm run smoke` verifies the package export path plus `/goal` command-hook behavior from a local install without invoking a model |
| OpenCode host | Manually smoke-tested against OpenCode 1.15.10 using the `opencode-go` provider (`qwen3.7-plus`) on this repo's local hardening branch; re-test your own version/provider stack before relying on unattended runs |
| Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |

## Install

Expand Down Expand Up @@ -209,19 +218,28 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil

### Smoke-test checklist

1. Install or file-load the plugin in a temporary OpenCode config.
2. Add a `goal` command with `"template": "$ARGUMENTS"`.
3. Run `/goal status` — should report no active goal.
4. Run `/goal inspect this repo and stop immediately with [goal:blocked] if you need user input`.
5. Verify `/goal status`, `/goal pause`, `/goal resume`, and `/goal clear` behave as expected.
1. Run `npm run smoke` to verify the package export path and `/goal` command hook without a model call.
2. Install or file-load the plugin in a temporary OpenCode config.
3. Add a `goal` command with `"template": "$ARGUMENTS"`.
4. Run `/goal status` — should report no active goal.
5. Run `/goal inspect this repo and stop immediately with [goal:blocked] if you need user input`.
6. Verify `/goal status`, `/goal pause`, `/goal resume`, and `/goal clear` behave as expected.
7. If you changed hook payload handling or command behavior, repeat the smoke test against the exact OpenCode version and provider/backend combination you care about.

### Release checklist

- Confirm `npm test`, `npm run test:coverage`, `npm run check`, `npm run smoke`, and `npm run pack:check` all pass.
- Re-test against a real OpenCode install when touching command hooks, idle-event handling, or system-prompt transforms.
- Update compatibility notes and changelog entries when behavior or tested surfaces change.

## Development

```sh
npm test # run the test suite
npm run test:coverage # run tests with coverage
npm run check # syntax check + tests
npm run pack:check # verify package contents before publishing
npm test # run the test suite
npm run test:coverage # run tests with coverage
npm run smoke # verify package export + command hook without a model call
npm run check # syntax check + tests
npm run pack:check # verify package contents before publishing
```

## License
Expand Down
14 changes: 10 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,25 @@ This project is experimental. Security fixes are provided for the latest publish

## Reporting a Vulnerability

Please report security issues through GitHub's private vulnerability reporting for this repository.
GitHub private vulnerability reporting may not always be enabled for this repository.

Do not open a public issue for vulnerabilities that could expose user data, credentials, or local system access.
Until a dedicated private reporting channel is documented here, do **not** open a public issue with exploit details, credentials, local paths, or reproduction steps that could expose user data or local system access.

Instead:

1. open a minimal public issue asking for a private contact path, or
2. contact the maintainer through their GitHub profile and request a private handoff.

## Scope

This plugin does not intentionally read credentials, write files, or execute shell commands. It observes OpenCode session events, injects goal context into prompts, and sends continuation prompts through OpenCode's SDK client.
This plugin does not intentionally read credentials, write arbitrary user files, or execute shell commands. It observes OpenCode session events, injects goal context into prompts, and sends continuation prompts through OpenCode's SDK client.

Relevant security-sensitive areas include:

- prompt-injection resistance for goal text
- unexpected auto-continuation behavior
- incorrect command or hook handling across OpenCode versions
- leakage of goal text through logs or status output
- malformed persisted state causing stale or unexpected goal recovery

The goal text is wrapped in `<goal_objective>` tags and the closing tag is escaped before insertion. Other structural tags used in continuation prompts (`<goal_continuation>`, `<progress_budget>`, etc.) are not escaped. Crafted goal text containing those literal strings would close the tag early in the plaintext prompt; the model treats it as text rather than structure, so the practical risk for a local single-user tool is negligible. Do not paste untrusted third-party text into a goal; treat goal text as if you typed it directly into the assistant.
The goal text is wrapped in `<goal_objective>` tags and the closing tag is escaped before insertion. Other structural tags used in continuation prompts (`<goal_continuation>`, `<progress_budget>`, etc.) are not escaped. Crafted goal text containing those literal strings would close the tag early in the plaintext prompt; the model still receives plaintext rather than true privileged structure, but you should still treat goal text as trusted local input rather than pasting in arbitrary third-party content.
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-goal-plugin",
"version": "0.1.10",
"version": "0.1.11",
"description": "Session-scoped /goal workflow for OpenCode.",
"type": "module",
"main": "./src/goal-plugin.js",
Expand All @@ -10,16 +10,19 @@
},
"files": [
"src",
"scripts",
"examples",
"README.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
"SECURITY.md",
"LICENSE"
"LICENSE",
".nvmrc"
],
"scripts": {
"test": "node --test",
"test:coverage": "node --test --experimental-test-coverage",
"smoke": "node scripts/smoke-command-hook.mjs",
"check": "node -c src/goal-plugin.js && npm test",
"pack:check": "npm pack --dry-run"
},
Expand Down
49 changes: 49 additions & 0 deletions scripts/smoke-command-hook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from "node:assert/strict"
import pluginModule, { GoalPlugin } from "opencode-goal-plugin"

const sessionID = `smoke-${Date.now()}`
const promptCalls = []
const logCalls = []

const client = {
app: {
log: async (input) => {
logCalls.push(input)
},
},
session: {
messages: async () => ({ data: [] }),
promptAsync: async (input) => {
promptCalls.push(input)
return {}
},
},
}

assert.equal(pluginModule.id, "opencode-goal-plugin")
assert.equal(pluginModule.server, GoalPlugin)

const hooks = await GoalPlugin({ client }, { minDelayMs: 1 })
assert.equal(typeof hooks["command.execute.before"], "function")
assert.equal(typeof hooks.event, "function")
assert.equal(typeof hooks["experimental.chat.system.transform"], "function")

const commandHook = hooks["command.execute.before"]

async function runGoalCommand(args) {
const output = { parts: [] }
await commandHook({ command: "goal", sessionID, arguments: args }, output)
assert.equal(output.parts.length, 1)
assert.equal(output.parts[0].type, "text")
return output.parts[0].text
}

assert.match(await runGoalCommand("status"), /No active goal/)
assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/)
assert.match(await runGoalCommand("status"), /Active goal: ship a smoke test/)
assert.match(await runGoalCommand("clear"), /Goal cleared/)
assert.match(await runGoalCommand("status"), /No active goal/)
assert.equal(promptCalls.length, 0)
assert.equal(logCalls.length, 0)

console.log("opencode-goal-plugin command hook smoke passed")
Loading
Loading