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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Make `/goal status` add explicit `State:` and `Completion audit:` lines without changing its existing `Active goal:` header; make `/goal list` report `active`, `paused`, or `blocked` and preserve the reason for stopped focused goals. Completion audit reporting distinguishes the evidence gate, built-in independent verifier, and custom completion auditor.
- Add bounded, transition-only lifecycle notices through OpenCode's structured log and TUI toast, with independent `lifecycleMessages` and `lifecycleMessenger` controls. Delivery is advisory, does not create model turns, and does not announce routine idle/checkpoint activity. Completion/block uses the audit-result message when `auditMessages` is enabled and one lifecycle fallback only when it is disabled.
- Harden lifecycle persistence around the new feedback path: failed completion writes cannot resurrect an older goal over newer session state, blocked ledger events repair a lagging snapshot with their concrete reason, and clear operations disclose when neither snapshot nor ledger recorded the deletion durably.

## 0.6.8 — 2026-08-02

- Serialize fresh-namespace migration-marker publication across concurrent processes so Windows does not reject competing first-start renames with `EPERM`.
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Compatibility: this plugin relies on experimental OpenCode hooks. Re-test agains
- Guarded auto-continuation with turn, duration, token, no-progress, and no-tool-call limits.
- Project-local restart recovery backed by persisted state and a bounded lifecycle ledger.
- Evidence-gated completion with an optional independent, fail-closed verifier.
- Explicit `active`, `paused`, and `blocked` status plus transition-only lifecycle notices.
- Canonical agent tools, collision-safe goal/verifier agents, multiple goals, and ordered goal sequences.

