diff --git a/.changeset/goal-command-alias.md b/.changeset/goal-command-alias.md new file mode 100644 index 00000000000..e2192b11de0 --- /dev/null +++ b/.changeset/goal-command-alias.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Expose a custom or plugin command named `goal` as `/goal:command` instead of dropping it. `/goal` still starts a session goal, and the configuration warning now names the alias so the command stays reachable. diff --git a/packages/kilo-docs/pages/automate/extending/plugins.md b/packages/kilo-docs/pages/automate/extending/plugins.md index 34f731753c2..52884f11aa7 100644 --- a/packages/kilo-docs/pages/automate/extending/plugins.md +++ b/packages/kilo-docs/pages/automate/extending/plugins.md @@ -363,7 +363,7 @@ Every hook is optional. Return only the ones you care about. | Hook | Description | |---|---| -| `config` | Receives the fully-resolved config at startup. Read-only — useful for inspection. | +| `config` | Receives the fully-resolved config at startup. Mutate it in place to add or change agents, commands, providers, and other config — this is how a plugin contributes its own slash commands. A command named `goal` is reserved for [session goals](/docs/code-with-ai/agents/goals): Kilo ignores it under that name, warns, and exposes it as `/goal:command` instead. | | `event` | Called for **every** event on the internal bus (see [Events](#events)). | ### Tools diff --git a/packages/kilo-docs/pages/code-with-ai/agents/goals.md b/packages/kilo-docs/pages/code-with-ai/agents/goals.md index 3beb82202d7..4fa522a5597 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/goals.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/goals.md @@ -103,7 +103,7 @@ Goal-composer mode accepts multiline objectives and file or image attachments. I ## Limits and integration -- A custom command or an MCP prompt named `goal` is reserved. Kilo rejects it and reports an error; rename it. +- A custom command named `goal` (from your config or a plugin) is reserved for this feature. Kilo ignores it, reports a configuration warning, and makes it available as `/goal:command` instead of running it as `/goal`. - The goal is stored under the `kilo.goal` session metadata key. - Headless mode supports only status and controls through `kilo run --command goal`, which accepts no argument, `pause`, or `clear`. The agent can still start or resume a goal itself with the `goal` tool. diff --git a/packages/kilo-docs/pages/customize/workflows.md b/packages/kilo-docs/pages/customize/workflows.md index 8b3e4777ad0..cbc22e7d48d 100644 --- a/packages/kilo-docs/pages/customize/workflows.md +++ b/packages/kilo-docs/pages/customize/workflows.md @@ -60,6 +60,10 @@ You are helping submit a pull request... | `variant` | Reasoning effort variant override (for example `low` or `high`), for models that support variants | | `subtask` | When `true`, runs as a sub-agent session | +### Reserved Names + +`goal` is reserved for [session goals](/docs/code-with-ai/agents/goals). A command named `goal` in your config or from a plugin is ignored under that name — Kilo reports a configuration warning and keeps `/goal` for its own command, but the command you defined is still reachable as `/goal:command`, the same way a skill that collides with a command is offered as `/name:skill`. + ### Model and Reasoning Variant Each workflow can run with its own model and reasoning effort variant. In the VS Code extension, open **Settings → Agent Behaviour → Workflows**, expand a workflow, and choose a model and variant. The selection is saved as a command override in your global config, so the workflow's template file stays unchanged. diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index dc342810aac..cc3bb3cc222 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -132,7 +132,9 @@ const layer = Layer.effect( const overrides: Array<{ name: string; command: Override }> = [] for (const [name, command] of Object.entries(cfg.command ?? {})) { if (Reserved.reserved(name)) { - yield* Effect.logWarning(Reserved.notice(name)) + const key = Reserved.rename(cfg.command, name) + if (key) applyOverride(commands, key, command, hints) + yield* Effect.logWarning(Reserved.notice(name, key)) continue } if (!applyOverride(commands, name, command, hints)) overrides.push({ name, command }) // kilocode_change diff --git a/packages/opencode/src/kilocode/command/reserved.ts b/packages/opencode/src/kilocode/command/reserved.ts index c024ca71f85..b1006d9a61b 100644 --- a/packages/opencode/src/kilocode/command/reserved.ts +++ b/packages/opencode/src/kilocode/command/reserved.ts @@ -1,8 +1,8 @@ // kilocode_change - new file // SessionPrompt.command intercepts `/goal` before it looks a command up, so a command -// registered under that name can never run. Skip it instead of failing: building the -// command list used to throw on a clash, and a single clashing name from config or a -// plugin then hid every slash command from the client. +// registered under that name can never run under that name. Rather than dropping it, +// expose it as `name:command` -- the same source-suffix convention skills (`:skill`) and +// MCP prompts (`:mcp`) already use -- so the workflow stays reachable. // // `goal` is the only such name. The other built-in session commands (`compact`, // `summarize`) are resolved through the registry rather than intercepted, so a command @@ -12,12 +12,47 @@ export function reserved(name: string) { return name === "goal" } -export function notice(name: string) { +export function alias(name: string) { + return `${name}:command` +} + +type Entry = { + template?: string + description?: string + agent?: string + model?: string + variant?: string + subtask?: boolean +} + +/** + * The alias key a reserved command should register under, or undefined when there is + * nothing to register. + * + * Undefined when the entry has no `template`: that is a partial override (it only tweaks + * fields on an existing command), and there is no existing `goal:command` target to apply + * it to. + * + * Undefined when an explicit `name:command` entry already defines its own `template`: that + * entry owns the alias, so the auto-registered one must not replace it. Checked against the + * config record rather than the registry so the result does not depend on object key order. + */ +export function rename(commands: Record | undefined, name: string) { + const entry = commands?.[name] + if (entry?.template === undefined) return undefined + if (commands?.[alias(name)]?.template !== undefined) return undefined + return alias(name) +} + +export function notice(name: string, key?: string) { return [ `Ignoring the "${name}" command registered by your config or a plugin:`, `/${name} is reserved for Kilo's own command.`, + key ? `Run yours as /${key} instead.` : "", "Rename it, or turn it off in the plugin that registers it, to stop this warning.", - ].join(" ") + ] + .filter(Boolean) + .join(" ") } /** @@ -31,8 +66,8 @@ export function notice(name: string) { * `plugin.init()` before an instance is handed out, so plugin commands are already in * config by the time any client can ask for warnings. */ -export function warnings(commands: Record | undefined) { +export function warnings(commands: Record | undefined) { return Object.keys(commands ?? {}) .filter(reserved) - .map((name) => ({ path: `command.${name}`, message: notice(name) })) + .map((name) => ({ path: `command.${name}`, message: notice(name, rename(commands, name)) })) } diff --git a/packages/opencode/test/kilocode/command/reserved.test.ts b/packages/opencode/test/kilocode/command/reserved.test.ts index 4a83a78d204..61e8a3c773e 100644 --- a/packages/opencode/test/kilocode/command/reserved.test.ts +++ b/packages/opencode/test/kilocode/command/reserved.test.ts @@ -48,9 +48,56 @@ describe("reserved command names", () => { expect(message).toContain("reserved for Kilo's own command") expect(message).toContain("Rename it") expect(message).toContain("turn it off in the plugin that registers it") + // No alias was registered, so there is nothing to point the user at. + expect(message).not.toContain("instead") }) - it.live("keeps every other command when config claims a reserved name", () => + test("names the alias in the warning when one is registered", () => { + const message = Reserved.notice("goal", "goal:command") + expect(message).toContain("Run yours as /goal:command instead.") + }) + + test("builds the alias as the reserved name plus the command-source suffix", () => { + expect(Reserved.alias("goal")).toBe("goal:command") + }) + + describe("rename", () => { + test("registers the alias for a full template definition", () => { + expect(Reserved.rename({ goal: { template: "Plugin goal: $ARGUMENTS" } }, "goal")).toBe("goal:command") + }) + + test("has nothing to register for a template-less partial override", () => { + expect(Reserved.rename({ goal: { model: "test/model" } }, "goal")).toBeUndefined() + }) + + test("defers to an explicit alias that defines its own template", () => { + expect( + Reserved.rename( + { + goal: { template: "Plugin goal: $ARGUMENTS" }, + "goal:command": { template: "Explicit alias" }, + }, + "goal", + ), + ).toBeUndefined() + }) + + test("still registers the alias when the explicit entry is template-less", () => { + // A template-less `goal:command` is itself a partial override; it is meant to land on + // top of the auto-registered alias via the deferred-override pass, not to block it. + expect( + Reserved.rename( + { + goal: { template: "Plugin goal: $ARGUMENTS" }, + "goal:command": { model: "test/model" }, + }, + "goal", + ), + ).toBe("goal:command") + }) + }) + + it.live("keeps every other command and exposes the clash as goal:command", () => provideTmpdirInstance((dir) => Effect.gen(function* () { yield* Effect.promise(() => @@ -72,14 +119,63 @@ describe("reserved command names", () => { expect(names).toContain("ship") expect(names).toContain("init") expect(names).toContain("review") + expect(names).toContain("goal:command") expect(list.filter((item) => item.name === "goal")).toHaveLength(1) + // Kilo's own /goal is untouched. expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) expect(yield* command.get("ship")).toMatchObject({ source: "command" }) + // The clashing template is reachable under the alias instead of being dropped. + expect(yield* command.get("goal:command")).toMatchObject({ + source: "command", + template: "Plugin goal: $ARGUMENTS", + description: "Plugin goal", + }) }), ), ) - it.live("reports a config-sourced clash as a config warning", () => + it.live("does not alias a template-less partial override", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ command: { goal: { model: "test/model" } } })), + ) + + const command = yield* Command.Service + const list = yield* command.list() + + expect(list.map((item) => item.name)).not.toContain("goal:command") + expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) + + const warnings = yield* Config.Service.use((svc) => svc.warnings()) + const message = warnings.find((item) => item.path === "command.goal")?.message + expect(message).toBeDefined() + expect(message).not.toContain("instead") + }), + ), + ) + + for (const order of ["alias-first", "goal-first"] as const) { + it.live(`lets an explicit goal:command entry win regardless of key order (${order})`, () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const command = { + "goal:command": { template: "Explicit alias" }, + goal: { template: "Plugin goal: $ARGUMENTS" }, + } + const ordered = + order === "alias-first" ? command : { goal: command.goal, "goal:command": command["goal:command"] } + yield* Effect.promise(() => Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ command: ordered }))) + + const svc = yield* Command.Service + expect(yield* svc.get("goal:command")).toMatchObject({ source: "command", template: "Explicit alias" }) + expect(yield* svc.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) + }), + ), + ) + } + + it.live("reports a config-sourced clash as a config warning naming the alias", () => provideTmpdirInstance((dir) => Effect.gen(function* () { yield* Effect.promise(() => @@ -92,9 +188,9 @@ describe("reserved command names", () => { const warnings = yield* Config.Service.use((svc) => svc.warnings()) expect(warnings.some((item) => item.path === "command.goal")).toBe(true) - expect(warnings.find((item) => item.path === "command.goal")?.message).toContain( - '"goal" command registered by your config or a plugin', - ) + const message = warnings.find((item) => item.path === "command.goal")?.message + expect(message).toContain('"goal" command registered by your config or a plugin') + expect(message).toContain("Run yours as /goal:command instead.") }), ), ) @@ -129,16 +225,22 @@ describe("reserved command names", () => { yield* Plugin.Service.use((svc) => svc.list()) const warnings = yield* Config.Service.use((svc) => svc.warnings()) - expect(warnings.find((item) => item.path === "command.goal")?.message).toContain( - '"goal" command registered by your config or a plugin', - ) + const message = warnings.find((item) => item.path === "command.goal")?.message + expect(message).toContain('"goal" command registered by your config or a plugin') + expect(message).toContain("Run yours as /goal:command instead.") // The plugin keeps every command that does not clash, and /goal stays Kilo's. const command = yield* Command.Service const list = yield* command.list() expect(list.map((item) => item.name)).toContain("handoff") + expect(list.map((item) => item.name)).toContain("goal:command") expect(list.filter((item) => item.name === "goal")).toHaveLength(1) expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) + // The plugin's own template is reachable under the alias. + expect(yield* command.get("goal:command")).toMatchObject({ + source: "command", + template: "Plugin goal: $ARGUMENTS", + }) }), ) }) diff --git a/packages/opencode/test/kilocode/server/command-reserved.test.ts b/packages/opencode/test/kilocode/server/command-reserved.test.ts index cdaeaf3bc85..6eec8c6ae14 100644 --- a/packages/opencode/test/kilocode/server/command-reserved.test.ts +++ b/packages/opencode/test/kilocode/server/command-reserved.test.ts @@ -46,10 +46,13 @@ describe("reserved command name over HTTP", () => { expect(names).toContain("ship") expect(names).toContain("init") expect(commands.filter((item) => item.name === "goal")).toHaveLength(1) + // The clashing template is reachable under the alias instead of being dropped. + expect(names).toContain("goal:command") const warnings = await json(await req(tmp.path, "/config/warnings")) const clash = warnings.find((item) => item.path === "command.goal") expect(clash?.message).toContain('"goal" command registered by your config or a plugin') expect(clash?.message).toContain("reserved for Kilo's own command") + expect(clash?.message).toContain("Run yours as /goal:command instead.") }) }) diff --git a/packages/opencode/test/kilocode/session/goal.test.ts b/packages/opencode/test/kilocode/session/goal.test.ts index 7c779b048ae..c3757eb2713 100644 --- a/packages/opencode/test/kilocode/session/goal.test.ts +++ b/packages/opencode/test/kilocode/session/goal.test.ts @@ -248,9 +248,9 @@ it.instance( yield* run.prompt.prompt({ sessionID: run.session.id, parts: [{ type: "text", text: "Start something" }] }) expect(GoalState.read(yield* run.metadata)).toBeUndefined() const parts = (yield* run.sessions.messages({ sessionID: run.session.id })).flatMap((message) => message.parts) - expect( - parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"), - ).toBe(true) + expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe( + true, + ) yield* Effect.sleep("5200 millis") expect(yield* run.llm.hits).toHaveLength(2) }), @@ -273,9 +273,7 @@ it.instance( permissions .list() .pipe( - Effect.map((items) => - items.find((item) => item.sessionID === run.session.id && item.permission === "goal"), - ), + Effect.map((items) => items.find((item) => item.sessionID === run.session.id && item.permission === "goal")), ), "Goal start did not request permission", "10 seconds", @@ -284,9 +282,9 @@ it.instance( yield* run.idle expect(GoalState.read(yield* run.metadata)).toBeUndefined() const parts = (yield* run.sessions.messages({ sessionID: run.session.id })).flatMap((message) => message.parts) - expect( - parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"), - ).toBe(true) + expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe( + true, + ) }), 30_000, ) @@ -304,9 +302,9 @@ it.instance( const metadata = yield* run.sessions.get(child.id).pipe(Effect.map((value) => value.metadata)) expect(GoalState.read(metadata)).toBeUndefined() const parts = (yield* run.sessions.messages({ sessionID: child.id })).flatMap((message) => message.parts) - expect( - parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"), - ).toBe(true) + expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe( + true, + ) }), 30_000, ) @@ -1175,7 +1173,10 @@ it.instance( sessionID: session.id, permission: [{ permission: "bash", pattern: "*", action: "ask" }], }) - yield* llm.tool("bash", { command: `echo goal-permission-${crypto.randomUUID()}`, description: "Check the workspace" }) + yield* llm.tool("bash", { + command: `echo goal-permission-${crypto.randomUUID()}`, + description: "Check the workspace", + }) yield* command(objective) const pending = yield* pollWithTimeout( permission.list().pipe(Effect.map((items) => items.find((item) => item.sessionID === session.id))), @@ -2017,8 +2018,9 @@ for (const override of [ { template: "Custom workflow: $ARGUMENTS", description: "Run a custom workflow" }, { agent: "ask", model: "test/selected-model", variant: "focused" }, ]) { + const aliased = "template" in override it.instance( - `ignores reserved goal ${"template" in override ? "templates" : "execution overrides"} without hiding other commands`, + `ignores reserved goal ${aliased ? "templates" : "execution overrides"} without hiding other commands`, Effect.gen(function* () { const run = yield* setup({ command: { goal: override, check: { template: "Check validation", description: "Check the workspace" } }, @@ -2036,6 +2038,10 @@ for (const override of [ expect(goals.at(0)?.template).toBe("$ARGUMENTS") expect(yield* commands.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" }) + // A template-less override has nothing to alias; a full template is reachable as + // goal:command instead of being dropped. + expect(list.map((item) => item.name).includes("goal:command")).toBe(aliased) + // Dispatch still reaches Kilo's own goal, on the dispatched model, and never // renders the override's template. yield* run.llm.text("Goal complete. All work is done.") @@ -2046,6 +2052,21 @@ for (const override of [ expect(hits).toHaveLength(1) expect(hits.at(0)?.body.model).toBe("test-model") expect(JSON.stringify(hits.at(0)?.body.messages)).not.toContain("Custom workflow") + + if (!aliased) return + + // The override's own template still runs, on its own alias, under the normal + // command path rather than the goal runner. + yield* run.prompt.command({ + sessionID: run.session.id, + agent: "code", + command: "goal:command", + arguments: "ship it", + model: "test/test-model", + }) + const aliasHits = yield* run.llm.hits + expect(aliasHits).toHaveLength(2) + expect(JSON.stringify(aliasHits.at(-1)?.body.messages)).toContain("Custom workflow: ship it") }), ) } @@ -2502,16 +2523,12 @@ it.instance( const done = yield* goalStatus(run, "complete") expect(done.active).toBe(false) yield* pollWithTimeout( - wake - .list({ sessionID: run.session.id }) - .pipe(Effect.map((items) => (items.length === 0 ? true : undefined))), + wake.list({ sessionID: run.session.id }).pipe(Effect.map((items) => (items.length === 0 ? true : undefined))), "goal did not release its wakeups", "5 seconds", ) yield* pollWithTimeout( - wake - .cronList({ sessionID: run.session.id }) - .pipe(Effect.map((items) => (items.length === 0 ? true : undefined))), + wake.cronList({ sessionID: run.session.id }).pipe(Effect.map((items) => (items.length === 0 ? true : undefined))), "goal did not release its cron tasks", "5 seconds", )