Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Kompass keeps AI coding agents on course with token-efficient, composable workfl

- Commands cover direct work (`/ask`, `/commit`, `/merge`, `/skill/create`, `/skill/optimize`), 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`, `command_expansion` (resolve a slash command and return the expanded prompt for immediate delegation), `pr_load`, `pr_sync`, `ticket_load`, `ticket_sync`.
- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `command_expansion` (resolve a slash command and return the expanded prompt for immediate delegation), `pr_load`, `pr_sync`, `ticket_load`, `ticket_sync`, `worktree_load`.
- Reusable command-template components live in `packages/core/components/` and are documented in the components reference.

## Prerequisites
Expand Down
1 change: 1 addition & 0 deletions kompass.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"pr_sync": { "enabled": true },
"ticket_load": { "enabled": true },
"ticket_sync": { "enabled": true },
"worktree_load": { "enabled": true },
},

"components": {
Expand Down
6 changes: 5 additions & 1 deletion kompass.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@
},
"ticket_load": {
"$ref": "#/$defs/toolConfig"
},
"worktree_load": {
"$ref": "#/$defs/toolConfig"
}
}
},
Expand Down Expand Up @@ -393,7 +396,8 @@
"pr_load",
"pr_sync",
"ticket_sync",
"ticket_load"
"ticket_load",
"worktree_load"
]
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/core/commands/pr/fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ $ARGUMENTS

<%~ include("@load-pr", { config: it.config, ref: "<pr-ref>", result: "<pr-context>" }) %>

### Load Worktree Context

- Call `<%= it.config.tools.worktree_load.name %>` and store the result as `<worktree-context>`

### Align Local Branch

<%~ include("@align-pr-branch", { action: "analyzing repository files or making code changes for this PR", scope: "inspect or modify local code for this PR", requiresBranch: true }) -%>
<%~ include("@align-pr-branch", { config: it.config, action: "analyzing repository files or making code changes for this PR", scope: "inspect or modify local code for this PR", requiresBranch: true }) -%>

### Load Changes

Expand Down
21 changes: 13 additions & 8 deletions packages/core/commands/pr/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ $ARGUMENTS

<%~ include("@load-pr", { config: it.config, ref: "<pr-ref>", result: "<pr-context>" }) %>

### Align Local Branch
### Load Worktree Context

<%~ include("@align-pr-branch", { action: "inspecting local repository files for this PR review", scope: "inspect local repository code for this PR", requiresBranch: false }) -%>
- Call `<%= it.config.tools.worktree_load.name %>` and store the result as `<worktree-context>`

### Align Review Context

<%~ include("@align-pr-branch", { config: it.config, action: "inspecting local repository files for this PR review", scope: "inspect local repository code for this PR", requiresBranch: false }) -%>

### Load Ticket Context

Expand All @@ -43,18 +47,19 @@ Call `<%= it.config.tools.changes_load.name %>` with `base: <pr-context.pr.baseR

