import { writable } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; import type { PrdTask, PrdFile } from "./prd"; export type TaskStatus = "pending" | "running" | "completed" | "failed" | "blocked"; export type LoopStatus = "idle" | "running" | "paused" | "stopped"; export interface TaskLoopTask extends PrdTask { status: TaskStatus; conversationId?: string; } /** Returns the index of the first pending task, or -1 if none. */ export function findNextPendingIndex(tasks: TaskLoopTask[]): number { return tasks.findIndex((t) => t.status === "pending"); } /** Counts tasks with the given status. */ export function countByStatus(tasks: TaskLoopTask[], status: TaskStatus): number { return tasks.filter((t) => t.status === status).length; } /** * Returns true if a task is blocked — i.e. any of its `dependsOn` IDs refer to a * task that has failed or is already blocked. */ export function isTaskBlocked(task: TaskLoopTask, allTasks: TaskLoopTask[]): boolean { if (!task.dependsOn || task.dependsOn.length === 0) return false; return task.dependsOn.some((depId) => { const dep = allTasks.find((t) => t.id === depId); return dep !== undefined && (dep.status === "failed" || dep.status === "blocked"); }); } /** * Returns indices of tasks that are ready to start: status is `pending` and all * `dependsOn` tasks are `completed`. Respects `limit` (concurrency cap). */ export function getReadyTasks(tasks: TaskLoopTask[], limit: number): number[] { const ready: number[] = []; for (let i = 0; i < tasks.length; i++) { if (ready.length >= limit) break; const task = tasks[i]; if (task.status !== "pending") continue; const depsAllDone = !task.dependsOn || task.dependsOn.length === 0 || task.dependsOn.every((depId) => { const dep = tasks.find((t) => t.id === depId); return dep === undefined || dep.status === "completed"; }); if (depsAllDone) { ready.push(i); } } return ready; } /** * Groups task indices into waves for UI display. Tasks with no pending dependencies * form wave 0; tasks whose deps are all in earlier waves form the next wave, etc. * Circular dependencies are collected into a final "overflow" wave. */ export function computeWaves(tasks: TaskLoopTask[]): number[][] { const waves: number[][] = []; const assigned = new Set(); // Build an id→index map const idToIndex = new Map(); tasks.forEach((t, i) => idToIndex.set(t.id, i)); let remaining = tasks.map((_, i) => i).filter((i) => !assigned.has(i)); while (remaining.length > 0) { const wave: number[] = []; for (const i of remaining) { const task = tasks[i]; const depsAllAssigned = !task.dependsOn || task.dependsOn.length === 0 || task.dependsOn.every((depId) => { const depIdx = idToIndex.get(depId); return depIdx === undefined || assigned.has(depIdx); }); if (depsAllAssigned) { wave.push(i); } } if (wave.length === 0) { // Circular dependency — dump all remaining into a single wave waves.push(remaining); break; } wave.forEach((i) => assigned.add(i)); waves.push(wave); remaining = remaining.filter((i) => !assigned.has(i)); } return waves; } /** * Normalises a file-picker path to a Unix path. * * On Windows/WSL the dialog returns a UNC path like: * \\wsl.localhost\Ubuntu\home\naomi\code\temp\hikari-tasks.json * which the WSL-side Claude process cannot use as a working directory. * This converts that to /home/naomi/code/temp/hikari-tasks.json. */ export function normalizeToUnixPath(path: string): string { // Matches both \\wsl.localhost\\... and \\wsl$\\... (legacy) const wslUncMatch = /^[/\\][/\\]wsl(?:\.localhost|\$)?[/\\][^/\\]+(.*)$/i.exec(path); if (wslUncMatch) { return wslUncMatch[1].replaceAll("\\", "/"); } 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, taskNumber: number, totalTasks: number ): string { return `[Automated Task Loop — Task ${taskNumber}/${totalTasks}]\n\n**${task.title}**\n\n${task.prompt}`; } function createTaskLoopStore() { const tasks = writable([]); const loopStatus = writable("idle"); const currentTaskIndex = writable(-1); const sourceFile = writable(""); const concurrencyLimit = writable(3); async function loadFile(path: string): Promise { const content = await invoke("read_file_content", { path }); const data = JSON.parse(content) as PrdFile; const loopTasks: TaskLoopTask[] = data.tasks.map((t) => ({ ...t, status: "pending" })); tasks.set(loopTasks); sourceFile.set(path); loopStatus.set("idle"); currentTaskIndex.set(-1); } function setTaskStatus(index: number, status: TaskStatus): void { tasks.update((current) => { const result = [...current]; if (result[index]) { result[index] = { ...result[index], status }; } return result; }); } function setTaskConversationId(index: number, conversationId: string): void { tasks.update((current) => { const result = [...current]; if (result[index]) { result[index] = { ...result[index], conversationId }; } return result; }); } function setLoopStatus(status: LoopStatus): void { loopStatus.set(status); } function setCurrentTaskIndex(index: number): void { currentTaskIndex.set(index); } function setConcurrencyLimit(limit: number): void { concurrencyLimit.set(Math.max(1, limit)); } function reset(): void { tasks.set([]); loopStatus.set("idle"); currentTaskIndex.set(-1); sourceFile.set(""); } return { tasks: { subscribe: tasks.subscribe }, loopStatus: { subscribe: loopStatus.subscribe }, currentTaskIndex: { subscribe: currentTaskIndex.subscribe }, sourceFile: { subscribe: sourceFile.subscribe }, concurrencyLimit: { subscribe: concurrencyLimit.subscribe }, loadFile, setTaskStatus, setTaskConversationId, setLoopStatus, setCurrentTaskIndex, setConcurrencyLimit, reset, }; } export const taskLoopStore = createTaskLoopStore();