From 2d24bebf89fc2b71bc4fb49f626a4601623e46dc Mon Sep 17 00:00:00 2001 From: willytop8 Date: Sat, 29 Aug 2026 11:50:34 -0500 Subject: [PATCH] feat: hold new goals created under a planning-only agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle guard pauses auto-continue when the active agent is Plan, but it only fires on the *next* idle. Setting a goal while in Plan mode therefore created a live goal, and the routed command text still told the model "Start working toward this goal now." — as a work turn. Command text reaches the model as a normal turn on current OpenCode builds, so that line was an escape from the very guard the idle check provides. Hold such a goal instead: record it with the same `plan agent active` stop reason the idle path uses, pause its clock so the budget survives the mode switch, and announce it through a read-only control turn that explicitly tells the model not to begin work. Also generalize the hardcoded Plan check into `restrictedAgents` (default `["plan"]`) with an `allowGoalExecutionFromPlan` opt-out, so a deployment can restrict other planning-only agents or release the restriction. `isPlanAgent` is kept as the built-in case and the existing `plan agent active` stop reason is preserved, so persisted state and existing consumers are unaffected. Pinned by a new mutation-contract entry: hardcoding the opt-out to true fails the suite. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 + README.md | 26 +++++++ index.d.ts | 16 ++++ scripts/mutation-contract.mjs | 7 ++ src/goal-plugin.js | 127 ++++++++++++++++++++++++++---- test/goal-plugin.test.js | 140 ++++++++++++++++++++++++++++++++++ 6 files changed, 303 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c20b146..b2b7d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Hold new goals created while a planning-only agent is active instead of starting them. Previously the idle guard caught a Plan-agent session only on the *next* idle, so `/goal ` in Plan mode created a live goal and the routed command text still told the model to start working. Such a goal is now recorded with stop reason `plan agent active`, keeps its budget, and is announced through a read-only control turn that explicitly tells the model not to begin work. +- Add `restrictedAgents` (default `["plan"]`) and `allowGoalExecutionFromPlan` (default `false`) so the planning-only restriction can name other agents or be released entirely. The default-on behavior is pinned by the mutation contract. + ## 0.8.2 — 2026-08-21 - Harden the post-compaction goal continuation guard introduced in diff --git a/README.md b/README.md index 4c19fb3..c9b2171 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,32 @@ await GoalPlugin( `timeoutMs` caps how long the built-in child-session auditor waits for a verdict. `failurePolicy` defaults to `reject`: an unavailable API, missing child-session ID, provider error, or timeout rejects the audit and pauses the goal for review. Set it to `approve` only as an explicit compatibility escape hatch; an actual negative or malformed verifier verdict still rejects. `auditorOptions` is ignored when a custom `auditor` function is supplied. +## Plan-mode safety + +A planning-only agent is never driven into execution by the goal loop. OpenCode's built-in `plan` agent is restricted by default: + +- A goal set while `plan` is active is **recorded but held**, with stop reason `plan agent active`. The objective and its budget survive, so nothing is lost — the goal simply does not start. +- The routed confirmation text for a held goal **omits the "start working" instruction** and is sent as a read-only control turn. This matters because command text reaches the model as a normal turn on current OpenCode builds (see [Limitations](#limitations)). +- Auto-continue stays suppressed on **every idle** while a restricted agent is active, so switching into `plan` mid-goal pauses the loop. +- Continuations retain the agent that started the goal, so the loop cannot drift into a different agent. + +Run `/goal resume` after switching back to an executing agent to start the work. + +| Option | Default | Controls | +|---|---|---| +| `restrictedAgents` | `["plan"]` | Agent names treated as planning-only (case-insensitive). Pass `[]` to release the restriction. | +| `allowGoalExecutionFromPlan` | `false` | Set `true` to allow goal creation and auto-continue while a restricted agent is active. | + +```json +{ + "plugin": [ + ["opencode-goal-plugin", { "restrictedAgents": ["plan", "review"] }] + ] +} +``` + +The restriction being on by default is pinned by the mutation contract: hardcoding `allowGoalExecutionFromPlan` to `true` fails the suite. + ## Prompt safety The goal text is wrapped in `` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies. diff --git a/index.d.ts b/index.d.ts index 6d0141f..c214fd5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -311,6 +311,22 @@ export interface GoalPluginOptions { /** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */ registerAgents?: boolean + /** + * Agent names treated as planning-only. A goal created while one of these + * agents is active is recorded but held paused instead of starting, and + * auto-continue stays suppressed while one is active. Matching is + * case-insensitive. Pass `[]` to release the restriction entirely. + * @default ["plan"] + */ + restrictedAgents?: string[] + + /** + * Opt out of the planning-only restriction, allowing goals to be created and + * auto-continued while a {@link restrictedAgents} agent is active. + * @default false + */ + allowGoalExecutionFromPlan?: boolean + /** Name of the native primary goal agent. @default "goal" */ goalAgentName?: string diff --git a/scripts/mutation-contract.mjs b/scripts/mutation-contract.mjs index 27218ba..9fce464 100644 --- a/scripts/mutation-contract.mjs +++ b/scripts/mutation-contract.mjs @@ -85,6 +85,13 @@ const mutants = [ to: (match) => match.replace(/\]\)$/, ', "prompt"])'), test: "test/opencode-session-api.test.js", }, + { + name: "planning-only agents hold new goals unless explicitly opted out", + file: "src/goal-plugin.js", + from: "const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true", + to: "const allowGoalExecutionFromPlan = true", + test: "test/goal-plugin.test.js", + }, { name: "completion evidence must be adjacent", file: "src/goal-plugin.js", diff --git a/src/goal-plugin.js b/src/goal-plugin.js index a8f9b5b..d4658de 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -396,8 +396,37 @@ function continuationContextInput(goal) { return context ? { ...context } : {} } +// Planning-only agents must never be driven into execution by the goal loop. +// `plan` is OpenCode's built-in read-only agent; `restrictedAgents` lets a +// deployment name others (for example a review-only agent). +const DEFAULT_RESTRICTED_AGENTS = ["plan"] + +function normalizeRestrictedAgents(value) { + // Anything that is not an array (including undefined) keeps the safe default; + // an explicit empty array is a deliberate opt-out. + if (!Array.isArray(value)) return [...DEFAULT_RESTRICTED_AGENTS] + const names = value + .map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : "")) + .filter(Boolean) + return [...new Set(names)] +} + +function isRestrictedAgent(agent, restrictedAgents = DEFAULT_RESTRICTED_AGENTS) { + if (typeof agent !== "string") return false + const name = agent.trim().toLowerCase() + if (!name) return false + return restrictedAgents.includes(name) +} + function isPlanAgent(agent) { - return typeof agent === "string" && agent.trim().toLowerCase() === "plan" + return isRestrictedAgent(agent, DEFAULT_RESTRICTED_AGENTS) +} + +// Stop reason for a goal held because a planning-only agent is active. The +// built-in `plan` case keeps its established wording so persisted state and +// existing consumers stay stable. +function restrictedAgentStopReason(agent) { + return isPlanAgent(agent) ? "plan agent active" : `${String(agent).trim().toLowerCase()} agent active` } function terminalEvent(event) { @@ -3956,6 +3985,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) cwd: pluginOptions.cwd || directory, }) const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions) + const restrictedAgents = normalizeRestrictedAgents(pluginOptions.restrictedAgents) + // Opt-out for deployments that deliberately drive execution from a planning + // agent. Defaults to false: unattended work must not escape Plan mode. + const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true + + // The restricted agent currently driving this session, or "" when execution + // is permitted. Reads the execution context the host reports through + // `chat.message`, `chat.params`, and `session.updated`. + const restrictedAgentFor = (sessionID) => { + if (allowGoalExecutionFromPlan) return "" + const agent = currentRuntime().sessionExecutionContexts.get(sessionID)?.agent + return isRestrictedAgent(agent, restrictedAgents) ? String(agent).trim() : "" + } + + // Record a newly created goal as held rather than active. Mirrors the idle + // guard's stop reason so `/goal status` reads the same either way. + const holdGoalForRestrictedAgent = (goal, agent) => { + const label = isPlanAgent(agent) ? "Plan" : agent + goal.stopped = true + goal.stopReason = restrictedAgentStopReason(agent) + goal.lastStatus = + `Goal recorded but held: the ${label} agent is planning-only. ` + + `Switch to an executing agent, then run /${commandName} resume to start work.` + pauseGoalClock(goal) + pushHistory(goal, "paused", `Created while the ${label} agent was active; held until an executing agent resumes it.`) + return label + } // Each session owns an independent snapshot, ledger, write chain, and // lifetime lease. A project can therefore host any number of unrelated goal @@ -4481,12 +4537,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null - const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID) - if (isPlanAgent(currentContext?.agent)) { + const activeRestrictedAgent = restrictedAgentFor(sessionID) + if (activeRestrictedAgent) { + const label = isPlanAgent(activeRestrictedAgent) ? "Plan" : activeRestrictedAgent await pauseActiveGoal(sessionID, { - stopReason: "plan agent active", - status: "Auto-continue paused because the active agent switched to Plan.", - history: "Paused before auto-continue because the active session agent switched to Plan.", + stopReason: restrictedAgentStopReason(activeRestrictedAgent), + status: `Auto-continue paused because the active agent switched to ${label}.`, + history: `Paused before auto-continue because the active session agent switched to ${label}.`, }) return null } @@ -5240,6 +5297,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) `Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} context tokens.`, ) + // A goal set while a planning-only agent is active is recorded but held, + // so the objective and its budget survive the mode switch. Without this + // the goal is created live and the routed command text tells the model to + // start working; the idle guard only catches it on the *next* idle. + const creationRestrictedAgent = restrictedAgentFor(sessionID) + if (creationRestrictedAgent) { + holdGoalForRestrictedAgent(goal, creationRestrictedAgent) + } + // Replace the focused goal (cleanupGoal discards it); backgrounded goals // for this session are preserved. Use `/goal add` to keep the current // goal and add another. Clear any ordered-sequence flag so the new @@ -5251,11 +5317,24 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) registerSessionGoal(goal) focusGoal(sessionID, goal) await persist(sessionID) - announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", { - goal, - transition: replacedGoal ? "replaced-active" : "active", - expectedState: "active", - }) + const heldLabel = creationRestrictedAgent + ? isPlanAgent(creationRestrictedAgent) + ? "Plan" + : creationRestrictedAgent + : "" + announceLifecycle( + sessionID, + heldLabel + ? `Goal recorded but held while ${heldLabel} is active.` + : replacedGoal + ? "Goal replaced and active." + : "Goal active.", + { + goal, + transition: heldLabel ? "paused" : replacedGoal ? "replaced-active" : "active", + expectedState: heldLabel ? "paused" : "active", + }, + ) replaceCommandOutputText( output, [ @@ -5266,14 +5345,25 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) "", ] : []), - `New active goal: ${goal.condition}`, + heldLabel ? `Goal recorded but held: ${goal.condition}` : `New active goal: ${goal.condition}`, goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null, goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null, goal.mode !== "normal" ? `Mode: ${goal.mode}` : null, "", - "Start working toward this goal now.", - "When the goal is fully satisfied, summarize your evidence on a line starting with `[goal:evidence]`, then end your response with `[goal:complete]`. A `[goal:complete]` without a `[goal:evidence]` line is rejected and not recorded.", - "If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.", + // A held goal must not be told to start working. Command text reaches + // the model as a normal turn on current OpenCode builds, so this line + // would be the escape the plan guard exists to prevent. + ...(heldLabel + ? [ + `The ${heldLabel} agent is planning-only, so this goal is not running.`, + "Do not begin work on it now. Continue planning only.", + `Switch to an executing agent, then run \`/${commandName} resume\` to start work.`, + ] + : [ + "Start working toward this goal now.", + "When the goal is fully satisfied, summarize your evidence on a line starting with `[goal:evidence]`, then end your response with `[goal:complete]`. A `[goal:complete]` without a `[goal:evidence]` line is rejected and not recorded.", + "If you are truly blocked and need the user, state the concrete blocker on the line immediately before `[goal:blocked]`.", + ]), `Use \`/${commandName} history\` to inspect recent lifecycle events and checkpoints.`, "", `Limits: ${goal.options.maxTurns} auto-continues, ${Math.round( @@ -5282,7 +5372,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) ] .filter((line) => line !== null) .join("\n"), - { preserveFiles: true, startsWork: true }, + // A held goal is a control turn, not a work turn: `startsWork: false` + // routes it through the read-only command framing. + { preserveFiles: true, startsWork: !heldLabel }, ) }, @@ -6607,6 +6699,9 @@ export const testInternals = { isIdleEvent, isPluginCommandMessage, isPluginContinuationMessage, + isPlanAgent, + isRestrictedAgent, + normalizeRestrictedAgents, isPluginGeneratedMessage, legacyStateFilePaths, messageHasToolCall, diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index c5c43bd..354efdf 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -36,6 +36,9 @@ const { isIdleEvent, isPluginCommandMessage, isPluginContinuationMessage, + isPlanAgent, + isRestrictedAgent, + normalizeRestrictedAgents, isPluginGeneratedMessage, ledgerPathFor, legacyStateFilePaths, @@ -9723,3 +9726,140 @@ test("null-assistant idle does not accumulate noProgressTurns", async () => { assert.equal(currentGoal("null-asst-noprog").noProgressTurns, 0, "noProgressTurns must reset on null-assistant idle") assert.equal(currentGoal("null-asst-noprog").stopped, false, "must not be stopped") }) + +// --------------------------------------------------------------------------- +// Planning-only agent hardening +// --------------------------------------------------------------------------- + +function planContextEvent(sessionID = "session-1", agent = "Plan") { + return { + event: { + type: "session.updated", + properties: { + sessionID, + info: { sessionID, agent, model: { providerID: "openai", id: "gpt-5" } }, + }, + }, + } +} + +test("normalizeRestrictedAgents defaults, normalizes, and honors explicit opt-out", () => { + assert.deepEqual(normalizeRestrictedAgents(undefined), ["plan"]) + assert.deepEqual(normalizeRestrictedAgents(null), ["plan"]) + // A non-array is not trusted; fall back to the safe default. + assert.deepEqual(normalizeRestrictedAgents("plan"), ["plan"]) + // An explicit empty array is a deliberate opt-out. + assert.deepEqual(normalizeRestrictedAgents([]), []) + assert.deepEqual(normalizeRestrictedAgents([" Plan ", "REVIEW", "plan", "", 7]), ["plan", "review"]) +}) + +test("isRestrictedAgent matches case-insensitively; isPlanAgent keeps built-in behavior", () => { + assert.equal(isRestrictedAgent("Plan", ["plan"]), true) + assert.equal(isRestrictedAgent("build", ["plan"]), false) + assert.equal(isRestrictedAgent("review", ["plan", "review"]), true) + assert.equal(isRestrictedAgent("", ["plan"]), false) + assert.equal(isRestrictedAgent(undefined, ["plan"]), false) + assert.equal(isPlanAgent("Plan"), true) + assert.equal(isPlanAgent("review"), false) +}) + +test("a goal set while Plan is active is recorded but held, and is not told to start work", async () => { + const { calls, hooks } = await createHooks({ options: { minDelayMs: 1 } }) + await hooks.event(planContextEvent()) + + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "refactor everything" }, + output, + ) + + const goal = currentGoal("session-1") + assert.equal(goal.condition, "refactor everything", "the goal must still be recorded") + assert.equal(goal.stopped, true, "a goal created under Plan must not be live") + assert.equal(goal.stopReason, "plan agent active") + + const text = output.parts.map((part) => part.text).join("\n") + assert.ok(!text.includes("Start working toward this goal now."), "must not instruct the model to start") + assert.match(text, /planning-only/) + assert.match(text, /Do not begin work on it now/) + + // And the following idle must not continue it. + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 0, "a held goal must never auto-continue") +}) + +test("a goal set under a normal agent still starts work as before", async () => { + const { hooks } = await createHooks({ options: { minDelayMs: 1 } }) + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + output, + ) + + const goal = currentGoal("session-1") + assert.equal(goal.stopped, false) + const text = output.parts.map((part) => part.text).join("\n") + assert.match(text, /Start working toward this goal now\./) + assert.ok(!text.includes("planning-only"), "no plan-hold language on a normal goal") +}) + +test("allowGoalExecutionFromPlan opts out of the planning-only restriction", async () => { + const { calls, hooks } = await createHooks({ + options: { minDelayMs: 1, allowGoalExecutionFromPlan: true }, + }) + await hooks.event(planContextEvent()) + + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + output, + ) + const goal = currentGoal("session-1") + assert.equal(goal.stopped, false, "the opt-out must let a Plan-mode goal run") + assert.match(output.parts.map((part) => part.text).join("\n"), /Start working toward this goal now\./) + + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 1, "auto-continue must fire when the guard is opted out") +}) + +test("restrictedAgents can name agents other than plan", async () => { + const { calls, hooks } = await createHooks({ + options: { minDelayMs: 1, restrictedAgents: ["review"] }, + }) + await hooks.event(planContextEvent("session-1", "review")) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + const goal = currentGoal("session-1") + assert.equal(goal.stopped, true, "a configured restricted agent must hold the goal") + assert.equal(goal.stopReason, "review agent active") + + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 0) +}) + +test("restrictedAgents: [] releases the built-in Plan restriction", async () => { + const { calls, hooks } = await createHooks({ + options: { minDelayMs: 1, restrictedAgents: [] }, + }) + await hooks.event(planContextEvent()) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + assert.equal(currentGoal("session-1").stopped, false) + + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 1, "an empty restricted list is a deliberate opt-out") +})