Following the reviewer agent guidance:
1. Check `<pr-context.reviews>`, `<pr-context.issueComments>`, and `<pr-context.threads>`
2. Use `<active-branch>` whenever local repository files need to be inspected alongside the diff
3. Derive `<author-decisions>` from `<pr-context.issueComments>` and `<pr-context.threads>`:
2. Use `<changes>` as the source of truth for the PR head diff
3. Inspect local repository files only when `<current-head>` equals `<pr-context.pr.headRefOid>` after review alignment; otherwise rely on `<changes>` and avoid treating local checkout files as PR-head files
4. Derive `<author-decisions>` from `<pr-context.issueComments>` and `<pr-context.threads>`:
- Include direct author replies that explicitly decline, defer, or intentionally narrow a suggestion and explain why they do not plan to implement it
- Treat each matching author reply as higher priority than `<ticket-context>` for that same concern, unless the current diff introduces a materially different defect with a concrete failure mode
- Do not re-raise the same concern solely because `<ticket-context>` still implies a broader scope
4. Derive `<settled-threads>` from `<pr-context.threads>`:
5. Derive `<settled-threads>` from `<pr-context.threads>`:
- Treat resolved threads as settled
- Treat threads as settled when they already contain feedback from `<pr-context.viewerLogin>` and a later reply makes it clear the concern was intentionally declined, deferred, or answered without a code change request
- Treat threads as settled when the author's reply directly answers the concern and the current diff does not add a materially different failure mode
5. Derive `<prior-review-baseline>` from `<pr-context.reviews>` authored by `<pr-context.viewerLogin>`
6. Use diff hunks in `<changes>` to map inline comments to the correct lines
7. Derive `<eligible-findings>` as findings that are:
6. Derive `<prior-review-baseline>` from `<pr-context.reviews>` authored by `<pr-context.viewerLogin>`
7. Use diff hunks in `<changes>` to map inline comments to the correct lines
8. Derive `<eligible-findings>` as findings that are:
- new in this diff
- from a previously unreviewed changed area
- clearly missed material defects with a concrete failure mode
Expand Down
21 changes: 14 additions & 7 deletions packages/core/components/align-pr-branch.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
- If `<pr-branch>` is unavailable, STOP and report that the PR head branch could not be determined
- Store the local branch as `<current-branch>` from `<worktree-context>.branch` when it is available
- Store the local HEAD commit as `<current-head>` from `<worktree-context>.headOid` when it is available
<% if (it.requiresBranch) { -%>
- If `<current-branch>` equals `<pr-branch>` and `<current-head>` equals `<pr-context.pr.headRefOid>`, store `<current-branch>` as `<active-branch>` and do not checkout again
- If `<current-branch>` differs from `<pr-branch>` or `<current-head>` differs from `<pr-context.pr.headRefOid>`:
- Run `gh pr checkout <pr-context.pr.number>` before <%= it.action %>
- After checkout, store the active branch as `<active-branch>`
- Run `git rev-parse HEAD` again and store the trimmed result as `<current-head>`
- Call `<%= it.config.tools.worktree_load.name %>` again and store the result as `<worktree-context>`
- Store the local branch as `<current-branch>` from `<worktree-context>.branch` when it is available
- Store the local HEAD commit as `<current-head>` from `<worktree-context>.headOid` when it is available
- Store `<current-branch>` as `<active-branch>` when it is available
- If checkout fails or times out, STOP and report that the PR branch could not be checked out locally; do not retry checkout unless the user explicitly asks
- Do not <%= it.scope %> until `<active-branch>` equals `<pr-branch>` and `<current-head>` equals `<pr-context.pr.headRefOid>`
<% } else { -%>
- If `<current-head>` equals `<pr-context.pr.headRefOid>`, store `<current-branch>` as `<active-branch>` when `<current-branch>` is available; otherwise store `<current-head>` as `<active-branch>`. Do not checkout because the worktree is already at the PR head commit.
- If `<current-head>` differs from `<pr-context.pr.headRefOid>`:
- If `<current-branch>` equals `<pr-branch>` and `<current-head>` equals `<pr-context.pr.headRefOid>`, store `<current-branch>` as `<active-branch>` and do not checkout again
- If `<current-branch>` is available and (`<current-branch>` differs from `<pr-branch>` or `<current-head>` differs from `<pr-context.pr.headRefOid>`):
- Run `gh pr checkout <pr-context.pr.number>` before <%= it.action %>
- After checkout, store the active branch as `<active-branch>`
- Run `git rev-parse HEAD` again and store the trimmed result as `<current-head>`
- Call `<%= it.config.tools.worktree_load.name %>` again and store the result as `<worktree-context>`
- Store the local branch as `<current-branch>` from `<worktree-context>.branch` when it is available
- Store the local HEAD commit as `<current-head>` from `<worktree-context>.headOid` when it is available
- Store `<current-branch>` as `<active-branch>` when it is available
- If checkout fails or times out, STOP and report that the PR branch could not be checked out locally; do not retry checkout unless the user explicitly asks
- Do not <%= it.scope %> until `<current-head>` equals `<pr-context.pr.headRefOid>`
- If `<current-branch>` is unavailable, store `<pr-context.pr.headRefOid>` as `<active-branch>` when it is available; otherwise store `<pr-branch>` as `<active-branch>`. Do not checkout from detached HEAD for read-only PR review.
- Do not <%= it.scope %> from local files unless `<current-head>` equals `<pr-context.pr.headRefOid>`; use `<active-branch>` and `<changes>` as the source of truth for the PR head diff when local HEAD differs
<% } -%>
2 changes: 0 additions & 2 deletions packages/core/components/load-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
- Do not run separate git or GitHub commands just to discover the PR before calling `<%= it.config.tools.pr_load.name %>`
- Store the result as `<%= it.result %>`
- Store the PR head branch as `<pr-branch>` from `<%= it.result %>.pr.headRefName` when it is available
- Run `git branch --show-current` and store the trimmed result as `<current-branch>` when it is available
- Run `git rev-parse HEAD` and store the trimmed result as `<current-head>` when it is available
- Treat the loaded PR body, discussion, review history, and any attachments or linked artifacts returned by the loader as part of the source context
- Review attached images, screenshots, videos, PDFs, and other linked files whenever they can affect the requested fix, review outcome, reproduction steps, or acceptance criteria
- If any relevant attachment cannot be accessed, note that gap and continue only when the remaining PR context is still sufficient to proceed reliably
1 change: 1 addition & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export { createPrLoadTool } from "./tools/pr-load.ts";
export { createPrSyncTool } from "./tools/pr-sync.ts";
export { createTicketLoadTool } from "./tools/ticket-load.ts";
export { createTicketSyncTool } from "./tools/ticket-sync.ts";
export { createWorktreeLoadTool } from "./tools/worktree-load.ts";
export type {
Shell,
ShellPromise,
Expand Down
1 change: 1 addition & 0 deletions packages/core/kompass.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"pr_sync": { "enabled": true },
"ticket_load": { "enabled": true },
"ticket_sync": { "enabled": true },
"worktree_load": { "enabled": true },
},

