pi-git-delegate 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { loadGitDelegateConfig } from "./config.ts";
3
+ import {
4
+ buildConfigureHelp,
5
+ formatGitDelegateConfig,
6
+ promptForGitDelegateConfig,
7
+ resolveWritableSettingsPath,
8
+ writeGitDelegateSettings,
9
+ } from "./settings-help.ts";
10
+
11
+ export const GIT_DELEGATE_COMMANDS = [
12
+ { name: "git-delegate:configure", description: "Help set up pi-git-delegate models in .pi/settings.json" },
13
+ { name: "git-delegate:status", description: "Show current pi-git-delegate model routing settings" },
14
+ ] as const;
15
+
16
+ export function registerGitDelegateCommands(pi: ExtensionAPI): void {
17
+ for (const command of GIT_DELEGATE_COMMANDS) {
18
+ pi.registerCommand(command.name, {
19
+ description: command.description,
20
+ handler: async (_args, ctx) => {
21
+ if (command.name === "git-delegate:status") {
22
+ const config = loadGitDelegateConfig(ctx.cwd);
23
+ const message = buildConfigureHelp(ctx.cwd, config);
24
+ ctx.ui.notify(message, "info");
25
+ return;
26
+ }
27
+
28
+ const config = loadGitDelegateConfig(ctx.cwd);
29
+ ctx.ui.notify(buildConfigureHelp(ctx.cwd, config), "info");
30
+
31
+ if (!ctx.hasUI) {
32
+ ctx.ui.notify("Interactive save requires UI support. Copy the JSON above into .pi/settings.json.", "warning");
33
+ return;
34
+ }
35
+
36
+ const target = resolveWritableSettingsPath(ctx.cwd);
37
+ const nextConfig = await promptForGitDelegateConfig(ctx.ui, config ?? undefined);
38
+ if (!nextConfig) {
39
+ ctx.ui.notify("Settings not saved.", "info");
40
+ return;
41
+ }
42
+
43
+ const save = await ctx.ui.confirm(
44
+ "Save pi-git-delegate settings?",
45
+ `${target.path}\n\n${formatGitDelegateConfig(nextConfig)}`,
46
+ );
47
+ if (!save) {
48
+ ctx.ui.notify("Settings not saved.", "info");
49
+ return;
50
+ }
51
+
52
+ writeGitDelegateSettings(target.path, nextConfig);
53
+ ctx.ui.notify(`Saved pi-git-delegate settings to ${target.path}`, "info");
54
+ },
55
+ });
56
+ }
57
+ }
@@ -0,0 +1,77 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { executeGitBlameSummary } from "./tools/git-blame-summary.ts";
4
+ import { executeGitDiffSummary } from "./tools/git-diff-summary.ts";
5
+ import { executeGitLogSummary } from "./tools/git-log-summary.ts";
6
+
7
+ const modelOverrideParameters = {
8
+ provider: Type.Optional(Type.String({ description: "Override provider for this call." })),
9
+ model: Type.Optional(Type.String({ description: "Override model for this call." })),
10
+ };
11
+
12
+ const gitDiffSummaryParameters = Type.Object({
13
+ ref: Type.Optional(Type.String({ description: 'Git ref to diff against (default: "HEAD").' })),
14
+ ...modelOverrideParameters,
15
+ });
16
+
17
+ const gitLogSummaryParameters = Type.Object({
18
+ range: Type.Optional(Type.String({ description: 'Git log range (default: "HEAD~10..HEAD").' })),
19
+ ...modelOverrideParameters,
20
+ });
21
+
22
+ const gitBlameSummaryParameters = Type.Object({
23
+ path: Type.String({ description: "File path to blame." }),
24
+ ref: Type.Optional(Type.String({ description: 'Git ref (default: "HEAD").' })),
25
+ ...modelOverrideParameters,
26
+ });
27
+
28
+ export const GIT_DELEGATE_TOOL_NAMES = [
29
+ "git_diff_summary",
30
+ "git_log_summary",
31
+ "git_blame_summary",
32
+ ] as const;
33
+
34
+ export function registerGitDelegateTools(pi: ExtensionAPI): void {
35
+ pi.registerTool({
36
+ name: "git_diff_summary",
37
+ label: "Git Diff Summary",
38
+ description: "Run git diff and delegate summarization to a subagent.",
39
+ promptSnippet: "git_diff_summary: summarize git diff via subagent",
40
+ promptGuidelines: [
41
+ "Use git_diff_summary when the user wants a concise summary of git diff output without loading raw diff into context.",
42
+ "Do not use this tool for write operations such as commit or push.",
43
+ ],
44
+ parameters: gitDiffSummaryParameters,
45
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
46
+ return executeGitDiffSummary(params, ctx, signal);
47
+ },
48
+ });
49
+
50
+ pi.registerTool({
51
+ name: "git_log_summary",
52
+ label: "Git Log Summary",
53
+ description: "Run git log and delegate digest generation to a subagent.",
54
+ promptSnippet: "git_log_summary: summarize recent commits via subagent",
55
+ promptGuidelines: [
56
+ "Use git_log_summary when the user wants a digest of recent commits without loading full log output into context.",
57
+ ],
58
+ parameters: gitLogSummaryParameters,
59
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
60
+ return executeGitLogSummary(params, ctx, signal);
61
+ },
62
+ });
63
+
64
+ pi.registerTool({
65
+ name: "git_blame_summary",
66
+ label: "Git Blame Summary",
67
+ description: "Run git blame and delegate contributor context to a subagent.",
68
+ promptSnippet: "git_blame_summary: summarize file blame history via subagent",
69
+ promptGuidelines: [
70
+ "Use git_blame_summary when the user asks who changed a file and wants a concise contributor summary.",
71
+ ],
72
+ parameters: gitBlameSummaryParameters,
73
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
74
+ return executeGitBlameSummary(params, ctx, signal);
75
+ },
76
+ });
77
+ }
package/lib/schema.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { Type, type TEnum, type TSchemaOptions } from "typebox";
2
+
3
+ export function StringEnum<const Values extends [string, ...string[]]>(
4
+ values: readonly [...Values],
5
+ options?: TSchemaOptions,
6
+ ): TEnum<Values> {
7
+ return Type.Enum([...values] as [string, ...string[]], options) as unknown as TEnum<Values>;
8
+ }
@@ -0,0 +1,176 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ DEFAULT_GIT_DELEGATE_CONFIG,
6
+ type GitDelegateConfig,
7
+ type GitDelegateToolKey,
8
+ type ModelRoute,
9
+ NULL_MODEL_ROUTE,
10
+ } from "./config.ts";
11
+
12
+ export const SETTINGS_KEY = "pi-git-delegate";
13
+
14
+ export interface SettingsLocation {
15
+ scope: "project" | "agent";
16
+ path: string;
17
+ exists: boolean;
18
+ }
19
+
20
+ const TOOL_LABELS: Record<GitDelegateToolKey, string> = {
21
+ diff: "git_diff_summary",
22
+ log: "git_log_summary",
23
+ blame: "git_blame_summary",
24
+ };
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ export function getProjectSettingsPath(cwd: string): string {
31
+ return join(cwd, ".pi", "settings.json");
32
+ }
33
+
34
+ export function getAgentSettingsPath(): string {
35
+ return join(getAgentDir(), "settings.json");
36
+ }
37
+
38
+ export function resolveWritableSettingsPath(cwd: string): SettingsLocation {
39
+ const projectPath = getProjectSettingsPath(cwd);
40
+ if (existsSync(projectPath)) {
41
+ return { scope: "project", path: projectPath, exists: true };
42
+ }
43
+
44
+ const agentPath = getAgentSettingsPath();
45
+ if (existsSync(agentPath)) {
46
+ return { scope: "agent", path: agentPath, exists: true };
47
+ }
48
+
49
+ return { scope: "project", path: projectPath, exists: false };
50
+ }
51
+
52
+ export function findActiveSettingsLocation(cwd: string): SettingsLocation | undefined {
53
+ const projectPath = getProjectSettingsPath(cwd);
54
+ if (existsSync(projectPath)) {
55
+ return { scope: "project", path: projectPath, exists: true };
56
+ }
57
+
58
+ const agentPath = getAgentSettingsPath();
59
+ if (existsSync(agentPath)) {
60
+ return { scope: "agent", path: agentPath, exists: true };
61
+ }
62
+
63
+ return undefined;
64
+ }
65
+
66
+ export function readSettingsObject(filePath: string): Record<string, unknown> {
67
+ if (!existsSync(filePath)) return {};
68
+ try {
69
+ const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
70
+ return isRecord(parsed) ? parsed : {};
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+
76
+ export function formatGitDelegateConfig(config: GitDelegateConfig = DEFAULT_GIT_DELEGATE_CONFIG): string {
77
+ const payload = {
78
+ [SETTINGS_KEY]: config,
79
+ };
80
+ return JSON.stringify(payload, null, 2);
81
+ }
82
+
83
+ export function formatRouteSummary(toolKey: GitDelegateToolKey, route: ModelRoute): string {
84
+ const provider = route.provider ?? "null";
85
+ const model = route.model ?? "null";
86
+ return `${TOOL_LABELS[toolKey]}: provider=${provider}, model=${model}`;
87
+ }
88
+
89
+ export function buildConfigureHelp(cwd: string, config: GitDelegateConfig | undefined): string {
90
+ const active = findActiveSettingsLocation(cwd);
91
+ const writeTarget = resolveWritableSettingsPath(cwd);
92
+ const resolved = config ?? DEFAULT_GIT_DELEGATE_CONFIG;
93
+ const lines = [
94
+ "Pi Git Delegate settings",
95
+ "",
96
+ "Add a pi-git-delegate block to .pi/settings.json to route each tool to a subagent model.",
97
+ "",
98
+ "Per tool, set:",
99
+ " provider -> LLM provider (null = session provider)",
100
+ " model -> model id (null = session model)",
101
+ "",
102
+ "Priority: tool parameter provider/model > settings.json > current session",
103
+ "",
104
+ `Active settings: ${active ? `${active.scope} (${active.path})` : "none"}`,
105
+ `Default write target: ${writeTarget.scope} (${writeTarget.path})`,
106
+ "",
107
+ "Current resolved routes:",
108
+ formatRouteSummary("diff", resolved.diff),
109
+ formatRouteSummary("log", resolved.log),
110
+ formatRouteSummary("blame", resolved.blame),
111
+ "",
112
+ "Starter config (null = use session defaults):",
113
+ formatGitDelegateConfig(DEFAULT_GIT_DELEGATE_CONFIG),
114
+ "",
115
+ "Run /git-delegate:configure in Pi to save this interactively.",
116
+ ];
117
+
118
+ return lines.join("\n");
119
+ }
120
+
121
+ export function mergeGitDelegateSettings(
122
+ filePath: string,
123
+ config: GitDelegateConfig,
124
+ ): Record<string, unknown> {
125
+ const settings = readSettingsObject(filePath);
126
+ settings[SETTINGS_KEY] = config;
127
+ return settings;
128
+ }
129
+
130
+ export function writeGitDelegateSettings(filePath: string, config: GitDelegateConfig): void {
131
+ mkdirSync(dirname(filePath), { recursive: true });
132
+ const merged = mergeGitDelegateSettings(filePath, config);
133
+ writeFileSync(filePath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
134
+ }
135
+
136
+ async function promptForRoute(
137
+ ui: {
138
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
139
+ },
140
+ toolKey: GitDelegateToolKey,
141
+ current: ModelRoute,
142
+ ): Promise<ModelRoute> {
143
+ const provider = (await ui.input(`${TOOL_LABELS[toolKey]} provider (empty = null)`, current.provider ?? ""))?.trim();
144
+ const model = (await ui.input(`${TOOL_LABELS[toolKey]} model (empty = null)`, current.model ?? ""))?.trim();
145
+
146
+ return {
147
+ provider: provider ? provider : null,
148
+ model: model ? model : null,
149
+ };
150
+ }
151
+
152
+ export async function promptForGitDelegateConfig(
153
+ ui: {
154
+ confirm: (title: string, message: string) => Promise<boolean>;
155
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
156
+ },
157
+ defaults: GitDelegateConfig = DEFAULT_GIT_DELEGATE_CONFIG,
158
+ ): Promise<GitDelegateConfig | undefined> {
159
+ const useNullDefaults = await ui.confirm(
160
+ "Use null defaults?",
161
+ "Save provider=null and model=null for all tools so subagents use the current session provider/model.",
162
+ );
163
+ if (useNullDefaults) {
164
+ return {
165
+ diff: { ...NULL_MODEL_ROUTE },
166
+ log: { ...NULL_MODEL_ROUTE },
167
+ blame: { ...NULL_MODEL_ROUTE },
168
+ };
169
+ }
170
+
171
+ return {
172
+ diff: await promptForRoute(ui, "diff", defaults.diff),
173
+ log: await promptForRoute(ui, "log", defaults.log),
174
+ blame: await promptForRoute(ui, "blame", defaults.blame),
175
+ };
176
+ }
@@ -0,0 +1,148 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { Message } from "@earendil-works/pi-ai";
5
+
6
+ export interface SubagentRequest {
7
+ cwd: string;
8
+ prompt: string;
9
+ provider?: string;
10
+ model?: string;
11
+ signal?: AbortSignal;
12
+ }
13
+
14
+ export interface SubagentResult {
15
+ exitCode: number;
16
+ outputText: string;
17
+ stderr: string;
18
+ }
19
+
20
+ export type SubagentRunner = (request: SubagentRequest) => Promise<SubagentResult>;
21
+
22
+ let runnerOverride: SubagentRunner | undefined;
23
+
24
+ export function setSubagentRunnerForTests(runner: SubagentRunner | undefined): void {
25
+ runnerOverride = runner;
26
+ }
27
+
28
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
29
+ const currentScript = process.argv[1];
30
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
31
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
32
+ return { command: process.execPath, args: [currentScript, ...args] };
33
+ }
34
+
35
+ const execName = path.basename(process.execPath).toLowerCase();
36
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
37
+ if (!isGenericRuntime) {
38
+ return { command: process.execPath, args };
39
+ }
40
+
41
+ return { command: "pi", args };
42
+ }
43
+
44
+ function getFinalOutput(messages: Message[]): string {
45
+ const assistantTexts = messages
46
+ .filter((message): message is Extract<Message, { role: "assistant" }> => message.role === "assistant")
47
+ .flatMap((message) =>
48
+ message.content
49
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
50
+ .map((block) => block.text),
51
+ );
52
+
53
+ return assistantTexts.join("\n").trim();
54
+ }
55
+
56
+ export async function runSubagent(request: SubagentRequest): Promise<SubagentResult> {
57
+ if (runnerOverride) {
58
+ return runnerOverride(request);
59
+ }
60
+
61
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
62
+ if (request.provider) {
63
+ args.push("--provider", request.provider);
64
+ }
65
+ if (request.model) {
66
+ args.push("--model", request.model);
67
+ }
68
+ args.push(request.prompt);
69
+
70
+ let stderr = "";
71
+ const messages: Message[] = [];
72
+
73
+ const exitCode = await new Promise<number>((resolve) => {
74
+ const invocation = getPiInvocation(args);
75
+ const proc = spawn(invocation.command, invocation.args, {
76
+ cwd: request.cwd,
77
+ shell: false,
78
+ stdio: ["ignore", "pipe", "pipe"],
79
+ });
80
+
81
+ let buffer = "";
82
+
83
+ const processLine = (line: string) => {
84
+ if (!line.trim()) return;
85
+ let event: { type?: string; message?: Message };
86
+ try {
87
+ event = JSON.parse(line) as { type?: string; message?: Message };
88
+ } catch {
89
+ return;
90
+ }
91
+
92
+ if (event.type === "message_end" && event.message) {
93
+ messages.push(event.message);
94
+ }
95
+ };
96
+
97
+ proc.stdout.on("data", (data) => {
98
+ buffer += data.toString();
99
+ const lines = buffer.split("\n");
100
+ buffer = lines.pop() || "";
101
+ for (const line of lines) processLine(line);
102
+ });
103
+
104
+ proc.stderr.on("data", (data) => {
105
+ stderr += data.toString();
106
+ });
107
+
108
+ proc.on("close", (code) => {
109
+ if (buffer.trim()) processLine(buffer);
110
+ resolve(code ?? 0);
111
+ });
112
+
113
+ proc.on("error", (error) => {
114
+ stderr += error.message;
115
+ resolve(1);
116
+ });
117
+
118
+ if (request.signal) {
119
+ const kill = () => {
120
+ proc.kill("SIGTERM");
121
+ setTimeout(() => {
122
+ if (!proc.killed) proc.kill("SIGKILL");
123
+ }, 5000);
124
+ };
125
+ if (request.signal.aborted) kill();
126
+ else request.signal.addEventListener("abort", kill, { once: true });
127
+ }
128
+ });
129
+
130
+ if (exitCode !== 0 && !getFinalOutput(messages)) {
131
+ const message = stderr.trim() || "Failed to run subagent. Ensure `pi` is available in PATH.";
132
+ return { exitCode, outputText: "", stderr: message };
133
+ }
134
+
135
+ return {
136
+ exitCode,
137
+ outputText: getFinalOutput(messages),
138
+ stderr,
139
+ };
140
+ }
141
+
142
+ export function createEchoSubagentRunner(summaryText = "Delegated git summary."): SubagentRunner {
143
+ return async () => ({
144
+ exitCode: 0,
145
+ outputText: summaryText,
146
+ stderr: "",
147
+ });
148
+ }
@@ -0,0 +1,64 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadGitDelegateConfig, resolveSubagentRoute } from "../config.ts";
3
+ import { runGit } from "../git-exec.ts";
4
+ import { buildSubagentPrompt, BLAME_SUMMARY_PROMPT } from "../prompts.ts";
5
+ import { runSubagent } from "../subagent-runner.ts";
6
+
7
+ export interface GitBlameSummaryParams {
8
+ path: string;
9
+ ref?: string;
10
+ provider?: string;
11
+ model?: string;
12
+ }
13
+
14
+ export async function executeGitBlameSummary(
15
+ params: GitBlameSummaryParams,
16
+ ctx: ExtensionContext,
17
+ signal?: AbortSignal,
18
+ ) {
19
+ const filePath = params.path?.trim();
20
+ if (!filePath) {
21
+ return textResult("path is required.", { error: true });
22
+ }
23
+
24
+ const ref = params.ref?.trim() || "HEAD";
25
+ const config = loadGitDelegateConfig(ctx.cwd);
26
+ const route = resolveSubagentRoute("git_blame_summary", config, {
27
+ provider: params.provider,
28
+ model: params.model,
29
+ });
30
+
31
+ const gitResult = runGit(["blame", ref, "--", filePath], ctx.cwd);
32
+ if (gitResult.status !== 0) {
33
+ const error = gitResult.stderr || gitResult.stdout || `git blame failed with exit code ${gitResult.status}`;
34
+ return textResult(error, { path: filePath, ref, error: true });
35
+ }
36
+
37
+ if (!gitResult.stdout) {
38
+ return textResult(`No blame data found for ${filePath}.`, { path: filePath, ref, empty: true });
39
+ }
40
+
41
+ const subagent = await runSubagent({
42
+ cwd: ctx.cwd,
43
+ prompt: buildSubagentPrompt(BLAME_SUMMARY_PROMPT, gitResult.stdout),
44
+ provider: route?.provider,
45
+ model: route?.model,
46
+ signal,
47
+ });
48
+
49
+ if (!subagent.outputText) {
50
+ const error = subagent.stderr || "Subagent returned no summary.";
51
+ return textResult(error, { path: filePath, ref, error: true });
52
+ }
53
+
54
+ return textResult(subagent.outputText, {
55
+ path: filePath,
56
+ ref,
57
+ provider: route?.provider ?? null,
58
+ model: route?.model ?? null,
59
+ });
60
+ }
61
+
62
+ function textResult(text: string, details: Record<string, unknown> = {}) {
63
+ return { content: [{ type: "text" as const, text }], details };
64
+ }
@@ -0,0 +1,57 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadGitDelegateConfig, resolveSubagentRoute } from "../config.ts";
3
+ import { runGit } from "../git-exec.ts";
4
+ import { buildSubagentPrompt, DIFF_SUMMARY_PROMPT } from "../prompts.ts";
5
+ import { runSubagent } from "../subagent-runner.ts";
6
+
7
+ export interface GitDiffSummaryParams {
8
+ ref?: string;
9
+ provider?: string;
10
+ model?: string;
11
+ }
12
+
13
+ export async function executeGitDiffSummary(
14
+ params: GitDiffSummaryParams,
15
+ ctx: ExtensionContext,
16
+ signal?: AbortSignal,
17
+ ) {
18
+ const ref = params.ref?.trim() || "HEAD";
19
+ const config = loadGitDelegateConfig(ctx.cwd);
20
+ const route = resolveSubagentRoute("git_diff_summary", config, {
21
+ provider: params.provider,
22
+ model: params.model,
23
+ });
24
+
25
+ const gitResult = runGit(["diff", ref], ctx.cwd);
26
+ if (gitResult.status !== 0) {
27
+ const error = gitResult.stderr || gitResult.stdout || `git diff failed with exit code ${gitResult.status}`;
28
+ return textResult(error, { ref, error: true });
29
+ }
30
+
31
+ if (!gitResult.stdout) {
32
+ return textResult("No changes found.", { ref, empty: true });
33
+ }
34
+
35
+ const subagent = await runSubagent({
36
+ cwd: ctx.cwd,
37
+ prompt: buildSubagentPrompt(DIFF_SUMMARY_PROMPT, gitResult.stdout),
38
+ provider: route?.provider,
39
+ model: route?.model,
40
+ signal,
41
+ });
42
+
43
+ if (!subagent.outputText) {
44
+ const error = subagent.stderr || "Subagent returned no summary.";
45
+ return textResult(error, { ref, error: true });
46
+ }
47
+
48
+ return textResult(subagent.outputText, {
49
+ ref,
50
+ provider: route?.provider ?? null,
51
+ model: route?.model ?? null,
52
+ });
53
+ }
54
+
55
+ function textResult(text: string, details: Record<string, unknown> = {}) {
56
+ return { content: [{ type: "text" as const, text }], details };
57
+ }
@@ -0,0 +1,64 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadGitDelegateConfig, resolveSubagentRoute } from "../config.ts";
3
+ import { runGit } from "../git-exec.ts";
4
+ import { buildSubagentPrompt, LOG_SUMMARY_PROMPT } from "../prompts.ts";
5
+ import { runSubagent } from "../subagent-runner.ts";
6
+
7
+ export interface GitLogSummaryParams {
8
+ range?: string;
9
+ provider?: string;
10
+ model?: string;
11
+ }
12
+
13
+ export async function executeGitLogSummary(
14
+ params: GitLogSummaryParams,
15
+ ctx: ExtensionContext,
16
+ signal?: AbortSignal,
17
+ ) {
18
+ const range = params.range?.trim() || "HEAD~10..HEAD";
19
+ const config = loadGitDelegateConfig(ctx.cwd);
20
+ const route = resolveSubagentRoute("git_log_summary", config, {
21
+ provider: params.provider,
22
+ model: params.model,
23
+ });
24
+
25
+ const gitResult = runGit(["log", "--oneline", range], ctx.cwd);
26
+ if (gitResult.status !== 0) {
27
+ if (isNoCommitsInRange(gitResult.stderr)) {
28
+ return textResult("No commits in range.", { range, empty: true });
29
+ }
30
+ const error = gitResult.stderr || gitResult.stdout || `git log failed with exit code ${gitResult.status}`;
31
+ return textResult(error, { range, error: true });
32
+ }
33
+
34
+ if (!gitResult.stdout) {
35
+ return textResult("No commits in range.", { range, empty: true });
36
+ }
37
+
38
+ const subagent = await runSubagent({
39
+ cwd: ctx.cwd,
40
+ prompt: buildSubagentPrompt(LOG_SUMMARY_PROMPT, gitResult.stdout),
41
+ provider: route?.provider,
42
+ model: route?.model,
43
+ signal,
44
+ });
45
+
46
+ if (!subagent.outputText) {
47
+ const error = subagent.stderr || "Subagent returned no summary.";
48
+ return textResult(error, { range, error: true });
49
+ }
50
+
51
+ return textResult(subagent.outputText, {
52
+ range,
53
+ provider: route?.provider ?? null,
54
+ model: route?.model ?? null,
55
+ });
56
+ }
57
+
58
+ function textResult(text: string, details: Record<string, unknown> = {}) {
59
+ return { content: [{ type: "text" as const, text }], details };
60
+ }
61
+
62
+ function isNoCommitsInRange(stderr: string): boolean {
63
+ return /does not have any commits|unknown revision|bad revision|needed single revision|ambiguous argument/i.test(stderr);
64
+ }