generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more #197
@@ -23,6 +23,7 @@
|
||||
import PrdPanel from "./PrdPanel.svelte";
|
||||
import ChangelogPanel from "./ChangelogPanel.svelte";
|
||||
import TaskLoopPanel from "./TaskLoopPanel.svelte";
|
||||
import WorkflowPanel from "./WorkflowPanel.svelte";
|
||||
import { injectTextStore } from "$lib/stores/projectContext";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
@@ -67,6 +68,7 @@
|
||||
let showPrdPanel = $state(false);
|
||||
let showChangelog = $state(false);
|
||||
let showTaskLoop = $state(false);
|
||||
let showWorkflowPanel = $state(false);
|
||||
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
@@ -245,6 +247,15 @@
|
||||
<span>Task Loop</span>
|
||||
</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 -->
|
||||
<button
|
||||
onclick={menuAction(() => editorStore.toggleEditor())}
|
||||
@@ -513,6 +524,21 @@
|
||||
<TaskLoopPanel onClose={() => (showTaskLoop = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showWorkflowPanel}
|
||||
<WorkflowPanel
|
||||
onClose={() => (showWorkflowPanel = false)}
|
||||
onOpenPrdPanel={() => {
|
||||
showWorkflowPanel = false;
|
||||
showPrdPanel = true;
|
||||
}}
|
||||
onOpenTaskLoop={() => {
|
||||
showWorkflowPanel = false;
|
||||
showTaskLoop = true;
|
||||
}}
|
||||
workingDirectory={workingDirectory || selectedDirectory}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.nav-item {
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
@@ -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