"components": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const DEFAULT_TOOL_NAMES = [
"pr_sync",
"ticket_sync",
"ticket_load",
"worktree_load",
] as const;

export const DEFAULT_COMMAND_NAMES = [
Expand Down Expand Up @@ -130,6 +131,7 @@ export interface KompassConfig {
pr_sync?: ToolConfig;
ticket_sync?: ToolConfig;
ticket_load?: ToolConfig;
worktree_load?: ToolConfig;
};
components?: {
"align-pr-branch"?: ComponentConfig;
Expand Down Expand Up @@ -180,6 +182,7 @@ export interface MergedKompassConfig {
pr_sync: ToolConfig;
ticket_sync: ToolConfig;
ticket_load: ToolConfig;
worktree_load: ToolConfig;
};
components: {
enabled: string[];
Expand Down Expand Up @@ -465,6 +468,7 @@ const defaultToolConfig: Record<ToolName, ToolConfig> = {
pr_sync: { enabled: true },
ticket_sync: { enabled: true },
ticket_load: { enabled: true },
worktree_load: { enabled: true },
};

function getToggleEntry<T extends ToggleConfig>(
Expand Down Expand Up @@ -616,6 +620,7 @@ export function mergeWithDefaults(
pr_sync: { ...defaultToolConfig.pr_sync, ...config?.tools?.pr_sync },
ticket_sync: { ...defaultToolConfig.ticket_sync, ...config?.tools?.ticket_sync },
ticket_load: { ...defaultToolConfig.ticket_load, ...config?.tools?.ticket_load },
worktree_load: { ...defaultToolConfig.worktree_load, ...config?.tools?.worktree_load },
},
components: {
enabled: getEnabledNames(
Expand Down
13 changes: 9 additions & 4 deletions packages/core/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,27 @@ describe("resolveCommands", () => {
assert.deepEqual(commands["pr/review"]?.config, { enabled: true });
});

test("skips PR checkout when pr/review is already at PR head", async () => {
test("pr/review aligns named local branches but not detached head", async () => {
const commands = await resolveCommands(process.cwd());
const template = commands["pr/review"]?.template ?? "";

assert.match(template, /git rev-parse HEAD/);
assert.match(template, /`<current-head>` equals `<pr-context\.pr\.headRefOid>`/);
assert.match(template, /### Load Worktree Context/);
assert.match(template, /Call `worktree_load` and store the result/);
assert.match(template, /Run `gh pr checkout <pr-context\.pr\.number>`/);
assert.match(template, /do not retry checkout unless the user explicitly asks/);
assert.match(template, /Do not checkout from detached HEAD for read-only PR review/);
assert.match(template, /Inspect local repository files only when `<current-head>` equals `<pr-context\.pr\.headRefOid>`/);
});

test("requires PR branch for pr/fix checkout alignment", async () => {
const commands = await resolveCommands(process.cwd());
const template = commands["pr/fix"]?.template ?? "";

assert.match(template, /### Load Worktree Context/);
assert.match(template, /Call `worktree_load` and store the result/);
assert.match(template, /`<current-branch>` equals `<pr-branch>` and `<current-head>` equals `<pr-context\.pr\.headRefOid>`/);
assert.match(template, /`<current-branch>` differs from `<pr-branch>` or `<current-head>` differs from `<pr-context\.pr\.headRefOid>`/);
assert.match(template, /Call `worktree_load` again/);
assert.match(template, /Store `<current-branch>` as `<active-branch>` when it is available/);
assert.match(template, /Do not inspect or modify local code for this PR until `<active-branch>` equals `<pr-branch>` and `<current-head>` equals `<pr-context\.pr\.headRefOid>`/);
});
});
69 changes: 69 additions & 0 deletions packages/core/test/worktree-load.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";

import { createToolContextForDirectory } from "../scripts/_tool-runner.ts";
import type { Shell, ShellPromise } from "../tools/shared.ts";
import { createWorktreeLoadTool } from "../tools/worktree-load.ts";

describe("worktree_load", () => {
test("loads branch and head state", async () => {
const tool = createWorktreeLoadTool(createMockShell([
{ contains: "git branch --show-current", stdout: "feature\n" },
{ contains: "git rev-parse HEAD", stdout: "abc123\n" },
]));

const output = await tool.execute({}, createToolContextForDirectory("/tmp/repo"));

assert.deepEqual(JSON.parse(output), {
branch: "feature",
headOid: "abc123",
isDetached: false,
});
});

test("reports detached head", async () => {
const tool = createWorktreeLoadTool(createMockShell([
{ contains: "git branch --show-current", stdout: "" },
{ contains: "git rev-parse HEAD", stdout: "merge123\n" },
]));

const output = await tool.execute({}, createToolContextForDirectory("/tmp/repo"));

assert.deepEqual(JSON.parse(output), {
headOid: "merge123",
isDetached: true,
});
});
});

function createMockShell(
fixtures: Array<{ contains: string; stdout: string; stderr?: string; exitCode?: number }>,
): Shell {
return (strings: TemplateStringsArray, ...expressions: unknown[]) => {
let command = strings[0] ?? "";
expressions.forEach((expression, index) => {
command += String(expression) + (strings[index + 1] ?? "");
});

const fixture = fixtures.find((item) => command.includes(item.contains));
if (!fixture) throw new Error(`Unhandled command: ${command}`);

return createShellPromise({
stdout: fixture.stdout,
stderr: fixture.stderr ?? "",
exitCode: fixture.exitCode ?? 0,
});
};
}

function createShellPromise(result: { stdout: string; stderr: string; exitCode: number }): ShellPromise {
return {
cwd() { return this; },
quiet() { return this; },
nothrow() { return this; },
text() { return result.stdout; },
json() { return JSON.parse(result.stdout); },
exitCode: result.exitCode,
stderr: Buffer.from(result.stderr),
};
}
2 changes: 2 additions & 0 deletions packages/core/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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 { createWorktreeLoadTool } from "./worktree-load.ts";
import type { Shell, ToolDefinition } from "./shared.ts";

const toolCreators: Record<string, ($: Shell, projectRoot: string) => ToolDefinition> = {
Expand All @@ -19,6 +20,7 @@ const toolCreators: Record<string, ($: Shell, projectRoot: string) => ToolDefini
pr_sync: ($) => createPrSyncTool($),
ticket_sync: ($) => createTicketSyncTool($),
ticket_load: ($) => createTicketLoadTool($),
worktree_load: ($) => createWorktreeLoadTool($),
};

export async function createTools($: Shell, projectRoot: string) {
Expand Down
25 changes: 25 additions & 0 deletions packages/core/tools/worktree-load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {
stringifyJson,
type Shell,
type ToolDefinition,
type ToolExecutionContext,
} from "./shared.ts";

export function createWorktreeLoadTool($: Shell) {
return {
description: "Load local git worktree state",
args: {},
async execute(_args: Record<string, never>, ctx: ToolExecutionContext) {
const branchProc = await $`git branch --show-current`.cwd(ctx.worktree).quiet().nothrow();
const headProc = await $`git rev-parse HEAD`.cwd(ctx.worktree).quiet().nothrow();
const branch = branchProc.exitCode === 0 ? branchProc.text().trim() : "";
const headOid = headProc.exitCode === 0 ? headProc.text().trim() : "";

return stringifyJson({
...(branch ? { branch } : {}),
...(headOid ? { headOid } : {}),
...(headOid ? { isDetached: !branch } : {}),
});
},
} satisfies ToolDefinition<Record<string, never>>;
}
Loading
Loading