From 1beaf86a68809427770053576763e9f2f1b0be0e Mon Sep 17 00:00:00 2001 From: dbpolito Date: Mon, 30 Mar 2026 18:58:19 -0300 Subject: [PATCH] feat: add session command dispatch support - add the merge command and session_command tool across core and OpenCode surfaces - switch navigator-driven workflows from dispatch tags to literal session_command tags - sync docs, config, schema, generated output, and tests for the new command flow --- AGENTS.md | 20 +- README.md | 4 +- kompass.jsonc | 2 + kompass.schema.json | 9 + packages/core/agents/navigator.md | 52 ++--- packages/core/commands/index.ts | 5 + packages/core/commands/merge.md | 101 ++++++++++ packages/core/commands/ship.md | 22 +-- packages/core/commands/ticket/dev.md | 26 +-- packages/core/commands/todo.md | 20 +- packages/core/index.ts | 4 +- packages/core/kompass.jsonc | 2 + packages/core/lib/config.ts | 12 +- packages/core/test/dispatch.test.ts | 61 ++++++ packages/core/tools/dispatch.ts | 119 ++++++++++++ packages/core/tools/index.ts | 16 +- .../opencode/.opencode/agents/navigator.md | 56 ++---- packages/opencode/.opencode/commands/merge.md | 106 +++++++++++ packages/opencode/.opencode/commands/ship.md | 22 +-- .../opencode/.opencode/commands/ticket/dev.md | 26 +-- packages/opencode/.opencode/commands/todo.md | 20 +- packages/opencode/.opencode/kompass.jsonc | 6 + packages/opencode/README.md | 2 +- packages/opencode/index.ts | 179 ++++++++---------- packages/opencode/kompass.jsonc | 2 + packages/opencode/test/agents-config.test.ts | 9 +- .../opencode/test/commands-config.test.ts | 42 ++-- packages/opencode/test/task-hook.test.ts | 42 +--- .../opencode/test/tool-registration.test.ts | 131 ++++++++++++- 29 files changed, 799 insertions(+), 319 deletions(-) create mode 100644 packages/core/commands/merge.md create mode 100644 packages/core/test/dispatch.test.ts create mode 100644 packages/core/tools/dispatch.ts create mode 100644 packages/opencode/.opencode/commands/merge.md diff --git a/AGENTS.md b/AGENTS.md index e11c909..c31363a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,9 +63,9 @@ packages/opencode/.opencode/ # Generated OpenCode output for review - For navigator-style commands, separate context loading, blocker checks, delegated execution, and final reporting into distinct workflow subsections so the control flow is easy to follow - Prefer explicit subsection names like `### Load ... Context`, `### Check Blockers`, `### Delegate ...`, and `### Mark Complete And Loop` when the command coordinates multiple phases or subagents - Treat loader tools and provided attachments as the source of truth for orchestration inputs; avoid extra exploratory commands when an existing tool result already answers the question -- Before delegating to a subagent, say what result should be stored and whether the workflow must stop, pause, or continue based on that result -- Use literal `` tags when the workflow must forward exact text as the next user message to a subagent session; `agent` is required, the block body is the exact rendered message to send, and slash commands belong on the first line of the body when needed -- Do not use `` blocks in command docs; author navigator delegation with `` blocks only +- Before dispatching a same-session command step, say what result should be stored and whether the workflow must stop, pause, or continue based on that result +- Use literal `` tags when the workflow must queue exact text through `session_command`; `agent` is required, the block body is the exact rendered message to send, and slash commands belong on the first line of the body when needed +- Do not use `` blocks in command docs; author navigator delegation with `` blocks only - When a command can pause for approval or loop over repeated work, describe the resume condition and the exact cases that must STOP without mutating state - Use `## Additional Context` for instructions about how optional guidance, related tickets, focus areas, or other stored context should influence analysis and response formatting - Use `### Output` as the final workflow step to define the exact user-facing response shape, including placeholders for generated values @@ -105,25 +105,25 @@ $ARGUMENTS ### Delegate Planning - + /ticket/plan Task: Task context: Additional context: - + - Store the result as `` - STOP if planning is blocked or unusable ### Delegate Implementation - + /dev Plan: Constraints: - + - STOP if implementation is blocked or incomplete @@ -136,13 +136,13 @@ Constraints: Example delegation rule: ```text -Before delegating, write the exact `...` block, say what result should be stored, and whether the workflow should continue or STOP based on that result. +Before dispatching, write the exact `...` block, say what result should be stored, and whether the workflow should continue or STOP based on that result. ``` -Example literal dispatch rule: +Example literal session command rule: ```text -Before literal command forwarding, write the exact `...` block, put the slash command on the first line of the body when needed, and say what result should be stored and whether the workflow should continue or STOP based on that result. +Before literal command forwarding, write the exact `...` block, put the slash command on the first line of the body when needed, then call `session_command` with the rendered body and exact `agent`, and say what queue acknowledgement should be stored and whether the workflow should continue or STOP based on that acknowledgement. ``` ## Component Authoring diff --git a/README.md b/README.md index d91c7c6..99235eb 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ Kompass keeps AI coding agents on course with token-efficient, composable workfl ## Bundled Surface -- Commands cover direct work (`/ask`, `/commit`), orchestration (`/dev`, `/ship`, `/todo`), ticket planning/sync, and PR review/shipping flows. +- Commands cover direct work (`/ask`, `/commit`, `/merge`), orchestration (`/dev`, `/ship`, `/todo`), ticket planning/sync, and PR review/shipping flows. - Agents are intentionally narrow: `worker` is generic, `planner` is no-edit planning, `navigator` owns multi-step orchestration, and `reviewer` is a no-edit review specialist. -- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `pr_load`, `pr_sync`, `ticket_load`, `ticket_sync`. +- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `session_command` (resolve a slash command and queue it into the current session), `pr_load`, `pr_sync`, `ticket_load`, `ticket_sync`. - Reusable command-template components live in `packages/core/components/` and are documented in the components reference. ## Installation diff --git a/kompass.jsonc b/kompass.jsonc index 48c53fd..3c1ebf8 100644 --- a/kompass.jsonc +++ b/kompass.jsonc @@ -17,6 +17,7 @@ "commit-and-push": { "enabled": true }, "dev": { "enabled": true }, "learn": { "enabled": true }, + "merge": { "enabled": true }, "pr/create": { "enabled": true }, "pr/fix": { "enabled": true }, "pr/review": { "enabled": true }, @@ -40,6 +41,7 @@ "tools": { "changes_load": { "enabled": true }, + "session_command": { "enabled": true }, "pr_load": { "enabled": true }, "pr_sync": { "enabled": true }, "ticket_load": { "enabled": true }, diff --git a/kompass.schema.json b/kompass.schema.json index ef1f6e0..60cbee5 100644 --- a/kompass.schema.json +++ b/kompass.schema.json @@ -50,6 +50,9 @@ "learn": { "$ref": "#/$defs/commandConfig" }, + "merge": { + "$ref": "#/$defs/commandConfig" + }, "pr/create": { "$ref": "#/$defs/commandConfig" }, @@ -97,6 +100,7 @@ "commit-and-push", "dev", "learn", + "merge", "pr/create", "pr/fix", "pr/review", @@ -124,6 +128,7 @@ "commit-and-push", "dev", "learn", + "merge", "pr/create", "pr/fix", "pr/review", @@ -179,6 +184,9 @@ "changes_load": { "$ref": "#/$defs/toolConfig" }, + "session_command": { + "$ref": "#/$defs/toolConfig" + }, "pr_load": { "$ref": "#/$defs/toolConfig" }, @@ -353,6 +361,7 @@ "type": "string", "enum": [ "changes_load", + "session_command", "pr_load", "pr_sync", "ticket_sync", diff --git a/packages/core/agents/navigator.md b/packages/core/agents/navigator.md index 31cd368..1e6bf84 100644 --- a/packages/core/agents/navigator.md +++ b/packages/core/agents/navigator.md @@ -1,41 +1,25 @@ -You are a navigation specialist for structured, multi-step workflows. +You coordinate structured, multi-step workflows. -## Operating Boundaries +## Rules - Follow the active command and provided context. -- Own the workflow yourself: decide the next step, load only the local context the command requires, dispatch when the command tells you to, and keep going until the command says to stop. -- Owning the workflow means managing step order, state, and stop conditions; it does not let you rewrite an explicit `` body. -- Delegate only explicit leaf tasks when the user explicitly requests a subagent or the command explicitly requires one. -- Gather only the context needed for the current step. -- Preserve workflow state, ordering, stop conditions, and approval gates across the whole command. -- Execute required user-interaction steps exactly as the active command defines them. -- If a required interaction tool is unavailable, follow the active command's non-interactive fallback instead of pausing or inventing a question. -- If a delegated step is blocked, incomplete, or fails, stop and report it clearly. - -## Dispatch Execution - -- Treat each `...` block as a literal message dispatch instruction. -- Dispatch blocks take precedence over generic delegation guidance; the rendered body is opaque. -- `agent` is required; invoke that exact subagent type. -- Set `prompt` to the dispatch body exactly after variable substitution. -- Do not add wrapper text or rewrite, summarize, interpret, expand, normalize, or improve the body. -- Preserve line breaks and ordering exactly. -- Send the rendered body as a real user turn to the target subagent session. -- Never infer what a slash command means when handling a dispatch block. Forward it literally. -- Process every valid dispatch block you receive. -- Run independent dispatch blocks in parallel only when the workflow makes that independence clear; otherwise run them sequentially in source order. -- If a dispatch block is malformed, report it as invalid, explain why briefly, and continue with remaining valid blocks when safe. -- If no valid dispatch blocks are present, continue with the command workflow. - -## Delegation - -- Treat delegated work as one step inside a larger workflow, not as a handoff of orchestration responsibility. -- For an explicit `` step, your job is only to render variables, send the exact body, store the result, and apply the command's continue-or-stop rules. -- Pass only the context that task needs. -- Use the agent type named by the command; otherwise match planner to planning, reviewer to review, and worker to implementation. -- When a command mixes local orchestration with delegated leaf steps, complete the local steps first and delegate only the explicit leaf steps. +- Manage step order, stored state, approvals, and stop conditions yourself. +- Load only the context needed for the current step. +- Execute required user-interaction steps exactly as the command defines them. +- If a required interaction tool is unavailable, use the command's non-interactive fallback. +- If a step is blocked, incomplete, or fails, stop and report it clearly. + +## Session Commands + +- Treat each `...` block as literal input. +- Render variables, then call `session_command` with `input` set to the rendered body and `agent` set to the tag value. +- `session_command` queues the next same-session user turn and returns immediately; it does not wait for the queued command result. +- Do not rewrite, summarize, or interpret the block body. +- Preserve line breaks and ordering. +- Run `session_command` blocks sequentially unless the workflow clearly makes them independent. +- If a `session_command` block is malformed, report it as invalid and continue with remaining valid blocks when safe. ## Output - Follow any explicit command output exactly. -- Otherwise report what finished, any delegated result, and whether the workflow is continuing, paused, blocked, or complete. +- Otherwise report what finished and whether the workflow is continuing, paused, blocked, or complete. diff --git a/packages/core/commands/index.ts b/packages/core/commands/index.ts index 886f7cd..52ac8db 100644 --- a/packages/core/commands/index.ts +++ b/packages/core/commands/index.ts @@ -42,6 +42,11 @@ export const commandDefinitions: Record = { templatePath: "commands/learn.md", subtask: false, }, + merge: { + description: "Merge a branch and auto-resolve conflicts best-effort", + agent: "worker", + templatePath: "commands/merge.md", + }, "pr/create": { description: "Summarize branch work and create a PR", agent: "worker", diff --git a/packages/core/commands/merge.md b/packages/core/commands/merge.md new file mode 100644 index 0000000..44ce4e2 --- /dev/null +++ b/packages/core/commands/merge.md @@ -0,0 +1,101 @@ +## Goal + +Merge a provided branch into the current branch, defaulting to the repo base branch, and resolve merge conflicts with a best-effort preference for the incoming branch. + +## Additional Context + +Consider `` when choosing between competing conflict resolutions. +- Default to preserving both sides when the intent is clear and the merged result remains coherent. +- When a confident manual merge is not obvious, prefer the incoming `` version to keep the command moving. +- Do not create a merge commit if any conflicts remain unresolved. + +## Workflow + +### Arguments + + +$ARGUMENTS + + +### Interpret Arguments + +- If `` starts with or contains a clear branch or ref name, store it as `` +- If `` includes additional merge guidance, store it as `` +- If no branch or ref was provided, leave `` undefined for now + +### Resolve Merge Source + +- If `` is already defined, keep it +- Otherwise, resolve the repo base branch and store it as `` +- Store the current checked out branch as `` + +### Check Blockers + +- If the working tree has uncommitted or untracked changes, STOP and report that merge automation requires a clean working tree +- If `` cannot be resolved to an existing local or remote ref, STOP and report that the merge source could not be found +- If `` equals ``, STOP and report that the current branch is already the merge source + +### Run Merge + +- Start the merge with `git merge ` +- If git reports a clean merge with no conflicts, store the new merge commit hash as `` +- If git reports conflicts, continue to `Resolve Conflicts` + +### Resolve Conflicts + +- Treat `git status` and the conflicted file markers as the source of truth for unresolved files +- For each conflicted file, attempt a best-effort merge that preserves the intended behavior of both sides +- Use `` when it helps disambiguate intent +- If a conflict can be resolved confidently by combining both sides, do that and stage that file with `git add ` +- If a confident manual merge is not obvious, prefer the incoming `` side for that conflict, then stage that file with `git add ` +- Do not wait until the end to stage everything implicitly; each resolved conflicted file must be explicitly staged before continuing +- After resolving all conflicts, finish the merge non-interactively with `GIT_EDITOR=true git merge --continue` and store the new merge commit hash as `` +- If any conflicts remain unresolved, STOP and report the remaining files without continuing the merge + +### Output + +If merge automation is blocked by a dirty working tree, display: +``` +Merge blocked: working tree is not clean + +Commit, stash, or discard local changes before running `/merge`. + +No additional steps are required. +``` + +If the merge source cannot be found, display: +``` +Merge blocked: source branch not found + +Source: + +No additional steps are required. +``` + +If the current branch already matches the merge source, display: +``` +Merge skipped: already on + +No additional steps are required. +``` + +If any conflicts remain unresolved, display: +``` +Merge blocked: unresolved conflicts remain + +Source: +Branch: + +Resolve the remaining conflicted files, then retry. + +No additional steps are required. +``` + +When the merge succeeds, display: +``` +Merged into + +Commit: + +No additional steps are required. +``` diff --git a/packages/core/commands/ship.md b/packages/core/commands/ship.md index e03f270..85bbfb3 100644 --- a/packages/core/commands/ship.md +++ b/packages/core/commands/ship.md @@ -1,10 +1,10 @@ ## Goal -Ship the current work by delegating branch creation, commit creation, and PR creation. +Ship the current work by dispatching branch creation, commit creation, and PR creation in the current session. ## Additional Context -Use `` to steer delegated branch naming. Use `` to refine the delegated commit and PR summaries. Pass `` through to PR creation when it was provided. This command is delegation-first: send each `` body literally and use the subagent result as the source of truth for the next step. +Use `` to steer dispatched branch naming. Use `` to refine the dispatched commit and PR summaries. Pass `` through to PR creation when it was provided. This command is session-command-first: send each `` body literally through `session_command` and use the result as the source of truth for the next step. ## Workflow @@ -23,24 +23,24 @@ $ARGUMENTS ### Ensure Feature Branch - + /branch Branch naming guidance: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` is blocked or incomplete, STOP and report the branch blocker - If `` says there was nothing to branch from, continue without changing branches - Otherwise, continue with the created branch ### Delegate Commit - + /commit Additional context: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` says there was nothing to commit, continue without creating a new commit - If `` is blocked or incomplete, STOP and report the commit blocker @@ -48,13 +48,13 @@ Additional context: ### Delegate PR Creation - + /pr/create Base branch: Additional context: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` is blocked or incomplete, STOP and report the PR blocker - If `` says there is nothing to include in a PR, STOP and report that there is nothing to ship diff --git a/packages/core/commands/ticket/dev.md b/packages/core/commands/ticket/dev.md index d793dde..1e46571 100644 --- a/packages/core/commands/ticket/dev.md +++ b/packages/core/commands/ticket/dev.md @@ -4,7 +4,7 @@ Implement a ticket by orchestrating development, branching, commit-and-push, and ## Additional Context -Use `` to refine scope, sequencing, and tradeoffs across the delegated `/dev`, `/branch`, `/commit-and-push`, and `/pr/create` steps. +Use `` to refine scope, sequencing, and tradeoffs across the dispatched `/dev`, `/branch`, `/commit-and-push`, and `/pr/create` steps. ## Workflow @@ -31,27 +31,27 @@ $ARGUMENTS ### Delegate Implementation -- Before delegating, send the exact dispatch block below +- Before continuing, send the exact `session_command` block below through `session_command` - + /dev Ticket reference: Ticket context: Additional context: - + - Store the result as `` - If `` is blocked or incomplete, STOP and report the implementation blocker ### Delegate Branch Creation -- Before delegating, send the exact dispatch block below +- Before continuing, send the exact `session_command` block below through `session_command` - + /branch Branch naming guidance: Additional context: - + - Store the result as `` - If `` is blocked or incomplete, STOP and report the branch blocker @@ -59,14 +59,14 @@ Additional context: ### Delegate Commit And Push -- Before delegating, send the exact dispatch block below +- Before continuing, send the exact `session_command` block below through `session_command` - + /commit-and-push Ticket reference: Ticket summary: Additional context: - + - Store the result as `` - If `` is blocked or incomplete, STOP and report the commit or push blocker @@ -74,14 +74,14 @@ Additional context: ### Delegate PR Creation -- Before delegating, send the exact dispatch block below +- Before continuing, send the exact `session_command` block below through `session_command` - + /pr/create Ticket reference: Ticket context: Additional context: - + - Store the result as `` - If `` is blocked or incomplete, STOP and report the PR blocker diff --git a/packages/core/commands/todo.md b/packages/core/commands/todo.md index de04e65..d16ac85 100644 --- a/packages/core/commands/todo.md +++ b/packages/core/commands/todo.md @@ -41,12 +41,12 @@ $ARGUMENTS ### Delegate Planning - + /ticket/plan Task: Task context: Additional context: - + - Ask the planner for a concise implementation plan with clear scope, risks, and validation steps - Store the result as `` @@ -63,14 +63,14 @@ Additional context: - `Revise` - update the plan based on feedback - custom answers enabled so the user can provide specific plan changes - If the user requests changes, store that feedback as `` - + /ticket/plan Task: Task context: Current plan: Plan feedback: Additional context: - + - Store the revised result as `` and continue the review loop - If the revised planner result is blocked or unusable, store that blocker as ``, then STOP and report it before continuing the review loop @@ -79,26 +79,26 @@ Additional context: ### Delegate Implementation - + /dev Plan: Task: Task context: Additional context: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` is incomplete, blocked, or fails validation, store the issue as ``, then STOP and report it without marking the task complete ### Delegate Commit - + /commit Task: Additional context: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` does not succeed, store the commit status as ``, then STOP and report it without marking the task complete ### Mark Complete And Loop diff --git a/packages/core/index.ts b/packages/core/index.ts index 56f127d..8518e3c 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -12,11 +12,13 @@ export type { AgentDefinition, KompassConfig, MergedKompassConfig, - ToolConfig, ToolName, + ToolConfig, } from "./lib/config.ts"; export { createTools } from "./tools/index.ts"; export { createChangesLoadTool } from "./tools/changes-load.ts"; +export { createSessionCommandTool, resolveSessionCommand } from "./tools/dispatch.ts"; +export type { SessionCommandResolution } from "./tools/dispatch.ts"; export { createPrLoadTool } from "./tools/pr-load.ts"; export { createPrSyncTool } from "./tools/pr-sync.ts"; export { createTicketLoadTool } from "./tools/ticket-load.ts"; diff --git a/packages/core/kompass.jsonc b/packages/core/kompass.jsonc index 48c53fd..3c1ebf8 100644 --- a/packages/core/kompass.jsonc +++ b/packages/core/kompass.jsonc @@ -17,6 +17,7 @@ "commit-and-push": { "enabled": true }, "dev": { "enabled": true }, "learn": { "enabled": true }, + "merge": { "enabled": true }, "pr/create": { "enabled": true }, "pr/fix": { "enabled": true }, "pr/review": { "enabled": true }, @@ -40,6 +41,7 @@ "tools": { "changes_load": { "enabled": true }, + "session_command": { "enabled": true }, "pr_load": { "enabled": true }, "pr_sync": { "enabled": true }, "ticket_load": { "enabled": true }, diff --git a/packages/core/lib/config.ts b/packages/core/lib/config.ts index 9074724..9e2332f 100644 --- a/packages/core/lib/config.ts +++ b/packages/core/lib/config.ts @@ -14,6 +14,7 @@ export interface AgentDefinition { export const DEFAULT_TOOL_NAMES = [ "changes_load", + "session_command", "pr_load", "pr_sync", "ticket_sync", @@ -27,6 +28,7 @@ export const DEFAULT_COMMAND_NAMES = [ "commit-and-push", "dev", "learn", + "merge", "pr/create", "pr/fix", "pr/review", @@ -90,6 +92,7 @@ export interface KompassConfig { "commit-and-push"?: CommandConfig; dev?: CommandConfig; learn?: CommandConfig; + merge?: CommandConfig; "pr/create"?: CommandConfig; "pr/fix"?: CommandConfig; "pr/review"?: CommandConfig; @@ -114,6 +117,7 @@ export interface KompassConfig { }; tools?: { changes_load?: ToolConfig; + session_command?: ToolConfig; pr_load?: ToolConfig; pr_sync?: ToolConfig; ticket_sync?: ToolConfig; @@ -161,6 +165,7 @@ export interface MergedKompassConfig { }; tools: { changes_load: ToolConfig; + session_command: ToolConfig; pr_load: ToolConfig; pr_sync: ToolConfig; ticket_sync: ToolConfig; @@ -420,7 +425,7 @@ const defaultAgentReviewer: AgentDefinition = { }; const defaultAgentNavigator: AgentDefinition = { - description: "Coordinate structured multi-step workflows by delegating focused leaf work to subagents.", + description: "Coordinate structured multi-step workflows and run focused slash-command steps in the current session.", promptPath: "agents/navigator.md", permission: { edit: "deny", task: "allow", question: "allow", todowrite: "allow" }, }; @@ -443,6 +448,7 @@ const defaultComponentPaths: Record = { const defaultToolConfig: Record = { changes_load: { enabled: true }, + session_command: { enabled: true }, pr_load: { enabled: true }, pr_sync: { enabled: true }, ticket_sync: { enabled: true }, @@ -560,6 +566,10 @@ export function mergeWithDefaults( }, tools: { changes_load: { ...defaultToolConfig.changes_load, ...config?.tools?.changes_load }, + session_command: { + ...defaultToolConfig.session_command, + ...config?.tools?.session_command, + }, pr_load: { ...defaultToolConfig.pr_load, ...config?.tools?.pr_load }, pr_sync: { ...defaultToolConfig.pr_sync, ...config?.tools?.pr_sync }, ticket_sync: { ...defaultToolConfig.ticket_sync, ...config?.tools?.ticket_sync }, diff --git a/packages/core/test/dispatch.test.ts b/packages/core/test/dispatch.test.ts new file mode 100644 index 0000000..ea03cee --- /dev/null +++ b/packages/core/test/dispatch.test.ts @@ -0,0 +1,61 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; + +import { resolveSessionCommand } from "../index.ts"; + +process.env.HOME = path.join(os.tmpdir(), `kompass-test-home-${process.pid}-core-dispatch`); + +describe("resolveSessionCommand", () => { + test("expands known slash commands and infers their default agent", async () => { + const result = await resolveSessionCommand(process.cwd(), "/review auth bug"); + + assert.equal(result.agent, "reviewer"); + assert.equal(result.command, "review"); + assert.equal(result.arguments, "auth bug"); + assert.equal(result.expanded, true); + assert.match(result.body, /\s*auth bug\s*<\/arguments>/); + }); + + test("preserves explicit agent routing when present", async () => { + const result = await resolveSessionCommand(process.cwd(), "@planner /ticket/plan auth bug"); + + assert.equal(result.agent, "planner"); + assert.equal(result.command, "ticket/plan"); + assert.equal(result.arguments, "auth bug"); + assert.equal(result.expanded, true); + }); + + test("allows a dispatch-tag agent override at tool level", async () => { + const { createSessionCommandTool } = await import("../index.ts"); + const tool = createSessionCommandTool(process.cwd()); + const output = await tool.execute( + { input: "/review auth bug", agent: "worker" }, + { worktree: process.cwd(), directory: process.cwd() }, + ); + const result = JSON.parse(output); + + assert.equal(result.agent, "worker"); + assert.equal(result.command, "review"); + }); + + test("keeps unknown slash commands dispatchable without expansion", async () => { + const result = await resolveSessionCommand(process.cwd(), "/unknown auth bug"); + + assert.deepEqual(result, { + input: "/unknown auth bug", + command: "unknown", + arguments: "auth bug", + body: "/unknown auth bug", + expanded: false, + }); + }); + + test("rejects plain-text input", async () => { + await assert.rejects( + resolveSessionCommand(process.cwd(), "Investigate the login redirect bug"), + /requires slash-command input/, + ); + }); +}); diff --git a/packages/core/tools/dispatch.ts b/packages/core/tools/dispatch.ts new file mode 100644 index 0000000..64b1974 --- /dev/null +++ b/packages/core/tools/dispatch.ts @@ -0,0 +1,119 @@ +import { resolveCommands } from "../commands/index.ts"; +import { stringifyJson, type ToolDefinition, type ToolExecutionContext } from "./shared.ts"; + +export type SessionCommandResolution = { + input: string; + agent?: string; + command: string; + arguments: string; + body: string; + expanded: boolean; +}; + +type ResolveSessionCommandOptions = { + rewriteBody?: (body: string) => string; +}; + +type ParsedSlashCommand = { + agent?: string; + command: string; + arguments: string; +}; + +function parseSlashCommand(value: string): ParsedSlashCommand | undefined { + const match = value.trim().match(/^(?:@(\S+)\s+)?\/([^\s]+)(?:\s+([\s\S]*))?$/); + + if (!match) return; + + return { + agent: match[1], + command: match[2], + arguments: match[3]?.trim() ?? "", + }; +} + +function expandCommandTemplate(template: string, commandArguments: string) { + const trimmedArguments = commandArguments.trim(); + const positionalArguments = trimmedArguments ? trimmedArguments.split(/\s+/) : []; + + let expandedTemplate = template.replaceAll("$ARGUMENTS", trimmedArguments); + + for (const [index, argument] of positionalArguments.entries()) { + expandedTemplate = expandedTemplate.replaceAll(`$${index + 1}`, argument); + } + + return expandedTemplate; +} + +export async function resolveSessionCommand( + projectRoot: string, + input: string, + options?: ResolveSessionCommandOptions, +): Promise { + const normalizedInput = input.trim(); + const parsedCommand = parseSlashCommand(input); + + if (!parsedCommand) { + throw new Error("session_command requires slash-command input"); + } + + const commands = await resolveCommands(projectRoot); + const definition = commands[parsedCommand.command]; + + if (!definition) { + return { + input: normalizedInput, + ...(parsedCommand.agent ? { agent: parsedCommand.agent } : {}), + command: parsedCommand.command, + arguments: parsedCommand.arguments, + body: normalizedInput, + expanded: false, + }; + } + + let body = expandCommandTemplate(definition.template, parsedCommand.arguments); + + if (options?.rewriteBody) { + body = options.rewriteBody(body); + } + + return { + input: normalizedInput, + agent: parsedCommand.agent ?? definition.agent, + command: parsedCommand.command, + arguments: parsedCommand.arguments, + body, + expanded: true, + }; +} + +export function createSessionCommandTool( + projectRoot: string, + options?: ResolveSessionCommandOptions, +) { + return { + description: "Resolve a slash command for same-session queuing", + args: { + input: { + type: "string", + description: "Raw slash command to resolve", + }, + agent: { + type: "string", + optional: true, + description: "Optional agent override from the dispatch tag", + }, + }, + async execute( + args: { input: string; agent?: string }, + _ctx: ToolExecutionContext, + ) { + const resolved = await resolveSessionCommand(projectRoot, args.input, options); + + return stringifyJson({ + ...resolved, + agent: args.agent?.trim() || resolved.agent, + }); + }, + } satisfies ToolDefinition<{ input: string; agent?: string }>; +} diff --git a/packages/core/tools/index.ts b/packages/core/tools/index.ts index 1927e06..ffaf5b3 100644 --- a/packages/core/tools/index.ts +++ b/packages/core/tools/index.ts @@ -5,18 +5,20 @@ import { mergeWithDefaults, } from "../lib/config.ts"; import { createChangesLoadTool } from "./changes-load.ts"; +import { createSessionCommandTool } from "./dispatch.ts"; import { createPrLoadTool } from "./pr-load.ts"; import { createPrSyncTool } from "./pr-sync.ts"; import { createTicketLoadTool } from "./ticket-load.ts"; import { createTicketSyncTool } from "./ticket-sync.ts"; import type { Shell, ToolDefinition } from "./shared.ts"; -const toolCreators: Record ToolDefinition> = { - changes_load: createChangesLoadTool, - pr_load: createPrLoadTool, - pr_sync: createPrSyncTool, - ticket_sync: createTicketSyncTool, - ticket_load: createTicketLoadTool, +const toolCreators: Record ToolDefinition> = { + changes_load: ($) => createChangesLoadTool($), + session_command: (_, projectRoot) => createSessionCommandTool(projectRoot), + pr_load: ($) => createPrLoadTool($), + pr_sync: ($) => createPrSyncTool($), + ticket_sync: ($) => createTicketSyncTool($), + ticket_load: ($) => createTicketLoadTool($), }; export async function createTools($: Shell, projectRoot: string) { @@ -28,7 +30,7 @@ export async function createTools($: Shell, projectRoot: string) { for (const toolName of getEnabledToolNames(config.tools)) { const creator = toolCreators[toolName]; if (creator) { - tools[getConfiguredToolName(config.tools, toolName)] = creator($); + tools[getConfiguredToolName(config.tools, toolName)] = creator($, projectRoot); } } diff --git a/packages/opencode/.opencode/agents/navigator.md b/packages/opencode/.opencode/agents/navigator.md index 82c798e..001d518 100644 --- a/packages/opencode/.opencode/agents/navigator.md +++ b/packages/opencode/.opencode/agents/navigator.md @@ -1,6 +1,6 @@ --- -description: Coordinate structured multi-step workflows by delegating focused - leaf work to subagents. +description: Coordinate structured multi-step workflows and run focused + slash-command steps in the current session. permission: edit: deny task: allow @@ -8,44 +8,28 @@ permission: todowrite: allow --- -You are a navigation specialist for structured, multi-step workflows. +You coordinate structured, multi-step workflows. -## Operating Boundaries +## Rules - Follow the active command and provided context. -- Own the workflow yourself: decide the next step, load only the local context the command requires, dispatch when the command tells you to, and keep going until the command says to stop. -- Owning the workflow means managing step order, state, and stop conditions; it does not let you rewrite an explicit `` body. -- Delegate only explicit leaf tasks when the user explicitly requests a subagent or the command explicitly requires one. -- Gather only the context needed for the current step. -- Preserve workflow state, ordering, stop conditions, and approval gates across the whole command. -- Execute required user-interaction steps exactly as the active command defines them. -- If a required interaction tool is unavailable, follow the active command's non-interactive fallback instead of pausing or inventing a question. -- If a delegated step is blocked, incomplete, or fails, stop and report it clearly. - -## Dispatch Execution - -- Treat each `...` block as a literal message dispatch instruction. -- Dispatch blocks take precedence over generic delegation guidance; the rendered body is opaque. -- `agent` is required; invoke that exact subagent type. -- Set `prompt` to the dispatch body exactly after variable substitution. -- Do not add wrapper text or rewrite, summarize, interpret, expand, normalize, or improve the body. -- Preserve line breaks and ordering exactly. -- Send the rendered body as a real user turn to the target subagent session. -- Never infer what a slash command means when handling a dispatch block. Forward it literally. -- Process every valid dispatch block you receive. -- Run independent dispatch blocks in parallel only when the workflow makes that independence clear; otherwise run them sequentially in source order. -- If a dispatch block is malformed, report it as invalid, explain why briefly, and continue with remaining valid blocks when safe. -- If no valid dispatch blocks are present, continue with the command workflow. - -## Delegation - -- Treat delegated work as one step inside a larger workflow, not as a handoff of orchestration responsibility. -- For an explicit `` step, your job is only to render variables, send the exact body, store the result, and apply the command's continue-or-stop rules. -- Pass only the context that task needs. -- Use the agent type named by the command; otherwise match planner to planning, reviewer to review, and worker to implementation. -- When a command mixes local orchestration with delegated leaf steps, complete the local steps first and delegate only the explicit leaf steps. +- Manage step order, stored state, approvals, and stop conditions yourself. +- Load only the context needed for the current step. +- Execute required user-interaction steps exactly as the command defines them. +- If a required interaction tool is unavailable, use the command's non-interactive fallback. +- If a step is blocked, incomplete, or fails, stop and report it clearly. + +## Session Commands + +- Treat each `...` block as literal input. +- Render variables, then call `kompass_session_command` with `input` set to the rendered body and `agent` set to the tag value. +- `kompass_session_command` queues the next same-session user turn and returns immediately; it does not wait for the queued command result. +- Do not rewrite, summarize, or interpret the block body. +- Preserve line breaks and ordering. +- Run `kompass_session_command` blocks sequentially unless the workflow clearly makes them independent. +- If a `kompass_session_command` block is malformed, report it as invalid and continue with remaining valid blocks when safe. ## Output - Follow any explicit command output exactly. -- Otherwise report what finished, any delegated result, and whether the workflow is continuing, paused, blocked, or complete. +- Otherwise report what finished and whether the workflow is continuing, paused, blocked, or complete. diff --git a/packages/opencode/.opencode/commands/merge.md b/packages/opencode/.opencode/commands/merge.md new file mode 100644 index 0000000..ce758b1 --- /dev/null +++ b/packages/opencode/.opencode/commands/merge.md @@ -0,0 +1,106 @@ +--- +description: Merge a branch and auto-resolve conflicts best-effort +agent: worker +--- + +## Goal + +Merge a provided branch into the current branch, defaulting to the repo base branch, and resolve merge conflicts with a best-effort preference for the incoming branch. + +## Additional Context + +Consider `` when choosing between competing conflict resolutions. +- Default to preserving both sides when the intent is clear and the merged result remains coherent. +- When a confident manual merge is not obvious, prefer the incoming `` version to keep the command moving. +- Do not create a merge commit if any conflicts remain unresolved. + +## Workflow + +### Arguments + + +$ARGUMENTS + + +### Interpret Arguments + +- If `` starts with or contains a clear branch or ref name, store it as `` +- If `` includes additional merge guidance, store it as `` +- If no branch or ref was provided, leave `` undefined for now + +### Resolve Merge Source + +- If `` is already defined, keep it +- Otherwise, resolve the repo base branch and store it as `` +- Store the current checked out branch as `` + +### Check Blockers + +- If the working tree has uncommitted or untracked changes, STOP and report that merge automation requires a clean working tree +- If `` cannot be resolved to an existing local or remote ref, STOP and report that the merge source could not be found +- If `` equals ``, STOP and report that the current branch is already the merge source + +### Run Merge + +- Start the merge with `git merge ` +- If git reports a clean merge with no conflicts, store the new merge commit hash as `` +- If git reports conflicts, continue to `Resolve Conflicts` + +### Resolve Conflicts + +- Treat `git status` and the conflicted file markers as the source of truth for unresolved files +- For each conflicted file, attempt a best-effort merge that preserves the intended behavior of both sides +- Use `` when it helps disambiguate intent +- If a conflict can be resolved confidently by combining both sides, do that and stage that file with `git add ` +- If a confident manual merge is not obvious, prefer the incoming `` side for that conflict, then stage that file with `git add ` +- Do not wait until the end to stage everything implicitly; each resolved conflicted file must be explicitly staged before continuing +- After resolving all conflicts, finish the merge non-interactively with `GIT_EDITOR=true git merge --continue` and store the new merge commit hash as `` +- If any conflicts remain unresolved, STOP and report the remaining files without continuing the merge + +### Output + +If merge automation is blocked by a dirty working tree, display: +``` +Merge blocked: working tree is not clean + +Commit, stash, or discard local changes before running `/merge`. + +No additional steps are required. +``` + +If the merge source cannot be found, display: +``` +Merge blocked: source branch not found + +Source: + +No additional steps are required. +``` + +If the current branch already matches the merge source, display: +``` +Merge skipped: already on + +No additional steps are required. +``` + +If any conflicts remain unresolved, display: +``` +Merge blocked: unresolved conflicts remain + +Source: +Branch: + +Resolve the remaining conflicted files, then retry. + +No additional steps are required. +``` + +When the merge succeeds, display: +``` +Merged into + +Commit: + +No additional steps are required. +``` diff --git a/packages/opencode/.opencode/commands/ship.md b/packages/opencode/.opencode/commands/ship.md index 1aabed0..de64eca 100644 --- a/packages/opencode/.opencode/commands/ship.md +++ b/packages/opencode/.opencode/commands/ship.md @@ -5,11 +5,11 @@ agent: navigator ## Goal -Ship the current work by delegating branch creation, commit creation, and PR creation. +Ship the current work by dispatching branch creation, commit creation, and PR creation in the current session. ## Additional Context -Use `` to steer delegated branch naming. Use `` to refine the delegated commit and PR summaries. Pass `` through to PR creation when it was provided. This command is delegation-first: send each `` body literally and use the subagent result as the source of truth for the next step. +Use `` to steer dispatched branch naming. Use `` to refine the dispatched commit and PR summaries. Pass `` through to PR creation when it was provided. This command is session-command-first: send each `` body literally through `kompass_session_command` and use the result as the source of truth for the next step. ## Workflow @@ -28,24 +28,24 @@ $ARGUMENTS ### Ensure Feature Branch - + /branch Branch naming guidance: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` is blocked or incomplete, STOP and report the branch blocker - If `` says there was nothing to branch from, continue without changing branches - Otherwise, continue with the created branch ### Delegate Commit - + /commit Additional context: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` says there was nothing to commit, continue without creating a new commit - If `` is blocked or incomplete, STOP and report the commit blocker @@ -53,13 +53,13 @@ Additional context: ### Delegate PR Creation - + /pr/create Base branch: Additional context: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` is blocked or incomplete, STOP and report the PR blocker - If `` says there is nothing to include in a PR, STOP and report that there is nothing to ship diff --git a/packages/opencode/.opencode/commands/ticket/dev.md b/packages/opencode/.opencode/commands/ticket/dev.md index 2556245..b8b952c 100644 --- a/packages/opencode/.opencode/commands/ticket/dev.md +++ b/packages/opencode/.opencode/commands/ticket/dev.md @@ -9,7 +9,7 @@ Implement a ticket by orchestrating development, branching, commit-and-push, and ## Additional Context -Use `` to refine scope, sequencing, and tradeoffs across the delegated `/dev`, `/branch`, `/commit-and-push`, and `/pr/create` steps. +Use `` to refine scope, sequencing, and tradeoffs across the dispatched `/dev`, `/branch`, `/commit-and-push`, and `/pr/create` steps. ## Workflow @@ -46,27 +46,27 @@ $ARGUMENTS ### Delegate Implementation -- Before delegating, send the exact dispatch block below +- Before continuing, send the exact `kompass_session_command` block below through `kompass_session_command` - + /dev Ticket reference: Ticket context: Additional context: - + - Store the result as `` - If `` is blocked or incomplete, STOP and report the implementation blocker ### Delegate Branch Creation -- Before delegating, send the exact dispatch block below +- Before continuing, send the exact `kompass_session_command` block below through `kompass_session_command` - + /branch Branch naming guidance: Additional context: - + - Store the result as `` - If `` is blocked or incomplete, STOP and report the branch blocker @@ -74,14 +74,14 @@ Additional context: ### Delegate Commit And Push -- Before delegating, send the exact dispatch block below +- Before continuing, send the exact `kompass_session_command` block below through `kompass_session_command` - + /commit-and-push Ticket reference: Ticket summary: Additional context: - + - Store the result as `` - If `` is blocked or incomplete, STOP and report the commit or push blocker @@ -89,14 +89,14 @@ Additional context: ### Delegate PR Creation -- Before delegating, send the exact dispatch block below +- Before continuing, send the exact `kompass_session_command` block below through `kompass_session_command` - + /pr/create Ticket reference: Ticket context: Additional context: - + - Store the result as `` - If `` is blocked or incomplete, STOP and report the PR blocker diff --git a/packages/opencode/.opencode/commands/todo.md b/packages/opencode/.opencode/commands/todo.md index 6855fae..75a2255 100644 --- a/packages/opencode/.opencode/commands/todo.md +++ b/packages/opencode/.opencode/commands/todo.md @@ -46,12 +46,12 @@ $ARGUMENTS ### Delegate Planning - + /ticket/plan Task: Task context: Additional context: - + - Ask the planner for a concise implementation plan with clear scope, risks, and validation steps - Store the result as `` @@ -68,14 +68,14 @@ Additional context: - `Revise` - update the plan based on feedback - custom answers enabled so the user can provide specific plan changes - If the user requests changes, store that feedback as `` - + /ticket/plan Task: Task context: Current plan: Plan feedback: Additional context: - + - Store the revised result as `` and continue the review loop - If the revised planner result is blocked or unusable, store that blocker as ``, then STOP and report it before continuing the review loop @@ -84,26 +84,26 @@ Additional context: ### Delegate Implementation - + /dev Plan: Task: Task context: Additional context: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` is incomplete, blocked, or fails validation, store the issue as ``, then STOP and report it without marking the task complete ### Delegate Commit - + /commit Task: Additional context: - + -- Store the subagent result as `` +- Store the dispatch result as `` - If `` does not succeed, store the commit status as ``, then STOP and report it without marking the task complete ### Mark Complete And Loop diff --git a/packages/opencode/.opencode/kompass.jsonc b/packages/opencode/.opencode/kompass.jsonc index f4bb040..bbbd650 100644 --- a/packages/opencode/.opencode/kompass.jsonc +++ b/packages/opencode/.opencode/kompass.jsonc @@ -25,6 +25,9 @@ "learn": { "enabled": true }, + "merge": { + "enabled": true + }, "pr/create": { "enabled": true }, @@ -80,6 +83,9 @@ "changes_load": { "enabled": true }, + "session_command": { + "enabled": true + }, "pr_load": { "enabled": true }, diff --git a/packages/opencode/README.md b/packages/opencode/README.md index d91c7c6..79634ed 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -20,7 +20,7 @@ Kompass keeps AI coding agents on course with token-efficient, composable workfl - Commands cover direct work (`/ask`, `/commit`), orchestration (`/dev`, `/ship`, `/todo`), ticket planning/sync, and PR review/shipping flows. - Agents are intentionally narrow: `worker` is generic, `planner` is no-edit planning, `navigator` owns multi-step orchestration, and `reviewer` is a no-edit review specialist. -- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `pr_load`, `pr_sync`, `ticket_load`, `ticket_sync`. +- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `session_command` (resolve a slash command and queue it into the current session), `pr_load`, `pr_sync`, `ticket_load`, `ticket_sync`. - Reusable command-template components live in `packages/core/components/` and are documented in the components reference. ## Installation diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index 2d05182..60965df 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -3,6 +3,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"; import { createChangesLoadTool, + createSessionCommandTool, createPrLoadTool, createPrSyncTool, createTicketLoadTool, @@ -10,7 +11,6 @@ import { getEnabledToolNames, loadKompassConfig, mergeWithDefaults, - resolveCommands, type MergedKompassConfig, type Shell, } from "../core/index.ts"; @@ -31,6 +31,12 @@ type CommandExecuteBeforeInput = Parameters[0]; type CommandExecuteBeforeOutput = Parameters[1]; type ChatMessageHook = NonNullable; type ChatMessageOutput = Parameters[1]; +type OpenCodeToolCreator = ( + $: PluginInput["$"], + client: PluginInput["client"], + config: MergedKompassConfig, + projectRoot: string, +) => ToolDefinition; function asShell(shell: PluginInput["$"]): Shell { return shell as unknown as Shell; @@ -42,8 +48,6 @@ export type TaskToolExecution = { description?: string; subagent_type?: string; command?: string; - command_name?: string; - command_arguments?: string; }; export type CommandExecution = { @@ -52,11 +56,6 @@ export type CommandExecution = { prompt: string; }; -type ParsedSlashCommand = { - command: string; - arguments: string; -}; - function getString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -73,76 +72,39 @@ async function logObservedFailure( }); } -function parseSlashCommand(value: string): ParsedSlashCommand | undefined { - const match = value.trim().match(/^(?:@\S+\s+)?\/([^\s]+)(?:\s+([\s\S]*))?$/); - - if (!match) return; - - return { - command: match[1], - arguments: match[2]?.trim() ?? "", - }; -} - -function expandCommandTemplate(template: string, commandArguments: string) { - const trimmedArguments = commandArguments.trim(); - const positionalArguments = trimmedArguments ? trimmedArguments.split(/\s+/) : []; - - let expandedTemplate = template.replaceAll("$ARGUMENTS", trimmedArguments); - - for (const [index, argument] of positionalArguments.entries()) { - expandedTemplate = expandedTemplate.replaceAll(`$${index + 1}`, argument); - } - - return expandedTemplate; +function getSessionError(result: unknown) { + if (!result || typeof result !== "object") return undefined; + if (!("error" in result)) return undefined; + return (result as { error?: unknown }).error; } -export async function expandSlashCommandPrompt( - projectRoot: string, - value: string, - logger?: PluginLogger, -): Promise { - const parsedCommand = parseSlashCommand(value); - - if (!parsedCommand) return; +async function executeSessionCommand( + client: PluginInput["client"], + context: { sessionID: string; directory: string }, + sessionCommand: { agent?: string; command: string; arguments: string; body: string; expanded: boolean }, +) { + const result = await client.session.promptAsync({ + path: { id: context.sessionID }, + query: { directory: context.directory }, + body: { + ...(sessionCommand.agent ? { agent: sessionCommand.agent } : {}), + parts: [{ type: "text", text: sessionCommand.body }], + }, + }); - const userConfig = await loadKompassConfig(projectRoot); - const config = mergeWithDefaults(userConfig); - const commands = await resolveCommands(projectRoot); - const definition = commands[parsedCommand.command]; - - if (!definition) return; - - const configuredToolNames = Object.fromEntries( - getEnabledToolNames(config.tools).map((toolName) => [ - toolName, - getConfiguredOpenCodeToolName(toolName, config.tools[toolName].name), - ]), - ); - const template = prefixKompassToolReferences(definition.template, configuredToolNames); - const expandedPrompt = expandCommandTemplate(template, parsedCommand.arguments); - - if (logger) { - await logger.info("Resolved slash command", { - input: value, - command: parsedCommand.command, - arguments: parsedCommand.arguments, - output: expandedPrompt, - }); + const error = getSessionError(result); + if (error) { + throw new Error(`Session command enqueue failed: ${JSON.stringify(error)}`); } return { - command: parsedCommand.command, - arguments: parsedCommand.arguments, - prompt: expandedPrompt, + mode: "prompt_async", }; } export async function getTaskToolExecution( input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput, - projectRoot: string, - logger?: PluginLogger, ): Promise { if (input.tool !== "task") return; if (!output.args || typeof output.args !== "object") return; @@ -153,35 +115,12 @@ export async function getTaskToolExecution( if (!prompt && !command) return; - let expandedCommand: CommandExecution | undefined; - try { - expandedCommand = prompt - ? await expandSlashCommandPrompt(projectRoot, prompt, logger) - : command - ? await expandSlashCommandPrompt(projectRoot, command, logger) - : undefined; - } catch (error) { - if (logger) { - await logObservedFailure(logger, "Failed to expand slash command for task tool", error, { - projectRoot, - tool: input.tool, - command, - prompt, - }); - } - } - const finalPrompt = expandedCommand?.prompt ?? prompt ?? command ?? ""; - - args.prompt = finalPrompt; - return { - prompt: finalPrompt, + prompt: prompt ?? command ?? "", raw_prompt: prompt, description: getString(args.description), subagent_type: getString(args.subagent_type), command, - command_name: expandedCommand?.command, - command_arguments: expandedCommand?.arguments, }; } @@ -216,8 +155,8 @@ export function removeSyntheticAgentHandoff(output: ChatMessageOutput): boolean return true; } -const opencodeToolCreators = { - changes_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig) { +const opencodeToolCreators: Record = { + changes_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string) { const definition = createChangesLoadTool(asShell($)); return tool({ description: definition.description, @@ -234,7 +173,49 @@ const opencodeToolCreators = { execute: (args, context) => definition.execute(args, context), }); }, - pr_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig) { + session_command(_: PluginInput["$"], client: PluginInput["client"], config: MergedKompassConfig, projectRoot: string) { + const configuredToolNames = Object.fromEntries( + getEnabledToolNames(config.tools).map((toolName) => [ + toolName, + getConfiguredOpenCodeToolName(toolName, config.tools[toolName].name), + ]), + ); + const definition = createSessionCommandTool(projectRoot, { + rewriteBody: (body) => prefixKompassToolReferences(body, configuredToolNames), + }); + + return tool({ + description: "Resolve a slash command and queue it in the current session", + args: { + input: tool.schema.string().describe("Raw slash command to execute"), + agent: tool.schema.string().describe("Optional agent override from the dispatch tag").optional(), + }, + execute: async (args, context) => { + context.metadata({ + title: `Command ${args.input.trim()}`, + metadata: { + agent: args.agent, + }, + }); + + const resolved = JSON.parse(await definition.execute({ input: args.input, agent: args.agent }, context)) as { + agent?: string; + command: string; + arguments: string; + body: string; + expanded: boolean; + }; + const dispatched = await executeSessionCommand(client, context, resolved); + + return JSON.stringify({ + ...resolved, + queued: true, + mode: dispatched.mode, + }); + }, + }); + }, + pr_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string) { const definition = createPrLoadTool(asShell($)); return tool({ description: definition.description, @@ -244,7 +225,7 @@ const opencodeToolCreators = { execute: (args, context) => definition.execute(args, context), }); }, - pr_sync($: PluginInput["$"], _: PluginInput["client"], config: MergedKompassConfig) { + pr_sync($: PluginInput["$"], _: PluginInput["client"], config: MergedKompassConfig, _projectRoot: string) { const definition = createPrSyncTool(asShell($)); return tool({ @@ -290,7 +271,7 @@ const opencodeToolCreators = { execute: (args, context) => definition.execute(args, context), }); }, - ticket_sync($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig) { + ticket_sync($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string) { const definition = createTicketSyncTool(asShell($)); return tool({ description: definition.description, @@ -313,7 +294,7 @@ const opencodeToolCreators = { execute: (args, context) => definition.execute(args, context), }); }, - ticket_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig) { + ticket_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string) { const definition = createTicketLoadTool(asShell($)); return tool({ description: definition.description, @@ -324,7 +305,7 @@ const opencodeToolCreators = { execute: (args, context) => definition.execute(args, context), }); }, -} as const; +}; export async function createOpenCodeTools( $: PluginInput["$"], @@ -340,7 +321,7 @@ export async function createOpenCodeTools( const creator = opencodeToolCreators[toolName as keyof typeof opencodeToolCreators]; if (creator) { const registeredName = getConfiguredOpenCodeToolName(toolName, config.tools[toolName].name); - tools[registeredName] = creator($, client, config); + tools[registeredName] = creator($, client, config, projectRoot); await logger.info("Loaded Kompass tool", { tool: toolName, registeredName, @@ -428,7 +409,7 @@ export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { }, async "tool.execute.before"(input, output) { try { - const taskExecution = await getTaskToolExecution(input, output, worktree, logger); + const taskExecution = await getTaskToolExecution(input, output); if (!taskExecution) return; diff --git a/packages/opencode/kompass.jsonc b/packages/opencode/kompass.jsonc index 48c53fd..3c1ebf8 100644 --- a/packages/opencode/kompass.jsonc +++ b/packages/opencode/kompass.jsonc @@ -17,6 +17,7 @@ "commit-and-push": { "enabled": true }, "dev": { "enabled": true }, "learn": { "enabled": true }, + "merge": { "enabled": true }, "pr/create": { "enabled": true }, "pr/fix": { "enabled": true }, "pr/review": { "enabled": true }, @@ -40,6 +41,7 @@ "tools": { "changes_load": { "enabled": true }, + "session_command": { "enabled": true }, "pr_load": { "enabled": true }, "pr_sync": { "enabled": true }, "ticket_load": { "enabled": true }, diff --git a/packages/opencode/test/agents-config.test.ts b/packages/opencode/test/agents-config.test.ts index 0984dcc..cd84790 100644 --- a/packages/opencode/test/agents-config.test.ts +++ b/packages/opencode/test/agents-config.test.ts @@ -35,7 +35,7 @@ describe("applyAgentsConfig", () => { assert.equal(cfg.agent.worker?.mode, undefined); assert.equal( cfg.agent.navigator?.description, - "Coordinate structured multi-step workflows by delegating focused leaf work to subagents.", + "Coordinate structured multi-step workflows and run focused slash-command steps in the current session.", ); assert.deepEqual(cfg.agent.navigator?.permission, { edit: "deny", @@ -54,9 +54,10 @@ describe("applyAgentsConfig", () => { todowrite: "allow", }); assert.equal(cfg.agent.worker?.prompt, undefined); - assert.match(cfg.agent.navigator?.prompt ?? "", /navigation specialist/i); - assert.match(cfg.agent.navigator?.prompt ?? "", /delegate only explicit leaf tasks/i); - assert.match(cfg.agent.navigator?.prompt ?? "", /complete the local steps first/i); + assert.match(cfg.agent.navigator?.prompt ?? "", /structured, multi-step workflows/i); + assert.match(cfg.agent.navigator?.prompt ?? "", /call `?kompass_session_command`?/i); + assert.match(cfg.agent.navigator?.prompt ?? "", /manage step order/i); + assert.match(cfg.agent.navigator?.prompt ?? "", / { const expectedCommands = [ "ask", "branch", + "merge", "pr/create", "pr/review", "pr/fix", @@ -60,6 +61,7 @@ describe("applyCommandsConfig", () => { assert.ok(cfg.command); assert.equal(cfg.command!["pr/review"]?.agent, "reviewer"); assert.equal(cfg.command!["branch"]?.agent, "worker"); + assert.equal(cfg.command!["merge"]?.agent, "worker"); assert.equal(cfg.command!["pr/create"]?.agent, "worker"); assert.equal(cfg.command!["ticket/create"]?.agent, "worker"); assert.equal(cfg.command!["ticket/plan"]?.agent, "planner"); @@ -420,7 +422,7 @@ describe("applyCommandsConfig", () => { assert.ok(cfg.command!["review"]?.template); }); - test("embeds literal dispatch blocks in ship command", async () => { + test("embeds literal session_command blocks in ship command", async () => { delete process.env.CI; const cfg: { command?: Record } = {}; @@ -430,15 +432,15 @@ describe("applyCommandsConfig", () => { const shipTemplate = cfg.command!["ship"].template; assert.match(shipTemplate, /## Goal/); - assert.match(shipTemplate, /Ship the current work by delegating/); + assert.match(shipTemplate, /Ship the current work by dispatching/); assert.match(shipTemplate, /Ensure Feature Branch/); - assert.match(shipTemplate, //); - assert.match(shipTemplate, /\n\/branch\nBranch naming guidance: \n<\/dispatch>/); - assert.match(shipTemplate, /Store the subagent result as ``/); - assert.match(shipTemplate, /Store the subagent result as ``/); - assert.match(shipTemplate, /\n\/commit\nAdditional context: \n<\/dispatch>/); - assert.match(shipTemplate, /Store the subagent result as ``/); - assert.match(shipTemplate, /\n\/pr\/create\nBase branch: \nAdditional context: \n<\/dispatch>/); + assert.match(shipTemplate, //); + assert.match(shipTemplate, /\n\/branch\nBranch naming guidance: \n<\/kompass_session_command>/); + assert.match(shipTemplate, /Store the dispatch result as ``/); + assert.match(shipTemplate, /Store the dispatch result as ``/); + assert.match(shipTemplate, /\n\/commit\nAdditional context: \n<\/kompass_session_command>/); + assert.match(shipTemplate, /Store the dispatch result as ``/); + assert.match(shipTemplate, /\n\/pr\/create\nBase branch: \nAdditional context: \n<\/kompass_session_command>/); assert.doesNotMatch(shipTemplate, /<%/); }); @@ -509,17 +511,17 @@ describe("applyCommandsConfig", () => { // PR Author content is now inline in pr/create, not embedded here assert.match(ticketDevTemplate, /## Goal/); assert.match(ticketDevTemplate, /Implement a ticket/); - assert.match(ticketDevTemplate, //); - assert.match(ticketDevTemplate, /\n\/dev\nTicket reference: \nTicket context: \nAdditional context: \n<\/dispatch>/); - assert.match(ticketDevTemplate, /\n\/branch\nBranch naming guidance: \nAdditional context: \n<\/dispatch>/); - assert.match(ticketDevTemplate, /\n\/commit-and-push\nTicket reference: \nTicket summary: \nAdditional context: \n<\/dispatch>/); - assert.match(ticketDevTemplate, /\n\/pr\/create\nTicket reference: \nTicket context: \nAdditional context: \n<\/dispatch>/); + assert.match(ticketDevTemplate, //); + assert.match(ticketDevTemplate, /\n\/dev\nTicket reference: \nTicket context: \nAdditional context: \n<\/kompass_session_command>/); + assert.match(ticketDevTemplate, /\n\/branch\nBranch naming guidance: \nAdditional context: \n<\/kompass_session_command>/); + assert.match(ticketDevTemplate, /\n\/commit-and-push\nTicket reference: \nTicket summary: \nAdditional context: \n<\/kompass_session_command>/); + assert.match(ticketDevTemplate, /\n\/pr\/create\nTicket reference: \nTicket context: \nAdditional context: \n<\/kompass_session_command>/); assert.doesNotMatch(ticketDevTemplate, / { + test("embeds literal session_command blocks in todo command", async () => { delete process.env.CI; const cfg: { command?: Record } = {}; @@ -530,12 +532,12 @@ describe("applyCommandsConfig", () => { assert.match(todoTemplate, /## Goal/); assert.match(todoTemplate, /Work through a todo file one pending item at a time/); - assert.match(todoTemplate, //); - assert.match(todoTemplate, /\n\/ticket\/plan\nTask: \nTask context: \nAdditional context: \n<\/dispatch>/); - assert.match(todoTemplate, //); + assert.match(todoTemplate, //); + assert.match(todoTemplate, /\n\/ticket\/plan\nTask: \nTask context: \nAdditional context: \n<\/kompass_session_command>/); + assert.match(todoTemplate, //); assert.match(todoTemplate, /Current plan: \nPlan feedback: /); - assert.match(todoTemplate, /\n\/dev\nPlan: \nTask: \nTask context: \nAdditional context: \n<\/dispatch>/); - assert.match(todoTemplate, /\n\/commit\nTask: \nAdditional context: \n<\/dispatch>/); + assert.match(todoTemplate, /\n\/dev\nPlan: \nTask: \nTask context: \nAdditional context: \n<\/kompass_session_command>/); + assert.match(todoTemplate, /\n\/commit\nTask: \nAdditional context: \n<\/kompass_session_command>/); assert.doesNotMatch(todoTemplate, / { - test("expands slash commands for task tool calls", async () => { + test("keeps raw task prompts unchanged", async () => { const output = { args: { prompt: "/review auth bug", @@ -25,20 +24,16 @@ describe("getTaskToolExecution", () => { callID: "call-1", }, output, - process.cwd(), ); + assert.equal(execution?.prompt, "/review auth bug"); assert.equal(execution?.raw_prompt, "/review auth bug"); assert.equal(execution?.description, "Run review command"); assert.equal(execution?.subagent_type, "reviewer"); assert.equal(execution?.command, "@reviewer /review auth bug"); - assert.equal(execution?.command_name, "review"); - assert.equal(execution?.command_arguments, "auth bug"); - assert.match(execution?.prompt ?? "", /\s*auth bug\s*<\/arguments>/); - assert.equal(output.args.prompt, execution?.prompt); }); - test("prefers the raw prompt over summarized command metadata", async () => { + test("prefers the raw prompt over command metadata", async () => { const output = { args: { prompt: "/review auth bug with multiline\nextra context", @@ -55,12 +50,10 @@ describe("getTaskToolExecution", () => { callID: "call-1", }, output, - process.cwd(), ); - assert.equal(execution?.command_name, "review"); - assert.equal(execution?.command_arguments, "auth bug with multiline\nextra context"); - assert.match(execution?.prompt ?? "", /\s*auth bug with multiline\nextra context\s*<\/arguments>/); + assert.equal(execution?.prompt, "/review auth bug with multiline\nextra context"); + assert.equal(execution?.command, "@reviewer /review auth bug"); }); test("ignores non-task tool calls", async () => { @@ -75,7 +68,6 @@ describe("getTaskToolExecution", () => { prompt: "should not be read", }, }, - process.cwd(), ); assert.equal(execution, undefined); @@ -94,13 +86,12 @@ describe("getTaskToolExecution", () => { subagent_type: "planner", }, }, - process.cwd(), ); assert.equal(execution, undefined); }); - test("falls back to the raw task prompt when the command is unknown", async () => { + test("returns the raw task prompt when the command is unknown", async () => { const execution = await getTaskToolExecution( { tool: "task", @@ -113,15 +104,12 @@ describe("getTaskToolExecution", () => { command: "/unknown auth bug", }, }, - process.cwd(), ); assert.deepEqual(execution, { prompt: "/unknown auth bug", raw_prompt: "/unknown auth bug", command: "/unknown auth bug", - command_name: undefined, - command_arguments: undefined, description: undefined, subagent_type: undefined, }); @@ -144,25 +132,11 @@ describe("getTaskToolExecution", () => { callID: "call-1", }, output, - process.cwd(), ); + assert.equal(execution?.prompt, "/branch Branch naming guidance: fix login redirect"); assert.equal(execution?.command, "/branch Branch naming guidance: fix login redirect"); - assert.equal(execution?.command_name, "branch"); - assert.equal(execution?.command_arguments, "Branch naming guidance: fix login redirect"); - assert.match(execution?.prompt ?? "", /## Goal/); - assert.match(execution?.prompt ?? "", /\s*Branch naming guidance: fix login redirect\s*<\/arguments>/); - assert.equal(output.args.prompt, execution?.prompt); - }); -}); - -describe("expandSlashCommandPrompt", () => { - test("expands command templates using slash command arguments", async () => { - const execution = await expandSlashCommandPrompt(process.cwd(), "@general /review asd"); - - assert.deepEqual(execution?.command, "review"); - assert.deepEqual(execution?.arguments, "asd"); - assert.match(execution?.prompt ?? "", /\s*asd\s*<\/arguments>/); + assert.equal(output.args.prompt, undefined); }); }); diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 9220fa2..bda20be 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -13,9 +13,17 @@ type MockLogEntry = { type MockClient = { logs: MockLogEntry[]; + sessionCommands: Array>; + sessionPrompts: Array>; + sessionPromptAsyncs: Array>; app: { log(entry: MockLogEntry): Promise; }; + session: { + command(options: Record): Promise>; + prompt(options: Record): Promise>; + promptAsync(options: Record): Promise>; + }; instance: { dispose(): Promise; }; @@ -42,15 +50,55 @@ async function withTempHome(run: (homeDir: string) => Promise): Promise function createMockClient(): MockClient { const logs: MockLogEntry[] = []; + const sessionCommands: Array> = []; + const sessionPrompts: Array> = []; + const sessionPromptAsyncs: Array> = []; + + function response(text: string, id: string) { + return { + data: { + info: { id }, + parts: [{ type: "text", text }], + }, + error: undefined, + request: new Request("http://localhost/mock"), + response: new Response(), + }; + } return { logs, + sessionCommands, + sessionPrompts, + sessionPromptAsyncs, app: { log: async (entry: (typeof logs)[number]) => { logs.push(entry); return true; }, }, + session: { + command: async (options: Record) => { + sessionCommands.push(options); + const body = (options.body ?? {}) as { command?: string; arguments?: string }; + return response(`Ran /${body.command ?? "unknown"} ${body.arguments ?? ""}`.trim(), "assistant-command"); + }, + prompt: async (options: Record) => { + sessionPrompts.push(options); + const parts = ((options.body ?? {}) as { parts?: Array<{ text?: string }> }).parts ?? []; + const text = parts.map((part) => part.text ?? "").join("\n").trim(); + return response(text, "assistant-prompt"); + }, + promptAsync: async (options: Record) => { + sessionPromptAsyncs.push(options); + return { + data: undefined, + error: undefined, + request: new Request("http://localhost/mock"), + response: new Response(null, { status: 204 }), + }; + }, + }, instance: { dispose: async () => true, }, @@ -65,11 +113,13 @@ describe("createOpenCodeTools", () => { }) as never, createMockClient() as never, process.cwd()); assert.ok(tools.kompass_changes_load); + assert.ok(tools.kompass_session_command); assert.ok(tools.kompass_pr_load); assert.ok(tools.kompass_pr_sync); assert.ok(tools.kompass_ticket_load); assert.ok(tools.kompass_ticket_sync); assert.equal(tools.changes_load, undefined); + assert.equal(tools.session_command, undefined); assert.equal(tools.pr_load, undefined); assert.equal(tools.pr_sync, undefined); assert.equal(tools.ticket_load, undefined); @@ -88,6 +138,7 @@ describe("createOpenCodeTools", () => { `{ "tools": { "changes_load": { "enabled": false }, + "session_command": { "enabled": false }, "pr_load": { "enabled": false }, "pr_sync": { "enabled": false }, "ticket_sync": { @@ -123,6 +174,9 @@ describe("createOpenCodeTools", () => { `{ // jsonc config should work "tools": { + "session_command": { + "enabled": false + }, "pr_load": { "enabled": true, "name": "pull_request_context", @@ -202,7 +256,80 @@ describe("createOpenCodeTools", () => { }); }); - test("observes slash-command expansion failures without throwing", async () => { + test("session_command queues expanded commands into the current session", async () => { + await withTempHome(async () => { + const client = createMockClient(); + const tools = await createOpenCodeTools((() => { + throw new Error("not implemented"); + }) as never, client as never, process.cwd()); + + const output = await (tools.kompass_session_command as any).execute( + { input: "/review auth bug" }, + { + sessionID: "session-1", + messageID: "message-1", + agent: "worker", + directory: process.cwd(), + worktree: process.cwd(), + abort: new AbortController().signal, + metadata() {}, + ask: async () => {}, + }, + ); + + const result = JSON.parse(output); + + assert.equal(result.agent, "reviewer"); + assert.equal(result.command, "review"); + assert.equal(result.arguments, "auth bug"); + assert.equal(result.expanded, true); + assert.equal(result.queued, true); + assert.equal(result.mode, "prompt_async"); + assert.match(result.body, /kompass_changes_load/); + assert.doesNotMatch(result.body, /`changes_load`/); + assert.equal(client.sessionCommands.length, 0); + assert.equal(client.sessionPromptAsyncs.length, 1); + assert.deepEqual(client.sessionPromptAsyncs[0], { + path: { id: "session-1" }, + query: { directory: process.cwd() }, + body: { + agent: "reviewer", + parts: [{ type: "text", text: result.body }], + }, + }); + assert.equal(client.sessionPrompts.length, 0); + }); + }); + + test("command tool rejects plain-text input", async () => { + await withTempHome(async () => { + const client = createMockClient(); + const tools = await createOpenCodeTools((() => { + throw new Error("not implemented"); + }) as never, client as never, process.cwd()); + + await assert.rejects( + (tools.kompass_session_command as any).execute( + { input: "Investigate the redirect bug", agent: "worker" }, + { + sessionID: "session-2", + messageID: "message-2", + agent: "worker", + directory: process.cwd(), + worktree: process.cwd(), + abort: new AbortController().signal, + metadata() {}, + ask: async () => {}, + }, + ), + /requires slash-command input/, + ); + assert.equal(client.sessionCommands.length, 0); + assert.equal(client.sessionPrompts.length, 0); + }); + }); + + test("does not expand slash commands in the task hook", async () => { await withTempHome(async () => { const client = createMockClient(); const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-tools-bad-config-")); @@ -244,7 +371,7 @@ describe("createOpenCodeTools", () => { assert.equal(output.args.prompt, "/review auth bug"); assert.ok(client.logs.some((entry) => entry.body?.message?.includes("Skipping Kompass tool registration"))); - assert.ok(client.logs.some((entry) => entry.body?.message?.includes("Failed to expand slash command for task tool"))); + assert.ok(client.logs.some((entry) => entry.body?.message?.includes("Executing Kompass task tool"))); } finally { await rm(tempDir, { recursive: true, force: true }); }