diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6584a5d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + groups: + development-dependencies: + dependency-type: development + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 013502c..3064e40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,39 +8,85 @@ on: schedule: - cron: "0 8 * * 1" +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: - check: + node-compatibility: + name: Node ${{ matrix.node-version }} runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: - node-version: [18, 20, 22, 24] + node-version: ["18", "20", "22", "24"] steps: - name: Check out repository - uses: actions/checkout@v4 - + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} - - - name: Run checks + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Run syntax and unit tests run: npm run check + quality-contracts: + name: Quality and package contracts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: npm + - name: Install locked dependencies + run: npm ci - name: Run tests with coverage run: npm run test:coverage - + - name: Compile installed-package type contracts + run: npm run type:check + - name: Prove critical tests kill safety regressions + run: npm run test:mutation - name: Run behavior and efficiency benchmark run: npm run benchmark:behavior - - - name: Run package smoke test + - name: Run source package smoke test run: npm run smoke - - name: Verify installed tarball host contract run: npm run smoke:packed-host - + - name: Verify installed tarball tool contract + run: npm run smoke:packed-tools + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high - name: Verify package contents run: npm run pack:check + + filesystem-compatibility: + name: Filesystem (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Run portable lifecycle and filesystem tests + run: node --test test/host-lifecycle.test.js test/persistence-lease.test.js test/public-hook-cancellation.test.js diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..ad430e4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,27 @@ +name: CodeQL + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "30 8 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: JavaScript analysis + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Initialize CodeQL + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: javascript-typescript + - name: Analyze + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 0000000..ddc8d78 --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,38 @@ +name: Release check + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify-artifact: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: npm + - name: Verify tag and package versions match + if: startsWith(github.ref, 'refs/tags/') + run: node -e "const p=require('./package.json');const t=process.env.GITHUB_REF_NAME;if(t!=='v'+p.version)throw new Error('tag '+t+' does not match package '+p.version)" + - name: Install locked dependencies + run: npm ci + - name: Run complete release gate + run: npm run release:check + - name: Build npm artifact + run: npm pack + - name: Upload verified artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-package-${{ github.ref_name }} + path: opencode-goal-plugin-*.tgz + if-no-files-found: error + retention-days: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9f22b..65b247a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +## 0.6.2 — 2026-07-11 + +- Keep paused, blocked, and crash-recovered goals inert in model turns with a stopped-goal system guard, and enforce status/history/list/pause/clear control turns as read-only through the host's tool-execution hook so routed command text cannot mutate or resurrect work. +- Add installed-package TypeScript and full tool-surface contracts, critical mutation testing, generated lifecycle-model testing, Linux/macOS/Windows filesystem CI, CodeQL, dependency updates, and a verified release workflow. +- Preserve multi-goal creation order when a paused goal resumes; execution epochs rotate through `runId` while the public `goalId` remains stable. +- Document the supported compatibility surface, release integrity process, and private vulnerability reporting path. + ## 0.6.1 — 2026-07-10 - Default-deny verifier tools except read/glob/grep, and fail closed unless the owned verifier agent registers successfully. @@ -9,7 +16,7 @@ - Prevent mutating SDK shape retries, validate child-session ancestry, and make auditor timeout independent of a hanging abort. - Move context-budget reset to the successful `session.compacted` event and prevent ordered goals from reusing a prior goal's completion message. - Drain accepted persistence writes before disposal, suppress late disposed-instance writes, and re-check goal identity after asynchronous audits/announcements. -- Harden persistence recovery with bounded hostile-state parsing, cross-session-safe ledger identities, multi-goal snapshots, archive restoration, corrupt-state quarantine, stale malformed-lock recovery, symlink-safe ledger appends, and exclusive one-project legacy migration with preserved backups. +- Improve persistence recovery with bounded hostile-state parsing, cross-session-safe ledger identities, multi-goal snapshots, archive restoration, corrupt-state quarantine, stale malformed-lock recovery, symlink-safe ledger appends, and exclusive one-project legacy migration with preserved backups. - Fail closed when neither terminal state nor ledger can be persisted; keep the goal paused instead of claiming archival. - Preserve paused time for queued/backgrounded goals, expose an opt-out for the tool-free heuristic, honor host `tokens.total`, and improve multi-step usage/cost availability accounting. - Add conditional TypeScript export mappings, an OpenCode engine range, honest auditor/visibility documentation, and cache-safe near-limit continuation warnings. @@ -122,7 +129,7 @@ ## 0.4.0 — 2026-06-21 -- **Expose agent-facing goal tools (`get_goal`, `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`)** when the host provides `@opencode-ai/plugin` (a new *optional* peer dependency, loaded via a cached dynamic import so the zero-runtime-dependency posture is preserved). `set_goal` is constrained by its description to explicit user requests (so the agent does not set goals on its own); it accepts optional `maxTurns` / `maxTokens` / `maxDurationMs` overrides plus `successCriteria` / `constraints` / `mode`. `update_goal` supports objective edits and `complete` / `blocked` / `paused` / `resumed` transitions (with `evidence` / `blocker`). Tools create, replace, and clear goals through the **multi-goal registry** (the same `buildGoalState` → `registerSessionGoal` → `focusGoal` path the `/goal` command uses), so tool-created goals persist, appear in `/goal list`, and are driven by the idle handler; `complete` archives with evidence and auto-promotes the next goal in an ordered (sisyphus) sequence. Registration is skipped gracefully when the package is absent or with `registerTools: false`. New `buildAgentToolHandlers` / `buildAgentTools` / `agentToolSessionID` helpers. Implements megalist items 7.1 and 7.2. _(This is the work the 0.3.0 changelog mistakenly listed as already shipped; it is now actually implemented and adapted to the current multi-goal architecture.)_ +- **Expose agent-facing goal tools (`get_goal`, `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`)** when the host provides `@opencode-ai/plugin` (a new *optional* peer dependency, loaded via a cached dynamic import so the zero-runtime-dependency posture is preserved). `set_goal` is constrained by its description to explicit user requests (so the agent does not set goals on its own); it accepts optional `maxTurns` / `maxTokens` / `maxDurationMs` overrides plus `successCriteria` / `constraints` / `mode`. `update_goal` supports objective edits and `complete` / `blocked` / `paused` / `resumed` transitions (with `evidence` / `blocker`). Tools create, replace, and clear goals through the **multi-goal registry** (the same `buildGoalState` → `registerSessionGoal` → `focusGoal` path the `/goal` command uses), so tool-created goals persist, appear in `/goal list`, and are driven by the idle handler; `complete` archives with evidence and auto-promotes the next goal in an ordered (sisyphus) sequence. Registration is skipped gracefully when the package is absent or with `registerTools: false`. New `buildAgentToolHandlers` / `buildAgentTools` / `agentToolSessionID` helpers. _(This is the work the 0.3.0 changelog mistakenly listed as already shipped; it is now actually implemented and adapted to the current multi-goal architecture.)_ - **Fix a goal-registry leak when resuming.** `resetGoalBudget` rotates a goal's `goalId`, but the multi-goal registry is keyed by `goalId`, so resuming and then clearing/replacing left a stale entry behind (visible in `/goal list` and persisted). Both the `/goal resume` command path and the agent `update_goal {status:"resumed"}` path now re-key the registry to the new id (the focused pointer holds the same object). Regression test added for the command path. ## 0.3.0 — 2026-06-14 @@ -131,35 +138,35 @@ ### Completion integrity & audit -- **Require evidence to complete a goal and a concrete blocker to block one.** A `[goal:complete]` marker is now only honored when the assistant also supplies a non-empty `[goal:evidence] ` line (on or before the completion marker); a `[goal:blocked]` is only honored when a concrete blocker is stated on the line before it. An unsubstantiated `[goal:complete]` or `[goal:blocked]` is rejected (not recorded / does not stop the goal) and the plugin sends a corrective continuation prompt demanding the missing evidence or blocker. The accepted evidence is stored on the result and shown in `/goal status` / `/goal history`. New `extractCompletionEvidence` helper, an `` structural tag (added to the injection-escaping set), and continuation/system/compaction/creation prompts all updated to instruct the evidence requirement. Implements megalist item 2.1. -- **Add an optional separate completion auditor that verifies before archival.** When a completion auditor is configured, a `[goal:complete]` (with evidence) is verified before the goal is archived: on approval it archives as achieved, on rejection the goal is *restored* (paused with stop reason `audit rejected` and the reason surfaced) rather than archived. Enable the built-in auditor — which spawns an independent OpenCode child session that replies `[audit:approved]`/`[audit:rejected]` — with `completionAudit: true`, or supply a custom `auditor({ goal, sessionID, latestText }) => { approved, reason }` (takes precedence). The built-in child-session auditor fails open if the session API is unavailable; a custom auditor that throws is treated as a rejection (fail closed). New `parseAuditVerdict` / `buildAuditPrompt` / `createChildSessionAuditor` helpers. Off by default. Implements megalist item 2.2. -- **Announce completion/blocker audits with visible messages instead of silent background work.** When the assistant marks a goal complete or blocked, the plugin emits an audit-start and an audit-result message (e.g. "Auditing goal completion…" → "Audit result: completion accepted — goal archived"). Delivery defaults to OpenCode's structured log (`client.app.log`) and is pluggable via an `auditMessenger(sessionID, text)` option or disable-able with `auditMessages: false`. New `defaultAuditMessenger` helper. Implements megalist item 2.4. +- **Require evidence to complete a goal and a concrete blocker to block one.** A `[goal:complete]` marker is now only honored when the assistant also supplies a non-empty `[goal:evidence] ` line (on or before the completion marker); a `[goal:blocked]` is only honored when a concrete blocker is stated on the line before it. An unsubstantiated `[goal:complete]` or `[goal:blocked]` is rejected (not recorded / does not stop the goal) and the plugin sends a corrective continuation prompt demanding the missing evidence or blocker. The accepted evidence is stored on the result and shown in `/goal status` / `/goal history`. New `extractCompletionEvidence` helper, an `` structural tag (added to the injection-escaping set), and continuation/system/compaction/creation prompts all updated to instruct the evidence requirement. +- **Add an optional separate completion auditor that verifies before archival.** When a completion auditor is configured, a `[goal:complete]` (with evidence) is verified before the goal is archived: on approval it archives as achieved, on rejection the goal is *restored* (paused with stop reason `audit rejected` and the reason surfaced) rather than archived. Enable the built-in auditor — which spawns an independent OpenCode child session that replies `[audit:approved]`/`[audit:rejected]` — with `completionAudit: true`, or supply a custom `auditor({ goal, sessionID, latestText }) => { approved, reason }` (takes precedence). In this release, the built-in child-session auditor approved when the session API was unavailable; later releases changed operational failures to reject by default. A custom auditor that throws is treated as a rejection. New `parseAuditVerdict` / `buildAuditPrompt` / `createChildSessionAuditor` helpers. Off by default. +- **Announce completion/blocker audits with visible messages instead of silent background work.** When the assistant marks a goal complete or blocked, the plugin emits an audit-start and an audit-result message (e.g. "Auditing goal completion…" → "Audit result: completion accepted — goal archived"). Delivery defaults to OpenCode's structured log (`client.app.log`) and is pluggable via an `auditMessenger(sessionID, text)` option or disable-able with `auditMessages: false`. New `defaultAuditMessenger` helper. ### Durability -- **Add an append-only JSONL lifecycle ledger with state reconstruction, and fail-closed terminal-state persistence.** Every lifecycle event (`pushHistory`) is also appended as one JSON line to `.ledger.jsonl` (synchronous, owner-only `0600`). Because in-memory history is capped, the ledger is the durable record: when the main state file is missing on startup, the plugin reconstructs still-active (non-`completed`/`cleared`) goals from the ledger and reloads them paused (new `reconstructed` load status). Terminal events are written to the ledger before the main state write, so a goal's terminal outcome survives a failed state write (fail-closed); `persistState` now returns success/failure and a failed terminal persist is logged at error level. Tied to `persistState`. New `appendLedgerLine` / `readLedgerEntries` / `reconstructGoalsFromLedger` helpers. Implements megalist items 2.3 and 2.5. -- **Build the compaction summary deterministically from the persisted goal record.** `buildCompactionContext` folds in a reproducible progress summary — recent checkpoints and lifecycle events — derived from the goal's persisted `checkpoints`/`history` (new `buildCompactionProgressSummary` helper) rather than chat memory, and labels it as such. Implements megalist item 6.3. +- **Add an append-only JSONL lifecycle ledger with state reconstruction, and fail-closed terminal-state persistence.** Every lifecycle event (`pushHistory`) is also appended as one JSON line to `.ledger.jsonl` (synchronous, owner-only `0600`). Because in-memory history is capped, the ledger is the durable record: when the main state file is missing on startup, the plugin reconstructs still-active (non-`completed`/`cleared`) goals from the ledger and reloads them paused (new `reconstructed` load status). Terminal events are written to the ledger before the main state write, so a goal's terminal outcome survives a failed state write (fail-closed); `persistState` now returns success/failure and a failed terminal persist is logged at error level. Tied to `persistState`. New `appendLedgerLine` / `readLedgerEntries` / `reconstructGoalsFromLedger` helpers. +- **Build the compaction summary deterministically from the persisted goal record.** `buildCompactionContext` folds in a reproducible progress summary — recent checkpoints and lifecycle events — derived from the goal's persisted `checkpoints`/`history` (new `buildCompactionProgressSummary` helper) rather than chat memory, and labels it as such. ### Auto-continue guardrails -- **Pause auto-continue on repeated tool-free continuation turns (no-tool-call gate).** Complementing the low-output no-progress check, the plugin tracks continuation turns whose assistant message has no tool calls (OpenCode `tool` / `subtask` parts) and, after `noToolCallTurnsBeforePause` consecutive such turns (default `2`), pauses with stop reason `no tool calls` to guard against self-chat loops. A tool-using turn resets the counter. Configurable via the `noToolCallTurnsBeforePause` option and `--no-tool-turns ` flag. New `messageHasToolCall` helper. Implements megalist item 5.1. -- **Pause auto-continue when a real user message arrives ("latest instruction wins").** The idle handler detects a genuine human message that arrived after the plugin's most recent continuation and pauses the goal (stop reason `user intervention`) instead of talking over the user; `/goal resume` hands control back. Plugin-generated continuation prompts (user-role messages framed in ``) are ignored, and detection requires `turnCount > 0` plus a visible plugin continuation so the first idle and scrolled-out sessions are never misread. New `isPluginContinuationMessage` / `userInterventionDetected` helpers. Implements megalist items 5.2 and 5.3. +- **Pause auto-continue on repeated tool-free continuation turns (no-tool-call gate).** Complementing the low-output no-progress check, the plugin tracks continuation turns whose assistant message has no tool calls (OpenCode `tool` / `subtask` parts) and, after `noToolCallTurnsBeforePause` consecutive such turns (default `2`), pauses with stop reason `no tool calls` to guard against self-chat loops. A tool-using turn resets the counter. Configurable via the `noToolCallTurnsBeforePause` option and `--no-tool-turns ` flag. New `messageHasToolCall` helper. +- **Pause auto-continue when a real user message arrives ("latest instruction wins").** The idle handler detects a genuine human message that arrived after the plugin's most recent continuation and pauses the goal (stop reason `user intervention`) instead of talking over the user; `/goal resume` hands control back. Plugin-generated continuation prompts (user-role messages framed in ``) are ignored, and detection requires `turnCount > 0` plus a visible plugin continuation so the first idle and scrolled-out sessions are never misread. New `isPluginContinuationMessage` / `userInterventionDetected` helpers. ### Multiple goals -- **Support multiple goals per session with `/goal add`, `/goal list`, and `/goal focus`.** A session can hold several live goals via a new `sessionGoals` registry; `goalStates` continues to track the single *focused* goal the idle handler drives. `/goal ` replaces the focused goal; `/goal add ` backgrounds the current goal and focuses a new one (only the focused goal auto-continues). `/goal list` shows numbered live goals plus a per-session archive of completed/cleared goals, and `/goal focus ` switches the active goal (numeric refs are index-only). Focus is tracked per session and persisted (state files gain a per-goal `focused` flag and an `archives` array; older single-goal files load with their goal focused). New `buildGoalState` / `formatGoalList` / session-registry helpers. Implements megalist items 3.1, 3.2, and 3.3. -- **Add `/goal sisyphus` ordered goal sequences.** `/goal sisyphus ; ; …` sets up a strict execution sequence: the first objective is focused and the rest queued, and when the focused goal completes the plugin auto-promotes the next until the sequence is exhausted. The ordered flag is tracked per session, shown in `/goal list`, persisted (`orderedSessions`), and cleared by `/goal clear`. New `promoteNextOrderedGoal` helper. Implements megalist item 3.4. +- **Support multiple goals per session with `/goal add`, `/goal list`, and `/goal focus`.** A session can hold several live goals via a new `sessionGoals` registry; `goalStates` continues to track the single *focused* goal the idle handler drives. `/goal ` replaces the focused goal; `/goal add ` backgrounds the current goal and focuses a new one (only the focused goal auto-continues). `/goal list` shows numbered live goals plus a per-session archive of completed/cleared goals, and `/goal focus ` switches the active goal (numeric refs are index-only). Focus is tracked per session and persisted (state files gain a per-goal `focused` flag and an `archives` array; older single-goal files load with their goal focused). New `buildGoalState` / `formatGoalList` / session-registry helpers. +- **Add `/goal sisyphus` ordered goal sequences.** `/goal sisyphus ; ; …` sets up a strict execution sequence: the first objective is focused and the rest queued, and when the focused goal completes the plugin auto-promotes the next until the sequence is exhausted. The ordered flag is tracked per session, shown in `/goal list`, persisted (`orderedSessions`), and cleared by `/goal clear`. New `promoteNextOrderedGoal` helper. ### Schema & command UX -- **Add success-criteria, constraints/non-goals, and mode to the goal schema.** A goal can carry `successCriteria` (`--success`), `constraints` (`--constraints` / `--non-goals`), and a `mode` of `normal` or `ordered` (`--mode`, `sisyphus` alias). These thread through state, persistence, the injected goal block (escaped, new `success_criteria` / `constraints` structural tags), creation output, and `/goal status`. New `normalizeMode` helper. Implements megalist items 4.1, 4.2, and 4.3. -- **Add an inline `--budget ` flag** on the create command — a shorthand for the context-token limit accepting a plain integer or `k`/`m` suffix (e.g. `--budget 100k`). New `parseTokenBudget` helper. Implements megalist item 8.1. -- **Make the slash command configurable (`commandName`) and optional (`registerCommand`).** `commandName` (default `goal`, leading slash tolerated) lets the plugin own e.g. `/objective`, with all user-facing hints following the configured name; `registerCommand: false` skips installing the command hook entirely. New `normalizeCommandOptions` helper. Implements megalist item 8.2. +- **Add success-criteria, constraints/non-goals, and mode to the goal schema.** A goal can carry `successCriteria` (`--success`), `constraints` (`--constraints` / `--non-goals`), and a `mode` of `normal` or `ordered` (`--mode`, `sisyphus` alias). These thread through state, persistence, the injected goal block (escaped, new `success_criteria` / `constraints` structural tags), creation output, and `/goal status`. New `normalizeMode` helper. +- **Add an inline `--budget ` flag** on the create command — a shorthand for the context-token limit accepting a plain integer or `k`/`m` suffix (e.g. `--budget 100k`). New `parseTokenBudget` helper. +- **Make the slash command configurable (`commandName`) and optional (`registerCommand`).** `commandName` (default `goal`, leading slash tolerated) lets the plugin own e.g. `/objective`, with all user-facing hints following the configured name; `registerCommand: false` skips installing the command hook entirely. New `normalizeCommandOptions` helper. ### Storage, tools & packaging -- **Default goal state to a project-local path, with an env override and migration fallbacks.** State resolves as `stateFilePath` option → `OPENCODE_GOAL_STATE_PATH` env var → project-local `/.opencode/goals/state.json` (previously `~/.opencode-goal-plugin/state.json`). When the default path is empty, the plugin migrates forward on first load from the legacy home path and the XDG path, then writes project-local. Explicit option/env paths are literal with no fallback; a present-but-corrupt primary is preserved. New `resolveStateFilePath` / `xdgStateFilePath` / `legacyStateFilePaths` helpers. Home-based fallback paths resolve from an injectable `env.HOME` (falling back to `os.homedir()`), making path resolution deterministic across platforms — `os.homedir()` ignores `$HOME` on macOS. Implements megalist items 6.1 and 6.2. -- _**Correction (2026-06-21):** an earlier version of this entry claimed agent-facing goal tools shipped in 0.3.0. They did not — the work was on an unmerged branch (`wr/agent-tools`) and was never included in the 0.3.0 release. The feature now actually ships; see the **0.6.0** section above. Megalist items 7.1 and 7.2._ +- **Default goal state to a project-local path, with an env override and migration fallbacks.** State resolves as `stateFilePath` option → `OPENCODE_GOAL_STATE_PATH` env var → project-local `/.opencode/goals/state.json` (previously `~/.opencode-goal-plugin/state.json`). When the default path is empty, the plugin migrates forward on first load from the legacy home path and the XDG path, then writes project-local. Explicit option/env paths are literal with no fallback; a present-but-corrupt primary is preserved. New `resolveStateFilePath` / `xdgStateFilePath` / `legacyStateFilePaths` helpers. Home-based fallback paths resolve from an injectable `env.HOME` (falling back to `os.homedir()`), making path resolution deterministic across platforms — `os.homedir()` ignores `$HOME` on macOS. +- _**Correction (2026-06-21):** an earlier version of this entry claimed agent-facing goal tools shipped in 0.3.0. They did not — the work was on an unmerged branch (`wr/agent-tools`) and was never included in the 0.3.0 release. The feature now ships; see the **0.4.0** section above._ - **Release automation note.** Development included a proposed npm Trusted Publishing workflow, but `.github/workflows/publish.yml` was not part of the final release history and is not present in the current repository. Releases therefore remain manual unless a separately reviewed publishing workflow is added. No package is published solely by the CI workflow documented in this repository. ## 0.2.0 — 2026-06-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42177a4..91fc1a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,8 +15,11 @@ Run the local checks before submitting changes: ```sh npm test npm run test:coverage +npm run type:check +npm run test:mutation npm run smoke npm run smoke:packed-host +npm run smoke:packed-tools npm run benchmark:behavior npm run verify npm run check @@ -30,25 +33,19 @@ For behavior changes, add or update tests in `test/goal-plugin.test.js`. 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. Run `npm run smoke` and `npm run smoke:packed-host` to verify the source and installed-tarball host contracts. +2. Run `npm run smoke`, `npm run smoke:packed-host`, `npm run smoke:packed-tools`, and `npm run type:check` to verify the source and installed-tarball contracts. 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. `npm run smoke:packed-host` installs the packed artifact in an isolated directory and checks the public hook/tool contract. `npm run benchmark:behavior` covers deterministic autonomy and token-efficiency scenarios. None replaces a real OpenCode smoke test after hook, SDK, or command behavior changes. +`npm run smoke` verifies the package export path and `/goal` command hook without invoking a model. The packed-host and packed-tool checks install the npm artifact in isolated consumer projects and verify the public hook and complete optional-peer tool contracts. `npm run type:check` compiles installed-package consumers with NodeNext and Bundler resolution. `npm run benchmark:behavior` covers deterministic autonomy and token-efficiency scenarios. None replaces a real OpenCode smoke test after hook, SDK, 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 smoke:packed-host` -- run `npm run benchmark:behavior` -- run `npm run verify` -- run `npm run check` -- run `npm run pack:check` +Before publishing or tagging a release, follow [the release process](docs/releasing.md). The required automated gate is: + +- run `npm ci` +- update `CHANGELOG.md` and both package-version files +- run `npm run release:check` - perform at least one manual OpenCode smoke test if hook behavior changed - refresh compatibility notes if the tested OpenCode surface changed diff --git a/README.md b/README.md index e4d3530..c9173ac 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ [![npm version](https://img.shields.io/npm/v/opencode-goal-plugin)](https://www.npmjs.com/package/opencode-goal-plugin) [![npm downloads](https://img.shields.io/npm/dm/opencode-goal-plugin)](https://www.npmjs.com/package/opencode-goal-plugin) [![CI](https://github.com/willytop8/OpenCode-goal-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/willytop8/OpenCode-goal-plugin/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](test/) +[![CodeQL](https://github.com/willytop8/OpenCode-goal-plugin/actions/workflows/codeql.yml/badge.svg)](https://github.com/willytop8/OpenCode-goal-plugin/actions/workflows/codeql.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -An experimental session-scoped `/goal` command for [OpenCode](https://opencode.ai/). +A session-scoped `/goal` workflow for [OpenCode](https://opencode.ai/). 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. @@ -27,9 +27,13 @@ This project is independently implemented for OpenCode. Product names used elsew | Surface | Status | |---|---| | Node.js | Declared support: `>=18`; CI covers Node 18, 20, 22, and 24 | -| Package entrypoint | `npm run smoke` verifies the package export path plus `/goal` command-hook behavior from a local install without invoking a model | +| Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows | +| Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools | | Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests | +See the [compatibility policy](docs/compatibility.md) for the supported public +surface and versioning expectations. + ### OpenCode version compatibility Manually tested via the OpenCode TUI (`tmux` + real provider credentials, no mocks), verified against the plugin's own persisted state rather than terminal display alone: @@ -380,7 +384,7 @@ The goal text is wrapped in `` tags and labeled as user-provided The assistant still signals candidate outcomes with `[goal:complete]` or `[goal:blocked]`. Completion can additionally be checked by a custom `auditor` callback or the built-in child-session auditor before the goal becomes terminal. Marker quality therefore remains model-dependent when auditing is disabled, and audit quality depends on the configured verifier model and evidence available in the session. The built-in verifier performs static inspection with `read`, `glob`, and `grep`; it cannot execute shell commands. -OpenCode's current `command.execute.before` hook does not fully intercept command text. The plugin can update in-memory goal state as a side effect, but the goal text may still be routed into the normal assistant conversation alongside the state update. +OpenCode's current `command.execute.before` hook does not fully intercept command text. The plugin can update in-memory goal state as a side effect, but the goal text may still be routed into the normal assistant conversation alongside the state update. The plugin therefore guards `/goal status`, `/goal history`, `/goal list`, `/goal pause`, and `/goal clear` (including its aliases) with `tool.execute.before`: inspection tools remain available, while mutation-capable tools are rejected for that routed command turn. Paused goals also inject a system guard that omits the objective and requires an explicit resume before goal work continues. The plugin depends on `experimental.chat.system.transform` and other OpenCode plugin hooks that may change between OpenCode versions. @@ -396,7 +400,7 @@ If a goal does not continue: 2. Run `/goal resume` only after resolving the reported reason. Resume creates a fresh local budget window; it does not erase the objective or history. 3. Check OpenCode's structured logs for persistence, SDK-shape, prompt, or auditor errors. 4. Confirm the configured project directory and state-path precedence described under [Safety limits](#safety-limits). A daemon started elsewhere can otherwise make a manually configured relative path surprising. -5. Run `npm run verify`, `npm run smoke`, and `npm run smoke:packed-host` against the installed source when diagnosing registration or packaging problems. `npm run benchmark:behavior` exercises completion, false-completion, loop, interruption, compaction, and restart behavior without a provider call. +5. Run `npm run verify`, `npm run smoke`, and `npm run smoke:packed-host` against the installed source when diagnosing registration or packaging problems. Maintainers can run `npm run release:check` for the complete artifact and quality gate. `npm run benchmark:behavior` exercises completion, false-completion, loop, interruption, compaction, and restart behavior without a provider call. Do not paste `state.json`, its ledger, or verbose logs into a public issue without reviewing them first: they can contain goal text, assistant checkpoints, blockers, local paths, and command evidence. Prefer the bounded status/history output and redact project-specific content. There is intentionally no broad "dump diagnostics" tool: exposing process-wide session state or persistence paths to the model would add more privacy risk than troubleshooting value. @@ -427,12 +431,16 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil ```sh npm test # run the test suite npm run test:coverage # run tests with coverage +npm run type:check # compile installed-package consumers +npm run test:mutation # prove critical regressions are detected npm run smoke # verify package export + command hook without a model call npm run smoke:packed-host # install the packed tarball and exercise the host contract +npm run smoke:packed-tools # verify all tools from an installed tarball npm run benchmark:behavior # deterministic autonomy + token-efficiency scenarios npm run verify # verify the installed plugin hook surface npm run check # syntax check + tests npm run pack:check # verify package contents before publishing +npm run release:check # run the complete release gate ``` ## License diff --git a/SECURITY.md b/SECURITY.md index 174efaf..6d4ca59 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,18 +2,16 @@ ## Supported Versions -This project is experimental. Security fixes are provided for the latest published version only. +Security fixes are provided for the latest published version only. Users should +upgrade to the newest patch release before reporting a vulnerability. ## Reporting a Vulnerability -GitHub private vulnerability reporting may not always be enabled for this repository. - -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. +Use [GitHub's private vulnerability report](https://github.com/willytop8/OpenCode-goal-plugin/security/advisories/new). +Do **not** open a public issue with exploit details, credentials, local paths, or +reproduction steps that could expose user data or local system access. If private +reporting is temporarily unavailable, contact the maintainer through their GitHub +profile and request a private handoff. ## Scope diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..71fe555 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,41 @@ +# Compatibility policy + +## Supported package surface + +The latest published release is the supported line. Public compatibility covers: + +- the package root and `opencode-goal-plugin/server` ESM exports +- the declarations exported by `index.d.ts` +- the documented `GoalPluginOptions` fields +- the documented OpenCode hook names +- the six canonical goal tools and five legacy tool aliases +- persisted-state recovery from versions documented in the changelog + +The package requires Node.js 18 or newer and OpenCode 1.17.15 through the latest +compatible 1.x release. CI runs the complete unit suite on Node 18, 20, 22, and +24. Installed-package contracts compile TypeScript consumers using both NodeNext +and Bundler resolution and load the npm tarball with and without the optional +`@opencode-ai/plugin` peer. + +Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows. POSIX file +mode and symbolic-link protections are applied where the operating system supports +them; the plugin does not claim that Windows provides equivalent POSIX semantics. + +## OpenCode host compatibility + +OpenCode's experimental hooks and SDK request shapes may change within the 1.x +line. Automated tests cover both current flattened session inputs and the legacy +generated-client shape, but a real-host smoke test remains required when hook or +SDK behavior changes. The current manual provider matrix is maintained in +[providers.md](providers.md). + +## Versioning + +Semantic-versioning intent is: + +- patch: compatible fixes, documentation, and stronger verification +- minor: backward-compatible options, hooks, commands, or tools +- major: removal or incompatible change to a documented public surface + +`testInternals` is exported for diagnostics and the project's own tests; it is not +part of the semantic-version compatibility guarantee. diff --git a/docs/providers.md b/docs/providers.md index ab3ca2b..8211cd0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -20,9 +20,9 @@ vary by provider and model: See the [OpenCode version compatibility table](../README.md#opencode-version-compatibility) in the README for the current findings. -All rows below were verified manually against the real OpenCode TUI (`tmux` -+ live provider credentials, no mocks) on OpenCode 1.17.15, driving the -plugin through `/goal status`, `/goal --max-turns N`, and +All rows below were verified against real OpenCode processes with live +provider credentials and no mocked plugin hooks on OpenCode 1.17.15, driving +the plugin through `/goal status`, `/goal --max-turns N`, and inspecting the plugin's persisted state file to confirm state mutations (limit parsing, turn/stop accounting, evidence-gated completion detection) independent of what was rendered in the terminal. @@ -31,10 +31,29 @@ independent of what was rendered in the terminal. | Provider | Model | Marker compliance | Notes | |---|---|---|---| +| `opencode` | `deepseek-v4-flash-free` | ✅ Canonical tools | OpenCode 1.17.15, isolated HOME/XDG/project, loading the exact 0.6.2 branch source by file URL. In a real interactive PTY, the model fixed an intentionally failing two-test project, ran the tests to 2/2 passing, and completed through `goal_complete`. A second goal checkpointed after writing `step-1`; the plugin ledger then recorded `auto-continue 1/2`, after which the model wrote and verified `step-2` and completed. Sessions: `ses_0b021d93affeCsNvWkmJWwZzF2` and `ses_0b01f3767ffej4Mn6DMSWft0aX`. `/goal status` text was routed to the model by this host version, which then called `goal_status`; the persisted ledger remained authoritative. | | `opencode-go` | `qwen3.7-plus` | ✅ Self-corrects | First attempt emitted bare `[goal:complete]` with no evidence line and was correctly rejected by the plugin. On the very next turn it read the `` re-prompt, added a `[goal:evidence]` line, and completed cleanly — a good demonstration of the evidence gate actually improving behavior rather than just failing closed. | | `opencode-go` | `glm-5.2` | ✅ Clean | Emitted a correct `[goal:evidence] ... [goal:complete]` pair on the first attempt. (An earlier plugin version without the evidence requirement showed GLM-5.2 sometimes trailing extra text after a bare marker — the more structured `` prompt this plugin version sends appears to help.) | | `deepseek` | `deepseek-chat` | ✅ Clean | Emitted a correct `[goal:evidence] ... [goal:complete]` pair on the first attempt, both in a short synthetic goal and in the full [demo](../demo/) (autonomously located and fixed a real bug, then reported evidence-backed completion). Correctly parses per-goal flags (`--max-turns`, etc.) out of the condition text. | +## OpenCode 1.17.15 lifecycle canaries + +The `opencode/deepseek-v4-flash-free` row was also exercised through an +isolated project, HOME, and XDG directories while loading the exact 0.6.2 +source by `file://` URL. Persisted state, ledger entries, and file contents +were checked independently of the model's prose. + +| Scenario | Result | Evidence | +|---|---|---| +| Normal completion | ✅ Pass | `ses_0b021d93affeCsNvWkmJWwZzF2`: fixed an intentionally failing project, reran 2/2 tests, and completed with structured evidence. | +| Idle auto-continuation | ✅ Pass | `ses_0b01f3767ffej4Mn6DMSWft0aX`: checkpointed `step-1`, ledger recorded auto-continue 1/2, then verified `step-2` and completed. | +| Pause and explicit resume across processes | ✅ Pass | `ses_0b01470c1ffeug0LX69fageiVm`: remained paused between OpenCode invocations; explicit resume released it and completion was archived. | +| Concrete blocker and restart | ✅ Pass | `ses_0b013a903ffeg3I6tGbArE2b5E`: stopped with the missing approval-file reason and did not auto-continue after a fresh process loaded it. | +| Hard process interruption and recovery | ✅ Pass | `ses_0b00b37a8ffeDrBshGDutFTqUm`: a running process was terminated during a shell wait; restart recovered paused, status blocked a stale `goal_resume`, the file stayed unchanged, and a later explicit resume completed. | +| Real host compaction | ✅ Pass | `ses_0b00958e3ffekLLH5ztkkvHIPL`: the documented session summarize endpoint returned `true`; exported session data contained a real compaction part plus injected goal/checkpoint context, token accounting reset, and the paused goal resumed cleanly. | +| Clear with stale conversation history | ✅ Pass | `ses_0affb9235ffeWl5LuZxXmvhSfM`: after command-side clearing, the routed model turn attempted `clear_goal` and `goal_resume`; the tool hook rejected all attempts, the file remained `before-clear`, and no goal survived in state. | +| Interactive Esc during a running shell tool | ⚠️ Not established | Esc sent through the automated PTY did not interrupt OpenCode's shell tool. Hard-process recovery is verified above and host-abort hooks have deterministic tests, but this specific TUI input path is not claimed as passed. | + Untested at time of writing: `deepseek-reasoner`, `mistral/*`, `openrouter/*`, and any `nvidia`/`google` provider — add rows here as they're verified. See [Testing a new model](#testing-a-new-model) below. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..84a070a --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,41 @@ +# Release process + +Releases are deliberately verified before they are published. GitHub Actions does +not publish to npm automatically; the maintainer reviews and publishes the exact +artifact after all checks pass. + +## Prepare + +1. Start from a clean branch based on `main`. +2. Update the version in `package.json` and `package-lock.json` together. +3. Move relevant entries from `Unreleased` into a dated changelog section. +4. Run `npm ci` followed by `npm run release:check`. +5. Inspect `npm pack --json` and the generated tarball before publishing. + +`release:check` runs the unit and coverage suites, consumer type compilation, +critical mutation contract, behavior benchmark, source and installed-artifact +smoke tests, full optional-peer tool registration, and package-content check. + +## Publish + +After the commit is reviewed and CI is green, create and push an annotated +`vX.Y.Z` tag at the same commit. The release workflow rejects a tag whose version +does not match `package.json`, reruns the complete release gate, and retains the +verified npm tarball as a workflow artifact. + +Download that artifact, inspect it, and publish the tarball itself: + +```sh +npm publish opencode-goal-plugin-X.Y.Z.tgz --access public +``` + +Confirm the registry digest and unpacked contents match the locally reviewed +artifact before drafting the GitHub release notes. Never rebuild or edit an +artifact after it has been published; prepare a new patch release instead. + +## Trusted publishing + +For a future fully automated publish, configure npm Trusted Publishing for this +repository and a narrowly scoped GitHub Actions workflow, keep `id-token: write` +only on the publish job, require the release environment, and publish with +provenance. Do not add a long-lived npm token to repository secrets. diff --git a/index.d.ts b/index.d.ts index 871eb24..51e9055 100644 --- a/index.d.ts +++ b/index.d.ts @@ -17,10 +17,58 @@ export interface CompletionAuditVerdict { reason?: string } +/** A timestamped lifecycle entry retained with an active or archived goal. */ +export interface GoalHistoryEntry { + type: string + detail: string + timestamp: number +} + +/** A bounded progress checkpoint retained with a goal. */ +export interface GoalCheckpoint { + summary: string + timestamp: number +} + +/** Normalized token and cost usage accumulated for the current goal run. */ +export interface GoalUsage { + input: number + output: number + reasoning: number + cacheRead: number + cacheWrite: number + cost: number + costKnown: boolean +} + +/** Read-only goal snapshot passed to custom completion auditors. */ +export interface GoalAuditSnapshot { + goalId: string + runId: string + condition: string + successCriteria: string + constraints: string + mode: "normal" | "sisyphus" + sessionID: string + turnCount: number + startedAt: number + pausedAt: number + totalTokens: number + usage: Readonly + options: Readonly + lastStatus: string + blockedReason: string + stopped: boolean + stopReason: string + history: readonly Readonly[] + checkpoints: readonly Readonly[] + lastCheckpoint: Readonly | null +} + /** Arguments passed to a custom {@link GoalPluginOptions.auditor} function. */ export interface CompletionAuditContext { /** The goal being audited (objective, budget usage, checkpoints, etc.). */ - goal: unknown + goal: Readonly /** The OpenCode session ID the goal belongs to. */ sessionID: string /** The assistant's latest response text, containing the `[goal:evidence]`/`[goal:complete]` claim. */ @@ -283,10 +331,10 @@ export interface GoalPluginOptions { /** * Custom sink for audit announcements. Defaults to routing through - * OpenCode's structured log (`client.app.log`). Provide this to route - * audit messages elsewhere, e.g. into the live conversation. + * OpenCode's structured log (`client.app.log`) and TUI toast when those + * host APIs are available. Provide this to route audit messages elsewhere. */ - auditMessenger?: (sessionID: string, text: string) => Promise + auditMessenger?: (sessionID: string, text: string) => Promise | void } /** @@ -300,6 +348,8 @@ export interface GoalPluginHooks { config: (config: unknown) => Promise /** Omitted entirely when {@link GoalPluginOptions.registerCommand} is `false`. */ "command.execute.before"?: (input: unknown, output: unknown) => Promise + /** Enforces read-only tool behavior when inspection, pause, or clear command text is routed to the model. */ + "tool.execute.before": (input: unknown, output: unknown) => Promise event: (input: unknown) => Promise "experimental.chat.system.transform": (input: unknown, output: unknown) => Promise "experimental.compaction.autocontinue": (input: unknown, output: unknown) => Promise @@ -312,7 +362,6 @@ export interface GoalPluginHooks { tool?: Record /** Cancels pending continuation work and releases this plugin instance. */ dispose: () => Promise - [hook: string]: unknown } /** diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..df78599 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,467 @@ +{ + "name": "opencode-goal-plugin", + "version": "0.6.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-goal-plugin", + "version": "0.6.2", + "license": "MIT", + "bin": { + "opencode-goal-plugin": "scripts/verify.mjs" + }, + "devDependencies": { + "@opencode-ai/plugin": "1.17.18", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=18", + "opencode": ">=1.17.15 <2" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.17.15 <2" + }, + "peerDependenciesMeta": { + "@opencode-ai/plugin": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.17.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.17.18.tgz", + "integrity": "sha512-tqVBzhTHYUzO0laAmcQeBtT56tXYM5VGUk9V60O+cMx4kkSfac4qEfPVguCGUMJnfcU+u+EPvpME6xmHWoQE8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.17.18", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.3", + "@opentui/keymap": ">=0.4.3", + "@opentui/solid": ">=0.4.3" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.17.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz", + "integrity": "sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.2.tgz", + "integrity": "sha512-m0vXfHODcw3gk+KONAOlVQ5yNHc3yS3B1ybM3HS1vqDoS0RWTDDVBVVTYi8hH0k+2OM1vmo9fb1WX9EVqjqfHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 57c1d19..66a9e2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-goal-plugin", - "version": "0.6.1", + "version": "0.6.2", "description": "Durable, guarded goal workflows for OpenCode.", "type": "module", "main": "./src/goal-plugin.js", @@ -22,7 +22,7 @@ }, "files": [ "src", - "scripts", + "scripts/verify.mjs", "examples", "demo", "docs", @@ -36,13 +36,18 @@ ], "scripts": { "test": "node --test test/*.test.js", + "test:model": "node --test test/lifecycle-model.test.js", + "test:mutation": "node scripts/mutation-contract.mjs", "test:coverage": "node --test --experimental-test-coverage test/*.test.js", + "type:check": "node scripts/type-contract.mjs", "smoke": "node scripts/smoke-command-hook.mjs", "smoke:packed-host": "node scripts/packed-host-contract.mjs", + "smoke:packed-tools": "node scripts/packed-tool-contract.mjs", "benchmark:behavior": "node scripts/behavior-benchmark.mjs", "verify": "node scripts/verify.mjs", "check": "node -c src/goal-plugin.js && npm test", - "pack:check": "npm pack --dry-run" + "pack:check": "npm pack --dry-run", + "release:check": "npm run check && npm run test:coverage && npm run type:check && npm run test:mutation && npm run benchmark:behavior && npm run smoke && npm run smoke:packed-host && npm run smoke:packed-tools && npm run verify && npm audit --omit=dev --audit-level=high && npm run pack:check" }, "keywords": [ "opencode", @@ -73,5 +78,9 @@ "homepage": "https://github.com/willytop8/OpenCode-goal-plugin#readme", "author": { "name": "willytop8" + }, + "devDependencies": { + "@opencode-ai/plugin": "1.17.18", + "typescript": "5.9.3" } } diff --git a/scripts/mutation-contract.mjs b/scripts/mutation-contract.mjs new file mode 100644 index 0000000..befe75c --- /dev/null +++ b/scripts/mutation-contract.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const repository = fileURLToPath(new URL("..", import.meta.url)) +const root = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-mutations-")) + +const mutants = [ + { + name: "verifier default deny", + file: "src/native-agent-config.js", + from: '\"*\": \"deny\"', + to: '\"*\": \"allow\"', + test: "test/native-agent-config.test.js", + }, + { + name: "mutating SDK calls are never replayed", + file: "src/opencode-session-api.js", + from: 'new Set([\"messages\", \"get\"])', + to: 'new Set([\"messages\", \"get\", \"prompt\"])', + test: "test/opencode-session-api.test.js", + }, + { + name: "completion evidence must be adjacent", + file: "src/goal-plugin.js", + from: "const previous = markerIndex - 1", + to: "const previous = markerIndex - 2", + test: "test/goal-plugin.test.js", + }, + { + name: "terminal completion requires durable storage", + file: "src/goal-plugin.js", + from: "if (durable === false) {\n restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })", + to: "if (false) {\n restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })", + test: "test/goal-plugin.test.js", + }, + { + name: "token accounting resets only after compaction succeeds", + file: "src/goal-plugin.js", + from: 'event?.type === \"session.compacted\"', + to: 'event?.type === \"session.compacting\"', + test: "test/goal-plugin.test.js", + }, +] + +try { + await Promise.all([ + cp(join(repository, "src"), join(root, "src"), { recursive: true }), + cp(join(repository, "test"), join(root, "test"), { recursive: true }), + writeFile(join(root, "package.json"), JSON.stringify({ private: true, type: "module" })), + ]) + + const baselineTests = [...new Set(mutants.map(({ test }) => test))] + for (const testFile of baselineTests) { + const result = spawnSync(process.execPath, ["--test", testFile], { cwd: root, encoding: "utf8" }) + assert.equal(result.status, 0, `mutation baseline failed for ${testFile}\n${result.stdout}\n${result.stderr}`) + } + + for (const mutant of mutants) { + const path = join(root, mutant.file) + const original = await readFile(path, "utf8") + const occurrences = original.split(mutant.from).length - 1 + assert.equal(occurrences, 1, `${mutant.name}: expected exactly one mutation target, found ${occurrences}`) + await writeFile(path, original.replace(mutant.from, mutant.to)) + + const result = spawnSync(process.execPath, ["--test", mutant.test], { cwd: root, encoding: "utf8" }) + assert.notEqual(result.status, 0, `${mutant.name}: test suite survived the mutant`) + await writeFile(path, original) + console.log(`killed mutant: ${mutant.name}`) + } + + console.log(`mutation contract passed (${mutants.length}/${mutants.length} critical mutants killed)`) +} finally { + await rm(root, { recursive: true, force: true }) +} diff --git a/scripts/packed-host-contract.mjs b/scripts/packed-host-contract.mjs index 179ee8e..ac894e4 100644 --- a/scripts/packed-host-contract.mjs +++ b/scripts/packed-host-contract.mjs @@ -107,6 +107,7 @@ try { for (const hook of [ "config", "command.execute.before", + "tool.execute.before", "event", "experimental.chat.system.transform", "experimental.session.compacting", diff --git a/scripts/packed-tool-contract.mjs b/scripts/packed-tool-contract.mjs new file mode 100644 index 0000000..1b76d0d --- /dev/null +++ b/scripts/packed-tool-contract.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { pathToFileURL } from "node:url" + +const repository = new URL("..", import.meta.url) +const root = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-packed-tools-")) +const packDirectory = join(root, "pack") +const projectDirectory = join(root, "consumer") +const cacheDirectory = join(root, "npm-cache") +const npmEnvironment = { ...process.env, npm_config_cache: cacheDirectory } +const sourceManifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) +const peerVersion = sourceManifest.devDependencies?.["@opencode-ai/plugin"] +assert.match(peerVersion ?? "", /^\d+\.\d+\.\d+$/, "a pinned @opencode-ai/plugin devDependency is required") + +const expectedTools = [ + "clear_goal", + "get_goal", + "get_goal_history", + "goal_block", + "goal_complete", + "goal_pause", + "goal_resume", + "goal_set", + "goal_status", + "set_goal", + "update_goal", +] + +try { + await Promise.all([ + mkdir(packDirectory, { recursive: true }), + mkdir(projectDirectory, { recursive: true }), + mkdir(cacheDirectory, { recursive: true }), + ]) + await writeFile(join(projectDirectory, "package.json"), JSON.stringify({ private: true, type: "module" })) + + const packResult = JSON.parse(execFileSync( + "npm", + ["pack", "--json", "--pack-destination", packDirectory], + { cwd: repository, encoding: "utf8", env: npmEnvironment }, + )) + assert.equal(packResult.length, 1) + const tarball = join(packDirectory, packResult[0].filename) + + execFileSync( + "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", "--cache", cacheDirectory, tarball, `@opencode-ai/plugin@${peerVersion}`], + { cwd: projectDirectory, stdio: "pipe", env: npmEnvironment }, + ) + + const installedRoot = join(projectDirectory, "node_modules", "opencode-goal-plugin") + const manifest = JSON.parse(await readFile(join(installedRoot, "package.json"), "utf8")) + const installed = await import(pathToFileURL(join(installedRoot, manifest.main)).href) + const hooks = await installed.GoalPlugin( + { client: { session: {} }, directory: projectDirectory }, + { persistState: false }, + ) + + assert.deepEqual(Object.keys(hooks.tool ?? {}).sort(), expectedTools) + for (const name of expectedTools) { + assert.equal(typeof hooks.tool[name].execute, "function", `${name} must be executable`) + } + + const config = {} + await hooks.config(config) + assert.equal(config.agent["goal-verify"].permission["*"], "deny") + assert.equal(config.agent["goal-verify"].permission.read, "allow") + assert.equal(config.agent["goal-verify"].tools.edit, false) + await hooks.dispose() + + console.log(`packed tool contract passed (${manifest.name}@${manifest.version}; ${expectedTools.length} tools)`) +} finally { + await rm(root, { recursive: true, force: true }) +} diff --git a/scripts/type-contract.mjs b/scripts/type-contract.mjs new file mode 100644 index 0000000..1f91f83 --- /dev/null +++ b/scripts/type-contract.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const repository = new URL("..", import.meta.url) +const repositoryPath = fileURLToPath(repository) +const root = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-types-")) +const packDirectory = join(root, "pack") +const consumerDirectory = join(root, "consumer") +const cacheDirectory = join(root, "npm-cache") +const npmEnvironment = { ...process.env, npm_config_cache: cacheDirectory } +const tsc = join(repositoryPath, "node_modules", "typescript", "bin", "tsc") + +const fixture = ` +import goalPlugin, { + GoalPlugin, + type CompletionAuditContext, + type GoalPluginHooks, + type GoalPluginOptions, +} from "opencode-goal-plugin" +import serverPlugin from "opencode-goal-plugin/server" + +const options = { + sdkShape: "flat", + maxTurns: 12, + maxDurationMs: 60_000, + maxTokens: 50_000, + minDelayMs: 10, + maxRecentMessages: 30, + noProgressTokenThreshold: 25, + noProgressTurnsBeforePause: 3, + noToolCallTurnsBeforePause: 0, + budgetWrapupRatio: 0.75, + warnTurnsRemaining: 2, + warnDurationMsRemaining: 10_000, + warnTokensRemaining: 5_000, + maxPromptFailures: 2, + persistState: false, + stateFilePath: "/tmp/goals.json", + ledgerFilePath: "/tmp/goals.ledger.jsonl", + ledgerMaxBytes: 1_000_000, + ledgerRetentionFiles: 2, + resultRetentionMs: 10_000, + maxStoredResults: 20, + commandName: "objective", + registerCommand: true, + registerTools: true, + registerAgents: true, + goalAgentName: "objective", + verifierAgentName: "objective-check", + completionAudit: true, + auditorOptions: { timeoutMs: 5_000, failurePolicy: "reject" }, + auditMessages: true, + auditMessenger: (_sessionID, _text) => {}, + auditor: async ({ goal, sessionID, latestText }: CompletionAuditContext) => ({ + approved: goal.sessionID === sessionID && latestText.length > 0, + reason: goal.lastCheckpoint?.summary, + }), +} satisfies GoalPluginOptions + +const hooks: GoalPluginHooks = await GoalPlugin({ client: {}, directory: "/tmp" }, options) +hooks.config({}) +hooks.event({}) +hooks["experimental.chat.system.transform"]({}, {}) +hooks["tool.execute.before"]({}, {}) +hooks["experimental.compaction.autocontinue"]({}, {}) +hooks["experimental.session.compacting"]({}, {}) +await hooks.dispose() + +const sameServer: typeof GoalPlugin = goalPlugin.server +const sameExport: typeof goalPlugin = serverPlugin +void sameServer +void sameExport + +// @ts-expect-error unknown hooks must not be hidden by an index signature +hooks["experimental.missing.hook"] +// @ts-expect-error invalid SDK shapes must be rejected by consumers +const invalid: GoalPluginOptions = { sdkShape: "automatic" } +void invalid +` + +try { + await Promise.all([ + mkdir(packDirectory, { recursive: true }), + mkdir(consumerDirectory, { recursive: true }), + mkdir(cacheDirectory, { recursive: true }), + ]) + await writeFile(join(consumerDirectory, "package.json"), JSON.stringify({ private: true, type: "module" })) + await writeFile(join(consumerDirectory, "contract.ts"), fixture) + + const packResult = JSON.parse(execFileSync( + "npm", + ["pack", "--json", "--pack-destination", packDirectory], + { cwd: repository, encoding: "utf8", env: npmEnvironment }, + )) + assert.equal(packResult.length, 1) + const tarball = join(packDirectory, packResult[0].filename) + execFileSync( + "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", "--omit=peer", "--offline", "--cache", cacheDirectory, tarball], + { cwd: consumerDirectory, stdio: "pipe", env: npmEnvironment }, + ) + + const common = ["--strict", "--noEmit", "--target", "ES2022", "--skipLibCheck", "false", "contract.ts"] + execFileSync(process.execPath, [tsc, "--module", "NodeNext", "--moduleResolution", "NodeNext", ...common], { + cwd: consumerDirectory, + stdio: "pipe", + }) + execFileSync(process.execPath, [tsc, "--module", "ESNext", "--moduleResolution", "Bundler", ...common], { + cwd: consumerDirectory, + stdio: "pipe", + }) + console.log(`type contract passed (NodeNext + Bundler; ${packResult[0].filename})`) +} finally { + await rm(root, { recursive: true, force: true }) +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index e34a365..77372ae 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -9,6 +9,7 @@ import assert from "node:assert/strict" const REQUIRED_HOOKS = [ "config", "command.execute.before", + "tool.execute.before", "event", "experimental.chat.system.transform", "experimental.compaction.autocontinue", diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 29c4e57..b2a7396 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -88,6 +88,7 @@ function createRuntimeState() { activeContinues: new Map(), continuationControllers: new Map(), seenIdleEventIDs: new Set(), + readOnlyCommandGuards: new Set(), ledgerSink: null, persistenceLease: null, migrationLease: null, @@ -143,6 +144,7 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens") const activeContinues = runtimeCollection("activeContinues") const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"]) const PAUSE_COMMANDS = new Set(["pause"]) +const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"]) const GOAL_FLAG_SPECS = { "--max-turns": { optionKey: "maxTurns", @@ -206,7 +208,7 @@ function messageHasToolCall(message) { const GOAL_MODES = new Set(["normal", "ordered"]) -// Goal "mode" field (item 4.3): normal vs ordered (a.k.a. sisyphus). `ordered` +// Goal mode: normal vs ordered (a.k.a. sisyphus). `ordered` // signals a strict execution sequence; `sisyphus` is accepted as an alias. // Returns the canonical mode or null when unrecognized. function normalizeMode(value) { @@ -289,12 +291,12 @@ function makeHistoryEntry(type, detail, timestamp = Date.now()) { } } -// Append-only lifecycle ledger (item 2.3). pushHistory emits every lifecycle +// Append-only lifecycle ledger. pushHistory emits every lifecycle // event to this sink, which a configured plugin instance points at a JSONL // file. Because the in-memory history is truncated to MAX_HISTORY_ENTRIES, the // ledger is the durable record used to reconstruct state if the main state file // is lost or corrupted, and it captures terminal events even when the main -// state write fails (fail-closed, item 2.5). +// state write fails (fail closed). function setLedgerSink(sink) { currentRuntime().ledgerSink = typeof sink === "function" ? sink : null } @@ -705,6 +707,7 @@ function clearRuntimeState() { activeContinues.clear() runtime.continuationControllers.clear() runtime.seenIdleEventIDs.clear() + runtime.readOnlyCommandGuards.clear() } function pruneGoalResults(options) { @@ -1001,7 +1004,7 @@ async function assertSafeProjectPersistencePath({ stateFilePath, projectRoot, en } } -// Command surface options (item 8.2): `commandName` lets the plugin own a +// Command surface options: `commandName` lets the plugin own a // different slash command (e.g. /objective) and `registerCommand: false` makes // the plugin skip the command hook entirely (agent/programmatic use only). A // leading slash in commandName is tolerated and stripped. @@ -1418,7 +1421,7 @@ async function loadPersistedState(persistenceOptions, client) { // Last-resort recovery: when the main state file is absent, rebuild still-active // goals from the append-only ledger so a lost/rotated state file does not drop -// in-flight goals (item 2.3). Recovered goals are paused (via deserializeGoal). +// in-flight goals. Recovered goals are paused (via deserializeGoal). async function reconstructFromLedger(persistenceOptions, client) { const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, { maxBytes: persistenceOptions.ledgerMaxBytes, @@ -1788,7 +1791,7 @@ function buildContinueMessage( // Deterministic progress summary built from the plugin's persisted goal record // (checkpoints + lifecycle history) rather than from chat memory, so it is -// stable and reproducible across a compaction (item 6.3). +// stable and reproducible across a compaction. function buildCompactionProgressSummary(goal, { maxCheckpoints = 3, maxEvents = 6 } = {}) { const lines = [] const checkpoints = Array.isArray(goal.checkpoints) ? goal.checkpoints.slice(-maxCheckpoints) : [] @@ -2069,7 +2072,7 @@ function isPluginContinuationMessage(message) { // "Latest instruction wins": detect a real (human) user message that arrived // after the plugin's most recent continuation prompt. Plugin-generated -// continuation/audit messages are ignored (item 5.2). Detection requires the +// continuation/audit messages are ignored. Detection requires the // loop to be running (turnCount > 0) and a plugin continuation to be visible in // the recent window, so the first idle after /goal set and sessions where the // continuations have scrolled out of view are never misread as intervention. @@ -2139,7 +2142,7 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = " const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"]) // Programmatic equivalents of the /goal command, exposed to the agent as tools -// (megalist items 7.1 / 7.2). Each handler operates on a session id and mutates +// Each handler operates on a session id and mutates // the same in-memory state the command path uses, persisting through the // provided `persist` callback, and returns a human-readable string for the tool // result. Goal creation/replacement routes through the multi-goal registry @@ -2356,12 +2359,9 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt } else if (status === "resumed") { if (!goal.stopped) return "Goal is already running. Pause or stop it first if you want to reset the budget window." - const previousGoalId = goal.goalId resetGoalBudget(goal) - // resetGoalBudget always rotates goalId via randomUUID; unconditionally - // re-key the registry so the goal stays findable by its new id. - removeSessionGoal(sessionID, previousGoalId) - registerSessionGoal(goal) + // goalId is stable across budget windows; runId is the execution epoch. + // Keeping the existing registry entry also preserves multi-goal order. focusGoal(sessionID, goal) goal.stopped = false goal.stopReason = "" @@ -2594,7 +2594,7 @@ function formatGoalList(sessionID, commandName = "goal") { return lines.join("\n") } -// Visible audit messages (item 2.4): when the plugin audits a completion or +// Visible audit messages: when the plugin audits a completion or // blocker it announces the audit and its result instead of doing the work // silently. Delivery is via this default messenger (structured app log, the // channel OpenCode surfaces to the user) or a caller-supplied `auditMessenger` @@ -2623,7 +2623,7 @@ async function defaultAuditMessenger(client, sessionID, text) { } } -// Completion auditor (item 2.2). When an auditor is configured, a [goal:complete] +// Completion auditor. When an auditor is configured, a [goal:complete] // is verified before the goal is archived: an approved verdict archives it, a // rejected verdict restores the goal (pauses it with the reason) instead of // archiving. The auditor is a function `({ goal, sessionID, latestText }) => @@ -2783,7 +2783,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } runtime.drainPersistence = () => persistChain.catch(() => false) - // Fail-closed (item 2.5): when persisting a terminal state (complete/blocked) + // Fail closed when persisting a terminal state (complete/blocked) // fails, surface it loudly. The terminal event is already in the append-only // ledger, so it stays recoverable across a restart even though the main state // file write did not land. @@ -2810,7 +2810,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) setLedgerSink(null) } - // Visible audit announcements (item 2.4). + // Visible audit announcements. const auditMessagesEnabled = pluginOptions.auditMessages !== false const auditMessenger = typeof pluginOptions.auditMessenger === "function" @@ -2885,6 +2885,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) }) if (pluginOptions.completionAudit) verifierRegistrationReady = true }, + "tool.execute.before": async (input) => { + const sessionID = input?.sessionID + if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return + if (READ_ONLY_COMMAND_TOOLS.has(input?.tool)) return + throw new Error( + `This /${commandName} control command is read-only for the routed model turn. Tool "${input?.tool || "unknown"}" was blocked. Wait for a separate user turn; do not modify work or goal state now.`, + ) + }, "command.execute.before": async (input, output) => { if (!input || input.command !== commandName || !output) return @@ -2898,10 +2906,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } const args = input.arguments.trim() const sessionID = input.sessionID + currentRuntime().readOnlyCommandGuards.delete(sessionID) pruneGoalResults(defaultGoalOptions) if (!args || args === "status") { const goal = goalStates.get(sessionID) + currentRuntime().readOnlyCommandGuards.add(sessionID) const lastResult = lastGoalResults.get(sessionID) output.parts = [ makeTextPart( @@ -2917,6 +2927,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (args === "history") { const goal = goalStates.get(sessionID) + currentRuntime().readOnlyCommandGuards.add(sessionID) const lastResult = lastGoalResults.get(sessionID) output.parts = [ makeTextPart( @@ -2943,6 +2954,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } if (CLEAR_COMMANDS.has(args)) { + currentRuntime().readOnlyCommandGuards.add(sessionID) // Record the clear in the ledger before cleanupGoal removes the goal // object, so reconstructFromLedger can identify cleared goals and skip // them rather than reconstructing them after a missing state file. @@ -2962,6 +2974,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } if (PAUSE_COMMANDS.has(args)) { + currentRuntime().readOnlyCommandGuards.add(sessionID) const goal = goalStates.get(sessionID) if (!goal) { output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} \`.`)] @@ -2987,12 +3000,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) return } - const previousGoalId = goal.goalId resetGoalBudget(goal) - // resetGoalBudget always rotates goalId via randomUUID; unconditionally - // re-key the registry so a later clear/replace removes the right entry. - removeSessionGoal(sessionID, previousGoalId) - registerSessionGoal(goal) + // goalId is stable across budget windows; runId is the execution epoch. + // Keeping the existing registry entry also preserves multi-goal order. focusGoal(sessionID, goal) goal.stopped = false goal.stopReason = "" @@ -3052,6 +3062,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } if (args === "list") { + currentRuntime().readOnlyCommandGuards.add(sessionID) output.parts = [makeTextPart(formatGoalList(sessionID, commandName))] return } @@ -3394,6 +3405,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (!isIdleEvent(event)) return const sessionID = getSessionID(event) + currentRuntime().readOnlyCommandGuards.delete(sessionID) const eventID = typeof event?.id === "string" ? event.id : "" const seenIdleEventIDs = currentRuntime().seenIdleEventIDs if (eventID && seenIdleEventIDs.has(eventID)) return @@ -3478,7 +3490,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // bail out without archiving — archiving a cleared goal would resurrect // it in memory and potentially in the persisted state. if (!activeGoal(sessionID, goalID, runID)) return - // Optional independent auditor (item 2.2): an approved verdict + // Optional independent auditor: an approved verdict // archives; a rejected verdict restores (pauses) the goal instead. if (completionAuditor) { let verdict @@ -3867,7 +3879,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const goal = goalStates.get(input.sessionID) if (!goal) return - if (goal.stopped) return const systemBlocks = Array.isArray(output.system) ? [...output.system] : [] if (systemBlocks.some((block) => systemBlockContainsGoal(block, goal.goalId))) return @@ -3880,14 +3891,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // on every continuation turn via buildContinueMessage (buildLimitWarning // and ), which is sufficient — the model doesn't need // them in the system prompt mid-turn. - const goalBlock = [ - ``, - buildGoalBlock(goal), - "Keep working until the goal is fully satisfied.", - "When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.", - "If user input is required, explain the concrete blocker in the line immediately before `[goal:blocked]`. A `[goal:blocked]` without a concrete blocker is rejected.", - "", - ].join("\n") + const goalBlock = goal.stopped + ? [ + ``, + "paused", + "A goal exists for this session, but it is paused. Do not continue or modify work toward it, and do not call completion or blocker tools, unless the current user message explicitly asks to resume it.", + "For status or history requests, only report the goal state; do not change files or goal state.", + `To continue, the user can run /${commandName} resume or explicitly ask you to call goal_resume before doing any goal work.`, + "", + ].join("\n") + : [ + ``, + buildGoalBlock(goal), + "Keep working until the goal is fully satisfied.", + "When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.", + "If user input is required, explain the concrete blocker in the line immediately before `[goal:blocked]`. A `[goal:blocked]` without a concrete blocker is rejected.", + "", + ].join("\n") if (systemBlocks.length === 0) { output.system = [goalBlock] @@ -3930,13 +3950,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) }, } - // register_command toggle (item 8.2): when disabled, the plugin does not own + // register_command toggle: when disabled, the plugin does not own // a slash command and only the event/transform/compaction hooks remain. if (!registerCommand) { delete hooks["command.execute.before"] } - // Register agent-facing tools (megalist 7.1 / 7.2) when @opencode-ai/plugin is + // Register agent-facing tools when @opencode-ai/plugin is // available (it provides the `tool` helper and zod-style schema). Disabled via // `registerTools: false`. When the helper is absent the command/event hooks // still work; only the programmatic tool surface is omitted, preserving the diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index a16f818..911061b 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -1051,7 +1051,11 @@ test("stopped goals can be resumed", async () => { const stoppedOutput = { system: [] } await hooks["experimental.chat.system.transform"]({ sessionID: "session-1" }, stoppedOutput) - assert.equal(stoppedOutput.system.length, 0) + assert.equal(stoppedOutput.system.length, 1) + assert.match(stoppedOutput.system[0], /paused<\/goal_state>/) + assert.match(stoppedOutput.system[0], /do not change files or goal state/i) + assert.doesNotMatch(stoppedOutput.system[0], //) + assert.doesNotMatch(stoppedOutput.system[0], /Keep working until/) const resumeOutput = { parts: [] } await hooks["command.execute.before"]( @@ -1063,6 +1067,73 @@ test("stopped goals can be resumed", async () => { const resumedOutput = { system: [] } await hooks["experimental.chat.system.transform"]({ sessionID: "session-1" }, resumedOutput) assert.equal(resumedOutput.system.length, 1) + assert.match(resumedOutput.system[0], /\nship it\n<\/goal_objective>/) + assert.match(resumedOutput.system[0], /Keep working until/) +}) + +test("inspection, pause, and clear commands block mutation tools when command text reaches the model", async () => { + const { hooks } = await createHooks() + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-read-only", arguments: "status" }, + { parts: [] }, + ) + await assert.rejects( + () => hooks["tool.execute.before"]( + { tool: "set_goal", sessionID: "session-read-only", callID: "empty-status-call" }, + { args: { objective: "stale work" } }, + ), + /control command.*set_goal.*blocked/i, + ) + await hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "session-read-only", status: { type: "idle" } }, + }, + }) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-read-only", arguments: "ship it" }, + { parts: [] }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-read-only", arguments: "pause" }, + { parts: [] }, + ) + await assert.doesNotReject(() => hooks["tool.execute.before"]( + { tool: "goal_status", sessionID: "session-read-only", callID: "status-call" }, + { args: {} }, + )) + await assert.rejects( + () => hooks["tool.execute.before"]( + { tool: "write", sessionID: "session-read-only", callID: "write-call" }, + { args: { filePath: "unsafe.txt", content: "unexpected" } }, + ), + /control command.*write.*blocked/i, + ) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-read-only", arguments: "clear" }, + { parts: [] }, + ) + await assert.rejects( + () => hooks["tool.execute.before"]( + { tool: "set_goal", sessionID: "session-read-only", callID: "clear-call" }, + { args: { objective: "resurrect stale work" } }, + ), + /control command.*set_goal.*blocked/i, + ) + + await hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "session-read-only", status: { type: "idle" } }, + }, + }) + await assert.doesNotReject(() => hooks["tool.execute.before"]( + { tool: "write", sessionID: "session-read-only", callID: "later-write-call" }, + { args: {} }, + )) }) test("resume after a limit stop starts a fresh local budget", async () => { @@ -3094,7 +3165,7 @@ test("buildCompactionContext includes the latest checkpoint when present", () => assert.match(context, /finish the audit/) }) -test("buildCompactionProgressSummary is deterministic and built from the persisted record (item 6.3)", () => { +test("buildCompactionProgressSummary is deterministic and built from the persisted record", () => { const now = Date.now() const goal = { checkpoints: [ @@ -3506,7 +3577,7 @@ test("completed archives survive a persistence round-trip", async () => { } }) -// ── Visible audit messages (item 2.4) ────────────────────────────────────── +// ── Visible audit messages ───────────────────────────────────────────────── test("completion emits visible audit-start and audit-result messages", async () => { const audits = [] @@ -3592,7 +3663,7 @@ test("defaultAuditMessenger posts through client.app.log and tolerates its absen await defaultAuditMessenger({}, "s", "x") }) -// ── Separate completion auditor (item 2.2) ───────────────────────────────── +// ── Separate completion auditor ──────────────────────────────────────────── test("parseAuditVerdict reads the verdict marker and reason", () => { assert.deepEqual(parseAuditVerdict("looks complete\n[audit:approved]"), { approved: true, reason: "" }) @@ -3753,7 +3824,7 @@ test("createChildSessionAuditor requires an explicit policy to approve operation ) }) -// ── Sisyphus ordered goals (item 3.4) ────────────────────────────────────── +// ── Sisyphus ordered goals ───────────────────────────────────────────────── async function idleOnce(hooks, sessionID) { await hooks.event({ @@ -3856,7 +3927,7 @@ test("ordered (sisyphus) flag survives a persistence round-trip", async () => { } }) -// ── Agent-facing tools (megalist items 7.1 / 7.2) ────────────────────────── +// ── Agent-facing tools ───────────────────────────────────────────────────── function makeAgentHandlers(options = {}) { const persistCalls = [] @@ -3978,7 +4049,7 @@ test("buildAgentTools wraps handlers into OpenCode tool defs and routes by sessi "set_goal", "update_goal", ]) - // The set_goal description constrains autonomous use (item 7.2). + // The set_goal description constrains autonomous use. assert.match(tools.set_goal.description, /ONLY call this when the user explicitly asks/) const sid = "agent-tool-s1" @@ -4173,24 +4244,40 @@ test("canonical errors use state and stable codes instead of parsing legacy pros assert.match(rejected.message, /custom provider verdict/) }) -test("/goal resume does not leak a stale registry entry on later clear", async () => { +test("/goal resume preserves registry identity and clears cleanly", async () => { const { hooks } = await createHooks() const run = (args) => hooks["command.execute.before"]({ command: "goal", sessionID: "resume-leak", arguments: args }, { parts: [] }) await run("ship it") assert.equal(listSessionGoals("resume-leak").length, 1) + const goalId = currentGoal("resume-leak").goalId await run("pause") - // resume rotates the goalId via resetGoalBudget; the registry must be re-keyed. await run("resume") assert.equal(listSessionGoals("resume-leak").length, 1) + assert.equal(currentGoal("resume-leak").goalId, goalId) await run("clear") - // Before the re-key fix this left a stale goal behind (length 1). assert.equal(listSessionGoals("resume-leak").length, 0) assert.equal(currentGoal("resume-leak"), null) }) -// ── PR B: Concurrency + Persistence fixes ───────────────────────────────────── +test("resuming a backgrounded goal preserves creation order", async () => { + const { hooks } = await createHooks() + const sessionID = "resume-order" + const run = (args) => + hooks["command.execute.before"]({ command: "goal", sessionID, arguments: args }, { parts: [] }) + + await run("first") + await run("add second") + await run("add third") + await run("focus 1") + await run("pause") + await run("resume") + + assert.deepEqual(listSessionGoals(sessionID).map(({ condition }) => condition), ["first", "second", "third"]) +}) + +// ── Concurrency and persistence ──────────────────────────────────────────────── test("/goal clear records a 'cleared' ledger event so cleared goals are not reconstructed after restart", async () => { const dir = await mkdtemp(join(tmpdir(), "goal-plugin-clear-ledger-")) @@ -4356,7 +4443,7 @@ test("agent completion approval cannot delete a goal that replaced it during aud assert.equal(currentGoal(sessionID).condition, "replacement objective") }) -// ── PR C: State machine + security fixes ────────────────────────────────────── +// ── State machine and security ──────────────────────────────────────────────── test("formatFailures is preserved through a persistence round-trip", async () => { const dir = await mkdtemp(join(tmpdir(), "goal-plugin-ff-")) @@ -5215,30 +5302,29 @@ test("approved completion that is lost while auditor runs produces an announceme assert.equal(currentGoal("lost-completion-s1"), null, "goal must be gone (was cleared)") }) -// ── PR G: Low-severity cleanups ─────────────────────────────────────────────── +// ── Regression coverage ────────────────────────────────────────────────────── -test("agent updateGoal status='resumed' does not leak a stale registry entry on later clear", async () => { - // resetGoalBudget always rotates goalId; the registry must be re-keyed - // unconditionally (rank 42 fix — removed dead if-conditional in agent path). +test("agent updateGoal status='resumed' preserves identity and clears cleanly", async () => { const { handlers } = makeAgentHandlers() const sid = "agent-resume-leak" await handlers.setGoal(sid, { objective: "ship it" }) assert.equal(listSessionGoals(sid).length, 1) + const goalId = currentGoal(sid).goalId await handlers.updateGoal(sid, { status: "paused" }) await handlers.updateGoal(sid, { status: "resumed" }) assert.equal(listSessionGoals(sid).length, 1, "registry must have exactly one entry after resume") + assert.equal(currentGoal(sid).goalId, goalId) await handlers.clearGoal(sid) - // Before the fix a stale pre-resume goalId entry remained. assert.equal(listSessionGoals(sid).length, 0, "registry must be empty after clear") assert.equal(currentGoal(sid), null) }) test("null-assistant idle does not accumulate noToolCallTurns", async () => { // When messages() returns only a user message (latestAssistant === null), - // the noToolCallTurns counter must be reset, not incremented (rank 43 fix). + // the noToolCallTurns counter must be reset, not incremented. const { hooks } = await createHooks({ messages: async () => ({ data: [{ info: { id: "msg-user-only", role: "user", sessionID: "null-asst-notool" }, parts: [textPart("hi")] }], @@ -5267,7 +5353,7 @@ test("null-assistant idle does not accumulate noToolCallTurns", async () => { test("null-assistant idle does not accumulate noProgressTurns", async () => { // When messages() returns only a user message (latestAssistant === null, // latestOutputTokens === null), the noProgressTurns counter must be reset, - // not incremented (rank 44 fix). + // not incremented. const { hooks } = await createHooks({ messages: async () => ({ data: [{ info: { id: "msg-user-only2", role: "user", sessionID: "null-asst-noprog" }, parts: [textPart("hi")] }], diff --git a/test/lifecycle-model.test.js b/test/lifecycle-model.test.js new file mode 100644 index 0000000..4b49fa0 --- /dev/null +++ b/test/lifecycle-model.test.js @@ -0,0 +1,91 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { GoalPlugin, testInternals } from "../src/goal-plugin.js" + +const { currentGoal, listSessionGoals } = testInternals + +function random(seed) { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 2 ** 32 + } +} + +async function command(hooks, sessionID, argumentsText) { + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID, arguments: argumentsText }, + output, + ) + return output.parts[0]?.text ?? "" +} + +test("generated command sequences preserve the multi-goal lifecycle model", async () => { + for (let seed = 1; seed <= 40; seed += 1) { + const next = random(seed) + const sessionID = `model-${seed}` + const hooks = await GoalPlugin( + { client: { session: {} } }, + { persistState: false, registerTools: false }, + ) + const model = { goals: [], focused: -1, running: false, serial: 0 } + + try { + for (let step = 0; step < 100; step += 1) { + const action = Math.floor(next() * 7) + if (action === 0) { + const objective = `set-${seed}-${model.serial++}` + if (model.focused >= 0) model.goals.splice(model.focused, 1) + model.goals.push(objective) + model.focused = model.goals.length - 1 + model.running = true + await command(hooks, sessionID, objective) + } else if (action === 1 && model.goals.length < 10) { + const objective = `add-${seed}-${model.serial++}` + model.goals.push(objective) + model.focused = model.goals.length - 1 + model.running = true + await command(hooks, sessionID, `add ${objective}`) + } else if (action === 2 && model.goals.length > 0) { + const index = Math.floor(next() * model.goals.length) + if (index !== model.focused) { + model.focused = index + model.running = true + } + await command(hooks, sessionID, `focus ${index + 1}`) + } else if (action === 3 && model.focused >= 0) { + model.running = false + await command(hooks, sessionID, "pause") + } else if (action === 4 && model.focused >= 0) { + model.running = true + await command(hooks, sessionID, "resume") + } else if (action === 5 && model.focused >= 0) { + const objective = `edit-${seed}-${model.serial++}` + model.goals[model.focused] = objective + model.running = true + await command(hooks, sessionID, `edit ${objective}`) + } else if (action === 6) { + model.goals = [] + model.focused = -1 + model.running = false + await command(hooks, sessionID, "clear") + } + + const actualGoals = listSessionGoals(sessionID) + const focused = currentGoal(sessionID) + assert.deepEqual(actualGoals.map(({ condition }) => condition), model.goals, `seed ${seed}, step ${step}`) + assert.equal(new Set(actualGoals.map(({ goalId }) => goalId)).size, actualGoals.length) + assert.equal(focused?.condition, model.focused >= 0 ? model.goals[model.focused] : undefined) + assert.equal(focused ? actualGoals.includes(focused) : false, model.focused >= 0) + if (focused) assert.equal(focused.stopped, !model.running) + for (const goal of actualGoals) { + if (goal !== focused) assert.equal(goal.stopped, true) + } + } + } finally { + await hooks.dispose() + } + } +})