This project is independently implemented for OpenCode. Product names used elsewhere identify their respective owners; no feature-parity or endorsement claim is implied.
Expand Down Expand Up @@ -100,6 +101,8 @@ Check status:
/goal status
```

`/goal status` keeps its existing `Active goal:` heading and adds an explicit `State:` line: `active` while the goal can continue, `blocked` when the assistant recorded a concrete blocker, and `paused` for other retained stops such as user intervention, a safety limit, or an audit rejection. A `Completion audit:` line distinguishes the always-on evidence gate from an optional built-in independent verifier or custom completion auditor.

View lifecycle history and the latest checkpoint:

```
Expand Down Expand Up @@ -156,7 +159,7 @@ A session can hold more than one goal. `/goal <condition>` replaces the focused
/goal focus 1
```

`/goal list` shows numbered live goals (focused and backgrounded) plus achieved goals retained in the per-session archive. `/goal clear` intentionally removes live goals and saved status from these views; its terminal ledger entries remain available for crash-safe recovery decisions. `/goal focus <number>` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart.
`/goal list` shows numbered live goals (focused and backgrounded) plus achieved goals retained in the per-session archive. Each live entry includes its explicit `active`, `paused`, or `blocked` state; a stopped focused goal keeps its bounded stop or blocker reason visible. `/goal clear` intentionally removes live goals and saved status from these views; its terminal ledger entries remain available for crash-safe recovery decisions. `/goal focus <number>` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart.

#### Ordered sequences

Expand Down Expand Up @@ -336,6 +339,8 @@ Additional plugin-level options:
- `ledgerMaxBytes` / `ledgerRetentionFiles` — bound the lifecycle ledger to 2 MiB per generation and three rotated generations by default. Set retention to `0` to discard the active ledger when it reaches the size ceiling.
- `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
- `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
- `lifecycleMessages` — announce applied goal-state transitions (default `true`). Set to `false` to disable lifecycle notices without disabling audit messages or persistence.
- `lifecycleMessenger(sessionID, text)` — route lifecycle notices to a custom sink instead of the default structured-log/TUI-toast path.

## Agent tools

Expand All @@ -352,12 +357,23 @@ These operate on the same per-session multi-goal state as the command path: a to

> Integration note: the tool execute-context shape (`ctx.sessionID`) and Zod argument definitions follow the OpenCode plugin docs. The tool **logic** is unit-tested independently, but live registration should still be confirmed against the exact OpenCode host used in production (see the smoke-test checklist).

## Lifecycle messages

The plugin announces meaningful, applied state transitions such as goal creation, focus changes, pause/resume, recovery, ordered-goal promotion, and clearing. It does not emit a notice for every idle event, checkpoint, or continuation attempt. Messages are bounded and avoid dumping the full objective, evidence, or filesystem paths.

By default, lifecycle notices go to OpenCode's structured log and to a TUI toast when that host capability is available. Provide a `lifecycleMessenger(sessionID, text)` plugin option to route them elsewhere, or set `lifecycleMessages: false` to disable them. Delivery is advisory: notices do not start an assistant turn or make any extra model call, and a log, toast, or custom-messenger failure does not undo the recorded state transition.

Lifecycle notices and audit messages are separate controls. Lifecycle notices describe applied goal state; audit messages describe completion/block validation. When `auditMessages` is `true`, its audit-result message is the sole completion/block announcement. When `auditMessages` is `false` and `lifecycleMessages` is `true`, the lifecycle channel emits one terminal fallback instead. Other transitions follow `lifecycleMessages`; disabling one control does not disable the other.

## Audit messages

When the assistant marks a goal complete or blocked, the plugin announces the audit instead of doing it silently: an audit-start message ("Auditing goal completion…") and an audit-result message ("completion accepted — goal archived" / "paused as blocked — …"). By default these are written to OpenCode's structured log and shown as a TUI toast when that client capability is available. Provide an `auditMessenger(sessionID, text)` plugin option to route them elsewhere, or set `auditMessages: false` to disable them.
When the assistant marks a goal complete or blocked, the plugin announces the audit instead of doing it silently: an audit-start message ("Auditing goal completion…") and an audit-result message ("completion accepted — goal archived" / "paused as blocked — …"). By default these are written to OpenCode's structured log and shown as a TUI toast when that client capability is available. Provide an `auditMessenger(sessionID, text)` plugin option to route them elsewhere, or set `auditMessages: false` to disable them. The audit-result message owns the terminal completion/block announcement while `auditMessages` is enabled, so the lifecycle channel does not duplicate it.

Audit messages are visibility only; enabling them does not turn on the independent completion auditor. The evidence gate always applies. Independent verification is enabled only with `completionAudit: true` or a custom `auditor`.

## Completion auditor (optional)

By default a `[goal:complete]` is accepted on the assistant's word. You can require an independent audit before a goal is archived:
Every `[goal:complete]` claim must first pass the local evidence gate described above. By default, that evidence gate is the only verifier. You can additionally require an independent audit before a goal is archived:

- `completionAudit: true` — the plugin spawns an independent OpenCode child session to verify the completion against the goal and workspace. The auditor replies with `[audit:approved]` or `[audit:rejected]` (with a reason).
- `auditor: async ({ goal, sessionID, latestText }) => ({ approved, reason })` — supply your own auditor function (takes precedence over `completionAudit`).
Expand Down
17 changes: 17 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,23 @@ export interface GoalPluginOptions {
*/
auditorOptions?: CompletionAuditorOptions

/**
* Whether the plugin announces applied goal-state transitions such as
* creation, pause/resume, recovery, promotion, and clearing. Routine
* idle/checkpoint activity is not announced. Completion/block uses the
* audit-result channel when enabled and this lifecycle channel only as its
* disabled fallback.
* @default true
*/
lifecycleMessages?: boolean

/**
* Custom sink for bounded lifecycle notices. Defaults to routing through
* OpenCode's structured log (`client.app.log`) and TUI toast when those host
* APIs are available. Delivery is advisory and does not make model calls.
*/
lifecycleMessenger?: (sessionID: string, text: string) => Promise<void> | void

/**
* Whether the plugin announces completion/blocked audits (an
* audit-start and an audit-result message) instead of running silently.
Expand Down
11 changes: 9 additions & 2 deletions scripts/mutation-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ const mutants = [
{
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 })",
from: 'const durable = await persistFinal(sessionID, "completion", ledgerDurable)\n if (durable === false) {',
to: 'const durable = await persistFinal(sessionID, "completion", ledgerDurable)\n if (false) {',
test: "test/goal-plugin.test.js",
},
{
name: "terminal rollback detects same-session mutation without cross-session coupling",
file: "src/goal-plugin.js",
from: "if ((sessionMutationVersions.get(sessionID) || 0) !== snapshot?.mutationVersion) return false",
to: "if (false) return false",
test: "test/goal-plugin.test.js",
},
{
Expand Down
8 changes: 6 additions & 2 deletions scripts/smoke-command-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,14 @@ assert.match(await runGoalCommand("status"), /No active goal/)
assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/)
const activeStatus = await runGoalCommand("status")
assert.match(activeStatus, /Active goal: ship a smoke test/)
assert.doesNotMatch(activeStatus, /State: Paused/)
assert.match(activeStatus, /State: active/)
assert.match(activeStatus, /Completion audit: evidence gate only \(independent verifier off\)/)
assert.match(await runGoalCommand("clear"), /Goal cleared/)
assert.match(await runGoalCommand("status"), /No active goal/)
assert.equal(promptCalls.length, 0)
assert.equal(logCalls.length, 0)
assert.deepEqual(logCalls.map((entry) => entry.body.extra.kind), ["goal-lifecycle", "goal-lifecycle"])
assert.match(logCalls[0].body.message, /Goal (?:active|started)/i)
assert.match(logCalls[1].body.message, /Goal cleared/i)
assert.ok(logCalls.every((entry) => !entry.body.message.includes("ship a smoke test")))

console.log("opencode-goal-plugin command hook smoke passed")
6 changes: 6 additions & 0 deletions scripts/type-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ const options = {
auditorOptions: { timeoutMs: 5_000, failurePolicy: "reject" },
auditMessages: true,
auditMessenger: (_sessionID, _text) => {},
lifecycleMessages: true,
lifecycleMessenger: (_sessionID, _text) => {},
auditor: async ({ goal, sessionID, latestText }: CompletionAuditContext) => {
const mode: "normal" | "ordered" = goal.mode
return {
Expand All @@ -74,6 +76,10 @@ const options = {
},
} satisfies GoalPluginOptions

// @ts-expect-error lifecycleMessages must be boolean
const invalidLifecycleMessages: GoalPluginOptions = { lifecycleMessages: "yes" }
void invalidLifecycleMessages

const hooks: GoalPluginHooks = await GoalPlugin({ client: {}, directory: "/tmp" }, options)
hooks.config({})
hooks.event({})
Expand Down
10 changes: 9 additions & 1 deletion scripts/verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ await check("/goal set works", async () => {
assert.match(text, /New active goal: verify the installation/)
const statusText = await runGoalCommand("status")
assert.match(statusText, /Active goal: verify the installation/)
assert.doesNotMatch(statusText, /State: Paused/)
assert.match(statusText, /State: active/)
assert.match(statusText, /Completion audit: evidence gate only \(independent verifier off\)/)
})

await check("no model calls were made during verification", () => {
Expand All @@ -156,6 +157,13 @@ await check("no model calls were made during verification", () => {
// Clean up the goal created above so this script has no side effects.
await runGoalCommand("clear")

await check("lifecycle transitions are visible without leaking objective text", () => {
assert.deepEqual(logCalls.map((entry) => entry.body.extra.kind), ["goal-lifecycle", "goal-lifecycle"])
assert.match(logCalls[0].body.message, /Goal (?:active|started)/i)
assert.match(logCalls[1].body.message, /Goal cleared/i)
assert.ok(logCalls.every((entry) => !entry.body.message.includes("verify the installation")))
})

console.log()

const failed = results.filter((r) => !r.ok)
Expand Down
Loading