From 5bc901621f22baa2b0514cdf0937cd7157f75aec Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Mon, 23 Mar 2026 17:30:05 -0400 Subject: [PATCH 01/21] feat(tui): add tab navigation with Plan, Execute, and Review views Implement 3-tab structure for the TUI with Plan view as the primary deliverable. Plan view includes a chat interface (spec/PRD creation via daemon jobs) and a task board (prd.json viewer + progress log). Extract existing dashboard into ExecuteView, add ReviewView placeholder. New hooks: useChat (poll-based daemon chat), usePlanFiles (fs.watch on .ralph/), usePlanInstance (auto-resolve OpenCode instance). Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/components/app.tsx | 511 ++------------------ apps/tui/src/components/execute-view.tsx | 182 +++++++ apps/tui/src/components/plan-chat.tsx | 113 +++++ apps/tui/src/components/plan-task-board.tsx | 93 ++++ apps/tui/src/components/plan-view.tsx | 52 ++ apps/tui/src/components/review-view.tsx | 14 + apps/tui/src/hooks/use-chat.ts | 109 +++++ apps/tui/src/hooks/use-plan-files.ts | 115 +++++ apps/tui/src/hooks/use-plan-instance.ts | 59 +++ apps/tui/src/skills.ts | 78 +++ 10 files changed, 855 insertions(+), 471 deletions(-) create mode 100644 apps/tui/src/components/execute-view.tsx create mode 100644 apps/tui/src/components/plan-chat.tsx create mode 100644 apps/tui/src/components/plan-task-board.tsx create mode 100644 apps/tui/src/components/plan-view.tsx create mode 100644 apps/tui/src/components/review-view.tsx create mode 100644 apps/tui/src/hooks/use-chat.ts create mode 100644 apps/tui/src/hooks/use-plan-files.ts create mode 100644 apps/tui/src/hooks/use-plan-instance.ts create mode 100644 apps/tui/src/skills.ts diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 1148bda..0c4dc0f 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -1,513 +1,82 @@ -import { basename } from "node:path"; -import { type SelectOption, TextAttributes } from "@opentui/core"; +import { TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; -import type { - DaemonJob, - DaemonSession, - HealthResult, - ManagedInstance, -} from "@techatnyu/ralphd"; -import { daemon } from "@techatnyu/ralphd"; -import { useCallback, useEffect, useState } from "react"; -import { ralphStore, setModelAndRecent } from "../lib/store"; -import { Chat } from "./chat"; - -type View = - | { type: "dashboard" } - | { - type: "chat"; - instanceId: string; - instanceName: string; - sessionId: string | null; - }; - -interface DashboardData { - health: HealthResult; - instances: ManagedInstance[]; - jobs: DaemonJob[]; -} - -/** Provider IDs sorted by popularity — used to push well-known providers to the top. */ -const PROVIDER_PRIORITY: Record = { - anthropic: 0, - openai: 1, - google: 2, - openrouter: 3, -}; - -const SEPARATOR_VALUE = "__separator__"; - -async function fetchModelOptions(): Promise { - const [result, store] = await Promise.all([ - daemon.providerList({ refresh: true }), - ralphStore.read(), - ]); - const connected = new Set(result.connected); - const recentRefs = new Set(store.recentModels ?? []); - - // Build flat list of all connected models - const allModels: SelectOption[] = result.providers - .filter((provider) => connected.has(provider.id)) - .sort( - (a, b) => - (PROVIDER_PRIORITY[a.id] ?? 99) - (PROVIDER_PRIORITY[b.id] ?? 99) || - a.name.localeCompare(b.name), - ) - .flatMap((provider) => - Object.values(provider.models) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((model) => ({ - name: `${provider.name}/${model.name}`, - description: `${provider.id}/${model.id}`, - value: `${provider.id}/${model.id}`, - })), - ); - - // Build recent section from stored order, only including models that still exist - const allByRef = new Map(allModels.map((m) => [m.value, m])); - const recentOptions: SelectOption[] = (store.recentModels ?? []) - .filter((ref) => allByRef.has(ref)) - .map((ref) => allByRef.get(ref) as SelectOption); - - if (recentOptions.length === 0) return allModels; - - // Filter recents out of the "all" section to avoid duplicates - const restModels = allModels.filter( - (m) => !recentRefs.has(m.value as string), - ); - - return [ - { name: "── Recent ──", description: "", value: SEPARATOR_VALUE }, - ...recentOptions, - { name: "── All Models ──", description: "", value: SEPARATOR_VALUE }, - ...restModels, - ]; -} +import { useState } from "react"; +import { ExecuteView } from "./execute-view"; +import { PlanView } from "./plan-view"; +import { ReviewView } from "./review-view"; interface AppProps { onQuit(): void; } -function clampIndex(index: number, length: number): number { - if (length <= 0) { - return 0; - } - return Math.min(Math.max(index, 0), length - 1); -} - -function countJobsByState( - jobs: DaemonJob[], - instanceId: string, -): { running: number; queued: number } { - let running = 0; - let queued = 0; - for (const job of jobs) { - if (job.instanceId !== instanceId) continue; - if (job.state === "running") running++; - else if (job.state === "queued") queued++; - } - return { running, queued }; -} - -function Dashboard({ - onQuit, - onSelectInstance, -}: { - onQuit(): void; - onSelectInstance( - instance: ManagedInstance, - session: DaemonSession | null, - ): void; -}) { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(); - const [data, setData] = useState(); - const [selectedIndex, setSelectedIndex] = useState(0); - const [sessions, setSessions] = useState([]); - const [selectedSessionIndex, setSelectedSessionIndex] = useState(0); - const [focusPanel, setFocusPanel] = useState<"instances" | "sessions">( - "instances", - ); - const [currentModel, setCurrentModel] = useState(""); - const [modelPicker, setModelPicker] = useState(false); - const [modelOptions, setModelOptions] = useState([]); - const [fetchingModels, setFetchingModels] = useState(false); - const [query, setQuery] = useState(""); - const [cursorOn, setCursorOn] = useState(true); - - useEffect(() => { - if (!modelPicker) return; - const id = setInterval(() => setCursorOn((v) => !v), 500); - return () => clearInterval(id); - }, [modelPicker]); +type FocusZone = "tabs" | "content"; - const q = query.toLowerCase(); - const visibleOptions = q - ? modelOptions.filter( - (o) => - o.value !== SEPARATOR_VALUE && - (o.name.toLowerCase().includes(q) || - (typeof o.description === "string" && - o.description.toLowerCase().includes(q))), - ) - : modelOptions; +const TAB_OPTIONS = [ + { name: "Plan", description: "Create spec & PRD" }, + { name: "Execute", description: "Run agents" }, + { name: "Review", description: "Review changes" }, +]; - const refresh = useCallback( - async (nextIndex = selectedIndex) => { - setLoading(true); - setError(undefined); - try { - const [health, instanceList, storeState] = await Promise.all([ - daemon.health(), - daemon.listInstances(), - ralphStore.read(), - ]); - setCurrentModel(storeState.model); - const safeIndex = clampIndex(nextIndex, instanceList.instances.length); - const selectedInst = instanceList.instances[safeIndex]; - const [jobs, sessionResult] = await Promise.all([ - daemon.listJobs(selectedInst ? { instanceId: selectedInst.id } : {}), - // If the selected instance was removed between `listInstances` - // and this call, the daemon now throws `not_found` instead of - // returning an empty list. Swallow that narrow race so the - // whole refresh doesn't fail. Any other error is a real failure - // and should propagate to the surrounding catch block. - selectedInst - ? daemon.listSessions(selectedInst.id).catch((err) => { - const code = (err as { code?: string } | undefined)?.code; - if (code === "not_found") return { sessions: [] }; - throw err; - }) - : Promise.resolve({ sessions: [] }), - ]); - setSelectedIndex(safeIndex); - setSessions(sessionResult.sessions); - setSelectedSessionIndex(0); - setData({ - health, - instances: instanceList.instances, - jobs: jobs.jobs, - }); - } catch (refreshError) { - setError( - refreshError instanceof Error - ? refreshError.message - : "Failed to load daemon status", - ); - } finally { - setLoading(false); - } - }, - [selectedIndex], - ); +const HELP_TEXT: Record = { + 0: "Tab: focus tabs Ctrl+H/L: switch panels m: toggle mode q: quit", + 1: "Tab: focus tabs j/k: select r: refresh q: quit", + 2: "Tab: focus tabs q: quit", +}; - useEffect(() => { - void refresh(); - }, [refresh]); +export function App({ onQuit }: AppProps) { + const [activeTab, setActiveTab] = useState(0); + const [focusZone, setFocusZone] = useState("content"); useKeyboard((key) => { - if (modelPicker) { - if (key.name === "escape") { - setModelPicker(false); - setQuery(""); - return; - } - if (key.name === "backspace") { - setQuery((prev) => prev.slice(0, -1)); - return; - } - if ( - !key.ctrl && - !key.meta && - typeof key.sequence === "string" && - key.sequence.length === 1 && - key.sequence >= " " && - key.sequence <= "~" - ) { - setQuery((prev) => prev + key.sequence); - } + if (key.name === "tab") { + setFocusZone((z) => (z === "tabs" ? "content" : "tabs")); return; } - if (key.name === "q" || (key.ctrl && key.name === "c")) { + if (key.name === "q" && focusZone === "tabs") { onQuit(); return; } - if (key.name === "r") { - void refresh(); - return; - } - - if (key.name === "m" && !fetchingModels) { - setQuery(""); - setFetchingModels(true); - void fetchModelOptions() - .then((options) => { - setModelOptions(options); - setModelPicker(true); - }) - .catch((err) => { - setError( - err instanceof Error ? err.message : "Failed to fetch models", - ); - }) - .finally(() => setFetchingModels(false)); - return; - } - - if (!data) { - return; - } - - if (key.name === "tab" || key.name === "l" || key.name === "right") { - if (focusPanel === "instances" && sessions.length > 0) { - setFocusPanel("sessions"); - } - return; - } - - if (key.name === "h" || key.name === "left") { - if (focusPanel === "sessions") { - setFocusPanel("instances"); - } - return; - } - - if (focusPanel === "instances") { - if (key.name === "down" || key.name === "j") { - const next = clampIndex(selectedIndex + 1, data.instances.length); - void refresh(next); - return; - } - - if (key.name === "up" || key.name === "k") { - const next = clampIndex(selectedIndex - 1, data.instances.length); - void refresh(next); - return; - } - - if (key.name === "return") { - const inst = data.instances[selectedIndex]; - if (inst) { - onSelectInstance(inst, null); - } - return; - } - } - - if (focusPanel === "sessions") { - if (key.name === "down" || key.name === "j") { - setSelectedSessionIndex((prev) => - clampIndex(prev + 1, sessions.length + 1), - ); - return; - } - - if (key.name === "up" || key.name === "k") { - setSelectedSessionIndex((prev) => - clampIndex(prev - 1, sessions.length + 1), - ); + if (focusZone === "tabs") { + if (key.name === "left" || key.name === "h") { + setActiveTab((t) => Math.max(0, t - 1)); return; } - - if (key.name === "return") { - const inst = data.instances[selectedIndex]; - if (!inst) return; - - // Index 0 is "New Chat", rest are sessions - if (selectedSessionIndex === 0) { - onSelectInstance(inst, null); - } else { - const session = sessions[selectedSessionIndex - 1]; - if (session) { - onSelectInstance(inst, session); - } - } - return; - } - - if (key.name === "escape") { - setFocusPanel("instances"); + if (key.name === "right" || key.name === "l") { + setActiveTab((t) => Math.min(TAB_OPTIONS.length - 1, t + 1)); return; } } }); - const selected = data?.instances[selectedIndex]; - - if (modelPicker) { - return ( - - - Select Model - esc - - - {query ? ( - {`${query}${cursorOn ? "\u2588" : " "}`} - ) : ( - - {`${cursorOn ? "\u2588" : "S"}earch`} - - )} - - + + + + ); +} diff --git a/apps/tui/src/components/plan-task-board.tsx b/apps/tui/src/components/plan-task-board.tsx new file mode 100644 index 0000000..8e1789e --- /dev/null +++ b/apps/tui/src/components/plan-task-board.tsx @@ -0,0 +1,93 @@ +import { SyntaxStyle, TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { useMemo, useState } from "react"; +import type { PlanFilesData, PrdTask } from "../hooks/use-plan-files"; + +interface PlanTaskBoardProps { + focused: boolean; + data: PlanFilesData; +} + +function clampIndex(index: number, length: number): number { + if (length <= 0) return 0; + return Math.min(Math.max(index, 0), length - 1); +} + +export function PlanTaskBoard({ focused, data }: PlanTaskBoardProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const { tasks, progress } = data; + const syntaxStyle = useMemo(() => SyntaxStyle.create(), []); + + useKeyboard((key) => { + if (!focused || tasks.length === 0) return; + + if (key.name === "down" || key.name === "j") { + setSelectedIndex((i) => clampIndex(i + 1, tasks.length)); + } + if (key.name === "up" || key.name === "k") { + setSelectedIndex((i) => clampIndex(i - 1, tasks.length)); + } + }); + + const selected: PrdTask | undefined = tasks[selectedIndex]; + const completedCount = tasks.filter((t) => t.passed).length; + + if (!data.hasPrd) { + return ( + + + No plan found — create a PRD to see tasks here + + + ); + } + + return ( + + + {`Tasks (${completedCount}/${tasks.length} complete)`} + + + + {tasks.map((task: PrdTask, index: number) => { + const isFocused = focused && index === selectedIndex; + const icon = task.passed ? "[x]" : "[ ]"; + return ( + + {`${isFocused ? ">" : " "} ${icon} ${task.description}`} + + ); + })} + + + {selected && ( + + Subtasks + {selected.subtasks.map((subtask: string) => ( + + {` - ${subtask}`} + + ))} + {selected.notes && ( + + Notes + {selected.notes} + + )} + + )} + + {progress && ( + + Progress Log + + + + + )} + + ); +} diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx new file mode 100644 index 0000000..1c94ead --- /dev/null +++ b/apps/tui/src/components/plan-view.tsx @@ -0,0 +1,52 @@ +import { useKeyboard } from "@opentui/react"; +import { useState } from "react"; +import { useChat } from "../hooks/use-chat"; +import { usePlanFiles } from "../hooks/use-plan-files"; +import { usePlanInstance } from "../hooks/use-plan-instance"; +import { PlanChat } from "./plan-chat"; +import { PlanTaskBoard } from "./plan-task-board"; + +type SubFocus = "chat" | "tasks"; + +interface PlanViewProps { + focused: boolean; +} + +export function PlanView({ focused }: PlanViewProps) { + const [subFocus, setSubFocus] = useState("chat"); + const planFiles = usePlanFiles(); + const planInstance = usePlanInstance(); + const chat = useChat(planInstance.ensure); + + useKeyboard((key) => { + if (!focused) return; + + if (key.name === "l" && key.ctrl) { + setSubFocus("tasks"); + } + if (key.name === "h" && key.ctrl) { + setSubFocus("chat"); + } + }); + + return ( + + + + + + + + + ); +} diff --git a/apps/tui/src/components/review-view.tsx b/apps/tui/src/components/review-view.tsx new file mode 100644 index 0000000..6aa06ca --- /dev/null +++ b/apps/tui/src/components/review-view.tsx @@ -0,0 +1,14 @@ +import { TextAttributes } from "@opentui/core"; + +export function ReviewView() { + return ( + + Review — Coming soon + + ); +} diff --git a/apps/tui/src/hooks/use-chat.ts b/apps/tui/src/hooks/use-chat.ts new file mode 100644 index 0000000..f157ebb --- /dev/null +++ b/apps/tui/src/hooks/use-chat.ts @@ -0,0 +1,109 @@ +import { daemon } from "@techatnyu/ralphd"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CREATE_PRD_SYSTEM_PROMPT, CREATE_SPEC_SYSTEM_PROMPT } from "../skills"; + +export type ChatMode = "create-spec" | "create-prd"; + +export interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +interface UseChatReturn { + messages: ChatMessage[]; + loading: boolean; + error: string | undefined; + send: (prompt: string, mode: ChatMode) => Promise; +} + +const SKILL_PROMPTS: Record = { + "create-spec": CREATE_SPEC_SYSTEM_PROMPT, + "create-prd": CREATE_PRD_SYSTEM_PROMPT, +}; + +export function useChat(ensureInstance: () => Promise): UseChatReturn { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const sessionIdRef = useRef(null); + const pollingRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }, []); + + useEffect(() => { + return () => stopPolling(); + }, [stopPolling]); + + const send = useCallback( + async (prompt: string, mode: ChatMode) => { + if (loading) return; + + setMessages((prev) => [...prev, { role: "user", content: prompt }]); + setLoading(true); + setError(undefined); + + try { + const instanceId = await ensureInstance(); + + const session = sessionIdRef.current + ? { type: "existing" as const, sessionId: sessionIdRef.current } + : { type: "new" as const, title: `Plan: ${mode}` }; + + const { job } = await daemon.submitJob({ + instanceId, + session, + task: { + type: "prompt", + prompt, + system: SKILL_PROMPTS[mode], + }, + }); + + pollingRef.current = setInterval(async () => { + try { + const { job: updated } = await daemon.getJob(job.id); + + if (updated.state === "succeeded") { + stopPolling(); + if (updated.sessionId) { + sessionIdRef.current = updated.sessionId; + } + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: updated.outputText ?? "(no response)", + }, + ]); + setLoading(false); + } else if ( + updated.state === "failed" || + updated.state === "cancelled" + ) { + stopPolling(); + setError(updated.error ?? "Job failed"); + setLoading(false); + } + } catch (pollError) { + stopPolling(); + setError( + pollError instanceof Error ? pollError.message : "Polling failed", + ); + setLoading(false); + } + }, 1000); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to submit message"); + setLoading(false); + } + }, + [loading, ensureInstance, stopPolling], + ); + + return { messages, loading, error, send }; +} diff --git a/apps/tui/src/hooks/use-plan-files.ts b/apps/tui/src/hooks/use-plan-files.ts new file mode 100644 index 0000000..5a902ec --- /dev/null +++ b/apps/tui/src/hooks/use-plan-files.ts @@ -0,0 +1,115 @@ +import { readFile, watch } from "node:fs"; +import { join } from "node:path"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface PrdTask { + description: string; + subtasks: string[]; + notes: string; + passed: boolean; +} + +interface PrdData { + tasks: PrdTask[]; +} + +export interface PlanFilesData { + tasks: PrdTask[]; + progress: string; + hasSpec: boolean; + hasPrd: boolean; +} + +interface UsePlanFilesReturn { + data: PlanFilesData; + loading: boolean; + error: string | undefined; + refresh: () => void; +} + +const RALPH_DIR = join(process.cwd(), ".ralph"); +const PRD_PATH = join(RALPH_DIR, "prd.json"); +const PROGRESS_PATH = join(RALPH_DIR, "progress.md"); +const SPEC_PATH = join(RALPH_DIR, "SPEC.md"); + +function readFileAsync(path: string): Promise { + return new Promise((resolve) => { + readFile(path, "utf-8", (err, data) => { + if (err) { + resolve(null); + } else { + resolve(data); + } + }); + }); +} + +function parsePrd(content: string | null): PrdTask[] { + if (!content) return []; + try { + const parsed = JSON.parse(content) as PrdData; + if (Array.isArray(parsed.tasks)) { + return parsed.tasks; + } + } catch { + // invalid JSON + } + return []; +} + +export function usePlanFiles(): UsePlanFilesReturn { + const [data, setData] = useState({ + tasks: [], + progress: "", + hasSpec: false, + hasPrd: false, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const debounceRef = useRef | null>(null); + + const loadFiles = useCallback(async () => { + setLoading(true); + setError(undefined); + try { + const [prdContent, progressContent, specContent] = await Promise.all([ + readFileAsync(PRD_PATH), + readFileAsync(PROGRESS_PATH), + readFileAsync(SPEC_PATH), + ]); + setData({ + tasks: parsePrd(prdContent), + progress: progressContent ?? "", + hasSpec: specContent !== null, + hasPrd: prdContent !== null, + }); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to read plan files"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadFiles(); + + let watcher: ReturnType | null = null; + try { + watcher = watch(RALPH_DIR, { recursive: true }, () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void loadFiles(); + }, 500); + }); + } catch { + // .ralph/ directory may not exist yet + } + + return () => { + watcher?.close(); + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [loadFiles]); + + return { data, loading, error, refresh: loadFiles }; +} diff --git a/apps/tui/src/hooks/use-plan-instance.ts b/apps/tui/src/hooks/use-plan-instance.ts new file mode 100644 index 0000000..ae0f3eb --- /dev/null +++ b/apps/tui/src/hooks/use-plan-instance.ts @@ -0,0 +1,59 @@ +import { daemon } from "@techatnyu/ralphd"; +import { useCallback, useRef, useState } from "react"; + +interface UsePlanInstanceReturn { + instanceId: string | null; + loading: boolean; + error: string | undefined; + ensure: () => Promise; +} + +export function usePlanInstance(): UsePlanInstanceReturn { + const [instanceId, setInstanceId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const resolving = useRef | null>(null); + + const ensure = useCallback(async (): Promise => { + if (instanceId) return instanceId; + if (resolving.current) return resolving.current; + + const resolve = async (): Promise => { + setLoading(true); + setError(undefined); + try { + const cwd = process.cwd(); + const { instances } = await daemon.listInstances(); + const existing = instances.find((i) => i.directory === cwd); + if (existing) { + setInstanceId(existing.id); + if (existing.status === "stopped") { + await daemon.startInstance(existing.id); + } + return existing.id; + } + + const { instance } = await daemon.createInstance({ + name: "plan", + directory: cwd, + }); + await daemon.startInstance(instance.id); + setInstanceId(instance.id); + return instance.id; + } catch (e) { + const msg = + e instanceof Error ? e.message : "Failed to resolve instance"; + setError(msg); + throw new Error(msg); + } finally { + setLoading(false); + resolving.current = null; + } + }; + + resolving.current = resolve(); + return resolving.current; + }, [instanceId]); + + return { instanceId, loading, error, ensure }; +} diff --git a/apps/tui/src/skills.ts b/apps/tui/src/skills.ts new file mode 100644 index 0000000..e700242 --- /dev/null +++ b/apps/tui/src/skills.ts @@ -0,0 +1,78 @@ +export const CREATE_SPEC_SYSTEM_PROMPT = `# SPEC Creation Helper + +Create or refine SPEC.md for Ralph — an AI coding agent that reads SPEC.md at the start of every iteration to understand what it's building. + +## Core Principles + +- SPEC.md captures what and why. High-level goals, scope, and architectural decisions. Not implementation steps — those belong in prd.json. +- Codebase is source of truth. The agent reads code for implementation details. SPEC.md should not duplicate what the code already shows. +- Keep it stable. A good spec rarely changes. + +## Output Template + +# Project Name + +> One-line description of the project. + +## Overview +[2-3 paragraphs: what you're building, the problem it solves, and who it's for] + +## Scope +### Included +- [High-level capability 1] +### Excluded +- [What this project will NOT do] + +## Technical Stack +- Language: [e.g., TypeScript 5.x] +- Framework: [e.g., Next.js 14] +- Database: [e.g., PostgreSQL 15 with Prisma] + +## Architecture +[High-level patterns, how major components communicate] + +## Constraints +[Non-functional requirements: performance, security, compatibility] + +## Workflow + +1. Gather requirements — ask the user about what they're building, scope, tech stack, architecture, constraints. +2. Draft the spec following the template. +3. Present for feedback — ask about missing scope, unclear decisions. +4. Refine and write the final SPEC.md to .ralph/SPEC.md.`; + +export const CREATE_PRD_SYSTEM_PROMPT = `# PRD/Task Creation Helper + +Create and manage prd.json task lists for Ralph — an AI coding agent that loops through tasks: reads prd.json, picks ONE task, completes it, marks passed: true, repeats until done. + +Task quality directly determines agent performance. Follow the rules below strictly. + +## Output Schema + +{ + "tasks": [ + { + "description": "Clear end-goal of the task", + "subtasks": ["Specific step 1", "Specific step 2", "Verification step"], + "notes": "Context, constraints, references, or tips", + "passed": false + } + ] +} + +## Task Rules + +1. Right-sized: Each task must be completable in a single agent session (~1-2 hours of human work). +2. Specific subtasks: Break down work into concrete steps. +3. Every task MUST end with verification + code quality checks (tests, type check, lint). +4. No overlapping scope between tasks. +5. Useful notes with context, constraints, and references. +6. Order logically: setup → models → features → integrations → polish → tests. + +## Workflow + +1. Read SPEC.md if it exists. Otherwise, explore the codebase and ask the user. +2. Analyze: Identify setup, models, features, APIs, components, integrations, testing needs. +3. Propose tasks following all rules above. +4. Get feedback from the user about ordering, sizing, missing features. +5. Write the final prd.json to .ralph/prd.json.`; From 34d9e5578ee2c4e5c6d8f1124ef32be770101d59 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Wed, 25 Mar 2026 19:55:31 -0400 Subject: [PATCH 02/21] feat(tui): clean up visual hierarchy --- CLAUDE.md | 72 +++++++ apps/tui/src/components/app.tsx | 111 ++++++++-- apps/tui/src/components/command-palette.tsx | 109 ++++++++++ apps/tui/src/components/context-sidebar.tsx | 52 +++++ apps/tui/src/components/execute-view.tsx | 81 ++++--- apps/tui/src/components/file-picker.tsx | 97 +++++++++ apps/tui/src/components/help-overlay.tsx | 107 ++++++++++ apps/tui/src/components/plan-chat.tsx | 224 +++++++++++++++----- apps/tui/src/components/plan-task-board.tsx | 93 -------- apps/tui/src/components/plan-view.tsx | 77 +++++-- apps/tui/src/components/status-bar.tsx | 36 ++++ apps/tui/src/components/task-overlay.tsx | 75 +++++++ apps/tui/src/components/welcome-screen.tsx | 95 +++++++++ apps/tui/src/hooks/use-chat.ts | 9 +- apps/tui/src/hooks/use-file-search.ts | 90 ++++++++ 15 files changed, 1113 insertions(+), 215 deletions(-) create mode 100644 CLAUDE.md create mode 100644 apps/tui/src/components/command-palette.tsx create mode 100644 apps/tui/src/components/context-sidebar.tsx create mode 100644 apps/tui/src/components/file-picker.tsx create mode 100644 apps/tui/src/components/help-overlay.tsx delete mode 100644 apps/tui/src/components/plan-task-board.tsx create mode 100644 apps/tui/src/components/status-bar.tsx create mode 100644 apps/tui/src/components/task-overlay.tsx create mode 100644 apps/tui/src/components/welcome-screen.tsx create mode 100644 apps/tui/src/hooks/use-file-search.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..414eab7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Ralph is a coding agent orchestration TUI — a daemon (ralphd) manages OpenCode SDK instances and jobs, while a React-based terminal UI provides interactive monitoring. Built as a Bun monorepo with Turbo. + +## Commands + +```bash +bun install # Install dependencies +bun run build # Build all packages (turbo) +bun run dev # Start all dev servers +bun run dev:docs # Start docs site only +bun run test # Run all tests (bun test) +bun run check # Biome lint + format check +bun run check:types # TypeScript type checking +``` + +### Per-package commands + +```bash +cd apps/tui && bun run dev # Run TUI in dev mode +cd packages/daemon && bun test # Run daemon tests only +cd apps/docs && bun run dev # Run docs dev server +``` + +### Release + +```bash +bun run release:build # Compile binaries for all platforms +bun run release:stage # Stage distribution for publishing +bun run release:publish # Publish to npm +bun run release:dry-run # Test publish without uploading +``` + +## Architecture + +### Monorepo Layout + +- `apps/tui/` — Terminal UI app (@techatnyu/ralph), React 19 + @opentui/react +- `apps/docs/` — Documentation site, Fumadocs + TanStack Start + Vite +- `packages/daemon/` — Background daemon (@techatnyu/ralphd), socket-based IPC +- `packages/config/` — Shared TypeScript configuration +- `scripts/` — Release and build automation + +### Daemon-Client Architecture + +The daemon (ralphd) runs as a background process and communicates with TUI clients via a Unix domain socket (`ralphd.sock`). Key patterns: + +- **Protocol-driven**: All requests/responses defined with Zod schemas in `packages/daemon/src/protocol.ts`. Type-safe discriminated unions for all message types. +- **Job lifecycle**: queued → running → succeeded/failed/cancelled. Per-instance concurrency control (default: 4, configurable via `RALPHD_MAX_CONCURRENCY`). +- **Instance management**: `ManagedInstance` tracks OpenCode runtimes with lazy initialization. States: stopped → starting → running → error. +- **State persistence**: JSON file at `~/.ralph/state.json` (or `$RALPH_HOME/state.json`). + +### TUI + +React components rendered in the terminal via @opentui/react. Real-time job monitoring with keyboard navigation (j/k or arrows). CLI argument parsing via CrustJS. + +## Code Style + +- **Biome** for linting and formatting: tab indentation, double quotes, import organization +- **TypeScript strict mode**, ES2022 target, bundler module resolution +- Shared base tsconfig in `packages/config/tsconfig.base.json` +- TUI uses `@opentui/react` as JSX import source + +## Environment Variables + +- `RALPH_HOME` — Base directory (default: `~/.ralph`, dev: `./.ralph-dev`) +- `RALPHD_MAX_CONCURRENCY` — Max concurrent jobs per instance (default: 4) +- `RALPHD_BIN` — Override daemon binary path diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 0c4dc0f..02f8078 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -1,9 +1,13 @@ import { TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; -import { useState } from "react"; +import { daemon } from "@techatnyu/ralphd"; +import { useCallback, useEffect, useState } from "react"; +import { usePlanFiles } from "../hooks/use-plan-files"; import { ExecuteView } from "./execute-view"; +import { HelpOverlay } from "./help-overlay"; import { PlanView } from "./plan-view"; import { ReviewView } from "./review-view"; +import { StatusBar } from "./status-bar"; interface AppProps { onQuit(): void; @@ -12,33 +16,65 @@ interface AppProps { type FocusZone = "tabs" | "content"; const TAB_OPTIONS = [ - { name: "Plan", description: "Create spec & PRD" }, - { name: "Execute", description: "Run agents" }, - { name: "Review", description: "Review changes" }, + { name: "Plan", description: "" }, + { name: "Execute", description: "" }, + { name: "Review", description: "" }, ]; -const HELP_TEXT: Record = { - 0: "Tab: focus tabs Ctrl+H/L: switch panels m: toggle mode q: quit", - 1: "Tab: focus tabs j/k: select r: refresh q: quit", - 2: "Tab: focus tabs q: quit", -}; - export function App({ onQuit }: AppProps) { const [activeTab, setActiveTab] = useState(0); const [focusZone, setFocusZone] = useState("content"); + const [daemonOnline, setDaemonOnline] = useState(true); + const [showHelp, setShowHelp] = useState(false); + const planFiles = usePlanFiles(); + + const checkDaemon = useCallback(async () => { + try { + await daemon.health(); + setDaemonOnline(true); + } catch { + setDaemonOnline(false); + } + }, []); + + useEffect(() => { + void checkDaemon(); + const interval = setInterval(() => void checkDaemon(), 10_000); + return () => clearInterval(interval); + }, [checkDaemon]); useKeyboard((key) => { + if (showHelp) { + if ( + key.name === "escape" || + key.name === "?" || + (key.name === "/" && key.ctrl) + ) { + setShowHelp(false); + } + return; + } + + if (key.name === "/" && key.ctrl) { + setShowHelp(true); + return; + } + if (key.name === "tab") { setFocusZone((z) => (z === "tabs" ? "content" : "tabs")); return; } - if (key.name === "q" && focusZone === "tabs") { - onQuit(); + if (key.name === "escape") { + setFocusZone("tabs"); return; } if (focusZone === "tabs") { + if (key.name === "q") { + onQuit(); + return; + } if (key.name === "left" || key.name === "h") { setActiveTab((t) => Math.max(0, t - 1)); return; @@ -47,36 +83,65 @@ export function App({ onQuit }: AppProps) { setActiveTab((t) => Math.min(TAB_OPTIONS.length - 1, t + 1)); return; } + if (key.name === "return") { + setFocusZone("content"); + return; + } + if (key.name === "1") { + setActiveTab(0); + setFocusZone("content"); + return; + } + if (key.name === "2") { + setActiveTab(1); + setFocusZone("content"); + return; + } + if (key.name === "3") { + setActiveTab(2); + setFocusZone("content"); + return; + } + if (key.name === "?") { + setShowHelp(true); + return; + } } }); const contentFocused = focusZone === "content"; return ( - - - + + + ralph + + {"● "} + + {daemonOnline ? "online" : "offline"} + { - setActiveTab(_index); + showDescription={false} + onChange={(index: number) => { + setActiveTab(index); }} /> - {activeTab === 0 && } + {activeTab === 0 && ( + + )} {activeTab === 1 && } {activeTab === 2 && } - - - {HELP_TEXT[activeTab] ?? ""} - - + {showHelp && setShowHelp(false)} />} + + ); } diff --git a/apps/tui/src/components/command-palette.tsx b/apps/tui/src/components/command-palette.tsx new file mode 100644 index 0000000..cb907e1 --- /dev/null +++ b/apps/tui/src/components/command-palette.tsx @@ -0,0 +1,109 @@ +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { useState } from "react"; + +export interface SlashCommand { + name: string; + description: string; +} + +export const SLASH_COMMANDS: SlashCommand[] = [ + { name: "/spec", description: "Generate a project spec" }, + { name: "/prd", description: "Break spec into tasks" }, + { name: "/tasks", description: "Toggle task overlay" }, + { name: "/clear", description: "Clear chat messages" }, +]; + +interface CommandPaletteProps { + query: string; + onSelect: (command: SlashCommand) => void; + onDismiss: () => void; + focused: boolean; +} + +export function CommandPalette({ + query, + onSelect, + onDismiss, + focused, +}: CommandPaletteProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + + const filtered = SLASH_COMMANDS.filter((cmd) => + cmd.name.toLowerCase().includes(`/${query.toLowerCase()}`), + ); + + useKeyboard((key) => { + if (!focused) return; + if (key.name === "escape") { + onDismiss(); + return; + } + if (key.name === "down" || (key.name === "j" && key.ctrl)) { + setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); + return; + } + if (key.name === "up" || (key.name === "k" && key.ctrl)) { + setSelectedIndex((i) => Math.max(i - 1, 0)); + return; + } + if (key.name === "return") { + const selected = filtered[selectedIndex]; + if (selected) { + onSelect(selected); + } + return; + } + }); + + if (filtered.length === 0) { + return ( + + + No matching commands + + + ); + } + + const clampedIndex = Math.min(selectedIndex, filtered.length - 1); + + return ( + + {filtered.map((cmd, index) => ( + + + {cmd.name} + + {` ${cmd.description}`} + + ))} + + ); +} diff --git a/apps/tui/src/components/context-sidebar.tsx b/apps/tui/src/components/context-sidebar.tsx new file mode 100644 index 0000000..99f9c62 --- /dev/null +++ b/apps/tui/src/components/context-sidebar.tsx @@ -0,0 +1,52 @@ +import { TextAttributes } from "@opentui/core"; +import type { ChatMode } from "../hooks/use-chat"; +import type { PlanFilesData } from "../hooks/use-plan-files"; + +const MODE_LABELS: Record = { + "create-spec": "Spec", + "create-prd": "PRD", +}; + +interface ContextSidebarProps { + planData: PlanFilesData; + mode: ChatMode; + messageCount: number; +} + +export function ContextSidebar({ + planData, + mode, + messageCount, +}: ContextSidebarProps) { + const doneCount = planData.tasks.filter((t) => t.passed).length; + + return ( + + Plan Status + + {planData.hasSpec ? "✓ SPEC.md" : "○ No spec"} + + + {planData.hasPrd ? "✓ prd.json" : "○ No PRD"} + + {planData.tasks.length > 0 && ( + {`${doneCount}/${planData.tasks.length} tasks`} + )} + + + Session + + {`Mode: ${MODE_LABELS[mode]}`} + {`${messageCount} messages`} + + ); +} diff --git a/apps/tui/src/components/execute-view.tsx b/apps/tui/src/components/execute-view.tsx index 8ac0ab3..f1b0d18 100644 --- a/apps/tui/src/components/execute-view.tsx +++ b/apps/tui/src/components/execute-view.tsx @@ -1,4 +1,3 @@ -import { basename } from "node:path"; import { TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; import type { @@ -40,6 +39,19 @@ function countJobsByState( return { running, queued }; } +function statusColor(status: string): string { + if (status === "running") return "green"; + if (status === "error") return "red"; + return "#666666"; +} + +function jobStateColor(state: string): string { + if (state === "running") return "cyan"; + if (state === "succeeded") return "green"; + if (state === "failed") return "red"; + return "#888888"; +} + export function ExecuteView({ focused }: ExecuteViewProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(); @@ -113,7 +125,7 @@ export function ExecuteView({ focused }: ExecuteViewProps) { {loading - ? "Refreshing daemon status..." + ? "Refreshing..." : data ? `Daemon online (pid ${data.health.pid})` : "Daemon status unavailable"} @@ -125,22 +137,28 @@ export function ExecuteView({ focused }: ExecuteViewProps) { - - + + Instances + {"─".repeat(20)} {data?.instances.length ? ( data.instances.map((instance: ManagedInstance, index: number) => { - const isFocused = index === selectedIndex; + const isSelected = index === selectedIndex; const counts = countJobsByState(data.jobs, instance.id); return ( - - {`${isFocused ? ">" : " "} ${instance.name} [${instance.status}] ${basename(instance.directory)} (${counts.running}r/${counts.queued}q)`} - + + {"● "} + + {instance.name} + + + + {`${instance.status} ${counts.running}r/${counts.queued}q`} + + ); }) ) : ( @@ -148,35 +166,42 @@ export function ExecuteView({ focused }: ExecuteViewProps) { )} - + - {selected ? `Jobs for ${selected.name}` : "Jobs"} + {selected ? `Jobs for "${selected.name}"` : "Jobs"} + {"─".repeat(30)} {selected ? ( data?.jobs.length ? ( - data.jobs.map((job: DaemonJob) => ( - - {`${job.id.slice(0, 8)} ${job.state} ${job.task.type === "prompt" ? job.task.prompt : ""}`} - - )) + + {data.jobs.map((job: DaemonJob) => ( + + + {job.id.slice(0, 8)} + + + {` ${job.state} `} + + + {job.task.type === "prompt" + ? job.task.prompt.slice(0, 40) + : ""} + + + ))} + ) : ( - No jobs for the selected instance + No jobs for this instance ) ) : ( - Select an instance to inspect jobs + Select an instance to see jobs )} - - - - {error ?? "j/k or arrows: select r: refresh"} - - ); } diff --git a/apps/tui/src/components/file-picker.tsx b/apps/tui/src/components/file-picker.tsx new file mode 100644 index 0000000..2b6f5e4 --- /dev/null +++ b/apps/tui/src/components/file-picker.tsx @@ -0,0 +1,97 @@ +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { useState } from "react"; +import { useFileSearch } from "../hooks/use-file-search"; + +interface FilePickerProps { + query: string; + onSelect: (path: string) => void; + onDismiss: () => void; + focused: boolean; +} + +export function FilePicker({ + query, + onSelect, + onDismiss, + focused, +}: FilePickerProps) { + const { results } = useFileSearch(query); + const [selectedIndex, setSelectedIndex] = useState(0); + + useKeyboard((key) => { + if (!focused) return; + if (key.name === "escape") { + onDismiss(); + return; + } + if (key.name === "down" || (key.name === "j" && key.ctrl)) { + setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); + return; + } + if (key.name === "up" || (key.name === "k" && key.ctrl)) { + setSelectedIndex((i) => Math.max(i - 1, 0)); + return; + } + if (key.name === "return") { + const selected = results[selectedIndex]; + if (selected) { + onSelect(selected); + } + return; + } + }); + + if (results.length === 0) { + return ( + + + No matching files + + + ); + } + + const visibleResults = results.slice(0, 10); + const clampedIndex = Math.min(selectedIndex, visibleResults.length - 1); + + return ( + + + {visibleResults.map((file, index) => ( + + {index === clampedIndex ? `> ${file}` : ` ${file}`} + + ))} + + + ); +} diff --git a/apps/tui/src/components/help-overlay.tsx b/apps/tui/src/components/help-overlay.tsx new file mode 100644 index 0000000..ae6300a --- /dev/null +++ b/apps/tui/src/components/help-overlay.tsx @@ -0,0 +1,107 @@ +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; + +interface HelpOverlayProps { + onClose: () => void; +} + +interface KeyBinding { + keys: string; + desc: string; +} + +const GENERAL_BINDINGS: KeyBinding[] = [ + { keys: "Tab", desc: "Switch focus (tabs / content)" }, + { keys: "1/2/3", desc: "Jump to tab" }, + { keys: "q", desc: "Quit (from tabs)" }, + { keys: "Esc", desc: "Back to tabs" }, + { keys: "?", desc: "Toggle this help" }, +]; + +const PLAN_BINDINGS: KeyBinding[] = [ + { keys: "Ctrl+M", desc: "Switch mode (Spec / PRD)" }, + { keys: "Ctrl+T", desc: "Toggle task list" }, + { keys: "@", desc: "Insert file reference" }, + { keys: "/", desc: "Open command palette" }, +]; + +const TASK_BINDINGS: KeyBinding[] = [ + { keys: "j/k", desc: "Navigate tasks" }, + { keys: "Esc", desc: "Close task overlay" }, +]; + +const EXECUTE_BINDINGS: KeyBinding[] = [ + { keys: "j/k", desc: "Select job" }, + { keys: "r", desc: "Refresh jobs" }, +]; + +function KeyRow({ keys, desc }: KeyBinding) { + return ( + + + {keys} + + {desc} + + ); +} + +export function HelpOverlay({ onClose }: HelpOverlayProps) { + useKeyboard((key) => { + if ( + key.name === "escape" || + key.name === "?" || + (key.name === "/" && key.ctrl) + ) { + onClose(); + } + }); + + return ( + + + + General + {GENERAL_BINDINGS.map((b) => ( + + ))} + + + Task Overlay + + {TASK_BINDINGS.map((b) => ( + + ))} + + + + Plan Chat + {PLAN_BINDINGS.map((b) => ( + + ))} + + + Execute + + {EXECUTE_BINDINGS.map((b) => ( + + ))} + + + + Press ? or Esc to close + + ); +} diff --git a/apps/tui/src/components/plan-chat.tsx b/apps/tui/src/components/plan-chat.tsx index 560cfc2..0955251 100644 --- a/apps/tui/src/components/plan-chat.tsx +++ b/apps/tui/src/components/plan-chat.tsx @@ -1,8 +1,14 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { SyntaxStyle, TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; import { useMemo, useState } from "react"; import type { ChatMessage, ChatMode } from "../hooks/use-chat"; import type { PlanFilesData } from "../hooks/use-plan-files"; +import type { SlashCommand } from "./command-palette"; +import { CommandPalette } from "./command-palette"; +import { FilePicker } from "./file-picker"; +import { WelcomeScreen } from "./welcome-screen"; interface PlanChatProps { focused: boolean; @@ -11,24 +17,34 @@ interface PlanChatProps { error: string | undefined; planData: PlanFilesData; onSend: (prompt: string, mode: ChatMode) => Promise; + onToggleMode: () => void; + onToggleTasks: () => void; + onClear: () => void; + onSetMode: (mode: ChatMode) => void; + mode: ChatMode; } -function getHint(planData: PlanFilesData): string { - if (!planData.hasSpec) { - return "No spec found — try Create Spec mode to define your project"; - } - if (!planData.hasPrd) { - return "Spec ready — try Create PRD mode to break it into tasks"; - } - return "Plan ready — switch to Execute to start"; -} - -const MODES: ChatMode[] = ["create-spec", "create-prd"]; const MODE_LABELS: Record = { - "create-spec": "Create Spec", - "create-prd": "Create PRD", + "create-spec": "Spec", + "create-prd": "PRD", }; +function extractFileQuery(input: string): string | null { + const lastAt = input.lastIndexOf("@"); + if (lastAt === -1) return null; + const afterAt = input.slice(lastAt + 1); + if (afterAt.includes(" ")) return null; + return afterAt; +} + +function readFileContents(path: string): string { + try { + return readFileSync(join(process.cwd(), path), "utf-8"); + } catch { + return "(file not found)"; + } +} + export function PlanChat({ focused, messages, @@ -36,76 +52,188 @@ export function PlanChat({ error, planData, onSend, + onToggleMode, + onToggleTasks, + onClear, + onSetMode, + mode, }: PlanChatProps) { const [inputValue, setInputValue] = useState(""); - const [modeIndex, setModeIndex] = useState(0); - const mode: ChatMode = MODES[modeIndex] ?? "create-spec"; + const [fileRefs, setFileRefs] = useState([]); + const [showFilePicker, setShowFilePicker] = useState(false); + const [showCommandPalette, setShowCommandPalette] = useState(false); const syntaxStyle = useMemo(() => SyntaxStyle.create(), []); useKeyboard((key) => { if (!focused) return; - if (key.name === "m" && !loading) { - setModeIndex((i) => (i + 1) % MODES.length); + if (key.name === "m" && key.ctrl) { + onToggleMode(); + } + if (key.name === "t" && key.ctrl) { + onToggleTasks(); } }); + const handleInputChange = (value: string) => { + setInputValue(value); + + const fileQuery = extractFileQuery(value); + setShowFilePicker(fileQuery !== null); + + setShowCommandPalette(value.startsWith("/") && value.length >= 1); + }; + + const handleFileSelect = (path: string) => { + const lastAt = inputValue.lastIndexOf("@"); + const newValue = `${inputValue.slice(0, lastAt)}@${path} `; + setInputValue(newValue); + if (!fileRefs.includes(path)) { + setFileRefs((prev) => [...prev, path]); + } + setShowFilePicker(false); + }; + + const handleCommandSelect = (command: SlashCommand) => { + setShowCommandPalette(false); + setInputValue(""); + + switch (command.name) { + case "/spec": + onSetMode("create-spec"); + setInputValue("Create a spec for this project"); + break; + case "/prd": + onSetMode("create-prd"); + setInputValue("Break the spec into tasks"); + break; + case "/tasks": + onToggleTasks(); + break; + case "/clear": + onClear(); + break; + } + }; + const handleSubmit = (value: string) => { const trimmed = value.trim(); if (!trimmed || loading) return; + + if (trimmed.startsWith("/")) { + setInputValue(""); + return; + } + + let prompt = trimmed; + if (fileRefs.length > 0) { + const fileContents = fileRefs + .map( + (ref) => `\n\n--- Contents of ${ref} ---\n${readFileContents(ref)}`, + ) + .join(""); + prompt = `${trimmed}${fileContents}`; + } + setInputValue(""); - void onSend(trimmed, mode); + setFileRefs([]); + void onSend(prompt, mode); }; + const fileQuery = extractFileQuery(inputValue); + const commandQuery = inputValue.startsWith("/") ? inputValue.slice(1) : ""; + return ( - - - {`Mode: ${MODE_LABELS[mode]}`} - - {" (m to switch)"} - - {messages.length === 0 && !loading ? ( - - Start a conversation to create your project plan - + ) : ( - messages.map((msg: ChatMessage, index: number) => { - const label = msg.role === "user" ? "You" : "Assistant"; - return ( + messages.map((msg: ChatMessage, index: number) => ( + - {label} - {msg.role === "assistant" ? ( - - ) : ( - {msg.content} - )} - - ); - }) + key={`msg-${index}`} + flexDirection="column" + marginBottom={1} + paddingLeft={2} + > + + {msg.role === "user" ? "You" : "Assistant"} + + {msg.role === "assistant" ? ( + + ) : ( + {msg.content} + )} + + )) + )} + {loading && ( + + + Thinking... + + )} - {loading && Thinking...} {error && ( - {`Error: ${error}`} + + {`Error: ${error}`} + )} + {showFilePicker && ( + setShowFilePicker(false)} + focused={focused} + /> + )} + + {showCommandPalette && !showFilePicker && ( + setShowCommandPalette(false)} + focused={focused} + /> + )} + - {getHint(planData)} - + {fileRefs.length > 0 && ( + + {fileRefs.map((ref) => ( + + {`@${ref} `} + + ))} + + )} + + + {` ${MODE_LABELS[mode]} `} + diff --git a/apps/tui/src/components/plan-task-board.tsx b/apps/tui/src/components/plan-task-board.tsx deleted file mode 100644 index 8e1789e..0000000 --- a/apps/tui/src/components/plan-task-board.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { SyntaxStyle, TextAttributes } from "@opentui/core"; -import { useKeyboard } from "@opentui/react"; -import { useMemo, useState } from "react"; -import type { PlanFilesData, PrdTask } from "../hooks/use-plan-files"; - -interface PlanTaskBoardProps { - focused: boolean; - data: PlanFilesData; -} - -function clampIndex(index: number, length: number): number { - if (length <= 0) return 0; - return Math.min(Math.max(index, 0), length - 1); -} - -export function PlanTaskBoard({ focused, data }: PlanTaskBoardProps) { - const [selectedIndex, setSelectedIndex] = useState(0); - const { tasks, progress } = data; - const syntaxStyle = useMemo(() => SyntaxStyle.create(), []); - - useKeyboard((key) => { - if (!focused || tasks.length === 0) return; - - if (key.name === "down" || key.name === "j") { - setSelectedIndex((i) => clampIndex(i + 1, tasks.length)); - } - if (key.name === "up" || key.name === "k") { - setSelectedIndex((i) => clampIndex(i - 1, tasks.length)); - } - }); - - const selected: PrdTask | undefined = tasks[selectedIndex]; - const completedCount = tasks.filter((t) => t.passed).length; - - if (!data.hasPrd) { - return ( - - - No plan found — create a PRD to see tasks here - - - ); - } - - return ( - - - {`Tasks (${completedCount}/${tasks.length} complete)`} - - - - {tasks.map((task: PrdTask, index: number) => { - const isFocused = focused && index === selectedIndex; - const icon = task.passed ? "[x]" : "[ ]"; - return ( - - {`${isFocused ? ">" : " "} ${icon} ${task.description}`} - - ); - })} - - - {selected && ( - - Subtasks - {selected.subtasks.map((subtask: string) => ( - - {` - ${subtask}`} - - ))} - {selected.notes && ( - - Notes - {selected.notes} - - )} - - )} - - {progress && ( - - Progress Log - - - - - )} - - ); -} diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx index 1c94ead..bfd01e3 100644 --- a/apps/tui/src/components/plan-view.tsx +++ b/apps/tui/src/components/plan-view.tsx @@ -1,52 +1,85 @@ -import { useKeyboard } from "@opentui/react"; +import { useKeyboard, useTerminalDimensions } from "@opentui/react"; import { useState } from "react"; +import type { ChatMode } from "../hooks/use-chat"; import { useChat } from "../hooks/use-chat"; -import { usePlanFiles } from "../hooks/use-plan-files"; +import type { PlanFilesData } from "../hooks/use-plan-files"; import { usePlanInstance } from "../hooks/use-plan-instance"; +import { ContextSidebar } from "./context-sidebar"; import { PlanChat } from "./plan-chat"; -import { PlanTaskBoard } from "./plan-task-board"; +import { TaskOverlay } from "./task-overlay"; -type SubFocus = "chat" | "tasks"; +const MODES: ChatMode[] = ["create-spec", "create-prd"]; +const SIDEBAR_MIN_WIDTH = 120; interface PlanViewProps { focused: boolean; + planData: PlanFilesData; } -export function PlanView({ focused }: PlanViewProps) { - const [subFocus, setSubFocus] = useState("chat"); - const planFiles = usePlanFiles(); +export function PlanView({ focused, planData }: PlanViewProps) { + const [showTasks, setShowTasks] = useState(false); + const [modeIndex, setModeIndex] = useState(0); const planInstance = usePlanInstance(); const chat = useChat(planInstance.ensure); + const mode: ChatMode = MODES[modeIndex] ?? "create-spec"; + const { width } = useTerminalDimensions(); + const showSidebar = width >= SIDEBAR_MIN_WIDTH; useKeyboard((key) => { if (!focused) return; - - if (key.name === "l" && key.ctrl) { - setSubFocus("tasks"); - } - if (key.name === "h" && key.ctrl) { - setSubFocus("chat"); + if (key.name === "t" && key.ctrl) { + setShowTasks((s) => !s); } }); + const toggleMode = () => { + setModeIndex((i) => (i + 1) % MODES.length); + }; + + const toggleTasks = () => { + setShowTasks((s) => !s); + }; + + const setMode = (newMode: ChatMode) => { + const index = MODES.indexOf(newMode); + if (index !== -1) { + setModeIndex(index); + } + }; + return ( - - + + + + {showSidebar && ( + + )} - - setShowTasks(false)} /> - + )} ); } diff --git a/apps/tui/src/components/status-bar.tsx b/apps/tui/src/components/status-bar.tsx new file mode 100644 index 0000000..e88722d --- /dev/null +++ b/apps/tui/src/components/status-bar.tsx @@ -0,0 +1,36 @@ +import { TextAttributes } from "@opentui/core"; +import type { PlanFilesData } from "../hooks/use-plan-files"; + +interface StatusBarProps { + activeTab: number; + planData: PlanFilesData; +} + +const HELP_BY_TAB: Record = { + 0: "Tab: tabs Ctrl+M: mode Ctrl+T: tasks ?: help", + 1: "Tab: tabs j/k: select r: refresh ?: help", + 2: "Tab: tabs ?: help", +}; + +function getTaskSummary(planData: PlanFilesData): string { + if (!planData.hasPrd || planData.tasks.length === 0) return ""; + const done = planData.tasks.filter((t) => t.passed).length; + return `${done}/${planData.tasks.length} tasks done`; +} + +export function StatusBar({ activeTab, planData }: StatusBarProps) { + const help = HELP_BY_TAB[activeTab] ?? ""; + const taskSummary = getTaskSummary(planData); + const allDone = + planData.tasks.length > 0 && planData.tasks.every((t) => t.passed); + + return ( + + {help} + + {taskSummary && ( + {taskSummary} + )} + + ); +} diff --git a/apps/tui/src/components/task-overlay.tsx b/apps/tui/src/components/task-overlay.tsx new file mode 100644 index 0000000..a7bc7cf --- /dev/null +++ b/apps/tui/src/components/task-overlay.tsx @@ -0,0 +1,75 @@ +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { useState } from "react"; +import type { PlanFilesData, PrdTask } from "../hooks/use-plan-files"; + +interface TaskOverlayProps { + focused: boolean; + data: PlanFilesData; + onClose: () => void; +} + +function clampIndex(index: number, length: number): number { + if (length <= 0) return 0; + return Math.min(Math.max(index, 0), length - 1); +} + +export function TaskOverlay({ focused, data, onClose }: TaskOverlayProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const { tasks } = data; + const completedCount = tasks.filter((t) => t.passed).length; + + useKeyboard((key) => { + if (!focused) return; + if (key.name === "escape" || (key.name === "t" && key.ctrl)) { + onClose(); + return; + } + if (key.name === "down" || key.name === "j") { + setSelectedIndex((i) => clampIndex(i + 1, tasks.length)); + } + if (key.name === "up" || key.name === "k") { + setSelectedIndex((i) => clampIndex(i - 1, tasks.length)); + } + }); + + return ( + + + {tasks.map((task: PrdTask, index: number) => { + const isSelected = focused && index === selectedIndex; + const icon = task.passed ? "✓" : "○"; + + return ( + + {`${icon} `} + + {task.description} + + + ); + })} + + + + j/k: navigate Esc: close + + + ); +} diff --git a/apps/tui/src/components/welcome-screen.tsx b/apps/tui/src/components/welcome-screen.tsx new file mode 100644 index 0000000..842e0ec --- /dev/null +++ b/apps/tui/src/components/welcome-screen.tsx @@ -0,0 +1,95 @@ +import { TextAttributes } from "@opentui/core"; +import type { PlanFilesData } from "../hooks/use-plan-files"; + +interface WelcomeScreenProps { + planData: PlanFilesData; +} + +export function WelcomeScreen({ planData }: WelcomeScreenProps) { + if (planData.hasSpec && planData.hasPrd) { + return ( + + Plan complete! + + Switch to the Execute tab to start building. + + + ); + } + + if (planData.hasSpec) { + return ( + + Spec ready + + Your spec is ready. Try /prd to break it into tasks. + + + ); + } + + return ( + + ralph + + AI-powered project planning + + + + Describe your project to get started, or use a command: + + + + + /spec + + {" Generate a project spec"} + + + + /prd + + {" Break spec into tasks"} + + + + + + Shortcuts: + + Ctrl+M + + {" Switch mode (Spec / PRD)"} + + + + Ctrl+T + {" Toggle task list"} + + + @file + {" Reference a file"} + + + + + Try: "Build me a todo app with auth and real-time sync" + + + ); +} diff --git a/apps/tui/src/hooks/use-chat.ts b/apps/tui/src/hooks/use-chat.ts index f157ebb..f63a9ef 100644 --- a/apps/tui/src/hooks/use-chat.ts +++ b/apps/tui/src/hooks/use-chat.ts @@ -14,6 +14,7 @@ interface UseChatReturn { loading: boolean; error: string | undefined; send: (prompt: string, mode: ChatMode) => Promise; + clear: () => void; } const SKILL_PROMPTS: Record = { @@ -105,5 +106,11 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { [loading, ensureInstance, stopPolling], ); - return { messages, loading, error, send }; + const clear = useCallback(() => { + setMessages([]); + sessionIdRef.current = null; + setError(undefined); + }, []); + + return { messages, loading, error, send, clear }; } diff --git a/apps/tui/src/hooks/use-file-search.ts b/apps/tui/src/hooks/use-file-search.ts new file mode 100644 index 0000000..702e4e6 --- /dev/null +++ b/apps/tui/src/hooks/use-file-search.ts @@ -0,0 +1,90 @@ +import { readdir } from "node:fs/promises"; +import { relative } from "node:path"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const IGNORE_DIRS = new Set([ + "node_modules", + ".git", + ".ralph", + ".ralph-dev", + "dist", + "build", + ".turbo", + ".next", +]); + +async function listFiles(dir: string): Promise { + const results: string[] = []; + try { + const entries = await readdir(dir, { + withFileTypes: true, + recursive: true, + }); + for (const entry of entries) { + if (entry.isFile()) { + const parent = + "parentPath" in entry + ? (entry.parentPath as string) + : (entry as unknown as { path: string }).path; + const fullPath = `${parent}/${entry.name}`; + const rel = relative(dir, fullPath); + const shouldIgnore = rel + .split("/") + .some((segment) => IGNORE_DIRS.has(segment)); + if (!shouldIgnore) { + results.push(rel); + } + } + } + } catch { + // directory may not exist + } + return results; +} + +function scoreMatch(file: string, query: string): number { + const lower = file.toLowerCase(); + const q = query.toLowerCase(); + const basename = lower.split("/").pop() ?? lower; + if (basename.startsWith(q)) return 3; + if (basename.includes(q)) return 2; + if (lower.includes(q)) return 1; + return 0; +} + +interface UseFileSearchReturn { + results: string[]; + loading: boolean; +} + +export function useFileSearch(query: string): UseFileSearchReturn { + const [allFiles, setAllFiles] = useState([]); + const [loading, setLoading] = useState(false); + const loadedRef = useRef(false); + + const scan = useCallback(async () => { + if (loadedRef.current) return; + setLoading(true); + const files = await listFiles(process.cwd()); + setAllFiles(files); + loadedRef.current = true; + setLoading(false); + }, []); + + useEffect(() => { + void scan(); + }, [scan]); + + if (!query) { + return { results: allFiles.slice(0, 20), loading }; + } + + const scored = allFiles + .map((file) => ({ file, score: scoreMatch(file, query) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 20) + .map(({ file }) => file); + + return { results: scored, loading }; +} From 0377c11a57c58a098eb8e748592e13c8616a8e8f Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Wed, 8 Apr 2026 19:25:42 -0400 Subject: [PATCH 03/21] feat(tui): add prompt mode, improve chat polling, and refine plan view Add create-prompt mode for generating PROMPT.md execution instructions. Fix chat polling to check immediately after job submission instead of waiting 1 second, and add a 2-minute timeout to prevent infinite "Thinking..." state. Surface instance errors in the plan chat UI. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + apps/tui/src/components/app.tsx | 31 +- apps/tui/src/components/command-palette.tsx | 50 +-- apps/tui/src/components/context-sidebar.tsx | 4 + apps/tui/src/components/file-picker.tsx | 46 +- apps/tui/src/components/help-overlay.tsx | 2 +- apps/tui/src/components/plan-chat.tsx | 148 ++++--- apps/tui/src/components/plan-view.tsx | 4 +- apps/tui/src/components/welcome-screen.tsx | 30 +- apps/tui/src/hooks/use-chat.ts | 43 +- apps/tui/src/hooks/use-plan-files.ts | 16 +- apps/tui/src/skills.ts | 455 ++++++++++++++++++-- 12 files changed, 625 insertions(+), 207 deletions(-) diff --git a/.gitignore b/.gitignore index 342b0dc..a8771ca 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ yarn-error.log* *.pem .ralph-dev + +# Claude +CLAUDE.md diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 02f8078..afb6237 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -61,7 +61,9 @@ export function App({ onQuit }: AppProps) { } if (key.name === "tab") { - setFocusZone((z) => (z === "tabs" ? "content" : "tabs")); + if (focusZone === "tabs") { + setFocusZone("content"); + } return; } @@ -75,33 +77,6 @@ export function App({ onQuit }: AppProps) { onQuit(); return; } - if (key.name === "left" || key.name === "h") { - setActiveTab((t) => Math.max(0, t - 1)); - return; - } - if (key.name === "right" || key.name === "l") { - setActiveTab((t) => Math.min(TAB_OPTIONS.length - 1, t + 1)); - return; - } - if (key.name === "return") { - setFocusZone("content"); - return; - } - if (key.name === "1") { - setActiveTab(0); - setFocusZone("content"); - return; - } - if (key.name === "2") { - setActiveTab(1); - setFocusZone("content"); - return; - } - if (key.name === "3") { - setActiveTab(2); - setFocusZone("content"); - return; - } if (key.name === "?") { setShowHelp(true); return; diff --git a/apps/tui/src/components/command-palette.tsx b/apps/tui/src/components/command-palette.tsx index cb907e1..67bf171 100644 --- a/apps/tui/src/components/command-palette.tsx +++ b/apps/tui/src/components/command-palette.tsx @@ -1,6 +1,4 @@ import { TextAttributes } from "@opentui/core"; -import { useKeyboard } from "@opentui/react"; -import { useState } from "react"; export interface SlashCommand { name: string; @@ -10,52 +8,26 @@ export interface SlashCommand { export const SLASH_COMMANDS: SlashCommand[] = [ { name: "/spec", description: "Generate a project spec" }, { name: "/prd", description: "Break spec into tasks" }, + { name: "/prompt", description: "Generate execution prompt" }, { name: "/tasks", description: "Toggle task overlay" }, { name: "/clear", description: "Clear chat messages" }, ]; +export function filterCommands(query: string): SlashCommand[] { + return SLASH_COMMANDS.filter((cmd) => + cmd.name.toLowerCase().includes(`/${query.toLowerCase()}`), + ); +} + interface CommandPaletteProps { - query: string; - onSelect: (command: SlashCommand) => void; - onDismiss: () => void; - focused: boolean; + commands: SlashCommand[]; + selectedIndex: number; } export function CommandPalette({ - query, - onSelect, - onDismiss, - focused, + commands: filtered, + selectedIndex, }: CommandPaletteProps) { - const [selectedIndex, setSelectedIndex] = useState(0); - - const filtered = SLASH_COMMANDS.filter((cmd) => - cmd.name.toLowerCase().includes(`/${query.toLowerCase()}`), - ); - - useKeyboard((key) => { - if (!focused) return; - if (key.name === "escape") { - onDismiss(); - return; - } - if (key.name === "down" || (key.name === "j" && key.ctrl)) { - setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); - return; - } - if (key.name === "up" || (key.name === "k" && key.ctrl)) { - setSelectedIndex((i) => Math.max(i - 1, 0)); - return; - } - if (key.name === "return") { - const selected = filtered[selectedIndex]; - if (selected) { - onSelect(selected); - } - return; - } - }); - if (filtered.length === 0) { return ( = { "create-spec": "Spec", "create-prd": "PRD", + "create-prompt": "Prompt", }; interface ContextSidebarProps { @@ -36,6 +37,9 @@ export function ContextSidebar({ {planData.hasPrd ? "✓ prd.json" : "○ No PRD"} + + {planData.hasPrompt ? "✓ PROMPT.md" : "○ No prompt"} + {planData.tasks.length > 0 && ( {`${doneCount}/${planData.tasks.length} tasks`} )} diff --git a/apps/tui/src/components/file-picker.tsx b/apps/tui/src/components/file-picker.tsx index 2b6f5e4..8f1ca48 100644 --- a/apps/tui/src/components/file-picker.tsx +++ b/apps/tui/src/components/file-picker.tsx @@ -1,47 +1,13 @@ import { TextAttributes } from "@opentui/core"; -import { useKeyboard } from "@opentui/react"; -import { useState } from "react"; -import { useFileSearch } from "../hooks/use-file-search"; + +export const FILE_PICKER_VISIBLE_COUNT = 10; interface FilePickerProps { - query: string; - onSelect: (path: string) => void; - onDismiss: () => void; - focused: boolean; + results: string[]; + selectedIndex: number; } -export function FilePicker({ - query, - onSelect, - onDismiss, - focused, -}: FilePickerProps) { - const { results } = useFileSearch(query); - const [selectedIndex, setSelectedIndex] = useState(0); - - useKeyboard((key) => { - if (!focused) return; - if (key.name === "escape") { - onDismiss(); - return; - } - if (key.name === "down" || (key.name === "j" && key.ctrl)) { - setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); - return; - } - if (key.name === "up" || (key.name === "k" && key.ctrl)) { - setSelectedIndex((i) => Math.max(i - 1, 0)); - return; - } - if (key.name === "return") { - const selected = results[selectedIndex]; - if (selected) { - onSelect(selected); - } - return; - } - }); - +export function FilePicker({ results, selectedIndex }: FilePickerProps) { if (results.length === 0) { return ( = { "create-spec": "Spec", "create-prd": "PRD", + "create-prompt": "Prompt", }; function extractFileQuery(input: string): string | null { @@ -60,10 +61,30 @@ export function PlanChat({ }: PlanChatProps) { const [inputValue, setInputValue] = useState(""); const [fileRefs, setFileRefs] = useState([]); - const [showFilePicker, setShowFilePicker] = useState(false); - const [showCommandPalette, setShowCommandPalette] = useState(false); + const [pickerIndex, setPickerIndex] = useState(0); const syntaxStyle = useMemo(() => SyntaxStyle.create(), []); + const fileQuery = extractFileQuery(inputValue); + const showFilePicker = fileQuery !== null; + const showCommandPalette = + inputValue.startsWith("/") && !inputValue.includes(" "); + const commandQuery = showCommandPalette ? inputValue.slice(1) : ""; + const { results: fileResults } = useFileSearch(fileQuery ?? ""); + const visibleFiles = fileResults.slice(0, FILE_PICKER_VISIBLE_COUNT); + const filteredCommands = filterCommands(commandQuery); + + const COMMAND_MODES: Record = { + "/spec": { + mode: "create-spec", + fallback: "Create a spec for this project", + }, + "/prd": { mode: "create-prd", fallback: "Break the spec into tasks" }, + "/prompt": { + mode: "create-prompt", + fallback: "Generate the execution prompt", + }, + }; + useKeyboard((key) => { if (!focused) return; if (key.name === "m" && key.ctrl) { @@ -72,15 +93,30 @@ export function PlanChat({ if (key.name === "t" && key.ctrl) { onToggleTasks(); } + if (key.name === "tab" && showCommandPalette && !showFilePicker) { + const idx = Math.min(pickerIndex, filteredCommands.length - 1); + const cmd = filteredCommands[idx]; + if (cmd) { + setInputValue(`${cmd.name} `); + setPickerIndex(0); + } + } + if (showCommandPalette || showFilePicker) { + const maxIndex = showCommandPalette + ? filteredCommands.length - 1 + : visibleFiles.length - 1; + if (key.name === "n" && key.ctrl) { + setPickerIndex((i) => Math.min(i + 1, maxIndex)); + } + if (key.name === "p" && key.ctrl) { + setPickerIndex((i) => Math.max(0, i - 1)); + } + } }); const handleInputChange = (value: string) => { setInputValue(value); - - const fileQuery = extractFileQuery(value); - setShowFilePicker(fileQuery !== null); - - setShowCommandPalette(value.startsWith("/") && value.length >= 1); + setPickerIndex(0); }; const handleFileSelect = (path: string) => { @@ -90,58 +126,69 @@ export function PlanChat({ if (!fileRefs.includes(path)) { setFileRefs((prev) => [...prev, path]); } - setShowFilePicker(false); }; - const handleCommandSelect = (command: SlashCommand) => { - setShowCommandPalette(false); - setInputValue(""); + const buildPrompt = (text: string): string => { + if (fileRefs.length === 0) return text; + const fileContents = fileRefs + .map((ref) => `\n\n--- Contents of ${ref} ---\n${readFileContents(ref)}`) + .join(""); + return `${text}${fileContents}`; + }; - switch (command.name) { - case "/spec": - onSetMode("create-spec"); - setInputValue("Create a spec for this project"); - break; - case "/prd": - onSetMode("create-prd"); - setInputValue("Break the spec into tasks"); - break; - case "/tasks": - onToggleTasks(); - break; - case "/clear": - onClear(); - break; + const executeCommand = (cmdName: string, rest: string) => { + if (cmdName === "/tasks") { + onToggleTasks(); + setInputValue(""); + return; + } + if (cmdName === "/clear") { + onClear(); + setInputValue(""); + return; + } + const config = COMMAND_MODES[cmdName]; + if (config) { + onSetMode(config.mode); + const prompt = buildPrompt(rest || config.fallback); + setInputValue(""); + setFileRefs([]); + void onSend(prompt, config.mode); } }; const handleSubmit = (value: string) => { + if (showCommandPalette && !showFilePicker) { + const idx = Math.min(pickerIndex, filteredCommands.length - 1); + const cmd = filteredCommands[idx]; + if (cmd) executeCommand(cmd.name, ""); + return; + } + + if (showFilePicker) { + const idx = Math.min(pickerIndex, visibleFiles.length - 1); + const file = visibleFiles[idx]; + if (file) handleFileSelect(file); + return; + } + const trimmed = value.trim(); if (!trimmed || loading) return; if (trimmed.startsWith("/")) { - setInputValue(""); + const spaceIdx = trimmed.indexOf(" "); + const cmdName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); + executeCommand(cmdName, rest); return; } - let prompt = trimmed; - if (fileRefs.length > 0) { - const fileContents = fileRefs - .map( - (ref) => `\n\n--- Contents of ${ref} ---\n${readFileContents(ref)}`, - ) - .join(""); - prompt = `${trimmed}${fileContents}`; - } - + const prompt = buildPrompt(trimmed); setInputValue(""); setFileRefs([]); void onSend(prompt, mode); }; - const fileQuery = extractFileQuery(inputValue); - const commandQuery = inputValue.startsWith("/") ? inputValue.slice(1) : ""; - return ( @@ -185,20 +232,13 @@ export function PlanChat({ {showFilePicker && ( - setShowFilePicker(false)} - focused={focused} - /> + )} {showCommandPalette && !showFilePicker && ( setShowCommandPalette(false)} - focused={focused} + commands={filteredCommands} + selectedIndex={pickerIndex} /> )} @@ -220,7 +260,7 @@ export function PlanChat({ flexDirection="row" > + Tasks ready + + Try /prompt to generate the execution prompt. + + + ); + } + if (planData.hasSpec) { return ( /spec - {" Generate a project spec"} + {" Generate a project spec"} /prd - {" Break spec into tasks"} + {" Break spec into tasks"} + + + + /prompt + + {" Generate execution prompt"} @@ -74,7 +96,7 @@ export function WelcomeScreen({ planData }: WelcomeScreenProps) { Ctrl+M - {" Switch mode (Spec / PRD)"} + {" Switch mode (Spec / PRD / Prompt)"} diff --git a/apps/tui/src/hooks/use-chat.ts b/apps/tui/src/hooks/use-chat.ts index f63a9ef..1590409 100644 --- a/apps/tui/src/hooks/use-chat.ts +++ b/apps/tui/src/hooks/use-chat.ts @@ -1,8 +1,12 @@ import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useRef, useState } from "react"; -import { CREATE_PRD_SYSTEM_PROMPT, CREATE_SPEC_SYSTEM_PROMPT } from "../skills"; +import { + CREATE_PRD_SYSTEM_PROMPT, + CREATE_PROMPT_SYSTEM_PROMPT, + CREATE_SPEC_SYSTEM_PROMPT, +} from "../skills"; -export type ChatMode = "create-spec" | "create-prd"; +export type ChatMode = "create-spec" | "create-prd" | "create-prompt"; export interface ChatMessage { role: "user" | "assistant"; @@ -20,8 +24,12 @@ interface UseChatReturn { const SKILL_PROMPTS: Record = { "create-spec": CREATE_SPEC_SYSTEM_PROMPT, "create-prd": CREATE_PRD_SYSTEM_PROMPT, + "create-prompt": CREATE_PROMPT_SYSTEM_PROMPT, }; +const POLL_INTERVAL_MS = 1000; +const POLL_TIMEOUT_MS = 2 * 60 * 1000; + export function useChat(ensureInstance: () => Promise): UseChatReturn { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); @@ -65,7 +73,16 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { }, }); - pollingRef.current = setInterval(async () => { + const pollStartedAt = Date.now(); + + const pollOnce = async (): Promise => { + if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) { + stopPolling(); + setError("Request timed out"); + setLoading(false); + return true; + } + try { const { job: updated } = await daemon.getJob(job.id); @@ -82,22 +99,32 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { }, ]); setLoading(false); - } else if ( - updated.state === "failed" || - updated.state === "cancelled" - ) { + return true; + } + if (updated.state === "failed" || updated.state === "cancelled") { stopPolling(); setError(updated.error ?? "Job failed"); setLoading(false); + return true; } + return false; } catch (pollError) { stopPolling(); setError( pollError instanceof Error ? pollError.message : "Polling failed", ); setLoading(false); + return true; } - }, 1000); + }; + + const done = await pollOnce(); + if (!done) { + pollingRef.current = setInterval( + () => void pollOnce(), + POLL_INTERVAL_MS, + ); + } } catch (e) { setError(e instanceof Error ? e.message : "Failed to submit message"); setLoading(false); diff --git a/apps/tui/src/hooks/use-plan-files.ts b/apps/tui/src/hooks/use-plan-files.ts index 5a902ec..2a5bb3f 100644 --- a/apps/tui/src/hooks/use-plan-files.ts +++ b/apps/tui/src/hooks/use-plan-files.ts @@ -18,6 +18,7 @@ export interface PlanFilesData { progress: string; hasSpec: boolean; hasPrd: boolean; + hasPrompt: boolean; } interface UsePlanFilesReturn { @@ -31,6 +32,7 @@ const RALPH_DIR = join(process.cwd(), ".ralph"); const PRD_PATH = join(RALPH_DIR, "prd.json"); const PROGRESS_PATH = join(RALPH_DIR, "progress.md"); const SPEC_PATH = join(RALPH_DIR, "SPEC.md"); +const PROMPT_PATH = join(RALPH_DIR, "PROMPT.md"); function readFileAsync(path: string): Promise { return new Promise((resolve) => { @@ -63,6 +65,7 @@ export function usePlanFiles(): UsePlanFilesReturn { progress: "", hasSpec: false, hasPrd: false, + hasPrompt: false, }); const [loading, setLoading] = useState(true); const [error, setError] = useState(); @@ -72,16 +75,19 @@ export function usePlanFiles(): UsePlanFilesReturn { setLoading(true); setError(undefined); try { - const [prdContent, progressContent, specContent] = await Promise.all([ - readFileAsync(PRD_PATH), - readFileAsync(PROGRESS_PATH), - readFileAsync(SPEC_PATH), - ]); + const [prdContent, progressContent, specContent, promptContent] = + await Promise.all([ + readFileAsync(PRD_PATH), + readFileAsync(PROGRESS_PATH), + readFileAsync(SPEC_PATH), + readFileAsync(PROMPT_PATH), + ]); setData({ tasks: parsePrd(prdContent), progress: progressContent ?? "", hasSpec: specContent !== null, hasPrd: prdContent !== null, + hasPrompt: promptContent !== null, }); } catch (e) { setError(e instanceof Error ? e.message : "Failed to read plan files"); diff --git a/apps/tui/src/skills.ts b/apps/tui/src/skills.ts index e700242..1a29758 100644 --- a/apps/tui/src/skills.ts +++ b/apps/tui/src/skills.ts @@ -1,54 +1,175 @@ export const CREATE_SPEC_SYSTEM_PROMPT = `# SPEC Creation Helper -Create or refine SPEC.md for Ralph — an AI coding agent that reads SPEC.md at the start of every iteration to understand what it's building. +Create or refine \`SPEC.md\` for Ralph — an AI coding agent that reads \`SPEC.md\` at the start of every iteration to understand what it's building. ## Core Principles -- SPEC.md captures what and why. High-level goals, scope, and architectural decisions. Not implementation steps — those belong in prd.json. -- Codebase is source of truth. The agent reads code for implementation details. SPEC.md should not duplicate what the code already shows. -- Keep it stable. A good spec rarely changes. +- **SPEC.md captures what and why.** High-level goals, scope, and architectural decisions. Not implementation steps — those belong in \`prd.json\`. +- **Codebase is source of truth.** The agent reads code for implementation details. SPEC.md should not duplicate what the code already shows. +- **Keep it stable.** A good spec rarely changes. If you're constantly updating it, you're putting implementation details in the wrong place. +- **Universal scope.** SPEC.md describes the project as it should be — not tied to "v1" or a single milestone. Use \`prd.json\` for phased work. ## Output Template +\`\`\`markdown # Project Name > One-line description of the project. ## Overview + [2-3 paragraphs: what you're building, the problem it solves, and who it's for] ## Scope + ### Included - [High-level capability 1] +- [High-level capability 2] + ### Excluded - [What this project will NOT do] ## Technical Stack -- Language: [e.g., TypeScript 5.x] -- Framework: [e.g., Next.js 14] -- Database: [e.g., PostgreSQL 15 with Prisma] + +- **Language**: [e.g., TypeScript 5.x with strict mode] +- **Framework**: [e.g., Next.js 14 with App Router] +- **Database**: [e.g., PostgreSQL 15 with Prisma ORM] +- **Authentication**: [e.g., NextAuth.js with JWT] +- **Testing**: [e.g., Vitest + Playwright] +- **Other**: [Any other key technologies] ## Architecture -[High-level patterns, how major components communicate] + +[High-level patterns, system structure, how major components communicate] ## Constraints -[Non-functional requirements: performance, security, compatibility] -## Workflow +- [e.g., All code must pass TypeScript strict mode] +- [e.g., API responses must stay under 200ms p95] +- [e.g., Node.js 18+ required] + +## References + +- [Links to design mockups, external API docs, or prior art] +\`\`\` + +## Section Rules + +### 1. Overview — what, why, who + +Clearly state what the project is, the problem it solves, and the target users. Vagueness here cascades everywhere. + +\`\`\` +GOOD: "A REST API for managing inventory in small retail stores, + reducing manual stock counting by 80%." +BAD: "A cool app for managing stuff." +\`\`\` + +### 2. Scope — high-level capabilities, not implementation tasks + +List what the project does and doesn't do. Think capabilities, not user stories or acceptance criteria — those belong in \`prd.json\`. + +\`\`\` +GOOD (spec): +- User authentication and role-based access control +- Real-time inventory tracking across multiple locations + +BAD (belongs in prd.json): +- User can reset password via email link with 24h expiry token +- POST /api/auth/register returns 201 with JWT +\`\`\` + +Always include an **Excluded** section. Without boundaries, the agent will over-build. + +### 3. Technical Stack — eliminate all guesswork + +Every major technology choice must be explicit. If the agent has to guess, it will guess wrong. + +\`\`\` +GOOD: +- **Language**: TypeScript 5.x with strict mode +- **Framework**: Next.js 14 with App Router +- **Database**: PostgreSQL 15 with Prisma ORM + +BAD: +- Some backend framework +- A database +\`\`\` + +Include: language, framework, database + access method, infrastructure, key libraries. + +### 4. Architecture — decisions, not file trees + +Describe the high-level patterns and how components interact. Do NOT document directory structure or file-level organization — the codebase shows that. + +\`\`\` +GOOD: "Monolithic Express app with layered architecture: + routes → controllers → services → repositories. + All business logic lives in the service layer." + +BAD: "src/routes/ contains route files, src/controllers/ + contains controller files, src/services/ ..." +\`\`\` + +Optional for simple projects. + +### 5. Constraints — guiding principles, not exact targets + +Capture non-functional requirements that guide the agent's decisions. Keep them directional — exact thresholds and metrics belong in \`prd.json\` task notes. + +Categories: performance, security, compatibility, code quality. -1. Gather requirements — ask the user about what they're building, scope, tech stack, architecture, constraints. -2. Draft the spec following the template. -3. Present for feedback — ask about missing scope, unclear decisions. -4. Refine and write the final SPEC.md to .ralph/SPEC.md.`; +### 6. References — link external context + +Links to design mockups, API docs, similar projects. Optional — omit if none exist. + +## Workflows + +| User Intent | Workflow | +| -------------------------------------------------------- | -------------- | +| "Create spec", "define requirements", "plan the project" | **Create** | +| "Review spec", "improve spec", "update spec" | **Refine** | +| Unclear | Ask the user | + +### Create + +1. **Gather requirements** — ask the user: + - What are you building? What problem does it solve? Who uses it? + - What's in scope? What's explicitly out? + - What language/framework/database? Key libraries? + - High-level architecture (monolith, microservices, serverless)? + - Any hard constraints (performance, security, compatibility)? +2. **Draft the spec** following the output template and section rules. +3. **Present for feedback** — ask about missing scope, unclear decisions, or tech stack changes. +4. **Refine and output** the final \`SPEC.md\` to \`.ralph/SPEC.md\`. + +### Refine + +1. **Read existing \`SPEC.md\`** and evaluate against section rules. +2. **Identify gaps** — missing sections, vague scope, unspecified tech, no boundaries, implementation details that should move to \`prd.json\`. +3. **Ask clarifying questions** to fill gaps. +4. **Output the refined \`SPEC.md\`** to \`.ralph/SPEC.md\`. + +## Validation Checklist + +- [ ] Overview clearly states what, why, and who +- [ ] Scope lists high-level capabilities (not implementation tasks) +- [ ] Excluded section defines explicit boundaries +- [ ] All major technology choices specified +- [ ] Architecture describes patterns, not file structure +- [ ] Constraints are directional, not over-specified +- [ ] No implementation details that belong in \`prd.json\` +- [ ] Stable — won't need updating as code evolves`; export const CREATE_PRD_SYSTEM_PROMPT = `# PRD/Task Creation Helper -Create and manage prd.json task lists for Ralph — an AI coding agent that loops through tasks: reads prd.json, picks ONE task, completes it, marks passed: true, repeats until done. +Create and manage \`prd.json\` task lists for Ralph — an AI coding agent that loops through tasks: reads \`prd.json\`, picks ONE task, completes it, marks \`passed: true\`, repeats until done. Task quality directly determines agent performance. Follow the rules below strictly. ## Output Schema +\`\`\`json { "tasks": [ { @@ -59,20 +180,302 @@ Task quality directly determines agent performance. Follow the rules below stric } ] } +\`\`\` + +- \`description\`: What should be achieved when done. Clear and specific. +- \`subtasks\`: Ordered, actionable implementation steps. +- \`notes\`: Context and constraints for the agent. Can be empty string. +- \`passed\`: Always \`false\` for new tasks. ## Task Rules -1. Right-sized: Each task must be completable in a single agent session (~1-2 hours of human work). -2. Specific subtasks: Break down work into concrete steps. -3. Every task MUST end with verification + code quality checks (tests, type check, lint). -4. No overlapping scope between tasks. -5. Useful notes with context, constraints, and references. -6. Order logically: setup → models → features → integrations → polish → tests. +### 1. Right-sized tasks + +Each task must be completable in a single agent session (~1-2 hours of human work). + +\`\`\` +GOOD: "Implement POST /api/auth/register endpoint" +BAD: "Build the authentication system" → split into 4-6 tasks +\`\`\` + +If it would take a full day, break it down. If it takes 15 minutes, combine with related work. + +### 2. Specific, actionable subtasks + +\`\`\`json +// GOOD +"subtasks": [ + "Create src/models/user.ts with User interface", + "Define fields: id (UUID), email (string), passwordHash (string), createdAt (Date)", + "Add Zod schema for validation", + "Export UserCreate and UserResponse types" +] + +// BAD +"subtasks": ["Create user model", "Add fields", "Add validation"] +\`\`\` + +### 3. Every task MUST end with verification + code quality checks + +The final subtasks of every task must include: + +1. **Tests**: Run the project's test suite +2. **Type checking**: Run the type check script (e.g., \`npm run typecheck\`, \`npx tsc --noEmit\`) +3. **Linting/Formatting**: Run the linter/formatter (e.g., \`npm run lint\`, \`npm run format:check\`) + +Check \`package.json\` scripts or equivalent config to determine the correct commands. + +\`\`\`json +"subtasks": [ + "... implementation steps ...", + "Write tests for success and failure cases", + "Run npm test to verify all tests pass", + "Run npm run typecheck to ensure no type errors", + "Run npm run lint to ensure code quality" +] +\`\`\` + +> If the project lacks these tools, the setup task should configure them. All subsequent tasks must include these checks. + +### 4. No overlapping scope + +Each task must have clear boundaries. No two tasks should modify the same files or implement the same logic. + +\`\`\`json +// BAD — overlapping +{ "description": "Create User model", "subtasks": ["Define schema", "Add validation", "Create API routes"] }, +{ "description": "Build user API", "subtasks": ["Create routes for users", "Add validation"] } + +// GOOD — clear boundaries +{ "description": "Create User model and validation schemas", "subtasks": ["Define schema", "Add Zod validation", "Export types"] }, +{ "description": "Implement User CRUD API endpoints", "subtasks": ["Create GET /api/users", "Create POST /api/users"] } +\`\`\` + +### 5. Useful notes + +Include in \`notes\`: references to SPEC.md decisions, constraints, related files, gotchas, edge cases, and context about previous tasks. + +### 6. Logical ordering + +Ralph infers task order from the list position. Order tasks as: + +1. Setup/configuration +2. Core models/types +3. Core features +4. Integrations +5. Polish and edge cases +6. Integration/E2E tests + +## Workflows + +Determine workflow from the user's request: + +| User Intent | Workflow | +| ------------------------------------------------------- | --------------------- | +| "Create PRD", "plan the project", "break down the spec" | **Full PRD Creation** | +| "Add a task", "create a task for X" | **Incremental** | +| Unclear | Ask the user | + +### Full PRD Creation + +1. **Get context**: Read \`SPEC.md\` if it exists. Otherwise, use the user's description. If neither exists, explore the codebase (directory structure, manifests, entry files, tests) and summarize your understanding to the user for confirmation. +2. **Analyze**: Identify setup requirements, data models, features, API endpoints, frontend components, integrations, and testing needs. +3. **Propose tasks**: Create the ordered task list following all rules above. +4. **Get feedback**: Present the list and ask about ordering, sizing, missing features, and tasks to combine/split. +5. **Refine and output**: Incorporate feedback and generate \`.ralph/prd.json\`. + +### Incremental Task Management + +1. **Read existing \`prd.json\`** to avoid duplicates and match existing style. +2. **Explore codebase** briefly if needed for context. +3. **Create one well-formed task** following all rules above. +4. **Present to user** for confirmation. +5. **Append to \`prd.json\`** (or create it if it doesn't exist), placing logically based on dependencies. + +## Examples + +### Project Setup + +\`\`\`json +{ + "description": "Initialize project with TypeScript, ESLint, and Prettier", + "subtasks": [ + "Run npm init -y to create package.json", + "Install TypeScript and initialize with npx tsc --init", + "Configure tsconfig.json with strict mode, ES2022 target, and path aliases", + "Install and configure ESLint with TypeScript plugin", + "Install and configure Prettier with ESLint integration", + "Add scripts to package.json: build, typecheck, lint, format", + "Create src/index.ts with a simple console.log to verify setup", + "Run npm run build && npm run typecheck to verify configuration", + "Run npm run lint && npm run format:check to verify code quality tooling" + ], + "notes": "Use ESM modules (type: module in package.json). Target Node.js 18+.", + "passed": false +} +\`\`\` + +### Data Model + +\`\`\`json +{ + "description": "Create User model with Prisma schema and TypeScript types", + "subtasks": [ + "Add User model to prisma/schema.prisma with fields: id (UUID), email, passwordHash, name, createdAt, updatedAt", + "Add unique constraint on email, set id default to uuid()", + "Run npx prisma migrate dev --name add-user-model", + "Create src/types/user.ts with User, UserCreate, and UserResponse types", + "Create src/lib/validation/user.ts with Zod schemas for each type", + "Run npx prisma generate to update client", + "Run npm run typecheck to ensure no type errors", + "Run npm run lint to ensure code quality" + ], + "notes": "Ensure passwordHash is never included in UserResponse type. Email should be lowercase and trimmed.", + "passed": false +} +\`\`\` + +### API Endpoint + +\`\`\`json +{ + "description": "Implement POST /api/auth/register endpoint", + "subtasks": [ + "Create src/routes/auth/register.ts", + "Add POST handler that accepts { email, password, name }", + "Validate request body using Zod schema from src/lib/validation/user.ts", + "Check if user with email already exists, return 409 if so", + "Hash password with bcrypt (12 rounds)", + "Create user in database with Prisma", + "Return 201 with user data (excluding passwordHash)", + "Write tests in tests/routes/auth/register.test.ts", + "Test: successful registration returns 201", + "Test: duplicate email returns 409", + "Test: invalid email format returns 400", + "Run npm test to verify all tests pass", + "Run npm run typecheck to ensure no type errors", + "Run npm run lint to ensure code quality" + ], + "notes": "Follow error response format in src/lib/errors.ts. Use the db client from src/lib/db.ts.", + "passed": false +} +\`\`\` + +## Validation Checklist + +Before finalizing, verify every task meets: + +- [ ] Completable in a single agent session +- [ ] Subtasks are specific and actionable +- [ ] Ends with test + type check + lint/format subtasks +- [ ] No overlapping scope with other tasks +- [ ] Logically ordered (setup → models → features → polish) +- [ ] Notes provide helpful context +- [ ] Valid JSON following the schema`; + +export const CREATE_PROMPT_SYSTEM_PROMPT = `# Execution Prompt Generator + +Generate \`PROMPT.md\` — the instruction file that Ralph's AI coding agent reads at the start of every iteration to know how to work through tasks. + +## Prerequisites + +Before generating, read the following files in the \`.ralph/\` directory: +- **\`.ralph/SPEC.md\`** — project specification (what's being built) +- **\`.ralph/prd.json\`** — task list with all tasks and their status + +If either file is missing, tell the user to create them first (\`/spec\` then \`/prd\`). + +## What to Generate + +Generate a \`PROMPT.md\` file tailored to this specific project. The prompt must follow this 7-step agent workflow: + +### Step 1: Understand Context + +Instruct the agent to read these files at the start of every session: +1. \`SPEC.md\` — project specification +2. \`prd.json\` — task list with status +3. \`progress.md\` — log of completed work from previous iterations + +> Note: these files are in the \`.ralph/\` subdirectory. + +### Step 2: Select a Task + +Instruct the agent to: +- Choose ONE task where \`passed: false\` +- Analyze task descriptions and current project state to determine the best next task +- Consider logical dependencies +- If unclear, prefer tasks listed earlier in the file + +### Step 3: Complete the Task + +Instruct the agent to: +- Follow the \`subtasks\` array as implementation guide +- Write clean, well-structured code +- Verify work before marking complete (run tests, type checks, linting) + +**Important**: Include the project-specific verification commands from SPEC.md or package.json. For example: +- Tests: \`bun test\`, \`npm test\`, \`pytest\`, etc. +- Type checking: \`bun run check:types\`, \`npx tsc --noEmit\`, \`mypy\`, etc. +- Linting: \`bun run check\`, \`npm run lint\`, etc. + +### Step 4: Update Progress + +Instruct the agent to append to \`progress.md\` using this format: + +\`\`\`markdown +--- + +## Task: [Task description from prd.json] + +### Completed +- [What was accomplished] + +### Files Changed +- [List of files] + +### Decisions +- [Any architectural or implementation decisions] + +### Notes for Future Agent +- [Helpful context for future iterations] +\`\`\` + +Rules: **APPEND ONLY** — never modify or delete previous entries. + +### Step 5: Update prd.json + +Instruct the agent to: +1. Set \`passed: true\` for the completed task +2. Update \`notes\` field of any other tasks if relevant context was discovered + +### Step 6: Commit Changes + +Instruct the agent to create a git commit with a clear, descriptive message about what was implemented. + +### Step 7: Signal Completion + +Instruct the agent to output this exact string on its own line when finished: + +\`\`\` +RALPH_TASK_COMPLETE +\`\`\` + +## Important Rules to Include + +The generated PROMPT.md must include these rules: + +1. **One task per session** — do not work on multiple tasks +2. **Verify before marking complete** — ensure the implementation actually works +3. **Append-only progress** — never edit previous progress.md entries +4. **Leave context** — future iterations depend on your notes +5. **Commit your work** — all changes must be committed before signaling completion ## Workflow -1. Read SPEC.md if it exists. Otherwise, explore the codebase and ask the user. -2. Analyze: Identify setup, models, features, APIs, components, integrations, testing needs. -3. Propose tasks following all rules above. -4. Get feedback from the user about ordering, sizing, missing features. -5. Write the final prd.json to .ralph/prd.json.`; +1. Read \`.ralph/SPEC.md\` and \`.ralph/prd.json\` to understand the project +2. Identify project-specific tooling (test runner, type checker, linter) from SPEC.md or by reading package.json / config files +3. Generate a tailored \`PROMPT.md\` that includes: + - The 7-step workflow above + - Project-specific commands for verification + - The important rules section +4. Write the output to \`.ralph/PROMPT.md\``; From d930dec78350b3884fb3f781ac2020d678fbab1b Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Wed, 15 Apr 2026 18:50:44 -0400 Subject: [PATCH 04/21] feat(tui): add execute start flow and keep tab views mounted Wires a start-execution action in the Execute view that reads .ralph/PROMPT.md and submits a job, surfaces a daemon-offline indicator in Plan chat, keeps all tab views mounted so state survives tab switches, and drops the unused Ctrl+M mode toggle. Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/components/app.tsx | 36 ++++++++-- apps/tui/src/components/context-sidebar.tsx | 12 ---- apps/tui/src/components/execute-view.tsx | 79 ++++++++++++++++++++- apps/tui/src/components/help-overlay.tsx | 1 - apps/tui/src/components/plan-chat.tsx | 14 ++-- apps/tui/src/components/plan-view.tsx | 21 ++---- apps/tui/src/components/status-bar.tsx | 2 +- apps/tui/src/components/welcome-screen.tsx | 6 -- apps/tui/src/hooks/use-plan-files.ts | 2 + 9 files changed, 125 insertions(+), 48 deletions(-) diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index afb6237..ce54cd9 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -107,11 +107,37 @@ export function App({ onQuit }: AppProps) { /> - {activeTab === 0 && ( - - )} - {activeTab === 1 && } - {activeTab === 2 && } + + + + + + + + + {showHelp && setShowHelp(false)} />} diff --git a/apps/tui/src/components/context-sidebar.tsx b/apps/tui/src/components/context-sidebar.tsx index dcd8a54..35317e2 100644 --- a/apps/tui/src/components/context-sidebar.tsx +++ b/apps/tui/src/components/context-sidebar.tsx @@ -1,22 +1,13 @@ import { TextAttributes } from "@opentui/core"; -import type { ChatMode } from "../hooks/use-chat"; import type { PlanFilesData } from "../hooks/use-plan-files"; -const MODE_LABELS: Record = { - "create-spec": "Spec", - "create-prd": "PRD", - "create-prompt": "Prompt", -}; - interface ContextSidebarProps { planData: PlanFilesData; - mode: ChatMode; messageCount: number; } export function ContextSidebar({ planData, - mode, messageCount, }: ContextSidebarProps) { const doneCount = planData.tasks.filter((t) => t.passed).length; @@ -47,9 +38,6 @@ export function ContextSidebar({ Session - {`Mode: ${MODE_LABELS[mode]}`} {`${messageCount} messages`} ); diff --git a/apps/tui/src/components/execute-view.tsx b/apps/tui/src/components/execute-view.tsx index f1b0d18..a774b01 100644 --- a/apps/tui/src/components/execute-view.tsx +++ b/apps/tui/src/components/execute-view.tsx @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; import type { @@ -7,6 +9,7 @@ import type { } from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; +import type { PlanFilesData } from "../hooks/use-plan-files"; interface DashboardData { health: HealthResult; @@ -16,6 +19,7 @@ interface DashboardData { interface ExecuteViewProps { focused: boolean; + planData: PlanFilesData; } function clampIndex(index: number, length: number): number { @@ -52,11 +56,13 @@ function jobStateColor(state: string): string { return "#888888"; } -export function ExecuteView({ focused }: ExecuteViewProps) { +export function ExecuteView({ focused, planData }: ExecuteViewProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [data, setData] = useState(); const [selectedIndex, setSelectedIndex] = useState(0); + const [starting, setStarting] = useState(false); + const [startMessage, setStartMessage] = useState(); const refresh = useCallback( async (nextIndex = selectedIndex) => { @@ -95,6 +101,51 @@ export function ExecuteView({ focused }: ExecuteViewProps) { void refresh(); }, [refresh]); + const handleStart = useCallback(async () => { + if (starting) return; + setStarting(true); + setStartMessage(undefined); + setError(undefined); + try { + const cwd = process.cwd(); + const promptPath = join(cwd, ".ralph", "PROMPT.md"); + const promptContent = (await readFile(promptPath, "utf-8")).trim(); + if (!promptContent) { + throw new Error("PROMPT.md is empty"); + } + + const { instances } = await daemon.listInstances(); + let instance = instances.find((i) => i.directory === cwd); + if (!instance) { + const created = await daemon.createInstance({ + name: "execute", + directory: cwd, + }); + instance = created.instance; + } + + await daemon.submitJob({ + instanceId: instance.id, + session: { type: "new" }, + task: { + type: "prompt", + prompt: promptContent, + }, + }); + + setStartMessage("Job submitted"); + await refresh(); + } catch (startError) { + setError( + startError instanceof Error + ? startError.message + : "Failed to start execution", + ); + } finally { + setStarting(false); + } + }, [refresh, starting]); + useKeyboard((key) => { if (!focused) return; @@ -103,6 +154,11 @@ export function ExecuteView({ focused }: ExecuteViewProps) { return; } + if (key.name === "s" && planData.hasPrompt && !starting) { + void handleStart(); + return; + } + if (!data) return; if (key.name === "down" || key.name === "j") { @@ -119,6 +175,7 @@ export function ExecuteView({ focused }: ExecuteViewProps) { }); const selected = data?.instances[selectedIndex]; + const planReady = planData.hasPrompt; return ( @@ -137,6 +194,26 @@ export function ExecuteView({ focused }: ExecuteViewProps) { + + {starting ? ( + Starting execution... + ) : planReady ? ( + <> + Plan ready + + {" Press [s] to start execution"} + + + ) : ( + + Complete spec, prd, and prompt in Plan view to enable execution + + )} + + {startMessage && !error && {startMessage}} + {error && {error}} + + Instances diff --git a/apps/tui/src/components/help-overlay.tsx b/apps/tui/src/components/help-overlay.tsx index 297ad44..10ac1a2 100644 --- a/apps/tui/src/components/help-overlay.tsx +++ b/apps/tui/src/components/help-overlay.tsx @@ -19,7 +19,6 @@ const GENERAL_BINDINGS: KeyBinding[] = [ ]; const PLAN_BINDINGS: KeyBinding[] = [ - { keys: "Ctrl+M", desc: "Switch mode (Spec / PRD / Prompt)" }, { keys: "Ctrl+T", desc: "Toggle task list" }, { keys: "@", desc: "Insert file reference" }, { keys: "/", desc: "Open command palette" }, diff --git a/apps/tui/src/components/plan-chat.tsx b/apps/tui/src/components/plan-chat.tsx index 1cbac02..ce151cf 100644 --- a/apps/tui/src/components/plan-chat.tsx +++ b/apps/tui/src/components/plan-chat.tsx @@ -16,8 +16,8 @@ interface PlanChatProps { loading: boolean; error: string | undefined; planData: PlanFilesData; + daemonOnline: boolean; onSend: (prompt: string, mode: ChatMode) => Promise; - onToggleMode: () => void; onToggleTasks: () => void; onClear: () => void; onSetMode: (mode: ChatMode) => void; @@ -52,8 +52,8 @@ export function PlanChat({ loading, error, planData, + daemonOnline, onSend, - onToggleMode, onToggleTasks, onClear, onSetMode, @@ -87,9 +87,6 @@ export function PlanChat({ useKeyboard((key) => { if (!focused) return; - if (key.name === "m" && key.ctrl) { - onToggleMode(); - } if (key.name === "t" && key.ctrl) { onToggleTasks(); } @@ -243,6 +240,13 @@ export function PlanChat({ )} + {!daemonOnline && ( + + + {"Daemon offline — start with bun run dev"} + + + )} {fileRefs.length > 0 && ( {fileRefs.map((ref) => ( diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx index 3d288d0..3c720f0 100644 --- a/apps/tui/src/components/plan-view.tsx +++ b/apps/tui/src/components/plan-view.tsx @@ -8,20 +8,19 @@ import { ContextSidebar } from "./context-sidebar"; import { PlanChat } from "./plan-chat"; import { TaskOverlay } from "./task-overlay"; -const MODES: ChatMode[] = ["create-spec", "create-prd", "create-prompt"]; const SIDEBAR_MIN_WIDTH = 120; interface PlanViewProps { focused: boolean; planData: PlanFilesData; + daemonOnline: boolean; } -export function PlanView({ focused, planData }: PlanViewProps) { +export function PlanView({ focused, planData, daemonOnline }: PlanViewProps) { const [showTasks, setShowTasks] = useState(false); - const [modeIndex, setModeIndex] = useState(0); + const [mode, setMode] = useState("create-spec"); const planInstance = usePlanInstance(); const chat = useChat(planInstance.ensure); - const mode: ChatMode = MODES[modeIndex] ?? "create-spec"; const { width } = useTerminalDimensions(); const showSidebar = width >= SIDEBAR_MIN_WIDTH; @@ -32,21 +31,10 @@ export function PlanView({ focused, planData }: PlanViewProps) { } }); - const toggleMode = () => { - setModeIndex((i) => (i + 1) % MODES.length); - }; - const toggleTasks = () => { setShowTasks((s) => !s); }; - const setMode = (newMode: ChatMode) => { - const index = MODES.indexOf(newMode); - if (index !== -1) { - setModeIndex(index); - } - }; - return ( @@ -56,8 +44,8 @@ export function PlanView({ focused, planData }: PlanViewProps) { loading={chat.loading} error={chat.error ?? planInstance.error} planData={planData} + daemonOnline={daemonOnline} onSend={chat.send} - onToggleMode={toggleMode} onToggleTasks={toggleTasks} onClear={chat.clear} onSetMode={setMode} @@ -67,7 +55,6 @@ export function PlanView({ focused, planData }: PlanViewProps) { {showSidebar && ( )} diff --git a/apps/tui/src/components/status-bar.tsx b/apps/tui/src/components/status-bar.tsx index e88722d..948871c 100644 --- a/apps/tui/src/components/status-bar.tsx +++ b/apps/tui/src/components/status-bar.tsx @@ -7,7 +7,7 @@ interface StatusBarProps { } const HELP_BY_TAB: Record = { - 0: "Tab: tabs Ctrl+M: mode Ctrl+T: tasks ?: help", + 0: "Tab: tabs Ctrl+T: tasks /: commands ?: help", 1: "Tab: tabs j/k: select r: refresh ?: help", 2: "Tab: tabs ?: help", }; diff --git a/apps/tui/src/components/welcome-screen.tsx b/apps/tui/src/components/welcome-screen.tsx index caec9f2..3b37682 100644 --- a/apps/tui/src/components/welcome-screen.tsx +++ b/apps/tui/src/components/welcome-screen.tsx @@ -93,12 +93,6 @@ export function WelcomeScreen({ planData }: WelcomeScreenProps) { Shortcuts: - - Ctrl+M - - {" Switch mode (Spec / PRD / Prompt)"} - - Ctrl+T {" Toggle task list"} diff --git a/apps/tui/src/hooks/use-plan-files.ts b/apps/tui/src/hooks/use-plan-files.ts index 2a5bb3f..5f7f158 100644 --- a/apps/tui/src/hooks/use-plan-files.ts +++ b/apps/tui/src/hooks/use-plan-files.ts @@ -1,4 +1,5 @@ import { readFile, watch } from "node:fs"; +import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -75,6 +76,7 @@ export function usePlanFiles(): UsePlanFilesReturn { setLoading(true); setError(undefined); try { + await mkdir(RALPH_DIR, { recursive: true }); const [prdContent, progressContent, specContent, promptContent] = await Promise.all([ readFileAsync(PRD_PATH), From c77171b39e2365b496b7d3888418bfcae60cee9b Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Wed, 15 Apr 2026 18:51:33 -0400 Subject: [PATCH 05/21] refactor(tui): stop auto-starting plan instance on ensure Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/hooks/use-plan-instance.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/tui/src/hooks/use-plan-instance.ts b/apps/tui/src/hooks/use-plan-instance.ts index ae0f3eb..dab60f8 100644 --- a/apps/tui/src/hooks/use-plan-instance.ts +++ b/apps/tui/src/hooks/use-plan-instance.ts @@ -27,9 +27,6 @@ export function usePlanInstance(): UsePlanInstanceReturn { const existing = instances.find((i) => i.directory === cwd); if (existing) { setInstanceId(existing.id); - if (existing.status === "stopped") { - await daemon.startInstance(existing.id); - } return existing.id; } @@ -37,7 +34,6 @@ export function usePlanInstance(): UsePlanInstanceReturn { name: "plan", directory: cwd, }); - await daemon.startInstance(instance.id); setInstanceId(instance.id); return instance.id; } catch (e) { From d8199870c6a6293f9fdc7fb14be61c91018fb4cf Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Wed, 15 Apr 2026 18:57:12 -0400 Subject: [PATCH 06/21] feat(tui): wire Chat into Execute tab Restores the per-instance Chat UI (added on main in #8) that was displaced by the tab-based rewrite. Pressing enter on a selected instance in the Execute view opens the full-screen Chat for that instance, matching the previous Dashboard flow. Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/components/app.tsx | 22 ++++++++++++++++++++++ apps/tui/src/components/execute-view.tsx | 15 ++++++++++++++- apps/tui/src/components/help-overlay.tsx | 6 ++++-- apps/tui/src/components/status-bar.tsx | 2 +- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index ce54cd9..e283234 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -3,6 +3,7 @@ import { useKeyboard } from "@opentui/react"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; import { usePlanFiles } from "../hooks/use-plan-files"; +import { Chat } from "./chat"; import { ExecuteView } from "./execute-view"; import { HelpOverlay } from "./help-overlay"; import { PlanView } from "./plan-view"; @@ -15,6 +16,11 @@ interface AppProps { type FocusZone = "tabs" | "content"; +interface ActiveChat { + instanceId: string; + instanceName: string; +} + const TAB_OPTIONS = [ { name: "Plan", description: "" }, { name: "Execute", description: "" }, @@ -26,6 +32,7 @@ export function App({ onQuit }: AppProps) { const [focusZone, setFocusZone] = useState("content"); const [daemonOnline, setDaemonOnline] = useState(true); const [showHelp, setShowHelp] = useState(false); + const [activeChat, setActiveChat] = useState(null); const planFiles = usePlanFiles(); const checkDaemon = useCallback(async () => { @@ -44,6 +51,7 @@ export function App({ onQuit }: AppProps) { }, [checkDaemon]); useKeyboard((key) => { + if (activeChat) return; if (showHelp) { if ( key.name === "escape" || @@ -86,6 +94,17 @@ export function App({ onQuit }: AppProps) { const contentFocused = focusZone === "content"; + if (activeChat) { + return ( + setActiveChat(null)} + onQuit={onQuit} + /> + ); + } + return ( @@ -128,6 +147,9 @@ export function App({ onQuit }: AppProps) { + setActiveChat({ instanceId, instanceName }) + } /> void; } function clampIndex(index: number, length: number): number { @@ -56,7 +57,11 @@ function jobStateColor(state: string): string { return "#888888"; } -export function ExecuteView({ focused, planData }: ExecuteViewProps) { +export function ExecuteView({ + focused, + planData, + onOpenChat, +}: ExecuteViewProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [data, setData] = useState(); @@ -161,6 +166,14 @@ export function ExecuteView({ focused, planData }: ExecuteViewProps) { if (!data) return; + if (key.name === "return") { + const instance = data.instances[selectedIndex]; + if (instance) { + onOpenChat(instance.id, instance.name); + } + return; + } + if (key.name === "down" || key.name === "j") { const next = clampIndex(selectedIndex + 1, data.instances.length); void refresh(next); diff --git a/apps/tui/src/components/help-overlay.tsx b/apps/tui/src/components/help-overlay.tsx index 10ac1a2..0003b46 100644 --- a/apps/tui/src/components/help-overlay.tsx +++ b/apps/tui/src/components/help-overlay.tsx @@ -30,8 +30,10 @@ const TASK_BINDINGS: KeyBinding[] = [ ]; const EXECUTE_BINDINGS: KeyBinding[] = [ - { keys: "j/k", desc: "Select job" }, - { keys: "r", desc: "Refresh jobs" }, + { keys: "j/k", desc: "Select instance" }, + { keys: "Enter", desc: "Chat with instance" }, + { keys: "s", desc: "Start execution from plan" }, + { keys: "r", desc: "Refresh" }, ]; function KeyRow({ keys, desc }: KeyBinding) { diff --git a/apps/tui/src/components/status-bar.tsx b/apps/tui/src/components/status-bar.tsx index 948871c..3619e05 100644 --- a/apps/tui/src/components/status-bar.tsx +++ b/apps/tui/src/components/status-bar.tsx @@ -8,7 +8,7 @@ interface StatusBarProps { const HELP_BY_TAB: Record = { 0: "Tab: tabs Ctrl+T: tasks /: commands ?: help", - 1: "Tab: tabs j/k: select r: refresh ?: help", + 1: "Tab: tabs j/k: select enter: chat r: refresh ?: help", 2: "Tab: tabs ?: help", }; From 8db3ce3c5091d0a92715b96a224729c1fef15943 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Fri, 24 Apr 2026 00:49:59 -0400 Subject: [PATCH 07/21] Add OpenCode handoff notes --- handoff.md | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 handoff.md diff --git a/handoff.md b/handoff.md new file mode 100644 index 0000000..2a852ca --- /dev/null +++ b/handoff.md @@ -0,0 +1,149 @@ +# Handoff + +Last updated: April 24, 2026 + +## Objective + +Ralph is a coding-agent orchestration TUI built around a Plan -> Execute -> Review flow: + +1. **Plan**: chat with an agent to produce `SPEC.md` and `prd.json`. +2. **Execute**: read `prd.json`, create isolated task agents, and monitor job progress. +3. **Review**: inspect per-task diffs and approve or reject changes. + +The current product focus is the Plan view and the overall structure of that flow. The immediate blocker was OpenCode crashing before Plan-mode messages could get through. + +## OpenCode Crash Diagnosis + +The OpenCode crash shown in the terminal was: + +```text +TypeError: undefined is not an object (evaluating 'n._zod.def') +``` + +The stack trace pointed into OpenCode's bundled Zod/tool validation code. The local OpenCode plugin at: + +```text +~/.config/opencode/plugins/claude-mem.js +``` + +registered `claude_mem_search` with plain JSON-schema-style args: + +```js +args: { + query: { + type: "string", + description: "Search query for memory observations", + }, +} +``` + +OpenCode `1.14.x` expects plugin tools to be wrapped with `tool(...)` from `@opencode-ai/plugin/tool`, and tool args must be Zod schemas: + +```js +import { tool } from "@opencode-ai/plugin/tool"; + +tool({ + description: "...", + args: { + query: tool.schema.string().describe("Search query for memory observations"), + }, + async execute(args) { + // ... + }, +}); +``` + +That mismatch explains why OpenCode tried to read `_zod.def` from an object that was not a Zod schema. + +## Fix Applied + +`~/.config/opencode/plugins/claude-mem.js` was rewritten as a readable ESM plugin that: + +- imports `tool` from `@opencode-ai/plugin/tool`; +- wraps `claude_mem_search` in `tool({ ... })`; +- replaces the plain `query` arg object with `tool.schema.string().describe(...)`; +- preserves the existing worker calls, session mapping, hooks, and event behavior. + +## Secondary Issues Found + +### Stale OpenCode Server On Port 4096 + +A stale `opencode serve` process was previously listening on port `4096`. Ralph's installed OpenCode SDK defaulted to port `4096`, so a stale server can collide with new daemon/OpenCode runtime startup. + +Useful checks: + +```bash +lsof -nP -iTCP:4096 -sTCP:LISTEN +ps -p -o pid,ppid,command +``` + +Cleanup: + +```bash +kill +``` + +### Invalid Ralph Model + +Ralph's dev config had: + +```text +concentrate/kimi-k2-5 +``` + +but OpenCode reported: + +```text +Provider not found: concentrate +``` + +Known-good OpenRouter examples from the local OpenCode setup: + +```text +openrouter/anthropic/claude-sonnet-4.5 +openrouter/anthropic/claude-haiku-4.5 +openrouter/minimax/minimax-m2.7 +openrouter/moonshotai/kimi-k2 +``` + +The TUI model store lives at: + +```text +~/.config/ralph/config.json +``` + +To set a valid Ralph model from `apps/tui`: + +```bash +bun run src/cli.ts model set openrouter/anthropic/claude-sonnet-4.5 +``` + +## Verification Commands + +After patching `claude-mem`, run: + +```bash +opencode models openrouter +``` + +This should no longer crash with `_zod.def`. + +Then compare a normal OpenCode run with a pure run: + +```bash +opencode /Users/kevinpei/ralph --prompt hi --model openrouter/anthropic/claude-haiku-4.5 +opencode /Users/kevinpei/ralph --pure --prompt hi --model openrouter/anthropic/claude-haiku-4.5 +``` + +Finally, start Ralph and verify the Plan chat can submit a message and receive non-empty output: + +```bash +cd /Users/kevinpei/ralph/apps/tui +bun run dev +``` + +## Ralph Plan-View Notes + +- `project.md` describes the intended Plan -> Execute -> Review architecture. +- `roadmap.md` is partially stale: it says streaming is missing, but the repo already contains `daemon.streamJob` and TUI hooks consuming it. +- The next Ralph work should focus on making Plan chat reliable against OpenCode, then aligning the Execute flow with `prd.json` task dispatch and worktree isolation. From 89b24578b5be896a3d6502992bfe9f390792a6fb Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Fri, 24 Apr 2026 01:11:50 -0400 Subject: [PATCH 08/21] Fix daemon completion with current OpenCode SDK --- bun.lock | 8 +- packages/daemon/package.json | 2 +- packages/daemon/src/opencode.ts | 44 +++++-- packages/daemon/src/protocol.ts | 8 ++ packages/daemon/src/server.ts | 212 +++++++++++++++++++++++++++----- 5 files changed, 228 insertions(+), 46 deletions(-) diff --git a/bun.lock b/bun.lock index 07d0f03..4c9b8a0 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,7 @@ "name": "@techatnyu/ralphd", "version": "0.0.0", "dependencies": { - "@opencode-ai/sdk": "^1.2.10", + "@opencode-ai/sdk": "1.14.22", "zod": "^4.3.6", }, "devDependencies": { @@ -346,7 +346,7 @@ "@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.13", "", {}, "sha512-/M6HlNnba+xf1EId6qFb2tG0cvq0db3PCQDug1glrf8wYOU57LYNF8WvHX9zoDKPTMv0F+O4pcP/8J+WvDaxHA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.22", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-1PjkrZRAwm9ocfTwOleP/e31HYtLVODb2E1hYTRHMmvF2rmAdCm7lztguYVkAPn/B6koGpFvhslTQH7j+38Fjw=="], "@opentui/core": ["@opentui/core@0.1.95", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.95", "@opentui/core-darwin-x64": "0.1.95", "@opentui/core-linux-arm64": "0.1.95", "@opentui/core-linux-x64": "0.1.95", "@opentui/core-win32-arm64": "0.1.95", "@opentui/core-win32-x64": "0.1.95", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Ha73I+PPSy6Jk8CTZgdGRHU+nnmrPAs7m6w0k6ge1/kWbcNcZB0lY67sWQMdoa6bSINQMNWg7SjbNCC9B/0exg=="], @@ -1554,6 +1554,8 @@ "@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@techatnyu/ralphd/@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1602,6 +1604,8 @@ "@tanstack/start-plugin-core/@tanstack/router-utils/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "@techatnyu/ralphd/@types/bun/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], "fumadocs-mdx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], diff --git a/packages/daemon/package.json b/packages/daemon/package.json index d9cec2f..8d93c84 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -12,7 +12,7 @@ "check:types": "tsc --noEmit" }, "dependencies": { - "@opencode-ai/sdk": "^1.2.10", + "@opencode-ai/sdk": "1.14.22", "zod": "^4.3.6" }, "devDependencies": { diff --git a/packages/daemon/src/opencode.ts b/packages/daemon/src/opencode.ts index 9509ec0..486eb38 100644 --- a/packages/daemon/src/opencode.ts +++ b/packages/daemon/src/opencode.ts @@ -8,7 +8,15 @@ import { } from "@opencode-ai/sdk/v2"; export interface OpencodeSessionClient { - create(parameters: { directory?: string; title?: string }): Promise; + create(parameters: { + directory?: string; + title?: string; + permission?: Array<{ + permission: string; + pattern: string; + action: "allow" | "deny" | "ask"; + }>; + }): Promise; prompt(parameters: { sessionID: string; directory?: string; @@ -47,6 +55,14 @@ export interface ProviderListResult { connected: string[]; } +type RawProviderModel = ProviderModel & { + capabilities?: { + attachment?: boolean; + reasoning?: boolean; + toolcall?: boolean; + }; +}; + export interface OpencodeRuntimeClient { session: OpencodeSessionClient; instance: { @@ -179,17 +195,21 @@ export class OpencodeRegistry implements OpencodeRuntimeManager { id: p.id, name: p.name, models: Object.fromEntries( - Object.entries(p.models).map(([k, m]) => [ - k, - { - id: m.id, - name: m.name, - family: m.family, - attachment: m.attachment, - reasoning: m.reasoning, - tool_call: m.tool_call, - }, - ]), + Object.entries(p.models).map(([k, rawModel]) => { + const m = rawModel as RawProviderModel; + return [ + k, + { + id: m.id, + name: m.name, + family: m.family, + attachment: + m.attachment ?? m.capabilities?.attachment, + reasoning: m.reasoning ?? m.capabilities?.reasoning, + tool_call: m.tool_call ?? m.capabilities?.toolcall, + }, + ]; + }), ), })), connected: response.data.connected, diff --git a/packages/daemon/src/protocol.ts b/packages/daemon/src/protocol.ts index b8545f7..3adeaec 100644 --- a/packages/daemon/src/protocol.ts +++ b/packages/daemon/src/protocol.ts @@ -46,11 +46,19 @@ const JobTask = z.discriminatedUnion("type", [ ]); export type JobTask = z.infer; +const PermissionRule = z.strictObject({ + permission: z.string().min(1), + pattern: z.string().min(1), + action: z.enum(["allow", "deny", "ask"]), +}); +export type PermissionRule = z.infer; + /** Whether the job starts a new conversation or continues an existing one. */ const JobSession = z.discriminatedUnion("type", [ z.strictObject({ type: z.literal("new"), title: z.string().min(1).optional(), + permission: z.array(PermissionRule).optional(), }), z.strictObject({ type: z.literal("existing"), diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 74e406e..abd67ae 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -135,6 +135,55 @@ function normalizeErrorMessage(error: unknown): string { return "Unknown daemon error"; } +function normalizeSessionError(error: unknown): string { + if (!error) { + return "OpenCode session error"; + } + + if (typeof error === "string") { + return error; + } + + if (typeof error !== "object") { + return String(error); + } + + const record = error as { + name?: unknown; + data?: unknown; + message?: unknown; + }; + const name = typeof record.name === "string" ? record.name : "OpenCodeError"; + const data = record.data; + + if (typeof data === "object" && data !== null) { + const dataRecord = data as { + message?: unknown; + providerID?: unknown; + modelID?: unknown; + }; + if (typeof dataRecord.message === "string") { + return `${name}: ${dataRecord.message}`; + } + if ( + typeof dataRecord.providerID === "string" && + typeof dataRecord.modelID === "string" + ) { + return `${name}: ${dataRecord.providerID}/${dataRecord.modelID}`; + } + } + + if (typeof record.message === "string") { + return `${name}: ${record.message}`; + } + + try { + return `${name}: ${JSON.stringify(data ?? error)}`; + } catch { + return name; + } +} + export class Daemon { private readonly registry: OpencodeRuntimeManager; /** Per-instance queue of job ids waiting to be scheduled. */ @@ -157,6 +206,9 @@ export class Daemon { private instanceCursor = 0; private readonly maxConcurrency: number; private readonly cancelWaitTimeoutMs: number; + private readonly sessionIdleWaiters = new Map void>(); + private readonly sessionErrors = new Map(); + private readonly pendingPermissions = new Map>(); constructor( private readonly store: StateStore, @@ -172,6 +224,22 @@ export class Daemon { event.properties.field, event.properties.delta, ); + } else if (event.type === "session.idle") { + this.resolveSessionIdle(event.properties.sessionID); + } else if ( + event.type === "session.status" && + event.properties.status.type === "idle" + ) { + this.resolveSessionIdle(event.properties.sessionID); + } else if (event.type === "session.error") { + const sessionId = event.properties.sessionID; + if (sessionId) { + this.sessionErrors.set( + sessionId, + normalizeSessionError(event.properties.error), + ); + this.resolveSessionIdle(sessionId); + } } }); this.maxConcurrency = @@ -381,6 +449,12 @@ export class Daemon { session: request.params.session, task: request.params.task, }); + if ( + request.params.session.type === "new" && + request.params.session.permission + ) { + this.pendingPermissions.set(job.id, request.params.session.permission); + } this.enqueueById(instanceId, job.id); this.scheduleDrain(); return { job }; @@ -524,6 +598,14 @@ export class Daemon { } } + private resolveSessionIdle(sessionId: string): void { + const resolve = this.sessionIdleWaiters.get(sessionId); + if (resolve) { + this.sessionIdleWaiters.delete(sessionId); + resolve(); + } + } + /** * Route an incoming delta from the OpenCode event stream to the matching * running job. Synchronously appends the delta to the job's `output_text` @@ -658,57 +740,120 @@ export class Daemon { >; const patch: { error?: string; outputText?: string; messageId?: string } = {}; + const log = (msg: string) => + process.stdout.write(`\n[job:${job.id.slice(0, 8)}] ${msg}\n`); try { + log("starting instance"); const instance = await this.startInstance(job.instanceId); + log("ensuring runtime"); const runtime = await this.registry.ensureStarted( instance.id, instance.directory, ); + log("resolving session"); const sessionId = await this.resolveSession( runtime.client, instance, job, ); this.runningSessionIds.set(job.id, sessionId); - - const response = await runtime.client.session.prompt({ - sessionID: sessionId, - directory: instance.directory, - agent: job.task.agent, - model: job.task.model - ? { - providerID: job.task.model.providerId, - modelID: job.task.model.modelId, + log(`session=${sessionId}`); + + switch (job.task.type) { + case "prompt": { + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + const NO_INFO_TIMEOUT_MS = 30 * 1000; + const idlePromise = new Promise((resolve) => { + this.sessionIdleWaiters.set(sessionId, resolve); + }); + log(`sending prompt: "${job.task.prompt.slice(0, 50)}"`); + const response = await runtime.client.session.prompt({ + sessionID: sessionId, + directory: instance.directory, + agent: job.task.agent, + model: job.task.model + ? { + providerID: job.task.model.providerId, + modelID: job.task.model.modelId, + } + : undefined, + system: job.task.system, + variant: job.task.variant, + parts: [{ type: "text", text: job.task.prompt }], + }); + patch.messageId = response.info?.id; + const finalText = extractText(response.parts ?? []); + const current = this.store.getJob(job.id); + if (!current?.outputText || current.outputText.length === 0) { + patch.outputText = finalText; + } + if (!patch.outputText || patch.outputText.length === 0) { + log("prompt sent, awaiting idle"); + try { + await Promise.race([ + idlePromise, + response.info + ? new Promise((_, reject) => { + setTimeout( + () => reject(new Error("session.idle timeout")), + IDLE_TIMEOUT_MS, + ); + }) + : new Promise((_, reject) => { + setTimeout( + () => + reject( + new Error("OpenCode returned no message data"), + ), + NO_INFO_TIMEOUT_MS, + ); + }), + ]); + log("idle received"); + } catch { + log("idle timeout — completing anyway"); + this.sessionIdleWaiters.delete(sessionId); } - : undefined, - system: job.task.system, - variant: job.task.variant, - parts: [{ type: "text", text: job.task.prompt }], - }); - patch.messageId = response.info.id; - // Prefer accumulated text from streamed deltas; fall back to the - // final parts payload if no deltas were received (non-streaming - // providers). Any deltas already landed in output_text via - // appendJobOutput, so only write the fallback when nothing was - // accumulated. - const current = this.store.getJob(job.id); - if (!current?.outputText || current.outputText.length === 0) { - patch.outputText = extractText(response.parts); - } - if (controller.signal.aborted) { - // prompt() returned successfully but the job was cancelled before - // the abort was observed — record the cancellation reason. - terminalState = "cancelled"; - patch.error = "Job cancelled"; - } else { - terminalState = "succeeded"; + } + const sessionError = this.sessionErrors.get(sessionId); + if (sessionError) { + terminalState = controller.signal.aborted + ? "cancelled" + : "failed"; + patch.error = controller.signal.aborted + ? "Job cancelled" + : sessionError; + } else if (!patch.outputText?.trim() && !current?.outputText?.trim()) { + terminalState = controller.signal.aborted + ? "cancelled" + : "failed"; + patch.error = controller.signal.aborted + ? "Job cancelled" + : "OpenCode returned no response. Check provider credentials and model availability."; + } else if (controller.signal.aborted) { + terminalState = "cancelled"; + patch.error = "Job cancelled"; + } else { + terminalState = "succeeded"; + } + log( + `job ${terminalState}, outputText length: ${patch.outputText?.length ?? current?.outputText?.length ?? 0}`, + ); + break; + } } } catch (error) { terminalState = controller.signal.aborted ? "cancelled" : "failed"; patch.error = controller.signal.aborted ? "Job cancelled" : normalizeErrorMessage(error); + log(`job error: ${patch.error}`); + } finally { + if (job.sessionId) { + this.sessionIdleWaiters.delete(job.sessionId); + this.sessionErrors.delete(job.sessionId); + } } const finalJob = this.store.markJobTerminal(job.id, terminalState, patch); @@ -725,9 +870,14 @@ export class Daemon { if (sessionRef.remoteSessionId) return sessionRef.remoteSessionId; const title = deriveSessionTitle(sessionRef, job); + const permission = this.pendingPermissions.get(job.id) ?? [ + { permission: "*", pattern: "*", action: "allow" }, + ]; + this.pendingPermissions.delete(job.id); const session = await client.session.create({ directory: instance.directory, title, + permission, }); this.store.assignRemoteSessionToJob(job.id, session.id, title); return session.id; From ccc0bea3a612da813cef990dc74d483cbb4fdb6c Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Sat, 25 Apr 2026 11:40:29 -0400 Subject: [PATCH 09/21] feat(tui): skill-based plan chat, daemon permissions, and execute view Refactor plan chat from open-ended conversation to bounded skills (/spec, /prd). Add per-session permission rulesets to daemon. Wire chat into execute tab. Fix daemon streaming with atomic submit-and-stream. Add plan instance management. Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 147 ++++++ CLAUDE.md | 3 + apps/tui/package.json | 3 +- apps/tui/src/components/app.tsx | 14 +- apps/tui/src/components/command-palette.tsx | 5 +- apps/tui/src/components/context-sidebar.tsx | 49 +- apps/tui/src/components/execute-view.tsx | 34 +- apps/tui/src/components/help-overlay.tsx | 9 +- apps/tui/src/components/plan-chat.tsx | 142 ++--- apps/tui/src/components/plan-view.tsx | 113 +++- apps/tui/src/components/welcome-screen.tsx | 87 ++-- apps/tui/src/hooks/use-chat.ts | 174 ++++--- apps/tui/src/hooks/use-execution.ts | 291 +++++++++++ apps/tui/src/hooks/use-plan-files.ts | 126 +++-- apps/tui/src/hooks/use-plan-instance.ts | 44 +- apps/tui/src/hooks/use-skill.ts | 28 + apps/tui/src/lib/scaffold.test.ts | 49 +- apps/tui/src/lib/scaffold.ts | 24 + apps/tui/src/skills.ts | 544 ++++---------------- bun.lock | 9 +- packages/daemon/src/client.ts | 69 +++ packages/daemon/src/opencode.ts | 90 ++-- packages/daemon/src/protocol.ts | 16 + packages/daemon/src/server.ts | 50 +- 24 files changed, 1316 insertions(+), 804 deletions(-) create mode 100644 AGENTS.md create mode 100644 apps/tui/src/hooks/use-execution.ts create mode 100644 apps/tui/src/hooks/use-skill.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..70175e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,147 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +Ralph is a coding agent orchestration TUI — a daemon (ralphd) manages OpenCode SDK instances and jobs, while a React-based terminal UI provides interactive monitoring. Built as a Bun monorepo with Turbo. + +## Commands + +```bash +bun install # Install dependencies +bun run build # Build all packages (turbo) +bun run dev # Start all dev servers +bun run dev:docs # Start docs site only +bun run test # Run all tests (bun test) +bun run check # Biome lint + format check +bun run check:types # TypeScript type checking +``` + +### Per-package commands + +```bash +cd apps/tui && bun run dev # Run TUI in dev mode +cd packages/daemon && bun test # Run daemon tests only +cd apps/docs && bun run dev # Run docs dev server +``` + +### Release + +```bash +bun run release:build # Compile binaries for all platforms +bun run release:stage # Stage distribution for publishing +bun run release:publish # Publish to npm +bun run release:dry-run # Test publish without uploading +``` + +## Architecture + +### Monorepo Layout + +- `apps/tui/` — Terminal UI app (@techatnyu/ralph), React 19 + @opentui/react +- `apps/docs/` — Documentation site, Fumadocs + TanStack Start + Vite +- `packages/daemon/` — Background daemon (@techatnyu/ralphd), socket-based IPC +- `packages/config/` — Shared TypeScript configuration +- `scripts/` — Release and build automation + +### Daemon-Client Architecture + +The daemon (ralphd) runs as a background process and communicates with TUI clients via a Unix domain socket (`ralphd.sock`). Key patterns: + +- **Protocol-driven**: All requests/responses defined with Zod schemas in `packages/daemon/src/protocol.ts`. Type-safe discriminated unions for all message types. +- **Job lifecycle**: queued → running → succeeded/failed/cancelled. Per-instance concurrency control (default: 4, configurable via `RALPHD_MAX_CONCURRENCY`). +- **Instance management**: `ManagedInstance` tracks OpenCode runtimes with lazy initialization. States: stopped → starting → running → error. +- **State persistence**: JSON file at `~/.ralph/state.json` (or `$RALPH_HOME/state.json`). + +### TUI + +React components rendered in the terminal via @opentui/react. Real-time job monitoring with keyboard navigation (j/k or arrows). CLI argument parsing via CrustJS. + +## Code Style + +- **Biome** for linting and formatting: tab indentation, double quotes, import organization +- **TypeScript strict mode**, ES2022 target, bundler module resolution +- Shared base tsconfig in `packages/config/tsconfig.base.json` +- TUI uses `@opentui/react` as JSX import source + +## Environment Variables + +- `RALPH_HOME` — Base directory (default: `~/.ralph`, dev: `./.ralph-dev`) +- `RALPHD_MAX_CONCURRENCY` — Max concurrent jobs per instance (default: 4) +- `RALPHD_BIN` — Override daemon binary path + +## Git Conventions +- Reasonably Commit after every fix. + + +# Memory Context + +# [ralph] recent context, 2026-04-24 1:27am EDT + +Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision 🚨security_alert 🔐security_note +Format: ID TIME TYPE TITLE +Fetch details: get_observations([IDs]) | Search: mem-search skill + +Stats: 50 obs (15,904t read) | 723,567t work | 98% savings + +### Apr 22, 2026 +S5 Fix OpenCode provider config to resolve empty responses in ralph's plan chat (Apr 22 at 6:11 PM) +S3 Phase-driven plan chat refactor complete — all lint and type checks pass (Apr 22 at 6:11 PM) +S6 Fix OpenCode Zod v3/v4 incompatibility crash by switching to OpenRouter with Claude Sonnet 4 (Apr 22 at 6:54 PM) +S32 Ralph daemon PID 72596 killed (Apr 22 at 6:55 PM) +### Apr 24, 2026 +70 1:06a ✅ Daemon SDK Pinned to @opencode-ai/sdk@1.14.22 — Version Mismatch Resolved +71 1:07a 🔴 OpencodeRegistry Provider Model Mapping Fixed for SDK v1.14.22 Capabilities Shape +72 " 🟣 Added normalizeSessionError() and Restored extractText() to Daemon Server +73 1:08a 🔴 Daemon SDK pinned to exact version 1.14.22 — resolving ProviderModelNotFoundError +74 " 🔄 Daemon job completion refactored to session.idle event-driven pattern +75 " 🟣 normalizeSessionError() added for structured OpenCode error handling in daemon +76 " 🔵 Zombie daemon process accumulation pattern identified in Ralph daemon lifecycle +77 1:09a 🔴 Daemon test suite fixed — 37/37 passing after resolveSessionIdle() method added +78 " ✅ Daemon fix changes uncommitted — 16 files modified across TUI and daemon packages +79 " ✅ Daemon package clean — biome formatted, types pass, 37/37 tests green, ready to commit +80 1:10a 🔵 Running daemon uses default RALPH_HOME (~/.ralph), not .ralph-dev — CLI commands need matching RALPH_HOME +81 " 🔵 Daemon PID 42833 survived SIGTERM but died on SIGKILL — opencode subprocess 42834 had already exited +82 " 🔴 Stale active jobs in .ralph-dev/state.json manually cancelled before daemon restart +83 " ✅ Daemon restarted with new code at PID 72596 using RALPH_HOME=.ralph-dev +84 1:11a 🔵 End-to-end live daemon job confirmed working — "RALPH_OK" response in ~3 seconds +85 " 🔵 OpenCode session.idle event flow traced in logs — confirms exact trigger sequence for daemon job completion +86 " ✅ Daemon fixes committed to feat/plan-view as "Fix daemon completion with current OpenCode SDK" +87 1:13a ✅ Ralph daemon PID 72596 killed +88 1:14a 🔴 Daemon OpencodeSessionClient prompt() return type made optional +89 " 🔴 Daemon server.ts null-safe access for prompt response fields +90 " 🔵 use-chat.ts streaming job event loop pattern +91 1:15a 🟣 use-chat.ts handles failed job state in done event +92 " 🔴 use-chat.ts updateLastMessage wrapped in useCallback to fix lint exhaustive-deps +93 " 🔵 Biome exhaustive-deps persists after useCallback wrap — dep array also needs updating +94 " 🔴 use-chat.ts send dep array updated to include updateLastMessage +95 " ✅ Daemon restarted after null-safety and use-chat fixes — clean state confirmed +96 1:16a ✅ Daemon restarted with updated null-safety code — PID 93736 +97 " 🔄 use-chat.ts fully refactored — polling replaced with streaming, ChatMode removed +98 " 🔵 Ralph TUI — 20 modified files and 6 new files uncommitted as of Apr 24 1:16am +100 1:17a ✅ Daemon null-safety fix committed — "Handle missing OpenCode prompt info" (30813fa) +101 1:18a 🔵 Ralph Daemon and OpenCode Processes Still Running After Kill Attempt +S39 Ralph Daemon and OpenCode Processes Still Running After Kill Attempt (Apr 24 at 1:18 AM) +102 1:20a 🔵 ProviderModelNotFoundError resurfaces in share-next subscriber — not blocking main execution +103 1:21a 🔵 Job stuck in running state after daemon restart mid-execution — idle event lost +104 " 🔵 SPEC system prompt confirmed wired into TUI job submission +105 1:22a 🔴 Daemon server.ts: fast-fail when OpenCode returns no message data and fail empty-output jobs +106 " 🔵 opencode/minimax-m2.5-free confirmed working as alternative free model +107 " ✅ Daemon test suite passes 37/37 after server.ts fast-fail and empty-output-fail fixes +108 " 🔴 Default model switched to opencode/minimax-m2.5-free to avoid ProviderModelNotFoundError +109 1:23a ✅ End-to-end smoke test passed with opencode/minimax-m2.5-free — DEV_OK confirmed +110 " ✅ Committed "Fail empty OpenCode prompt responses" — 80d50b7 on feat/plan-view +111 1:24a 🔵 TUI CLI missing "daemon status" and "model current" subcommands +112 1:25a 🔵 state.json confirms original "response.info.id" TypeError bug before null-safety fix +113 " 🔵 plan-chat.tsx architecture — file picker, slash commands, phase-aware system prompt +114 " 🔵 chat.tsx has independent daemon streaming — separate from use-chat.ts hook +115 " 🔵 Ralph TUI CLI full command surface — daemon and model subcommands mapped +116 1:26a 🔵 Daemon instance stops on restart — requires manual "daemon instance start" to resume +117 " 🔵 CLI "daemon submit" reads model from ralphStore via parseModelRef() — SMOKE_OK confirmed +118 1:27a 🔵 TUI development server confirmed running alongside daemon — full dev stack active +119 " 🔵 TUI dev process uses relative RALPH_HOME — OPENROUTER/OPENAI keys not inherited +120 " 🔵 Daemon client protocol architecture — Unix socket, newline-delimited JSON, Zod v4 schemas + +Access 724k tokens of past work via get_observations([IDs]) or mem-search skill. + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 414eab7..bb34ad0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,3 +70,6 @@ React components rendered in the terminal via @opentui/react. Real-time job moni - `RALPH_HOME` — Base directory (default: `~/.ralph`, dev: `./.ralph-dev`) - `RALPHD_MAX_CONCURRENCY` — Max concurrent jobs per instance (default: 4) - `RALPHD_BIN` — Override daemon binary path + +## Git Conventions +- Reasonably Commit after every fix. \ No newline at end of file diff --git a/apps/tui/package.json b/apps/tui/package.json index 7fca44c..d2fb199 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -27,6 +27,7 @@ "@techatnyu/ralphd": "workspace:*", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", - "react": "^19.2.4" + "react": "^19.2.4", + "zod": "^4.3.6" } } diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index e283234..14ed19f 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -3,6 +3,7 @@ import { useKeyboard } from "@opentui/react"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; import { usePlanFiles } from "../hooks/use-plan-files"; +import { usePlanInstance } from "../hooks/use-plan-instance"; import { Chat } from "./chat"; import { ExecuteView } from "./execute-view"; import { HelpOverlay } from "./help-overlay"; @@ -33,7 +34,8 @@ export function App({ onQuit }: AppProps) { const [daemonOnline, setDaemonOnline] = useState(true); const [showHelp, setShowHelp] = useState(false); const [activeChat, setActiveChat] = useState(null); - const planFiles = usePlanFiles(); + const planInstance = usePlanInstance(); + const planFiles = usePlanFiles(planInstance.scaffoldPath); const checkDaemon = useCallback(async () => { try { @@ -127,21 +129,20 @@ export function App({ onQuit }: AppProps) { diff --git a/apps/tui/src/components/command-palette.tsx b/apps/tui/src/components/command-palette.tsx index 67bf171..1692d4f 100644 --- a/apps/tui/src/components/command-palette.tsx +++ b/apps/tui/src/components/command-palette.tsx @@ -6,9 +6,8 @@ export interface SlashCommand { } export const SLASH_COMMANDS: SlashCommand[] = [ - { name: "/spec", description: "Generate a project spec" }, - { name: "/prd", description: "Break spec into tasks" }, - { name: "/prompt", description: "Generate execution prompt" }, + { name: "/spec", description: "Write project spec (.ralph/SPEC.md)" }, + { name: "/prd", description: "Create task breakdown (.ralph/prd.json)" }, { name: "/tasks", description: "Toggle task overlay" }, { name: "/clear", description: "Clear chat messages" }, ]; diff --git a/apps/tui/src/components/context-sidebar.tsx b/apps/tui/src/components/context-sidebar.tsx index 35317e2..0bd0ff5 100644 --- a/apps/tui/src/components/context-sidebar.tsx +++ b/apps/tui/src/components/context-sidebar.tsx @@ -1,14 +1,17 @@ import { TextAttributes } from "@opentui/core"; import type { PlanFilesData } from "../hooks/use-plan-files"; +import type { ActiveSkill } from "../skills"; interface ContextSidebarProps { planData: PlanFilesData; messageCount: number; + activeSkill: ActiveSkill; } export function ContextSidebar({ planData, messageCount, + activeSkill, }: ContextSidebarProps) { const doneCount = planData.tasks.filter((t) => t.passed).length; @@ -21,18 +24,46 @@ export function ContextSidebar({ flexDirection="column" flexShrink={0} > - Plan Status - - {planData.hasSpec ? "✓ SPEC.md" : "○ No spec"} + Skill + + {activeSkill ? activeSkill.toUpperCase() : "None"} - - {planData.hasPrd ? "✓ prd.json" : "○ No PRD"} - - - {planData.hasPrompt ? "✓ PROMPT.md" : "○ No prompt"} + + + Artifacts + {planData.specError ? ( + <> + ✗ SPEC.md + + {` ${planData.specError}`} + + + ) : ( + + {`${planData.hasSpec ? "✓" : "○"} SPEC.md`} + + )} + {planData.prdError ? ( + <> + ✗ prd.json + + {` ${planData.prdError}`} + + + ) : ( + + {`${planData.hasPrd ? "✓" : "○"} prd.json`} + + )} + {planData.tasks.length > 0 && ( - {`${doneCount}/${planData.tasks.length} tasks`} + <> + + Tasks + + {`${doneCount}/${planData.tasks.length} done`} + )} diff --git a/apps/tui/src/components/execute-view.tsx b/apps/tui/src/components/execute-view.tsx index f3a3eb8..05ece37 100644 --- a/apps/tui/src/components/execute-view.tsx +++ b/apps/tui/src/components/execute-view.tsx @@ -1,5 +1,3 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; import type { @@ -107,18 +105,28 @@ export function ExecuteView({ }, [refresh]); const handleStart = useCallback(async () => { - if (starting) return; + if (starting || !planData.hasPrd || planData.tasks.length === 0) return; setStarting(true); setStartMessage(undefined); setError(undefined); try { - const cwd = process.cwd(); - const promptPath = join(cwd, ".ralph", "PROMPT.md"); - const promptContent = (await readFile(promptPath, "utf-8")).trim(); - if (!promptContent) { - throw new Error("PROMPT.md is empty"); + const pendingTasks = planData.tasks.filter((t) => !t.passed); + if (pendingTasks.length === 0) { + throw new Error("All tasks are already completed"); + } + const task = pendingTasks[0] as (typeof pendingTasks)[number]; + const lines = [ + task.description, + "", + "Subtasks:", + ...task.subtasks.map((s) => `- ${s}`), + ]; + if (task.notes) { + lines.push("", `Notes: ${task.notes}`); } + const prompt = lines.join("\n"); + const cwd = process.cwd(); const { instances } = await daemon.listInstances(); let instance = instances.find((i) => i.directory === cwd); if (!instance) { @@ -134,7 +142,7 @@ export function ExecuteView({ session: { type: "new" }, task: { type: "prompt", - prompt: promptContent, + prompt, }, }); @@ -149,7 +157,7 @@ export function ExecuteView({ } finally { setStarting(false); } - }, [refresh, starting]); + }, [refresh, starting, planData.hasPrd, planData.tasks]); useKeyboard((key) => { if (!focused) return; @@ -159,7 +167,7 @@ export function ExecuteView({ return; } - if (key.name === "s" && planData.hasPrompt && !starting) { + if (key.name === "s" && planData.hasPrd && !starting) { void handleStart(); return; } @@ -188,7 +196,7 @@ export function ExecuteView({ }); const selected = data?.instances[selectedIndex]; - const planReady = planData.hasPrompt; + const planReady = planData.hasPrd && planData.tasks.length > 0; return ( @@ -219,7 +227,7 @@ export function ExecuteView({ ) : ( - Complete spec, prd, and prompt in Plan view to enable execution + Complete spec and prd in Plan view to enable execution )} diff --git a/apps/tui/src/components/help-overlay.tsx b/apps/tui/src/components/help-overlay.tsx index 0003b46..c0bcac0 100644 --- a/apps/tui/src/components/help-overlay.tsx +++ b/apps/tui/src/components/help-overlay.tsx @@ -61,10 +61,11 @@ export function HelpOverlay({ onClose }: HelpOverlayProps) { return ( Promise; + onSendPrompt: (prompt: string) => Promise; onToggleTasks: () => void; onClear: () => void; - onSetMode: (mode: ChatMode) => void; - mode: ChatMode; + onStartSkill: (id: "spec" | "prd") => void; + skill: Skill | undefined; } -const MODE_LABELS: Record = { - "create-spec": "Spec", - "create-prd": "PRD", - "create-prompt": "Prompt", -}; - function extractFileQuery(input: string): string | null { const lastAt = input.lastIndexOf("@"); if (lastAt === -1) return null; @@ -51,13 +44,12 @@ export function PlanChat({ messages, loading, error, - planData, daemonOnline, - onSend, + onSendPrompt, onToggleTasks, onClear, - onSetMode, - mode, + onStartSkill, + skill, }: PlanChatProps) { const [inputValue, setInputValue] = useState(""); const [fileRefs, setFileRefs] = useState([]); @@ -73,18 +65,6 @@ export function PlanChat({ const visibleFiles = fileResults.slice(0, FILE_PICKER_VISIBLE_COUNT); const filteredCommands = filterCommands(commandQuery); - const COMMAND_MODES: Record = { - "/spec": { - mode: "create-spec", - fallback: "Create a spec for this project", - }, - "/prd": { mode: "create-prd", fallback: "Break the spec into tasks" }, - "/prompt": { - mode: "create-prompt", - fallback: "Generate the execution prompt", - }, - }; - useKeyboard((key) => { if (!focused) return; if (key.name === "t" && key.ctrl) { @@ -98,6 +78,14 @@ export function PlanChat({ setPickerIndex(0); } } + if (key.name === "tab" && showFilePicker) { + const idx = Math.min(pickerIndex, visibleFiles.length - 1); + const file = visibleFiles[idx]; + if (file) { + handleFileSelect(file); + setPickerIndex(0); + } + } if (showCommandPalette || showFilePicker) { const maxIndex = showCommandPalette ? filteredCommands.length - 1 @@ -133,7 +121,12 @@ export function PlanChat({ return `${text}${fileContents}`; }; - const executeCommand = (cmdName: string, rest: string) => { + const executeCommand = (cmdName: string) => { + if (cmdName === "/spec" || cmdName === "/prd") { + onStartSkill(cmdName.slice(1) as "spec" | "prd"); + setInputValue(""); + return; + } if (cmdName === "/tasks") { onToggleTasks(); setInputValue(""); @@ -144,21 +137,13 @@ export function PlanChat({ setInputValue(""); return; } - const config = COMMAND_MODES[cmdName]; - if (config) { - onSetMode(config.mode); - const prompt = buildPrompt(rest || config.fallback); - setInputValue(""); - setFileRefs([]); - void onSend(prompt, config.mode); - } }; const handleSubmit = (value: string) => { if (showCommandPalette && !showFilePicker) { const idx = Math.min(pickerIndex, filteredCommands.length - 1); const cmd = filteredCommands[idx]; - if (cmd) executeCommand(cmd.name, ""); + if (cmd) executeCommand(cmd.name); return; } @@ -175,44 +160,65 @@ export function PlanChat({ if (trimmed.startsWith("/")) { const spaceIdx = trimmed.indexOf(" "); const cmdName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); - const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); - executeCommand(cmdName, rest); + executeCommand(cmdName); return; } + if (!skill) return; + const prompt = buildPrompt(trimmed); setInputValue(""); setFileRefs([]); - void onSend(prompt, mode); + void onSendPrompt(prompt); }; + const placeholder = !skill + ? "Type /spec or /prd to start..." + : loading + ? "Waiting for response..." + : skill.inputPlaceholder; + return ( {messages.length === 0 && !loading ? ( - + ) : ( - messages.map((msg: ChatMessage, index: number) => ( - - + msg.role === "system" ? ( + - {msg.role === "user" ? "You" : "Assistant"} - - {msg.role === "assistant" ? ( - - ) : ( - {msg.content} - )} - - )) + + {`— ${msg.content} —`} + + + ) : ( + + + {msg.role === "user" ? "You" : "Assistant"} + + {msg.role === "assistant" ? ( + + ) : ( + {msg.content} + )} + + ), + ) )} {loading && ( @@ -266,18 +272,18 @@ export function PlanChat({ - - {` ${MODE_LABELS[mode]} `} - + {skill && ( + + {` ${skill.name} `} + + )} diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx index 3c720f0..40785ca 100644 --- a/apps/tui/src/components/plan-view.tsx +++ b/apps/tui/src/components/plan-view.tsx @@ -1,9 +1,10 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react"; -import { useState } from "react"; -import type { ChatMode } from "../hooks/use-chat"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useChat } from "../hooks/use-chat"; import type { PlanFilesData } from "../hooks/use-plan-files"; -import { usePlanInstance } from "../hooks/use-plan-instance"; +import type { usePlanInstance } from "../hooks/use-plan-instance"; +import { useSkill } from "../hooks/use-skill"; +import type { SkillContext } from "../skills"; import { ContextSidebar } from "./context-sidebar"; import { PlanChat } from "./plan-chat"; import { TaskOverlay } from "./task-overlay"; @@ -14,16 +15,106 @@ interface PlanViewProps { focused: boolean; planData: PlanFilesData; daemonOnline: boolean; + planInstance: ReturnType; } -export function PlanView({ focused, planData, daemonOnline }: PlanViewProps) { +export function PlanView({ + focused, + planData, + daemonOnline, + planInstance, +}: PlanViewProps) { const [showTasks, setShowTasks] = useState(false); - const [mode, setMode] = useState("create-spec"); - const planInstance = usePlanInstance(); - const chat = useChat(planInstance.ensure); + const { activeSkill, skill, startSkill } = useSkill(); + const { ensure: ensureInstance } = planInstance; + const ensureInstanceId = useCallback( + () => ensureInstance().then((h) => h.instanceId), + [ensureInstance], + ); + const chat = useChat(ensureInstanceId); const { width } = useTerminalDimensions(); const showSidebar = width >= SIDEBAR_MIN_WIDTH; + const { hasSpec, hasPrd, tasks, specError, prdError } = planData; + const prevHasSpec = useRef(hasSpec); + const prevHasPrd = useRef(hasPrd); + const prevLoading = useRef(chat.loading); + const { addSystemMessage, loading: chatLoading } = chat; + + useEffect(() => { + if (!prevHasSpec.current && hasSpec && activeSkill) { + addSystemMessage( + "wrote SPEC.md — type /prd to generate the task breakdown", + ); + } + prevHasSpec.current = hasSpec; + }, [hasSpec, activeSkill, addSystemMessage]); + + useEffect(() => { + if (!prevHasPrd.current && hasPrd && activeSkill) { + const n = tasks.length; + addSystemMessage( + `wrote prd.json (${n} task${n === 1 ? "" : "s"}) — press Ctrl+T to review, then switch to Execute`, + ); + } + prevHasPrd.current = hasPrd; + }, [hasPrd, tasks.length, activeSkill, addSystemMessage]); + + useEffect(() => { + const wasLoading = prevLoading.current; + prevLoading.current = chatLoading; + if (!wasLoading || chatLoading || !activeSkill) return; + + const target = activeSkill; + const timeoutId = setTimeout(() => { + const produced = target === "spec" ? hasSpec : hasPrd; + if (produced) return; + const filename = target === "spec" ? "SPEC.md" : "prd.json"; + const err = target === "spec" ? specError : prdError; + const reason = err + ? `written but invalid (${err}) — review and retry` + : "not written — check model permissions or retry"; + addSystemMessage(`${filename}: ${reason}`); + }, 3000); + return () => clearTimeout(timeoutId); + }, [ + chatLoading, + activeSkill, + hasSpec, + hasPrd, + specError, + prdError, + addSystemMessage, + ]); + + const { send: chatSend, clear: chatClear } = chat; + const sendWithSkill = useCallback( + async (prompt: string) => { + if (!skill) return; + const { scaffoldPath } = await ensureInstance(); + const ctx: SkillContext = { scaffoldPath }; + await chatSend({ + prompt, + systemPrompt: skill.buildSystemPrompt(ctx), + permission: skill.buildPermission(ctx), + }); + }, + [skill, ensureInstance, chatSend], + ); + + const handleStartSkill = async (id: "spec" | "prd") => { + const s = startSkill(id); + chatClear(); + if (!s.buildAutoPrompt) return; + const { scaffoldPath } = await ensureInstance(); + const ctx: SkillContext = { scaffoldPath }; + await chatSend({ + prompt: s.buildAutoPrompt(ctx), + systemPrompt: s.buildSystemPrompt(ctx), + permission: s.buildPermission(ctx), + }); + }; + useKeyboard((key) => { if (!focused) return; if (key.name === "t" && key.ctrl) { @@ -43,19 +134,19 @@ export function PlanView({ focused, planData, daemonOnline }: PlanViewProps) { messages={chat.messages} loading={chat.loading} error={chat.error ?? planInstance.error} - planData={planData} daemonOnline={daemonOnline} - onSend={chat.send} + onSendPrompt={sendWithSkill} onToggleTasks={toggleTasks} onClear={chat.clear} - onSetMode={setMode} - mode={mode} + onStartSkill={handleStartSkill} + skill={skill} /> {showSidebar && ( )} diff --git a/apps/tui/src/components/welcome-screen.tsx b/apps/tui/src/components/welcome-screen.tsx index 3b37682..494db38 100644 --- a/apps/tui/src/components/welcome-screen.tsx +++ b/apps/tui/src/components/welcome-screen.tsx @@ -1,12 +1,12 @@ import { TextAttributes } from "@opentui/core"; -import type { PlanFilesData } from "../hooks/use-plan-files"; +import type { Skill } from "../skills"; interface WelcomeScreenProps { - planData: PlanFilesData; + skill: Skill | undefined; } -export function WelcomeScreen({ planData }: WelcomeScreenProps) { - if (planData.hasSpec && planData.hasPrd && planData.hasPrompt) { +export function WelcomeScreen({ skill }: WelcomeScreenProps) { + if (skill) { return ( - Plan complete! + {skill.name} - Switch to the Execute tab to start building. - - - ); - } - - if (planData.hasSpec && planData.hasPrd) { - return ( - - Tasks ready - - Try /prompt to generate the execution prompt. - - - ); - } - - if (planData.hasSpec) { - return ( - - Spec ready - - Your spec is ready. Try /prd to break it into tasks. + {skill.inputPlaceholder} ); @@ -66,46 +34,57 @@ export function WelcomeScreen({ planData }: WelcomeScreenProps) { AI-powered project planning - - Describe your project to get started, or use a command: - - - + + Skills: /spec - {" Generate a project spec"} + {" Write project spec (.ralph/SPEC.md)"} /prd - {" Break spec into tasks"} + {" Create task breakdown (.ralph/prd.json)"} + + + + Commands: + + /tasks + {" Toggle task list"} + - /prompt + /clear - {" Generate execution prompt"} + {" Clear chat messages"} - + Shortcuts: Ctrl+T - {" Toggle task list"} + + {" Toggle task list"} + + + + Ctrl+N / P + + {" Next / previous suggestion"} + @file - {" Reference a file"} + + {" Reference a file"} + - - - Try: "Build me a todo app with auth and real-time sync" - ); } diff --git a/apps/tui/src/hooks/use-chat.ts b/apps/tui/src/hooks/use-chat.ts index 1590409..e87b044 100644 --- a/apps/tui/src/hooks/use-chat.ts +++ b/apps/tui/src/hooks/use-chat.ts @@ -1,143 +1,147 @@ -import { daemon } from "@techatnyu/ralphd"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - CREATE_PRD_SYSTEM_PROMPT, - CREATE_PROMPT_SYSTEM_PROMPT, - CREATE_SPEC_SYSTEM_PROMPT, -} from "../skills"; - -export type ChatMode = "create-spec" | "create-prd" | "create-prompt"; +import { daemon, type PermissionRule } from "@techatnyu/ralphd"; +import { useCallback, useRef, useState } from "react"; +import { createPromptTask } from "../lib/prompt-task"; +import { ralphStore } from "../lib/store"; export interface ChatMessage { - role: "user" | "assistant"; + role: "user" | "assistant" | "system"; content: string; } +export interface SendOptions { + prompt: string; + systemPrompt: string; + permission?: PermissionRule[]; +} + interface UseChatReturn { messages: ChatMessage[]; loading: boolean; error: string | undefined; - send: (prompt: string, mode: ChatMode) => Promise; + send: (options: SendOptions) => Promise; + addSystemMessage: (content: string) => void; + resetSession: () => void; clear: () => void; } -const SKILL_PROMPTS: Record = { - "create-spec": CREATE_SPEC_SYSTEM_PROMPT, - "create-prd": CREATE_PRD_SYSTEM_PROMPT, - "create-prompt": CREATE_PROMPT_SYSTEM_PROMPT, -}; - -const POLL_INTERVAL_MS = 1000; -const POLL_TIMEOUT_MS = 2 * 60 * 1000; - export function useChat(ensureInstance: () => Promise): UseChatReturn { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const sessionIdRef = useRef(null); - const pollingRef = useRef | null>(null); + const cancelledRef = useRef(false); + + const addSystemMessage = useCallback((content: string) => { + setMessages((prev) => [...prev, { role: "system", content }]); + }, []); - const stopPolling = useCallback(() => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } + const resetSession = useCallback(() => { + sessionIdRef.current = null; }, []); - useEffect(() => { - return () => stopPolling(); - }, [stopPolling]); + const updateLastMessage = useCallback( + (updater: (msg: ChatMessage) => ChatMessage) => { + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last) next[next.length - 1] = updater(last); + return next; + }); + }, + [], + ); const send = useCallback( - async (prompt: string, mode: ChatMode) => { + async ({ prompt, systemPrompt, permission }: SendOptions) => { if (loading) return; setMessages((prev) => [...prev, { role: "user", content: prompt }]); setLoading(true); setError(undefined); + cancelledRef.current = false; try { const instanceId = await ensureInstance(); const session = sessionIdRef.current ? { type: "existing" as const, sessionId: sessionIdRef.current } - : { type: "new" as const, title: `Plan: ${mode}` }; + : { type: "new" as const, title: "Plan", permission }; - const { job } = await daemon.submitJob({ + const { model: storedModel } = await ralphStore.read(); + const { events } = await daemon.submitAndStreamJob({ instanceId, session, task: { - type: "prompt", - prompt, - system: SKILL_PROMPTS[mode], + ...createPromptTask({ prompt, storedModel }), + system: systemPrompt, }, }); - const pollStartedAt = Date.now(); - - const pollOnce = async (): Promise => { - if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) { - stopPolling(); - setError("Request timed out"); - setLoading(false); - return true; - } - - try { - const { job: updated } = await daemon.getJob(job.id); - - if (updated.state === "succeeded") { - stopPolling(); - if (updated.sessionId) { - sessionIdRef.current = updated.sessionId; - } - setMessages((prev) => [ - ...prev, - { - role: "assistant", - content: updated.outputText ?? "(no response)", - }, - ]); - setLoading(false); - return true; + setMessages((prev) => [...prev, { role: "assistant", content: "" }]); + let content = ""; + + for await (const event of events) { + if (cancelledRef.current) break; + + if (event.type === "snapshot") { + content = event.text; + updateLastMessage(() => ({ role: "assistant", content })); + } else if (event.type === "delta" && event.field === "text") { + content += event.delta; + updateLastMessage(() => ({ role: "assistant", content })); + } else if (event.type === "done") { + if (event.job.sessionId) { + sessionIdRef.current = event.job.sessionId; + } + if (event.job.state === "failed") { + const message = event.job.error || "Job failed"; + setError(message); + updateLastMessage(() => ({ + role: "system", + content: `Error: ${message}`, + })); + break; } - if (updated.state === "failed" || updated.state === "cancelled") { - stopPolling(); - setError(updated.error ?? "Job failed"); - setLoading(false); - return true; + if (!content.trim()) { + const final = event.job.outputText?.trim() || "(empty response)"; + updateLastMessage(() => ({ + role: "assistant", + content: final, + })); } - return false; - } catch (pollError) { - stopPolling(); - setError( - pollError instanceof Error ? pollError.message : "Polling failed", - ); - setLoading(false); - return true; + break; + } else if (event.type === "error") { + setError(event.error); + updateLastMessage(() => ({ + role: "system", + content: `Error: ${event.error}`, + })); + break; } - }; - - const done = await pollOnce(); - if (!done) { - pollingRef.current = setInterval( - () => void pollOnce(), - POLL_INTERVAL_MS, - ); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to submit message"); + } finally { setLoading(false); } }, - [loading, ensureInstance, stopPolling], + [loading, ensureInstance, updateLastMessage], ); const clear = useCallback(() => { + cancelledRef.current = true; setMessages([]); sessionIdRef.current = null; setError(undefined); }, []); - return { messages, loading, error, send, clear }; + return { + messages, + loading, + error, + send, + addSystemMessage, + resetSession, + clear, + }; } diff --git a/apps/tui/src/hooks/use-execution.ts b/apps/tui/src/hooks/use-execution.ts new file mode 100644 index 0000000..69ae103 --- /dev/null +++ b/apps/tui/src/hooks/use-execution.ts @@ -0,0 +1,291 @@ +import { daemon } from "@techatnyu/ralphd"; +import { useCallback, useRef, useState } from "react"; +import { Worktree, type WorktreeInfo } from "../lib/worktree"; +import type { PrdTask } from "./use-plan-files"; + +export type TaskExecutionState = + | "pending" + | "creating" + | "running" + | "succeeded" + | "failed" + | "cancelled"; + +export interface TaskWorktree { + taskIndex: number; + task: PrdTask; + worktreeName: string; + worktreeInfo?: WorktreeInfo; + instanceId?: string; + jobId?: string; + state: TaskExecutionState; + error?: string; +} + +export interface UseExecutionReturn { + taskWorktrees: TaskWorktree[]; + executing: boolean; + startAll: (tasks: PrdTask[]) => Promise; + cancelTask: (taskIndex: number) => Promise; + cancelAll: () => Promise; + cleanup: () => Promise; + refresh: () => Promise; +} + +function buildTaskPrompt(task: PrdTask): string { + const lines = [ + task.description, + "", + "Subtasks:", + ...task.subtasks.map((s) => `- ${s}`), + ]; + if (task.notes) { + lines.push("", `Notes: ${task.notes}`); + } + return lines.join("\n"); +} + +export function useExecution(): UseExecutionReturn { + const [taskWorktrees, setTaskWorktrees] = useState([]); + const [executing, setExecuting] = useState(false); + const worktreeRef = useRef(new Worktree()); + const taskWorktreesRef = useRef(taskWorktrees); + taskWorktreesRef.current = taskWorktrees; + + const updateTask = useCallback( + (taskIndex: number, updates: Partial) => { + setTaskWorktrees((prev) => + prev.map((tw) => + tw.taskIndex === taskIndex ? { ...tw, ...updates } : tw, + ), + ); + }, + [], + ); + + const dispatchSingleTask = useCallback( + async (tw: TaskWorktree): Promise => { + const wt = worktreeRef.current; + + try { + updateTask(tw.taskIndex, { state: "creating" }); + + // Check if worktree already exists + const existing = await wt.list(); + let worktreeInfo = existing.find((w) => w.name === tw.worktreeName); + if (!worktreeInfo) { + worktreeInfo = await wt.create(tw.worktreeName); + } + + // Check if instance already exists at this directory + const { instances } = await daemon.listInstances(); + let instanceId: string | undefined; + const existingInstance = instances.find( + (i) => i.directory === worktreeInfo.path, + ); + + if (existingInstance) { + instanceId = existingInstance.id; + } else { + const created = await daemon.createInstance({ + name: tw.worktreeName, + directory: worktreeInfo.path, + maxConcurrency: 1, + }); + instanceId = created.instance.id; + } + + // Start instance if stopped + const instanceResult = await daemon.getInstance(instanceId); + if (instanceResult.instance.status === "stopped") { + await daemon.startInstance(instanceId); + } + + // Submit job + const prompt = buildTaskPrompt(tw.task); + const { job } = await daemon.submitJob({ + instanceId, + session: { type: "new" }, + task: { type: "prompt", prompt }, + }); + + updateTask(tw.taskIndex, { + worktreeInfo, + instanceId, + jobId: job.id, + state: "running", + }); + } catch (err) { + updateTask(tw.taskIndex, { + state: "failed", + error: err instanceof Error ? err.message : "Failed to dispatch task", + }); + } + }, + [updateTask], + ); + + const startAll = useCallback( + async (tasks: PrdTask[]) => { + if (executing) return; + setExecuting(true); + + try { + // Build entries for pending tasks not already tracked + const existingIndices = new Set( + taskWorktreesRef.current.map((tw) => tw.taskIndex), + ); + const newEntries: TaskWorktree[] = []; + + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i] as PrdTask; + if (task.passed) continue; + if (existingIndices.has(i)) continue; + newEntries.push({ + taskIndex: i, + task, + worktreeName: `task-${i}`, + state: "pending", + }); + } + + // Reset previously failed tasks + setTaskWorktrees((prev) => { + const reset = prev.map((tw) => + tw.state === "failed" + ? { ...tw, state: "pending" as const, error: undefined } + : tw, + ); + return [...reset, ...newEntries]; + }); + + // Collect all tasks to dispatch + const toDispatch = [ + ...taskWorktreesRef.current.filter( + (tw) => tw.state === "failed" || tw.state === "pending", + ), + ...newEntries, + ]; + + // Dispatch all in parallel + await Promise.allSettled( + toDispatch.map((tw) => dispatchSingleTask(tw)), + ); + } finally { + setExecuting(false); + } + }, + [executing, dispatchSingleTask], + ); + + const refresh = useCallback(async () => { + const current = taskWorktreesRef.current.filter( + (tw) => tw.jobId && (tw.state === "running" || tw.state === "creating"), + ); + if (current.length === 0) return; + + const results = await Promise.allSettled( + current.map((tw) => daemon.getJob(tw.jobId as string)), + ); + + setTaskWorktrees((prev) => + prev.map((tw) => { + if (!tw.jobId || (tw.state !== "running" && tw.state !== "creating")) { + return tw; + } + const idx = current.findIndex((c) => c.taskIndex === tw.taskIndex); + if (idx === -1) return tw; + const result = results[idx]; + if (!result || result.status === "rejected") return tw; + + const job = result.value.job; + if (job.state === "succeeded") { + return { ...tw, state: "succeeded" as const }; + } + if (job.state === "failed") { + return { + ...tw, + state: "failed" as const, + error: job.error ?? "Job failed", + }; + } + if (job.state === "cancelled") { + return { ...tw, state: "cancelled" as const }; + } + return tw; + }), + ); + }, []); + + const cancelTask = useCallback( + async (taskIndex: number) => { + const tw = taskWorktreesRef.current.find( + (t) => t.taskIndex === taskIndex, + ); + if (!tw?.jobId) return; + try { + await daemon.cancelJob(tw.jobId); + updateTask(taskIndex, { state: "cancelled" }); + } catch (e) { + updateTask(taskIndex, { + state: "failed", + error: e instanceof Error ? e.message : "Failed to cancel", + }); + } + }, + [updateTask], + ); + + const cancelAll = useCallback(async () => { + const running = taskWorktreesRef.current.filter( + (tw) => tw.jobId && (tw.state === "running" || tw.state === "creating"), + ); + await Promise.allSettled(running.map((tw) => cancelTask(tw.taskIndex))); + }, [cancelTask]); + + const cleanup = useCallback(async () => { + const wt = worktreeRef.current; + const terminal = taskWorktreesRef.current.filter( + (tw) => + tw.state === "succeeded" || + tw.state === "failed" || + tw.state === "cancelled", + ); + + await Promise.allSettled( + terminal.map(async (tw) => { + if (tw.instanceId) { + try { + await daemon.removeInstance(tw.instanceId); + } catch { + // instance may already be removed + } + } + try { + await wt.remove(tw.worktreeName, { force: true }); + } catch { + // worktree may already be removed + } + }), + ); + + setTaskWorktrees((prev) => + prev.filter( + (tw) => + tw.state !== "succeeded" && + tw.state !== "failed" && + tw.state !== "cancelled", + ), + ); + }, []); + + return { + taskWorktrees, + executing, + startAll, + cancelTask, + cancelAll, + cleanup, + refresh, + }; +} diff --git a/apps/tui/src/hooks/use-plan-files.ts b/apps/tui/src/hooks/use-plan-files.ts index 5f7f158..9f8f195 100644 --- a/apps/tui/src/hooks/use-plan-files.ts +++ b/apps/tui/src/hooks/use-plan-files.ts @@ -1,25 +1,28 @@ import { readFile, watch } from "node:fs"; -import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { useCallback, useEffect, useRef, useState } from "react"; +import { z } from "zod"; -export interface PrdTask { - description: string; - subtasks: string[]; - notes: string; - passed: boolean; -} +const PrdTaskSchema = z.object({ + description: z.string().min(1), + subtasks: z.array(z.string().min(1)).min(1), + notes: z.string().optional().default(""), + passed: z.boolean().optional().default(false), +}); -interface PrdData { - tasks: PrdTask[]; -} +const PrdFileSchema = z.object({ + tasks: z.array(PrdTaskSchema).min(1), +}); + +export type PrdTask = z.infer; export interface PlanFilesData { tasks: PrdTask[]; progress: string; hasSpec: boolean; hasPrd: boolean; - hasPrompt: boolean; + specError?: string; + prdError?: string; } interface UsePlanFilesReturn { @@ -29,12 +32,6 @@ interface UsePlanFilesReturn { refresh: () => void; } -const RALPH_DIR = join(process.cwd(), ".ralph"); -const PRD_PATH = join(RALPH_DIR, "prd.json"); -const PROGRESS_PATH = join(RALPH_DIR, "progress.md"); -const SPEC_PATH = join(RALPH_DIR, "SPEC.md"); -const PROMPT_PATH = join(RALPH_DIR, "PROMPT.md"); - function readFileAsync(path: string): Promise { return new Promise((resolve) => { readFile(path, "utf-8", (err, data) => { @@ -47,77 +44,118 @@ function readFileAsync(path: string): Promise { }); } -function parsePrd(content: string | null): PrdTask[] { - if (!content) return []; +interface PrdParseResult { + tasks: PrdTask[]; + error?: string; +} + +function parsePrd(content: string | null): PrdParseResult { + if (content === null) return { tasks: [] }; + let json: unknown; try { - const parsed = JSON.parse(content) as PrdData; - if (Array.isArray(parsed.tasks)) { - return parsed.tasks; - } + json = JSON.parse(content); } catch { - // invalid JSON + return { tasks: [], error: "invalid JSON" }; + } + const parsed = PrdFileSchema.safeParse(json); + if (!parsed.success) { + const first = parsed.error.issues[0]; + const path = first?.path.join(".") || "root"; + const message = first?.message ?? "validation failed"; + return { tasks: [], error: `${path}: ${message}` }; } - return []; + return { tasks: parsed.data.tasks }; } -export function usePlanFiles(): UsePlanFilesReturn { +interface SpecValidation { + valid: boolean; + error?: string; +} + +function validateSpec(content: string | null): SpecValidation { + if (content === null) return { valid: false }; + const trimmed = content.trim(); + if (trimmed.length < 100) { + return { valid: false, error: "too short (<100 chars)" }; + } + if (!/^#\s+\S/m.test(trimmed)) { + return { valid: false, error: "missing markdown heading" }; + } + return { valid: true }; +} + +export function usePlanFiles(scaffoldPath: string | null): UsePlanFilesReturn { const [data, setData] = useState({ tasks: [], progress: "", hasSpec: false, hasPrd: false, - hasPrompt: false, }); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(); const debounceRef = useRef | null>(null); const loadFiles = useCallback(async () => { + if (!scaffoldPath) { + setData({ + tasks: [], + progress: "", + hasSpec: false, + hasPrd: false, + }); + return; + } setLoading(true); setError(undefined); try { - await mkdir(RALPH_DIR, { recursive: true }); - const [prdContent, progressContent, specContent, promptContent] = - await Promise.all([ - readFileAsync(PRD_PATH), - readFileAsync(PROGRESS_PATH), - readFileAsync(SPEC_PATH), - readFileAsync(PROMPT_PATH), - ]); + const [prdContent, progressContent, specContent] = await Promise.all([ + readFileAsync(join(scaffoldPath, "prd.json")), + readFileAsync(join(scaffoldPath, "progress.md")), + readFileAsync(join(scaffoldPath, "SPEC.md")), + ]); + const prdResult = parsePrd(prdContent); + const specResult = validateSpec(specContent); setData({ - tasks: parsePrd(prdContent), + tasks: prdResult.tasks, progress: progressContent ?? "", - hasSpec: specContent !== null, - hasPrd: prdContent !== null, - hasPrompt: promptContent !== null, + hasSpec: specContent !== null && specResult.valid, + hasPrd: prdContent !== null && !prdResult.error, + specError: + specContent !== null && !specResult.valid + ? specResult.error + : undefined, + prdError: + prdContent !== null && prdResult.error ? prdResult.error : undefined, }); } catch (e) { setError(e instanceof Error ? e.message : "Failed to read plan files"); } finally { setLoading(false); } - }, []); + }, [scaffoldPath]); useEffect(() => { void loadFiles(); + if (!scaffoldPath) return; + let watcher: ReturnType | null = null; try { - watcher = watch(RALPH_DIR, { recursive: true }, () => { + watcher = watch(scaffoldPath, { recursive: true }, () => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { void loadFiles(); }, 500); }); } catch { - // .ralph/ directory may not exist yet + // scaffoldPath may not exist yet — load will create it implicitly } return () => { watcher?.close(); if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [loadFiles]); + }, [loadFiles, scaffoldPath]); return { data, loading, error, refresh: loadFiles }; } diff --git a/apps/tui/src/hooks/use-plan-instance.ts b/apps/tui/src/hooks/use-plan-instance.ts index dab60f8..766a59f 100644 --- a/apps/tui/src/hooks/use-plan-instance.ts +++ b/apps/tui/src/hooks/use-plan-instance.ts @@ -1,41 +1,49 @@ import { daemon } from "@techatnyu/ralphd"; import { useCallback, useRef, useState } from "react"; +import { bootstrapInstanceScaffold } from "../lib/scaffold"; + +export interface PlanInstanceHandle { + instanceId: string; + scaffoldPath: string; +} interface UsePlanInstanceReturn { instanceId: string | null; + scaffoldPath: string | null; loading: boolean; error: string | undefined; - ensure: () => Promise; + ensure: () => Promise; } export function usePlanInstance(): UsePlanInstanceReturn { const [instanceId, setInstanceId] = useState(null); + const [scaffoldPath, setScaffoldPath] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - const resolving = useRef | null>(null); + const resolving = useRef | null>(null); - const ensure = useCallback(async (): Promise => { - if (instanceId) return instanceId; + const ensure = useCallback(async (): Promise => { + if (instanceId && scaffoldPath) { + return { instanceId, scaffoldPath }; + } if (resolving.current) return resolving.current; - const resolve = async (): Promise => { + const resolve = async (): Promise => { setLoading(true); setError(undefined); try { const cwd = process.cwd(); const { instances } = await daemon.listInstances(); const existing = instances.find((i) => i.directory === cwd); - if (existing) { - setInstanceId(existing.id); - return existing.id; - } - - const { instance } = await daemon.createInstance({ - name: "plan", - directory: cwd, - }); - setInstanceId(instance.id); - return instance.id; + const id = existing + ? existing.id + : (await daemon.createInstance({ name: "plan", directory: cwd })) + .instance.id; + + const path = await bootstrapInstanceScaffold({ instanceId: id }); + setInstanceId(id); + setScaffoldPath(path); + return { instanceId: id, scaffoldPath: path }; } catch (e) { const msg = e instanceof Error ? e.message : "Failed to resolve instance"; @@ -49,7 +57,7 @@ export function usePlanInstance(): UsePlanInstanceReturn { resolving.current = resolve(); return resolving.current; - }, [instanceId]); + }, [instanceId, scaffoldPath]); - return { instanceId, loading, error, ensure }; + return { instanceId, scaffoldPath, loading, error, ensure }; } diff --git a/apps/tui/src/hooks/use-skill.ts b/apps/tui/src/hooks/use-skill.ts new file mode 100644 index 0000000..51ef612 --- /dev/null +++ b/apps/tui/src/hooks/use-skill.ts @@ -0,0 +1,28 @@ +import { useCallback, useState } from "react"; +import { type ActiveSkill, getSkill, type Skill } from "../skills"; + +interface UseSkillReturn { + activeSkill: ActiveSkill; + skill: Skill | undefined; + startSkill: (id: "spec" | "prd") => Skill; + clearSkill: () => void; +} + +export function useSkill(): UseSkillReturn { + const [activeSkill, setActiveSkill] = useState(null); + + const skill = activeSkill ? getSkill(activeSkill) : undefined; + + const startSkill = useCallback((id: "spec" | "prd"): Skill => { + const s = getSkill(id); + if (!s) throw new Error(`Unknown skill: ${id}`); + setActiveSkill(id); + return s; + }, []); + + const clearSkill = useCallback(() => { + setActiveSkill(null); + }, []); + + return { activeSkill, skill, startSkill, clearSkill }; +} diff --git a/apps/tui/src/lib/scaffold.test.ts b/apps/tui/src/lib/scaffold.test.ts index ca8cdb4..c588a89 100644 --- a/apps/tui/src/lib/scaffold.test.ts +++ b/apps/tui/src/lib/scaffold.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + bootstrapInstanceScaffold, bootstrapSessionScaffold, + resolveInstanceScaffoldPath, resolveSessionScaffoldPath, } from "./scaffold"; @@ -100,4 +102,49 @@ describe("scaffold", () => { expect(JSON.parse(prd)).toEqual({ tasks: [] }); expect(progress.trim()).toBe("# Progress Log"); }); + + it("resolves the instance scaffold path under sessions//plan", () => { + expect( + resolveInstanceScaffoldPath({ + instanceId: "instance-9", + ralphHome: "/tmp/ralph-home", + }), + ).toBe("/tmp/ralph-home/sessions/instance-9/plan"); + }); + + it("rejects invalid instance ids", () => { + expect(() => + resolveInstanceScaffoldPath({ + instanceId: "", + ralphHome: "/tmp/ralph-home", + }), + ).toThrow("instanceId is required"); + + expect(() => + resolveInstanceScaffoldPath({ + instanceId: "a/b", + ralphHome: "/tmp/ralph-home", + }), + ).toThrow("instanceId must not contain path separators"); + }); + + it("creates the instance scaffold directory idempotently", async () => { + const tempHome = await mkdtemp(join(tmpdir(), "ralph-instance-scaffold-")); + tempDirs.push(tempHome); + + const first = await bootstrapInstanceScaffold({ + instanceId: "instance-10", + ralphHome: tempHome, + }); + expect(first).toBe(join(tempHome, "sessions", "instance-10", "plan")); + + const info = await stat(first); + expect(info.isDirectory()).toBe(true); + + const second = await bootstrapInstanceScaffold({ + instanceId: "instance-10", + ralphHome: tempHome, + }); + expect(second).toBe(first); + }); }); diff --git a/apps/tui/src/lib/scaffold.ts b/apps/tui/src/lib/scaffold.ts index 9a4004f..5787f6a 100644 --- a/apps/tui/src/lib/scaffold.ts +++ b/apps/tui/src/lib/scaffold.ts @@ -53,3 +53,27 @@ export async function bootstrapSessionScaffold( return sessionPath; } + +export interface InstanceScaffoldOptions { + instanceId: string; + ralphHome?: string; +} + +export function resolveInstanceScaffoldPath( + options: InstanceScaffoldOptions, +): string { + assertValidSegment(options.instanceId, "instanceId"); + + const ralphHome = + options.ralphHome ?? resolveDaemonPaths(process.env).ralphHome; + + return join(ralphHome, "sessions", options.instanceId, "plan"); +} + +export async function bootstrapInstanceScaffold( + options: InstanceScaffoldOptions, +): Promise { + const scaffoldPath = resolveInstanceScaffoldPath(options); + await mkdir(scaffoldPath, { recursive: true }); + return scaffoldPath; +} diff --git a/apps/tui/src/skills.ts b/apps/tui/src/skills.ts index 1a29758..b30127b 100644 --- a/apps/tui/src/skills.ts +++ b/apps/tui/src/skills.ts @@ -1,481 +1,131 @@ -export const CREATE_SPEC_SYSTEM_PROMPT = `# SPEC Creation Helper +import type { PermissionRule } from "@techatnyu/ralphd"; -Create or refine \`SPEC.md\` for Ralph — an AI coding agent that reads \`SPEC.md\` at the start of every iteration to understand what it's building. - -## Core Principles +export interface SkillContext { + scaffoldPath: string; +} -- **SPEC.md captures what and why.** High-level goals, scope, and architectural decisions. Not implementation steps — those belong in \`prd.json\`. -- **Codebase is source of truth.** The agent reads code for implementation details. SPEC.md should not duplicate what the code already shows. -- **Keep it stable.** A good spec rarely changes. If you're constantly updating it, you're putting implementation details in the wrong place. -- **Universal scope.** SPEC.md describes the project as it should be — not tied to "v1" or a single milestone. Use \`prd.json\` for phased work. +export interface Skill { + id: string; + name: string; + inputPlaceholder: string; + buildSystemPrompt: (ctx: SkillContext) => string; + buildPermission: (ctx: SkillContext) => PermissionRule[]; + buildAutoPrompt?: (ctx: SkillContext) => string; +} -## Output Template +export type ActiveSkill = "spec" | "prd" | null; + +function buildPlanFilePermissions(ctx: SkillContext): PermissionRule[] { + return [ + { permission: "read", pattern: "*", action: "allow" }, + { + permission: "write", + pattern: `${ctx.scaffoldPath}/*`, + action: "allow", + }, + { permission: "question", pattern: "*", action: "deny" }, + { permission: "*", pattern: "*", action: "deny" }, + ]; +} -\`\`\`markdown +export const SPEC_SKILL: Skill = { + id: "spec", + name: "Spec", + inputPlaceholder: "Describe your project...", + buildPermission: buildPlanFilePermissions, + buildSystemPrompt: ( + ctx, + ) => `You are a spec writer. Your ONLY job is to create \`SPEC.md\` in the plan workspace by calling the \`write\` tool. + +RULES: +- You MUST use the \`write\` tool to create the file. Do NOT emit the spec as text in your response — it must be written via the tool. +- Ask the user 2-3 brief clarifying questions about what they're building, then write the spec. If the description is already clear, skip questions and write immediately. +- Do NOT run shell commands. Do NOT create other files. +- After calling \`write\`, confirm briefly in text ("wrote SPEC.md") and stop. + +TARGET FILE (absolute path): +${ctx.scaffoldPath}/SPEC.md + +SPEC.md TEMPLATE: # Project Name - -> One-line description of the project. +> One-line description. ## Overview - -[2-3 paragraphs: what you're building, the problem it solves, and who it's for] +[What you're building, the problem it solves, who it's for] ## Scope - ### Included - [High-level capability 1] -- [High-level capability 2] - ### Excluded - [What this project will NOT do] ## Technical Stack - -- **Language**: [e.g., TypeScript 5.x with strict mode] -- **Framework**: [e.g., Next.js 14 with App Router] -- **Database**: [e.g., PostgreSQL 15 with Prisma ORM] -- **Authentication**: [e.g., NextAuth.js with JWT] -- **Testing**: [e.g., Vitest + Playwright] -- **Other**: [Any other key technologies] +- **Language**: [e.g., TypeScript 5.x] +- **Framework**: [e.g., Next.js 14] +- **Database**: [e.g., PostgreSQL with Prisma] +- **Testing**: [e.g., Vitest] ## Architecture - -[High-level patterns, system structure, how major components communicate] +[High-level patterns, how major components communicate] ## Constraints - -- [e.g., All code must pass TypeScript strict mode] -- [e.g., API responses must stay under 200ms p95] -- [e.g., Node.js 18+ required] - -## References - -- [Links to design mockups, external API docs, or prior art] -\`\`\` - -## Section Rules - -### 1. Overview — what, why, who - -Clearly state what the project is, the problem it solves, and the target users. Vagueness here cascades everywhere. - -\`\`\` -GOOD: "A REST API for managing inventory in small retail stores, - reducing manual stock counting by 80%." -BAD: "A cool app for managing stuff." -\`\`\` - -### 2. Scope — high-level capabilities, not implementation tasks - -List what the project does and doesn't do. Think capabilities, not user stories or acceptance criteria — those belong in \`prd.json\`. - -\`\`\` -GOOD (spec): -- User authentication and role-based access control -- Real-time inventory tracking across multiple locations - -BAD (belongs in prd.json): -- User can reset password via email link with 24h expiry token -- POST /api/auth/register returns 201 with JWT -\`\`\` - -Always include an **Excluded** section. Without boundaries, the agent will over-build. - -### 3. Technical Stack — eliminate all guesswork - -Every major technology choice must be explicit. If the agent has to guess, it will guess wrong. - -\`\`\` -GOOD: -- **Language**: TypeScript 5.x with strict mode -- **Framework**: Next.js 14 with App Router -- **Database**: PostgreSQL 15 with Prisma ORM - -BAD: -- Some backend framework -- A database -\`\`\` - -Include: language, framework, database + access method, infrastructure, key libraries. - -### 4. Architecture — decisions, not file trees - -Describe the high-level patterns and how components interact. Do NOT document directory structure or file-level organization — the codebase shows that. - -\`\`\` -GOOD: "Monolithic Express app with layered architecture: - routes → controllers → services → repositories. - All business logic lives in the service layer." - -BAD: "src/routes/ contains route files, src/controllers/ - contains controller files, src/services/ ..." -\`\`\` - -Optional for simple projects. - -### 5. Constraints — guiding principles, not exact targets - -Capture non-functional requirements that guide the agent's decisions. Keep them directional — exact thresholds and metrics belong in \`prd.json\` task notes. - -Categories: performance, security, compatibility, code quality. - -### 6. References — link external context - -Links to design mockups, API docs, similar projects. Optional — omit if none exist. - -## Workflows - -| User Intent | Workflow | -| -------------------------------------------------------- | -------------- | -| "Create spec", "define requirements", "plan the project" | **Create** | -| "Review spec", "improve spec", "update spec" | **Refine** | -| Unclear | Ask the user | - -### Create - -1. **Gather requirements** — ask the user: - - What are you building? What problem does it solve? Who uses it? - - What's in scope? What's explicitly out? - - What language/framework/database? Key libraries? - - High-level architecture (monolith, microservices, serverless)? - - Any hard constraints (performance, security, compatibility)? -2. **Draft the spec** following the output template and section rules. -3. **Present for feedback** — ask about missing scope, unclear decisions, or tech stack changes. -4. **Refine and output** the final \`SPEC.md\` to \`.ralph/SPEC.md\`. - -### Refine - -1. **Read existing \`SPEC.md\`** and evaluate against section rules. -2. **Identify gaps** — missing sections, vague scope, unspecified tech, no boundaries, implementation details that should move to \`prd.json\`. -3. **Ask clarifying questions** to fill gaps. -4. **Output the refined \`SPEC.md\`** to \`.ralph/SPEC.md\`. - -## Validation Checklist - -- [ ] Overview clearly states what, why, and who -- [ ] Scope lists high-level capabilities (not implementation tasks) -- [ ] Excluded section defines explicit boundaries -- [ ] All major technology choices specified -- [ ] Architecture describes patterns, not file structure -- [ ] Constraints are directional, not over-specified -- [ ] No implementation details that belong in \`prd.json\` -- [ ] Stable — won't need updating as code evolves`; - -export const CREATE_PRD_SYSTEM_PROMPT = `# PRD/Task Creation Helper - -Create and manage \`prd.json\` task lists for Ralph — an AI coding agent that loops through tasks: reads \`prd.json\`, picks ONE task, completes it, marks \`passed: true\`, repeats until done. - -Task quality directly determines agent performance. Follow the rules below strictly. - -## Output Schema - +- [Non-functional requirements that guide decisions]`, +}; + +export const PRD_SKILL: Skill = { + id: "prd", + name: "PRD", + inputPlaceholder: "Refine the task breakdown...", + buildPermission: buildPlanFilePermissions, + buildAutoPrompt: (ctx) => + `Read ${ctx.scaffoldPath}/SPEC.md and create a task breakdown. Write it to ${ctx.scaffoldPath}/prd.json using the write tool.`, + buildSystemPrompt: ( + ctx, + ) => `You are a task planner. Your ONLY job is to read \`SPEC.md\` from the plan workspace and produce \`prd.json\` in the same workspace by calling the \`write\` tool. + +RULES: +- You MUST use the \`write\` tool to create the file. Do NOT emit the JSON as text in your response — it must be written via the tool. +- Do NOT run shell commands. Do NOT create other files. +- Each task must be completable in a single agent session (~1-2 hours). +- Every task MUST end with verification subtasks (tests, typecheck, lint). +- No overlapping scope between tasks. +- Order: setup → models → features → polish → integration tests. +- After calling \`write\`, confirm briefly in text ("wrote prd.json") and stop. + +INPUT FILE (absolute path): +${ctx.scaffoldPath}/SPEC.md + +TARGET FILE (absolute path): +${ctx.scaffoldPath}/prd.json + +OUTPUT SCHEMA: \`\`\`json { "tasks": [ { "description": "Clear end-goal of the task", - "subtasks": ["Specific step 1", "Specific step 2", "Verification step"], - "notes": "Context, constraints, references, or tips", + "subtasks": ["Specific step 1", "Specific step 2", "Run tests", "Run typecheck", "Run lint"], + "notes": "Context, constraints, references", "passed": false } ] } \`\`\` -- \`description\`: What should be achieved when done. Clear and specific. -- \`subtasks\`: Ordered, actionable implementation steps. -- \`notes\`: Context and constraints for the agent. Can be empty string. -- \`passed\`: Always \`false\` for new tasks. - -## Task Rules - -### 1. Right-sized tasks - -Each task must be completable in a single agent session (~1-2 hours of human work). - -\`\`\` -GOOD: "Implement POST /api/auth/register endpoint" -BAD: "Build the authentication system" → split into 4-6 tasks -\`\`\` - -If it would take a full day, break it down. If it takes 15 minutes, combine with related work. - -### 2. Specific, actionable subtasks - -\`\`\`json -// GOOD -"subtasks": [ - "Create src/models/user.ts with User interface", - "Define fields: id (UUID), email (string), passwordHash (string), createdAt (Date)", - "Add Zod schema for validation", - "Export UserCreate and UserResponse types" -] - -// BAD -"subtasks": ["Create user model", "Add fields", "Add validation"] -\`\`\` - -### 3. Every task MUST end with verification + code quality checks - -The final subtasks of every task must include: - -1. **Tests**: Run the project's test suite -2. **Type checking**: Run the type check script (e.g., \`npm run typecheck\`, \`npx tsc --noEmit\`) -3. **Linting/Formatting**: Run the linter/formatter (e.g., \`npm run lint\`, \`npm run format:check\`) - -Check \`package.json\` scripts or equivalent config to determine the correct commands. - -\`\`\`json -"subtasks": [ - "... implementation steps ...", - "Write tests for success and failure cases", - "Run npm test to verify all tests pass", - "Run npm run typecheck to ensure no type errors", - "Run npm run lint to ensure code quality" -] -\`\`\` - -> If the project lacks these tools, the setup task should configure them. All subsequent tasks must include these checks. - -### 4. No overlapping scope - -Each task must have clear boundaries. No two tasks should modify the same files or implement the same logic. - -\`\`\`json -// BAD — overlapping -{ "description": "Create User model", "subtasks": ["Define schema", "Add validation", "Create API routes"] }, -{ "description": "Build user API", "subtasks": ["Create routes for users", "Add validation"] } - -// GOOD — clear boundaries -{ "description": "Create User model and validation schemas", "subtasks": ["Define schema", "Add Zod validation", "Export types"] }, -{ "description": "Implement User CRUD API endpoints", "subtasks": ["Create GET /api/users", "Create POST /api/users"] } -\`\`\` - -### 5. Useful notes - -Include in \`notes\`: references to SPEC.md decisions, constraints, related files, gotchas, edge cases, and context about previous tasks. - -### 6. Logical ordering - -Ralph infers task order from the list position. Order tasks as: - -1. Setup/configuration -2. Core models/types -3. Core features -4. Integrations -5. Polish and edge cases -6. Integration/E2E tests - -## Workflows - -Determine workflow from the user's request: - -| User Intent | Workflow | -| ------------------------------------------------------- | --------------------- | -| "Create PRD", "plan the project", "break down the spec" | **Full PRD Creation** | -| "Add a task", "create a task for X" | **Incremental** | -| Unclear | Ask the user | - -### Full PRD Creation - -1. **Get context**: Read \`SPEC.md\` if it exists. Otherwise, use the user's description. If neither exists, explore the codebase (directory structure, manifests, entry files, tests) and summarize your understanding to the user for confirmation. -2. **Analyze**: Identify setup requirements, data models, features, API endpoints, frontend components, integrations, and testing needs. -3. **Propose tasks**: Create the ordered task list following all rules above. -4. **Get feedback**: Present the list and ask about ordering, sizing, missing features, and tasks to combine/split. -5. **Refine and output**: Incorporate feedback and generate \`.ralph/prd.json\`. - -### Incremental Task Management - -1. **Read existing \`prd.json\`** to avoid duplicates and match existing style. -2. **Explore codebase** briefly if needed for context. -3. **Create one well-formed task** following all rules above. -4. **Present to user** for confirmation. -5. **Append to \`prd.json\`** (or create it if it doesn't exist), placing logically based on dependencies. - -## Examples - -### Project Setup - -\`\`\`json -{ - "description": "Initialize project with TypeScript, ESLint, and Prettier", - "subtasks": [ - "Run npm init -y to create package.json", - "Install TypeScript and initialize with npx tsc --init", - "Configure tsconfig.json with strict mode, ES2022 target, and path aliases", - "Install and configure ESLint with TypeScript plugin", - "Install and configure Prettier with ESLint integration", - "Add scripts to package.json: build, typecheck, lint, format", - "Create src/index.ts with a simple console.log to verify setup", - "Run npm run build && npm run typecheck to verify configuration", - "Run npm run lint && npm run format:check to verify code quality tooling" - ], - "notes": "Use ESM modules (type: module in package.json). Target Node.js 18+.", - "passed": false -} -\`\`\` - -### Data Model +TASK SIZING: +- GOOD: "Implement POST /api/auth/register endpoint" +- BAD: "Build the authentication system" → split into 4-6 tasks -\`\`\`json -{ - "description": "Create User model with Prisma schema and TypeScript types", - "subtasks": [ - "Add User model to prisma/schema.prisma with fields: id (UUID), email, passwordHash, name, createdAt, updatedAt", - "Add unique constraint on email, set id default to uuid()", - "Run npx prisma migrate dev --name add-user-model", - "Create src/types/user.ts with User, UserCreate, and UserResponse types", - "Create src/lib/validation/user.ts with Zod schemas for each type", - "Run npx prisma generate to update client", - "Run npm run typecheck to ensure no type errors", - "Run npm run lint to ensure code quality" - ], - "notes": "Ensure passwordHash is never included in UserResponse type. Email should be lowercase and trimmed.", - "passed": false -} -\`\`\` +SUBTASK SPECIFICITY: +- GOOD: "Create src/models/user.ts with User interface, fields: id (UUID), email, passwordHash, createdAt" +- BAD: "Create user model"`, +}; -### API Endpoint +const SKILLS: Record = { + spec: SPEC_SKILL, + prd: PRD_SKILL, +}; -\`\`\`json -{ - "description": "Implement POST /api/auth/register endpoint", - "subtasks": [ - "Create src/routes/auth/register.ts", - "Add POST handler that accepts { email, password, name }", - "Validate request body using Zod schema from src/lib/validation/user.ts", - "Check if user with email already exists, return 409 if so", - "Hash password with bcrypt (12 rounds)", - "Create user in database with Prisma", - "Return 201 with user data (excluding passwordHash)", - "Write tests in tests/routes/auth/register.test.ts", - "Test: successful registration returns 201", - "Test: duplicate email returns 409", - "Test: invalid email format returns 400", - "Run npm test to verify all tests pass", - "Run npm run typecheck to ensure no type errors", - "Run npm run lint to ensure code quality" - ], - "notes": "Follow error response format in src/lib/errors.ts. Use the db client from src/lib/db.ts.", - "passed": false +export function getSkill(id: string): Skill | undefined { + return SKILLS[id]; } -\`\`\` - -## Validation Checklist - -Before finalizing, verify every task meets: - -- [ ] Completable in a single agent session -- [ ] Subtasks are specific and actionable -- [ ] Ends with test + type check + lint/format subtasks -- [ ] No overlapping scope with other tasks -- [ ] Logically ordered (setup → models → features → polish) -- [ ] Notes provide helpful context -- [ ] Valid JSON following the schema`; - -export const CREATE_PROMPT_SYSTEM_PROMPT = `# Execution Prompt Generator - -Generate \`PROMPT.md\` — the instruction file that Ralph's AI coding agent reads at the start of every iteration to know how to work through tasks. - -## Prerequisites - -Before generating, read the following files in the \`.ralph/\` directory: -- **\`.ralph/SPEC.md\`** — project specification (what's being built) -- **\`.ralph/prd.json\`** — task list with all tasks and their status - -If either file is missing, tell the user to create them first (\`/spec\` then \`/prd\`). - -## What to Generate - -Generate a \`PROMPT.md\` file tailored to this specific project. The prompt must follow this 7-step agent workflow: - -### Step 1: Understand Context - -Instruct the agent to read these files at the start of every session: -1. \`SPEC.md\` — project specification -2. \`prd.json\` — task list with status -3. \`progress.md\` — log of completed work from previous iterations - -> Note: these files are in the \`.ralph/\` subdirectory. - -### Step 2: Select a Task - -Instruct the agent to: -- Choose ONE task where \`passed: false\` -- Analyze task descriptions and current project state to determine the best next task -- Consider logical dependencies -- If unclear, prefer tasks listed earlier in the file - -### Step 3: Complete the Task - -Instruct the agent to: -- Follow the \`subtasks\` array as implementation guide -- Write clean, well-structured code -- Verify work before marking complete (run tests, type checks, linting) - -**Important**: Include the project-specific verification commands from SPEC.md or package.json. For example: -- Tests: \`bun test\`, \`npm test\`, \`pytest\`, etc. -- Type checking: \`bun run check:types\`, \`npx tsc --noEmit\`, \`mypy\`, etc. -- Linting: \`bun run check\`, \`npm run lint\`, etc. - -### Step 4: Update Progress - -Instruct the agent to append to \`progress.md\` using this format: - -\`\`\`markdown ---- - -## Task: [Task description from prd.json] - -### Completed -- [What was accomplished] - -### Files Changed -- [List of files] - -### Decisions -- [Any architectural or implementation decisions] - -### Notes for Future Agent -- [Helpful context for future iterations] -\`\`\` - -Rules: **APPEND ONLY** — never modify or delete previous entries. - -### Step 5: Update prd.json - -Instruct the agent to: -1. Set \`passed: true\` for the completed task -2. Update \`notes\` field of any other tasks if relevant context was discovered - -### Step 6: Commit Changes - -Instruct the agent to create a git commit with a clear, descriptive message about what was implemented. - -### Step 7: Signal Completion - -Instruct the agent to output this exact string on its own line when finished: - -\`\`\` -RALPH_TASK_COMPLETE -\`\`\` - -## Important Rules to Include - -The generated PROMPT.md must include these rules: - -1. **One task per session** — do not work on multiple tasks -2. **Verify before marking complete** — ensure the implementation actually works -3. **Append-only progress** — never edit previous progress.md entries -4. **Leave context** — future iterations depend on your notes -5. **Commit your work** — all changes must be committed before signaling completion - -## Workflow - -1. Read \`.ralph/SPEC.md\` and \`.ralph/prd.json\` to understand the project -2. Identify project-specific tooling (test runner, type checker, linter) from SPEC.md or by reading package.json / config files -3. Generate a tailored \`PROMPT.md\` that includes: - - The 7-step workflow above - - Project-specific commands for verification - - The important rules section -4. Write the output to \`.ralph/PROMPT.md\``; diff --git a/bun.lock b/bun.lock index 4c9b8a0..29674c4 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "@opentui/react": "^0.1.77", "@techatnyu/ralphd": "workspace:*", "react": "^19.2.4", + "zod": "^4.3.6", }, "devDependencies": { "@types/bun": "latest", @@ -602,7 +603,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -684,7 +685,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], @@ -1554,8 +1555,6 @@ "@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@techatnyu/ralphd/@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1604,8 +1603,6 @@ "@tanstack/start-plugin-core/@tanstack/router-utils/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - "@techatnyu/ralphd/@types/bun/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], "fumadocs-mdx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], diff --git a/packages/daemon/src/client.ts b/packages/daemon/src/client.ts index 18c77cf..e0bdbc5 100644 --- a/packages/daemon/src/client.ts +++ b/packages/daemon/src/client.ts @@ -233,6 +233,75 @@ export class DaemonClient { >; } + /** + * Submit a job and stream events on the same socket. Eliminates the + * race between separate submit + stream calls where deltas could be + * lost between the two connections. + */ + async submitAndStreamJob( + params: ParamsByMethod<"job.submit_and_stream">, + ): Promise<{ + job: ResultByMethod<"job.submit_and_stream">["job"]; + events: AsyncGenerator; + }> { + const request = RequestMessageSchema.parse({ + id: Bun.randomUUIDv7(), + method: "job.submit_and_stream", + params, + }); + + const socket = connect(this.socketPath); + socket.setEncoding("utf8"); + socket.on("error", () => {}); + const rl = createInterface({ input: socket }); + const lines = rl[Symbol.asyncIterator](); + + socket.write(`${JSON.stringify(request)}\n`); + + const firstLine = await lines.next(); + if (firstLine.done || !firstLine.value.trim()) { + rl.close(); + socket.destroy(); + throw new Error("daemon closed connection before ack"); + } + + const ack = ResponseMessageSchema.safeParse(JSON.parse(firstLine.value)); + if (!ack.success || !ack.data.ok) { + rl.close(); + socket.destroy(); + throw new Error( + !ack.success + ? "invalid daemon response" + : (ack.data as { error: { message: string } }).error.message, + ); + } + + const job = ( + ack.data.result as { job: ResultByMethod<"job.submit_and_stream">["job"] } + ).job; + + async function* eventStream(): AsyncGenerator { + try { + for await (const line of rl) { + if (!line.trim()) continue; + const event = JobStreamEventSchema.safeParse( + JSON.parse(line) as unknown, + ); + if (!event.success) continue; + yield event.data; + if (event.data.type === "done" || event.data.type === "error") { + return; + } + } + } finally { + rl.close(); + socket.destroy(); + } + } + + return { job, events: eventStream() }; + } + /** * Open a stream over the daemon socket and yield job events as they * arrive. The first line on the wire is the ack response (a normal diff --git a/packages/daemon/src/opencode.ts b/packages/daemon/src/opencode.ts index 486eb38..ab4626c 100644 --- a/packages/daemon/src/opencode.ts +++ b/packages/daemon/src/opencode.ts @@ -1,3 +1,4 @@ +import { createServer } from "node:net"; import { type AssistantMessage, createOpencode, @@ -117,6 +118,22 @@ interface InstanceSubscription { cancel(): void; } +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (typeof addr === "object" && addr) { + const port = addr.port; + srv.close(() => resolve(port)); + } else { + srv.close(() => reject(new Error("Failed to get port"))); + } + }); + srv.on("error", reject); + }); +} + export class OpencodeRegistry implements OpencodeRuntimeManager { private shared?: SharedRuntime; private sharedStarting?: Promise; @@ -176,44 +193,53 @@ export class OpencodeRegistry implements OpencodeRuntimeManager { responseStyle: "data", }); return res as unknown as { - info: AssistantMessage; - parts: Part[]; + info?: AssistantMessage; + parts?: Part[]; }; }, abort: (parameters) => client.session.abort(parameters, { throwOnError: true, }), - }, - provider: { - list: async (parameters) => { - const response = await client.provider.list(parameters, { - throwOnError: true, - }); - return { - providers: response.data.all.map((p) => ({ - id: p.id, - name: p.name, - models: Object.fromEntries( - Object.entries(p.models).map(([k, rawModel]) => { - const m = rawModel as RawProviderModel; - return [ - k, - { - id: m.id, - name: m.name, - family: m.family, - attachment: - m.attachment ?? m.capabilities?.attachment, - reasoning: m.reasoning ?? m.capabilities?.reasoning, - tool_call: m.tool_call ?? m.capabilities?.toolcall, - }, - ]; - }), - ), - })), - connected: response.data.connected, - }; + }, + provider: { + list: async (parameters) => { + const response = await client.provider.list(parameters, { + throwOnError: true, + }); + return { + providers: response.data.all.map((p) => ({ + id: p.id, + name: p.name, + models: Object.fromEntries( + Object.entries(p.models).map(([k, rawModel]) => { + const m = rawModel as RawProviderModel; + return [ + k, + { + id: m.id, + name: m.name, + family: m.family, + attachment: + m.attachment ?? m.capabilities?.attachment, + reasoning: m.reasoning ?? m.capabilities?.reasoning, + tool_call: m.tool_call ?? m.capabilities?.toolcall, + }, + ]; + }), + ), + })), + connected: response.data.connected, + }; + }, + }, + async ping() { + try { + await client.path.get({}, { throwOnError: true }); + return true; + } catch { + return false; + } }, }, async ping() { diff --git a/packages/daemon/src/protocol.ts b/packages/daemon/src/protocol.ts index 3adeaec..3376bcc 100644 --- a/packages/daemon/src/protocol.ts +++ b/packages/daemon/src/protocol.ts @@ -386,6 +386,7 @@ const RequestMethod = z.enum([ "job.get", "job.cancel", "job.stream", + "job.submit_and_stream", ]); export type RequestMethod = z.infer; @@ -495,6 +496,12 @@ const JobStreamRequest = z.strictObject({ params: JobStreamParams, }); +const JobSubmitAndStreamRequest = z.strictObject({ + id: z.string().min(1), + method: z.literal("job.submit_and_stream"), + params: JobSubmitParams, +}); + /** Union of every valid request the daemon accepts. */ export const RequestMessage = z.discriminatedUnion("method", [ DaemonHealthRequest, @@ -513,6 +520,7 @@ export const RequestMessage = z.discriminatedUnion("method", [ JobGetRequest, JobCancelRequest, JobStreamRequest, + JobSubmitAndStreamRequest, ]); export type RequestMessage = z.infer; @@ -642,6 +650,13 @@ const JobStreamSuccess = z.strictObject({ result: StreamAckResult, }); +const JobSubmitAndStreamSuccess = z.strictObject({ + id: z.string().min(1), + method: z.literal("job.submit_and_stream"), + ok: z.literal(true), + result: SubmitResult, +}); + // Error response const ErrorResponse = z.strictObject({ @@ -670,6 +685,7 @@ export const ResponseMessage = z.union([ JobGetSuccess, JobCancelSuccess, JobStreamSuccess, + JobSubmitAndStreamSuccess, ErrorResponse, ]); export type ResponseMessage = z.infer; diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index abd67ae..3127297 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -305,6 +305,8 @@ export class Daemon { return this.success(raw, await this.handleJobCancel(raw)); case "job.stream": return this.success(raw, this.handleJobStream(raw)); + case "job.submit_and_stream": + return this.success(raw, await this.handleJobSubmitAndStream(raw)); } } catch (error) { return this.failure(raw.id, raw.method, this.toResponseError(error)); @@ -460,6 +462,32 @@ export class Daemon { return { job }; } + private async handleJobSubmitAndStream( + request: RequestByMethod<"job.submit_and_stream">, + ): Promise { + if (this.shuttingDown) { + throw new StoreError("shutdown", "daemon is shutting down"); + } + + const { instanceId } = request.params; + this.store.assertInstance(this.state, instanceId); + + const now = new Date().toISOString(); + const job: DaemonJob = { + id: randomUUID(), + instanceId, + session: request.params.session, + task: request.params.task, + state: "queued", + createdAt: now, + updatedAt: now, + }; + this.state = this.store.upsertJob(this.state, job); + this.enqueue(job); + await this.store.save(this.state); + return { job }; + } + private handleJobList(request: RequestByMethod<"job.list">): ListResult { return { jobs: this.store.listJobs(request.params) }; } @@ -658,7 +686,7 @@ export class Daemon { } } - private scheduleDrain(): void { + scheduleDrain(): void { if (this.drainPromise) { this.drainPending = true; return; @@ -1084,6 +1112,26 @@ export function createConnectionHandler(daemon: Daemon) { return; } + if (request.data.method === "job.submit_and_stream") { + void daemon.handleRequest(request.data).then((ack) => { + if (!writeLine(ack)) return; + if (!ack.ok) return; + + const jobId = (ack.result as SubmitResult).job.id; + const unsub = daemon.subscribeJob(jobId, (event) => { + if (socket.writable) { + socket.write(`${JSON.stringify(event)}\n`); + } + if (event.type === "done" || event.type === "error") { + socket.end(); + } + }); + socket.on("close", unsub); + daemon.scheduleDrain(); + }); + return; + } + if (request.data.method === "job.stream") { const { jobId } = request.data.params as { jobId: string }; void daemon.handleRequest(request.data).then((ack) => { From 004faeb87c3797650565be28262a531e5bb2050c Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Sat, 25 Apr 2026 20:37:13 -0400 Subject: [PATCH 10/21] Make plan artifact writes deterministic --- apps/tui/src/components/plan-view.tsx | 95 +++++++++++++++++-- apps/tui/src/hooks/use-chat.ts | 27 ++++-- apps/tui/src/hooks/use-plan-files.ts | 55 +---------- apps/tui/src/lib/plan-artifacts.test.ts | 117 ++++++++++++++++++++++++ apps/tui/src/lib/plan-artifacts.ts | 58 ++++++++++++ apps/tui/src/lib/plan-validation.ts | 55 +++++++++++ apps/tui/src/skills.ts | 31 +++---- 7 files changed, 353 insertions(+), 85 deletions(-) create mode 100644 apps/tui/src/lib/plan-artifacts.test.ts create mode 100644 apps/tui/src/lib/plan-artifacts.ts create mode 100644 apps/tui/src/lib/plan-validation.ts diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx index 40785ca..5a7cdfc 100644 --- a/apps/tui/src/components/plan-view.tsx +++ b/apps/tui/src/components/plan-view.tsx @@ -1,10 +1,13 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { useKeyboard, useTerminalDimensions } from "@opentui/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useChat } from "../hooks/use-chat"; import type { PlanFilesData } from "../hooks/use-plan-files"; import type { usePlanInstance } from "../hooks/use-plan-instance"; import { useSkill } from "../hooks/use-skill"; -import type { SkillContext } from "../skills"; +import { writePrdArtifact, writeSpecArtifact } from "../lib/plan-artifacts"; +import type { Skill, SkillContext } from "../skills"; import { ContextSidebar } from "./context-sidebar"; import { PlanChat } from "./plan-chat"; import { TaskOverlay } from "./task-overlay"; @@ -18,6 +21,46 @@ interface PlanViewProps { planInstance: ReturnType; } +async function readSpecForPrompt(scaffoldPath: string): Promise { + const spec = await readFile(join(scaffoldPath, "SPEC.md"), "utf8"); + const trimmed = spec.trim(); + if (!trimmed) { + throw new Error("SPEC.md is empty"); + } + return trimmed; +} + +async function buildSkillPrompt( + skill: Skill, + ctx: SkillContext, + prompt: string, +): Promise { + if (skill.id !== "prd") { + return prompt; + } + const spec = await readSpecForPrompt(ctx.scaffoldPath); + return `${prompt} + +SPEC.md: +\`\`\`markdown +${spec} +\`\`\``; +} + +async function writeSkillArtifact( + skill: Skill, + ctx: SkillContext, + content: string, +): Promise { + if (skill.id === "spec") { + await writeSpecArtifact(ctx.scaffoldPath, content); + return; + } + if (skill.id === "prd") { + await writePrdArtifact(ctx.scaffoldPath, content); + } +} + export function PlanView({ focused, planData, @@ -93,13 +136,33 @@ export function PlanView({ if (!skill) return; const { scaffoldPath } = await ensureInstance(); const ctx: SkillContext = { scaffoldPath }; - await chatSend({ - prompt, + let finalPrompt: string; + try { + finalPrompt = await buildSkillPrompt(skill, ctx, prompt); + } catch (e) { + addSystemMessage( + `SPEC.md: ${e instanceof Error ? e.message : "failed to read file"}`, + ); + return; + } + const result = await chatSend({ + prompt: finalPrompt, systemPrompt: skill.buildSystemPrompt(ctx), permission: skill.buildPermission(ctx), }); + if (!result?.content) return; + try { + await writeSkillArtifact(skill, ctx, result.content); + } catch (e) { + const filename = skill.id === "spec" ? "SPEC.md" : "prd.json"; + const reason = + e instanceof Error ? e.message : "generated response was invalid"; + addSystemMessage( + `${filename}: generated response was invalid (${reason})`, + ); + } }, - [skill, ensureInstance, chatSend], + [skill, ensureInstance, chatSend, addSystemMessage], ); const handleStartSkill = async (id: "spec" | "prd") => { @@ -108,11 +171,31 @@ export function PlanView({ if (!s.buildAutoPrompt) return; const { scaffoldPath } = await ensureInstance(); const ctx: SkillContext = { scaffoldPath }; - await chatSend({ - prompt: s.buildAutoPrompt(ctx), + let prompt: string; + try { + prompt = await buildSkillPrompt(s, ctx, s.buildAutoPrompt(ctx)); + } catch (e) { + addSystemMessage( + `SPEC.md: ${e instanceof Error ? e.message : "failed to read file"}`, + ); + return; + } + const result = await chatSend({ + prompt, systemPrompt: s.buildSystemPrompt(ctx), permission: s.buildPermission(ctx), }); + if (!result?.content) return; + try { + await writeSkillArtifact(s, ctx, result.content); + } catch (e) { + const filename = s.id === "spec" ? "SPEC.md" : "prd.json"; + const reason = + e instanceof Error ? e.message : "generated response was invalid"; + addSystemMessage( + `${filename}: generated response was invalid (${reason})`, + ); + } }; useKeyboard((key) => { diff --git a/apps/tui/src/hooks/use-chat.ts b/apps/tui/src/hooks/use-chat.ts index e87b044..7e75b87 100644 --- a/apps/tui/src/hooks/use-chat.ts +++ b/apps/tui/src/hooks/use-chat.ts @@ -1,4 +1,4 @@ -import { daemon, type PermissionRule } from "@techatnyu/ralphd"; +import { type DaemonJob, daemon, type PermissionRule } from "@techatnyu/ralphd"; import { useCallback, useRef, useState } from "react"; import { createPromptTask } from "../lib/prompt-task"; import { ralphStore } from "../lib/store"; @@ -14,11 +14,16 @@ export interface SendOptions { permission?: PermissionRule[]; } +export interface SendResult { + content: string; + job?: DaemonJob; +} + interface UseChatReturn { messages: ChatMessage[]; loading: boolean; error: string | undefined; - send: (options: SendOptions) => Promise; + send: (options: SendOptions) => Promise; addSystemMessage: (content: string) => void; resetSession: () => void; clear: () => void; @@ -53,7 +58,7 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { const send = useCallback( async ({ prompt, systemPrompt, permission }: SendOptions) => { - if (loading) return; + if (loading) return undefined; setMessages((prev) => [...prev, { role: "user", content: prompt }]); setLoading(true); @@ -100,27 +105,33 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { role: "system", content: `Error: ${message}`, })); - break; + return undefined; + } + if (event.job.state !== "succeeded") { + return undefined; } + let final = content.trim(); if (!content.trim()) { - const final = event.job.outputText?.trim() || "(empty response)"; + final = event.job.outputText?.trim() || ""; updateLastMessage(() => ({ role: "assistant", - content: final, + content: final || "(empty response)", })); } - break; + return { content: final, job: event.job }; } else if (event.type === "error") { setError(event.error); updateLastMessage(() => ({ role: "system", content: `Error: ${event.error}`, })); - break; + return undefined; } } + return undefined; } catch (e) { setError(e instanceof Error ? e.message : "Failed to submit message"); + return undefined; } finally { setLoading(false); } diff --git a/apps/tui/src/hooks/use-plan-files.ts b/apps/tui/src/hooks/use-plan-files.ts index 9f8f195..d927f30 100644 --- a/apps/tui/src/hooks/use-plan-files.ts +++ b/apps/tui/src/hooks/use-plan-files.ts @@ -1,20 +1,9 @@ import { readFile, watch } from "node:fs"; import { join } from "node:path"; import { useCallback, useEffect, useRef, useState } from "react"; -import { z } from "zod"; +import { type PrdTask, parsePrd, validateSpec } from "../lib/plan-validation"; -const PrdTaskSchema = z.object({ - description: z.string().min(1), - subtasks: z.array(z.string().min(1)).min(1), - notes: z.string().optional().default(""), - passed: z.boolean().optional().default(false), -}); - -const PrdFileSchema = z.object({ - tasks: z.array(PrdTaskSchema).min(1), -}); - -export type PrdTask = z.infer; +export type { PrdTask } from "../lib/plan-validation"; export interface PlanFilesData { tasks: PrdTask[]; @@ -44,46 +33,6 @@ function readFileAsync(path: string): Promise { }); } -interface PrdParseResult { - tasks: PrdTask[]; - error?: string; -} - -function parsePrd(content: string | null): PrdParseResult { - if (content === null) return { tasks: [] }; - let json: unknown; - try { - json = JSON.parse(content); - } catch { - return { tasks: [], error: "invalid JSON" }; - } - const parsed = PrdFileSchema.safeParse(json); - if (!parsed.success) { - const first = parsed.error.issues[0]; - const path = first?.path.join(".") || "root"; - const message = first?.message ?? "validation failed"; - return { tasks: [], error: `${path}: ${message}` }; - } - return { tasks: parsed.data.tasks }; -} - -interface SpecValidation { - valid: boolean; - error?: string; -} - -function validateSpec(content: string | null): SpecValidation { - if (content === null) return { valid: false }; - const trimmed = content.trim(); - if (trimmed.length < 100) { - return { valid: false, error: "too short (<100 chars)" }; - } - if (!/^#\s+\S/m.test(trimmed)) { - return { valid: false, error: "missing markdown heading" }; - } - return { valid: true }; -} - export function usePlanFiles(scaffoldPath: string | null): UsePlanFilesReturn { const [data, setData] = useState({ tasks: [], diff --git a/apps/tui/src/lib/plan-artifacts.test.ts b/apps/tui/src/lib/plan-artifacts.test.ts new file mode 100644 index 0000000..ae1f912 --- /dev/null +++ b/apps/tui/src/lib/plan-artifacts.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writePrdArtifact, writeSpecArtifact } from "./plan-artifacts"; + +describe("plan artifacts", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + async function tempScaffold(): Promise { + const dir = await mkdtemp(join(tmpdir(), "ralph-plan-artifacts-")); + tempDirs.push(dir); + return dir; + } + + it("writes valid SPEC markdown", async () => { + const scaffold = await tempScaffold(); + const spec = `# Todo App + +> A small task manager for personal use. + +## Overview +Build a browser todo app that lets one person add, complete, and delete tasks while keeping state locally. + +## Scope +- Add tasks +- Complete tasks +- Delete tasks +`; + + await writeSpecArtifact(scaffold, spec); + + expect(await readFile(join(scaffold, "SPEC.md"), "utf8")).toBe( + `${spec.trim()}\n`, + ); + }); + + it("rejects too-short SPEC markdown", async () => { + const scaffold = await tempScaffold(); + + await expect(writeSpecArtifact(scaffold, "# Tiny\n")).rejects.toThrow( + "too short", + ); + }); + + it("rejects fake tool calls for SPEC output", async () => { + const scaffold = await tempScaffold(); + + await expect( + writeSpecArtifact(scaffold, 'write("SPEC.md", "# Todo")'), + ).rejects.toThrow("printed a tool call"); + }); + + it("writes valid PRD JSON and normalizes formatting", async () => { + const scaffold = await tempScaffold(); + const prd = `{"tasks":[{"description":"Build task storage","subtasks":["Create storage helper","Run tests"]}]}`; + + await writePrdArtifact(scaffold, prd); + + const written = await readFile(join(scaffold, "prd.json"), "utf8"); + expect(written).toContain('\n\t"tasks"'); + expect(JSON.parse(written)).toEqual({ + tasks: [ + { + description: "Build task storage", + subtasks: ["Create storage helper", "Run tests"], + notes: "", + passed: false, + }, + ], + }); + }); + + it("accepts a single fenced json PRD block", async () => { + const scaffold = await tempScaffold(); + + await writePrdArtifact( + scaffold, + [ + "```json", + '{"tasks":[{"description":"Create UI","subtasks":["Add form","Run typecheck"],"notes":"Keep it simple","passed":false}]}', + "```", + ].join("\n"), + ); + + const written = await readFile(join(scaffold, "prd.json"), "utf8"); + expect(JSON.parse(written).tasks).toHaveLength(1); + }); + + it("rejects prose-wrapped PRD JSON", async () => { + const scaffold = await tempScaffold(); + + await expect( + writePrdArtifact( + scaffold, + `Here is the JSON: +{"tasks":[{"description":"Create UI","subtasks":["Run tests"]}]}`, + ), + ).rejects.toThrow("raw JSON or a single fenced json block"); + }); + + it("rejects schema-invalid PRD JSON", async () => { + const scaffold = await tempScaffold(); + + await expect(writePrdArtifact(scaffold, `{"tasks":[]}`)).rejects.toThrow( + "tasks", + ); + }); +}); diff --git a/apps/tui/src/lib/plan-artifacts.ts b/apps/tui/src/lib/plan-artifacts.ts new file mode 100644 index 0000000..040db90 --- /dev/null +++ b/apps/tui/src/lib/plan-artifacts.ts @@ -0,0 +1,58 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { parsePrd, validateSpec } from "./plan-validation"; + +function normalizeModelOutput(content: string): string { + const trimmed = content.trim(); + if (!trimmed) { + throw new Error("empty response"); + } + if (/ { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); +} + +export async function writeSpecArtifact( + scaffoldPath: string, + content: string, +): Promise { + const spec = normalizeModelOutput(content); + const validation = validateSpec(spec); + if (!validation.valid) { + throw new Error(validation.error ?? "invalid SPEC.md"); + } + await writeArtifact(join(scaffoldPath, "SPEC.md"), `${spec}\n`); +} + +export async function writePrdArtifact( + scaffoldPath: string, + content: string, +): Promise { + const jsonText = extractPrdJson(content); + const parsed = parsePrd(jsonText); + if (parsed.error) { + throw new Error(parsed.error); + } + await writeArtifact( + join(scaffoldPath, "prd.json"), + `${JSON.stringify({ tasks: parsed.tasks }, null, "\t")}\n`, + ); +} diff --git a/apps/tui/src/lib/plan-validation.ts b/apps/tui/src/lib/plan-validation.ts new file mode 100644 index 0000000..54b743b --- /dev/null +++ b/apps/tui/src/lib/plan-validation.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const PrdTaskSchema = z.object({ + description: z.string().min(1), + subtasks: z.array(z.string().min(1)).min(1), + notes: z.string().optional().default(""), + passed: z.boolean().optional().default(false), +}); + +export const PrdFileSchema = z.object({ + tasks: z.array(PrdTaskSchema).min(1), +}); + +export type PrdTask = z.infer; +export type PrdFile = z.infer; + +export interface PrdParseResult { + tasks: PrdTask[]; + error?: string; +} + +export function parsePrd(content: string | null): PrdParseResult { + if (content === null) return { tasks: [] }; + let json: unknown; + try { + json = JSON.parse(content); + } catch { + return { tasks: [], error: "invalid JSON" }; + } + const parsed = PrdFileSchema.safeParse(json); + if (!parsed.success) { + const first = parsed.error.issues[0]; + const path = first?.path.join(".") || "root"; + const message = first?.message ?? "validation failed"; + return { tasks: [], error: `${path}: ${message}` }; + } + return { tasks: parsed.data.tasks }; +} + +export interface SpecValidation { + valid: boolean; + error?: string; +} + +export function validateSpec(content: string | null): SpecValidation { + if (content === null) return { valid: false }; + const trimmed = content.trim(); + if (trimmed.length < 100) { + return { valid: false, error: "too short (<100 chars)" }; + } + if (!/^#\s+\S/m.test(trimmed)) { + return { valid: false, error: "missing markdown heading" }; + } + return { valid: true }; +} diff --git a/apps/tui/src/skills.ts b/apps/tui/src/skills.ts index b30127b..67ebae7 100644 --- a/apps/tui/src/skills.ts +++ b/apps/tui/src/skills.ts @@ -15,14 +15,8 @@ export interface Skill { export type ActiveSkill = "spec" | "prd" | null; -function buildPlanFilePermissions(ctx: SkillContext): PermissionRule[] { +function buildPlanChatPermissions(_ctx: SkillContext): PermissionRule[] { return [ - { permission: "read", pattern: "*", action: "allow" }, - { - permission: "write", - pattern: `${ctx.scaffoldPath}/*`, - action: "allow", - }, { permission: "question", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" }, ]; @@ -32,16 +26,16 @@ export const SPEC_SKILL: Skill = { id: "spec", name: "Spec", inputPlaceholder: "Describe your project...", - buildPermission: buildPlanFilePermissions, + buildPermission: buildPlanChatPermissions, buildSystemPrompt: ( ctx, - ) => `You are a spec writer. Your ONLY job is to create \`SPEC.md\` in the plan workspace by calling the \`write\` tool. + ) => `You are a spec writer. Your ONLY job is to produce the final contents for \`SPEC.md\`. RULES: -- You MUST use the \`write\` tool to create the file. Do NOT emit the spec as text in your response — it must be written via the tool. +- Return ONLY the markdown content for \`SPEC.md\`. +- Do NOT call tools. Do NOT print pseudo tool calls such as \`write(...)\`. - Ask the user 2-3 brief clarifying questions about what they're building, then write the spec. If the description is already clear, skip questions and write immediately. -- Do NOT run shell commands. Do NOT create other files. -- After calling \`write\`, confirm briefly in text ("wrote SPEC.md") and stop. +- Do NOT include commentary before or after the markdown. TARGET FILE (absolute path): ${ctx.scaffoldPath}/SPEC.md @@ -76,21 +70,22 @@ export const PRD_SKILL: Skill = { id: "prd", name: "PRD", inputPlaceholder: "Refine the task breakdown...", - buildPermission: buildPlanFilePermissions, + buildPermission: buildPlanChatPermissions, buildAutoPrompt: (ctx) => - `Read ${ctx.scaffoldPath}/SPEC.md and create a task breakdown. Write it to ${ctx.scaffoldPath}/prd.json using the write tool.`, + `Create a task breakdown for ${ctx.scaffoldPath}/prd.json from the SPEC.md content below.`, buildSystemPrompt: ( ctx, - ) => `You are a task planner. Your ONLY job is to read \`SPEC.md\` from the plan workspace and produce \`prd.json\` in the same workspace by calling the \`write\` tool. + ) => `You are a task planner. Your ONLY job is to produce the final JSON contents for \`prd.json\`. RULES: -- You MUST use the \`write\` tool to create the file. Do NOT emit the JSON as text in your response — it must be written via the tool. -- Do NOT run shell commands. Do NOT create other files. +- Return ONLY raw JSON matching the schema below. +- Do NOT call tools. Do NOT print pseudo tool calls such as \`write(...)\`. +- Use the SPEC.md content supplied in the user prompt. - Each task must be completable in a single agent session (~1-2 hours). - Every task MUST end with verification subtasks (tests, typecheck, lint). - No overlapping scope between tasks. - Order: setup → models → features → polish → integration tests. -- After calling \`write\`, confirm briefly in text ("wrote prd.json") and stop. +- Do NOT include commentary before or after the JSON. INPUT FILE (absolute path): ${ctx.scaffoldPath}/SPEC.md From 9da336f19d72a52703319c8e77beea01b092bf8b Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Mon, 27 Apr 2026 03:16:27 -0400 Subject: [PATCH 11/21] Redesign plan chat artifact generation --- apps/tui/src/components/command-palette.tsx | 2 +- apps/tui/src/components/help-overlay.tsx | 1 + apps/tui/src/components/plan-chat.tsx | 16 ++-- apps/tui/src/components/plan-view.tsx | 99 ++++++++++++++++----- apps/tui/src/components/spec-overlay.tsx | 50 +++++++++++ apps/tui/src/components/status-bar.tsx | 2 +- apps/tui/src/components/welcome-screen.tsx | 21 ++++- apps/tui/src/hooks/use-chat.ts | 56 ++++++++---- apps/tui/src/hooks/use-plan-files.ts | 4 + apps/tui/src/hooks/use-skill.ts | 11 ++- apps/tui/src/lib/plan-artifacts.test.ts | 19 ++++ apps/tui/src/lib/plan-artifacts.ts | 11 ++- apps/tui/src/skills.ts | 32 +++++-- 13 files changed, 261 insertions(+), 63 deletions(-) create mode 100644 apps/tui/src/components/spec-overlay.tsx diff --git a/apps/tui/src/components/command-palette.tsx b/apps/tui/src/components/command-palette.tsx index 1692d4f..5addeab 100644 --- a/apps/tui/src/components/command-palette.tsx +++ b/apps/tui/src/components/command-palette.tsx @@ -6,7 +6,7 @@ export interface SlashCommand { } export const SLASH_COMMANDS: SlashCommand[] = [ - { name: "/spec", description: "Write project spec (.ralph/SPEC.md)" }, + { name: "/spec", description: "Generate project spec (.ralph/SPEC.md)" }, { name: "/prd", description: "Create task breakdown (.ralph/prd.json)" }, { name: "/tasks", description: "Toggle task overlay" }, { name: "/clear", description: "Clear chat messages" }, diff --git a/apps/tui/src/components/help-overlay.tsx b/apps/tui/src/components/help-overlay.tsx index c0bcac0..146f974 100644 --- a/apps/tui/src/components/help-overlay.tsx +++ b/apps/tui/src/components/help-overlay.tsx @@ -19,6 +19,7 @@ const GENERAL_BINDINGS: KeyBinding[] = [ ]; const PLAN_BINDINGS: KeyBinding[] = [ + { keys: "Ctrl+S", desc: "Toggle SPEC.md viewer" }, { keys: "Ctrl+T", desc: "Toggle task list" }, { keys: "@", desc: "Insert file reference" }, { keys: "/", desc: "Open command palette" }, diff --git a/apps/tui/src/components/plan-chat.tsx b/apps/tui/src/components/plan-chat.tsx index 3bd44cd..4c061e1 100644 --- a/apps/tui/src/components/plan-chat.tsx +++ b/apps/tui/src/components/plan-chat.tsx @@ -5,7 +5,7 @@ import { useKeyboard } from "@opentui/react"; import { useMemo, useState } from "react"; import type { ChatMessage } from "../hooks/use-chat"; import { useFileSearch } from "../hooks/use-file-search"; -import type { Skill } from "../skills"; +import type { Skill, SkillId } from "../skills"; import { CommandPalette, filterCommands } from "./command-palette"; import { FILE_PICKER_VISIBLE_COUNT, FilePicker } from "./file-picker"; import { WelcomeScreen } from "./welcome-screen"; @@ -19,7 +19,7 @@ interface PlanChatProps { onSendPrompt: (prompt: string) => Promise; onToggleTasks: () => void; onClear: () => void; - onStartSkill: (id: "spec" | "prd") => void; + onStartSkill: (id: Extract) => void; skill: Skill | undefined; } @@ -123,7 +123,7 @@ export function PlanChat({ const executeCommand = (cmdName: string) => { if (cmdName === "/spec" || cmdName === "/prd") { - onStartSkill(cmdName.slice(1) as "spec" | "prd"); + onStartSkill(cmdName.slice(1) as Extract); setInputValue(""); return; } @@ -164,19 +164,15 @@ export function PlanChat({ return; } - if (!skill) return; - const prompt = buildPrompt(trimmed); setInputValue(""); setFileRefs([]); void onSendPrompt(prompt); }; - const placeholder = !skill - ? "Type /spec or /prd to start..." - : loading - ? "Waiting for response..." - : skill.inputPlaceholder; + const placeholder = loading + ? "Waiting for response..." + : (skill?.inputPlaceholder ?? "What do you want to build?"); return ( diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx index 5a7cdfc..022d9f1 100644 --- a/apps/tui/src/components/plan-view.tsx +++ b/apps/tui/src/components/plan-view.tsx @@ -10,6 +10,7 @@ import { writePrdArtifact, writeSpecArtifact } from "../lib/plan-artifacts"; import type { Skill, SkillContext } from "../skills"; import { ContextSidebar } from "./context-sidebar"; import { PlanChat } from "./plan-chat"; +import { SpecOverlay } from "./spec-overlay"; import { TaskOverlay } from "./task-overlay"; const SIDEBAR_MIN_WIDTH = 120; @@ -68,6 +69,7 @@ export function PlanView({ planInstance, }: PlanViewProps) { const [showTasks, setShowTasks] = useState(false); + const [showSpec, setShowSpec] = useState(false); const { activeSkill, skill, startSkill } = useSkill(); const { ensure: ensureInstance } = planInstance; const ensureInstanceId = useCallback( @@ -78,35 +80,44 @@ export function PlanView({ const { width } = useTerminalDimensions(); const showSidebar = width >= SIDEBAR_MIN_WIDTH; - const { hasSpec, hasPrd, tasks, specError, prdError } = planData; - const prevHasSpec = useRef(hasSpec); - const prevHasPrd = useRef(hasPrd); + const { hasSpec, hasPrd, specError, prdError } = planData; const prevLoading = useRef(chat.loading); + const brainstormHintShown = useRef(false); const { addSystemMessage, loading: chatLoading } = chat; useEffect(() => { - if (!prevHasSpec.current && hasSpec && activeSkill) { - addSystemMessage( - "wrote SPEC.md — type /prd to generate the task breakdown", - ); + if (!activeSkill) { + startSkill("brainstorm"); } - prevHasSpec.current = hasSpec; - }, [hasSpec, activeSkill, addSystemMessage]); + }, [activeSkill, startSkill]); useEffect(() => { - if (!prevHasPrd.current && hasPrd && activeSkill) { - const n = tasks.length; - addSystemMessage( - `wrote prd.json (${n} task${n === 1 ? "" : "s"}) — press Ctrl+T to review, then switch to Execute`, - ); + if ( + activeSkill !== "brainstorm" || + chatLoading || + brainstormHintShown.current + ) { + return; } - prevHasPrd.current = hasPrd; - }, [hasPrd, tasks.length, activeSkill, addSystemMessage]); + const hasAssistantReply = chat.messages.some( + (message) => message.role === "assistant" && message.content.trim(), + ); + if (!hasAssistantReply) return; + + brainstormHintShown.current = true; + addSystemMessage("Type /spec when you're ready to generate the spec."); + }, [activeSkill, chatLoading, chat.messages, addSystemMessage]); useEffect(() => { const wasLoading = prevLoading.current; prevLoading.current = chatLoading; - if (!wasLoading || chatLoading || !activeSkill) return; + if ( + !wasLoading || + chatLoading || + (activeSkill !== "spec" && activeSkill !== "prd") + ) { + return; + } const target = activeSkill; const timeoutId = setTimeout(() => { @@ -167,7 +178,6 @@ export function PlanView({ const handleStartSkill = async (id: "spec" | "prd") => { const s = startSkill(id); - chatClear(); if (!s.buildAutoPrompt) return; const { scaffoldPath } = await ensureInstance(); const ctx: SkillContext = { scaffoldPath }; @@ -178,16 +188,28 @@ export function PlanView({ addSystemMessage( `SPEC.md: ${e instanceof Error ? e.message : "failed to read file"}`, ); + startSkill("brainstorm"); return; } const result = await chatSend({ prompt, systemPrompt: s.buildSystemPrompt(ctx), permission: s.buildPermission(ctx), + displayAssistant: false, + displayUser: false, }); - if (!result?.content) return; try { + if (!result?.content) return; await writeSkillArtifact(s, ctx, result.content); + if (s.id === "spec") { + addSystemMessage( + "SPEC.md generated. Press Ctrl+S to view. Type /prd when ready.", + ); + } else { + addSystemMessage( + "prd.json generated. Press Ctrl+T to review tasks, then switch to Execute.", + ); + } } catch (e) { const filename = s.id === "spec" ? "SPEC.md" : "prd.json"; const reason = @@ -195,32 +217,55 @@ export function PlanView({ addSystemMessage( `${filename}: generated response was invalid (${reason})`, ); + } finally { + startSkill("brainstorm"); } }; useKeyboard((key) => { if (!focused) return; if (key.name === "t" && key.ctrl) { - setShowTasks((s) => !s); + setShowTasks((s) => { + const next = !s; + if (next) setShowSpec(false); + return next; + }); + } + if (key.name === "s" && key.ctrl) { + setShowSpec((s) => { + const next = !s; + if (next) setShowTasks(false); + return next; + }); } }); const toggleTasks = () => { - setShowTasks((s) => !s); + setShowTasks((s) => { + const next = !s; + if (next) setShowSpec(false); + return next; + }); + }; + + const handleClear = () => { + brainstormHintShown.current = false; + chatClear(); + startSkill("brainstorm"); }; return ( @@ -234,6 +279,14 @@ export function PlanView({ )} + {showSpec && ( + setShowSpec(false)} + /> + )} + {showTasks && planData.hasPrd && ( void; +} + +export function SpecOverlay({ focused, data, onClose }: SpecOverlayProps) { + const syntaxStyle = useMemo(() => SyntaxStyle.create(), []); + + useKeyboard((key) => { + if (!focused) return; + if (key.name === "escape" || (key.name === "s" && key.ctrl)) { + onClose(); + } + }); + + return ( + + + {data.hasSpec ? ( + + ) : ( + No SPEC.md generated yet + )} + + + + Esc/Ctrl+S: close + + + ); +} diff --git a/apps/tui/src/components/status-bar.tsx b/apps/tui/src/components/status-bar.tsx index 3619e05..096f411 100644 --- a/apps/tui/src/components/status-bar.tsx +++ b/apps/tui/src/components/status-bar.tsx @@ -7,7 +7,7 @@ interface StatusBarProps { } const HELP_BY_TAB: Record = { - 0: "Tab: tabs Ctrl+T: tasks /: commands ?: help", + 0: "Tab: tabs Ctrl+S: spec Ctrl+T: tasks /: commands ?: help", 1: "Tab: tabs j/k: select enter: chat r: refresh ?: help", 2: "Tab: tabs ?: help", }; diff --git a/apps/tui/src/components/welcome-screen.tsx b/apps/tui/src/components/welcome-screen.tsx index 494db38..c9cac2c 100644 --- a/apps/tui/src/components/welcome-screen.tsx +++ b/apps/tui/src/components/welcome-screen.tsx @@ -6,6 +6,25 @@ interface WelcomeScreenProps { } export function WelcomeScreen({ skill }: WelcomeScreenProps) { + if (skill?.id === "brainstorm") { + return ( + + Plan + + Describe what you want to build. + + + When ready, type /spec to generate the spec. + + + ); + } + if (skill) { return ( /spec - {" Write project spec (.ralph/SPEC.md)"} + {" Generate project spec (.ralph/SPEC.md)"} diff --git a/apps/tui/src/hooks/use-chat.ts b/apps/tui/src/hooks/use-chat.ts index 7e75b87..49ce6a9 100644 --- a/apps/tui/src/hooks/use-chat.ts +++ b/apps/tui/src/hooks/use-chat.ts @@ -12,6 +12,8 @@ export interface SendOptions { prompt: string; systemPrompt: string; permission?: PermissionRule[]; + displayAssistant?: boolean; + displayUser?: boolean; } export interface SendResult { @@ -57,10 +59,18 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { ); const send = useCallback( - async ({ prompt, systemPrompt, permission }: SendOptions) => { + async ({ + prompt, + systemPrompt, + permission, + displayAssistant = true, + displayUser = true, + }: SendOptions) => { if (loading) return undefined; - setMessages((prev) => [...prev, { role: "user", content: prompt }]); + if (displayUser) { + setMessages((prev) => [...prev, { role: "user", content: prompt }]); + } setLoading(true); setError(undefined); cancelledRef.current = false; @@ -82,7 +92,9 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { }, }); - setMessages((prev) => [...prev, { role: "assistant", content: "" }]); + if (displayAssistant) { + setMessages((prev) => [...prev, { role: "assistant", content: "" }]); + } let content = ""; for await (const event of events) { @@ -90,10 +102,14 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { if (event.type === "snapshot") { content = event.text; - updateLastMessage(() => ({ role: "assistant", content })); + if (displayAssistant) { + updateLastMessage(() => ({ role: "assistant", content })); + } } else if (event.type === "delta" && event.field === "text") { content += event.delta; - updateLastMessage(() => ({ role: "assistant", content })); + if (displayAssistant) { + updateLastMessage(() => ({ role: "assistant", content })); + } } else if (event.type === "done") { if (event.job.sessionId) { sessionIdRef.current = event.job.sessionId; @@ -101,10 +117,12 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { if (event.job.state === "failed") { const message = event.job.error || "Job failed"; setError(message); - updateLastMessage(() => ({ - role: "system", - content: `Error: ${message}`, - })); + if (displayAssistant) { + updateLastMessage(() => ({ + role: "system", + content: `Error: ${message}`, + })); + } return undefined; } if (event.job.state !== "succeeded") { @@ -113,18 +131,22 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { let final = content.trim(); if (!content.trim()) { final = event.job.outputText?.trim() || ""; - updateLastMessage(() => ({ - role: "assistant", - content: final || "(empty response)", - })); + if (displayAssistant) { + updateLastMessage(() => ({ + role: "assistant", + content: final || "(empty response)", + })); + } } return { content: final, job: event.job }; } else if (event.type === "error") { setError(event.error); - updateLastMessage(() => ({ - role: "system", - content: `Error: ${event.error}`, - })); + if (displayAssistant) { + updateLastMessage(() => ({ + role: "system", + content: `Error: ${event.error}`, + })); + } return undefined; } } diff --git a/apps/tui/src/hooks/use-plan-files.ts b/apps/tui/src/hooks/use-plan-files.ts index d927f30..f8db45e 100644 --- a/apps/tui/src/hooks/use-plan-files.ts +++ b/apps/tui/src/hooks/use-plan-files.ts @@ -8,6 +8,7 @@ export type { PrdTask } from "../lib/plan-validation"; export interface PlanFilesData { tasks: PrdTask[]; progress: string; + specContent: string; hasSpec: boolean; hasPrd: boolean; specError?: string; @@ -37,6 +38,7 @@ export function usePlanFiles(scaffoldPath: string | null): UsePlanFilesReturn { const [data, setData] = useState({ tasks: [], progress: "", + specContent: "", hasSpec: false, hasPrd: false, }); @@ -49,6 +51,7 @@ export function usePlanFiles(scaffoldPath: string | null): UsePlanFilesReturn { setData({ tasks: [], progress: "", + specContent: "", hasSpec: false, hasPrd: false, }); @@ -67,6 +70,7 @@ export function usePlanFiles(scaffoldPath: string | null): UsePlanFilesReturn { setData({ tasks: prdResult.tasks, progress: progressContent ?? "", + specContent: specContent ?? "", hasSpec: specContent !== null && specResult.valid, hasPrd: prdContent !== null && !prdResult.error, specError: diff --git a/apps/tui/src/hooks/use-skill.ts b/apps/tui/src/hooks/use-skill.ts index 51ef612..d8f15f0 100644 --- a/apps/tui/src/hooks/use-skill.ts +++ b/apps/tui/src/hooks/use-skill.ts @@ -1,10 +1,15 @@ import { useCallback, useState } from "react"; -import { type ActiveSkill, getSkill, type Skill } from "../skills"; +import { + type ActiveSkill, + getSkill, + type Skill, + type SkillId, +} from "../skills"; interface UseSkillReturn { activeSkill: ActiveSkill; skill: Skill | undefined; - startSkill: (id: "spec" | "prd") => Skill; + startSkill: (id: SkillId) => Skill; clearSkill: () => void; } @@ -13,7 +18,7 @@ export function useSkill(): UseSkillReturn { const skill = activeSkill ? getSkill(activeSkill) : undefined; - const startSkill = useCallback((id: "spec" | "prd"): Skill => { + const startSkill = useCallback((id: SkillId): Skill => { const s = getSkill(id); if (!s) throw new Error(`Unknown skill: ${id}`); setActiveSkill(id); diff --git a/apps/tui/src/lib/plan-artifacts.test.ts b/apps/tui/src/lib/plan-artifacts.test.ts index ae1f912..d092d5b 100644 --- a/apps/tui/src/lib/plan-artifacts.test.ts +++ b/apps/tui/src/lib/plan-artifacts.test.ts @@ -59,6 +59,25 @@ Build a browser todo app that lets one person add, complete, and delete tasks wh ).rejects.toThrow("printed a tool call"); }); + it("accepts a single fenced markdown SPEC block", async () => { + const scaffold = await tempScaffold(); + const spec = `# Todo App + +> A small task manager for personal use. + +## Overview +Build a browser todo app that lets one person add, complete, and delete tasks while keeping state locally. + +## Scope +- Add tasks +- Complete tasks +- Delete tasks`; + + await writeSpecArtifact(scaffold, ["```markdown", spec, "```"].join("\n")); + + expect(await readFile(join(scaffold, "SPEC.md"), "utf8")).toBe(`${spec}\n`); + }); + it("writes valid PRD JSON and normalizes formatting", async () => { const scaffold = await tempScaffold(); const prd = `{"tasks":[{"description":"Build task storage","subtasks":["Create storage helper","Run tests"]}]}`; diff --git a/apps/tui/src/lib/plan-artifacts.ts b/apps/tui/src/lib/plan-artifacts.ts index 040db90..54f53ee 100644 --- a/apps/tui/src/lib/plan-artifacts.ts +++ b/apps/tui/src/lib/plan-artifacts.ts @@ -25,6 +25,15 @@ function extractPrdJson(content: string): string { throw new Error("response must be raw JSON or a single fenced json block"); } +function extractSpecMarkdown(content: string): string { + const trimmed = normalizeModelOutput(content); + const fenced = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i); + if (fenced?.[1]) { + return fenced[1].trim(); + } + return trimmed; +} + async function writeArtifact(path: string, content: string): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile(path, content, "utf8"); @@ -34,7 +43,7 @@ export async function writeSpecArtifact( scaffoldPath: string, content: string, ): Promise { - const spec = normalizeModelOutput(content); + const spec = extractSpecMarkdown(content); const validation = validateSpec(spec); if (!validation.valid) { throw new Error(validation.error ?? "invalid SPEC.md"); diff --git a/apps/tui/src/skills.ts b/apps/tui/src/skills.ts index 67ebae7..37e1c98 100644 --- a/apps/tui/src/skills.ts +++ b/apps/tui/src/skills.ts @@ -4,8 +4,11 @@ export interface SkillContext { scaffoldPath: string; } +export type SkillId = "brainstorm" | "spec" | "prd"; +export type ActiveSkill = SkillId | null; + export interface Skill { - id: string; + id: SkillId; name: string; inputPlaceholder: string; buildSystemPrompt: (ctx: SkillContext) => string; @@ -13,8 +16,6 @@ export interface Skill { buildAutoPrompt?: (ctx: SkillContext) => string; } -export type ActiveSkill = "spec" | "prd" | null; - function buildPlanChatPermissions(_ctx: SkillContext): PermissionRule[] { return [ { permission: "question", pattern: "*", action: "deny" }, @@ -22,6 +23,22 @@ function buildPlanChatPermissions(_ctx: SkillContext): PermissionRule[] { ]; } +export const BRAINSTORM_SKILL: Skill = { + id: "brainstorm", + name: "Plan", + inputPlaceholder: "What do you want to build?", + buildPermission: buildPlanChatPermissions, + buildSystemPrompt: () => `You are a project planning partner. + +RULES: +- Help the user refine what they want to build through natural conversation. +- Ask concise clarifying questions when useful. +- Offer concrete product, scope, architecture, and implementation tradeoffs. +- Do NOT create or modify files. +- Do NOT call tools. Do NOT print pseudo tool calls such as \`write(...)\`. +- When the idea is clear enough, mention that the user can type \`/spec\` to generate the project spec.`, +}; + export const SPEC_SKILL: Skill = { id: "spec", name: "Spec", @@ -34,7 +51,7 @@ export const SPEC_SKILL: Skill = { RULES: - Return ONLY the markdown content for \`SPEC.md\`. - Do NOT call tools. Do NOT print pseudo tool calls such as \`write(...)\`. -- Ask the user 2-3 brief clarifying questions about what they're building, then write the spec. If the description is already clear, skip questions and write immediately. +- Use the conversation context above. Do NOT ask more questions — generate immediately. - Do NOT include commentary before or after the markdown. TARGET FILE (absolute path): @@ -64,6 +81,8 @@ SPEC.md TEMPLATE: ## Constraints - [Non-functional requirements that guide decisions]`, + buildAutoPrompt: () => + "Based on our conversation, generate final SPEC.md. Return markdown only.", }; export const PRD_SKILL: Skill = { @@ -116,11 +135,12 @@ SUBTASK SPECIFICITY: - BAD: "Create user model"`, }; -const SKILLS: Record = { +const SKILLS: Record = { + brainstorm: BRAINSTORM_SKILL, spec: SPEC_SKILL, prd: PRD_SKILL, }; -export function getSkill(id: string): Skill | undefined { +export function getSkill(id: SkillId): Skill | undefined { return SKILLS[id]; } From ae4aaecfb0fe5a16b0c2a0b76ad9487352deccdb Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Mon, 27 Apr 2026 17:30:50 -0400 Subject: [PATCH 12/21] Make plan overlays opaque --- apps/tui/src/components/spec-overlay.tsx | 2 ++ apps/tui/src/components/task-overlay.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/tui/src/components/spec-overlay.tsx b/apps/tui/src/components/spec-overlay.tsx index 23994f3..efa3598 100644 --- a/apps/tui/src/components/spec-overlay.tsx +++ b/apps/tui/src/components/spec-overlay.tsx @@ -25,7 +25,9 @@ export function SpecOverlay({ focused, data, onClose }: SpecOverlayProps) { right={1} top={0} bottom={3} + zIndex={10} width="50%" + backgroundColor="black" border={true} borderStyle="rounded" borderColor="#555555" diff --git a/apps/tui/src/components/task-overlay.tsx b/apps/tui/src/components/task-overlay.tsx index a7bc7cf..8d138e4 100644 --- a/apps/tui/src/components/task-overlay.tsx +++ b/apps/tui/src/components/task-overlay.tsx @@ -39,7 +39,9 @@ export function TaskOverlay({ focused, data, onClose }: TaskOverlayProps) { right={1} top={0} bottom={3} + zIndex={10} width="40%" + backgroundColor="black" border={true} borderStyle="rounded" borderColor="#555555" From e8ba37655793a0947cb67608987de89248f8f6e8 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Mon, 27 Apr 2026 17:34:13 -0400 Subject: [PATCH 13/21] Fix task overlay shortcut handling --- apps/tui/src/components/plan-chat.tsx | 3 -- apps/tui/src/components/plan-view.tsx | 2 +- apps/tui/src/components/task-overlay.tsx | 38 ++++++++++++++---------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/apps/tui/src/components/plan-chat.tsx b/apps/tui/src/components/plan-chat.tsx index 4c061e1..8e0db48 100644 --- a/apps/tui/src/components/plan-chat.tsx +++ b/apps/tui/src/components/plan-chat.tsx @@ -67,9 +67,6 @@ export function PlanChat({ useKeyboard((key) => { if (!focused) return; - if (key.name === "t" && key.ctrl) { - onToggleTasks(); - } if (key.name === "tab" && showCommandPalette && !showFilePicker) { const idx = Math.min(pickerIndex, filteredCommands.length - 1); const cmd = filteredCommands[idx]; diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx index 022d9f1..f94a5ee 100644 --- a/apps/tui/src/components/plan-view.tsx +++ b/apps/tui/src/components/plan-view.tsx @@ -287,7 +287,7 @@ export function PlanView({ /> )} - {showTasks && planData.hasPrd && ( + {showTasks && ( { if (!focused) return; - if (key.name === "escape" || (key.name === "t" && key.ctrl)) { + if (key.name === "escape") { onClose(); return; } @@ -51,22 +51,28 @@ export function TaskOverlay({ focused, data, onClose }: TaskOverlayProps) { padding={1} > - {tasks.map((task: PrdTask, index: number) => { - const isSelected = focused && index === selectedIndex; - const icon = task.passed ? "✓" : "○"; + {tasks.length > 0 ? ( + tasks.map((task: PrdTask, index: number) => { + const isSelected = focused && index === selectedIndex; + const icon = task.passed ? "✓" : "○"; - return ( - - {`${icon} `} - - {task.description} - - - ); - })} + return ( + + {`${icon} `} + + {task.description} + + + ); + }) + ) : ( + No prd.json generated yet + )} From e66e0a8c3812acbd5e1688ba96435f359a173a77 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Mon, 27 Apr 2026 17:42:16 -0400 Subject: [PATCH 14/21] feat(tui): rename assistant to Ralph, increase no-info timeout Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/components/plan-chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tui/src/components/plan-chat.tsx b/apps/tui/src/components/plan-chat.tsx index 8e0db48..5c7f5d0 100644 --- a/apps/tui/src/components/plan-chat.tsx +++ b/apps/tui/src/components/plan-chat.tsx @@ -202,7 +202,7 @@ export function PlanChat({ fg={msg.role === "user" ? "brightWhite" : "cyan"} attributes={TextAttributes.BOLD} > - {msg.role === "user" ? "You" : "Assistant"} + {msg.role === "user" ? "You" : "Ralph"} {msg.role === "assistant" ? ( From f59d4e69e1c4086b7be7e7d35af8342ec0389ff1 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Mon, 27 Apr 2026 17:59:26 -0400 Subject: [PATCH 15/21] fix: resolve rebase conflicts with main's sqlite migration Merge idle waiters, null-safe response handling, and permission support with main's StateStore/markJobTerminal persistence pattern. Fix streaming test hangs by checking accumulated outputText before entering idle wait. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/components/app.tsx | 4 +- packages/daemon/src/opencode.ts | 71 ++++++++++++++------------------- packages/daemon/src/server.ts | 30 +++++++------- 3 files changed, 50 insertions(+), 55 deletions(-) diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 14ed19f..181df77 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -20,6 +20,7 @@ type FocusZone = "tabs" | "content"; interface ActiveChat { instanceId: string; instanceName: string; + sessionId: string | null; } const TAB_OPTIONS = [ @@ -101,6 +102,7 @@ export function App({ onQuit }: AppProps) { setActiveChat(null)} onQuit={onQuit} /> @@ -149,7 +151,7 @@ export function App({ onQuit }: AppProps) { focused={contentFocused && activeTab === 1} planData={planFiles.data} onOpenChat={(instanceId, instanceName) => - setActiveChat({ instanceId, instanceName }) + setActiveChat({ instanceId, instanceName, sessionId: null }) } /> diff --git a/packages/daemon/src/opencode.ts b/packages/daemon/src/opencode.ts index ab4626c..fdd7906 100644 --- a/packages/daemon/src/opencode.ts +++ b/packages/daemon/src/opencode.ts @@ -29,7 +29,7 @@ export interface OpencodeSessionClient { system?: string; variant?: string; parts?: Array; - }): Promise<{ info: AssistantMessage; parts: Part[] }>; + }): Promise<{ info?: AssistantMessage; parts?: Part[] }>; abort(parameters: { sessionID: string; directory?: string; @@ -201,45 +201,36 @@ export class OpencodeRegistry implements OpencodeRuntimeManager { client.session.abort(parameters, { throwOnError: true, }), - }, - provider: { - list: async (parameters) => { - const response = await client.provider.list(parameters, { - throwOnError: true, - }); - return { - providers: response.data.all.map((p) => ({ - id: p.id, - name: p.name, - models: Object.fromEntries( - Object.entries(p.models).map(([k, rawModel]) => { - const m = rawModel as RawProviderModel; - return [ - k, - { - id: m.id, - name: m.name, - family: m.family, - attachment: - m.attachment ?? m.capabilities?.attachment, - reasoning: m.reasoning ?? m.capabilities?.reasoning, - tool_call: m.tool_call ?? m.capabilities?.toolcall, - }, - ]; - }), - ), - })), - connected: response.data.connected, - }; - }, - }, - async ping() { - try { - await client.path.get({}, { throwOnError: true }); - return true; - } catch { - return false; - } + }, + provider: { + list: async (parameters) => { + const response = await client.provider.list(parameters, { + throwOnError: true, + }); + return { + providers: response.data.all.map((p) => ({ + id: p.id, + name: p.name, + models: Object.fromEntries( + Object.entries(p.models).map(([k, rawModel]) => { + const m = rawModel as RawProviderModel; + return [ + k, + { + id: m.id, + name: m.name, + family: m.family, + attachment: + m.attachment ?? m.capabilities?.attachment, + reasoning: m.reasoning ?? m.capabilities?.reasoning, + tool_call: m.tool_call ?? m.capabilities?.toolcall, + }, + ]; + }), + ), + })), + connected: response.data.connected, + }; }, }, async ping() { diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 3127297..91acc6a 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -208,7 +208,7 @@ export class Daemon { private readonly cancelWaitTimeoutMs: number; private readonly sessionIdleWaiters = new Map void>(); private readonly sessionErrors = new Map(); - private readonly pendingPermissions = new Map>(); + private readonly pendingPermissions = new Map>(); constructor( private readonly store: StateStore, @@ -470,21 +470,21 @@ export class Daemon { } const { instanceId } = request.params; - this.store.assertInstance(this.state, instanceId); + this.store.assertInstance(instanceId); - const now = new Date().toISOString(); - const job: DaemonJob = { - id: randomUUID(), + const job = this.store.createJob({ instanceId, session: request.params.session, task: request.params.task, - state: "queued", - createdAt: now, - updatedAt: now, - }; - this.state = this.store.upsertJob(this.state, job); - this.enqueue(job); - await this.store.save(this.state); + }); + if ( + request.params.session.type === "new" && + request.params.session.permission + ) { + this.pendingPermissions.set(job.id, request.params.session.permission); + } + this.enqueueById(instanceId, job.id); + this.scheduleDrain(); return { job }; } @@ -816,7 +816,9 @@ export class Daemon { if (!current?.outputText || current.outputText.length === 0) { patch.outputText = finalText; } - if (!patch.outputText || patch.outputText.length === 0) { + const hasOutput = (patch.outputText && patch.outputText.length > 0) || + (current?.outputText && current.outputText.length > 0); + if (!hasOutput) { log("prompt sent, awaiting idle"); try { await Promise.race([ @@ -852,7 +854,7 @@ export class Daemon { patch.error = controller.signal.aborted ? "Job cancelled" : sessionError; - } else if (!patch.outputText?.trim() && !current?.outputText?.trim()) { + } else if (!hasOutput) { terminalState = controller.signal.aborted ? "cancelled" : "failed"; From 9255671baa312095e1c1cf7573269202812da1a5 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Mon, 27 Apr 2026 18:01:36 -0400 Subject: [PATCH 16/21] style: fix biome formatting Co-Authored-By: Claude Opus 4.7 --- packages/daemon/src/server.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 91acc6a..4f3fe93 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -208,7 +208,14 @@ export class Daemon { private readonly cancelWaitTimeoutMs: number; private readonly sessionIdleWaiters = new Map void>(); private readonly sessionErrors = new Map(); - private readonly pendingPermissions = new Map>(); + private readonly pendingPermissions = new Map< + string, + Array<{ + permission: string; + pattern: string; + action: "allow" | "deny" | "ask"; + }> + >(); constructor( private readonly store: StateStore, @@ -816,7 +823,8 @@ export class Daemon { if (!current?.outputText || current.outputText.length === 0) { patch.outputText = finalText; } - const hasOutput = (patch.outputText && patch.outputText.length > 0) || + const hasOutput = + (patch.outputText && patch.outputText.length > 0) || (current?.outputText && current.outputText.length > 0); if (!hasOutput) { log("prompt sent, awaiting idle"); @@ -848,16 +856,12 @@ export class Daemon { } const sessionError = this.sessionErrors.get(sessionId); if (sessionError) { - terminalState = controller.signal.aborted - ? "cancelled" - : "failed"; + terminalState = controller.signal.aborted ? "cancelled" : "failed"; patch.error = controller.signal.aborted ? "Job cancelled" : sessionError; } else if (!hasOutput) { - terminalState = controller.signal.aborted - ? "cancelled" - : "failed"; + terminalState = controller.signal.aborted ? "cancelled" : "failed"; patch.error = controller.signal.aborted ? "Job cancelled" : "OpenCode returned no response. Check provider credentials and model availability."; From 4007ae518dda17d8379e73bd0b02772fe3fdd52e Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Tue, 28 Apr 2026 03:26:29 -0400 Subject: [PATCH 17/21] Implement project-store execution loop --- apps/tui/src/components/app.tsx | 2 + apps/tui/src/components/execute-view.tsx | 211 ++++++-- apps/tui/src/components/help-overlay.tsx | 4 +- apps/tui/src/components/status-bar.tsx | 2 +- apps/tui/src/hooks/use-plan-files.ts | 2 +- apps/tui/src/hooks/use-plan-instance.ts | 59 +- apps/tui/src/lib/execution-loop.test.ts | 318 +++++++++++ apps/tui/src/lib/execution-loop.ts | 661 +++++++++++++++++++++++ apps/tui/src/lib/project-store.test.ts | 143 +++++ apps/tui/src/lib/project-store.ts | 275 ++++++++++ 10 files changed, 1616 insertions(+), 61 deletions(-) create mode 100644 apps/tui/src/lib/execution-loop.test.ts create mode 100644 apps/tui/src/lib/execution-loop.ts create mode 100644 apps/tui/src/lib/project-store.test.ts create mode 100644 apps/tui/src/lib/project-store.ts diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 181df77..dd2bd02 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -150,6 +150,8 @@ export function App({ onQuit }: AppProps) { setActiveChat({ instanceId, instanceName, sessionId: null }) } diff --git a/apps/tui/src/components/execute-view.tsx b/apps/tui/src/components/execute-view.tsx index 05ece37..848ae07 100644 --- a/apps/tui/src/components/execute-view.tsx +++ b/apps/tui/src/components/execute-view.tsx @@ -6,8 +6,18 @@ import type { ManagedInstance, } from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { PlanFilesData } from "../hooks/use-plan-files"; +import type { usePlanInstance } from "../hooks/use-plan-instance"; +import { + advanceExecutionLoop, + getActiveAttempt, + type LoopState, + loadLoopState, + markActiveAttemptCancelled, + markLoopPaused, +} from "../lib/execution-loop"; +import { buildProjectStorePaths } from "../lib/project-store"; interface DashboardData { health: HealthResult; @@ -18,6 +28,8 @@ interface DashboardData { interface ExecuteViewProps { focused: boolean; planData: PlanFilesData; + planInstance: ReturnType; + onPlanRefresh: () => Promise; onOpenChat: (instanceId: string, instanceName: string) => void; } @@ -55,9 +67,23 @@ function jobStateColor(state: string): string { return "#888888"; } +function loopStatusColor(status?: string): string { + if (status === "running") return "cyan"; + if (status === "completed") return "green"; + if (status === "needs_attention") return "yellow"; + if (status === "paused") return "#aaaaaa"; + return "#888888"; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export function ExecuteView({ focused, planData, + planInstance, + onPlanRefresh, onOpenChat, }: ExecuteViewProps) { const [loading, setLoading] = useState(true); @@ -66,22 +92,31 @@ export function ExecuteView({ const [selectedIndex, setSelectedIndex] = useState(0); const [starting, setStarting] = useState(false); const [startMessage, setStartMessage] = useState(); + const [loopState, setLoopState] = useState(); + const loopRunningRef = useRef(false); + const { ensure } = planInstance; const refresh = useCallback( async (nextIndex = selectedIndex) => { setLoading(true); setError(undefined); try { + const handle = await ensure(); const [health, instanceList] = await Promise.all([ daemon.health(), daemon.listInstances(), ]); const safeIndex = clampIndex(nextIndex, instanceList.instances.length); const selected = instanceList.instances[safeIndex]; - const jobs = await daemon.listJobs( + const jobsPromise = daemon.listJobs( selected ? { instanceId: selected.id } : {}, ); + const [jobs, state] = await Promise.all([ + jobsPromise, + loadLoopState(buildProjectStorePaths(handle.projectRoot)), + ]); setSelectedIndex(safeIndex); + setLoopState(state); setData({ health, instances: instanceList.instances, @@ -97,7 +132,7 @@ export function ExecuteView({ setLoading(false); } }, - [selectedIndex], + [selectedIndex, ensure], ); useEffect(() => { @@ -105,73 +140,130 @@ export function ExecuteView({ }, [refresh]); const handleStart = useCallback(async () => { + if (loopRunningRef.current) { + setStartMessage("Loop already running"); + return; + } if (starting || !planData.hasPrd || planData.tasks.length === 0) return; setStarting(true); + loopRunningRef.current = true; setStartMessage(undefined); setError(undefined); try { - const pendingTasks = planData.tasks.filter((t) => !t.passed); - if (pendingTasks.length === 0) { - throw new Error("All tasks are already completed"); - } - const task = pendingTasks[0] as (typeof pendingTasks)[number]; - const lines = [ - task.description, - "", - "Subtasks:", - ...task.subtasks.map((s) => `- ${s}`), - ]; - if (task.notes) { - lines.push("", `Notes: ${task.notes}`); - } - const prompt = lines.join("\n"); - - const cwd = process.cwd(); - const { instances } = await daemon.listInstances(); - let instance = instances.find((i) => i.directory === cwd); - if (!instance) { - const created = await daemon.createInstance({ - name: "execute", - directory: cwd, + const handle = await ensure(); + const paths = buildProjectStorePaths(handle.projectRoot); + + while (loopRunningRef.current) { + const result = await advanceExecutionLoop({ + paths, + daemonClient: daemon, }); - instance = created.instance; - } + setLoopState(result.state); + setStartMessage(result.message); + await onPlanRefresh(); + await refresh(); - await daemon.submitJob({ - instanceId: instance.id, - session: { type: "new" }, - task: { - type: "prompt", - prompt, - }, - }); + if (result.action === "completed" || result.action === "paused") { + break; + } - setStartMessage("Job submitted"); - await refresh(); + if (result.action === "verified") { + continue; + } + + await sleep(result.action === "monitoring" ? 2000 : 1000); + } } catch (startError) { setError( startError instanceof Error ? startError.message : "Failed to start execution", ); + } finally { + loopRunningRef.current = false; + setStarting(false); + } + }, [ + ensure, + onPlanRefresh, + refresh, + starting, + planData.hasPrd, + planData.tasks.length, + ]); + + const handleCancel = useCallback(async () => { + setError(undefined); + try { + loopRunningRef.current = false; + const handle = await ensure(); + const paths = buildProjectStorePaths(handle.projectRoot); + const state = await loadLoopState(paths); + const active = getActiveAttempt(state); + if (active?.jobId) { + await daemon.cancelJob(active.jobId); + } + const next = await markActiveAttemptCancelled(paths); + setLoopState(next); + setStartMessage("Loop cancelled"); + await onPlanRefresh(); + await refresh(); + } catch (cancelError) { + setError( + cancelError instanceof Error + ? cancelError.message + : "Failed to cancel execution", + ); + } finally { + setStarting(false); + } + }, [ensure, onPlanRefresh, refresh]); + + const handlePause = useCallback(async () => { + setError(undefined); + try { + loopRunningRef.current = false; + const handle = await ensure(); + const paths = buildProjectStorePaths(handle.projectRoot); + const next = await markLoopPaused(paths); + setLoopState(next); + setStartMessage("Loop paused"); + await onPlanRefresh(); + await refresh(); + } catch (pauseError) { + setError( + pauseError instanceof Error + ? pauseError.message + : "Failed to pause execution", + ); } finally { setStarting(false); } - }, [refresh, starting, planData.hasPrd, planData.tasks]); + }, [ensure, onPlanRefresh, refresh]); useKeyboard((key) => { if (!focused) return; if (key.name === "r") { - void refresh(); + void Promise.all([refresh(), onPlanRefresh()]); return; } - if (key.name === "s" && planData.hasPrd && !starting) { + if (key.name === "s" && planData.hasPrd) { void handleStart(); return; } + if (key.name === "c") { + void handleCancel(); + return; + } + + if (key.name === "p") { + void handlePause(); + return; + } + if (!data) return; if (key.name === "return") { @@ -197,6 +289,12 @@ export function ExecuteView({ const selected = data?.instances[selectedIndex]; const planReady = planData.hasPrd && planData.tasks.length > 0; + const activeAttempt = loopState ? getActiveAttempt(loopState) : undefined; + const completedTasks = planData.tasks.filter((task) => task.passed).length; + const currentTaskLabel = + loopState?.currentTaskIndex !== undefined + ? `${loopState.currentTaskIndex + 1}/${planData.tasks.length}` + : `${completedTasks}/${planData.tasks.length}`; return ( @@ -215,14 +313,38 @@ export function ExecuteView({ + + + + {`Loop ${loopState?.status ?? "idle"}`} + + {planReady && ( + + {` task ${currentTaskLabel}`} + + )} + {activeAttempt?.jobId && ( + + {` job ${activeAttempt.jobId.slice(0, 8)}`} + + )} + + {startMessage && !error && {startMessage}} + {error && {error}} + + {loopState?.lastVerificationFailure && !error && ( + {loopState.lastVerificationFailure} + )} + + {starting ? ( - Starting execution... + Execution loop running... ) : planReady ? ( <> Plan ready - {" Press [s] to start execution"} + {" Press [s] start/resume, [p] pause, [c] cancel"} ) : ( @@ -230,9 +352,6 @@ export function ExecuteView({ Complete spec and prd in Plan view to enable execution )} - - {startMessage && !error && {startMessage}} - {error && {error}} diff --git a/apps/tui/src/components/help-overlay.tsx b/apps/tui/src/components/help-overlay.tsx index 146f974..82deb5c 100644 --- a/apps/tui/src/components/help-overlay.tsx +++ b/apps/tui/src/components/help-overlay.tsx @@ -33,7 +33,9 @@ const TASK_BINDINGS: KeyBinding[] = [ const EXECUTE_BINDINGS: KeyBinding[] = [ { keys: "j/k", desc: "Select instance" }, { keys: "Enter", desc: "Chat with instance" }, - { keys: "s", desc: "Start execution from plan" }, + { keys: "s", desc: "Start or resume execution loop" }, + { keys: "p", desc: "Pause loop monitoring" }, + { keys: "c", desc: "Cancel active loop job" }, { keys: "r", desc: "Refresh" }, ]; diff --git a/apps/tui/src/components/status-bar.tsx b/apps/tui/src/components/status-bar.tsx index 096f411..e28f5dd 100644 --- a/apps/tui/src/components/status-bar.tsx +++ b/apps/tui/src/components/status-bar.tsx @@ -8,7 +8,7 @@ interface StatusBarProps { const HELP_BY_TAB: Record = { 0: "Tab: tabs Ctrl+S: spec Ctrl+T: tasks /: commands ?: help", - 1: "Tab: tabs j/k: select enter: chat r: refresh ?: help", + 1: "Tab: tabs s: start/resume p: pause c: cancel r: refresh enter: chat ?: help", 2: "Tab: tabs ?: help", }; diff --git a/apps/tui/src/hooks/use-plan-files.ts b/apps/tui/src/hooks/use-plan-files.ts index f8db45e..f8979f4 100644 --- a/apps/tui/src/hooks/use-plan-files.ts +++ b/apps/tui/src/hooks/use-plan-files.ts @@ -19,7 +19,7 @@ interface UsePlanFilesReturn { data: PlanFilesData; loading: boolean; error: string | undefined; - refresh: () => void; + refresh: () => Promise; } function readFileAsync(path: string): Promise { diff --git a/apps/tui/src/hooks/use-plan-instance.ts b/apps/tui/src/hooks/use-plan-instance.ts index 766a59f..143f2fd 100644 --- a/apps/tui/src/hooks/use-plan-instance.ts +++ b/apps/tui/src/hooks/use-plan-instance.ts @@ -1,15 +1,23 @@ import { daemon } from "@techatnyu/ralphd"; -import { useCallback, useRef, useState } from "react"; -import { bootstrapInstanceScaffold } from "../lib/scaffold"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + createProjectSlug, + ensureProjectStore, + resolveProjectRoot, +} from "../lib/project-store"; export interface PlanInstanceHandle { instanceId: string; scaffoldPath: string; + projectRoot: string; + projectSlug: string; } interface UsePlanInstanceReturn { instanceId: string | null; scaffoldPath: string | null; + projectRoot: string | null; + projectSlug: string | null; loading: boolean; error: string | undefined; ensure: () => Promise; @@ -18,13 +26,15 @@ interface UsePlanInstanceReturn { export function usePlanInstance(): UsePlanInstanceReturn { const [instanceId, setInstanceId] = useState(null); const [scaffoldPath, setScaffoldPath] = useState(null); + const [projectRoot, setProjectRoot] = useState(null); + const [projectSlug, setProjectSlug] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const resolving = useRef | null>(null); const ensure = useCallback(async (): Promise => { - if (instanceId && scaffoldPath) { - return { instanceId, scaffoldPath }; + if (instanceId && scaffoldPath && projectRoot && projectSlug) { + return { instanceId, scaffoldPath, projectRoot, projectSlug }; } if (resolving.current) return resolving.current; @@ -32,18 +42,29 @@ export function usePlanInstance(): UsePlanInstanceReturn { setLoading(true); setError(undefined); try { - const cwd = process.cwd(); + const root = await resolveProjectRoot(process.cwd()); + const slug = createProjectSlug(root); const { instances } = await daemon.listInstances(); - const existing = instances.find((i) => i.directory === cwd); + const existing = instances.find((i) => i.directory === root); const id = existing ? existing.id - : (await daemon.createInstance({ name: "plan", directory: cwd })) + : (await daemon.createInstance({ name: slug, directory: root })) .instance.id; - const path = await bootstrapInstanceScaffold({ instanceId: id }); + const store = await ensureProjectStore({ + projectRoot: root, + legacyInstanceId: id, + }); setInstanceId(id); - setScaffoldPath(path); - return { instanceId: id, scaffoldPath: path }; + setScaffoldPath(store.storeDir); + setProjectRoot(store.projectRoot); + setProjectSlug(store.slug); + return { + instanceId: id, + scaffoldPath: store.storeDir, + projectRoot: store.projectRoot, + projectSlug: store.slug, + }; } catch (e) { const msg = e instanceof Error ? e.message : "Failed to resolve instance"; @@ -57,7 +78,21 @@ export function usePlanInstance(): UsePlanInstanceReturn { resolving.current = resolve(); return resolving.current; - }, [instanceId, scaffoldPath]); + }, [instanceId, scaffoldPath, projectRoot, projectSlug]); - return { instanceId, scaffoldPath, loading, error, ensure }; + useEffect(() => { + void ensure().catch(() => { + // The error state is set inside ensure; callers can retry explicitly. + }); + }, [ensure]); + + return { + instanceId, + scaffoldPath, + projectRoot, + projectSlug, + loading, + error, + ensure, + }; } diff --git a/apps/tui/src/lib/execution-loop.test.ts b/apps/tui/src/lib/execution-loop.test.ts new file mode 100644 index 0000000..30dbb32 --- /dev/null +++ b/apps/tui/src/lib/execution-loop.test.ts @@ -0,0 +1,318 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + DaemonJob, + JobSession, + JobTask, + ManagedInstance, +} from "@techatnyu/ralphd"; +import { + advanceExecutionLoop, + buildExecutionPrompt, + type ExecutionDaemon, + markActiveAttemptCancelled, + markLoopPaused, + TASK_COMPLETE_SENTINEL, + type VerificationSnapshot, + verifyTaskCompletion, +} from "./execution-loop"; +import type { PrdTask } from "./plan-validation"; +import { ensureProjectStore, type ProjectStorePaths } from "./project-store"; + +const TASK_0: PrdTask = { + description: "Set up project structure and HTML skeleton", + subtasks: ["Create index.html", "Add app container"], + notes: "", + passed: false, +}; + +const TASK_1: PrdTask = { + description: "Add task input and persistence", + subtasks: ["Create form", "Save tasks locally"], + notes: "", + passed: false, +}; + +const TASKS: PrdTask[] = [TASK_0, TASK_1]; + +class FakeDaemon implements ExecutionDaemon { + instances: ManagedInstance[] = []; + jobs: DaemonJob[] = []; + submittedPrompts: string[] = []; + + async listInstances(): Promise<{ instances: ManagedInstance[] }> { + return { instances: this.instances }; + } + + async createInstance(params: { + name: string; + directory: string; + maxConcurrency?: number; + }): Promise<{ instance: ManagedInstance }> { + const now = new Date().toISOString(); + const instance: ManagedInstance = { + id: `instance-${this.instances.length}`, + name: params.name, + directory: params.directory, + status: "running", + maxConcurrency: params.maxConcurrency ?? 1, + createdAt: now, + updatedAt: now, + }; + this.instances.push(instance); + return { instance }; + } + + async submitJob(params: { + instanceId: string; + session: JobSession; + task: JobTask; + }): Promise<{ job: DaemonJob }> { + const now = new Date().toISOString(); + const job: DaemonJob = { + id: `job-${this.jobs.length}`, + instanceId: params.instanceId, + sessionId: `session-${this.jobs.length}`, + task: params.task, + state: "running", + createdAt: now, + updatedAt: now, + startedAt: now, + }; + this.jobs.push(job); + this.submittedPrompts.push(params.task.prompt); + return { job }; + } + + async getJob(jobId: string): Promise<{ job: DaemonJob }> { + const job = this.jobs.find((candidate) => candidate.id === jobId); + if (!job) throw new Error(`missing job ${jobId}`); + return { job }; + } + + completeJob(jobId: string, outputText = TASK_COMPLETE_SENTINEL): void { + const index = this.jobs.findIndex((job) => job.id === jobId); + const job = this.jobs[index]; + if (!job) throw new Error(`missing job ${jobId}`); + this.jobs[index] = { + ...job, + state: "succeeded", + outputText, + endedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } +} + +describe("execution loop", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + async function createStore(): Promise { + const projectRoot = await tempDir("ralph-loop-project-"); + const ralphHome = await tempDir("ralph-loop-home-"); + await mkdir(projectRoot, { recursive: true }); + await writeFile(join(projectRoot, "package.json"), "{}", "utf8"); + const paths = await ensureProjectStore({ projectRoot, ralphHome }); + await writePrd(paths, TASKS); + await writeFile(paths.progressPath, "# Progress Log\n", "utf8"); + return paths; + } + + async function writePrd( + paths: ProjectStorePaths, + tasks: PrdTask[], + ): Promise { + await writeFile( + paths.prdPath, + `${JSON.stringify({ tasks }, null, "\t")}\n`, + "utf8", + ); + } + + async function appendProgress( + paths: ProjectStorePaths, + text: string, + ): Promise { + const current = await readFile(paths.progressPath, "utf8"); + await writeFile(paths.progressPath, `${current}\n${text}\n`, "utf8"); + } + + it("builds prompts with user-root artifact paths and the exact task index", async () => { + const paths = await createStore(); + const prompt = buildExecutionPrompt({ + paths, + task: TASK_1, + taskIndex: 1, + totalTasks: TASKS.length, + }); + + expect(prompt).toContain(paths.specPath); + expect(prompt).toContain(paths.prdPath); + expect(prompt).toContain(paths.progressPath); + expect(prompt).toContain("task index 1"); + expect(prompt).toContain("tasks[1].passed"); + }); + + it("verifies a completed task and reports contract failures", async () => { + const paths = await createStore(); + const before: VerificationSnapshot = { + progressLength: (await readFile(paths.progressPath, "utf8")).length, + gitRepository: false, + gitHead: null, + }; + const passedTasks = [{ ...TASK_0, passed: true }, TASK_1]; + await writePrd(paths, passedTasks); + await appendProgress(paths, `Task 1: ${TASK_0.description}`); + + const ok = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), + before, + }); + expect(ok).toEqual({ ok: true, errors: [] }); + + const missingSentinel = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", "done"), + before, + }); + expect(missingSentinel.errors).toContain( + `job output is missing ${TASK_COMPLETE_SENTINEL}`, + ); + + await writePrd(paths, TASKS); + const unchangedPrd = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), + before, + }); + expect(unchangedPrd.errors).toContain("tasks[0].passed is not true"); + + const unchangedProgress = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), + before: { + ...before, + progressLength: (await readFile(paths.progressPath, "utf8")).length, + }, + }); + expect(unchangedProgress.errors).toContain("progress.md was not appended"); + + const failedJob = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("failed", TASK_COMPLETE_SENTINEL), + before, + }); + expect(failedJob.errors).toContain("job ended as failed"); + + const missingCommit = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), + before: { ...before, gitRepository: true, gitHead: "abc" }, + readGitHead: async () => "abc", + }); + expect(missingCommit.errors).toContain( + "git HEAD did not advance during the attempt", + ); + }); + + it("runs pending tasks sequentially without resubmitting a verified task", async () => { + const paths = await createStore(); + const fakeDaemon = new FakeDaemon(); + + const first = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(first.action).toBe("submitted"); + expect(fakeDaemon.submittedPrompts).toHaveLength(1); + expect(fakeDaemon.submittedPrompts[0]).toContain("task index 0"); + + await writePrd(paths, [{ ...TASK_0, passed: true }, TASK_1]); + await appendProgress(paths, `Task 1: ${TASK_0.description}`); + fakeDaemon.completeJob("job-0"); + + const verified = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(verified.action).toBe("verified"); + expect(fakeDaemon.submittedPrompts).toHaveLength(1); + + const second = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(second.action).toBe("submitted"); + expect(fakeDaemon.submittedPrompts).toHaveLength(2); + expect(fakeDaemon.submittedPrompts[1]).toContain("task index 1"); + }); + + it("pauses without cancelling and cancellation leaves the task pending", async () => { + const paths = await createStore(); + const fakeDaemon = new FakeDaemon(); + + const submitted = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(submitted.action).toBe("submitted"); + + const paused = await markLoopPaused(paths); + expect(paused.status).toBe("paused"); + expect(paused.attempts[0]?.status).toBe("running"); + + const cancelled = await markActiveAttemptCancelled(paths); + expect(cancelled.status).toBe("paused"); + expect(cancelled.attempts[0]?.status).toBe("cancelled"); + + const resumed = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(resumed.action).toBe("submitted"); + expect(resumed.attempt?.taskIndex).toBe(0); + expect(fakeDaemon.submittedPrompts).toHaveLength(2); + }); +}); + +function makeJob(state: DaemonJob["state"], outputText?: string): DaemonJob { + const now = new Date().toISOString(); + return { + id: "job-test", + instanceId: "instance-test", + task: { type: "prompt", prompt: "test" }, + state, + createdAt: now, + updatedAt: now, + outputText, + }; +} diff --git a/apps/tui/src/lib/execution-loop.ts b/apps/tui/src/lib/execution-loop.ts new file mode 100644 index 0000000..2921e3d --- /dev/null +++ b/apps/tui/src/lib/execution-loop.ts @@ -0,0 +1,661 @@ +import { readFile, writeFile } from "node:fs/promises"; +import type { + DaemonJob, + JobSession, + JobTask, + ManagedInstance, +} from "@techatnyu/ralphd"; +import { z } from "zod"; +import type { PrdTask } from "../hooks/use-plan-files"; +import { parsePrd } from "./plan-validation"; +import type { ProjectStorePaths } from "./project-store"; + +export const TASK_COMPLETE_SENTINEL = "RALPH_TASK_COMPLETE"; + +const LoopStatusSchema = z.enum([ + "idle", + "running", + "paused", + "needs_attention", + "completed", +]); + +const AttemptStatusSchema = z.enum([ + "queued", + "running", + "succeeded", + "failed", + "cancelled", + "needs_attention", + "verified", +]); + +const VerificationSnapshotSchema = z.object({ + progressLength: z.number().int().nonnegative(), + gitRepository: z.boolean(), + gitHead: z.string().nullable(), +}); + +const TaskAttemptSchema = z.object({ + id: z.string().min(1), + taskIndex: z.number().int().nonnegative(), + taskDescription: z.string().min(1), + attemptNumber: z.number().int().positive(), + status: AttemptStatusSchema, + jobId: z.string().min(1).optional(), + sessionId: z.string().min(1).optional(), + submittedAt: z.string().optional(), + updatedAt: z.string(), + verifiedAt: z.string().optional(), + verificationErrors: z.array(z.string()).optional(), + before: VerificationSnapshotSchema.optional(), +}); + +const LoopStateSchema = z.object({ + version: z.literal(1), + projectRoot: z.string().min(1), + status: LoopStatusSchema, + currentTaskIndex: z.number().int().nonnegative().optional(), + attempts: z.array(TaskAttemptSchema), + lastVerificationFailure: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), + completedAt: z.string().optional(), +}); + +export type LoopStatus = z.infer; +export type AttemptStatus = z.infer; +export type VerificationSnapshot = z.infer; +export type TaskAttempt = z.infer; +export type LoopState = z.infer; + +export interface ExecutionDaemon { + listInstances(): Promise<{ instances: ManagedInstance[] }>; + createInstance(params: { + name: string; + directory: string; + maxConcurrency?: number; + }): Promise<{ instance: ManagedInstance }>; + submitJob(params: { + instanceId: string; + session: JobSession; + task: JobTask; + }): Promise<{ job: DaemonJob }>; + getJob(jobId: string): Promise<{ job: DaemonJob }>; +} + +export interface LoopAdvanceResult { + state: LoopState; + action: "submitted" | "monitoring" | "verified" | "paused" | "completed"; + message: string; + job?: DaemonJob; + attempt?: TaskAttempt; +} + +export interface VerifyTaskCompletionOptions { + paths: ProjectStorePaths; + taskIndex: number; + task: PrdTask; + job: DaemonJob; + before?: VerificationSnapshot; + readGitHead?: (projectRoot: string) => Promise; +} + +export interface VerificationResult { + ok: boolean; + errors: string[]; +} + +function isoNow(now: () => Date): string { + return now().toISOString(); +} + +function createLoopState(paths: ProjectStorePaths, now: () => Date): LoopState { + const timestamp = isoNow(now); + return { + version: 1, + projectRoot: paths.projectRoot, + status: "idle", + attempts: [], + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +async function readText(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return null; + } +} + +export async function loadLoopState( + paths: ProjectStorePaths, + now: () => Date = () => new Date(), +): Promise { + const raw = await readText(paths.loopPath); + if (!raw) { + return createLoopState(paths, now); + } + + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + throw new Error("loop.json: invalid JSON"); + } + + const parsed = LoopStateSchema.safeParse(json); + if (!parsed.success) { + throw new Error( + `loop.json: ${parsed.error.issues[0]?.message ?? "invalid"}`, + ); + } + + return parsed.data; +} + +export async function saveLoopState( + paths: ProjectStorePaths, + state: LoopState, +): Promise { + await writeFile( + paths.loopPath, + `${JSON.stringify(state, null, "\t")}\n`, + "utf8", + ); +} + +export async function readProjectTasks( + paths: ProjectStorePaths, +): Promise { + const prd = await readText(paths.prdPath); + const parsed = parsePrd(prd); + if (parsed.error) { + throw new Error(`prd.json: ${parsed.error}`); + } + return parsed.tasks; +} + +export function findNextPendingTaskIndex(tasks: PrdTask[]): number | null { + const index = tasks.findIndex((task) => !task.passed); + return index === -1 ? null : index; +} + +export function getActiveAttempt(state: LoopState): TaskAttempt | undefined { + return [...state.attempts] + .reverse() + .find((attempt) => + ["queued", "running", "succeeded", "failed"].includes(attempt.status), + ); +} + +function getAttemptCount(state: LoopState, taskIndex: number): number { + return state.attempts.filter((attempt) => attempt.taskIndex === taskIndex) + .length; +} + +function isTerminalJob(job: DaemonJob): boolean { + return ( + job.state === "succeeded" || + job.state === "failed" || + job.state === "cancelled" + ); +} + +function jobStateToAttemptStatus(job: DaemonJob): AttemptStatus { + if (job.state === "succeeded") return "succeeded"; + if (job.state === "failed") return "failed"; + if (job.state === "cancelled") return "cancelled"; + if (job.state === "running") return "running"; + return "queued"; +} + +async function runGit( + projectRoot: string, + args: string[], +): Promise { + try { + const proc = Bun.spawn(["git", ...args], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + if (exitCode !== 0) return null; + return stdout.trim(); + } catch { + return null; + } +} + +export async function isGitRepository(projectRoot: string): Promise { + return ( + (await runGit(projectRoot, ["rev-parse", "--is-inside-work-tree"])) === + "true" + ); +} + +export async function readGitHead(projectRoot: string): Promise { + return runGit(projectRoot, ["rev-parse", "HEAD"]); +} + +export async function captureVerificationSnapshot( + paths: ProjectStorePaths, +): Promise { + const progress = (await readText(paths.progressPath)) ?? ""; + const gitRepository = await isGitRepository(paths.projectRoot); + const gitHead = gitRepository ? await readGitHead(paths.projectRoot) : null; + return { + progressLength: progress.length, + gitRepository, + gitHead, + }; +} + +function progressMentionsTask( + progress: string, + taskIndex: number, + task: PrdTask, +): boolean { + const normalized = progress.toLowerCase(); + const description = task.description.trim().toLowerCase(); + const descriptionPrefix = description.slice( + 0, + Math.min(description.length, 80), + ); + return ( + normalized.includes(descriptionPrefix) || + normalized.includes(`task ${taskIndex}`) || + normalized.includes(`task ${taskIndex + 1}`) + ); +} + +export function buildExecutionPrompt({ + paths, + task, + taskIndex, + totalTasks, +}: { + paths: ProjectStorePaths; + task: PrdTask; + taskIndex: number; + totalTasks: number; +}): string { + const lines = [ + "You are Ralph's execution agent for one PRD task attempt.", + "", + "Project:", + `- Project root: ${paths.projectRoot}`, + `- Project store: ${paths.storeDir}`, + `- SPEC.md: ${paths.specPath}`, + `- prd.json: ${paths.prdPath}`, + `- progress.md: ${paths.progressPath}`, + `- loop.json: ${paths.loopPath}`, + "", + "Execution contract:", + `- Implement exactly task index ${taskIndex} (task ${taskIndex + 1} of ${totalTasks}).`, + "- Read SPEC.md, prd.json, and progress.md from the absolute paths above before editing.", + "- Do not work on later tasks unless they are strictly required by this task.", + "- Append a progress.md entry that references this task and summarizes the work.", + `- Set only tasks[${taskIndex}].passed to true in prd.json after the work is complete.`, + "- Preserve valid prd.json JSON with the existing tasks array shape.", + "- If the project root is a git repository, commit the project-root code changes before finishing.", + `- Print ${TASK_COMPLETE_SENTINEL} on its own line only when the task is truly complete.`, + "", + "Task:", + `Description: ${task.description}`, + "", + "Subtasks:", + ...task.subtasks.map((subtask) => `- ${subtask}`), + ]; + + if (task.notes?.trim()) { + lines.push("", `Notes: ${task.notes.trim()}`); + } + + return lines.join("\n"); +} + +export async function verifyTaskCompletion({ + paths, + taskIndex, + task, + job, + before, + readGitHead: readHead = readGitHead, +}: VerifyTaskCompletionOptions): Promise { + const errors: string[] = []; + + if (job.state !== "succeeded") { + errors.push(`job ended as ${job.state}`); + } + + if (!job.outputText?.includes(TASK_COMPLETE_SENTINEL)) { + errors.push(`job output is missing ${TASK_COMPLETE_SENTINEL}`); + } + + let tasks: PrdTask[] = []; + try { + tasks = await readProjectTasks(paths); + } catch (error) { + errors.push( + error instanceof Error ? error.message : "prd.json did not parse", + ); + } + + if (!tasks[taskIndex]?.passed) { + errors.push(`tasks[${taskIndex}].passed is not true`); + } + + const progress = (await readText(paths.progressPath)) ?? ""; + if (before && progress.length <= before.progressLength) { + errors.push("progress.md was not appended"); + } + if (!progressMentionsTask(progress, taskIndex, task)) { + errors.push("progress.md does not reference the task"); + } + + if (before?.gitRepository) { + const afterHead = await readHead(paths.projectRoot); + if (!afterHead) { + errors.push("git HEAD is missing after the attempt"); + } else if (afterHead === before.gitHead) { + errors.push("git HEAD did not advance during the attempt"); + } + } + + return { + ok: errors.length === 0, + errors, + }; +} + +async function ensureProjectInstance( + daemonClient: ExecutionDaemon, + paths: ProjectStorePaths, +): Promise { + const { instances } = await daemonClient.listInstances(); + const existing = instances.find( + (instance) => instance.directory === paths.projectRoot, + ); + if (existing) return existing; + return ( + await daemonClient.createInstance({ + name: paths.slug, + directory: paths.projectRoot, + maxConcurrency: 1, + }) + ).instance; +} + +function updateAttempt( + state: LoopState, + attemptId: string, + update: Partial, +): LoopState { + return { + ...state, + attempts: state.attempts.map((attempt) => + attempt.id === attemptId ? { ...attempt, ...update } : attempt, + ), + }; +} + +export async function advanceExecutionLoop({ + paths, + daemonClient, + now = () => new Date(), +}: { + paths: ProjectStorePaths; + daemonClient: ExecutionDaemon; + now?: () => Date; +}): Promise { + const timestamp = isoNow(now); + let state = await loadLoopState(paths, now); + + if (state.status === "needs_attention") { + return { + state, + action: "paused", + message: state.lastVerificationFailure ?? "Loop needs attention", + }; + } + + const tasks = await readProjectTasks(paths); + const active = getActiveAttempt(state); + + if (active?.jobId) { + const { job } = await daemonClient.getJob(active.jobId); + const status = jobStateToAttemptStatus(job); + state = updateAttempt(state, active.id, { + status, + sessionId: job.sessionId ?? active.sessionId, + updatedAt: timestamp, + }); + + if (!isTerminalJob(job)) { + state = { + ...state, + status: "running", + currentTaskIndex: active.taskIndex, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "monitoring", + message: `Monitoring job ${job.id.slice(0, 8)}`, + job, + attempt: state.attempts.find((attempt) => attempt.id === active.id), + }; + } + + if (job.state !== "succeeded") { + const message = job.error ?? `Job ended as ${job.state}`; + state = updateAttempt(state, active.id, { + status, + verificationErrors: [message], + updatedAt: timestamp, + }); + state = { + ...state, + status: job.state === "cancelled" ? "paused" : "needs_attention", + lastVerificationFailure: message, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "paused", + message, + job, + attempt: state.attempts.find((attempt) => attempt.id === active.id), + }; + } + + const task = tasks[active.taskIndex]; + if (!task) { + const message = `Task ${active.taskIndex} no longer exists in prd.json`; + state = updateAttempt(state, active.id, { + status: "needs_attention", + verificationErrors: [message], + updatedAt: timestamp, + }); + state = { + ...state, + status: "needs_attention", + lastVerificationFailure: message, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { state, action: "paused", message, job }; + } + + const verification = await verifyTaskCompletion({ + paths, + taskIndex: active.taskIndex, + task, + job, + before: active.before, + }); + if (!verification.ok) { + const message = verification.errors.join("; "); + state = updateAttempt(state, active.id, { + status: "needs_attention", + verificationErrors: verification.errors, + updatedAt: timestamp, + }); + state = { + ...state, + status: "needs_attention", + lastVerificationFailure: message, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "paused", + message, + job, + attempt: state.attempts.find((attempt) => attempt.id === active.id), + }; + } + + state = updateAttempt(state, active.id, { + status: "verified", + verificationErrors: [], + verifiedAt: timestamp, + updatedAt: timestamp, + }); + state = { + ...state, + status: "running", + lastVerificationFailure: undefined, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "verified", + message: `Verified task ${active.taskIndex + 1}`, + job, + attempt: state.attempts.find((attempt) => attempt.id === active.id), + }; + } + + const pendingIndex = findNextPendingTaskIndex(tasks); + if (pendingIndex === null) { + state = { + ...state, + status: "completed", + currentTaskIndex: undefined, + completedAt: timestamp, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { state, action: "completed", message: "All tasks completed" }; + } + + const task = tasks[pendingIndex]; + if (!task) { + throw new Error(`Task ${pendingIndex} is missing`); + } + + const instance = await ensureProjectInstance(daemonClient, paths); + const before = await captureVerificationSnapshot(paths); + const prompt = buildExecutionPrompt({ + paths, + task, + taskIndex: pendingIndex, + totalTasks: tasks.length, + }); + const { job } = await daemonClient.submitJob({ + instanceId: instance.id, + session: { + type: "new", + title: `Task ${pendingIndex + 1}: ${task.description.slice(0, 60)}`, + }, + task: { type: "prompt", prompt }, + }); + + const attempt: TaskAttempt = { + id: `${pendingIndex}-${Date.now().toString(36)}`, + taskIndex: pendingIndex, + taskDescription: task.description, + attemptNumber: getAttemptCount(state, pendingIndex) + 1, + status: jobStateToAttemptStatus(job), + jobId: job.id, + sessionId: job.sessionId, + submittedAt: timestamp, + updatedAt: timestamp, + before, + }; + + state = { + ...state, + status: "running", + currentTaskIndex: pendingIndex, + attempts: [...state.attempts, attempt], + lastVerificationFailure: undefined, + updatedAt: timestamp, + }; + await saveLoopState(paths, state); + return { + state, + action: "submitted", + message: `Submitted task ${pendingIndex + 1}`, + job, + attempt, + }; +} + +export async function markActiveAttemptCancelled( + paths: ProjectStorePaths, + now: () => Date = () => new Date(), +): Promise { + const timestamp = isoNow(now); + const state = await loadLoopState(paths, now); + const active = getActiveAttempt(state); + if (!active) { + const next = { + ...state, + status: "paused" as const, + updatedAt: timestamp, + }; + await saveLoopState(paths, next); + return next; + } + const next = updateAttempt(state, active.id, { + status: "cancelled", + updatedAt: timestamp, + verificationErrors: ["Cancelled by user"], + }); + const cancelled = { + ...next, + status: "paused" as const, + lastVerificationFailure: "Cancelled by user", + updatedAt: timestamp, + }; + await saveLoopState(paths, cancelled); + return cancelled; +} + +export async function markLoopPaused( + paths: ProjectStorePaths, + now: () => Date = () => new Date(), +): Promise { + const timestamp = isoNow(now); + const state = await loadLoopState(paths, now); + const paused = { + ...state, + status: "paused" as const, + updatedAt: timestamp, + }; + await saveLoopState(paths, paused); + return paused; +} diff --git a/apps/tui/src/lib/project-store.test.ts b/apps/tui/src/lib/project-store.test.ts new file mode 100644 index 0000000..a9961e4 --- /dev/null +++ b/apps/tui/src/lib/project-store.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + createProjectSlug, + ensureProjectStore, + resolveProjectRoot, + resolveProjectStore, +} from "./project-store"; + +const VALID_SPEC = `# Todo App + +This specification is intentionally long enough for the validator. It describes a small todo app with a task form, a task list, completion state, deletion, and simple persistent behavior for Ralph project-store migration tests. +`; + +describe("project store", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + it("resolves the project root by walking up to package.json", async () => { + const root = await tempDir("ralph-project-root-"); + const nested = join(root, "apps", "tui", "src"); + await mkdir(nested, { recursive: true }); + await writeFile(join(root, "package.json"), "{}", "utf8"); + + await expect(resolveProjectRoot(nested)).resolves.toBe(root); + }); + + it("falls back to cwd when no root marker exists", async () => { + const root = await tempDir("ralph-project-fallback-"); + const nested = join(root, "loose"); + await mkdir(nested, { recursive: true }); + + await expect(resolveProjectRoot(nested)).resolves.toBe(resolve(nested)); + }); + + it("builds a stable sanitized slug with a short root hash", () => { + const root = "/tmp/My Demo App!"; + const first = createProjectSlug(root); + const second = createProjectSlug(root); + + expect(first).toBe(second); + expect(first).toMatch(/^my-demo-app-[a-f0-9]{8}$/); + }); + + it("creates metadata and preserves createdAt across updates", async () => { + const projectRoot = await tempDir("ralph-project-metadata-"); + const ralphHome = await tempDir("ralph-home-metadata-"); + await writeFile(join(projectRoot, "package.json"), "{}", "utf8"); + + const first = await ensureProjectStore({ + projectRoot, + ralphHome, + now: () => new Date("2026-01-01T00:00:00.000Z"), + }); + const second = await ensureProjectStore({ + projectRoot, + ralphHome, + now: () => new Date("2026-01-02T00:00:00.000Z"), + }); + + expect(second.storeDir).toBe(first.storeDir); + const metadata = JSON.parse(await readFile(first.metadataPath, "utf8")); + expect(metadata.projectRoot).toBe(projectRoot); + expect(metadata.slug).toBe(createProjectSlug(projectRoot)); + expect(metadata.createdAt).toBe("2026-01-01T00:00:00.000Z"); + expect(metadata.updatedAt).toBe("2026-01-02T00:00:00.000Z"); + }); + + it("migrates valid legacy plan files without overwriting project-store files", async () => { + const projectRoot = await tempDir("ralph-project-migrate-"); + const ralphHome = await tempDir("ralph-home-migrate-"); + await writeFile(join(projectRoot, "package.json"), "{}", "utf8"); + + const legacyDir = join(ralphHome, "sessions", "instance-1", "plan"); + await mkdir(legacyDir, { recursive: true }); + await writeFile(join(legacyDir, "SPEC.md"), VALID_SPEC, "utf8"); + await writeFile( + join(legacyDir, "prd.json"), + JSON.stringify({ + tasks: [ + { + description: "Build the shell", + subtasks: ["Create index.html"], + passed: false, + }, + ], + }), + "utf8", + ); + await writeFile( + join(legacyDir, "progress.md"), + "# Progress Log\n\nLegacy", + "utf8", + ); + + const paths = await ensureProjectStore({ + projectRoot, + ralphHome, + legacyInstanceId: "instance-1", + }); + expect(await readFile(paths.specPath, "utf8")).toBe(VALID_SPEC); + expect(await readFile(paths.progressPath, "utf8")).toContain("Legacy"); + + await writeFile( + join(legacyDir, "SPEC.md"), + `${VALID_SPEC}\nchanged`, + "utf8", + ); + await ensureProjectStore({ + projectRoot, + ralphHome, + legacyInstanceId: "instance-1", + }); + expect(await readFile(paths.specPath, "utf8")).toBe(VALID_SPEC); + }); + + it("places canonical files under RALPH_HOME/projects/", async () => { + const projectRoot = await tempDir("ralph-project-paths-"); + const ralphHome = await tempDir("ralph-home-paths-"); + const paths = await resolveProjectStore({ projectRoot, ralphHome }); + + expect(paths.storeDir).toBe( + join(ralphHome, "projects", createProjectSlug(projectRoot)), + ); + expect(paths.prdPath).toBe(join(paths.storeDir, "prd.json")); + expect(paths.loopPath).toBe(join(paths.storeDir, "loop.json")); + }); +}); diff --git a/apps/tui/src/lib/project-store.ts b/apps/tui/src/lib/project-store.ts new file mode 100644 index 0000000..c70ccef --- /dev/null +++ b/apps/tui/src/lib/project-store.ts @@ -0,0 +1,275 @@ +import { createHash } from "node:crypto"; +import { + access, + copyFile, + mkdir, + readFile, + stat, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { resolveDaemonPaths } from "@techatnyu/ralphd"; +import { parsePrd, validateSpec } from "./plan-validation"; + +export interface ProjectStorePaths { + projectRoot: string; + slug: string; + storeDir: string; + specPath: string; + prdPath: string; + progressPath: string; + promptPath: string; + loopPath: string; + metadataPath: string; +} + +export interface ProjectStoreMetadata { + version: 1; + projectRoot: string; + projectName: string; + slug: string; + createdAt: string; + updatedAt: string; +} + +export interface ResolveProjectStoreOptions { + projectRoot?: string; + cwd?: string; + ralphHome?: string; +} + +export interface EnsureProjectStoreOptions extends ResolveProjectStoreOptions { + legacyInstanceId?: string; + now?: () => Date; +} + +const PROJECT_FILES = { + spec: "SPEC.md", + prd: "prd.json", + progress: "progress.md", + prompt: "PROMPT.md", + loop: "loop.json", + metadata: "metadata.json", +} as const; + +function getRalphHome(ralphHome?: string): string { + return resolve(ralphHome ?? resolveDaemonPaths().ralphHome); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function hasRootMarker(path: string): Promise { + const [git, packageJson] = await Promise.all([ + stat(join(path, ".git")) + .then(() => true) + .catch(() => false), + stat(join(path, "package.json")) + .then((info) => info.isFile()) + .catch(() => false), + ]); + return git || packageJson; +} + +export async function resolveProjectRoot(cwd = process.cwd()): Promise { + let current = resolve(cwd); + + while (true) { + if (await hasRootMarker(current)) { + return current; + } + + const parent = dirname(current); + if (parent === current) { + return resolve(cwd); + } + current = parent; + } +} + +export function createProjectSlug(projectRoot: string): string { + const root = resolve(projectRoot); + const name = basename(root) || "project"; + const sanitized = name + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const hash = createHash("sha1").update(root).digest("hex").slice(0, 8); + return `${sanitized || "project"}-${hash}`; +} + +export function buildProjectStorePaths( + projectRoot: string, + ralphHome?: string, +): ProjectStorePaths { + const normalizedRoot = resolve(projectRoot); + const slug = createProjectSlug(normalizedRoot); + const storeDir = join(getRalphHome(ralphHome), "projects", slug); + + return { + projectRoot: normalizedRoot, + slug, + storeDir, + specPath: join(storeDir, PROJECT_FILES.spec), + prdPath: join(storeDir, PROJECT_FILES.prd), + progressPath: join(storeDir, PROJECT_FILES.progress), + promptPath: join(storeDir, PROJECT_FILES.prompt), + loopPath: join(storeDir, PROJECT_FILES.loop), + metadataPath: join(storeDir, PROJECT_FILES.metadata), + }; +} + +export async function resolveProjectStore( + options: ResolveProjectStoreOptions = {}, +): Promise { + const projectRoot = + options.projectRoot ?? + (await resolveProjectRoot(options.cwd ?? process.cwd())); + return buildProjectStorePaths(projectRoot, options.ralphHome); +} + +function buildProjectPrompt(paths: ProjectStorePaths): string { + return `# Ralph Project Store + +This directory is Ralph's canonical project store for: + +- Project root: ${paths.projectRoot} +- SPEC.md: ${paths.specPath} +- prd.json: ${paths.prdPath} +- progress.md: ${paths.progressPath} +- loop.json: ${paths.loopPath} + +Execution agents must update prd.json and progress.md here, then commit project-root changes when the project is a git repository. Finish successful task attempts by printing exactly: + +RALPH_TASK_COMPLETE +`; +} + +async function writeProjectPromptIfMissing( + paths: ProjectStorePaths, +): Promise { + if (await pathExists(paths.promptPath)) return; + await writeFile(paths.promptPath, buildProjectPrompt(paths), "utf8"); +} + +async function copyValidLegacyFile( + legacyPath: string, + targetPath: string, + validate: (content: string) => boolean, +): Promise { + if (await pathExists(targetPath)) return; + if (!(await pathExists(legacyPath))) return; + + const content = await readFile(legacyPath, "utf8"); + if (!validate(content)) return; + + await copyFile(legacyPath, targetPath); +} + +async function migrateLegacyPlanFiles( + paths: ProjectStorePaths, + legacyInstanceId: string | undefined, + ralphHome?: string, +): Promise { + if (!legacyInstanceId) return; + + const legacyDir = join( + getRalphHome(ralphHome), + "sessions", + legacyInstanceId, + "plan", + ); + await Promise.all([ + copyValidLegacyFile( + join(legacyDir, PROJECT_FILES.spec), + paths.specPath, + (content) => validateSpec(content).valid, + ), + copyValidLegacyFile( + join(legacyDir, PROJECT_FILES.prd), + paths.prdPath, + (content) => !parsePrd(content).error, + ), + copyValidLegacyFile( + join(legacyDir, PROJECT_FILES.progress), + paths.progressPath, + (content) => content.trim().length > 0, + ), + ]); +} + +async function readMetadata( + paths: ProjectStorePaths, +): Promise { + try { + const raw = await readFile(paths.metadataPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.createdAt !== "string" || + typeof parsed.updatedAt !== "string" + ) { + return null; + } + return { + version: 1, + projectRoot: paths.projectRoot, + projectName: basename(paths.projectRoot), + slug: paths.slug, + createdAt: parsed.createdAt, + updatedAt: parsed.updatedAt, + }; + } catch { + return null; + } +} + +async function writeMetadata( + paths: ProjectStorePaths, + now: () => Date, +): Promise { + const timestamp = now().toISOString(); + const existing = await readMetadata(paths); + const metadata: ProjectStoreMetadata = { + version: 1, + projectRoot: paths.projectRoot, + projectName: basename(paths.projectRoot), + slug: paths.slug, + createdAt: existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; + await writeFile( + paths.metadataPath, + `${JSON.stringify(metadata, null, "\t")}\n`, + "utf8", + ); + return metadata; +} + +export async function ensureProjectStore( + options: EnsureProjectStoreOptions = {}, +): Promise { + const paths = await resolveProjectStore(options); + const now = options.now ?? (() => new Date()); + + await mkdir(paths.storeDir, { recursive: true }); + await migrateLegacyPlanFiles( + paths, + options.legacyInstanceId, + options.ralphHome, + ); + await writeProjectPromptIfMissing(paths); + + if (!(await pathExists(paths.progressPath))) { + await writeFile(paths.progressPath, "# Progress Log\n", "utf8"); + } + + await writeMetadata(paths, now); + return paths; +} From 609ab4a4c2108bf53261df1b6a7a40d29400e0a7 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Tue, 28 Apr 2026 04:41:58 -0400 Subject: [PATCH 18/21] Handle durable completion without sentinel --- apps/tui/src/components/execute-view.tsx | 8 +++ apps/tui/src/lib/execution-loop.test.ts | 90 ++++++++++++++++++++++-- apps/tui/src/lib/execution-loop.ts | 63 +++++++++++++++-- 3 files changed, 151 insertions(+), 10 deletions(-) diff --git a/apps/tui/src/components/execute-view.tsx b/apps/tui/src/components/execute-view.tsx index 848ae07..4cfe1d7 100644 --- a/apps/tui/src/components/execute-view.tsx +++ b/apps/tui/src/components/execute-view.tsx @@ -290,6 +290,11 @@ export function ExecuteView({ const selected = data?.instances[selectedIndex]; const planReady = planData.hasPrd && planData.tasks.length > 0; const activeAttempt = loopState ? getActiveAttempt(loopState) : undefined; + const latestWarning = loopState?.attempts + .slice() + .reverse() + .find((attempt) => attempt.verificationWarnings?.length) + ?.verificationWarnings?.join(", "); const completedTasks = planData.tasks.filter((task) => task.passed).length; const currentTaskLabel = loopState?.currentTaskIndex !== undefined @@ -335,6 +340,9 @@ export function ExecuteView({ {loopState?.lastVerificationFailure && !error && ( {loopState.lastVerificationFailure} )} + {!loopState?.lastVerificationFailure && latestWarning && !error && ( + {latestWarning} + )} diff --git a/apps/tui/src/lib/execution-loop.test.ts b/apps/tui/src/lib/execution-loop.test.ts index 30dbb32..91e6358 100644 --- a/apps/tui/src/lib/execution-loop.test.ts +++ b/apps/tui/src/lib/execution-loop.test.ts @@ -9,6 +9,7 @@ import type { ManagedInstance, } from "@techatnyu/ralphd"; import { + acceptActiveAttempt, advanceExecutionLoop, buildExecutionPrompt, type ExecutionDaemon, @@ -187,7 +188,7 @@ describe("execution loop", () => { job: makeJob("succeeded", TASK_COMPLETE_SENTINEL), before, }); - expect(ok).toEqual({ ok: true, errors: [] }); + expect(ok).toEqual({ ok: true, errors: [], warnings: [] }); const missingSentinel = await verifyTaskCompletion({ paths, @@ -196,9 +197,11 @@ describe("execution loop", () => { job: makeJob("succeeded", "done"), before, }); - expect(missingSentinel.errors).toContain( - `job output is missing ${TASK_COMPLETE_SENTINEL}`, - ); + expect(missingSentinel).toEqual({ + ok: true, + errors: [], + warnings: ["verified without sentinel"], + }); await writePrd(paths, TASKS); const unchangedPrd = await verifyTaskCompletion({ @@ -210,6 +213,20 @@ describe("execution loop", () => { }); expect(unchangedPrd.errors).toContain("tasks[0].passed is not true"); + const missingSentinelWithFailedDurable = await verifyTaskCompletion({ + paths, + taskIndex: 0, + task: TASK_0, + job: makeJob("succeeded", "done"), + before, + }); + expect(missingSentinelWithFailedDurable.errors).toContain( + "tasks[0].passed is not true", + ); + expect(missingSentinelWithFailedDurable.errors).toContain( + `job output is missing ${TASK_COMPLETE_SENTINEL}`, + ); + const unchangedProgress = await verifyTaskCompletion({ paths, taskIndex: 0, @@ -276,6 +293,71 @@ describe("execution loop", () => { expect(fakeDaemon.submittedPrompts[1]).toContain("task index 1"); }); + it("advances after a sentinel-only miss when durable checks pass", async () => { + const paths = await createStore(); + const fakeDaemon = new FakeDaemon(); + + const first = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(first.action).toBe("submitted"); + + await writePrd(paths, [{ ...TASK_0, passed: true }, TASK_1]); + await appendProgress(paths, `Task 1: ${TASK_0.description}`); + fakeDaemon.completeJob("job-0", "All durable work is done."); + + const verified = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(verified.action).toBe("verified"); + expect(verified.attempt?.verificationWarnings).toEqual([ + "verified without sentinel", + ]); + + const second = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(second.action).toBe("submitted"); + expect(second.attempt?.taskIndex).toBe(1); + }); + + it("accepts a needs_attention attempt and resumes at the next pending task", async () => { + const paths = await createStore(); + const fakeDaemon = new FakeDaemon(); + + await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + fakeDaemon.completeJob("job-0", "no sentinel and no durable updates"); + const paused = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(paused.state.status).toBe("needs_attention"); + + await writePrd(paths, [{ ...TASK_0, passed: true }, TASK_1]); + await appendProgress(paths, `Task 1: ${TASK_0.description}`); + const accepted = await acceptActiveAttempt(paths, { + warning: "verified without sentinel", + }); + expect(accepted.status).toBe("running"); + expect(accepted.attempts[0]?.status).toBe("verified"); + expect(accepted.attempts[0]?.verificationWarnings).toEqual([ + "verified without sentinel", + ]); + + const resumed = await advanceExecutionLoop({ + paths, + daemonClient: fakeDaemon, + }); + expect(resumed.action).toBe("submitted"); + expect(resumed.attempt?.taskIndex).toBe(1); + }); + it("pauses without cancelling and cancellation leaves the task pending", async () => { const paths = await createStore(); const fakeDaemon = new FakeDaemon(); diff --git a/apps/tui/src/lib/execution-loop.ts b/apps/tui/src/lib/execution-loop.ts index 2921e3d..27de540 100644 --- a/apps/tui/src/lib/execution-loop.ts +++ b/apps/tui/src/lib/execution-loop.ts @@ -48,6 +48,7 @@ const TaskAttemptSchema = z.object({ updatedAt: z.string(), verifiedAt: z.string().optional(), verificationErrors: z.array(z.string()).optional(), + verificationWarnings: z.array(z.string()).optional(), before: VerificationSnapshotSchema.optional(), }); @@ -104,6 +105,7 @@ export interface VerifyTaskCompletionOptions { export interface VerificationResult { ok: boolean; errors: string[]; + warnings: string[]; } function isoNow(now: () => Date): string { @@ -187,7 +189,9 @@ export function getActiveAttempt(state: LoopState): TaskAttempt | undefined { return [...state.attempts] .reverse() .find((attempt) => - ["queued", "running", "succeeded", "failed"].includes(attempt.status), + ["queued", "running", "succeeded", "failed", "needs_attention"].includes( + attempt.status, + ), ); } @@ -330,15 +334,13 @@ export async function verifyTaskCompletion({ readGitHead: readHead = readGitHead, }: VerifyTaskCompletionOptions): Promise { const errors: string[] = []; + const warnings: string[] = []; + const missingSentinel = !job.outputText?.includes(TASK_COMPLETE_SENTINEL); if (job.state !== "succeeded") { errors.push(`job ended as ${job.state}`); } - if (!job.outputText?.includes(TASK_COMPLETE_SENTINEL)) { - errors.push(`job output is missing ${TASK_COMPLETE_SENTINEL}`); - } - let tasks: PrdTask[] = []; try { tasks = await readProjectTasks(paths); @@ -369,9 +371,18 @@ export async function verifyTaskCompletion({ } } + if (missingSentinel) { + if (errors.length === 0) { + warnings.push("verified without sentinel"); + } else { + errors.push(`job output is missing ${TASK_COMPLETE_SENTINEL}`); + } + } + return { ok: errors.length === 0, errors, + warnings, }; } @@ -508,6 +519,7 @@ export async function advanceExecutionLoop({ state = updateAttempt(state, active.id, { status: "needs_attention", verificationErrors: verification.errors, + verificationWarnings: verification.warnings, updatedAt: timestamp, }); state = { @@ -529,6 +541,7 @@ export async function advanceExecutionLoop({ state = updateAttempt(state, active.id, { status: "verified", verificationErrors: [], + verificationWarnings: verification.warnings, verifiedAt: timestamp, updatedAt: timestamp, }); @@ -539,10 +552,13 @@ export async function advanceExecutionLoop({ updatedAt: timestamp, }; await saveLoopState(paths, state); + const warningSuffix = verification.warnings.length + ? ` (${verification.warnings.join(", ")})` + : ""; return { state, action: "verified", - message: `Verified task ${active.taskIndex + 1}`, + message: `Verified task ${active.taskIndex + 1}${warningSuffix}`, job, attempt: state.attempts.find((attempt) => attempt.id === active.id), }; @@ -659,3 +675,38 @@ export async function markLoopPaused( await saveLoopState(paths, paused); return paused; } + +export async function acceptActiveAttempt( + paths: ProjectStorePaths, + options: { + warning?: string; + now?: () => Date; + } = {}, +): Promise { + const now = options.now ?? (() => new Date()); + const timestamp = isoNow(now); + const state = await loadLoopState(paths, now); + const active = getActiveAttempt(state); + if (!active) { + throw new Error("No active attempt to accept"); + } + const warnings = [ + ...(active.verificationWarnings ?? []), + options.warning ?? "manually accepted", + ].filter((warning, index, all) => all.indexOf(warning) === index); + const next = updateAttempt(state, active.id, { + status: "verified", + verificationErrors: [], + verificationWarnings: warnings, + verifiedAt: timestamp, + updatedAt: timestamp, + }); + const accepted = { + ...next, + status: "running" as const, + lastVerificationFailure: undefined, + updatedAt: timestamp, + }; + await saveLoopState(paths, accepted); + return accepted; +} From c68366a1e6bb1eff0690adcdf4d987d83fdc62e2 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Tue, 28 Apr 2026 16:42:55 -0400 Subject: [PATCH 19/21] Redesign execute tab around plan progress --- apps/tui/src/components/app.tsx | 4 +- apps/tui/src/components/execute-view.tsx | 361 ++++++++++++++------ apps/tui/src/components/status-bar.tsx | 2 +- apps/tui/src/lib/execute-view-model.test.ts | 224 ++++++++++++ apps/tui/src/lib/execute-view-model.ts | 214 ++++++++++++ 5 files changed, 697 insertions(+), 108 deletions(-) create mode 100644 apps/tui/src/lib/execute-view-model.test.ts create mode 100644 apps/tui/src/lib/execute-view-model.ts diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index dd2bd02..b31ca0b 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -152,8 +152,8 @@ export function App({ onQuit }: AppProps) { planData={planFiles.data} planInstance={planInstance} onPlanRefresh={planFiles.refresh} - onOpenChat={(instanceId, instanceName) => - setActiveChat({ instanceId, instanceName, sessionId: null }) + onOpenChat={(instanceId, instanceName, sessionId = null) => + setActiveChat({ instanceId, instanceName, sessionId }) } /> diff --git a/apps/tui/src/components/execute-view.tsx b/apps/tui/src/components/execute-view.tsx index 4cfe1d7..31ad362 100644 --- a/apps/tui/src/components/execute-view.tsx +++ b/apps/tui/src/components/execute-view.tsx @@ -1,3 +1,4 @@ +import { basename } from "node:path"; import { TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; import type { @@ -9,6 +10,11 @@ import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useRef, useState } from "react"; import type { PlanFilesData } from "../hooks/use-plan-files"; import type { usePlanInstance } from "../hooks/use-plan-instance"; +import { + buildExecuteViewModel, + type ExecuteTaskRow, + type ExecuteTaskStatus, +} from "../lib/execute-view-model"; import { advanceExecutionLoop, getActiveAttempt, @@ -23,6 +29,7 @@ interface DashboardData { health: HealthResult; instances: ManagedInstance[]; jobs: DaemonJob[]; + projectRoot: string; } interface ExecuteViewProps { @@ -30,7 +37,11 @@ interface ExecuteViewProps { planData: PlanFilesData; planInstance: ReturnType; onPlanRefresh: () => Promise; - onOpenChat: (instanceId: string, instanceName: string) => void; + onOpenChat: ( + instanceId: string, + instanceName: string, + sessionId?: string | null, + ) => void; } function clampIndex(index: number, length: number): number { @@ -54,7 +65,7 @@ function countJobsByState( return { running, queued }; } -function statusColor(status: string): string { +function instanceStatusColor(status: string): string { if (status === "running") return "green"; if (status === "error") return "red"; return "#666666"; @@ -75,6 +86,32 @@ function loopStatusColor(status?: string): string { return "#888888"; } +function taskStatusColor(status: ExecuteTaskStatus): string { + if (status === "running" || status === "queued") return "cyan"; + if (status === "verified") return "green"; + if (status === "warning" || status === "needs_attention") return "yellow"; + if (status === "failed" || status === "cancelled") return "red"; + return "#777777"; +} + +function taskMarker(status: ExecuteTaskStatus): string { + if (status === "verified") return "ok"; + if (status === "warning") return "warn"; + if (status === "running") return "run"; + if (status === "queued") return "queue"; + if (status === "needs_attention") return "need"; + if (status === "failed") return "fail"; + if (status === "cancelled") return "cancel"; + return "todo"; +} + +function truncateText(text: string, maxLength: number): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) return normalized; + if (maxLength <= 3) return normalized.slice(0, maxLength); + return `${normalized.slice(0, maxLength - 3)}...`; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -89,7 +126,8 @@ export function ExecuteView({ const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [data, setData] = useState(); - const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedTaskIndex, setSelectedTaskIndex] = useState(0); + const [showDebug, setShowDebug] = useState(false); const [starting, setStarting] = useState(false); const [startMessage, setStartMessage] = useState(); const [loopState, setLoopState] = useState(); @@ -97,7 +135,7 @@ export function ExecuteView({ const { ensure } = planInstance; const refresh = useCallback( - async (nextIndex = selectedIndex) => { + async (nextTaskIndex = selectedTaskIndex) => { setLoading(true); setError(undefined); try { @@ -106,21 +144,17 @@ export function ExecuteView({ daemon.health(), daemon.listInstances(), ]); - const safeIndex = clampIndex(nextIndex, instanceList.instances.length); - const selected = instanceList.instances[safeIndex]; - const jobsPromise = daemon.listJobs( - selected ? { instanceId: selected.id } : {}, - ); const [jobs, state] = await Promise.all([ - jobsPromise, + daemon.listJobs({}), loadLoopState(buildProjectStorePaths(handle.projectRoot)), ]); - setSelectedIndex(safeIndex); + setSelectedTaskIndex(clampIndex(nextTaskIndex, planData.tasks.length)); setLoopState(state); setData({ health, instances: instanceList.instances, jobs: jobs.jobs, + projectRoot: handle.projectRoot, }); } catch (refreshError) { setError( @@ -132,7 +166,7 @@ export function ExecuteView({ setLoading(false); } }, - [selectedIndex, ensure], + [selectedTaskIndex, ensure, planData.tasks.length], ); useEffect(() => { @@ -264,84 +298,131 @@ export function ExecuteView({ return; } - if (!data) return; + if (key.name === "d") { + setShowDebug((current) => !current); + return; + } + + const taskRows = buildExecuteViewModel({ + tasks: planData.tasks, + progress: planData.progress, + loopState, + jobs: data?.jobs, + }).rows; if (key.name === "return") { - const instance = data.instances[selectedIndex]; - if (instance) { - onOpenChat(instance.id, instance.name); + const selectedRow = taskRows[selectedTaskIndex]; + if (!selectedRow?.sessionId) { + setStartMessage("Selected task has no session"); + return; + } + const instance = + data?.instances.find( + (candidate) => candidate.id === selectedRow.job?.instanceId, + ) ?? + data?.instances.find( + (candidate) => candidate.directory === data.projectRoot, + ); + if (!instance) { + setStartMessage("Selected task has no instance"); + return; } + onOpenChat(instance.id, instance.name, selectedRow.sessionId); return; } if (key.name === "down" || key.name === "j") { - const next = clampIndex(selectedIndex + 1, data.instances.length); + const next = clampIndex(selectedTaskIndex + 1, planData.tasks.length); + setSelectedTaskIndex(next); void refresh(next); return; } if (key.name === "up" || key.name === "k") { - const next = clampIndex(selectedIndex - 1, data.instances.length); + const next = clampIndex(selectedTaskIndex - 1, planData.tasks.length); + setSelectedTaskIndex(next); void refresh(next); return; } }); - const selected = data?.instances[selectedIndex]; const planReady = planData.hasPrd && planData.tasks.length > 0; - const activeAttempt = loopState ? getActiveAttempt(loopState) : undefined; - const latestWarning = loopState?.attempts - .slice() - .reverse() - .find((attempt) => attempt.verificationWarnings?.length) - ?.verificationWarnings?.join(", "); - const completedTasks = planData.tasks.filter((task) => task.passed).length; + const viewModel = buildExecuteViewModel({ + tasks: planData.tasks, + progress: planData.progress, + loopState, + jobs: data?.jobs, + }); + const selectedRow = + viewModel.rows[clampIndex(selectedTaskIndex, viewModel.rows.length)] ?? + viewModel.rows[0]; + const projectRoot = data?.projectRoot ?? planInstance.projectRoot ?? ""; + const projectName = projectRoot ? basename(projectRoot) : "project"; const currentTaskLabel = - loopState?.currentTaskIndex !== undefined - ? `${loopState.currentTaskIndex + 1}/${planData.tasks.length}` - : `${completedTasks}/${planData.tasks.length}`; + viewModel.currentTaskIndex !== undefined && viewModel.totalTasks > 0 + ? `${viewModel.currentTaskIndex + 1}/${viewModel.totalTasks}` + : `${viewModel.completedTasks}/${viewModel.totalTasks}`; + const activeJobId = + viewModel.activeJob?.id ?? viewModel.activeAttempt?.jobId ?? undefined; + const projectInstance = data?.instances.find( + (instance) => instance.directory === projectRoot, + ); + const selectedInstance = + (selectedRow?.job?.instanceId + ? data?.instances.find( + (instance) => instance.id === selectedRow.job?.instanceId, + ) + : undefined) ?? projectInstance; return ( - - - {loading - ? "Refreshing..." - : data - ? `Daemon online (pid ${data.health.pid})` - : "Daemon status unavailable"} - - - {data - ? `${data.health.running} running, ${data.health.queued} queued` - : (error ?? "No data available")} - - - - - {`Loop ${loopState?.status ?? "idle"}`} + {`Project ${projectName}`} + {projectRoot && ( + + {` ${truncateText(projectRoot, 64)}`} + + )} + + + {loading + ? "refreshing" + : data + ? `daemon online pid ${data.health.pid}` + : "daemon offline"} + + + + + {`Loop ${viewModel.status}`} {planReady && ( - {` task ${currentTaskLabel}`} + {` task ${currentTaskLabel} ${viewModel.completedTasks}/${viewModel.totalTasks} done`} )} - {activeAttempt?.jobId && ( + {activeJobId && ( - {` job ${activeAttempt.jobId.slice(0, 8)}`} + {` job ${activeJobId.slice(0, 8)}`} )} {startMessage && !error && {startMessage}} {error && {error}} - {loopState?.lastVerificationFailure && !error && ( - {loopState.lastVerificationFailure} + + + + {viewModel.blockingMessage && !error && ( + + {truncateText(viewModel.blockingMessage, 120)} + )} - {!loopState?.lastVerificationFailure && latestWarning && !error && ( - {latestWarning} + {!viewModel.blockingMessage && viewModel.latestWarning && !error && ( + {viewModel.latestWarning} )} @@ -352,7 +433,7 @@ export function ExecuteView({ <> Plan ready - {" Press [s] start/resume, [p] pause, [c] cancel"} + {" [s] start/resume [p] pause [c] cancel [d] daemon details"} ) : ( @@ -363,66 +444,136 @@ export function ExecuteView({ - - Instances - {"─".repeat(20)} - {data?.instances.length ? ( - data.instances.map((instance: ManagedInstance, index: number) => { - const isSelected = index === selectedIndex; - const counts = countJobsByState(data.jobs, instance.id); - return ( - - {"● "} - - {instance.name} - - - - {`${instance.status} ${counts.running}r/${counts.queued}q`} - - - ); - }) - ) : ( - No instances registered - )} - - - - - {selected ? `Jobs for "${selected.name}"` : "Jobs"} - - {"─".repeat(30)} - {selected ? ( - data?.jobs.length ? ( - - {data.jobs.map((job: DaemonJob) => ( - - - {job.id.slice(0, 8)} + + Tasks + {"─".repeat(56)} + {viewModel.rows.length ? ( + + {viewModel.rows.map((row: ExecuteTaskRow) => { + const isSelected = row.index === selectedRow?.index; + const warningOrError = row.errors.length + ? "!" + : row.warnings.length + ? "~" + : " "; + return ( + + + {isSelected ? "> " : " "} + + + {String(row.index + 1).padStart(2, "0")} + + + {` ${taskMarker(row.status).padEnd(6)}`} + + + {truncateText(row.description, 48)} + + + + {row.attemptCount ? `a${row.attemptCount}` : " "} - {` ${job.state} `} + {row.jobId ? ` ${row.jobId.slice(0, 8)}` : " "} - - {job.task.type === "prompt" - ? job.task.prompt.slice(0, 40) - : ""} + + {warningOrError} - ))} - - ) : ( + ); + })} + + ) : ( + No PRD tasks ready yet + )} + + + + Task Detail + {"─".repeat(34)} + {selectedRow ? ( + + + {`Task ${selectedRow.index + 1}: ${selectedRow.statusText}`} + + + {truncateText(selectedRow.description, 44)} + - No jobs for this instance + {`attempts ${selectedRow.attemptCount || 0}`} - ) + {selectedRow.jobId && ( + + {`job ${selectedRow.jobId}`} + + )} + {selectedRow.sessionId && ( + + {`session ${selectedRow.sessionId}`} + + )} + {selectedInstance && ( + + {`instance ${selectedInstance.name}`} + + )} + {selectedRow.errors.length > 0 && ( + + Verification errors + {selectedRow.errors.map((message) => ( + + {truncateText(message, 46)} + + ))} + + )} + {selectedRow.warnings.length > 0 && ( + + Warnings + {selectedRow.warnings.map((message) => ( + + {truncateText(message, 46)} + + ))} + + )} + + Progress + + {selectedRow.progressEntry + ? truncateText(selectedRow.progressEntry, 120) + : "No progress entry for this task yet"} + + + {showDebug && data && ( + + Daemon Details + {data.instances.map((instance) => { + const counts = countJobsByState(data.jobs, instance.id); + return ( + + {`${instance.name} ${instance.status} ${counts.running}r/${counts.queued}q`} + + ); + })} + {selectedRow.job && ( + + {`selected job ${selectedRow.job.state}`} + + )} + + )} + ) : ( - Select an instance to see jobs + Select a task to inspect execution details )} diff --git a/apps/tui/src/components/status-bar.tsx b/apps/tui/src/components/status-bar.tsx index e28f5dd..aadcf8a 100644 --- a/apps/tui/src/components/status-bar.tsx +++ b/apps/tui/src/components/status-bar.tsx @@ -8,7 +8,7 @@ interface StatusBarProps { const HELP_BY_TAB: Record = { 0: "Tab: tabs Ctrl+S: spec Ctrl+T: tasks /: commands ?: help", - 1: "Tab: tabs s: start/resume p: pause c: cancel r: refresh enter: chat ?: help", + 1: "Tab: tabs j/k: tasks s: start/resume p: pause c: cancel d: details r: refresh enter: chat ?: help", 2: "Tab: tabs ?: help", }; diff --git a/apps/tui/src/lib/execute-view-model.test.ts b/apps/tui/src/lib/execute-view-model.test.ts new file mode 100644 index 0000000..14f22a9 --- /dev/null +++ b/apps/tui/src/lib/execute-view-model.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "bun:test"; +import type { DaemonJob } from "@techatnyu/ralphd"; +import { + buildExecuteViewModel, + readLatestProgressEntry, +} from "./execute-view-model"; +import type { LoopState, TaskAttempt } from "./execution-loop"; +import type { PrdTask } from "./plan-validation"; + +const NOW = "2026-04-28T00:00:00.000Z"; + +const TASK_0: PrdTask = { + description: "Set up project structure", + subtasks: ["Create files"], + notes: "", + passed: false, +}; + +const TASK_1: PrdTask = { + description: "Render task list", + subtasks: ["Show tasks"], + notes: "", + passed: false, +}; + +const TASKS: PrdTask[] = [TASK_0, TASK_1]; + +function attempt( + partial: Partial & Pick, +): TaskAttempt { + const { id, taskIndex, status, ...rest } = partial; + return { + id: id ?? `${taskIndex}-attempt`, + taskIndex, + taskDescription: TASKS[taskIndex]?.description ?? "Unknown", + attemptNumber: 1, + status, + updatedAt: NOW, + ...rest, + }; +} + +function loopState( + partial: Partial & { attempts?: TaskAttempt[] } = {}, +): LoopState { + return { + version: 1, + projectRoot: "/tmp/project", + status: "running", + attempts: [], + createdAt: NOW, + updatedAt: NOW, + ...partial, + }; +} + +function job(partial: Partial & Pick): DaemonJob { + const { id, ...rest } = partial; + return { + id, + instanceId: "instance-1", + sessionId: "session-1", + task: { + type: "prompt", + prompt: "You are Ralph's execution agent for one PRD task attempt.", + }, + state: "running", + createdAt: NOW, + updatedAt: NOW, + ...rest, + }; +} + +describe("execute view model", () => { + it("renders pending tasks when there are no attempts", () => { + const model = buildExecuteViewModel({ + tasks: TASKS, + progress: "", + }); + + expect(model.status).toBe("idle"); + expect(model.rows.map((row) => row.status)).toEqual(["pending", "pending"]); + expect(model.rows[0]?.description).toBe("Set up project structure"); + }); + + it("shows the running active attempt with its job and session", () => { + const active = attempt({ + taskIndex: 1, + status: "running", + jobId: "job-1", + sessionId: "session-1", + }); + const model = buildExecuteViewModel({ + tasks: TASKS, + progress: "", + loopState: loopState({ + currentTaskIndex: 1, + attempts: [active], + }), + jobs: [job({ id: "job-1", state: "running" })], + }); + + expect(model.currentTaskIndex).toBe(1); + expect(model.activeAttempt?.id).toBe(active.id); + expect(model.activeJob?.id).toBe("job-1"); + expect(model.rows[1]).toMatchObject({ + status: "running", + jobId: "job-1", + sessionId: "session-1", + }); + }); + + it("renders verified attempts with warnings as warning rows", () => { + const model = buildExecuteViewModel({ + tasks: [{ ...TASK_0, passed: true }], + progress: "Task 1: Set up project structure finished.", + loopState: loopState({ + status: "completed", + attempts: [ + attempt({ + taskIndex: 0, + status: "verified", + jobId: "job-1", + verificationWarnings: ["verified without sentinel"], + }), + ], + }), + jobs: [job({ id: "job-1", state: "succeeded" })], + }); + + expect(model.rows[0]?.status).toBe("warning"); + expect(model.latestWarning).toBe("verified without sentinel"); + }); + + it("surfaces failed and needs_attention verification errors", () => { + const model = buildExecuteViewModel({ + tasks: TASKS, + progress: "", + loopState: loopState({ + status: "needs_attention", + lastVerificationFailure: "progress.md was not appended", + attempts: [ + attempt({ + taskIndex: 0, + status: "needs_attention", + verificationErrors: ["progress.md was not appended"], + }), + attempt({ + taskIndex: 1, + status: "failed", + verificationErrors: ["Job failed"], + }), + ], + }), + }); + + expect(model.status).toBe("needs_attention"); + expect(model.blockingMessage).toBe("progress.md was not appended"); + expect(model.rows[0]?.status).toBe("needs_attention"); + expect(model.rows[1]?.status).toBe("failed"); + }); + + it("shows completed when all tasks passed even if loop state is paused", () => { + const model = buildExecuteViewModel({ + tasks: TASKS.map((task) => ({ ...task, passed: true })), + progress: "", + loopState: loopState({ + status: "paused", + currentTaskIndex: 1, + attempts: [ + attempt({ taskIndex: 0, status: "verified" }), + attempt({ taskIndex: 1, status: "verified" }), + ], + }), + }); + + expect(model.status).toBe("completed"); + expect(model.completedTasks).toBe(2); + }); + + it("uses PRD descriptions instead of daemon prompt snippets", () => { + const model = buildExecuteViewModel({ + tasks: TASKS, + progress: "", + loopState: loopState({ + attempts: [ + attempt({ + taskIndex: 0, + status: "running", + jobId: "job-1", + }), + ], + }), + jobs: [ + job({ + id: "job-1", + task: { + type: "prompt", + prompt: "You are Ralph's execution agent for one PRD task attempt.", + }, + }), + ], + }); + + expect(model.rows[0]?.description).toBe("Set up project structure"); + expect(model.rows[0]?.description).not.toContain("execution agent"); + }); + + it("finds the latest progress entry for a task", () => { + const entry = readLatestProgressEntry( + [ + "# Progress Log", + "", + "Task 1: Set up project structure finished.", + "", + "Task 2: Render task list with empty state.", + ].join("\n"), + 1, + "Render task list", + ); + + expect(entry).toBe("Task 2: Render task list with empty state."); + }); +}); diff --git a/apps/tui/src/lib/execute-view-model.ts b/apps/tui/src/lib/execute-view-model.ts new file mode 100644 index 0000000..d67d594 --- /dev/null +++ b/apps/tui/src/lib/execute-view-model.ts @@ -0,0 +1,214 @@ +import type { DaemonJob } from "@techatnyu/ralphd"; +import { + getActiveAttempt, + type LoopState, + type TaskAttempt, +} from "./execution-loop"; +import type { PrdTask } from "./plan-validation"; + +export type ExecuteTaskStatus = + | "pending" + | "queued" + | "running" + | "verified" + | "warning" + | "failed" + | "cancelled" + | "needs_attention"; + +export type ExecuteDisplayStatus = + | "idle" + | "running" + | "paused" + | "needs_attention" + | "completed"; + +export interface ExecuteTaskRow { + index: number; + description: string; + task: PrdTask; + status: ExecuteTaskStatus; + statusText: string; + attemptCount: number; + latestAttempt?: TaskAttempt; + job?: DaemonJob; + jobId?: string; + sessionId?: string; + warnings: string[]; + errors: string[]; + progressEntry?: string; +} + +export interface ExecuteDisplayState { + status: ExecuteDisplayStatus; + completedTasks: number; + totalTasks: number; + currentTaskIndex?: number; + activeAttempt?: TaskAttempt; + activeJob?: DaemonJob; + blockingMessage?: string; + latestWarning?: string; + rows: ExecuteTaskRow[]; +} + +export interface BuildExecuteViewModelInput { + tasks: PrdTask[]; + progress: string; + loopState?: LoopState; + jobs?: DaemonJob[]; +} + +function jobById(jobs: DaemonJob[] | undefined): Map { + return new Map((jobs ?? []).map((job) => [job.id, job])); +} + +function truncateText(text: string, maxLength: number): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) return normalized; + if (maxLength <= 3) return normalized.slice(0, maxLength); + return `${normalized.slice(0, maxLength - 3)}...`; +} + +function latestAttemptForTask( + attempts: TaskAttempt[], + taskIndex: number, +): TaskAttempt | undefined { + return [...attempts] + .reverse() + .find((attempt) => attempt.taskIndex === taskIndex); +} + +function statusFromAttempt( + task: PrdTask, + attempt: TaskAttempt | undefined, +): Pick { + if (!attempt) { + return task.passed + ? { status: "verified", statusText: "verified" } + : { status: "pending", statusText: "pending" }; + } + + if ( + attempt.status === "verified" && + (attempt.verificationWarnings?.length ?? 0) > 0 + ) { + return { status: "warning", statusText: "warning" }; + } + + if (attempt.status === "succeeded") { + return { status: "running", statusText: "verifying" }; + } + + if (attempt.status === "verified") { + return { status: "verified", statusText: "verified" }; + } + + return { status: attempt.status, statusText: attempt.status }; +} + +export function readLatestProgressEntry( + progress: string, + taskIndex: number, + taskDescription: string, +): string | undefined { + const blocks = progress + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter((block) => block && block !== "# Progress Log"); + const normalizedDescription = taskDescription.trim().toLowerCase(); + const descriptionPrefix = normalizedDescription.slice( + 0, + Math.min(normalizedDescription.length, 80), + ); + const taskTerms = [ + `task ${taskIndex + 1}`, + `task index ${taskIndex}`, + `tasks[${taskIndex}]`, + ]; + + for (const block of [...blocks].reverse()) { + const normalized = block.toLowerCase(); + if ( + (descriptionPrefix && normalized.includes(descriptionPrefix)) || + taskTerms.some((term) => normalized.includes(term)) + ) { + return truncateText(block, 240); + } + } + + const fallback = blocks.at(-1); + return fallback ? truncateText(fallback, 240) : undefined; +} + +export function buildExecuteViewModel({ + tasks, + progress, + loopState, + jobs, +}: BuildExecuteViewModelInput): ExecuteDisplayState { + const jobsById = jobById(jobs); + const activeAttempt = loopState ? getActiveAttempt(loopState) : undefined; + const activeJob = activeAttempt?.jobId + ? jobsById.get(activeAttempt.jobId) + : undefined; + const completedTasks = tasks.filter((task) => task.passed).length; + const allDone = tasks.length > 0 && completedTasks === tasks.length; + const status: ExecuteDisplayStatus = + allDone && !activeAttempt ? "completed" : (loopState?.status ?? "idle"); + + const rows = tasks.map((task, index): ExecuteTaskRow => { + const latestAttempt = latestAttemptForTask( + loopState?.attempts ?? [], + index, + ); + const job = latestAttempt?.jobId + ? jobsById.get(latestAttempt.jobId) + : undefined; + const statusInfo = statusFromAttempt(task, latestAttempt); + const warnings = latestAttempt?.verificationWarnings ?? []; + const errors = latestAttempt?.verificationErrors ?? []; + return { + index, + description: task.description, + task, + ...statusInfo, + attemptCount: + loopState?.attempts.filter((attempt) => attempt.taskIndex === index) + .length ?? 0, + latestAttempt, + job, + jobId: latestAttempt?.jobId, + sessionId: latestAttempt?.sessionId ?? job?.sessionId, + warnings, + errors, + progressEntry: readLatestProgressEntry(progress, index, task.description), + }; + }); + + const firstPending = rows.find((row) => row.status === "pending")?.index; + const latestAttempt = loopState?.attempts.at(-1); + const currentTaskIndex = + activeAttempt?.taskIndex ?? + loopState?.currentTaskIndex ?? + firstPending ?? + latestAttempt?.taskIndex; + const latestWarning = [...rows] + .reverse() + .find((row) => row.warnings.length) + ?.warnings.join(", "); + const blockingMessage = + loopState?.lastVerificationFailure ?? + rows.find((row) => row.errors.length)?.errors.join("; "); + + return { + status, + completedTasks, + totalTasks: tasks.length, + currentTaskIndex, + activeAttempt, + activeJob, + blockingMessage, + latestWarning, + rows, + }; +} From 121fc6d8a6ffdd1d21b2c7f29aa7fb98d47bed37 Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Tue, 28 Apr 2026 17:17:18 -0400 Subject: [PATCH 20/21] Isolate plan artifact generation sessions --- apps/tui/src/components/command-palette.tsx | 8 +--- apps/tui/src/components/file-picker.tsx | 8 +--- apps/tui/src/components/plan-view.test.ts | 31 +++++++++++++++ apps/tui/src/components/plan-view.tsx | 42 ++++++++++++++++++++- apps/tui/src/hooks/use-chat.ts | 11 ++++-- 5 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 apps/tui/src/components/plan-view.test.ts diff --git a/apps/tui/src/components/command-palette.tsx b/apps/tui/src/components/command-palette.tsx index 5addeab..a16119b 100644 --- a/apps/tui/src/components/command-palette.tsx +++ b/apps/tui/src/components/command-palette.tsx @@ -30,15 +30,13 @@ export function CommandPalette({ if (filtered.length === 0) { return ( No matching commands @@ -51,9 +49,6 @@ export function CommandPalette({ return ( {filtered.map((cmd, index) => ( diff --git a/apps/tui/src/components/file-picker.tsx b/apps/tui/src/components/file-picker.tsx index 8f1ca48..8b8eb29 100644 --- a/apps/tui/src/components/file-picker.tsx +++ b/apps/tui/src/components/file-picker.tsx @@ -11,15 +11,13 @@ export function FilePicker({ results, selectedIndex }: FilePickerProps) { if (results.length === 0) { return ( No matching files @@ -33,9 +31,6 @@ export function FilePicker({ results, selectedIndex }: FilePickerProps) { return ( {visibleResults.map((file, index) => ( diff --git a/apps/tui/src/components/plan-view.test.ts b/apps/tui/src/components/plan-view.test.ts new file mode 100644 index 0000000..b46fdbe --- /dev/null +++ b/apps/tui/src/components/plan-view.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { + addTranscriptToPrompt, + buildConversationTranscript, +} from "./plan-view"; + +describe("plan view prompt helpers", () => { + it("builds artifact prompts from visible conversation only", () => { + const transcript = buildConversationTranscript([ + { role: "system", content: "SPEC.md generated." }, + { role: "user", content: "i want a basic todo app" }, + { role: "assistant", content: "Any tech preferences?" }, + { role: "user", content: "actually can we use react" }, + ]); + + expect(transcript).toContain("User: i want a basic todo app"); + expect(transcript).toContain("Ralph: Any tech preferences?"); + expect(transcript).toContain("User: actually can we use react"); + expect(transcript).not.toContain("SPEC.md generated"); + }); + + it("adds the transcript to auto artifact prompts", () => { + const prompt = addTranscriptToPrompt("Generate SPEC.md.", [ + { role: "user", content: "basic todo app" }, + ]); + + expect(prompt).toContain("Generate SPEC.md."); + expect(prompt).toContain("Conversation transcript:"); + expect(prompt).toContain("User: basic todo app"); + }); +}); diff --git a/apps/tui/src/components/plan-view.tsx b/apps/tui/src/components/plan-view.tsx index f94a5ee..2997d3f 100644 --- a/apps/tui/src/components/plan-view.tsx +++ b/apps/tui/src/components/plan-view.tsx @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { useKeyboard, useTerminalDimensions } from "@opentui/react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useChat } from "../hooks/use-chat"; +import { type ChatMessage, useChat } from "../hooks/use-chat"; import type { PlanFilesData } from "../hooks/use-plan-files"; import type { usePlanInstance } from "../hooks/use-plan-instance"; import { useSkill } from "../hooks/use-skill"; @@ -48,6 +48,28 @@ ${spec} \`\`\``; } +export function buildConversationTranscript(messages: ChatMessage[]): string { + return messages + .filter((message) => message.role !== "system" && message.content.trim()) + .map((message) => { + const speaker = message.role === "user" ? "User" : "Ralph"; + return `${speaker}: ${message.content.trim()}`; + }) + .join("\n\n"); +} + +export function addTranscriptToPrompt( + prompt: string, + messages: ChatMessage[], +): string { + const transcript = buildConversationTranscript(messages); + if (!transcript) return prompt; + return `${prompt} + +Conversation transcript: +${transcript}`; +} + async function writeSkillArtifact( skill: Skill, ctx: SkillContext, @@ -160,10 +182,21 @@ export function PlanView({ prompt: finalPrompt, systemPrompt: skill.buildSystemPrompt(ctx), permission: skill.buildPermission(ctx), + displayAssistant: skill.id === "brainstorm", + sessionMode: skill.id === "brainstorm" ? "current" : "ephemeral", }); if (!result?.content) return; try { await writeSkillArtifact(skill, ctx, result.content); + if (skill.id === "spec") { + addSystemMessage( + "SPEC.md updated. Press Ctrl+S to view. Type /prd when ready.", + ); + } else if (skill.id === "prd") { + addSystemMessage( + "prd.json updated. Press Ctrl+T to review tasks, then switch to Execute.", + ); + } } catch (e) { const filename = skill.id === "spec" ? "SPEC.md" : "prd.json"; const reason = @@ -183,7 +216,11 @@ export function PlanView({ const ctx: SkillContext = { scaffoldPath }; let prompt: string; try { - prompt = await buildSkillPrompt(s, ctx, s.buildAutoPrompt(ctx)); + const autoPrompt = + s.id === "spec" + ? addTranscriptToPrompt(s.buildAutoPrompt(ctx), chat.messages) + : s.buildAutoPrompt(ctx); + prompt = await buildSkillPrompt(s, ctx, autoPrompt); } catch (e) { addSystemMessage( `SPEC.md: ${e instanceof Error ? e.message : "failed to read file"}`, @@ -197,6 +234,7 @@ export function PlanView({ permission: s.buildPermission(ctx), displayAssistant: false, displayUser: false, + sessionMode: "ephemeral", }); try { if (!result?.content) return; diff --git a/apps/tui/src/hooks/use-chat.ts b/apps/tui/src/hooks/use-chat.ts index 49ce6a9..8ad3167 100644 --- a/apps/tui/src/hooks/use-chat.ts +++ b/apps/tui/src/hooks/use-chat.ts @@ -14,6 +14,7 @@ export interface SendOptions { permission?: PermissionRule[]; displayAssistant?: boolean; displayUser?: boolean; + sessionMode?: "current" | "ephemeral"; } export interface SendResult { @@ -65,6 +66,7 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { permission, displayAssistant = true, displayUser = true, + sessionMode = "current", }: SendOptions) => { if (loading) return undefined; @@ -78,9 +80,10 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { try { const instanceId = await ensureInstance(); - const session = sessionIdRef.current - ? { type: "existing" as const, sessionId: sessionIdRef.current } - : { type: "new" as const, title: "Plan", permission }; + const session = + sessionMode === "current" && sessionIdRef.current + ? { type: "existing" as const, sessionId: sessionIdRef.current } + : { type: "new" as const, title: "Plan", permission }; const { model: storedModel } = await ralphStore.read(); const { events } = await daemon.submitAndStreamJob({ @@ -111,7 +114,7 @@ export function useChat(ensureInstance: () => Promise): UseChatReturn { updateLastMessage(() => ({ role: "assistant", content })); } } else if (event.type === "done") { - if (event.job.sessionId) { + if (sessionMode === "current" && event.job.sessionId) { sessionIdRef.current = event.job.sessionId; } if (event.job.state === "failed") { From 3ef5a07d7f887895446a1710d71aa271fdd9213b Mon Sep 17 00:00:00 2001 From: Kevin Pei Date: Tue, 28 Apr 2026 17:27:14 -0400 Subject: [PATCH 21/21] Remove local metadata from plan view branch --- .gitignore | 3 - AGENTS.md | 147 -------------- CLAUDE.md | 75 ------- apps/tui/src/hooks/use-execution.ts | 291 ---------------------------- handoff.md | 149 -------------- 5 files changed, 665 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md delete mode 100644 apps/tui/src/hooks/use-execution.ts delete mode 100644 handoff.md diff --git a/.gitignore b/.gitignore index a8771ca..342b0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,3 @@ yarn-error.log* *.pem .ralph-dev - -# Claude -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 70175e2..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,147 +0,0 @@ -# AGENTS.md - -This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. - -## Project Overview - -Ralph is a coding agent orchestration TUI — a daemon (ralphd) manages OpenCode SDK instances and jobs, while a React-based terminal UI provides interactive monitoring. Built as a Bun monorepo with Turbo. - -## Commands - -```bash -bun install # Install dependencies -bun run build # Build all packages (turbo) -bun run dev # Start all dev servers -bun run dev:docs # Start docs site only -bun run test # Run all tests (bun test) -bun run check # Biome lint + format check -bun run check:types # TypeScript type checking -``` - -### Per-package commands - -```bash -cd apps/tui && bun run dev # Run TUI in dev mode -cd packages/daemon && bun test # Run daemon tests only -cd apps/docs && bun run dev # Run docs dev server -``` - -### Release - -```bash -bun run release:build # Compile binaries for all platforms -bun run release:stage # Stage distribution for publishing -bun run release:publish # Publish to npm -bun run release:dry-run # Test publish without uploading -``` - -## Architecture - -### Monorepo Layout - -- `apps/tui/` — Terminal UI app (@techatnyu/ralph), React 19 + @opentui/react -- `apps/docs/` — Documentation site, Fumadocs + TanStack Start + Vite -- `packages/daemon/` — Background daemon (@techatnyu/ralphd), socket-based IPC -- `packages/config/` — Shared TypeScript configuration -- `scripts/` — Release and build automation - -### Daemon-Client Architecture - -The daemon (ralphd) runs as a background process and communicates with TUI clients via a Unix domain socket (`ralphd.sock`). Key patterns: - -- **Protocol-driven**: All requests/responses defined with Zod schemas in `packages/daemon/src/protocol.ts`. Type-safe discriminated unions for all message types. -- **Job lifecycle**: queued → running → succeeded/failed/cancelled. Per-instance concurrency control (default: 4, configurable via `RALPHD_MAX_CONCURRENCY`). -- **Instance management**: `ManagedInstance` tracks OpenCode runtimes with lazy initialization. States: stopped → starting → running → error. -- **State persistence**: JSON file at `~/.ralph/state.json` (or `$RALPH_HOME/state.json`). - -### TUI - -React components rendered in the terminal via @opentui/react. Real-time job monitoring with keyboard navigation (j/k or arrows). CLI argument parsing via CrustJS. - -## Code Style - -- **Biome** for linting and formatting: tab indentation, double quotes, import organization -- **TypeScript strict mode**, ES2022 target, bundler module resolution -- Shared base tsconfig in `packages/config/tsconfig.base.json` -- TUI uses `@opentui/react` as JSX import source - -## Environment Variables - -- `RALPH_HOME` — Base directory (default: `~/.ralph`, dev: `./.ralph-dev`) -- `RALPHD_MAX_CONCURRENCY` — Max concurrent jobs per instance (default: 4) -- `RALPHD_BIN` — Override daemon binary path - -## Git Conventions -- Reasonably Commit after every fix. - - -# Memory Context - -# [ralph] recent context, 2026-04-24 1:27am EDT - -Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision 🚨security_alert 🔐security_note -Format: ID TIME TYPE TITLE -Fetch details: get_observations([IDs]) | Search: mem-search skill - -Stats: 50 obs (15,904t read) | 723,567t work | 98% savings - -### Apr 22, 2026 -S5 Fix OpenCode provider config to resolve empty responses in ralph's plan chat (Apr 22 at 6:11 PM) -S3 Phase-driven plan chat refactor complete — all lint and type checks pass (Apr 22 at 6:11 PM) -S6 Fix OpenCode Zod v3/v4 incompatibility crash by switching to OpenRouter with Claude Sonnet 4 (Apr 22 at 6:54 PM) -S32 Ralph daemon PID 72596 killed (Apr 22 at 6:55 PM) -### Apr 24, 2026 -70 1:06a ✅ Daemon SDK Pinned to @opencode-ai/sdk@1.14.22 — Version Mismatch Resolved -71 1:07a 🔴 OpencodeRegistry Provider Model Mapping Fixed for SDK v1.14.22 Capabilities Shape -72 " 🟣 Added normalizeSessionError() and Restored extractText() to Daemon Server -73 1:08a 🔴 Daemon SDK pinned to exact version 1.14.22 — resolving ProviderModelNotFoundError -74 " 🔄 Daemon job completion refactored to session.idle event-driven pattern -75 " 🟣 normalizeSessionError() added for structured OpenCode error handling in daemon -76 " 🔵 Zombie daemon process accumulation pattern identified in Ralph daemon lifecycle -77 1:09a 🔴 Daemon test suite fixed — 37/37 passing after resolveSessionIdle() method added -78 " ✅ Daemon fix changes uncommitted — 16 files modified across TUI and daemon packages -79 " ✅ Daemon package clean — biome formatted, types pass, 37/37 tests green, ready to commit -80 1:10a 🔵 Running daemon uses default RALPH_HOME (~/.ralph), not .ralph-dev — CLI commands need matching RALPH_HOME -81 " 🔵 Daemon PID 42833 survived SIGTERM but died on SIGKILL — opencode subprocess 42834 had already exited -82 " 🔴 Stale active jobs in .ralph-dev/state.json manually cancelled before daemon restart -83 " ✅ Daemon restarted with new code at PID 72596 using RALPH_HOME=.ralph-dev -84 1:11a 🔵 End-to-end live daemon job confirmed working — "RALPH_OK" response in ~3 seconds -85 " 🔵 OpenCode session.idle event flow traced in logs — confirms exact trigger sequence for daemon job completion -86 " ✅ Daemon fixes committed to feat/plan-view as "Fix daemon completion with current OpenCode SDK" -87 1:13a ✅ Ralph daemon PID 72596 killed -88 1:14a 🔴 Daemon OpencodeSessionClient prompt() return type made optional -89 " 🔴 Daemon server.ts null-safe access for prompt response fields -90 " 🔵 use-chat.ts streaming job event loop pattern -91 1:15a 🟣 use-chat.ts handles failed job state in done event -92 " 🔴 use-chat.ts updateLastMessage wrapped in useCallback to fix lint exhaustive-deps -93 " 🔵 Biome exhaustive-deps persists after useCallback wrap — dep array also needs updating -94 " 🔴 use-chat.ts send dep array updated to include updateLastMessage -95 " ✅ Daemon restarted after null-safety and use-chat fixes — clean state confirmed -96 1:16a ✅ Daemon restarted with updated null-safety code — PID 93736 -97 " 🔄 use-chat.ts fully refactored — polling replaced with streaming, ChatMode removed -98 " 🔵 Ralph TUI — 20 modified files and 6 new files uncommitted as of Apr 24 1:16am -100 1:17a ✅ Daemon null-safety fix committed — "Handle missing OpenCode prompt info" (30813fa) -101 1:18a 🔵 Ralph Daemon and OpenCode Processes Still Running After Kill Attempt -S39 Ralph Daemon and OpenCode Processes Still Running After Kill Attempt (Apr 24 at 1:18 AM) -102 1:20a 🔵 ProviderModelNotFoundError resurfaces in share-next subscriber — not blocking main execution -103 1:21a 🔵 Job stuck in running state after daemon restart mid-execution — idle event lost -104 " 🔵 SPEC system prompt confirmed wired into TUI job submission -105 1:22a 🔴 Daemon server.ts: fast-fail when OpenCode returns no message data and fail empty-output jobs -106 " 🔵 opencode/minimax-m2.5-free confirmed working as alternative free model -107 " ✅ Daemon test suite passes 37/37 after server.ts fast-fail and empty-output-fail fixes -108 " 🔴 Default model switched to opencode/minimax-m2.5-free to avoid ProviderModelNotFoundError -109 1:23a ✅ End-to-end smoke test passed with opencode/minimax-m2.5-free — DEV_OK confirmed -110 " ✅ Committed "Fail empty OpenCode prompt responses" — 80d50b7 on feat/plan-view -111 1:24a 🔵 TUI CLI missing "daemon status" and "model current" subcommands -112 1:25a 🔵 state.json confirms original "response.info.id" TypeError bug before null-safety fix -113 " 🔵 plan-chat.tsx architecture — file picker, slash commands, phase-aware system prompt -114 " 🔵 chat.tsx has independent daemon streaming — separate from use-chat.ts hook -115 " 🔵 Ralph TUI CLI full command surface — daemon and model subcommands mapped -116 1:26a 🔵 Daemon instance stops on restart — requires manual "daemon instance start" to resume -117 " 🔵 CLI "daemon submit" reads model from ralphStore via parseModelRef() — SMOKE_OK confirmed -118 1:27a 🔵 TUI development server confirmed running alongside daemon — full dev stack active -119 " 🔵 TUI dev process uses relative RALPH_HOME — OPENROUTER/OPENAI keys not inherited -120 " 🔵 Daemon client protocol architecture — Unix socket, newline-delimited JSON, Zod v4 schemas - -Access 724k tokens of past work via get_observations([IDs]) or mem-search skill. - \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index bb34ad0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,75 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Ralph is a coding agent orchestration TUI — a daemon (ralphd) manages OpenCode SDK instances and jobs, while a React-based terminal UI provides interactive monitoring. Built as a Bun monorepo with Turbo. - -## Commands - -```bash -bun install # Install dependencies -bun run build # Build all packages (turbo) -bun run dev # Start all dev servers -bun run dev:docs # Start docs site only -bun run test # Run all tests (bun test) -bun run check # Biome lint + format check -bun run check:types # TypeScript type checking -``` - -### Per-package commands - -```bash -cd apps/tui && bun run dev # Run TUI in dev mode -cd packages/daemon && bun test # Run daemon tests only -cd apps/docs && bun run dev # Run docs dev server -``` - -### Release - -```bash -bun run release:build # Compile binaries for all platforms -bun run release:stage # Stage distribution for publishing -bun run release:publish # Publish to npm -bun run release:dry-run # Test publish without uploading -``` - -## Architecture - -### Monorepo Layout - -- `apps/tui/` — Terminal UI app (@techatnyu/ralph), React 19 + @opentui/react -- `apps/docs/` — Documentation site, Fumadocs + TanStack Start + Vite -- `packages/daemon/` — Background daemon (@techatnyu/ralphd), socket-based IPC -- `packages/config/` — Shared TypeScript configuration -- `scripts/` — Release and build automation - -### Daemon-Client Architecture - -The daemon (ralphd) runs as a background process and communicates with TUI clients via a Unix domain socket (`ralphd.sock`). Key patterns: - -- **Protocol-driven**: All requests/responses defined with Zod schemas in `packages/daemon/src/protocol.ts`. Type-safe discriminated unions for all message types. -- **Job lifecycle**: queued → running → succeeded/failed/cancelled. Per-instance concurrency control (default: 4, configurable via `RALPHD_MAX_CONCURRENCY`). -- **Instance management**: `ManagedInstance` tracks OpenCode runtimes with lazy initialization. States: stopped → starting → running → error. -- **State persistence**: JSON file at `~/.ralph/state.json` (or `$RALPH_HOME/state.json`). - -### TUI - -React components rendered in the terminal via @opentui/react. Real-time job monitoring with keyboard navigation (j/k or arrows). CLI argument parsing via CrustJS. - -## Code Style - -- **Biome** for linting and formatting: tab indentation, double quotes, import organization -- **TypeScript strict mode**, ES2022 target, bundler module resolution -- Shared base tsconfig in `packages/config/tsconfig.base.json` -- TUI uses `@opentui/react` as JSX import source - -## Environment Variables - -- `RALPH_HOME` — Base directory (default: `~/.ralph`, dev: `./.ralph-dev`) -- `RALPHD_MAX_CONCURRENCY` — Max concurrent jobs per instance (default: 4) -- `RALPHD_BIN` — Override daemon binary path - -## Git Conventions -- Reasonably Commit after every fix. \ No newline at end of file diff --git a/apps/tui/src/hooks/use-execution.ts b/apps/tui/src/hooks/use-execution.ts deleted file mode 100644 index 69ae103..0000000 --- a/apps/tui/src/hooks/use-execution.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { daemon } from "@techatnyu/ralphd"; -import { useCallback, useRef, useState } from "react"; -import { Worktree, type WorktreeInfo } from "../lib/worktree"; -import type { PrdTask } from "./use-plan-files"; - -export type TaskExecutionState = - | "pending" - | "creating" - | "running" - | "succeeded" - | "failed" - | "cancelled"; - -export interface TaskWorktree { - taskIndex: number; - task: PrdTask; - worktreeName: string; - worktreeInfo?: WorktreeInfo; - instanceId?: string; - jobId?: string; - state: TaskExecutionState; - error?: string; -} - -export interface UseExecutionReturn { - taskWorktrees: TaskWorktree[]; - executing: boolean; - startAll: (tasks: PrdTask[]) => Promise; - cancelTask: (taskIndex: number) => Promise; - cancelAll: () => Promise; - cleanup: () => Promise; - refresh: () => Promise; -} - -function buildTaskPrompt(task: PrdTask): string { - const lines = [ - task.description, - "", - "Subtasks:", - ...task.subtasks.map((s) => `- ${s}`), - ]; - if (task.notes) { - lines.push("", `Notes: ${task.notes}`); - } - return lines.join("\n"); -} - -export function useExecution(): UseExecutionReturn { - const [taskWorktrees, setTaskWorktrees] = useState([]); - const [executing, setExecuting] = useState(false); - const worktreeRef = useRef(new Worktree()); - const taskWorktreesRef = useRef(taskWorktrees); - taskWorktreesRef.current = taskWorktrees; - - const updateTask = useCallback( - (taskIndex: number, updates: Partial) => { - setTaskWorktrees((prev) => - prev.map((tw) => - tw.taskIndex === taskIndex ? { ...tw, ...updates } : tw, - ), - ); - }, - [], - ); - - const dispatchSingleTask = useCallback( - async (tw: TaskWorktree): Promise => { - const wt = worktreeRef.current; - - try { - updateTask(tw.taskIndex, { state: "creating" }); - - // Check if worktree already exists - const existing = await wt.list(); - let worktreeInfo = existing.find((w) => w.name === tw.worktreeName); - if (!worktreeInfo) { - worktreeInfo = await wt.create(tw.worktreeName); - } - - // Check if instance already exists at this directory - const { instances } = await daemon.listInstances(); - let instanceId: string | undefined; - const existingInstance = instances.find( - (i) => i.directory === worktreeInfo.path, - ); - - if (existingInstance) { - instanceId = existingInstance.id; - } else { - const created = await daemon.createInstance({ - name: tw.worktreeName, - directory: worktreeInfo.path, - maxConcurrency: 1, - }); - instanceId = created.instance.id; - } - - // Start instance if stopped - const instanceResult = await daemon.getInstance(instanceId); - if (instanceResult.instance.status === "stopped") { - await daemon.startInstance(instanceId); - } - - // Submit job - const prompt = buildTaskPrompt(tw.task); - const { job } = await daemon.submitJob({ - instanceId, - session: { type: "new" }, - task: { type: "prompt", prompt }, - }); - - updateTask(tw.taskIndex, { - worktreeInfo, - instanceId, - jobId: job.id, - state: "running", - }); - } catch (err) { - updateTask(tw.taskIndex, { - state: "failed", - error: err instanceof Error ? err.message : "Failed to dispatch task", - }); - } - }, - [updateTask], - ); - - const startAll = useCallback( - async (tasks: PrdTask[]) => { - if (executing) return; - setExecuting(true); - - try { - // Build entries for pending tasks not already tracked - const existingIndices = new Set( - taskWorktreesRef.current.map((tw) => tw.taskIndex), - ); - const newEntries: TaskWorktree[] = []; - - for (let i = 0; i < tasks.length; i++) { - const task = tasks[i] as PrdTask; - if (task.passed) continue; - if (existingIndices.has(i)) continue; - newEntries.push({ - taskIndex: i, - task, - worktreeName: `task-${i}`, - state: "pending", - }); - } - - // Reset previously failed tasks - setTaskWorktrees((prev) => { - const reset = prev.map((tw) => - tw.state === "failed" - ? { ...tw, state: "pending" as const, error: undefined } - : tw, - ); - return [...reset, ...newEntries]; - }); - - // Collect all tasks to dispatch - const toDispatch = [ - ...taskWorktreesRef.current.filter( - (tw) => tw.state === "failed" || tw.state === "pending", - ), - ...newEntries, - ]; - - // Dispatch all in parallel - await Promise.allSettled( - toDispatch.map((tw) => dispatchSingleTask(tw)), - ); - } finally { - setExecuting(false); - } - }, - [executing, dispatchSingleTask], - ); - - const refresh = useCallback(async () => { - const current = taskWorktreesRef.current.filter( - (tw) => tw.jobId && (tw.state === "running" || tw.state === "creating"), - ); - if (current.length === 0) return; - - const results = await Promise.allSettled( - current.map((tw) => daemon.getJob(tw.jobId as string)), - ); - - setTaskWorktrees((prev) => - prev.map((tw) => { - if (!tw.jobId || (tw.state !== "running" && tw.state !== "creating")) { - return tw; - } - const idx = current.findIndex((c) => c.taskIndex === tw.taskIndex); - if (idx === -1) return tw; - const result = results[idx]; - if (!result || result.status === "rejected") return tw; - - const job = result.value.job; - if (job.state === "succeeded") { - return { ...tw, state: "succeeded" as const }; - } - if (job.state === "failed") { - return { - ...tw, - state: "failed" as const, - error: job.error ?? "Job failed", - }; - } - if (job.state === "cancelled") { - return { ...tw, state: "cancelled" as const }; - } - return tw; - }), - ); - }, []); - - const cancelTask = useCallback( - async (taskIndex: number) => { - const tw = taskWorktreesRef.current.find( - (t) => t.taskIndex === taskIndex, - ); - if (!tw?.jobId) return; - try { - await daemon.cancelJob(tw.jobId); - updateTask(taskIndex, { state: "cancelled" }); - } catch (e) { - updateTask(taskIndex, { - state: "failed", - error: e instanceof Error ? e.message : "Failed to cancel", - }); - } - }, - [updateTask], - ); - - const cancelAll = useCallback(async () => { - const running = taskWorktreesRef.current.filter( - (tw) => tw.jobId && (tw.state === "running" || tw.state === "creating"), - ); - await Promise.allSettled(running.map((tw) => cancelTask(tw.taskIndex))); - }, [cancelTask]); - - const cleanup = useCallback(async () => { - const wt = worktreeRef.current; - const terminal = taskWorktreesRef.current.filter( - (tw) => - tw.state === "succeeded" || - tw.state === "failed" || - tw.state === "cancelled", - ); - - await Promise.allSettled( - terminal.map(async (tw) => { - if (tw.instanceId) { - try { - await daemon.removeInstance(tw.instanceId); - } catch { - // instance may already be removed - } - } - try { - await wt.remove(tw.worktreeName, { force: true }); - } catch { - // worktree may already be removed - } - }), - ); - - setTaskWorktrees((prev) => - prev.filter( - (tw) => - tw.state !== "succeeded" && - tw.state !== "failed" && - tw.state !== "cancelled", - ), - ); - }, []); - - return { - taskWorktrees, - executing, - startAll, - cancelTask, - cancelAll, - cleanup, - refresh, - }; -} diff --git a/handoff.md b/handoff.md deleted file mode 100644 index 2a852ca..0000000 --- a/handoff.md +++ /dev/null @@ -1,149 +0,0 @@ -# Handoff - -Last updated: April 24, 2026 - -## Objective - -Ralph is a coding-agent orchestration TUI built around a Plan -> Execute -> Review flow: - -1. **Plan**: chat with an agent to produce `SPEC.md` and `prd.json`. -2. **Execute**: read `prd.json`, create isolated task agents, and monitor job progress. -3. **Review**: inspect per-task diffs and approve or reject changes. - -The current product focus is the Plan view and the overall structure of that flow. The immediate blocker was OpenCode crashing before Plan-mode messages could get through. - -## OpenCode Crash Diagnosis - -The OpenCode crash shown in the terminal was: - -```text -TypeError: undefined is not an object (evaluating 'n._zod.def') -``` - -The stack trace pointed into OpenCode's bundled Zod/tool validation code. The local OpenCode plugin at: - -```text -~/.config/opencode/plugins/claude-mem.js -``` - -registered `claude_mem_search` with plain JSON-schema-style args: - -```js -args: { - query: { - type: "string", - description: "Search query for memory observations", - }, -} -``` - -OpenCode `1.14.x` expects plugin tools to be wrapped with `tool(...)` from `@opencode-ai/plugin/tool`, and tool args must be Zod schemas: - -```js -import { tool } from "@opencode-ai/plugin/tool"; - -tool({ - description: "...", - args: { - query: tool.schema.string().describe("Search query for memory observations"), - }, - async execute(args) { - // ... - }, -}); -``` - -That mismatch explains why OpenCode tried to read `_zod.def` from an object that was not a Zod schema. - -## Fix Applied - -`~/.config/opencode/plugins/claude-mem.js` was rewritten as a readable ESM plugin that: - -- imports `tool` from `@opencode-ai/plugin/tool`; -- wraps `claude_mem_search` in `tool({ ... })`; -- replaces the plain `query` arg object with `tool.schema.string().describe(...)`; -- preserves the existing worker calls, session mapping, hooks, and event behavior. - -## Secondary Issues Found - -### Stale OpenCode Server On Port 4096 - -A stale `opencode serve` process was previously listening on port `4096`. Ralph's installed OpenCode SDK defaulted to port `4096`, so a stale server can collide with new daemon/OpenCode runtime startup. - -Useful checks: - -```bash -lsof -nP -iTCP:4096 -sTCP:LISTEN -ps -p -o pid,ppid,command -``` - -Cleanup: - -```bash -kill -``` - -### Invalid Ralph Model - -Ralph's dev config had: - -```text -concentrate/kimi-k2-5 -``` - -but OpenCode reported: - -```text -Provider not found: concentrate -``` - -Known-good OpenRouter examples from the local OpenCode setup: - -```text -openrouter/anthropic/claude-sonnet-4.5 -openrouter/anthropic/claude-haiku-4.5 -openrouter/minimax/minimax-m2.7 -openrouter/moonshotai/kimi-k2 -``` - -The TUI model store lives at: - -```text -~/.config/ralph/config.json -``` - -To set a valid Ralph model from `apps/tui`: - -```bash -bun run src/cli.ts model set openrouter/anthropic/claude-sonnet-4.5 -``` - -## Verification Commands - -After patching `claude-mem`, run: - -```bash -opencode models openrouter -``` - -This should no longer crash with `_zod.def`. - -Then compare a normal OpenCode run with a pure run: - -```bash -opencode /Users/kevinpei/ralph --prompt hi --model openrouter/anthropic/claude-haiku-4.5 -opencode /Users/kevinpei/ralph --pure --prompt hi --model openrouter/anthropic/claude-haiku-4.5 -``` - -Finally, start Ralph and verify the Plan chat can submit a message and receive non-empty output: - -```bash -cd /Users/kevinpei/ralph/apps/tui -bun run dev -``` - -## Ralph Plan-View Notes - -- `project.md` describes the intended Plan -> Execute -> Review architecture. -- `roadmap.md` is partially stale: it says streaming is missing, but the repo already contains `daemon.streamJob` and TUI hooks consuming it. -- The next Ralph work should focus on making Plan chat reliable against OpenCode, then aligning the Execute flow with `prd.json` task dispatch and worktree isolation.