generated from nhcarrigan/template
feat: add auto-commit after task completion in Task Loop
This commit is contained in:
@@ -158,6 +158,16 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_ui_font_family: Option<String>,
|
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 {
|
impl Default for HikariConfig {
|
||||||
@@ -201,6 +211,9 @@ impl Default for HikariConfig {
|
|||||||
custom_font_family: None,
|
custom_font_family: None,
|
||||||
custom_ui_font_path: None,
|
custom_ui_font_path: None,
|
||||||
custom_ui_font_family: 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
|
0.3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_task_loop_commit_prefix() -> String {
|
||||||
|
"feat".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum BudgetAction {
|
pub enum BudgetAction {
|
||||||
@@ -332,6 +349,9 @@ mod tests {
|
|||||||
assert!(config.custom_font_family.is_none());
|
assert!(config.custom_font_family.is_none());
|
||||||
assert!(config.custom_ui_font_path.is_none());
|
assert!(config.custom_ui_font_path.is_none());
|
||||||
assert!(config.custom_ui_font_family.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]
|
#[test]
|
||||||
@@ -375,6 +395,9 @@ mod tests {
|
|||||||
custom_font_family: Some("MyFont".to_string()),
|
custom_font_family: Some("MyFont".to_string()),
|
||||||
custom_ui_font_path: None,
|
custom_ui_font_path: None,
|
||||||
custom_ui_font_family: 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();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
@@ -389,6 +412,9 @@ mod tests {
|
|||||||
deserialized.greeting_custom_prompt,
|
deserialized.greeting_custom_prompt,
|
||||||
Some("Hello!".to_string())
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -66,6 +66,9 @@
|
|||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
custom_ui_font_path: null,
|
custom_ui_font_path: null,
|
||||||
custom_ui_font_family: 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);
|
let showCustomThemeEditor = $state(false);
|
||||||
|
|||||||
@@ -84,6 +84,9 @@
|
|||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
custom_ui_font_path: null,
|
custom_ui_font_path: null,
|
||||||
custom_ui_font_family: 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);
|
let streamerModeActive = $state(false);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
computeWaves,
|
computeWaves,
|
||||||
isTaskBlocked,
|
isTaskBlocked,
|
||||||
buildTaskPrompt,
|
buildTaskPrompt,
|
||||||
|
buildAutoCommitPrompt,
|
||||||
normalizeToUnixPath,
|
normalizeToUnixPath,
|
||||||
type TaskLoopTask,
|
type TaskLoopTask,
|
||||||
} from "$lib/stores/taskLoop";
|
} from "$lib/stores/taskLoop";
|
||||||
@@ -28,13 +29,17 @@
|
|||||||
const sourceFile = $derived(taskLoopStore.sourceFile);
|
const sourceFile = $derived(taskLoopStore.sourceFile);
|
||||||
const conversations = $derived(claudeStore.conversations);
|
const conversations = $derived(claudeStore.conversations);
|
||||||
const concurrencyLimit = $derived(taskLoopStore.concurrencyLimit);
|
const concurrencyLimit = $derived(taskLoopStore.concurrencyLimit);
|
||||||
|
const config = $derived(configStore.config);
|
||||||
|
|
||||||
// Per-task orchestration phases (panel-local, not persisted)
|
// 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 activePhases = $state<Record<number, LoopPhase>>({});
|
||||||
let taskEverStartedMap = $state<Record<number, boolean>>({});
|
let taskEverStartedMap = $state<Record<number, boolean>>({});
|
||||||
|
let commitEverStartedMap = $state<Record<number, boolean>>({});
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let errorMessage = $state<string | null>(null);
|
let errorMessage = $state<string | null>(null);
|
||||||
|
let sessionTimestamp = $state("");
|
||||||
|
let showSettings = $state(false);
|
||||||
|
|
||||||
const completedCount = $derived($tasks.filter((t) => t.status === "completed").length);
|
const completedCount = $derived($tasks.filter((t) => t.status === "completed").length);
|
||||||
const failedCount = $derived($tasks.filter((t) => t.status === "failed").length);
|
const failedCount = $derived($tasks.filter((t) => t.status === "failed").length);
|
||||||
@@ -71,11 +76,34 @@
|
|||||||
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: true };
|
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: true };
|
||||||
}
|
}
|
||||||
if (taskEverStartedMap[taskIdx] && conv.characterState === "idle") {
|
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(
|
activePhases = Object.fromEntries(
|
||||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||||
);
|
);
|
||||||
taskEverStartedMap = Object.fromEntries(
|
commitEverStartedMap = Object.fromEntries(
|
||||||
Object.entries(taskEverStartedMap).filter(([k]) => Number(k) !== taskIdx)
|
Object.entries(commitEverStartedMap).filter(([k]) => Number(k) !== taskIdx)
|
||||||
);
|
);
|
||||||
void onTaskCompleted(taskIdx, "completed");
|
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> {
|
async function onTaskCompleted(taskIdx: number, status: "completed" | "failed"): Promise<void> {
|
||||||
taskLoopStore.setTaskStatus(taskIdx, status);
|
taskLoopStore.setTaskStatus(taskIdx, status);
|
||||||
|
|
||||||
@@ -137,9 +188,9 @@
|
|||||||
|
|
||||||
async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise<void> {
|
async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise<void> {
|
||||||
const task = taskList[taskIdx];
|
const task = taskList[taskIdx];
|
||||||
const config = get(configStore.config);
|
const cfg = get(configStore.config);
|
||||||
const allAllowedTools = [
|
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);
|
const filePath = get(taskLoopStore.sourceFile);
|
||||||
@@ -159,14 +210,14 @@
|
|||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: workingDir,
|
working_dir: workingDir,
|
||||||
model: config.model ?? null,
|
model: cfg.model ?? null,
|
||||||
api_key: config.api_key ?? null,
|
api_key: cfg.api_key ?? null,
|
||||||
custom_instructions: (config.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
custom_instructions: (cfg.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||||
mcp_servers_json: config.mcp_servers_json ?? null,
|
mcp_servers_json: cfg.mcp_servers_json ?? null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: cfg.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: cfg.disable_1m_context ?? false,
|
||||||
max_output_tokens: config.max_output_tokens ?? null,
|
max_output_tokens: cfg.max_output_tokens ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -203,6 +254,7 @@
|
|||||||
const readyIndices = getReadyTasks(taskList, limit);
|
const readyIndices = getReadyTasks(taskList, limit);
|
||||||
if (readyIndices.length === 0) return;
|
if (readyIndices.length === 0) return;
|
||||||
|
|
||||||
|
sessionTimestamp = new Date().toISOString();
|
||||||
taskLoopStore.setLoopStatus("running");
|
taskLoopStore.setLoopStatus("running");
|
||||||
await Promise.all(readyIndices.map((i) => startTask(i, taskList)));
|
await Promise.all(readyIndices.map((i) => startTask(i, taskList)));
|
||||||
}
|
}
|
||||||
@@ -241,13 +293,16 @@
|
|||||||
|
|
||||||
activePhases = {};
|
activePhases = {};
|
||||||
taskEverStartedMap = {};
|
taskEverStartedMap = {};
|
||||||
|
commitEverStartedMap = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset(): void {
|
function handleReset(): void {
|
||||||
taskLoopStore.reset();
|
taskLoopStore.reset();
|
||||||
activePhases = {};
|
activePhases = {};
|
||||||
taskEverStartedMap = {};
|
taskEverStartedMap = {};
|
||||||
|
commitEverStartedMap = {};
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
sessionTimestamp = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusColour(status: TaskLoopTask["status"]): string {
|
function statusColour(status: TaskLoopTask["status"]): string {
|
||||||
@@ -292,6 +347,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPendingTasks = $derived($tasks.some((t) => t.status === "pending"));
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -346,6 +415,27 @@
|
|||||||
← Workflow
|
← Workflow
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
@@ -363,6 +453,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Body -->
|
||||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
@@ -455,9 +622,15 @@
|
|||||||
{task.priority}
|
{task.priority}
|
||||||
</span>
|
</span>
|
||||||
{#if task.status === "running"}
|
{#if task.status === "running"}
|
||||||
<span class="text-xs text-blue-400 animate-pulse shrink-0"
|
{#if activePhases[taskIdx] === "waiting_for_auto_commit"}
|
||||||
>● running</span
|
<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"}
|
{:else if task.status === "blocked"}
|
||||||
<span class="text-xs text-[var(--text-tertiary)] shrink-0">blocked</span
|
<span class="text-xs text-[var(--text-tertiary)] shrink-0">blocked</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ describe("config store", () => {
|
|||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
custom_ui_font_path: null,
|
custom_ui_font_path: null,
|
||||||
custom_ui_font_family: 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");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -273,6 +276,9 @@ describe("config store", () => {
|
|||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
custom_ui_font_path: null,
|
custom_ui_font_path: null,
|
||||||
custom_ui_font_family: 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();
|
expect(config.model).toBeNull();
|
||||||
@@ -884,6 +890,9 @@ describe("config store", () => {
|
|||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
custom_ui_font_path: null,
|
custom_ui_font_path: null,
|
||||||
custom_ui_font_family: 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);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ export interface HikariConfig {
|
|||||||
// Custom UI font settings
|
// Custom UI font settings
|
||||||
custom_ui_font_path: string | null;
|
custom_ui_font_path: string | null;
|
||||||
custom_ui_font_family: 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 = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -127,6 +131,9 @@ const defaultConfig: HikariConfig = {
|
|||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
custom_ui_font_path: null,
|
custom_ui_font_path: null,
|
||||||
custom_ui_font_family: null,
|
custom_ui_font_family: null,
|
||||||
|
task_loop_auto_commit: false,
|
||||||
|
task_loop_commit_prefix: "feat",
|
||||||
|
task_loop_include_summary: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
findNextPendingIndex,
|
findNextPendingIndex,
|
||||||
countByStatus,
|
countByStatus,
|
||||||
buildTaskPrompt,
|
buildTaskPrompt,
|
||||||
|
buildAutoCommitPrompt,
|
||||||
normalizeToUnixPath,
|
normalizeToUnixPath,
|
||||||
isTaskBlocked,
|
isTaskBlocked,
|
||||||
getReadyTasks,
|
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", () => {
|
describe("normalizeToUnixPath", () => {
|
||||||
it("converts a WSL UNC path with wsl.localhost to a Unix path", () => {
|
it("converts a WSL UNC path with wsl.localhost to a Unix path", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -118,6 +118,34 @@ export function normalizeToUnixPath(path: string): string {
|
|||||||
return path;
|
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. */
|
/** Builds the prompt sent to Claude Code for an automated task. */
|
||||||
export function buildTaskPrompt(
|
export function buildTaskPrompt(
|
||||||
task: TaskLoopTask,
|
task: TaskLoopTask,
|
||||||
|
|||||||
Reference in New Issue
Block a user