generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more (#197)
## Summary A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation. ### Features - **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion - **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control - **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel - **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost - **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume - **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically - **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure - **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine - **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app - **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature ### Performance & Fixes - **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions - **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected ### UX - Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation - Navigation icon cluster replaced with a single clean dropdown menu ## Closes Closes #179 Closes #180 Closes #181 Closes #188 Closes #189 Closes #190 Closes #191 Closes #192 Closes #193 Closes #194 Closes #195 Closes #196 --- ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #197 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #197.
This commit is contained in:
@@ -1471,11 +1471,14 @@ export const achievementsByRarity = derived(achievementsStore, ($store) => {
|
||||
return byRarity;
|
||||
});
|
||||
|
||||
export const achievementProgress = derived(achievementsStore, ($store) => ({
|
||||
unlocked: $store.totalUnlocked,
|
||||
total: Object.keys($store.achievements).length,
|
||||
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
|
||||
}));
|
||||
export const achievementProgress = derived(achievementsStore, ($store) => {
|
||||
const total = Object.keys($store.achievements).length;
|
||||
return {
|
||||
unlocked: $store.totalUnlocked,
|
||||
total,
|
||||
percentage: Math.round(($store.totalUnlocked / total) * 100),
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize achievement listener
|
||||
export async function initAchievementsListener() {
|
||||
|
||||
@@ -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();
|
||||
@@ -337,6 +343,62 @@ describe("config store", () => {
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for dracula theme", () => {
|
||||
applyTheme("dracula");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dracula");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for catppuccin theme", () => {
|
||||
applyTheme("catppuccin");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("catppuccin");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for nord theme", () => {
|
||||
applyTheme("nord");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("nord");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for solarized theme", () => {
|
||||
applyTheme("solarized");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("solarized");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for solarized-light theme", () => {
|
||||
applyTheme("solarized-light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("solarized-light");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for catppuccin-latte theme", () => {
|
||||
applyTheme("catppuccin-latte");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("catppuccin-latte");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for gruvbox-light theme", () => {
|
||||
applyTheme("gruvbox-light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("gruvbox-light");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for rose-pine-dawn theme", () => {
|
||||
applyTheme("rose-pine-dawn");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("rose-pine-dawn");
|
||||
});
|
||||
|
||||
it("does not apply custom colors for preset themes", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#ff0000",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
applyTheme("dracula", colors);
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
|
||||
});
|
||||
|
||||
it("applies custom colors when theme is custom", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
@@ -828,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);
|
||||
|
||||
@@ -2,7 +2,19 @@ import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||
export type Theme =
|
||||
| "dark"
|
||||
| "light"
|
||||
| "high-contrast"
|
||||
| "custom"
|
||||
| "dracula"
|
||||
| "catppuccin"
|
||||
| "nord"
|
||||
| "solarized"
|
||||
| "solarized-light"
|
||||
| "catppuccin-latte"
|
||||
| "gruvbox-light"
|
||||
| "rose-pine-dawn";
|
||||
export type BudgetAction = "warn" | "block";
|
||||
|
||||
export interface CustomThemeColors {
|
||||
@@ -65,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 = {
|
||||
@@ -115,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() {
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import { prdStore, PRD_FILENAME, type PrdTask } from "./prd";
|
||||
|
||||
describe("PRD_FILENAME", () => {
|
||||
it("is hikari-tasks.json", () => {
|
||||
expect(PRD_FILENAME).toBe("hikari-tasks.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prdStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prdStore.reset();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("has empty goal", () => {
|
||||
expect(get(prdStore.goal)).toBe("");
|
||||
});
|
||||
|
||||
it("has empty tasks array", () => {
|
||||
expect(get(prdStore.tasks)).toEqual([]);
|
||||
});
|
||||
|
||||
it("has false isGenerating", () => {
|
||||
expect(get(prdStore.isGenerating)).toBe(false);
|
||||
});
|
||||
|
||||
it("has false isLoaded", () => {
|
||||
expect(get(prdStore.isLoaded)).toBe(false);
|
||||
});
|
||||
|
||||
it("has false isLoading", () => {
|
||||
expect(get(prdStore.isLoading)).toBe(false);
|
||||
});
|
||||
|
||||
it("has false isSaving", () => {
|
||||
expect(get(prdStore.isSaving)).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes all expected methods", () => {
|
||||
expect(typeof prdStore.loadFromFile).toBe("function");
|
||||
expect(typeof prdStore.saveToFile).toBe("function");
|
||||
expect(typeof prdStore.generatePrd).toBe("function");
|
||||
expect(typeof prdStore.finishGenerating).toBe("function");
|
||||
expect(typeof prdStore.addTask).toBe("function");
|
||||
expect(typeof prdStore.updateTask).toBe("function");
|
||||
expect(typeof prdStore.removeTask).toBe("function");
|
||||
expect(typeof prdStore.moveTaskUp).toBe("function");
|
||||
expect(typeof prdStore.moveTaskDown).toBe("function");
|
||||
expect(typeof prdStore.reset).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadFromFile", () => {
|
||||
const mockPrdFile = JSON.stringify({
|
||||
version: 1,
|
||||
goal: "Build a REST API",
|
||||
tasks: [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Set up project",
|
||||
prompt: "Initialise the Node.js project",
|
||||
priority: "high",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("calls read_file_content with the correct path", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||
path: "/home/naomi/myproject/hikari-tasks.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets goal from loaded file", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.goal)).toBe("Build a REST API");
|
||||
});
|
||||
|
||||
it("sets tasks from loaded file", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toBe("Set up project");
|
||||
});
|
||||
|
||||
it("sets isLoaded to true on success", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoaded)).toBe(true);
|
||||
});
|
||||
|
||||
it("sets isLoaded to false when file not found", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoaded)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoaded to false on JSON parse error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", "not valid json {{{");
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoaded)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoading to false after success", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoading)).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isLoading to false on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("Read error"));
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoading)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs error when file load fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load PRD file:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveToFile", () => {
|
||||
it("calls write_file_content with the correct path", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("write_file_content", {
|
||||
path: "/home/naomi/myproject/hikari-tasks.json",
|
||||
content: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("writes valid JSON with version 1", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||
const content = (writeCall?.[1] as { content: string } | undefined)?.content ?? "";
|
||||
const parsed = JSON.parse(content) as { version: number };
|
||||
expect(parsed.version).toBe(1);
|
||||
});
|
||||
|
||||
it("includes current goal in the saved file", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
const mockPrdFile = JSON.stringify({ version: 1, goal: "My goal", tasks: [] });
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
vi.clearAllMocks();
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||
const content = (writeCall?.[1] as { content: string } | undefined)?.content ?? "";
|
||||
const parsed = JSON.parse(content) as { goal: string };
|
||||
expect(parsed.goal).toBe("My goal");
|
||||
});
|
||||
|
||||
it("returns true on success", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
const result = await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on failure", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||
|
||||
const result = await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(result).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs error on failure", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save PRD file:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isSaving to false after success", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isSaving)).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isSaving to false on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isSaving)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePrd", () => {
|
||||
it("calls send_prompt with the conversation id", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("prompt includes the user goal", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("Build an API");
|
||||
});
|
||||
|
||||
it("prompt includes the working directory", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("/home/naomi/myproject");
|
||||
});
|
||||
|
||||
it("prompt mentions hikari-tasks.json", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("hikari-tasks.json");
|
||||
});
|
||||
|
||||
it("sets the goal in the store", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(get(prdStore.goal)).toBe("Build an API");
|
||||
});
|
||||
|
||||
it("resets isGenerating to false on send_prompt error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("send_prompt", new Error("Send failed"));
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(get(prdStore.isGenerating)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs error when send_prompt fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("send_prompt", new Error("Send failed"));
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to generate PRD:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("finishGenerating", () => {
|
||||
it("sets isGenerating to false", () => {
|
||||
prdStore.finishGenerating();
|
||||
expect(get(prdStore.isGenerating)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executePrd", () => {
|
||||
beforeEach(() => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
prdStore.addTask({
|
||||
title: "Set up project",
|
||||
prompt: "Initialise the repo",
|
||||
priority: "high",
|
||||
});
|
||||
prdStore.addTask({ title: "Write tests", prompt: "Add vitest tests", priority: "medium" });
|
||||
});
|
||||
|
||||
it("saves the file before sending the prompt", async () => {
|
||||
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||
|
||||
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||
expect(writeCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls send_prompt with the conversation id", async () => {
|
||||
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("prompt includes all task titles", async () => {
|
||||
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("Set up project");
|
||||
expect(message).toContain("Write tests");
|
||||
});
|
||||
|
||||
it("prompt includes all task prompts", async () => {
|
||||
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("Initialise the repo");
|
||||
expect(message).toContain("Add vitest tests");
|
||||
});
|
||||
|
||||
it("prompt includes the working directory", async () => {
|
||||
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("/home/naomi/myproject");
|
||||
});
|
||||
|
||||
it("prompt references hikari-tasks.json", async () => {
|
||||
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("hikari-tasks.json");
|
||||
});
|
||||
|
||||
it("exposes executePrd as a method", () => {
|
||||
expect(typeof prdStore.executePrd).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTask", () => {
|
||||
it("appends a new task to the list", () => {
|
||||
prdStore.addTask({ title: "Task A", prompt: "Do A", priority: "high" });
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toBe("Task A");
|
||||
});
|
||||
|
||||
it("assigns a unique id to each task", () => {
|
||||
prdStore.addTask({ title: "Task A", prompt: "Do A", priority: "high" });
|
||||
prdStore.addTask({ title: "Task B", prompt: "Do B", priority: "low" });
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks[0].id).not.toBe(tasks[1].id);
|
||||
});
|
||||
|
||||
it("preserves all task fields", () => {
|
||||
prdStore.addTask({ title: "My Task", prompt: "Do the thing", priority: "medium" });
|
||||
|
||||
const task = get(prdStore.tasks)[0];
|
||||
expect(task.title).toBe("My Task");
|
||||
expect(task.prompt).toBe("Do the thing");
|
||||
expect(task.priority).toBe("medium");
|
||||
});
|
||||
|
||||
it("adds tasks in order", () => {
|
||||
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||
prdStore.addTask({ title: "Second", prompt: "B", priority: "low" });
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks[0].title).toBe("First");
|
||||
expect(tasks[1].title).toBe("Second");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask", () => {
|
||||
let taskId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
prdStore.addTask({ title: "Original", prompt: "Original prompt", priority: "high" });
|
||||
taskId = get(prdStore.tasks)[0].id;
|
||||
});
|
||||
|
||||
it("updates the title of the specified task", () => {
|
||||
prdStore.updateTask(taskId, { title: "Updated" });
|
||||
|
||||
expect(get(prdStore.tasks)[0].title).toBe("Updated");
|
||||
});
|
||||
|
||||
it("updates the prompt of the specified task", () => {
|
||||
prdStore.updateTask(taskId, { prompt: "New prompt" });
|
||||
|
||||
expect(get(prdStore.tasks)[0].prompt).toBe("New prompt");
|
||||
});
|
||||
|
||||
it("updates the priority of the specified task", () => {
|
||||
prdStore.updateTask(taskId, { priority: "low" });
|
||||
|
||||
expect(get(prdStore.tasks)[0].priority).toBe("low");
|
||||
});
|
||||
|
||||
it("does not affect other tasks", () => {
|
||||
prdStore.addTask({ title: "Other", prompt: "Other prompt", priority: "medium" });
|
||||
const otherId = get(prdStore.tasks)[1].id;
|
||||
|
||||
prdStore.updateTask(taskId, { title: "Changed" });
|
||||
|
||||
const otherTask = get(prdStore.tasks).find((t) => t.id === otherId);
|
||||
expect(otherTask?.title).toBe("Other");
|
||||
});
|
||||
|
||||
it("does nothing when id is not found", () => {
|
||||
prdStore.updateTask("nonexistent-id", { title: "Ghost" });
|
||||
|
||||
expect(get(prdStore.tasks)[0].title).toBe("Original");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeTask", () => {
|
||||
it("removes the task with the given id", () => {
|
||||
prdStore.addTask({ title: "Keep", prompt: "A", priority: "high" });
|
||||
prdStore.addTask({ title: "Remove", prompt: "B", priority: "low" });
|
||||
const removeId = get(prdStore.tasks)[1].id;
|
||||
|
||||
prdStore.removeTask(removeId);
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toBe("Keep");
|
||||
});
|
||||
|
||||
it("does nothing when id is not found", () => {
|
||||
prdStore.addTask({ title: "Task", prompt: "A", priority: "high" });
|
||||
|
||||
prdStore.removeTask("nonexistent-id");
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("results in empty array when removing the only task", () => {
|
||||
prdStore.addTask({ title: "Only", prompt: "A", priority: "high" });
|
||||
const id = get(prdStore.tasks)[0].id;
|
||||
|
||||
prdStore.removeTask(id);
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTaskUp", () => {
|
||||
let tasks: PrdTask[];
|
||||
|
||||
beforeEach(() => {
|
||||
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||
prdStore.addTask({ title: "Second", prompt: "B", priority: "medium" });
|
||||
prdStore.addTask({ title: "Third", prompt: "C", priority: "low" });
|
||||
tasks = get(prdStore.tasks);
|
||||
});
|
||||
|
||||
it("swaps the task with the one above it", () => {
|
||||
prdStore.moveTaskUp(tasks[1].id);
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[0].title).toBe("Second");
|
||||
expect(result[1].title).toBe("First");
|
||||
});
|
||||
|
||||
it("does nothing when task is already first", () => {
|
||||
prdStore.moveTaskUp(tasks[0].id);
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[0].title).toBe("First");
|
||||
});
|
||||
|
||||
it("does nothing when id is not found", () => {
|
||||
prdStore.moveTaskUp("nonexistent");
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[0].title).toBe("First");
|
||||
expect(result[1].title).toBe("Second");
|
||||
expect(result[2].title).toBe("Third");
|
||||
});
|
||||
|
||||
it("does not change array length", () => {
|
||||
prdStore.moveTaskUp(tasks[2].id);
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTaskDown", () => {
|
||||
let tasks: PrdTask[];
|
||||
|
||||
beforeEach(() => {
|
||||
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||
prdStore.addTask({ title: "Second", prompt: "B", priority: "medium" });
|
||||
prdStore.addTask({ title: "Third", prompt: "C", priority: "low" });
|
||||
tasks = get(prdStore.tasks);
|
||||
});
|
||||
|
||||
it("swaps the task with the one below it", () => {
|
||||
prdStore.moveTaskDown(tasks[1].id);
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[1].title).toBe("Third");
|
||||
expect(result[2].title).toBe("Second");
|
||||
});
|
||||
|
||||
it("does nothing when task is already last", () => {
|
||||
prdStore.moveTaskDown(tasks[2].id);
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[2].title).toBe("Third");
|
||||
});
|
||||
|
||||
it("does nothing when id is not found", () => {
|
||||
prdStore.moveTaskDown("nonexistent");
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[0].title).toBe("First");
|
||||
expect(result[1].title).toBe("Second");
|
||||
expect(result[2].title).toBe("Third");
|
||||
});
|
||||
|
||||
it("does not change array length", () => {
|
||||
prdStore.moveTaskDown(tasks[0].id);
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("clears the goal", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
await prdStore.generatePrd("Some goal", "/wd", "conv-1");
|
||||
|
||||
prdStore.reset();
|
||||
|
||||
expect(get(prdStore.goal)).toBe("");
|
||||
});
|
||||
|
||||
it("clears all tasks", () => {
|
||||
prdStore.addTask({ title: "Task", prompt: "A", priority: "high" });
|
||||
|
||||
prdStore.reset();
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sets isLoaded to false", async () => {
|
||||
const mockPrdFile = JSON.stringify({ version: 1, goal: "goal", tasks: [] });
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
await prdStore.loadFromFile("/wd");
|
||||
|
||||
prdStore.reset();
|
||||
|
||||
expect(get(prdStore.isLoaded)).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isGenerating to false", () => {
|
||||
prdStore.finishGenerating();
|
||||
prdStore.reset();
|
||||
|
||||
expect(get(prdStore.isGenerating)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface PrdTask {
|
||||
id: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
dependsOn?: string[];
|
||||
}
|
||||
|
||||
export interface PrdFile {
|
||||
version: 1;
|
||||
goal: string;
|
||||
tasks: PrdTask[];
|
||||
}
|
||||
|
||||
export const PRD_FILENAME = "hikari-tasks.json";
|
||||
|
||||
function buildPrdPrompt(userGoal: string, workingDirectory: string): string {
|
||||
return `Please create a PRD task breakdown for the following goal and write it as \`hikari-tasks.json\` in the working directory.
|
||||
|
||||
Goal: ${userGoal}
|
||||
|
||||
Write the file to \`${workingDirectory}/hikari-tasks.json\` containing valid JSON in this exact format:
|
||||
\`\`\`json
|
||||
{
|
||||
"version": 1,
|
||||
"goal": "<the goal>",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"title": "<short descriptive title>",
|
||||
"prompt": "<detailed prompt that Claude Code can execute to complete this task>",
|
||||
"priority": "<high|medium|low>",
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Guidelines:
|
||||
- Break the goal into 3–10 concrete, actionable tasks
|
||||
- Each task should be completable in a single Claude Code session
|
||||
- Prompts should be specific and actionable, not vague
|
||||
- Order tasks logically (dependencies first)
|
||||
- Assign priority: high for critical path, medium for features, low for polish/cleanup
|
||||
- Fill in \`dependsOn\` with IDs of tasks that must complete before this one (use \`[]\` if none)
|
||||
- Write only the JSON file — no explanations needed`;
|
||||
}
|
||||
|
||||
function createPrdStore() {
|
||||
const goal = writable<string>("");
|
||||
const tasks = writable<PrdTask[]>([]);
|
||||
const isGenerating = writable<boolean>(false);
|
||||
const isLoaded = writable<boolean>(false);
|
||||
const isLoading = writable<boolean>(false);
|
||||
const isSaving = writable<boolean>(false);
|
||||
let idCounter = 0;
|
||||
|
||||
async function loadFromFile(workingDirectory: string): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const path = `${workingDirectory}/${PRD_FILENAME}`;
|
||||
const content = await invoke<string>("read_file_content", { path });
|
||||
const data = JSON.parse(content) as PrdFile;
|
||||
goal.set(data.goal);
|
||||
tasks.set(data.tasks);
|
||||
isLoaded.set(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to load PRD file:", error);
|
||||
isLoaded.set(false);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToFile(workingDirectory: string): Promise<boolean> {
|
||||
isSaving.set(true);
|
||||
try {
|
||||
const path = `${workingDirectory}/${PRD_FILENAME}`;
|
||||
const data: PrdFile = {
|
||||
version: 1,
|
||||
goal: get(goal),
|
||||
tasks: get(tasks),
|
||||
};
|
||||
await invoke("write_file_content", { path, content: JSON.stringify(data, null, 2) });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save PRD file:", error);
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePrd(
|
||||
userGoal: string,
|
||||
workingDirectory: string,
|
||||
conversationId: string
|
||||
): Promise<void> {
|
||||
isGenerating.set(true);
|
||||
goal.set(userGoal);
|
||||
try {
|
||||
const prompt = buildPrdPrompt(userGoal, workingDirectory);
|
||||
await invoke("send_prompt", { conversationId, message: prompt });
|
||||
} catch (error) {
|
||||
console.error("Failed to generate PRD:", error);
|
||||
isGenerating.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
function finishGenerating(): void {
|
||||
isGenerating.set(false);
|
||||
}
|
||||
|
||||
async function executePrd(workingDirectory: string, conversationId: string): Promise<void> {
|
||||
await saveToFile(workingDirectory);
|
||||
const currentTasks = get(tasks);
|
||||
const currentGoal = get(goal);
|
||||
const taskList = currentTasks
|
||||
.map((t, i) => `${i + 1}. [${t.priority}] **${t.title}**\n ${t.prompt}`)
|
||||
.join("\n\n");
|
||||
const prompt = `Please execute the following task list for the goal: "${currentGoal}"
|
||||
|
||||
Work through each task in order, completing it fully before moving to the next:
|
||||
|
||||
${taskList}
|
||||
|
||||
The task list is also saved in \`${workingDirectory}/hikari-tasks.json\` for reference.`;
|
||||
await invoke("send_prompt", { conversationId, message: prompt });
|
||||
}
|
||||
|
||||
function addTask(task: Omit<PrdTask, "id">): void {
|
||||
idCounter += 1;
|
||||
const id = `task-${idCounter}`;
|
||||
tasks.update((current) => [...current, { ...task, id }]);
|
||||
}
|
||||
|
||||
function updateTask(id: string, changes: Partial<Omit<PrdTask, "id">>): void {
|
||||
tasks.update((current) => current.map((t) => (t.id === id ? { ...t, ...changes } : t)));
|
||||
}
|
||||
|
||||
function removeTask(id: string): void {
|
||||
tasks.update((current) => current.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
function moveTaskUp(id: string): void {
|
||||
tasks.update((current) => {
|
||||
const index = current.findIndex((t) => t.id === id);
|
||||
if (index <= 0) return current;
|
||||
const result = [...current];
|
||||
[result[index - 1], result[index]] = [result[index], result[index - 1]];
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function moveTaskDown(id: string): void {
|
||||
tasks.update((current) => {
|
||||
const index = current.findIndex((t) => t.id === id);
|
||||
if (index < 0 || index >= current.length - 1) return current;
|
||||
const result = [...current];
|
||||
[result[index], result[index + 1]] = [result[index + 1], result[index]];
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
goal.set("");
|
||||
tasks.set([]);
|
||||
isLoaded.set(false);
|
||||
isGenerating.set(false);
|
||||
idCounter = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
goal: { subscribe: goal.subscribe },
|
||||
tasks: { subscribe: tasks.subscribe },
|
||||
isGenerating: { subscribe: isGenerating.subscribe },
|
||||
isLoaded: { subscribe: isLoaded.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
isSaving: { subscribe: isSaving.subscribe },
|
||||
loadFromFile,
|
||||
saveToFile,
|
||||
generatePrd,
|
||||
finishGenerating,
|
||||
executePrd,
|
||||
addTask,
|
||||
updateTask,
|
||||
removeTask,
|
||||
moveTaskUp,
|
||||
moveTaskDown,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const prdStore = createPrdStore();
|
||||
@@ -0,0 +1,390 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import {
|
||||
projectContextStore,
|
||||
PROJECT_FILE_NAMES,
|
||||
PROJECT_TEMPLATES,
|
||||
PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
injectTextStore,
|
||||
type ProjectFile,
|
||||
type ProjectScan,
|
||||
} from "./projectContext";
|
||||
|
||||
describe("PROJECT_FILE_NAMES", () => {
|
||||
it("maps all five project file types", () => {
|
||||
expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md");
|
||||
expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md");
|
||||
expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md");
|
||||
expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md");
|
||||
expect(PROJECT_FILE_NAMES.CODEBASE).toBe("CODEBASE.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PROJECT_TEMPLATES", () => {
|
||||
const editableFiles: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||
|
||||
it.each(editableFiles)("returns a non-empty template for %s", (file) => {
|
||||
expect(PROJECT_TEMPLATES[file]).toBeTruthy();
|
||||
expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has an empty string template for CODEBASE (auto-generated)", () => {
|
||||
expect(PROJECT_TEMPLATES.CODEBASE).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectContextStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("has null contents for all files", () => {
|
||||
const state = get(projectContextStore.contents);
|
||||
expect(state.PROJECT).toBeNull();
|
||||
expect(state.REQUIREMENTS).toBeNull();
|
||||
expect(state.ROADMAP).toBeNull();
|
||||
expect(state.STATE).toBeNull();
|
||||
expect(state.CODEBASE).toBeNull();
|
||||
});
|
||||
|
||||
it("has false isLoading for all files", () => {
|
||||
const state = get(projectContextStore.isLoading);
|
||||
expect(state.PROJECT).toBe(false);
|
||||
expect(state.REQUIREMENTS).toBe(false);
|
||||
expect(state.ROADMAP).toBe(false);
|
||||
expect(state.STATE).toBe(false);
|
||||
expect(state.CODEBASE).toBe(false);
|
||||
});
|
||||
|
||||
it("has false isSaving for all files", () => {
|
||||
const state = get(projectContextStore.isSaving);
|
||||
expect(state.PROJECT).toBe(false);
|
||||
expect(state.REQUIREMENTS).toBe(false);
|
||||
expect(state.ROADMAP).toBe(false);
|
||||
expect(state.STATE).toBe(false);
|
||||
expect(state.CODEBASE).toBe(false);
|
||||
});
|
||||
|
||||
it("has PROJECT as the default activeFile", () => {
|
||||
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
|
||||
});
|
||||
|
||||
it("has false isMappingCodebase initially", () => {
|
||||
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes all expected methods", () => {
|
||||
expect(typeof projectContextStore.loadFile).toBe("function");
|
||||
expect(typeof projectContextStore.saveFile).toBe("function");
|
||||
expect(typeof projectContextStore.loadAll).toBe("function");
|
||||
expect(typeof projectContextStore.setActiveFile).toBe("function");
|
||||
expect(typeof projectContextStore.getTemplate).toBe("function");
|
||||
expect(typeof projectContextStore.mapCodebase).toBe("function");
|
||||
expect(typeof projectContextStore.finishMapping).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadFile", () => {
|
||||
it("calls read_file_content with the correct path", async () => {
|
||||
setMockInvokeResult("read_file_content", "# Project\n\nContent here");
|
||||
|
||||
await projectContextStore.loadFile("PROJECT", "/home/naomi/myproject");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||
path: "/home/naomi/myproject/PROJECT.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates contents store with file content on success", async () => {
|
||||
const content = "# My Project\n\nDescription here";
|
||||
setMockInvokeResult("read_file_content", content);
|
||||
|
||||
await projectContextStore.loadFile("PROJECT", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.contents).PROJECT).toBe(content);
|
||||
});
|
||||
|
||||
it("sets content to null when file does not exist", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||
|
||||
await projectContextStore.loadFile("REQUIREMENTS", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.contents).REQUIREMENTS).toBeNull();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoading to false after completion", async () => {
|
||||
setMockInvokeResult("read_file_content", "content");
|
||||
|
||||
await projectContextStore.loadFile("ROADMAP", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.isLoading).ROADMAP).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isLoading to false even on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("Read error"));
|
||||
|
||||
await projectContextStore.loadFile("STATE", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.isLoading).STATE).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses correct filename for each file type", async () => {
|
||||
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||
for (const file of files) {
|
||||
setMockInvokeResult("read_file_content", `content for ${file}`);
|
||||
await projectContextStore.loadFile(file, "/wd");
|
||||
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||
path: `/wd/${PROJECT_FILE_NAMES[file]}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveFile", () => {
|
||||
it("calls write_file_content with the correct path and content", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await projectContextStore.saveFile("PROJECT", "# New content", "/home/naomi/myproject");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("write_file_content", {
|
||||
path: "/home/naomi/myproject/PROJECT.md",
|
||||
content: "# New content",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns true on success", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
const result = await projectContextStore.saveFile(
|
||||
"PROJECT",
|
||||
"# Content",
|
||||
"/home/naomi/myproject"
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("updates contents store with saved content on success", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
const newContent = "# Updated Project\n\nNew content";
|
||||
await projectContextStore.saveFile("REQUIREMENTS", newContent, "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.contents).REQUIREMENTS).toBe(newContent);
|
||||
});
|
||||
|
||||
it("returns false and logs error on failure", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||
|
||||
const result = await projectContextStore.saveFile(
|
||||
"ROADMAP",
|
||||
"content",
|
||||
"/home/naomi/myproject"
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to save project context file:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isSaving to false after completion", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await projectContextStore.saveFile("STATE", "content", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.isSaving).STATE).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isSaving to false even on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Error"));
|
||||
|
||||
await projectContextStore.saveFile("PROJECT", "content", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.isSaving).PROJECT).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAll", () => {
|
||||
it("loads all five files in parallel", async () => {
|
||||
setMockInvokeResult("read_file_content", "file content");
|
||||
|
||||
await projectContextStore.loadAll("/home/naomi/myproject");
|
||||
|
||||
const calls = vi.mocked(invoke).mock.calls.filter(([cmd]) => cmd === "read_file_content");
|
||||
const paths = calls.map(([, args]) => (args as { path: string }).path);
|
||||
|
||||
expect(paths).toContain("/home/naomi/myproject/PROJECT.md");
|
||||
expect(paths).toContain("/home/naomi/myproject/REQUIREMENTS.md");
|
||||
expect(paths).toContain("/home/naomi/myproject/ROADMAP.md");
|
||||
expect(paths).toContain("/home/naomi/myproject/STATE.md");
|
||||
expect(paths).toContain("/home/naomi/myproject/CODEBASE.md");
|
||||
});
|
||||
|
||||
it("sets all files isLoading to false after completion", async () => {
|
||||
setMockInvokeResult("read_file_content", "content");
|
||||
|
||||
await projectContextStore.loadAll("/home/naomi/myproject");
|
||||
|
||||
const loadingState = get(projectContextStore.isLoading);
|
||||
expect(loadingState.PROJECT).toBe(false);
|
||||
expect(loadingState.REQUIREMENTS).toBe(false);
|
||||
expect(loadingState.ROADMAP).toBe(false);
|
||||
expect(loadingState.STATE).toBe(false);
|
||||
expect(loadingState.CODEBASE).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setActiveFile", () => {
|
||||
it("updates the activeFile store", () => {
|
||||
projectContextStore.setActiveFile("REQUIREMENTS");
|
||||
expect(get(projectContextStore.activeFile)).toBe("REQUIREMENTS");
|
||||
|
||||
projectContextStore.setActiveFile("STATE");
|
||||
expect(get(projectContextStore.activeFile)).toBe("STATE");
|
||||
|
||||
projectContextStore.setActiveFile("PROJECT");
|
||||
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTemplate", () => {
|
||||
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||
|
||||
it.each(files)("returns a non-empty string for %s", (file) => {
|
||||
const template = projectContextStore.getTemplate(file);
|
||||
expect(typeof template).toBe("string");
|
||||
expect(template.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns distinct templates for each file type", () => {
|
||||
const templates = files.map((f) => projectContextStore.getTemplate(f));
|
||||
const uniqueTemplates = new Set(templates);
|
||||
expect(uniqueTemplates.size).toBe(files.length);
|
||||
});
|
||||
|
||||
it("returns empty string for CODEBASE", () => {
|
||||
expect(projectContextStore.getTemplate("CODEBASE")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCodebase", () => {
|
||||
const mockScan: ProjectScan = {
|
||||
working_dir: "/home/naomi/myproject",
|
||||
file_tree: "/home/naomi/myproject/\n├── src/\n└── package.json",
|
||||
detected_type: "Node.js",
|
||||
key_files: ["package.json"],
|
||||
};
|
||||
|
||||
it("calls scan_project with the working directory", async () => {
|
||||
setMockInvokeResult("scan_project", mockScan);
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("scan_project", {
|
||||
workingDir: "/home/naomi/myproject",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls send_prompt with the conversation id and a non-empty prompt", async () => {
|
||||
setMockInvokeResult("scan_project", mockScan);
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: expect.stringContaining("CODEBASE.md"),
|
||||
});
|
||||
});
|
||||
|
||||
it("prompt includes detected project type", async () => {
|
||||
setMockInvokeResult("scan_project", mockScan);
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("Node.js");
|
||||
});
|
||||
|
||||
it("prompt includes file tree", async () => {
|
||||
setMockInvokeResult("scan_project", mockScan);
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||
|
||||
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||
expect(message).toContain("package.json");
|
||||
});
|
||||
|
||||
it("resets isMappingCodebase to false on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("scan_project", new Error("Scan failed"));
|
||||
|
||||
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs error when scan_project fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("scan_project", new Error("Scan failed"));
|
||||
|
||||
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to map codebase:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("finishMapping", () => {
|
||||
it("sets isMappingCodebase to false", () => {
|
||||
projectContextStore.finishMapping();
|
||||
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PROJECT_CONTEXT_SYSTEM_ADDENDUM", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof PROJECT_CONTEXT_SYSTEM_ADDENDUM).toBe("string");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mentions all five context file names", () => {
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("CODEBASE.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectTextStore", () => {
|
||||
it("initialises to null", () => {
|
||||
expect(get(injectTextStore)).toBeNull();
|
||||
});
|
||||
|
||||
it("can be set and read", () => {
|
||||
injectTextStore.set("hello world");
|
||||
expect(get(injectTextStore)).toBe("hello world");
|
||||
injectTextStore.set(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE" | "CODEBASE";
|
||||
|
||||
export const PROJECT_FILE_NAMES: Record<ProjectFile, string> = {
|
||||
PROJECT: "PROJECT.md",
|
||||
REQUIREMENTS: "REQUIREMENTS.md",
|
||||
ROADMAP: "ROADMAP.md",
|
||||
STATE: "STATE.md",
|
||||
CODEBASE: "CODEBASE.md",
|
||||
};
|
||||
|
||||
export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
|
||||
PROJECT: `# Project Overview
|
||||
|
||||
## What is this project?
|
||||
|
||||
## Goals
|
||||
|
||||
## Tech Stack
|
||||
|
||||
## Architecture
|
||||
`,
|
||||
REQUIREMENTS: `# Requirements
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
## Out of Scope
|
||||
`,
|
||||
ROADMAP: `# Roadmap
|
||||
|
||||
## Current Sprint
|
||||
|
||||
## Next Sprint
|
||||
|
||||
## Backlog
|
||||
|
||||
## Completed
|
||||
`,
|
||||
STATE: `# Current State
|
||||
|
||||
## Last Updated
|
||||
|
||||
## What's Working
|
||||
|
||||
## In Progress
|
||||
|
||||
## Known Issues
|
||||
|
||||
## Next Steps
|
||||
`,
|
||||
CODEBASE: "",
|
||||
};
|
||||
|
||||
const PROJECT_FILES = Object.keys(PROJECT_FILE_NAMES) as ProjectFile[];
|
||||
|
||||
export interface ProjectScan {
|
||||
working_dir: string;
|
||||
file_tree: string;
|
||||
detected_type: string;
|
||||
key_files: string[];
|
||||
}
|
||||
|
||||
function createProjectContextStore() {
|
||||
const contents = writable<Record<ProjectFile, string | null>>({
|
||||
PROJECT: null,
|
||||
REQUIREMENTS: null,
|
||||
ROADMAP: null,
|
||||
STATE: null,
|
||||
CODEBASE: null,
|
||||
});
|
||||
|
||||
const isLoading = writable<Record<ProjectFile, boolean>>({
|
||||
PROJECT: false,
|
||||
REQUIREMENTS: false,
|
||||
ROADMAP: false,
|
||||
STATE: false,
|
||||
CODEBASE: false,
|
||||
});
|
||||
|
||||
const isSaving = writable<Record<ProjectFile, boolean>>({
|
||||
PROJECT: false,
|
||||
REQUIREMENTS: false,
|
||||
ROADMAP: false,
|
||||
STATE: false,
|
||||
CODEBASE: false,
|
||||
});
|
||||
|
||||
const activeFile = writable<ProjectFile>("PROJECT");
|
||||
const isMappingCodebase = writable<boolean>(false);
|
||||
|
||||
async function loadFile(file: ProjectFile, workingDirectory: string): Promise<void> {
|
||||
isLoading.update((state) => ({ ...state, [file]: true }));
|
||||
try {
|
||||
const path = `${workingDirectory}/${PROJECT_FILE_NAMES[file]}`;
|
||||
const content = await invoke<string>("read_file_content", { path });
|
||||
contents.update((state) => ({ ...state, [file]: content }));
|
||||
} catch {
|
||||
contents.update((state) => ({ ...state, [file]: null }));
|
||||
} finally {
|
||||
isLoading.update((state) => ({ ...state, [file]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(
|
||||
file: ProjectFile,
|
||||
content: string,
|
||||
workingDirectory: string
|
||||
): Promise<boolean> {
|
||||
isSaving.update((state) => ({ ...state, [file]: true }));
|
||||
try {
|
||||
const path = `${workingDirectory}/${PROJECT_FILE_NAMES[file]}`;
|
||||
await invoke("write_file_content", { path, content });
|
||||
contents.update((state) => ({ ...state, [file]: content }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save project context file:", error);
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.update((state) => ({ ...state, [file]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll(workingDirectory: string): Promise<void> {
|
||||
await Promise.all(PROJECT_FILES.map((file) => loadFile(file, workingDirectory)));
|
||||
}
|
||||
|
||||
function setActiveFile(file: ProjectFile): void {
|
||||
activeFile.set(file);
|
||||
}
|
||||
|
||||
function getTemplate(file: ProjectFile): string {
|
||||
return PROJECT_TEMPLATES[file];
|
||||
}
|
||||
|
||||
async function mapCodebase(workingDirectory: string, conversationId: string): Promise<void> {
|
||||
isMappingCodebase.set(true);
|
||||
try {
|
||||
const scan = await invoke<ProjectScan>("scan_project", {
|
||||
workingDir: workingDirectory,
|
||||
});
|
||||
|
||||
const prompt = buildCodebaseMapPrompt(scan);
|
||||
await invoke("send_prompt", { conversationId, message: prompt });
|
||||
} catch (error) {
|
||||
console.error("Failed to map codebase:", error);
|
||||
isMappingCodebase.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
function finishMapping(): void {
|
||||
isMappingCodebase.set(false);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: { subscribe: contents.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
isSaving: { subscribe: isSaving.subscribe },
|
||||
activeFile: { subscribe: activeFile.subscribe },
|
||||
isMappingCodebase: { subscribe: isMappingCodebase.subscribe },
|
||||
loadFile,
|
||||
saveFile,
|
||||
loadAll,
|
||||
setActiveFile,
|
||||
getTemplate,
|
||||
mapCodebase,
|
||||
finishMapping,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodebaseMapPrompt(scan: ProjectScan): string {
|
||||
const keyFilesSection =
|
||||
scan.key_files.length > 0
|
||||
? `\n\nKey files detected:\n${scan.key_files.map((f) => `- ${f}`).join("\n")}`
|
||||
: "";
|
||||
|
||||
return `Please analyse this codebase and generate a comprehensive \`CODEBASE.md\` file in the working directory (${scan.working_dir}).
|
||||
|
||||
Project type detected: **${scan.detected_type}**${keyFilesSection}
|
||||
|
||||
Directory structure:
|
||||
\`\`\`
|
||||
${scan.file_tree}
|
||||
\`\`\`
|
||||
|
||||
The CODEBASE.md file should include:
|
||||
1. **Overview** — what the project does and its purpose
|
||||
2. **Architecture** — key directories, how the code is organised, and the overall structure
|
||||
3. **Key Components** — the most important files and modules, what they do, and how they interact
|
||||
4. **Data Flow** — how data moves through the system (if applicable)
|
||||
5. **Dependencies** — notable external dependencies and why they are used
|
||||
6. **Development Notes** — anything helpful for a developer new to the codebase
|
||||
|
||||
Write the file concisely but thoroughly. Focus on information that helps a developer understand the codebase quickly. Use the actual file structure above to inform your analysis — read the key files as needed before writing.`;
|
||||
}
|
||||
|
||||
export const projectContextStore = createProjectContextStore();
|
||||
|
||||
// Signal store for injecting context into the active InputBar.
|
||||
// StatusBar sets this; InputBar subscribes and applies it to inputValue directly,
|
||||
// then resets it to null so the signal only fires once.
|
||||
export const injectTextStore = writable<string | null>(null);
|
||||
|
||||
// Appended silently to custom_instructions at connection time (never saved to config).
|
||||
// Mirrors how CLAUDE.md works natively — Claude checks the files itself if they exist.
|
||||
export const PROJECT_CONTEXT_SYSTEM_ADDENDUM = `
|
||||
|
||||
---
|
||||
The following project context files may exist in your working directory. If they exist, read and refer to them as needed:
|
||||
- PROJECT.md — project overview, goals, and architecture
|
||||
- REQUIREMENTS.md — functional and non-functional requirements
|
||||
- ROADMAP.md — current sprint, backlog, and completed work
|
||||
- STATE.md — current state, known issues, and next steps
|
||||
- CODEBASE.md — auto-generated codebase map and architecture overview`;
|
||||
@@ -0,0 +1,325 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
findNextPendingIndex,
|
||||
countByStatus,
|
||||
buildTaskPrompt,
|
||||
buildAutoCommitPrompt,
|
||||
normalizeToUnixPath,
|
||||
isTaskBlocked,
|
||||
getReadyTasks,
|
||||
computeWaves,
|
||||
type TaskLoopTask,
|
||||
} from "./taskLoop";
|
||||
|
||||
const makeTask = (
|
||||
id: string,
|
||||
status: TaskLoopTask["status"] = "pending",
|
||||
dependsOn?: string[]
|
||||
): TaskLoopTask => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
prompt: `Do the thing for ${id}`,
|
||||
priority: "medium",
|
||||
status,
|
||||
dependsOn,
|
||||
});
|
||||
|
||||
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("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(
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTaskBlocked", () => {
|
||||
it("returns false when dependsOn is empty", () => {
|
||||
const task = makeTask("a", "pending", []);
|
||||
expect(isTaskBlocked(task, [task])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when dependsOn is undefined", () => {
|
||||
const task = makeTask("a", "pending");
|
||||
expect(isTaskBlocked(task, [task])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when all dependencies are completed", () => {
|
||||
const dep = makeTask("dep", "completed");
|
||||
const task = makeTask("a", "pending", ["dep"]);
|
||||
expect(isTaskBlocked(task, [dep, task])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a dependency has failed", () => {
|
||||
const dep = makeTask("dep", "failed");
|
||||
const task = makeTask("a", "pending", ["dep"]);
|
||||
expect(isTaskBlocked(task, [dep, task])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when a dependency is blocked", () => {
|
||||
const dep = makeTask("dep", "blocked");
|
||||
const task = makeTask("a", "pending", ["dep"]);
|
||||
expect(isTaskBlocked(task, [dep, task])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when a dependency is still pending (not yet failed)", () => {
|
||||
const dep = makeTask("dep", "pending");
|
||||
const task = makeTask("a", "pending", ["dep"]);
|
||||
expect(isTaskBlocked(task, [dep, task])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when dependency ID does not exist in task list", () => {
|
||||
const task = makeTask("a", "pending", ["nonexistent"]);
|
||||
expect(isTaskBlocked(task, [task])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReadyTasks", () => {
|
||||
it("returns empty array when task list is empty", () => {
|
||||
expect(getReadyTasks([], 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all pending tasks with no deps when under limit", () => {
|
||||
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
|
||||
expect(getReadyTasks(tasks, 5)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("respects the concurrency limit", () => {
|
||||
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
|
||||
expect(getReadyTasks(tasks, 2)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("skips tasks whose dependencies are not completed", () => {
|
||||
const tasks = [makeTask("a", "pending"), makeTask("b", "pending", ["a"])];
|
||||
// b depends on a which is pending, not completed — so only a is ready
|
||||
expect(getReadyTasks(tasks, 5)).toEqual([0]);
|
||||
});
|
||||
|
||||
it("includes task when all its dependencies are completed", () => {
|
||||
const tasks = [makeTask("a", "completed"), makeTask("b", "pending", ["a"])];
|
||||
expect(getReadyTasks(tasks, 5)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("skips running, completed, failed, and blocked tasks", () => {
|
||||
const tasks = [
|
||||
makeTask("a", "running"),
|
||||
makeTask("b", "completed"),
|
||||
makeTask("c", "failed"),
|
||||
makeTask("d", "blocked"),
|
||||
makeTask("e", "pending"),
|
||||
];
|
||||
expect(getReadyTasks(tasks, 5)).toEqual([4]);
|
||||
});
|
||||
|
||||
it("returns empty when limit is 0", () => {
|
||||
const tasks = [makeTask("a", "pending")];
|
||||
expect(getReadyTasks(tasks, 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeWaves", () => {
|
||||
it("returns empty array for empty task list", () => {
|
||||
expect(computeWaves([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts all independent tasks in a single wave", () => {
|
||||
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
|
||||
expect(computeWaves(tasks)).toEqual([[0, 1, 2]]);
|
||||
});
|
||||
|
||||
it("creates one wave per task for a linear chain", () => {
|
||||
const tasks = [
|
||||
makeTask("a", "pending"),
|
||||
makeTask("b", "pending", ["a"]),
|
||||
makeTask("c", "pending", ["b"]),
|
||||
];
|
||||
expect(computeWaves(tasks)).toEqual([[0], [1], [2]]);
|
||||
});
|
||||
|
||||
it("handles diamond dependency: A → B,C → D", () => {
|
||||
// A has no deps, B and C depend on A, D depends on B and C
|
||||
const tasks = [
|
||||
makeTask("a", "pending"),
|
||||
makeTask("b", "pending", ["a"]),
|
||||
makeTask("c", "pending", ["a"]),
|
||||
makeTask("d", "pending", ["b", "c"]),
|
||||
];
|
||||
const waves = computeWaves(tasks);
|
||||
expect(waves).toHaveLength(3);
|
||||
expect(waves[0]).toEqual([0]);
|
||||
expect(waves[1]).toEqual([1, 2]);
|
||||
expect(waves[2]).toEqual([3]);
|
||||
});
|
||||
|
||||
it("groups circular dependencies into a final overflow wave", () => {
|
||||
// a→b, b→a — circular; c has no deps so goes in wave 0
|
||||
const tasks = [
|
||||
makeTask("a", "pending", ["b"]),
|
||||
makeTask("b", "pending", ["a"]),
|
||||
makeTask("c", "pending"),
|
||||
];
|
||||
const waves = computeWaves(tasks);
|
||||
// c goes in wave 0, then a+b get dumped in overflow
|
||||
expect(waves[0]).toEqual([2]);
|
||||
expect(waves[1]).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("ignores unknown dependency IDs (treats them as satisfied)", () => {
|
||||
const tasks = [makeTask("a", "pending", ["nonexistent"])];
|
||||
expect(computeWaves(tasks)).toEqual([[0]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
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<number>();
|
||||
|
||||
// Build an id→index map
|
||||
const idToIndex = new Map<string, number>();
|
||||
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\<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 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<TaskLoopTask[]>([]);
|
||||
const loopStatus = writable<LoopStatus>("idle");
|
||||
const currentTaskIndex = writable<number>(-1);
|
||||
const sourceFile = writable<string>("");
|
||||
const concurrencyLimit = writable<number>(3);
|
||||
|
||||
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 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();
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildDiscussPrompt,
|
||||
buildVerifyPrompt,
|
||||
canAdvancePhase,
|
||||
canGoBack,
|
||||
getPhaseLabel,
|
||||
generateCriterionId,
|
||||
type WorkflowState,
|
||||
type VerifyCriterion,
|
||||
} from "./workflow";
|
||||
|
||||
// ─── buildDiscussPrompt ───────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDiscussPrompt", () => {
|
||||
it("includes the description in the output", () => {
|
||||
const prompt = buildDiscussPrompt("Build a user authentication system");
|
||||
expect(prompt).toContain("Build a user authentication system");
|
||||
});
|
||||
|
||||
it("references CONTEXT.md", () => {
|
||||
const prompt = buildDiscussPrompt("some project");
|
||||
expect(prompt).toContain("CONTEXT.md");
|
||||
});
|
||||
|
||||
it("mentions acceptance criteria section", () => {
|
||||
const prompt = buildDiscussPrompt("some project");
|
||||
expect(prompt.toLowerCase()).toContain("acceptance criteria");
|
||||
});
|
||||
|
||||
it("returns a non-empty string for any description", () => {
|
||||
expect(buildDiscussPrompt("a").length).toBeGreaterThan(0);
|
||||
expect(buildDiscussPrompt("very long description ".repeat(20)).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildVerifyPrompt ────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildVerifyPrompt", () => {
|
||||
it("references VERIFY.md", () => {
|
||||
const prompt = buildVerifyPrompt([]);
|
||||
expect(prompt).toContain("VERIFY.md");
|
||||
});
|
||||
|
||||
it("handles empty criteria list gracefully", () => {
|
||||
const prompt = buildVerifyPrompt([]);
|
||||
expect(prompt.length).toBeGreaterThan(0);
|
||||
expect(prompt).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("includes all criteria text in the output", () => {
|
||||
const criteria: VerifyCriterion[] = [
|
||||
{ id: "c1", text: "Login must work", status: "pending" },
|
||||
{ id: "c2", text: "Tests must pass", status: "pending" },
|
||||
];
|
||||
const prompt = buildVerifyPrompt(criteria);
|
||||
expect(prompt).toContain("Login must work");
|
||||
expect(prompt).toContain("Tests must pass");
|
||||
});
|
||||
|
||||
it("numbers criteria starting from 1", () => {
|
||||
const criteria: VerifyCriterion[] = [{ id: "c1", text: "First criterion", status: "pending" }];
|
||||
const prompt = buildVerifyPrompt(criteria);
|
||||
expect(prompt).toContain("1. First criterion");
|
||||
});
|
||||
|
||||
it("includes all criteria when multiple are provided", () => {
|
||||
const criteria: VerifyCriterion[] = [
|
||||
{ id: "c1", text: "Alpha", status: "pending" },
|
||||
{ id: "c2", text: "Beta", status: "pending" },
|
||||
{ id: "c3", text: "Gamma", status: "pending" },
|
||||
];
|
||||
const prompt = buildVerifyPrompt(criteria);
|
||||
expect(prompt).toContain("1. Alpha");
|
||||
expect(prompt).toContain("2. Beta");
|
||||
expect(prompt).toContain("3. Gamma");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canAdvancePhase ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeState(overrides: Partial<WorkflowState> = {}): WorkflowState {
|
||||
return {
|
||||
version: 1,
|
||||
currentPhase: 1,
|
||||
quickMode: false,
|
||||
discuss: { description: "", contextCaptured: false },
|
||||
plan: { tasksApproved: false },
|
||||
execute: { completed: false },
|
||||
verify: { criteria: [], verificationComplete: false, report: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("canAdvancePhase", () => {
|
||||
describe("phase 1 (Discuss)", () => {
|
||||
it("returns false when context not captured and not quick mode", () => {
|
||||
const state = makeState({ currentPhase: 1 });
|
||||
expect(canAdvancePhase(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when context is captured", () => {
|
||||
const state = makeState({
|
||||
currentPhase: 1,
|
||||
discuss: { description: "test", contextCaptured: true },
|
||||
});
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true in quick mode even without context captured", () => {
|
||||
const state = makeState({ currentPhase: 1, quickMode: true });
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase 2 (Plan)", () => {
|
||||
it("returns false when plan not approved", () => {
|
||||
const state = makeState({ currentPhase: 2 });
|
||||
expect(canAdvancePhase(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when plan is approved", () => {
|
||||
const state = makeState({ currentPhase: 2, plan: { tasksApproved: true } });
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase 3 (Execute)", () => {
|
||||
it("returns false when execution not completed", () => {
|
||||
const state = makeState({ currentPhase: 3 });
|
||||
expect(canAdvancePhase(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when execution is completed", () => {
|
||||
const state = makeState({ currentPhase: 3, execute: { completed: true } });
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase 4 (Verify)", () => {
|
||||
it("returns false when verification not complete", () => {
|
||||
const state = makeState({ currentPhase: 4 });
|
||||
expect(canAdvancePhase(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when verification is complete", () => {
|
||||
const state = makeState({
|
||||
currentPhase: 4,
|
||||
verify: { criteria: [], verificationComplete: true, report: "All good" },
|
||||
});
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canGoBack ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("canGoBack", () => {
|
||||
it("returns false for phase 1", () => {
|
||||
expect(canGoBack(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for phase 2", () => {
|
||||
expect(canGoBack(2)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for phase 3", () => {
|
||||
expect(canGoBack(3)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for phase 4", () => {
|
||||
expect(canGoBack(4)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getPhaseLabel ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getPhaseLabel", () => {
|
||||
it("returns Discuss for phase 1", () => {
|
||||
expect(getPhaseLabel(1)).toBe("Discuss");
|
||||
});
|
||||
|
||||
it("returns Plan for phase 2", () => {
|
||||
expect(getPhaseLabel(2)).toBe("Plan");
|
||||
});
|
||||
|
||||
it("returns Execute for phase 3", () => {
|
||||
expect(getPhaseLabel(3)).toBe("Execute");
|
||||
});
|
||||
|
||||
it("returns Verify for phase 4", () => {
|
||||
expect(getPhaseLabel(4)).toBe("Verify");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── generateCriterionId ─────────────────────────────────────────────────────
|
||||
|
||||
describe("generateCriterionId", () => {
|
||||
it("returns a non-empty string", () => {
|
||||
expect(generateCriterionId().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns unique IDs on successive calls", () => {
|
||||
const ids = new Set(Array.from({ length: 20 }, () => generateCriterionId()));
|
||||
expect(ids.size).toBe(20);
|
||||
});
|
||||
|
||||
it("starts with the expected prefix", () => {
|
||||
expect(generateCriterionId()).toMatch(/^criterion-/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type WorkflowPhase = 1 | 2 | 3 | 4;
|
||||
export type CriterionStatus = "pending" | "pass" | "fail" | "partial";
|
||||
|
||||
export interface VerifyCriterion {
|
||||
id: string;
|
||||
text: string;
|
||||
status: CriterionStatus;
|
||||
}
|
||||
|
||||
export interface WorkflowState {
|
||||
version: 1;
|
||||
currentPhase: WorkflowPhase;
|
||||
quickMode: boolean;
|
||||
discuss: {
|
||||
description: string;
|
||||
contextCaptured: boolean;
|
||||
};
|
||||
plan: {
|
||||
tasksApproved: boolean;
|
||||
};
|
||||
execute: {
|
||||
completed: boolean;
|
||||
};
|
||||
verify: {
|
||||
criteria: VerifyCriterion[];
|
||||
verificationComplete: boolean;
|
||||
report: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const WORKFLOW_STATE_FILENAME = "workflow-state.json";
|
||||
|
||||
const DEFAULT_STATE: WorkflowState = {
|
||||
version: 1,
|
||||
currentPhase: 1,
|
||||
quickMode: false,
|
||||
discuss: { description: "", contextCaptured: false },
|
||||
plan: { tasksApproved: false },
|
||||
execute: { completed: false },
|
||||
verify: { criteria: [], verificationComplete: false, report: "" },
|
||||
};
|
||||
|
||||
// ─── Pure functions (exported for testing) ───────────────────────────────────
|
||||
|
||||
export function buildDiscussPrompt(description: string): string {
|
||||
return `Please help me clarify and document the following project goal, then write a \`CONTEXT.md\` file in the working directory.
|
||||
|
||||
Project description:
|
||||
${description}
|
||||
|
||||
The \`CONTEXT.md\` file should include:
|
||||
|
||||
## Goal
|
||||
A clear, one-paragraph statement of what we are building and why.
|
||||
|
||||
## Scope
|
||||
What is in scope and what is explicitly out of scope.
|
||||
|
||||
## Acceptance Criteria
|
||||
A numbered list of concrete, verifiable criteria that must be met for this project to be considered complete. Each criterion should be specific and testable.
|
||||
|
||||
## Key Assumptions
|
||||
Any assumptions being made about the implementation, environment, or user needs.
|
||||
|
||||
## Open Questions
|
||||
Any questions that need to be resolved before or during development.
|
||||
|
||||
Write the file concisely but thoroughly. Focus on information that guides implementation and defines success.`;
|
||||
}
|
||||
|
||||
export function buildVerifyPrompt(criteria: VerifyCriterion[]): string {
|
||||
if (criteria.length === 0) {
|
||||
return `Please review the project implementation and write a \`VERIFY.md\` file in the working directory with your overall assessment.
|
||||
|
||||
Include:
|
||||
## Summary
|
||||
Overall pass/fail assessment.
|
||||
|
||||
## Findings
|
||||
What you found when reviewing the implementation.
|
||||
|
||||
## Recommendation
|
||||
Whether the project is ready to ship or what remains to be done.`;
|
||||
}
|
||||
|
||||
const criteriaList = criteria.map((c, i) => `${i + 1}. ${c.text}`).join("\n");
|
||||
|
||||
return `Please verify the project implementation against the following acceptance criteria and write a \`VERIFY.md\` file in the working directory.
|
||||
|
||||
Acceptance criteria:
|
||||
${criteriaList}
|
||||
|
||||
For each criterion, check whether it is met by examining the codebase and any relevant files. Then write \`VERIFY.md\` with:
|
||||
|
||||
## Summary
|
||||
Overall PASSED / FAILED / PARTIAL status.
|
||||
|
||||
## Criterion Results
|
||||
For each criterion: state whether it PASSES, FAILS, or is PARTIAL, with a brief explanation.
|
||||
|
||||
## Findings
|
||||
Any notable issues, edge cases, or improvements spotted during review.
|
||||
|
||||
## Recommendation
|
||||
Whether the project is ready to ship or what remains to be done.`;
|
||||
}
|
||||
|
||||
export function canAdvancePhase(state: WorkflowState): boolean {
|
||||
switch (state.currentPhase) {
|
||||
case 1:
|
||||
return state.quickMode || state.discuss.contextCaptured;
|
||||
case 2:
|
||||
return state.plan.tasksApproved;
|
||||
case 3:
|
||||
return state.execute.completed;
|
||||
case 4:
|
||||
return state.verify.verificationComplete;
|
||||
}
|
||||
}
|
||||
|
||||
export function canGoBack(phase: WorkflowPhase): boolean {
|
||||
return phase > 1;
|
||||
}
|
||||
|
||||
export function getPhaseLabel(phase: WorkflowPhase): string {
|
||||
switch (phase) {
|
||||
case 1:
|
||||
return "Discuss";
|
||||
case 2:
|
||||
return "Plan";
|
||||
case 3:
|
||||
return "Execute";
|
||||
case 4:
|
||||
return "Verify";
|
||||
}
|
||||
}
|
||||
|
||||
export function generateCriterionId(): string {
|
||||
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function createWorkflowStore() {
|
||||
const state = writable<WorkflowState>({ ...DEFAULT_STATE });
|
||||
|
||||
async function loadState(workingDirectory: string): Promise<void> {
|
||||
try {
|
||||
const path = `${workingDirectory}/${WORKFLOW_STATE_FILENAME}`;
|
||||
const content = await invoke<string>("read_file_content", { path });
|
||||
const parsed = JSON.parse(content) as WorkflowState;
|
||||
state.set(parsed);
|
||||
} catch {
|
||||
state.set({ ...DEFAULT_STATE });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState(workingDirectory: string): Promise<void> {
|
||||
try {
|
||||
const path = `${workingDirectory}/${WORKFLOW_STATE_FILENAME}`;
|
||||
const current = get(state);
|
||||
await invoke("write_file_content", { path, content: JSON.stringify(current, null, 2) });
|
||||
} catch (error) {
|
||||
console.error("Failed to save workflow state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function setPhase(phase: WorkflowPhase): void {
|
||||
state.update((s) => ({ ...s, currentPhase: phase }));
|
||||
}
|
||||
|
||||
function setQuickMode(value: boolean): void {
|
||||
state.update((s) => ({ ...s, quickMode: value }));
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
state.set({ ...DEFAULT_STATE });
|
||||
}
|
||||
|
||||
function setDiscussDescription(text: string): void {
|
||||
state.update((s) => ({ ...s, discuss: { ...s.discuss, description: text } }));
|
||||
}
|
||||
|
||||
function markContextCaptured(): void {
|
||||
state.update((s) => ({ ...s, discuss: { ...s.discuss, contextCaptured: true } }));
|
||||
}
|
||||
|
||||
function approvePlan(): void {
|
||||
state.update((s) => ({ ...s, plan: { tasksApproved: true } }));
|
||||
}
|
||||
|
||||
function completeExecution(): void {
|
||||
state.update((s) => ({ ...s, execute: { completed: true } }));
|
||||
}
|
||||
|
||||
function addCriterion(text: string): void {
|
||||
const criterion: VerifyCriterion = {
|
||||
id: generateCriterionId(),
|
||||
text,
|
||||
status: "pending",
|
||||
};
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
verify: { ...s.verify, criteria: [...s.verify.criteria, criterion] },
|
||||
}));
|
||||
}
|
||||
|
||||
function removeCriterion(id: string): void {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
verify: { ...s.verify, criteria: s.verify.criteria.filter((c) => c.id !== id) },
|
||||
}));
|
||||
}
|
||||
|
||||
function updateCriterionStatus(id: string, status: CriterionStatus): void {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
verify: {
|
||||
...s.verify,
|
||||
criteria: s.verify.criteria.map((c) => (c.id === id ? { ...c, status } : c)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function completeVerification(report: string): void {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
verify: { ...s.verify, verificationComplete: true, report },
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
state: { subscribe: state.subscribe },
|
||||
loadState,
|
||||
saveState,
|
||||
setPhase,
|
||||
setQuickMode,
|
||||
reset,
|
||||
setDiscussDescription,
|
||||
markContextCaptured,
|
||||
approvePlan,
|
||||
completeExecution,
|
||||
addCriterion,
|
||||
removeCriterion,
|
||||
updateCriterionStatus,
|
||||
completeVerification,
|
||||
};
|
||||
}
|
||||
|
||||
export const workflowStore = createWorkflowStore();
|
||||
Reference in New Issue
Block a user