feat: productivity suite — task loop, workflow, theming, docs & more #197

Merged
naomi merged 16 commits from feat/productivity into main 2026-03-07 03:08:33 -08:00
5 changed files with 792 additions and 0 deletions
Showing only changes of commit 93b3aa379c - Show all commits
+19
View File
@@ -22,6 +22,7 @@
import ProjectContextPanel from "./ProjectContextPanel.svelte";
import PrdPanel from "./PrdPanel.svelte";
import ChangelogPanel from "./ChangelogPanel.svelte";
import TaskLoopPanel from "./TaskLoopPanel.svelte";
import { injectTextStore } from "$lib/stores/projectContext";
const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -65,6 +66,7 @@
let showProjectContext = $state(false);
let showPrdPanel = $state(false);
let showChangelog = $state(false);
let showTaskLoop = $state(false);
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
@@ -230,6 +232,19 @@
<span>PRD Creator</span>
</button>
<!-- Task Loop -->
<button onclick={menuAction(() => (showTaskLoop = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>Task Loop</span>
</button>
<!-- File Editor -->
<button
onclick={menuAction(() => editorStore.toggleEditor())}
@@ -494,6 +509,10 @@
<ChangelogPanel onClose={() => (showChangelog = false)} />
{/if}
{#if showTaskLoop}
<TaskLoopPanel onClose={() => (showTaskLoop = false)} />
{/if}
<style>
.nav-item {
display: flex;
+22
View File
@@ -25,6 +25,7 @@
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
import type { WorkspaceHookInfo } from "$lib/types/messages";
import NavMenu from "./NavMenu.svelte";
import { taskLoopStore } from "$lib/stores/taskLoop";
let connectionStatus: ConnectionStatus = $state("disconnected");
let workingDirectory = $state("");
@@ -90,6 +91,14 @@
streamerModeActive = value;
});
const loopStatus = $derived(taskLoopStore.loopStatus);
const loopTasks = $derived(taskLoopStore.tasks);
const loopCurrentIndex = $derived(taskLoopStore.currentTaskIndex);
const loopCompletedCount = $derived(
$loopTasks.filter((t) => t.status === "completed" || t.status === "failed").length
);
const loopTotalCount = $derived($loopTasks.length);
onMount(async () => {
appVersion = await getVersion();
});
@@ -410,6 +419,19 @@
></div>
{/if}
{#if $loopStatus === "running" || $loopStatus === "paused"}
<span
class="text-xs px-2 py-0.5 rounded-full border shrink-0 {$loopStatus === 'running'
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 animate-pulse'
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}"
title="Task loop {$loopStatus}"
>
Loop {$loopStatus === "running" ? "▶" : "⏸"}
{loopCompletedCount +
($loopStatus === "running" && $loopCurrentIndex >= 0 ? 1 : 0)}/{loopTotalCount}
</span>
{/if}
<NavMenu
{connectionStatus}
{workingDirectory}
+510
View File
@@ -0,0 +1,510 @@
<script lang="ts">
import { get } from "svelte/store";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import {
taskLoopStore,
findNextPendingIndex,
buildTaskPrompt,
normalizeToUnixPath,
type TaskLoopTask,
} from "$lib/stores/taskLoop";
import { claudeStore } from "$lib/stores/claude";
import { configStore } from "$lib/stores/config";
import { PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
import type { CharacterState } from "$lib/types/states";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const tasks = $derived(taskLoopStore.tasks);
const loopStatus = $derived(taskLoopStore.loopStatus);
const currentTaskIndex = $derived(taskLoopStore.currentTaskIndex);
const sourceFile = $derived(taskLoopStore.sourceFile);
const conversations = $derived(claudeStore.conversations);
// Orchestration phase (panel-local, not persisted)
type LoopPhase = "waiting_for_connection" | "waiting_for_completion";
let loopPhase = $state<LoopPhase | null>(null);
let taskEverStarted = $state(false);
let isLoading = $state(false);
let errorMessage = $state<string | null>(null);
const completedCount = $derived($tasks.filter((t) => t.status === "completed").length);
const failedCount = $derived($tasks.filter((t) => t.status === "failed").length);
const totalCount = $derived($tasks.length);
const workingStates: CharacterState[] = ["thinking", "typing", "coding", "searching", "mcp"];
// Watch the current task's conversation for state transitions
$effect(() => {
const phase = loopPhase;
if (!phase) return;
const taskIdx = $currentTaskIndex;
const taskList = $tasks;
if (taskIdx < 0 || taskIdx >= taskList.length) return;
const currentTask = taskList[taskIdx];
if (!currentTask.conversationId) return;
const conv = $conversations.get(currentTask.conversationId);
if (!conv) return;
if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") {
loopPhase = "waiting_for_completion";
taskEverStarted = false;
void sendTaskPrompt(currentTask, taskIdx, taskList.length);
return;
}
if (phase === "waiting_for_completion") {
if (workingStates.includes(conv.characterState)) {
taskEverStarted = true;
}
if (taskEverStarted && conv.characterState === "idle") {
taskEverStarted = false;
loopPhase = null;
void onTaskCompleted(taskIdx, "completed");
}
}
});
async function sendTaskPrompt(task: TaskLoopTask, taskIdx: number, total: number): Promise<void> {
const prompt = buildTaskPrompt(task, taskIdx + 1, total);
try {
await invoke("send_prompt", {
conversationId: task.conversationId,
message: prompt,
});
} catch (error) {
console.error("Failed to send task prompt:", error);
loopPhase = null;
void onTaskCompleted(taskIdx, "failed");
}
}
async function onTaskCompleted(taskIdx: number, status: "completed" | "failed"): Promise<void> {
taskLoopStore.setTaskStatus(taskIdx, status);
const currentLoopStatus = get(taskLoopStore.loopStatus);
if (currentLoopStatus !== "running") return;
const taskList = get(taskLoopStore.tasks);
const nextIdx = findNextPendingIndex(taskList);
if (nextIdx === -1) {
taskLoopStore.setLoopStatus("stopped");
taskLoopStore.setCurrentTaskIndex(-1);
return;
}
await startTask(nextIdx, taskList);
}
async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise<void> {
const task = taskList[taskIdx];
const config = get(configStore.config);
const allAllowedTools = [
...new Set([...get(claudeStore.grantedTools), ...(config.auto_granted_tools ?? [])]),
];
// sourceFile is already normalised to a Unix path at import time.
const filePath = get(taskLoopStore.sourceFile);
const workingDir = filePath.split("/").slice(0, -1).join("/");
// Create a new conversation for this task
const conversationId = claudeStore.createConversation(task.title);
void claudeStore.switchConversation(conversationId);
taskLoopStore.setTaskConversationId(taskIdx, conversationId);
taskLoopStore.setTaskStatus(taskIdx, "running");
taskLoopStore.setCurrentTaskIndex(taskIdx);
loopPhase = "waiting_for_connection";
taskEverStarted = false;
try {
await invoke("start_claude", {
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,
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,
},
});
} catch (error) {
console.error("Failed to start Claude for task:", error);
loopPhase = null;
void onTaskCompleted(taskIdx, "failed");
}
}
async function handleImportFile(): Promise<void> {
const selected = await open({
title: "Select hikari-tasks.json",
filters: [{ name: "Hikari Tasks", extensions: ["json"] }],
multiple: false,
});
if (!selected || typeof selected !== "string") return;
isLoading = true;
errorMessage = null;
try {
await taskLoopStore.loadFile(normalizeToUnixPath(selected));
} catch (error) {
errorMessage = `Failed to load file: ${error instanceof Error ? error.message : String(error)}`;
} finally {
isLoading = false;
}
}
async function handleStart(): Promise<void> {
const taskList = get(taskLoopStore.tasks);
const nextIdx = findNextPendingIndex(taskList);
if (nextIdx === -1) return;
taskLoopStore.setLoopStatus("running");
await startTask(nextIdx, taskList);
}
function handlePause(): void {
taskLoopStore.setLoopStatus("paused");
}
function handleResume(): void {
taskLoopStore.setLoopStatus("running");
// If we're between tasks (no active phase), advance immediately
if (!loopPhase) {
const taskList = get(taskLoopStore.tasks);
const nextIdx = findNextPendingIndex(taskList);
if (nextIdx !== -1) {
void startTask(nextIdx, taskList);
} else {
taskLoopStore.setLoopStatus("stopped");
}
}
}
async function handleStop(): Promise<void> {
const taskIdx = get(taskLoopStore.currentTaskIndex);
const taskList = get(taskLoopStore.tasks);
const currentTask = taskIdx >= 0 ? taskList[taskIdx] : null;
taskLoopStore.setLoopStatus("stopped");
loopPhase = null;
// Stop Claude process for the current task if running
if (currentTask?.conversationId) {
try {
await invoke("stop_claude", { conversationId: currentTask.conversationId });
} catch (error) {
console.error("Failed to stop Claude for current task:", error);
}
if (currentTask.status === "running") {
taskLoopStore.setTaskStatus(taskIdx, "failed");
}
}
taskLoopStore.setCurrentTaskIndex(-1);
}
function handleReset(): void {
taskLoopStore.reset();
loopPhase = null;
taskEverStarted = false;
errorMessage = null;
}
function statusColour(status: TaskLoopTask["status"]): string {
switch (status) {
case "pending":
return "text-[var(--text-tertiary)]";
case "running":
return "text-blue-400";
case "completed":
return "text-green-400";
case "failed":
return "text-red-400";
}
}
function statusIcon(status: TaskLoopTask["status"]): string {
switch (status) {
case "pending":
return "○";
case "running":
return "⟳";
case "completed":
return "✓";
case "failed":
return "✗";
}
}
function priorityColour(priority: TaskLoopTask["priority"]): string {
switch (priority) {
case "high":
return "bg-red-500/20 text-red-400 border-red-500/30";
case "medium":
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
case "low":
return "bg-green-500/20 text-green-400 border-green-500/30";
}
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="task-loop-panel-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<h2 id="task-loop-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
Task Loop
</h2>
{#if $loopStatus === "running"}
<span
class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 border border-blue-500/30 animate-pulse"
>
Running {completedCount +
failedCount +
($loopStatus === "running" ? 1 : 0)}/{totalCount}
</span>
{:else if $loopStatus === "paused"}
<span
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
Paused
</span>
{:else if $loopStatus === "stopped" && totalCount > 0}
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-secondary)] text-[var(--text-tertiary)] border border-[var(--border-color)]"
>
{completedCount}/{totalCount} completed{failedCount > 0
? `, ${failedCount} failed`
: ""}
</span>
{/if}
</div>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-4 min-h-0">
{#if isLoading}
<div class="flex items-center justify-center py-16 gap-3 text-[var(--text-secondary)]">
<div class="animate-spin text-2xl">⚙️</div>
<span class="text-sm">Loading tasks...</span>
</div>
{:else if errorMessage}
<div class="flex flex-col items-center justify-center py-16 gap-4 text-center">
<p class="text-sm text-red-400">{errorMessage}</p>
<button
onclick={handleImportFile}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Try Again
</button>
</div>
{:else if totalCount === 0}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-16 gap-4 text-center">
<div class="text-4xl">📋</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">No Tasks Loaded</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-sm">
Import a <span class="font-mono text-xs">hikari-tasks.json</span> file created by the PRD
Creator to run tasks automatically.
</p>
<button
onclick={handleImportFile}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Import hikari-tasks.json
</button>
</div>
{:else}
<!-- Source file path -->
<div class="text-xs text-[var(--text-tertiary)] font-mono mb-3 truncate">
{$sourceFile}
</div>
<!-- Task list -->
<div class="flex flex-col gap-2">
{#each $tasks as task, index (task.id)}
<div
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 flex items-start gap-3 {$currentTaskIndex ===
index && $loopStatus === 'running'
? 'border-blue-500/40 bg-blue-500/5'
: ''}"
>
<!-- Status icon -->
<span
class="text-sm font-mono mt-0.5 w-4 text-center shrink-0 {statusColour(
task.status
)}"
>
{statusIcon(task.status)}
</span>
<!-- Task info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
{task.title}
</span>
<span
class="text-xs px-1.5 py-0.5 rounded-full border shrink-0 {priorityColour(
task.priority
)}"
>
{task.priority}
</span>
{#if $currentTaskIndex === index && $loopStatus === "running"}
<span class="text-xs text-blue-400 animate-pulse shrink-0">● running</span>
{/if}
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-0.5 line-clamp-2 font-mono">
{task.prompt}
</p>
</div>
<!-- Task number -->
<span class="text-xs text-[var(--text-tertiary)] font-mono shrink-0"
>#{index + 1}</span
>
</div>
{/each}
</div>
{/if}
</div>
<!-- Footer -->
<div
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
>
<div class="flex items-center gap-2">
{#if totalCount > 0 && $loopStatus === "idle"}
<button
onclick={handleImportFile}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Change File
</button>
<button
onclick={handleReset}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Reset
</button>
{:else if $loopStatus === "stopped"}
<button
onclick={handleReset}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Reset
</button>
{/if}
</div>
<div class="flex items-center gap-2">
{#if totalCount === 0}
<!-- no actions until tasks are loaded -->
{:else if $loopStatus === "idle" || $loopStatus === "stopped"}
{#if findNextPendingIndex($tasks) !== -1}
<button
onclick={handleStart}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Start Loop
</button>
{:else}
<span class="text-xs text-[var(--text-tertiary)]">All tasks complete</span>
{/if}
{:else if $loopStatus === "running"}
<button
onclick={handlePause}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Pause
</button>
<button
onclick={handleStop}
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-colors"
>
Stop
</button>
{:else if $loopStatus === "paused"}
<button
onclick={handleResume}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Resume
</button>
<button
onclick={handleStop}
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-colors"
>
Stop
</button>
{/if}
</div>
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import {
findNextPendingIndex,
countByStatus,
buildTaskPrompt,
normalizeToUnixPath,
type TaskLoopTask,
} from "./taskLoop";
const makeTask = (id: string, status: TaskLoopTask["status"] = "pending"): TaskLoopTask => ({
id,
title: `Task ${id}`,
prompt: `Do the thing for ${id}`,
priority: "medium",
status,
});
describe("findNextPendingIndex", () => {
it("returns -1 for an empty list", () => {
expect(findNextPendingIndex([])).toBe(-1);
});
it("returns 0 when the first task is pending", () => {
const tasks = [makeTask("1", "pending"), makeTask("2", "pending")];
expect(findNextPendingIndex(tasks)).toBe(0);
});
it("skips completed and failed tasks to find the next pending", () => {
const tasks = [
makeTask("1", "completed"),
makeTask("2", "failed"),
makeTask("3", "pending"),
makeTask("4", "pending"),
];
expect(findNextPendingIndex(tasks)).toBe(2);
});
it("returns -1 when all tasks are completed", () => {
const tasks = [makeTask("1", "completed"), makeTask("2", "completed")];
expect(findNextPendingIndex(tasks)).toBe(-1);
});
it("skips running tasks", () => {
const tasks = [makeTask("1", "running"), makeTask("2", "pending")];
expect(findNextPendingIndex(tasks)).toBe(1);
});
});
describe("countByStatus", () => {
it("returns 0 for an empty list", () => {
expect(countByStatus([], "pending")).toBe(0);
});
it("counts only tasks with the specified status", () => {
const tasks = [
makeTask("1", "pending"),
makeTask("2", "completed"),
makeTask("3", "pending"),
makeTask("4", "failed"),
];
expect(countByStatus(tasks, "pending")).toBe(2);
expect(countByStatus(tasks, "completed")).toBe(1);
expect(countByStatus(tasks, "failed")).toBe(1);
expect(countByStatus(tasks, "running")).toBe(0);
});
it("counts all tasks when all have the same status", () => {
const tasks = [makeTask("1", "completed"), makeTask("2", "completed")];
expect(countByStatus(tasks, "completed")).toBe(2);
});
});
describe("buildTaskPrompt", () => {
it("includes the task number and total", () => {
const task = makeTask("1");
const result = buildTaskPrompt(task, 1, 5);
expect(result).toContain("1/5");
});
it("includes the task title", () => {
const task = makeTask("abc");
const result = buildTaskPrompt(task, 2, 3);
expect(result).toContain("Task abc");
});
it("includes the task prompt", () => {
const task = makeTask("x");
const result = buildTaskPrompt(task, 1, 1);
expect(result).toContain("Do the thing for x");
});
it("labels the output as an automated task loop entry", () => {
const task = makeTask("1");
const result = buildTaskPrompt(task, 1, 1);
expect(result).toContain("Automated Task Loop");
});
});
describe("normalizeToUnixPath", () => {
it("converts a WSL UNC path with wsl.localhost to a Unix path", () => {
expect(
normalizeToUnixPath("\\\\wsl.localhost\\Ubuntu\\home\\naomi\\code\\temp\\file.json")
).toBe("/home/naomi/code/temp/file.json");
});
it("converts a WSL UNC path with wsl$ (legacy) to a Unix path", () => {
expect(normalizeToUnixPath("\\\\wsl$\\Ubuntu\\home\\naomi\\file.json")).toBe(
"/home/naomi/file.json"
);
});
it("handles forward-slash UNC paths produced by some tools", () => {
expect(normalizeToUnixPath("//wsl.localhost/Ubuntu/home/naomi/file.json")).toBe(
"/home/naomi/file.json"
);
});
it("leaves a plain Unix path unchanged", () => {
expect(normalizeToUnixPath("/home/naomi/code/temp/file.json")).toBe(
"/home/naomi/code/temp/file.json"
);
});
it("leaves an empty string unchanged", () => {
expect(normalizeToUnixPath("")).toBe("");
});
});
+114
View File
@@ -0,0 +1,114 @@
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";
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;
}
/**
* 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\<distro>\... and \\wsl$\<distro>\... (legacy)
const wslUncMatch = /^[/\\][/\\]wsl(?:\.localhost|\$)?[/\\][^/\\]+(.*)$/i.exec(path);
if (wslUncMatch) {
return wslUncMatch[1].replaceAll("\\", "/");
}
return path;
}
/** 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<TaskLoopTask[]>([]);
const loopStatus = writable<LoopStatus>("idle");
const currentTaskIndex = writable<number>(-1);
const sourceFile = writable<string>("");
async function loadFile(path: string): Promise<void> {
const content = await invoke<string>("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 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 },
loadFile,
setTaskStatus,
setTaskConversationId,
setLoopStatus,
setCurrentTaskIndex,
reset,
};
}
export const taskLoopStore = createTaskLoopStore();