diff --git a/README.md b/README.md index fcf1fbe..2a88822 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Goal X asks the assistant to refine that topic into a proposed goal. No durable /goal-confirm ``` +By default, the `/goal` draft command runs through OpenCode's `plan` agent and `/goal-confirm` switches execution to `build`. + If you already know the exact objective and want to start immediately, use `/goal-set` instead: ```text @@ -49,6 +51,7 @@ The TUI plugin target, `opencode-goal-x/tui`, is optional. Load it through OpenC - Keeps a persistent goal pool in `.opencode/goals/` with active markdown mirrors, archived markdown files, strict `state.json`, pending drafts, captured execution context, audit progress, and append-only `ledger.jsonl`. - Makes `/goal ` safe by default: it starts a draft/planning flow and never creates or executes a goal until explicit `/goal-confirm`. +- Registers draft commands with the `plan` agent and start/resume commands with the `build` agent by default, with plugin options for custom agent names. - Provides `/goal-set ` as the explicit immediate-start shortcut. - Captures active agent/model/provider/variant context and reuses it for plugin-driven continuations, compaction followups, and audits unless explicit auditor config overrides it. Non-default variants such as `xhigh` are preserved by default. - Runs guarded auto-continuation from `session.idle`, with budget limits for turns, runtime, tracked tokens, prompt failures, no-tool loops, low-progress loops, and stale focus. @@ -181,6 +184,8 @@ Configure through OpenCode's plugin tuple form: "maxRuntimeMs": 28800000, "maxTokens": 2000000, "minDelayMs": 1000, + "planningAgent": "plan", + "executionAgent": "build", "noProgressTurnsBeforePause": 4, "noToolCallTurnsBeforePause": 3, "noProgressTokenThreshold": 40, @@ -205,6 +210,8 @@ Useful options: - `commandName`: command prefix, default `goal`. - `stateDir`: project-relative state directory, default `.opencode/goals`; absolute paths, traversal, and NUL bytes are rejected. +- `planningAgent`: agent assigned to `/goal`, default `plan`. +- `executionAgent`: agent assigned to `/goal-set`, `/goal-confirm`, and `/goal-resume`, default `build`. - `maxTurns`, `maxRuntimeMs`, `maxTokens`, `minDelayMs`: autoContinue budgets. - `noProgressTurnsBeforePause`, `noToolCallTurnsBeforePause`, `noProgressTokenThreshold`, `maxPromptFailures`: conservative loop guards. - `requireAudit` / `completionAudit`: fail-closed audit gate, default `true`. @@ -282,7 +289,7 @@ Manual OpenCode smoke tests before release: 1. Start OpenCode with this plugin loaded locally and restart after config changes. 2. Select a non-default model variant such as `xhigh`. 3. Run `/goal draft a small verified change`; verify no active goal appears before confirmation. -4. Confirm the draft with `/goal-confirm`; verify the same agent/model/variant remains selected. +4. Confirm the draft with `/goal-confirm`; verify execution switches to the configured execution agent while preserving model/variant context. 5. Run `/goal-set make a trivial documented test fixture --max-turns 2`; verify immediate persistence and autoContinue. 6. Force/simulate compaction; verify Goal X context survives and generic compaction auto-continue does not race the goal loop. 7. Trigger `complete_goal` with weak evidence; verify the audit rejects and pauses. diff --git a/src/defaults.ts b/src/defaults.ts index cf9eb4e..60989ac 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -31,6 +31,8 @@ export const DEFAULT_BUDGET: GoalBudget = { export const DEFAULT_OPTIONS: GoalRuntimeOptions = { commandName: "goal", stateDir: DEFAULT_STATE_DIR, + planningAgent: "plan", + executionAgent: "build", ...DEFAULT_BUDGET, requireAudit: true, auditTimeoutMs: DEFAULT_AUDIT_TIMEOUT_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND, diff --git a/src/runtime.ts b/src/runtime.ts index 6f54223..2f6df97 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -205,14 +205,17 @@ export class GoalRuntime { const commandName = this.options.commandName; this.registerCommand(config, commandName, { description: "Draft a durable opencode-goal-x objective for explicit confirmation, or manage existing goals.", + agent: this.options.planningAgent, template: "$ARGUMENTS", }); this.registerCommand(config, `${commandName}-set`, { description: "Start a durable opencode-goal-x objective immediately.", + agent: this.options.executionAgent, template: "$ARGUMENTS", }); this.registerCommand(config, `${commandName}-confirm`, { description: "Confirm the latest drafted opencode-goal-x objective and start it.", + agent: this.options.executionAgent, template: "$ARGUMENTS", }); this.registerCommand(config, `${commandName}-reject`, { @@ -223,7 +226,7 @@ export class GoalRuntime { this.registerCommand(config, `${commandName}-list`, { description: "List open goals.", template: "$ARGUMENTS" }); this.registerCommand(config, `${commandName}-focus`, { description: "Focus an open goal by number or id.", template: "$ARGUMENTS" }); this.registerCommand(config, `${commandName}-pause`, { description: "Pause the focused goal.", template: "$ARGUMENTS" }); - this.registerCommand(config, `${commandName}-resume`, { description: "Resume the focused paused goal.", template: "$ARGUMENTS" }); + this.registerCommand(config, `${commandName}-resume`, { description: "Resume the focused paused goal.", agent: this.options.executionAgent, template: "$ARGUMENTS" }); this.registerCommand(config, `${commandName}-tweak`, { description: "Revise the focused goal objective.", template: "$ARGUMENTS" }); this.registerCommand(config, `${commandName}-abort`, { description: "Abort and archive the focused goal.", template: "$ARGUMENTS" }); this.registerCommand(config, `${commandName}-clear`, { description: "Clear and archive the focused goal.", template: "$ARGUMENTS" }); diff --git a/src/schemas.ts b/src/schemas.ts index 664012d..f8642d6 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -155,6 +155,8 @@ export const GoalStoreSnapshotSchema: z.ZodType = z.object({ const PluginOptionsSchema = z.object({ commandName: OptionalNonEmptyStringSchema, stateDir: OptionalNonEmptyStringSchema, + planningAgent: OptionalNonEmptyStringSchema, + executionAgent: OptionalNonEmptyStringSchema, maxTurns: z.number().int().positive().optional(), maxRuntimeMs: z.number().int().positive().optional(), maxMinutes: z.number().positive().optional(), @@ -190,6 +192,8 @@ export function normalizeOptions(rawOptions: unknown): GoalRuntimeOptions { return { commandName: normalizeCommandName(options.commandName ?? DEFAULT_OPTIONS.commandName), stateDir: options.stateDir ?? DEFAULT_OPTIONS.stateDir, + planningAgent: options.planningAgent ?? DEFAULT_OPTIONS.planningAgent, + executionAgent: options.executionAgent ?? DEFAULT_OPTIONS.executionAgent, maxTurns: options.maxTurns ?? DEFAULT_OPTIONS.maxTurns, maxRuntimeMs, maxTokens: options.maxTokens ?? DEFAULT_OPTIONS.maxTokens, diff --git a/src/types.ts b/src/types.ts index 956aa21..a57b940 100644 --- a/src/types.ts +++ b/src/types.ts @@ -173,6 +173,8 @@ export interface GoalPaths { export interface GoalRuntimeOptions extends GoalBudget { commandName: string; stateDir: string; + planningAgent: string; + executionAgent: string; requireAudit: boolean; auditTimeoutMs: number; auditorModel?: string; diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 83e2c74..0edd7e7 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -53,7 +53,11 @@ describe("GoalRuntime integration-style behavior", () => { await configHook(config); expect(config.command?.goal).toBeDefined(); + expect(config.command?.goal?.agent).toBe("plan"); + expect(config.command?.["goal-set"]?.agent).toBe("build"); expect(config.command?.["goal-confirm"]).toBeDefined(); + expect(config.command?.["goal-confirm"]?.agent).toBe("build"); + expect(config.command?.["goal-resume"]?.agent).toBe("build"); expect(config.command?.goals).toBeUndefined(); expect(config.command?.["goals-confirm"]).toBeUndefined(); @@ -80,6 +84,20 @@ describe("GoalRuntime integration-style behavior", () => { await setup.runtime.disposeForTest(); }); + test("custom planning and execution agents are registered on goal commands", async () => { + const setup = createRuntimeSetup({ requireAudit: false, planningAgent: "goal-planner", executionAgent: "goal-builder" }); + const configHook = requireConfigHook(setup.hooks); + const config: Config = {}; + + await configHook(config); + + expect(config.command?.goal?.agent).toBe("goal-planner"); + expect(config.command?.["goal-set"]?.agent).toBe("goal-builder"); + expect(config.command?.["goal-confirm"]?.agent).toBe("goal-builder"); + expect(config.command?.["goal-resume"]?.agent).toBe("goal-builder"); + await setup.runtime.disposeForTest(); + }); + test("propose_goal_draft creates a finalized draft that /goal-confirm starts", async () => { const setup = createRuntimeSetup({ requireAudit: false }); const proposeDraft = requireTool(setup.hooks, "propose_goal_draft"); diff --git a/test/schemas.test.ts b/test/schemas.test.ts index ca1cb61..10f36d7 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -7,4 +7,11 @@ describe("plugin option normalization", () => { expect(normalizeOptions({ commandName: "/goals" }).commandName).toBe("goals"); expect(normalizeOptions({ commandName: "/" }).commandName).toBe(DEFAULT_OPTIONS.commandName); }); + + test("normalizes command agent routing options", () => { + expect(normalizeOptions({}).planningAgent).toBe(DEFAULT_OPTIONS.planningAgent); + expect(normalizeOptions({}).executionAgent).toBe(DEFAULT_OPTIONS.executionAgent); + expect(normalizeOptions({ planningAgent: "goal-planner", executionAgent: "goal-builder" }).planningAgent).toBe("goal-planner"); + expect(normalizeOptions({ planningAgent: "goal-planner", executionAgent: "goal-builder" }).executionAgent).toBe("goal-builder"); + }); });