From b820fa25a2569ed82f6cbb6039c551d79b7f533b Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 01:14:08 -0800 Subject: [PATCH] feat: add auto-commit after task completion in Task Loop --- src-tauri/src/config.rs | 26 +++ src/lib/components/ConfigSidebar.svelte | 3 + src/lib/components/StatusBar.svelte | 3 + src/lib/components/TaskLoopPanel.svelte | 203 ++++++++++++++++++++++-- src/lib/stores/config.test.ts | 9 ++ src/lib/stores/config.ts | 7 + src/lib/stores/taskLoop.test.ts | 53 +++++++ src/lib/stores/taskLoop.ts | 28 ++++ 8 files changed, 317 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 0a6b8ef..dedae81 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -158,6 +158,16 @@ pub struct HikariConfig { #[serde(default)] pub custom_ui_font_family: Option, + + // Task Loop auto-commit settings + #[serde(default)] + pub task_loop_auto_commit: bool, + + #[serde(default = "default_task_loop_commit_prefix")] + pub task_loop_commit_prefix: String, + + #[serde(default)] + pub task_loop_include_summary: bool, } impl Default for HikariConfig { @@ -201,6 +211,9 @@ impl Default for HikariConfig { custom_font_family: None, custom_ui_font_path: None, custom_ui_font_family: None, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat".to_string(), + task_loop_include_summary: false, } } } @@ -241,6 +254,10 @@ fn default_background_image_opacity() -> f32 { 0.3 } +fn default_task_loop_commit_prefix() -> String { + "feat".to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "lowercase")] pub enum BudgetAction { @@ -332,6 +349,9 @@ mod tests { assert!(config.custom_font_family.is_none()); assert!(config.custom_ui_font_path.is_none()); assert!(config.custom_ui_font_family.is_none()); + assert!(!config.task_loop_auto_commit); + assert_eq!(config.task_loop_commit_prefix, "feat"); + assert!(!config.task_loop_include_summary); } #[test] @@ -375,6 +395,9 @@ mod tests { custom_font_family: Some("MyFont".to_string()), custom_ui_font_path: None, custom_ui_font_family: None, + task_loop_auto_commit: true, + task_loop_commit_prefix: "fix".to_string(), + task_loop_include_summary: true, }; let json = serde_json::to_string(&config).unwrap(); @@ -389,6 +412,9 @@ mod tests { deserialized.greeting_custom_prompt, Some("Hello!".to_string()) ); + assert!(deserialized.task_loop_auto_commit); + assert_eq!(deserialized.task_loop_commit_prefix, "fix"); + assert!(deserialized.task_loop_include_summary); } #[test] diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 4be0c1e..8a9474b 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -66,6 +66,9 @@ custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }); let showCustomThemeEditor = $state(false); diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index eb7bb9c..77485ea 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -84,6 +84,9 @@ custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }); let streamerModeActive = $state(false); diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index 6a131e7..29bb6e0 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -8,6 +8,7 @@ computeWaves, isTaskBlocked, buildTaskPrompt, + buildAutoCommitPrompt, normalizeToUnixPath, type TaskLoopTask, } from "$lib/stores/taskLoop"; @@ -28,13 +29,17 @@ const sourceFile = $derived(taskLoopStore.sourceFile); const conversations = $derived(claudeStore.conversations); const concurrencyLimit = $derived(taskLoopStore.concurrencyLimit); + const config = $derived(configStore.config); // Per-task orchestration phases (panel-local, not persisted) - type LoopPhase = "waiting_for_connection" | "waiting_for_completion"; + type LoopPhase = "waiting_for_connection" | "waiting_for_completion" | "waiting_for_auto_commit"; let activePhases = $state>({}); let taskEverStartedMap = $state>({}); + let commitEverStartedMap = $state>({}); let isLoading = $state(false); let errorMessage = $state(null); + let sessionTimestamp = $state(""); + let showSettings = $state(false); const completedCount = $derived($tasks.filter((t) => t.status === "completed").length); const failedCount = $derived($tasks.filter((t) => t.status === "failed").length); @@ -71,11 +76,34 @@ taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: true }; } if (taskEverStartedMap[taskIdx] && conv.characterState === "idle") { + taskEverStartedMap = Object.fromEntries( + Object.entries(taskEverStartedMap).filter(([k]) => Number(k) !== taskIdx) + ); + + const autoCommit = get(configStore.config).task_loop_auto_commit; + if (autoCommit) { + activePhases = { ...activePhases, [taskIdx]: "waiting_for_auto_commit" }; + commitEverStartedMap = { ...commitEverStartedMap, [taskIdx]: false }; + void sendAutoCommitPrompt(currentTask, taskIdx); + } else { + activePhases = Object.fromEntries( + Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) + ); + void onTaskCompleted(taskIdx, "completed"); + } + } + } + + if (phase === "waiting_for_auto_commit") { + if (workingStates.includes(conv.characterState)) { + commitEverStartedMap = { ...commitEverStartedMap, [taskIdx]: true }; + } + if (commitEverStartedMap[taskIdx] && conv.characterState === "idle") { activePhases = Object.fromEntries( Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) ); - taskEverStartedMap = Object.fromEntries( - Object.entries(taskEverStartedMap).filter(([k]) => Number(k) !== taskIdx) + commitEverStartedMap = Object.fromEntries( + Object.entries(commitEverStartedMap).filter(([k]) => Number(k) !== taskIdx) ); void onTaskCompleted(taskIdx, "completed"); } @@ -99,6 +127,29 @@ } } + async function sendAutoCommitPrompt(task: TaskLoopTask, taskIdx: number): Promise { + const cfg = get(configStore.config); + const prompt = buildAutoCommitPrompt( + task, + cfg.task_loop_commit_prefix || "feat", + cfg.task_loop_include_summary, + sessionTimestamp + ); + try { + await invoke("send_prompt", { + conversationId: task.conversationId, + message: prompt, + }); + } catch (error) { + console.error("Failed to send auto-commit prompt:", error); + // Non-blocking: still mark task as completed even if commit prompt fails + activePhases = Object.fromEntries( + Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) + ); + void onTaskCompleted(taskIdx, "completed"); + } + } + async function onTaskCompleted(taskIdx: number, status: "completed" | "failed"): Promise { taskLoopStore.setTaskStatus(taskIdx, status); @@ -137,9 +188,9 @@ async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise { const task = taskList[taskIdx]; - const config = get(configStore.config); + const cfg = get(configStore.config); const allAllowedTools = [ - ...new Set([...get(claudeStore.grantedTools), ...(config.auto_granted_tools ?? [])]), + ...new Set([...get(claudeStore.grantedTools), ...(cfg.auto_granted_tools ?? [])]), ]; const filePath = get(taskLoopStore.sourceFile); @@ -159,14 +210,14 @@ conversationId, options: { working_dir: workingDir, - model: config.model ?? null, - api_key: config.api_key ?? null, - custom_instructions: (config.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM, - mcp_servers_json: config.mcp_servers_json ?? null, + model: cfg.model ?? null, + api_key: cfg.api_key ?? null, + custom_instructions: (cfg.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM, + mcp_servers_json: cfg.mcp_servers_json ?? null, allowed_tools: allAllowedTools, - use_worktree: config.use_worktree ?? false, - disable_1m_context: config.disable_1m_context ?? false, - max_output_tokens: config.max_output_tokens ?? null, + use_worktree: cfg.use_worktree ?? false, + disable_1m_context: cfg.disable_1m_context ?? false, + max_output_tokens: cfg.max_output_tokens ?? null, }, }); } catch (error) { @@ -203,6 +254,7 @@ const readyIndices = getReadyTasks(taskList, limit); if (readyIndices.length === 0) return; + sessionTimestamp = new Date().toISOString(); taskLoopStore.setLoopStatus("running"); await Promise.all(readyIndices.map((i) => startTask(i, taskList))); } @@ -241,13 +293,16 @@ activePhases = {}; taskEverStartedMap = {}; + commitEverStartedMap = {}; } function handleReset(): void { taskLoopStore.reset(); activePhases = {}; taskEverStartedMap = {}; + commitEverStartedMap = {}; errorMessage = null; + sessionTimestamp = ""; } function statusColour(status: TaskLoopTask["status"]): string { @@ -292,6 +347,20 @@ } const hasPendingTasks = $derived($tasks.some((t) => t.status === "pending")); + + async function toggleAutoCommit(): Promise { + await configStore.updateConfig({ task_loop_auto_commit: !$config.task_loop_auto_commit }); + } + + async function toggleIncludeSummary(): Promise { + await configStore.updateConfig({ + task_loop_include_summary: !$config.task_loop_include_summary, + }); + } + + async function updateCommitPrefix(value: string): Promise { + await configStore.updateConfig({ task_loop_commit_prefix: value }); + }
{/if} +
+ + {#if showSettings} +
+

+ Auto-commit Settings +

+ + + + + {#if $config.task_loop_auto_commit} + +
+ + updateCommitPrefix((e.target as HTMLInputElement).value)} + placeholder="feat" + class="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]" + /> + : task title +
+ + + + {/if} +
+ {/if} +
{#if isLoading} @@ -455,9 +622,15 @@ {task.priority} {#if task.status === "running"} - ● running + {#if activePhases[taskIdx] === "waiting_for_auto_commit"} + ● committing + {:else} + ● running + {/if} {:else if task.status === "blocked"} blocked diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index e2aee51..86744ce 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -217,6 +217,9 @@ describe("config store", () => { custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }; expect(config.model).toBe("claude-sonnet-4"); @@ -273,6 +276,9 @@ describe("config store", () => { custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }; expect(config.model).toBeNull(); @@ -884,6 +890,9 @@ describe("config store", () => { custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }; const mockInvokeImpl = vi.mocked(invoke); diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index ca824fe..0383d7c 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -77,6 +77,10 @@ export interface HikariConfig { // Custom UI font settings custom_ui_font_path: string | null; custom_ui_font_family: string | null; + // Task Loop auto-commit settings + task_loop_auto_commit: boolean; + task_loop_commit_prefix: string; + task_loop_include_summary: boolean; } const defaultConfig: HikariConfig = { @@ -127,6 +131,9 @@ const defaultConfig: HikariConfig = { custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }; function createConfigStore() { diff --git a/src/lib/stores/taskLoop.test.ts b/src/lib/stores/taskLoop.test.ts index 245aa23..66d91e3 100644 --- a/src/lib/stores/taskLoop.test.ts +++ b/src/lib/stores/taskLoop.test.ts @@ -3,6 +3,7 @@ import { findNextPendingIndex, countByStatus, buildTaskPrompt, + buildAutoCommitPrompt, normalizeToUnixPath, isTaskBlocked, getReadyTasks, @@ -104,6 +105,58 @@ describe("buildTaskPrompt", () => { }); }); +describe("buildAutoCommitPrompt", () => { + const task = makeTask("task-1"); + + it("includes the git add and commit commands", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("git add -A"); + expect(result).toContain("git commit -m"); + }); + + it("uses the provided prefix in the commit message", () => { + const result = buildAutoCommitPrompt(task, "fix", false, "2026-03-07T12:00:00"); + expect(result).toContain("fix:"); + }); + + it("includes the task title in the commit message", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("Task task-1"); + }); + + it("includes the task id in the commit body", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("task-1"); + }); + + it("includes the session timestamp in the commit body", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("2026-03-07T12:00:00"); + }); + + it("does not include SUMMARY.md instructions when includeSummary is false", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).not.toContain("SUMMARY.md"); + }); + + it("includes SUMMARY.md instructions when includeSummary is true", () => { + const result = buildAutoCommitPrompt(task, "feat", true, "2026-03-07T12:00:00"); + expect(result).toContain("SUMMARY.md"); + }); + + it("mentions non-blocking failure handling", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("do not retry"); + }); + + it("escapes double quotes in the task title", () => { + const quotedTask = makeTask("q1"); + quotedTask.title = 'Fix "quoted" title'; + const result = buildAutoCommitPrompt(quotedTask, "fix", false, "2026-03-07T12:00:00"); + expect(result).toContain('\\"quoted\\"'); + }); +}); + describe("normalizeToUnixPath", () => { it("converts a WSL UNC path with wsl.localhost to a Unix path", () => { expect( diff --git a/src/lib/stores/taskLoop.ts b/src/lib/stores/taskLoop.ts index d056d69..b558c41 100644 --- a/src/lib/stores/taskLoop.ts +++ b/src/lib/stores/taskLoop.ts @@ -118,6 +118,34 @@ export function normalizeToUnixPath(path: string): string { return path; } +/** + * Builds the prompt sent to Claude after a task completes to commit the changes. + * If `includeSummary` is true, Claude is also asked to write/append to SUMMARY.md first. + */ +export function buildAutoCommitPrompt( + task: TaskLoopTask, + prefix: string, + includeSummary: boolean, + sessionTimestamp: string +): string { + const escapedTitle = task.title.replaceAll('"', '\\"'); + const commitMsg = `${prefix}: ${escapedTitle}\\n\\nAuto-committed by Hikari Task Loop\\nTask ID: ${task.id}\\nLoop session: ${sessionTimestamp}`; + + const gitCommands = `git add -A && git commit -m "${commitMsg}"`; + + const summaryRequest = includeSummary + ? `\n\nBefore committing, please write or append to \`SUMMARY.md\` in the working directory with:\n- What was implemented\n- Key decisions made\n- Files changed\n- Any caveats or follow-up work\n\nInclude SUMMARY.md in the commit.\n` + : ""; + + return `[Auto-commit] Please run the following in the current working directory:${summaryRequest} + +\`\`\`bash +${gitCommands} +\`\`\` + +If this fails (e.g. nothing to commit, no git repository), acknowledge it briefly and do not retry.`; +} + /** Builds the prompt sent to Claude Code for an automated task. */ export function buildTaskPrompt( task: TaskLoopTask,