feat: add auto-commit after task completion in Task Loop

This commit is contained in:
2026-03-07 01:14:08 -08:00
committed by Naomi Carrigan
parent f60e45e486
commit b820fa25a2
8 changed files with 317 additions and 15 deletions
+26
View File
@@ -158,6 +158,16 @@ pub struct HikariConfig {
#[serde(default)]
pub custom_ui_font_family: Option<String>,
// 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]
+3
View File
@@ -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);
+3
View File
@@ -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);
+188 -15
View File
@@ -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<Record<number, LoopPhase>>({});
let taskEverStartedMap = $state<Record<number, boolean>>({});
let commitEverStartedMap = $state<Record<number, boolean>>({});
let isLoading = $state(false);
let errorMessage = $state<string | null>(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<void> {
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<void> {
taskLoopStore.setTaskStatus(taskIdx, status);
@@ -137,9 +188,9 @@
async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise<void> {
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<void> {
await configStore.updateConfig({ task_loop_auto_commit: !$config.task_loop_auto_commit });
}
async function toggleIncludeSummary(): Promise<void> {
await configStore.updateConfig({
task_loop_include_summary: !$config.task_loop_include_summary,
});
}
async function updateCommitPrefix(value: string): Promise<void> {
await configStore.updateConfig({ task_loop_commit_prefix: value });
}
</script>
<div
@@ -346,6 +415,27 @@
← Workflow
</button>
{/if}
<button
onclick={() => (showSettings = !showSettings)}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Toggle settings"
aria-pressed={showSettings}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
@@ -363,6 +453,83 @@
</div>
</div>
<!-- Settings panel (collapsible) -->
{#if showSettings}
<div
class="px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-secondary)] flex flex-col gap-3"
>
<p class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide">
Auto-commit Settings
</p>
<!-- Auto-commit toggle -->
<label class="flex items-center gap-3 cursor-pointer">
<div
class="relative w-9 h-5 rounded-full transition-colors {$config.task_loop_auto_commit
? 'bg-[var(--accent-primary)]'
: 'bg-[var(--bg-tertiary)] border border-[var(--border-color)]'}"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$config.task_loop_auto_commit
? 'left-4'
: 'left-0.5'}"
></div>
<input
type="checkbox"
class="sr-only"
checked={$config.task_loop_auto_commit}
onchange={toggleAutoCommit}
/>
</div>
<span class="text-sm text-[var(--text-primary)]">Auto-commit on task completion</span>
</label>
{#if $config.task_loop_auto_commit}
<!-- Commit prefix -->
<div class="flex items-center gap-3">
<label
class="text-sm text-[var(--text-secondary)] shrink-0 w-28"
for="commit-prefix-input"
>
Commit prefix
</label>
<input
id="commit-prefix-input"
type="text"
value={$config.task_loop_commit_prefix}
onchange={(e) => 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)]"
/>
<span class="text-xs text-[var(--text-tertiary)]">: task title</span>
</div>
<!-- Include SUMMARY.md toggle -->
<label class="flex items-center gap-3 cursor-pointer">
<div
class="relative w-9 h-5 rounded-full transition-colors {$config.task_loop_include_summary
? 'bg-[var(--accent-primary)]'
: 'bg-[var(--bg-tertiary)] border border-[var(--border-color)]'}"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$config.task_loop_include_summary
? 'left-4'
: 'left-0.5'}"
></div>
<input
type="checkbox"
class="sr-only"
checked={$config.task_loop_include_summary}
onchange={toggleIncludeSummary}
/>
</div>
<span class="text-sm text-[var(--text-primary)]">Generate SUMMARY.md before commit</span
>
</label>
{/if}
</div>
{/if}
<!-- Body -->
<div class="flex-1 overflow-y-auto p-4 min-h-0">
{#if isLoading}
@@ -455,9 +622,15 @@
{task.priority}
</span>
{#if task.status === "running"}
<span class="text-xs text-blue-400 animate-pulse shrink-0"
>● running</span
>
{#if activePhases[taskIdx] === "waiting_for_auto_commit"}
<span class="text-xs text-violet-400 animate-pulse shrink-0"
>● committing</span
>
{:else}
<span class="text-xs text-blue-400 animate-pulse shrink-0"
>● running</span
>
{/if}
{:else if task.status === "blocked"}
<span class="text-xs text-[var(--text-tertiary)] shrink-0">blocked</span
>
+9
View File
@@ -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);
+7
View File
@@ -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() {
+53
View File
@@ -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(
+28
View File
@@ -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,