From aa8ccd1b37413aa23428cc72a1a9bfbbd204fd3d Mon Sep 17 00:00:00 2001 From: Willy Date: Sat, 13 Jun 2026 19:00:07 -0500 Subject: [PATCH 1/2] feat: port /goal edit and compaction survival from prevalentWare fork Adapt two substantive behaviors from the prevalentWare/opencode-goal-plugin reimplementation into the marker-based plugin, preserving naming/branding: - /goal edit : revise the active goal in place, keeping the turn/token/time budget and history (from their update_goal_objective tool). - experimental.session.compacting hook: inject goal objective, status, budget, and latest checkpoint into the compaction context so the goal survives a session compaction (from their compactionContext injection). Adds 8 tests (80 total, all passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 + README.md | 9 +++ src/goal-plugin.js | 72 +++++++++++++++++++++ test/goal-plugin.test.js | 132 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8c87b..f1b81f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- **Add `/goal edit `.** Revise the active goal's objective in place while preserving its turn/token/time budget and lifecycle history. Any pause/blocked state is cleared and `noProgressTurns` resets so the revised goal can continue; a goal already at a hard limit re-pauses on the next idle (use `/goal resume` for a fresh budget window). Ported from prevalentWare/opencode-goal-plugin's `update_goal_objective` tool, adapted to the marker-based command model. +- **Preserve the goal across session compaction.** A new `experimental.session.compacting` hook injects the goal objective, status, budget usage, elapsed time, and latest checkpoint into the compaction context so a compaction no longer drops the goal thread mid-run. Ported from prevalentWare/opencode-goal-plugin's `compactionContext` injection. + ## 0.1.14 — 2026-06-12 - **Count cached context tokens in the budget.** `totalTokensForMessage` now includes `tokens.cache.read` / `cache.write` alongside `input + output + reasoning`. On providers with prompt caching (e.g. Anthropic) most of the conversation context arrives as cache reads with a tiny `input`, so the prior estimate undercounted the context window and the token budget / wrap-up could effectively never trigger. diff --git a/README.md b/README.md index 913fe72..203d906 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,14 @@ Resume a paused or stopped goal: /goal resume ``` +Edit the active goal's objective without losing its budget or history: + +``` +/goal edit fix the failing tests and also update the docs +``` + +`/goal edit ` revises the goal in place: the turn, token, and time budget plus the lifecycle history are preserved, and any pause/blocked state is cleared so the revised goal can continue. A goal that already hit a hard limit will re-pause on the next idle — run `/goal resume` for a fresh budget window. + Pause without clearing the active goal: ``` @@ -89,6 +97,7 @@ Clear the active goal: 1. When you set a goal, the plugin stores it in session memory and injects it into the system prompt so the assistant keeps it in view on every turn. 2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done. 3. The plugin stops auto-continuing when the assistant ends a response with `[goal:complete]` or `[goal:blocked]`, or when a safety limit is reached. +4. If OpenCode compacts the session, the plugin injects the goal objective, budget usage, and latest checkpoint into the compaction context so the goal survives the compaction and the assistant keeps the thread. ## Completion markers diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 248d329..10aaf2b 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -783,6 +783,24 @@ function buildContinueMessage(goal, { budgetWrapup = false } = {}) { return lines.filter(Boolean).join("\n") } +function buildCompactionContext(goal) { + // Preserve the active goal across an OpenCode session compaction. Without + // this, a compaction can drop the goal objective and budget state from the + // working context, so the assistant loses the thread mid-run even though the + // plugin still re-injects via system.transform afterward. + const elapsedSeconds = Math.round((Date.now() - goal.startedAt) / 1000) + return [ + "An OpenCode goal is active for this session. Preserve it across compaction.", + buildGoalBlock(goal), + `Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`, + `Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.`, + goal.lastCheckpoint ? `Latest checkpoint: ${goal.lastCheckpoint.summary}` : null, + "After compaction, continue from the next concrete unfinished step while the goal is active. Verify the result against the goal objective before ending; output [goal:complete] only when fully satisfied, or [goal:blocked] only if user input is required.", + ] + .filter(Boolean) + .join("\n") +} + function extractBlockedReason(text) { const lines = text.trimEnd().split("\n") const markerIndex = lines.findIndex((line) => { @@ -1031,6 +1049,47 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => { return } + if (args === "edit" || args.toLowerCase().startsWith("edit ")) { + const goal = goalStates.get(sessionID) + if (!goal) { + output.parts = [ + makeTextPart("No active goal to edit. Set one with `/goal `."), + ] + return + } + const newObjective = stripWrappingQuotes(args.slice("edit".length).trim()) + if (!newObjective) { + output.parts = [ + makeTextPart("No new objective provided. Use `/goal edit `."), + ] + return + } + + goal.condition = newObjective + // Editing the objective revises the goal in place: keep the turn, + // token, and time budget plus history, but clear soft-stop state so the + // revised goal can continue. A goal that hit a hard limit will re-pause + // on the next idle (use /goal resume for a fresh budget window). + goal.stopped = false + goal.stopReason = "" + goal.blockedReason = "" + goal.budgetWrapupSent = false + goal.noProgressTurns = 0 + goal.lastStatus = "Goal objective updated." + pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`) + await persist() + output.parts = [ + makeTextPart( + [ + `Goal objective updated: ${goal.condition}`, + "", + "Budgets and history are preserved. Run `/goal resume` for a fresh budget window, or `/goal status` to review.", + ].join("\n"), + ), + ] + return + } + const parsed = parseGoalArguments(args, defaultGoalOptions) if (parsed.errors.length > 0) { output.parts = [makeTextPart(formatArgumentErrors(parsed.errors))] @@ -1362,6 +1421,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => { } output.system = systemBlocks }, + + "experimental.session.compacting": async (input, output) => { + if (!input?.sessionID || !output) return + const goal = goalStates.get(input.sessionID) + if (!goal) return + const context = buildCompactionContext(goal) + if (Array.isArray(output.context)) { + output.context.push(context) + } else { + output.context = [context] + } + }, } } @@ -1373,6 +1444,7 @@ export default { export const testInternals = { activeGoal, buildLimitWarning, + buildCompactionContext, buildContinueMessage, buildGoalBlock, budgetWrapupNeeded, diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index a2d60de..14b9171 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -6,6 +6,7 @@ import test from "node:test" import pluginModule, { GoalPlugin, testInternals } from "../src/goal-plugin.js" const { + buildCompactionContext, buildContinueMessage, buildGoalBlock, buildLimitWarning, @@ -1812,3 +1813,134 @@ test("normalizeOptions rejects budgetWrapupRatio at boundary values 0 and 1", () assert.equal(normalizeOptions({ budgetWrapupRatio: "high" }).budgetWrapupRatio, defaults.budgetWrapupRatio) assert.equal(normalizeOptions({ budgetWrapupRatio: 0.5 }).budgetWrapupRatio, 0.5) }) + +test("/goal edit updates the objective in place and preserves budget", async () => { + const { hooks } = await createHooks({ options: { minDelayMs: 1, maxTurns: 5 } }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-edit", arguments: "ship the first thing" }, + { parts: [] }, + ) + + const goal = currentGoal("session-edit") + goal.turnCount = 2 + goal.totalTokens = 1234 + + const editOutput = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-edit", arguments: "edit ship the better thing" }, + editOutput, + ) + assert.match(editOutput.parts[0].text, /Goal objective updated: ship the better thing/) + + const updated = currentGoal("session-edit") + assert.equal(updated.condition, "ship the better thing") + // Budget and history are preserved across an edit. + assert.equal(updated.turnCount, 2) + assert.equal(updated.totalTokens, 1234) + assert.ok(updated.history.some((entry) => entry.type === "edited")) +}) + +test("/goal edit re-activates a paused goal and clears blocked state", async () => { + const { hooks } = await createHooks({ options: { minDelayMs: 1 } }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-edit-2", arguments: "ship it" }, + { parts: [] }, + ) + + const goal = currentGoal("session-edit-2") + goal.stopped = true + goal.stopReason = "blocked" + goal.blockedReason = "needs an API key" + goal.noProgressTurns = 3 + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-edit-2", arguments: "edit ship it differently" }, + { parts: [] }, + ) + + const updated = currentGoal("session-edit-2") + assert.equal(updated.stopped, false) + assert.equal(updated.stopReason, "") + assert.equal(updated.blockedReason, "") + assert.equal(updated.noProgressTurns, 0) + + // The edited objective is injected into the system prompt again. + const systemOutput = { system: [] } + await hooks["experimental.chat.system.transform"]({ sessionID: "session-edit-2" }, systemOutput) + assert.equal(systemOutput.system.length, 1) + assert.match(systemOutput.system[0], /ship it differently/) +}) + +test("/goal edit with no active goal returns help text", async () => { + const { hooks } = await createHooks() + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-none", arguments: "edit something" }, + output, + ) + assert.match(output.parts[0].text, /No active goal to edit/) +}) + +test("/goal edit without a new objective returns help text", async () => { + const { hooks } = await createHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-edit-3", arguments: "ship it" }, + { parts: [] }, + ) + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-edit-3", arguments: "edit" }, + output, + ) + assert.match(output.parts[0].text, /No new objective provided/) +}) + +test("session compaction preserves the active goal objective and budget", async () => { + const { hooks } = await createHooks({ options: { minDelayMs: 1, maxTurns: 7 } }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-compact", arguments: "migrate the database" }, + { parts: [] }, + ) + + const compactOutput = { context: [] } + await hooks["experimental.session.compacting"]({ sessionID: "session-compact" }, compactOutput) + assert.equal(compactOutput.context.length, 1) + assert.match(compactOutput.context[0], /migrate the database/) + assert.match(compactOutput.context[0], /Preserve it across compaction/) + assert.match(compactOutput.context[0], /Auto-continues used: 0\/7/) +}) + +test("session compaction is a no-op when no goal is active", async () => { + const { hooks } = await createHooks() + const compactOutput = { context: [] } + await hooks["experimental.session.compacting"]({ sessionID: "session-empty" }, compactOutput) + assert.equal(compactOutput.context.length, 0) +}) + +test("buildCompactionContext initializes context when output has none", async () => { + const { hooks } = await createHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-compact-2", arguments: "do the thing" }, + { parts: [] }, + ) + const compactOutput = {} + await hooks["experimental.session.compacting"]({ sessionID: "session-compact-2" }, compactOutput) + assert.ok(Array.isArray(compactOutput.context)) + assert.equal(compactOutput.context.length, 1) + assert.match(compactOutput.context[0], /do the thing/) +}) + +test("buildCompactionContext includes the latest checkpoint when present", () => { + const goal = { + condition: "finish the audit", + startedAt: Date.now(), + turnCount: 1, + totalTokens: 500, + stopped: false, + options: { maxTurns: 10, maxTokens: 200000 }, + lastCheckpoint: { summary: "wrote the parser", timestamp: Date.now() }, + } + const context = buildCompactionContext(goal) + assert.match(context, /Latest checkpoint: wrote the parser/) + assert.match(context, /finish the audit/) +}) From de14686b06489f742023b6769b1388ba23c0855e Mon Sep 17 00:00:00 2001 From: Willy Date: Sat, 13 Jun 2026 23:40:49 -0500 Subject: [PATCH 2/2] feat: disable post-compaction auto-continue while a goal is active Add experimental.compaction.autocontinue hook as the defensive pair to the compaction-survival hook: when an active (non-stopped) goal is present, set output.enabled = false so OpenCode's native post-compaction continuation does not race the plugin's own idle-triggered continuation. Paused/stopped goals leave the native behavior untouched. Ported from prevalentWare/opencode-goal-plugin. Adds 3 tests (83 total, all passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + README.md | 2 +- src/goal-plugin.js | 11 +++++++++++ test/goal-plugin.test.js | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b81f7..82285fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - **Add `/goal edit `.** Revise the active goal's objective in place while preserving its turn/token/time budget and lifecycle history. Any pause/blocked state is cleared and `noProgressTurns` resets so the revised goal can continue; a goal already at a hard limit re-pauses on the next idle (use `/goal resume` for a fresh budget window). Ported from prevalentWare/opencode-goal-plugin's `update_goal_objective` tool, adapted to the marker-based command model. - **Preserve the goal across session compaction.** A new `experimental.session.compacting` hook injects the goal objective, status, budget usage, elapsed time, and latest checkpoint into the compaction context so a compaction no longer drops the goal thread mid-run. Ported from prevalentWare/opencode-goal-plugin's `compactionContext` injection. +- **Disable generic post-compaction auto-continue while a goal is active.** A new `experimental.compaction.autocontinue` hook sets `enabled = false` whenever an active (non-stopped) goal is present, so OpenCode's native post-compaction continuation does not race the plugin's own idle-triggered continuation. Paused/stopped goals leave the native behavior untouched. Ported from prevalentWare/opencode-goal-plugin. ## 0.1.14 — 2026-06-12 diff --git a/README.md b/README.md index 203d906..23aeca8 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Clear the active goal: 1. When you set a goal, the plugin stores it in session memory and injects it into the system prompt so the assistant keeps it in view on every turn. 2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done. 3. The plugin stops auto-continuing when the assistant ends a response with `[goal:complete]` or `[goal:blocked]`, or when a safety limit is reached. -4. If OpenCode compacts the session, the plugin injects the goal objective, budget usage, and latest checkpoint into the compaction context so the goal survives the compaction and the assistant keeps the thread. +4. If OpenCode compacts the session, the plugin injects the goal objective, budget usage, and latest checkpoint into the compaction context so the goal survives the compaction and the assistant keeps the thread. While a goal is active, the plugin also disables OpenCode's generic post-compaction auto-continue so it does not race the plugin's own continuation. ## Completion markers diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 10aaf2b..1683a2c 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -1433,6 +1433,17 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => { output.context = [context] } }, + + "experimental.compaction.autocontinue": async (input, output) => { + // When a goal is active the plugin drives its own idle-triggered + // continuation, so disable OpenCode's generic post-compaction + // auto-continue to avoid two continuations racing after a compaction. + // Paused/stopped goals leave the native behavior untouched. + if (!input?.sessionID || !output) return + const goal = goalStates.get(input.sessionID) + if (!goal || goal.stopped) return + output.enabled = false + }, } } diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 14b9171..7caf68f 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -1944,3 +1944,36 @@ test("buildCompactionContext includes the latest checkpoint when present", () => assert.match(context, /Latest checkpoint: wrote the parser/) assert.match(context, /finish the audit/) }) + +test("compaction autocontinue is disabled while a goal is active", async () => { + const { hooks } = await createHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-ac", arguments: "keep going" }, + { parts: [] }, + ) + const output = { enabled: true } + await hooks["experimental.compaction.autocontinue"]({ sessionID: "session-ac" }, output) + assert.equal(output.enabled, false) +}) + +test("compaction autocontinue is left untouched for a paused goal", async () => { + const { hooks } = await createHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-ac-paused", arguments: "keep going" }, + { parts: [] }, + ) + const goal = currentGoal("session-ac-paused") + goal.stopped = true + goal.stopReason = "paused" + + const output = { enabled: true } + await hooks["experimental.compaction.autocontinue"]({ sessionID: "session-ac-paused" }, output) + assert.equal(output.enabled, true) +}) + +test("compaction autocontinue is a no-op when no goal is active", async () => { + const { hooks } = await createHooks() + const output = { enabled: true } + await hooks["experimental.compaction.autocontinue"]({ sessionID: "session-ac-none" }, output) + assert.equal(output.enabled, true) +})