feat: add guided project workflow panel (Discuss → Plan → Execute → Verify)

This commit is contained in:
2026-03-07 00:17:38 -08:00
committed by Naomi Carrigan
parent 987598a47c
commit 7911d67d0d
4 changed files with 1323 additions and 0 deletions
+26
View File
@@ -23,6 +23,7 @@
import PrdPanel from "./PrdPanel.svelte"; import PrdPanel from "./PrdPanel.svelte";
import ChangelogPanel from "./ChangelogPanel.svelte"; import ChangelogPanel from "./ChangelogPanel.svelte";
import TaskLoopPanel from "./TaskLoopPanel.svelte"; import TaskLoopPanel from "./TaskLoopPanel.svelte";
import WorkflowPanel from "./WorkflowPanel.svelte";
import { injectTextStore } from "$lib/stores/projectContext"; import { injectTextStore } from "$lib/stores/projectContext";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -67,6 +68,7 @@
let showPrdPanel = $state(false); let showPrdPanel = $state(false);
let showChangelog = $state(false); let showChangelog = $state(false);
let showTaskLoop = $state(false); let showTaskLoop = $state(false);
let showWorkflowPanel = $state(false);
const progress = $derived($achievementProgress); const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount); const activeAgentCount = $derived($runningAgentCount);
@@ -245,6 +247,15 @@
<span>Task Loop</span> <span>Task Loop</span>
</button> </button>
<!-- Guided Workflow -->
<button onclick={menuAction(() => (showWorkflowPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12h18" />
</svg>
<span>Guided Workflow</span>
</button>
<!-- File Editor --> <!-- File Editor -->
<button <button
onclick={menuAction(() => editorStore.toggleEditor())} onclick={menuAction(() => editorStore.toggleEditor())}
@@ -513,6 +524,21 @@
<TaskLoopPanel onClose={() => (showTaskLoop = false)} /> <TaskLoopPanel onClose={() => (showTaskLoop = false)} />
{/if} {/if}
{#if showWorkflowPanel}
<WorkflowPanel
onClose={() => (showWorkflowPanel = false)}
onOpenPrdPanel={() => {
showWorkflowPanel = false;
showPrdPanel = true;
}}
onOpenTaskLoop={() => {
showWorkflowPanel = false;
showTaskLoop = true;
}}
workingDirectory={workingDirectory || selectedDirectory}
/>
{/if}
<style> <style>
.nav-item { .nav-item {
display: flex; display: flex;
+833
View File
@@ -0,0 +1,833 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import {
workflowStore,
buildDiscussPrompt,
buildVerifyPrompt,
canAdvancePhase,
canGoBack,
getPhaseLabel,
type WorkflowPhase,
type CriterionStatus,
} from "$lib/stores/workflow";
import { prdStore } from "$lib/stores/prd";
import { taskLoopStore, countByStatus } from "$lib/stores/taskLoop";
import { characterState } from "$lib/stores/character";
import { claudeStore } from "$lib/stores/claude";
interface Props {
onClose: () => void;
onOpenPrdPanel: () => void;
onOpenTaskLoop: () => void;
workingDirectory: string;
}
const { onClose, onOpenPrdPanel, onOpenTaskLoop, workingDirectory }: Props = $props();
const workflowState = $derived(workflowStore.state);
const prdTasks = $derived(prdStore.tasks);
const prdIsLoaded = $derived(prdStore.isLoaded);
const loopTasks = $derived(taskLoopStore.tasks);
const loopStatus = $derived(taskLoopStore.loopStatus);
let previousCharacterState = $state<string>("idle");
let isWaitingForDiscuss = $state(false);
let isWaitingForVerify = $state(false);
let contextContent = $state<string | null>(null);
let verifyContent = $state<string | null>(null);
let newCriterionText = $state("");
let isLoadingContext = $state(false);
let isLoadingVerify = $state(false);
const PHASES: WorkflowPhase[] = [1, 2, 3, 4];
onMount(async () => {
await workflowStore.loadState(workingDirectory);
await prdStore.loadFromFile(workingDirectory);
await tryLoadContextFile();
await tryLoadVerifyFile();
});
// Watch characterState to detect when Claude finishes working
$effect(() => {
const currentState = $characterState;
if (isWaitingForDiscuss && previousCharacterState !== "idle" && currentState === "idle") {
isWaitingForDiscuss = false;
void tryLoadContextFile().then(() => {
if (contextContent !== null) {
workflowStore.markContextCaptured();
void workflowStore.saveState(workingDirectory);
}
});
}
if (isWaitingForVerify && previousCharacterState !== "idle" && currentState === "idle") {
isWaitingForVerify = false;
void tryLoadVerifyFile().then(() => {
if (verifyContent !== null) {
workflowStore.completeVerification(verifyContent);
void workflowStore.saveState(workingDirectory);
}
});
}
previousCharacterState = currentState;
});
async function tryLoadContextFile(): Promise<void> {
isLoadingContext = true;
try {
const content = await invoke<string>("read_file_content", {
path: `${workingDirectory}/CONTEXT.md`,
});
contextContent = content;
} catch {
contextContent = null;
} finally {
isLoadingContext = false;
}
}
async function tryLoadVerifyFile(): Promise<void> {
isLoadingVerify = true;
try {
const content = await invoke<string>("read_file_content", {
path: `${workingDirectory}/VERIFY.md`,
});
verifyContent = content;
} catch {
verifyContent = null;
} finally {
isLoadingVerify = false;
}
}
async function handleStartDiscussion(): Promise<void> {
const description = $workflowState.discuss.description.trim();
if (!description) return;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
const prompt = buildDiscussPrompt(description);
isWaitingForDiscuss = true;
await invoke("send_prompt", { conversationId, message: prompt });
}
async function handleQuickCaptureContext(): Promise<void> {
const description = $workflowState.discuss.description.trim();
if (!description) return;
const content = `# CONTEXT\n\n## Goal\n\n${description}\n`;
await invoke("write_file_content", { path: `${workingDirectory}/CONTEXT.md`, content });
contextContent = content;
workflowStore.markContextCaptured();
await workflowStore.saveState(workingDirectory);
}
async function handleApprovePlan(): Promise<void> {
workflowStore.approvePlan();
await workflowStore.saveState(workingDirectory);
}
async function handleCompleteExecution(): Promise<void> {
workflowStore.completeExecution();
await workflowStore.saveState(workingDirectory);
}
async function handleExtractCriteria(): Promise<void> {
if (!contextContent) return;
const lines = contextContent.split("\n");
let inCriteria = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^##\s*acceptance criteria/i.test(trimmed)) {
inCriteria = true;
continue;
}
if (inCriteria && /^##/.test(trimmed)) {
inCriteria = false;
continue;
}
if (inCriteria && /^\d+\./.test(trimmed)) {
const text = trimmed.replace(/^\d+\.\s*/, "").trim();
if (text) workflowStore.addCriterion(text);
}
}
await workflowStore.saveState(workingDirectory);
}
async function handleRunVerification(): Promise<void> {
const criteria = $workflowState.verify.criteria;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
const prompt = buildVerifyPrompt(criteria);
isWaitingForVerify = true;
await invoke("send_prompt", { conversationId, message: prompt });
}
async function handleAddCriterion(): Promise<void> {
const text = newCriterionText.trim();
if (!text) return;
workflowStore.addCriterion(text);
newCriterionText = "";
await workflowStore.saveState(workingDirectory);
}
async function handleRemoveCriterion(id: string): Promise<void> {
workflowStore.removeCriterion(id);
await workflowStore.saveState(workingDirectory);
}
async function handleSetCriterionStatus(id: string, status: CriterionStatus): Promise<void> {
workflowStore.updateCriterionStatus(id, status);
await workflowStore.saveState(workingDirectory);
}
async function handleAdvance(): Promise<void> {
const current = $workflowState.currentPhase;
if (current < 4) {
workflowStore.setPhase((current + 1) as WorkflowPhase);
await workflowStore.saveState(workingDirectory);
} else {
onClose();
}
}
async function handleBack(): Promise<void> {
const current = $workflowState.currentPhase;
if (canGoBack(current)) {
workflowStore.setPhase((current - 1) as WorkflowPhase);
await workflowStore.saveState(workingDirectory);
}
}
async function handleReset(): Promise<void> {
workflowStore.reset();
contextContent = null;
verifyContent = null;
await workflowStore.saveState(workingDirectory);
}
function completedTaskCount(): number {
return countByStatus($loopTasks, "completed");
}
function failedTaskCount(): number {
return countByStatus($loopTasks, "failed");
}
function allTasksDone(): boolean {
const tasks = $loopTasks;
return (
tasks.length > 0 && tasks.every((t) => t.status === "completed" || t.status === "failed")
);
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="workflow-panel-title"
tabindex="-1"
>
<!-- Header: title + phase stepper + quick mode -->
<div class="flex flex-col gap-3 p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<h2 id="workflow-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
Guided Workflow
</h2>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer select-none">
<span class="text-xs text-[var(--text-secondary)]">Quick Mode</span>
<button
role="switch"
aria-checked={$workflowState.quickMode}
onclick={() => {
workflowStore.setQuickMode(!$workflowState.quickMode);
void workflowStore.saveState(workingDirectory);
}}
class="w-9 h-5 rounded-full transition-colors {$workflowState.quickMode
? 'bg-[var(--accent-primary)]'
: 'bg-[var(--bg-tertiary)]'} relative"
>
<span
class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$workflowState.quickMode
? 'translate-x-4'
: 'translate-x-0'}"
></span>
</button>
</label>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Phase stepper -->
<div class="flex items-center gap-1">
{#each PHASES as phase (phase)}
{@const isActive = $workflowState.currentPhase === phase}
{@const isDone = $workflowState.currentPhase > phase}
<button
onclick={() => {
workflowStore.setPhase(phase);
void workflowStore.saveState(workingDirectory);
}}
aria-label="Go to phase {phase}: {getPhaseLabel(phase)}"
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors {isActive
? 'bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-semibold'
: isDone
? 'text-green-400'
: 'text-[var(--text-tertiary)]'}"
>
<span
class="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold {isActive
? 'bg-[var(--accent-primary)] text-white'
: isDone
? 'bg-green-500/20 text-green-400 border border-green-500/40'
: 'bg-[var(--bg-secondary)] border border-[var(--border-color)]'}"
>
{#if isDone}{:else}{phase}{/if}
</span>
<span class="hidden sm:inline">{getPhaseLabel(phase)}</span>
</button>
{#if phase < 4}
<svg
class="w-3 h-3 text-[var(--text-tertiary)] shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
{/if}
{/each}
</div>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-6 min-h-0">
{#if $workflowState.currentPhase === 1}
<!-- ── Phase 1: Discuss ── -->
<div class="flex flex-col gap-4">
<div>
<label
for="workflow-description"
class="block text-sm font-medium text-[var(--text-secondary)] mb-2"
>
Describe what you want to build
</label>
<textarea
id="workflow-description"
value={$workflowState.discuss.description}
oninput={(e) => {
workflowStore.setDiscussDescription((e.target as HTMLTextAreaElement).value);
}}
rows={5}
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
placeholder="Describe the feature or project you want to build..."
spellcheck="false"
disabled={isWaitingForDiscuss}
></textarea>
</div>
{#if isWaitingForDiscuss}
<div
class="flex items-center gap-3 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
>
<div class="text-xl animate-spin">⚙️</div>
<div>
<p class="text-sm font-medium text-[var(--text-primary)]">Claude is working...</p>
<p class="text-xs text-[var(--text-secondary)]">
Writing CONTEXT.md — will auto-detect when complete.
</p>
</div>
</div>
{:else if contextContent !== null}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-green-400">✓ CONTEXT.md captured</span>
<button
onclick={tryLoadContextFile}
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
>
Refresh
</button>
</div>
<textarea
value={contextContent}
readonly
rows={8}
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none leading-relaxed"
></textarea>
</div>
{:else if isLoadingContext}
<p class="text-xs text-[var(--text-tertiary)]">Checking for CONTEXT.md...</p>
{:else}
<div class="flex flex-col gap-2">
<p class="text-xs text-[var(--text-tertiary)]">
{#if $workflowState.quickMode}
Quick mode: your description will be saved directly to CONTEXT.md without
discussion.
{:else}
Claude will analyse your description and write a structured CONTEXT.md with
acceptance criteria.
{/if}
</p>
<div class="flex gap-2">
{#if !$workflowState.quickMode}
<button
onclick={handleStartDiscussion}
disabled={!$workflowState.discuss.description.trim()}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Start Discussion
</button>
{:else}
<button
onclick={handleQuickCaptureContext}
disabled={!$workflowState.discuss.description.trim()}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Save to CONTEXT.md
</button>
{/if}
<button
onclick={tryLoadContextFile}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Check for CONTEXT.md
</button>
</div>
</div>
{/if}
</div>
{:else if $workflowState.currentPhase === 2}
<!-- ── Phase 2: Plan ── -->
<div class="flex flex-col gap-4">
<p class="text-sm text-[var(--text-secondary)]">
Use the PRD Creator to generate your task breakdown, then approve it here to advance.
</p>
<!-- PRD status card -->
<div class="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]">
{#if $prdIsLoaded && $prdTasks.length > 0}
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-[var(--text-primary)]">
{$prdTasks.length} task{$prdTasks.length === 1 ? "" : "s"} ready
</p>
<p class="text-xs text-[var(--text-tertiary)] mt-0.5">hikari-tasks.json loaded</p>
</div>
<span
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
>
Ready
</span>
</div>
{:else}
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[var(--text-tertiary)]">No tasks generated yet</p>
<p class="text-xs text-[var(--text-tertiary)] mt-0.5">
Open PRD Creator to generate a task breakdown
</p>
</div>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-tertiary)] text-[var(--text-tertiary)] border border-[var(--border-color)]"
>
Pending
</span>
</div>
{/if}
</div>
<div class="flex gap-2">
<button
onclick={onOpenPrdPanel}
class="px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Open PRD Creator →
</button>
<button
onclick={async () => {
await prdStore.loadFromFile(workingDirectory);
}}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Reload Tasks
</button>
</div>
{#if $workflowState.plan.tasksApproved}
<div
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
>
<span class="text-green-400 text-sm"
>✓ Plan approved — ready to advance to Execute</span
>
</div>
{/if}
</div>
{:else if $workflowState.currentPhase === 3}
<!-- ── Phase 3: Execute ── -->
<div class="flex flex-col gap-4">
<p class="text-sm text-[var(--text-secondary)]">
Run your tasks in the Task Loop panel, then mark execution complete here.
</p>
<!-- Task Loop progress -->
<div class="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]">
{#if $loopTasks.length > 0}
{@const done = completedTaskCount()}
{@const failed = failedTaskCount()}
{@const total = $loopTasks.length}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-[var(--text-primary)]">
{done} / {total} tasks completed
</p>
{#if $loopStatus !== "idle"}
<span
class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 border border-blue-500/30 capitalize"
>
{$loopStatus}
</span>
{/if}
</div>
<!-- Progress bar -->
<div class="w-full bg-[var(--bg-primary)] rounded-full h-2 overflow-hidden">
<div
class="h-full bg-[var(--accent-primary)] transition-all duration-300"
style="width: {total > 0 ? (done / total) * 100 : 0}%"
></div>
</div>
{#if failed > 0}
<p class="text-xs text-red-400">
{failed} task{failed === 1 ? "" : "s"} failed
</p>
{/if}
</div>
{:else}
<p class="text-sm text-[var(--text-tertiary)]">
No tasks loaded — open Task Loop to load and run tasks
</p>
{/if}
</div>
<div class="flex gap-2">
<button
onclick={onOpenTaskLoop}
class="px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Open Task Loop →
</button>
</div>
{#if $workflowState.execute.completed}
<div
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
>
<span class="text-green-400 text-sm"
>✓ Execution complete — ready to advance to Verify</span
>
</div>
{/if}
</div>
{:else if $workflowState.currentPhase === 4}
<!-- ── Phase 4: Verify ── -->
<div class="flex flex-col gap-4">
<p class="text-sm text-[var(--text-secondary)]">
Verify the implementation against your acceptance criteria.
</p>
<!-- Criteria list -->
{#if $workflowState.verify.criteria.length > 0}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-[var(--text-secondary)]">Acceptance Criteria</p>
{#if contextContent !== null}
<button
onclick={handleExtractCriteria}
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
>
Re-extract from CONTEXT.md
</button>
{/if}
</div>
{#each $workflowState.verify.criteria as criterion (criterion.id)}
<div
class="flex items-center gap-2 p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
>
<p class="flex-1 text-sm text-[var(--text-primary)]">{criterion.text}</p>
<div class="flex items-center gap-1 shrink-0">
<button
onclick={() => handleSetCriterionStatus(criterion.id, "pass")}
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
'pass'
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'text-[var(--text-tertiary)] hover:text-green-400'}"
>
Pass
</button>
<button
onclick={() => handleSetCriterionStatus(criterion.id, "partial")}
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
'partial'
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
: 'text-[var(--text-tertiary)] hover:text-amber-400'}"
>
Partial
</button>
<button
onclick={() => handleSetCriterionStatus(criterion.id, "fail")}
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
'fail'
? 'bg-red-500/20 text-red-400 border border-red-500/30'
: 'text-[var(--text-tertiary)] hover:text-red-400'}"
>
Fail
</button>
<button
onclick={() => handleRemoveCriterion(criterion.id)}
class="p-0.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors ml-1"
aria-label="Remove criterion"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/each}
</div>
{:else}
<div
class="flex flex-col gap-2 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
>
<p class="text-sm text-[var(--text-tertiary)]">No criteria yet.</p>
{#if contextContent !== null}
<button
onclick={handleExtractCriteria}
class="self-start text-xs px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Extract from CONTEXT.md
</button>
{/if}
</div>
{/if}
<!-- Add criterion -->
<div class="flex gap-2">
<input
type="text"
bind:value={newCriterionText}
onkeydown={(e) => e.key === "Enter" && handleAddCriterion()}
class="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-3 py-1.5 text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
placeholder="Add criterion..."
/>
<button
onclick={handleAddCriterion}
disabled={!newCriterionText.trim()}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Add
</button>
</div>
<!-- Verification actions -->
{#if isWaitingForVerify}
<div
class="flex items-center gap-3 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
>
<div class="text-xl animate-spin">⚙️</div>
<div>
<p class="text-sm font-medium text-[var(--text-primary)]">Claude is verifying...</p>
<p class="text-xs text-[var(--text-secondary)]">
Writing VERIFY.md — will auto-detect when complete.
</p>
</div>
</div>
{:else if verifyContent !== null}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-green-400">✓ VERIFY.md generated</span>
<button
onclick={tryLoadVerifyFile}
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
>
Refresh
</button>
</div>
<textarea
value={verifyContent}
readonly
rows={8}
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none leading-relaxed"
></textarea>
</div>
{:else if isLoadingVerify}
<p class="text-xs text-[var(--text-tertiary)]">Checking for VERIFY.md...</p>
{:else}
<div class="flex gap-2">
{#if !$workflowState.quickMode}
<button
onclick={handleRunVerification}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Run Verification
</button>
{/if}
<button
onclick={tryLoadVerifyFile}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Check for VERIFY.md
</button>
</div>
{/if}
{#if $workflowState.verify.verificationComplete}
<div
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
>
<span class="text-green-400 text-sm">✓ Verification complete</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer navigation -->
<div
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
>
<div class="flex items-center gap-2">
<button
onclick={handleBack}
disabled={!canGoBack($workflowState.currentPhase)}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
← Back
</button>
<button
onclick={handleReset}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-red-400 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Reset
</button>
</div>
<div class="flex items-center gap-2">
<!-- Phase-specific primary action -->
{#if $workflowState.currentPhase === 2 && !$workflowState.plan.tasksApproved}
<button
onclick={handleApprovePlan}
disabled={!$workflowState.quickMode && (!$prdIsLoaded || $prdTasks.length === 0)}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Approve Plan
</button>
{:else if $workflowState.currentPhase === 3 && !$workflowState.execute.completed}
<button
onclick={handleCompleteExecution}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
{allTasksDone() ? "Mark Complete ✓" : "Mark Complete (Override)"}
</button>
{:else if $workflowState.currentPhase === 4 && !$workflowState.verify.verificationComplete && $workflowState.quickMode}
<button
onclick={() => {
workflowStore.completeVerification("Manual verification complete.");
void workflowStore.saveState(workingDirectory);
}}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Complete Workflow
</button>
{/if}
<!-- Advance / Close -->
<button
onclick={handleAdvance}
disabled={!canAdvancePhase($workflowState)}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{$workflowState.currentPhase === 4 ? "Close" : "Next →"}
</button>
</div>
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
textarea {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: transparent;
}
textarea::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
+211
View File
@@ -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-/);
});
});
+253
View File
@@ -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();