feat: add task management CLI and improve activity curation

- Add task store with dependency tracking and CLI (packages/server/src/tasks/)
- Extract principal param logic to shared util for server/app reuse
- Improve activity curator: collapse timeline, dedupe tool calls, simpler output
- Add MessageInput ref for focus control after agent cancel
- Include recent activity in wait timeout messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Mohamed Boudra
2026-01-08 14:56:10 +07:00
parent 80886592ac
commit d233115ab9
17 changed files with 1779 additions and 130 deletions

9
.tasks/0c32789c.md Normal file
View File

@@ -0,0 +1,9 @@
---
id: 0c32789c
title: Add three random numbers
status: open
deps: [2769910f, 7018be3f, 537563b5]
created: 2026-01-07T16:32:47.905Z
---
Epic: We will add three random numbers together. Task 1 picks a number, Task 2 adds another, Task 3 adds a third and reports the final sum.

15
.tasks/2769910f.md Normal file
View File

@@ -0,0 +1,15 @@
---
id: 2769910f
title: Pick first random number
status: done
deps: []
created: 2026-01-07T16:32:56.827Z
---
Pick a random number between 1-100. Record it in your notes as 'Number: X'. This is the starting value.
## Notes
**2026-01-07T16:33:43.884Z**
Number: 47

15
.tasks/537563b5.md Normal file
View File

@@ -0,0 +1,15 @@
---
id: 537563b5
title: Add third random number and report final sum
status: done
deps: [7018be3f]
created: 2026-01-07T16:32:57.495Z
---
Pick a final random number between 1-100. Read previous notes to get the running total. Add your number. Record in notes: 'Added: X, FINAL SUM: Y'
## Notes
**2026-01-07T16:34:56.240Z**
Added: 42, FINAL SUM: 112

15
.tasks/7018be3f.md Normal file
View File

@@ -0,0 +1,15 @@
---
id: 7018be3f
title: Add second random number
status: done
deps: [2769910f]
created: 2026-01-07T16:32:57.142Z
---
Pick another random number between 1-100. Read the previous task's notes to get the running total. Add your number to it. Record in notes: 'Added: X, Running total: Y'
## Notes
**2026-01-07T16:34:17.347Z**
Added: 23, Running total: 70

1
package-lock.json generated
View File

@@ -21246,6 +21246,7 @@
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/python": "^1.1.18",
"@paseo/server": "*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",

View File

@@ -15,6 +15,7 @@
"test": "vitest run"
},
"dependencies": {
"@paseo/server": "*",
"@boudra/expo-two-way-audio": "^0.1.3",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",

View File

@@ -26,6 +26,7 @@ import {
MessageInput,
type MessagePayload,
type ImageAttachment,
type MessageInputRef,
} from "./message-input";
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
import { Theme } from "@/styles/theme";
@@ -132,6 +133,7 @@ export function AgentInputArea({
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef(sendAgentMessage);
const onSubmitMessageRef = useRef(onSubmitMessage);
const messageInputRef = useRef<MessageInputRef>(null);
// Expose addImages function to parent for drag-and-drop support
const addImages = useCallback((images: ImageAttachment[]) => {
@@ -338,6 +340,7 @@ export function AgentInputArea({
}
setIsCancellingAgent(true);
cancelAgentRun(agentIdRef.current);
messageInputRef.current?.focus();
}
function handleEditQueuedMessage(id: string) {
@@ -470,6 +473,7 @@ export function AgentInputArea({
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}

View File

@@ -9,7 +9,14 @@ import {
Platform,
BackHandler,
} from "react-native";
import { useState, useRef, useCallback, useEffect } from "react";
import {
useState,
useRef,
useCallback,
useEffect,
useImperativeHandle,
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, ArrowUp, Paperclip, X, Square } from "lucide-react-native";
import Animated, {
@@ -58,6 +65,10 @@ export interface MessageInputProps {
onQueue?: (payload: MessagePayload) => void;
}
export interface MessageInputRef {
focus: () => void;
}
const MIN_INPUT_HEIGHT = 30;
const MAX_INPUT_HEIGHT = 160;
const IS_WEB = Platform.OS === "web";
@@ -78,35 +89,45 @@ type TextAreaHandle = {
} & Record<string, unknown>;
};
export function MessageInput({
value,
onChangeText,
onSubmit,
isSubmitDisabled = false,
isSubmitLoading = false,
images = [],
onPickImages,
onRemoveImage,
ws,
sendAgentAudio,
placeholder = "Message...",
autoFocus = false,
disabled = false,
leftContent,
rightContent,
isAgentRunning = false,
onQueue,
}: MessageInputProps) {
const { theme } = useUnistyles();
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const textInputRef = useRef<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>(null);
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null);
const overlayTransition = useSharedValue(0);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function MessageInput(
{
value,
onChangeText,
onSubmit,
isSubmitDisabled = false,
isSubmitLoading = false,
images = [],
onPickImages,
onRemoveImage,
ws,
sendAgentAudio,
placeholder = "Message...",
autoFocus = false,
disabled = false,
leftContent,
rightContent,
isAgentRunning = false,
onQueue,
},
ref
) {
const { theme } = useUnistyles();
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const textInputRef = useRef<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>(null);
useImperativeHandle(ref, () => ({
focus: () => {
textInputRef.current?.focus();
},
}));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null);
const overlayTransition = useSharedValue(0);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
useEffect(() => {
valueRef.current = value;
@@ -566,7 +587,8 @@ export function MessageInput({
</Animated.View>
</View>
);
}
}
);
const styles = StyleSheet.create(((theme: any) => ({
container: {

View File

@@ -754,39 +754,10 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] {
}
// ---- Principal Parameter Extraction ----
const PrincipalParamSchema = z.union([
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })),
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
]);
export function stripCwdPrefix(filePath: string, cwd?: string): string {
if (!cwd || !filePath) return filePath;
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = filePath.replace(/\\/g, "/");
const prefix = `${normalizedCwd}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
if (normalizedPath === normalizedCwd) {
return ".";
}
return filePath;
}
export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined {
const parsed = PrincipalParamSchema.safeParse(args);
if (!parsed.success) {
return undefined;
}
const { type, value } = parsed.data;
return type === "path" ? stripCwdPrefix(value, cwd) : value;
}
// Re-export from server to avoid drift
export {
extractPrincipalParam,
stripCwdPrefix,
extractTodos,
type TodoItem,
} from "@paseo/server/utils/tool-call-parsers";

View File

@@ -3,6 +3,9 @@
"version": "0.1.0",
"description": "Paseo backend server",
"type": "module",
"exports": {
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
},
"scripts": {
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",

View File

@@ -1,19 +1,8 @@
import type { AgentTimelineItem, ToolCallKind } from "./agent-sdk-types.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { extractPrincipalParam } from "../../utils/tool-call-parsers.js";
const DEFAULT_MAX_ITEMS = 40;
/**
* Derive tool kind from the tool name for rendering purposes.
*/
function getToolKind(name: string): ToolCallKind {
const lower = name.toLowerCase();
if (lower === "read" || lower === "read_file") return "read";
if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit";
if (lower === "bash" || lower === "shell") return "execute";
if (lower === "grep" || lower === "glob" || lower === "web_search") return "search";
return "other";
}
function appendText(buffer: string, text: string): string {
const normalized = text.trim();
if (!normalized) {
@@ -36,37 +25,69 @@ function flushBuffers(lines: string[], buffers: { message: string; thought: stri
buffers.thought = "";
}
function isObject(value: unknown): value is { [key: string]: unknown } {
return typeof value === "object" && value !== null;
}
/**
* Collapse timeline items:
* - Dedupe tool calls by callId (pending/completed -> single)
* - Merge consecutive assistant_message/reasoning into single items
*/
function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] {
const result: AgentTimelineItem[] = [];
const toolCallMap = new Map<string, AgentTimelineItem>();
let assistantBuffer = "";
let reasoningBuffer = "";
function isFileChange(value: unknown): value is { path: string; kind: string } {
return (
isObject(value) &&
typeof value.path === "string" &&
typeof value.kind === "string"
);
}
function extractFileChanges(value: unknown): { path: string; kind: string }[] {
if (!isObject(value) || !Array.isArray(value.files)) {
return [];
function flushAssistant() {
if (assistantBuffer) {
result.push({ type: "assistant_message", text: assistantBuffer });
assistantBuffer = "";
}
}
return value.files.filter(isFileChange);
}
function extractWebQuery(value: unknown): string {
if (!isObject(value) || typeof value.query !== "string") {
return "";
function flushReasoning() {
if (reasoningBuffer) {
result.push({ type: "reasoning", text: reasoningBuffer });
reasoningBuffer = "";
}
}
return value.query;
}
function extractCommand(value: unknown): string {
if (!isObject(value) || typeof value.command !== "string") {
return "";
function flushToolCalls() {
for (const toolItem of toolCallMap.values()) {
result.push(toolItem);
}
toolCallMap.clear();
}
return value.command;
for (const item of items) {
if (item.type === "assistant_message") {
flushReasoning();
flushToolCalls();
assistantBuffer += item.text;
} else if (item.type === "reasoning") {
flushAssistant();
flushToolCalls();
reasoningBuffer += item.text;
} else if (item.type === "tool_call" && item.callId) {
flushAssistant();
flushReasoning();
toolCallMap.set(item.callId, item);
} else if (item.type === "tool_call") {
flushAssistant();
flushReasoning();
flushToolCalls();
result.push(item);
} else {
flushAssistant();
flushReasoning();
flushToolCalls();
result.push(item);
}
}
flushAssistant();
flushReasoning();
flushToolCalls();
return result;
}
/**
@@ -80,11 +101,14 @@ export function curateAgentActivity(
return "No activity to display.";
}
// Collapse timeline: dedupe tool calls, merge consecutive messages
const collapsed = collapseTimeline(timeline);
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
const recentItems =
maxItems > 0 && timeline.length > maxItems
? timeline.slice(-maxItems)
: timeline;
maxItems > 0 && collapsed.length > maxItems
? collapsed.slice(-maxItems)
: collapsed;
const lines: string[] = [];
const buffers = { message: "", thought: "" };
@@ -103,26 +127,11 @@ export function curateAgentActivity(
break;
case "tool_call": {
flushBuffers(lines, buffers);
const status = item.status ? ` ${item.status}` : "";
const kind = getToolKind(item.name);
if (kind === "execute") {
const command = extractCommand(item.input);
lines.push(`[Command: ${command || item.name}]${status}`);
} else if (kind === "edit") {
const files = extractFileChanges(item.output);
if (files.length > 0) {
lines.push("[File Changes]");
for (const file of files) {
lines.push(`- (${file.kind}) ${file.path}`);
}
} else {
lines.push(`[Edit] ${item.name}${status}`);
}
} else if (kind === "search") {
const query = extractWebQuery(item.input);
lines.push(`[Web Search] ${query || item.name}`);
const principal = extractPrincipalParam(item.input);
if (principal) {
lines.push(`[${item.name}] ${principal}`);
} else {
lines.push(`[Tool ${item.name}]${status}`);
lines.push(`[${item.name}]`);
}
break;
}
@@ -143,5 +152,5 @@ export function curateAgentActivity(
flushBuffers(lines, buffers);
return lines.length > 0 ? lines.join("\n\n") : "No activity to display.";
return lines.length > 0 ? lines.join("\n") : "No activity to display.";
}

View File

@@ -157,11 +157,13 @@ async function waitForAgentWithTimeout(
} catch (error) {
if (error instanceof Error && error.message === "wait timeout") {
const snapshot = agentManager.getAgent(agentId);
const timeline = agentManager.getTimeline(agentId);
const recentActivity = curateAgentActivity(timeline.slice(-5));
const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
return {
status: snapshot?.lifecycle ?? "idle",
permission: null,
lastMessage:
"Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.",
lastMessage: message,
};
}
throw error;

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env node
import { Command } from "commander";
import { spawnSync } from "node:child_process";
import { appendFileSync, existsSync, openSync } from "node:fs";
import { resolve } from "node:path";
import { FileTaskStore } from "./task-store.js";
import type { AgentType, Task } from "./types.js";
const TASKS_DIR = resolve(process.cwd(), ".tasks");
const store = new FileTaskStore(TASKS_DIR);
const program = new Command()
.name("task")
.description("Minimal task management with dependency tracking")
.version("0.1.0")
.addHelpText(
"after",
`
Examples:
# Create an epic with subtasks (top-down)
task create "Build auth system"
task create "Add login endpoint" --parent abc123
task create "Add logout endpoint" --parent abc123
# Create with dependencies (bottom-up)
task create "Setup database"
task create "Add user model" --deps def456
# Assign to specific agent
task create "Complex refactor" --assignee codex
# Create as draft (not actionable until opened)
task create "Future feature" --draft
task open abc123 # make it actionable
# View the work breakdown
task tree abc123
# See what's ready to work on
task ready
task ready --scope abc123
# See completed work
task closed --scope abc123
# Run agent loop on an epic
task run abc123
task run abc123 --agent codex
task run --watch
`
);
program
.command("create <title>")
.description("Create a new task")
.option("-d, --description <text>", "Task description")
.option("--deps <ids>", "Comma-separated dependency IDs")
.option("--parent <id>", "Parent task (parent will depend on this new task)")
.option("--assignee <agent>", "Agent to assign (claude or codex)")
.option("--draft", "Create as draft (not actionable)")
.action(async (title, opts) => {
const task = await store.create(title, {
description: opts.description,
deps: opts.deps
? opts.deps.split(",").map((s: string) => s.trim())
: [],
status: opts.draft ? "draft" : "open",
assignee: opts.assignee as AgentType | undefined,
});
if (opts.parent) {
await store.addDep(opts.parent, task.id);
}
console.log(task.id);
});
program
.command("list")
.alias("ls")
.description("List all tasks")
.option("-s, --status <status>", "Filter by status")
.action(async (opts) => {
const tasks = await store.list();
const filtered = opts.status
? tasks.filter((t) => t.status === opts.status)
: tasks;
for (const t of filtered) {
const deps = t.deps.length ? ` <- [${t.deps.join(", ")}]` : "";
const assignee = t.assignee ? ` @${t.assignee}` : "";
console.log(`${t.id} [${t.status}] ${t.title}${assignee}${deps}`);
}
});
program
.command("show <id>")
.description("Show task details")
.action(async (id) => {
const task = await store.get(id);
if (!task) {
console.error(`Task not found: ${id}`);
process.exit(1);
}
console.log(`id: ${task.id}`);
console.log(`title: ${task.title}`);
console.log(`status: ${task.status}`);
console.log(`created: ${task.created}`);
if (task.assignee) {
console.log(`assignee: ${task.assignee}`);
}
console.log(`deps: [${task.deps.join(", ")}]`);
if (task.description) {
console.log(`\n${task.description}`);
}
if (task.notes.length) {
console.log("\n## Notes");
for (const note of task.notes) {
console.log(`\n**${note.timestamp}**\n${note.content}`);
}
}
});
program
.command("ready")
.description("List tasks ready to work on (open + deps resolved)")
.option("--scope <id>", "Scope to epic/task dep tree")
.action(async (opts) => {
const tasks = await store.getReady(opts.scope);
for (const t of tasks) {
const assignee = t.assignee ? ` @${t.assignee}` : "";
console.log(`${t.id} ${t.title}${assignee}`);
}
});
program
.command("blocked")
.description("List tasks blocked by unresolved deps")
.option("--scope <id>", "Scope to epic/task dep tree")
.action(async (opts) => {
const tasks = await store.getBlocked(opts.scope);
for (const t of tasks) {
console.log(`${t.id} ${t.title} <- [${t.deps.join(", ")}]`);
}
});
program
.command("closed")
.description("List completed tasks")
.option("--scope <id>", "Scope to epic/task dep tree")
.action(async (opts) => {
const tasks = await store.getClosed(opts.scope);
for (const t of tasks) {
console.log(`${t.id} ${t.title}`);
}
});
program
.command("tree <id>")
.description("Show dependency tree")
.action(async (id) => {
const root = await store.get(id);
if (!root) {
console.error(`Task not found: ${id}`);
process.exit(1);
}
console.log(`${root.id} [${root.status}] ${root.title}`);
const tree = await store.getDepTree(id);
const taskMap = new Map(tree.map((t) => [t.id, t]));
const printed = new Set<string>();
const printDeps = async (taskId: string, prefix: string) => {
const task = await store.get(taskId);
if (!task) return;
const deps = task.deps.filter((d) => !printed.has(d));
for (let i = 0; i < deps.length; i++) {
const depId = deps[i];
const dep = taskMap.get(depId);
if (!dep) continue;
printed.add(depId);
const isLast = i === deps.length - 1;
const connector = isLast ? "└── " : "├── ";
const childPrefix = isLast ? " " : "│ ";
console.log(
`${prefix}${connector}${dep.id} [${dep.status}] ${dep.title}`
);
await printDeps(depId, prefix + childPrefix);
}
};
await printDeps(id, "");
});
program
.command("dep <id> <dep-id>")
.description("Add dependency (id depends on dep-id)")
.action(async (id, depId) => {
await store.addDep(id, depId);
console.log(`Added: ${id} -> ${depId}`);
});
program
.command("undep <id> <dep-id>")
.description("Remove dependency")
.action(async (id, depId) => {
await store.removeDep(id, depId);
console.log(`Removed: ${id} -> ${depId}`);
});
program
.command("note <id> <content>")
.description("Add a timestamped note")
.action(async (id, content) => {
await store.addNote(id, content);
console.log("Note added");
});
program
.command("open <id>")
.description("Mark draft as open (actionable)")
.action(async (id) => {
await store.open(id);
console.log(`${id} -> open`);
});
program
.command("start <id>")
.description("Mark as in progress")
.action(async (id) => {
await store.start(id);
console.log(`${id} -> in_progress`);
});
program
.command("close <id>")
.alias("done")
.description("Mark as done")
.action(async (id) => {
await store.close(id);
console.log(`${id} -> done`);
});
// Agent runner
async function makePrompt(
task: Task,
scopeId: string | undefined
): Promise<string> {
const scopeArg = scopeId ? ` --scope ${scopeId}` : "";
let scopeContext = "";
if (scopeId) {
const scope = await store.get(scopeId);
if (scope) {
scopeContext = `Scope: ${scope.title} (${scopeId})
${scope.description ? `\n${scope.description}\n` : ""}`;
}
}
return `Working directory: ${process.cwd()}
${scopeContext}
---
YOUR TASK (${task.id}): ${task.title}
${task.description || "(no description)"}
---
STEPS:
1. UNDERSTAND CONTEXT FIRST - Before any implementation:
- Run \`task tree ${task.id}\` to see the full dependency graph
- Run \`task closed${scopeArg}\` to see completed sibling tasks
- Run \`task show <id>\` on completed tasks to read their notes
- Understand what's been done, what decisions were made, what's planned
2. Implement the task described above
3. Add a note documenting what you did: \`task note ${task.id} "what you did"\`
4. Mark complete: \`task close ${task.id}\`
COMMANDS:
- \`task tree <id>\` - see dependency graph from any task
- \`task show <id>\` - view task details and notes
- \`task closed${scopeArg}\` - list completed tasks in scope
- \`task note ${task.id} "content"\` - add a note to your task
- \`task close ${task.id}\` - mark your task done
You MUST run \`task close ${task.id}\` when finished.
`;
}
function runAgent(prompt: string, agent: AgentType, logFile: string): boolean {
const args =
agent === "claude"
? ["--dangerously-skip-permissions", "-p", prompt]
: ["exec", "--dangerously-bypass-approvals-and-sandbox", prompt];
const fd = openSync(logFile, "a");
const result = spawnSync(agent, args, {
stdio: ["inherit", fd, fd],
cwd: process.cwd(),
});
return result.status === 0;
}
function getLogFile(): string {
let num = 0;
while (existsSync(`task-run.${num}.log`)) {
num++;
}
return `task-run.${num}.log`;
}
function log(logFile: string, message: string): void {
const timestamp = new Date().toISOString();
appendFileSync(logFile, `[${timestamp}] ${message}\n`);
}
program
.command("run [scope]")
.description("Run agent loop on tasks")
.option("--agent <type>", "Agent to use (claude or codex)", "claude")
.option("-w, --watch", "Keep running and wait for new tasks")
.action(async (scopeId: string | undefined, opts) => {
const defaultAgent = opts.agent as AgentType;
const watchMode = opts.watch;
const logFile = getLogFile();
console.log("Task Runner started");
console.log(`Agent: ${defaultAgent}`);
if (scopeId) console.log(`Scope: ${scopeId}`);
console.log(`Log: ${logFile}`);
console.log("");
log(logFile, `Started with agent=${defaultAgent} scope=${scopeId || "all"}`);
const MAX_RETRIES = 3;
const runLoop = async (): Promise<void> => {
while (true) {
const ready = await store.getReady(scopeId);
if (ready.length === 0) break;
const task = ready[0];
const agent = task.assignee || defaultAgent;
console.log(`${task.title} [${agent}]`);
log(logFile, `Starting: ${task.id} - ${task.title} [${agent}]`);
await store.start(task.id);
const prompt = await makePrompt(task, scopeId);
let attempt = 1;
let success = false;
while (attempt <= MAX_RETRIES) {
success = runAgent(prompt, agent, logFile);
if (success) break;
if (attempt < MAX_RETRIES) {
const backoff = attempt * 10;
console.log(`⚠️ Attempt ${attempt} failed, retrying in ${backoff}s...`);
log(logFile, `Attempt ${attempt} failed, retrying in ${backoff}s`);
await new Promise((r) => setTimeout(r, backoff * 1000));
}
attempt++;
}
if (!success) {
console.log(`${task.title} (failed after ${MAX_RETRIES} attempts)`);
log(logFile, `Failed: ${task.id} after ${MAX_RETRIES} attempts`);
process.exit(1);
}
// Check if agent closed the task
const updated = await store.get(task.id);
if (updated?.status !== "done") {
console.log(`⚠️ Agent did not close task ${task.id}`);
log(logFile, `Warning: agent did not close task ${task.id}`);
}
console.log(`${task.title}`);
log(logFile, `Completed: ${task.id}`);
}
};
await runLoop();
if (watchMode) {
console.log("💤 Waiting for new tasks...");
while (true) {
await new Promise((r) => setTimeout(r, 5000));
const ready = await store.getReady(scopeId);
if (ready.length > 0) {
await runLoop();
console.log("💤 Waiting for new tasks...");
}
}
}
console.log("");
console.log(`All tasks complete. (${new Date().toISOString()})`);
log(logFile, "All tasks complete");
});
program.parse();

View File

@@ -0,0 +1,677 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { FileTaskStore } from "./task-store.js";
describe("FileTaskStore", () => {
let tempDir: string;
let store: FileTaskStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "task-store-test-"));
store = new FileTaskStore(tempDir);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("create", () => {
it("creates a task with default status open", async () => {
const task = await store.create("My first task");
expect(task.id).toMatch(/^[a-f0-9]{8}$/);
expect(task.title).toBe("My first task");
expect(task.status).toBe("open");
expect(task.deps).toEqual([]);
expect(task.description).toBe("");
expect(task.notes).toEqual([]);
expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(task.assignee).toBeUndefined();
});
it("creates a task with custom status", async () => {
const task = await store.create("Draft task", { status: "draft" });
expect(task.status).toBe("draft");
});
it("creates a task with dependencies", async () => {
const dep1 = await store.create("Dependency 1");
const dep2 = await store.create("Dependency 2");
const task = await store.create("Main task", {
deps: [dep1.id, dep2.id],
});
expect(task.deps).toEqual([dep1.id, dep2.id]);
});
it("creates a task with description", async () => {
const task = await store.create("Task with desc", {
description: "This is a **long** description\n\nWith multiple lines.",
});
expect(task.description).toBe(
"This is a **long** description\n\nWith multiple lines."
);
});
it("creates a task with assignee", async () => {
const task = await store.create("Task for Claude", {
assignee: "claude",
});
expect(task.assignee).toBe("claude");
});
it("generates unique IDs for each task", async () => {
const task1 = await store.create("Task 1");
const task2 = await store.create("Task 2");
const task3 = await store.create("Task 3");
const ids = [task1.id, task2.id, task3.id];
expect(new Set(ids).size).toBe(3);
});
it("sets created timestamp", async () => {
const before = new Date().toISOString();
const task = await store.create("Task");
const after = new Date().toISOString();
expect(task.created >= before).toBe(true);
expect(task.created <= after).toBe(true);
});
});
describe("get", () => {
it("returns task by id", async () => {
const created = await store.create("Test task");
const retrieved = await store.get(created.id);
expect(retrieved).toEqual(created);
});
it("returns null for non-existent task", async () => {
const result = await store.get("nonexistent");
expect(result).toBeNull();
});
it("preserves assignee field", async () => {
const created = await store.create("Task", { assignee: "codex" });
const retrieved = await store.get(created.id);
expect(retrieved?.assignee).toBe("codex");
});
});
describe("list", () => {
it("returns empty array when no tasks", async () => {
const tasks = await store.list();
expect(tasks).toEqual([]);
});
it("returns all tasks", async () => {
await store.create("Task 1");
await store.create("Task 2");
await store.create("Task 3");
const tasks = await store.list();
expect(tasks).toHaveLength(3);
expect(tasks.map((t) => t.title).sort()).toEqual([
"Task 1",
"Task 2",
"Task 3",
]);
});
});
describe("update", () => {
it("updates task title", async () => {
const task = await store.create("Original title");
const updated = await store.update(task.id, { title: "New title" });
expect(updated.title).toBe("New title");
expect(updated.id).toBe(task.id);
});
it("updates task description", async () => {
const task = await store.create("Task");
const updated = await store.update(task.id, {
description: "New description",
});
expect(updated.description).toBe("New description");
});
it("updates task assignee", async () => {
const task = await store.create("Task");
const updated = await store.update(task.id, { assignee: "claude" });
expect(updated.assignee).toBe("claude");
});
it("persists updates", async () => {
const task = await store.create("Task");
await store.update(task.id, { title: "Updated" });
const retrieved = await store.get(task.id);
expect(retrieved?.title).toBe("Updated");
});
it("preserves created timestamp on update", async () => {
const task = await store.create("Task");
const originalCreated = task.created;
await new Promise((r) => setTimeout(r, 10));
await store.update(task.id, { title: "Updated" });
const retrieved = await store.get(task.id);
expect(retrieved?.created).toBe(originalCreated);
});
it("throws for non-existent task", async () => {
await expect(
store.update("nonexistent", { title: "New" })
).rejects.toThrow();
});
});
describe("status transitions", () => {
describe("open", () => {
it("transitions draft to open", async () => {
const task = await store.create("Draft", { status: "draft" });
await store.open(task.id);
const updated = await store.get(task.id);
expect(updated?.status).toBe("open");
});
it("throws when task is not draft", async () => {
const task = await store.create("Open task", { status: "open" });
await expect(store.open(task.id)).rejects.toThrow();
});
});
describe("start", () => {
it("transitions open to in_progress", async () => {
const task = await store.create("Task");
await store.start(task.id);
const updated = await store.get(task.id);
expect(updated?.status).toBe("in_progress");
});
it("throws when task is draft", async () => {
const task = await store.create("Draft", { status: "draft" });
await expect(store.start(task.id)).rejects.toThrow();
});
it("throws when task is already done", async () => {
const task = await store.create("Task");
await store.close(task.id);
await expect(store.start(task.id)).rejects.toThrow();
});
});
describe("close", () => {
it("transitions open to done", async () => {
const task = await store.create("Task");
await store.close(task.id);
const updated = await store.get(task.id);
expect(updated?.status).toBe("done");
});
it("transitions in_progress to done", async () => {
const task = await store.create("Task");
await store.start(task.id);
await store.close(task.id);
const updated = await store.get(task.id);
expect(updated?.status).toBe("done");
});
it("transitions draft to done", async () => {
const task = await store.create("Task", { status: "draft" });
await store.close(task.id);
const updated = await store.get(task.id);
expect(updated?.status).toBe("done");
});
});
});
describe("dependencies", () => {
describe("addDep", () => {
it("adds a dependency", async () => {
const dep = await store.create("Dependency");
const task = await store.create("Task");
await store.addDep(task.id, dep.id);
const updated = await store.get(task.id);
expect(updated?.deps).toContain(dep.id);
});
it("does not duplicate dependencies", async () => {
const dep = await store.create("Dependency");
const task = await store.create("Task");
await store.addDep(task.id, dep.id);
await store.addDep(task.id, dep.id);
const updated = await store.get(task.id);
expect(updated?.deps).toEqual([dep.id]);
});
it("throws for non-existent task", async () => {
const dep = await store.create("Dependency");
await expect(store.addDep("nonexistent", dep.id)).rejects.toThrow();
});
it("throws for non-existent dependency", async () => {
const task = await store.create("Task");
await expect(store.addDep(task.id, "nonexistent")).rejects.toThrow();
});
});
describe("removeDep", () => {
it("removes a dependency", async () => {
const dep = await store.create("Dependency");
const task = await store.create("Task", { deps: [dep.id] });
await store.removeDep(task.id, dep.id);
const updated = await store.get(task.id);
expect(updated?.deps).toEqual([]);
});
it("is idempotent for non-existent dep", async () => {
const task = await store.create("Task");
await store.removeDep(task.id, "nonexistent");
const updated = await store.get(task.id);
expect(updated?.deps).toEqual([]);
});
});
});
describe("notes", () => {
it("adds a note with timestamp", async () => {
const task = await store.create("Task");
const before = new Date().toISOString();
await store.addNote(task.id, "This is a note");
const updated = await store.get(task.id);
expect(updated?.notes).toHaveLength(1);
expect(updated?.notes[0].content).toBe("This is a note");
expect(updated?.notes[0].timestamp >= before).toBe(true);
});
it("appends multiple notes in order", async () => {
const task = await store.create("Task");
await store.addNote(task.id, "First note");
await store.addNote(task.id, "Second note");
await store.addNote(task.id, "Third note");
const updated = await store.get(task.id);
expect(updated?.notes).toHaveLength(3);
expect(updated?.notes.map((n) => n.content)).toEqual([
"First note",
"Second note",
"Third note",
]);
});
});
describe("getReady", () => {
it("returns open tasks with no deps", async () => {
const task = await store.create("Ready task");
const ready = await store.getReady();
expect(ready).toHaveLength(1);
expect(ready[0].id).toBe(task.id);
});
it("excludes draft tasks", async () => {
await store.create("Draft task", { status: "draft" });
const ready = await store.getReady();
expect(ready).toHaveLength(0);
});
it("excludes in_progress tasks", async () => {
const task = await store.create("Task");
await store.start(task.id);
const ready = await store.getReady();
expect(ready).toHaveLength(0);
});
it("excludes done tasks", async () => {
const task = await store.create("Task");
await store.close(task.id);
const ready = await store.getReady();
expect(ready).toHaveLength(0);
});
it("excludes tasks with unresolved deps", async () => {
const dep = await store.create("Dependency");
await store.create("Blocked task", { deps: [dep.id] });
const ready = await store.getReady();
expect(ready).toHaveLength(1);
expect(ready[0].id).toBe(dep.id);
});
it("includes tasks when all deps are done", async () => {
const dep = await store.create("Dependency");
const task = await store.create("Task", { deps: [dep.id] });
await store.close(dep.id);
const ready = await store.getReady();
expect(ready).toHaveLength(1);
expect(ready[0].id).toBe(task.id);
});
it("handles multiple deps correctly", async () => {
const dep1 = await store.create("Dep 1");
const dep2 = await store.create("Dep 2");
const task = await store.create("Task", { deps: [dep1.id, dep2.id] });
// Only one dep done - task not ready
await store.close(dep1.id);
let ready = await store.getReady();
expect(ready.map((t) => t.id)).not.toContain(task.id);
// Both deps done - task ready
await store.close(dep2.id);
ready = await store.getReady();
expect(ready.map((t) => t.id)).toContain(task.id);
});
it("sorts by created date (oldest first)", async () => {
const task1 = await store.create("Task 1");
await new Promise((r) => setTimeout(r, 10));
const task2 = await store.create("Task 2");
await new Promise((r) => setTimeout(r, 10));
const task3 = await store.create("Task 3");
const ready = await store.getReady();
expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]);
});
describe("scoped to epic", () => {
it("returns only ready tasks in epic dep tree", async () => {
await store.create("Unrelated task");
const dep = await store.create("Epic dep");
const epic = await store.create("Epic", { deps: [dep.id] });
const ready = await store.getReady(epic.id);
expect(ready).toHaveLength(1);
expect(ready[0].id).toBe(dep.id);
});
it("returns empty when epic has no ready deps", async () => {
const dep = await store.create("Dep", { status: "draft" });
const epic = await store.create("Epic", { deps: [dep.id] });
const ready = await store.getReady(epic.id);
expect(ready).toHaveLength(0);
});
it("handles nested deps", async () => {
const leaf = await store.create("Leaf");
const middle = await store.create("Middle", { deps: [leaf.id] });
const epic = await store.create("Epic", { deps: [middle.id] });
// Only leaf is ready initially
let ready = await store.getReady(epic.id);
expect(ready).toHaveLength(1);
expect(ready[0].id).toBe(leaf.id);
// After leaf done, middle is ready
await store.close(leaf.id);
ready = await store.getReady(epic.id);
expect(ready).toHaveLength(1);
expect(ready[0].id).toBe(middle.id);
// After middle done, epic itself is ready (but we're scoped, so epic not in results)
await store.close(middle.id);
ready = await store.getReady(epic.id);
expect(ready).toHaveLength(0);
});
});
});
describe("getBlocked", () => {
it("returns tasks with unresolved deps", async () => {
const dep = await store.create("Dependency");
const blocked = await store.create("Blocked", { deps: [dep.id] });
const result = await store.getBlocked();
expect(result).toHaveLength(1);
expect(result[0].id).toBe(blocked.id);
});
it("excludes tasks with no deps", async () => {
await store.create("No deps");
const result = await store.getBlocked();
expect(result).toHaveLength(0);
});
it("excludes tasks with all deps done", async () => {
const dep = await store.create("Dep");
await store.create("Task", { deps: [dep.id] });
await store.close(dep.id);
const result = await store.getBlocked();
expect(result).toHaveLength(0);
});
it("excludes draft tasks", async () => {
const dep = await store.create("Dep");
await store.create("Draft blocked", { status: "draft", deps: [dep.id] });
const result = await store.getBlocked();
expect(result).toHaveLength(0);
});
it("includes in_progress tasks with unresolved deps", async () => {
const dep = await store.create("Dep");
const task = await store.create("Task", { deps: [dep.id] });
// Force start even with unresolved deps (edge case)
await store.update(task.id, { status: "in_progress" });
const result = await store.getBlocked();
expect(result).toHaveLength(1);
expect(result[0].id).toBe(task.id);
});
describe("scoped to epic", () => {
it("returns only blocked tasks in epic dep tree", async () => {
const unrelatedDep = await store.create("Unrelated dep");
await store.create("Unrelated blocked", {
deps: [unrelatedDep.id],
});
const epicDep = await store.create("Epic dep");
const epicChild = await store.create("Epic child", {
deps: [epicDep.id],
});
const epic = await store.create("Epic", { deps: [epicChild.id] });
const blocked = await store.getBlocked(epic.id);
expect(blocked).toHaveLength(1);
expect(blocked[0].id).toBe(epicChild.id);
});
});
});
describe("getClosed", () => {
it("returns done tasks", async () => {
const task = await store.create("Task");
await store.close(task.id);
const closed = await store.getClosed();
expect(closed).toHaveLength(1);
expect(closed[0].id).toBe(task.id);
});
it("excludes non-done tasks", async () => {
await store.create("Open task");
await store.create("Draft task", { status: "draft" });
const inProgress = await store.create("In progress");
await store.start(inProgress.id);
const closed = await store.getClosed();
expect(closed).toHaveLength(0);
});
it("sorts by created date (most recent first)", async () => {
const task1 = await store.create("Task 1");
await new Promise((r) => setTimeout(r, 10));
const task2 = await store.create("Task 2");
await new Promise((r) => setTimeout(r, 10));
const task3 = await store.create("Task 3");
await store.close(task1.id);
await store.close(task2.id);
await store.close(task3.id);
const closed = await store.getClosed();
expect(closed.map((t) => t.id)).toEqual([task3.id, task2.id, task1.id]);
});
describe("scoped to epic", () => {
it("returns only closed tasks in epic dep tree", async () => {
const unrelated = await store.create("Unrelated");
await store.close(unrelated.id);
const dep = await store.create("Epic dep");
const epic = await store.create("Epic", { deps: [dep.id] });
await store.close(dep.id);
const closed = await store.getClosed(epic.id);
expect(closed).toHaveLength(1);
expect(closed[0].id).toBe(dep.id);
});
});
});
describe("getDepTree", () => {
it("returns empty for task with no deps", async () => {
const task = await store.create("Leaf task");
const tree = await store.getDepTree(task.id);
expect(tree).toEqual([]);
});
it("returns direct deps", async () => {
const dep1 = await store.create("Dep 1");
const dep2 = await store.create("Dep 2");
const task = await store.create("Task", { deps: [dep1.id, dep2.id] });
const tree = await store.getDepTree(task.id);
expect(tree).toHaveLength(2);
expect(tree.map((t) => t.id).sort()).toEqual([dep1.id, dep2.id].sort());
});
it("returns nested deps recursively", async () => {
const leaf = await store.create("Leaf");
const middle = await store.create("Middle", { deps: [leaf.id] });
const root = await store.create("Root", { deps: [middle.id] });
const tree = await store.getDepTree(root.id);
expect(tree).toHaveLength(2);
expect(tree.map((t) => t.id).sort()).toEqual([leaf.id, middle.id].sort());
});
it("handles diamond deps without duplicates", async () => {
const shared = await store.create("Shared");
const left = await store.create("Left", { deps: [shared.id] });
const right = await store.create("Right", { deps: [shared.id] });
const root = await store.create("Root", { deps: [left.id, right.id] });
const tree = await store.getDepTree(root.id);
expect(tree).toHaveLength(3);
expect(tree.map((t) => t.id).sort()).toEqual(
[shared.id, left.id, right.id].sort()
);
});
it("handles circular deps gracefully", async () => {
const task1 = await store.create("Task 1");
const task2 = await store.create("Task 2", { deps: [task1.id] });
await store.addDep(task1.id, task2.id); // create cycle
// Should not infinite loop
const tree = await store.getDepTree(task1.id);
expect(tree.map((t) => t.id)).toContain(task2.id);
});
it("throws for non-existent task", async () => {
await expect(store.getDepTree("nonexistent")).rejects.toThrow();
});
});
describe("file persistence", () => {
it("persists tasks across store instances", async () => {
const task = await store.create("Persistent task", {
description: "With description",
assignee: "claude",
});
await store.addNote(task.id, "A note");
// Create new store instance pointing to same dir
const store2 = new FileTaskStore(tempDir);
const retrieved = await store2.get(task.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.title).toBe("Persistent task");
expect(retrieved?.description).toBe("With description");
expect(retrieved?.assignee).toBe("claude");
expect(retrieved?.created).toBe(task.created);
expect(retrieved?.notes).toHaveLength(1);
expect(retrieved?.notes[0].content).toBe("A note");
});
});
});

View File

@@ -0,0 +1,359 @@
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import type {
Task,
TaskStore,
CreateTaskOptions,
TaskStatus,
AgentType,
} from "./types.js";
function generateId(): string {
return randomBytes(4).toString("hex");
}
function serializeTask(task: Task): string {
const frontmatterLines = [
"---",
`id: ${task.id}`,
`title: ${task.title}`,
`status: ${task.status}`,
`deps: [${task.deps.join(", ")}]`,
`created: ${task.created}`,
];
if (task.assignee) {
frontmatterLines.push(`assignee: ${task.assignee}`);
}
frontmatterLines.push("---");
const frontmatter = frontmatterLines.join("\n");
let body = "";
if (task.description) {
body += task.description + "\n";
}
if (task.notes.length > 0) {
body += "\n## Notes\n";
for (const note of task.notes) {
body += `\n**${note.timestamp}**\n\n${note.content}\n`;
}
}
return frontmatter + "\n\n" + body;
}
function parseTask(content: string): Task {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
if (!frontmatterMatch) {
throw new Error("Invalid task file: missing frontmatter");
}
const frontmatter = frontmatterMatch[1];
const body = content.slice(frontmatterMatch[0].length);
const getValue = (key: string): string => {
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
return match ? match[1] : "";
};
const depsStr = getValue("deps");
const depsMatch = depsStr.match(/\[(.*)\]/);
const deps =
depsMatch && depsMatch[1].trim()
? depsMatch[1]
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [];
// Parse notes from body
const notes: Task["notes"] = [];
const notesSection = body.match(/## Notes\n([\s\S]*?)$/);
if (notesSection) {
const noteMatches = notesSection[1].matchAll(
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g
);
for (const match of noteMatches) {
notes.push({
timestamp: match[1],
content: match[2].trim(),
});
}
}
// Description is everything before ## Notes
let description = body;
if (notesSection) {
description = body.slice(0, body.indexOf("## Notes")).trim();
}
description = description.trim();
const assignee = getValue("assignee") as AgentType | "";
return {
id: getValue("id"),
title: getValue("title"),
status: getValue("status") as TaskStatus,
deps,
description,
notes,
created: getValue("created") || new Date().toISOString(),
assignee: assignee || undefined,
};
}
export class FileTaskStore implements TaskStore {
constructor(private readonly dir: string) {}
private taskPath(id: string): string {
return join(this.dir, `${id}.md`);
}
private async ensureDir(): Promise<void> {
await mkdir(this.dir, { recursive: true });
}
private async readTask(id: string): Promise<Task | null> {
try {
const content = await readFile(this.taskPath(id), "utf-8");
return parseTask(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
private async writeTask(task: Task): Promise<void> {
await this.ensureDir();
await writeFile(this.taskPath(task.id), serializeTask(task), "utf-8");
}
async list(): Promise<Task[]> {
await this.ensureDir();
try {
const files = await readdir(this.dir);
const tasks: Task[] = [];
for (const file of files) {
if (file.endsWith(".md")) {
const id = file.slice(0, -3);
const task = await this.readTask(id);
if (task) {
tasks.push(task);
}
}
}
return tasks;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
}
async get(id: string): Promise<Task | null> {
return this.readTask(id);
}
async getDepTree(id: string): Promise<Task[]> {
const root = await this.get(id);
if (!root) {
throw new Error(`Task not found: ${id}`);
}
const visited = new Set<string>();
const result: Task[] = [];
const traverse = async (taskId: string): Promise<void> => {
if (visited.has(taskId)) return;
visited.add(taskId);
const task = await this.get(taskId);
if (!task) return;
for (const depId of task.deps) {
if (!visited.has(depId)) {
const dep = await this.get(depId);
if (dep) {
result.push(dep);
await traverse(depId);
}
}
}
};
await traverse(id);
return result;
}
async getReady(scopeId?: string): Promise<Task[]> {
const allTasks = await this.list();
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
let candidates: Task[];
if (scopeId) {
const tree = await this.getDepTree(scopeId);
candidates = tree;
} else {
candidates = allTasks;
}
const isReady = (task: Task): boolean => {
if (task.status !== "open") return false;
return task.deps.every((depId) => {
const dep = taskMap.get(depId);
return dep?.status === "done";
});
};
// Sort by created date (oldest first) for consistent ordering
return candidates.filter(isReady).sort((a, b) => {
return a.created.localeCompare(b.created);
});
}
async getBlocked(scopeId?: string): Promise<Task[]> {
const allTasks = await this.list();
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
let candidates: Task[];
if (scopeId) {
const tree = await this.getDepTree(scopeId);
candidates = tree;
} else {
candidates = allTasks;
}
const isBlocked = (task: Task): boolean => {
if (task.status === "draft" || task.status === "done") return false;
if (task.deps.length === 0) return false;
return task.deps.some((depId) => {
const dep = taskMap.get(depId);
return dep?.status !== "done";
});
};
return candidates.filter(isBlocked);
}
async getClosed(scopeId?: string): Promise<Task[]> {
let candidates: Task[];
if (scopeId) {
const tree = await this.getDepTree(scopeId);
candidates = tree;
} else {
candidates = await this.list();
}
// Sort by created date (most recent first) for closed tasks
return candidates
.filter((t) => t.status === "done")
.sort((a, b) => b.created.localeCompare(a.created));
}
async create(title: string, opts?: CreateTaskOptions): Promise<Task> {
const task: Task = {
id: generateId(),
title,
status: opts?.status ?? "open",
deps: opts?.deps ?? [],
description: opts?.description ?? "",
notes: [],
created: new Date().toISOString(),
assignee: opts?.assignee,
};
await this.writeTask(task);
return task;
}
async update(
id: string,
changes: Partial<Omit<Task, "id" | "created">>
): Promise<Task> {
const task = await this.get(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
const updated: Task = { ...task, ...changes };
await this.writeTask(updated);
return updated;
}
async addDep(id: string, depId: string): Promise<void> {
const task = await this.get(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
const dep = await this.get(depId);
if (!dep) {
throw new Error(`Dependency not found: ${depId}`);
}
if (!task.deps.includes(depId)) {
task.deps.push(depId);
await this.writeTask(task);
}
}
async removeDep(id: string, depId: string): Promise<void> {
const task = await this.get(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
task.deps = task.deps.filter((d) => d !== depId);
await this.writeTask(task);
}
async addNote(id: string, content: string): Promise<void> {
const task = await this.get(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
task.notes.push({
timestamp: new Date().toISOString(),
content,
});
await this.writeTask(task);
}
async open(id: string): Promise<void> {
const task = await this.get(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
if (task.status !== "draft") {
throw new Error(`Cannot open task with status: ${task.status}`);
}
await this.update(id, { status: "open" });
}
async start(id: string): Promise<void> {
const task = await this.get(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
if (task.status !== "open") {
throw new Error(`Cannot start task with status: ${task.status}`);
}
await this.update(id, { status: "in_progress" });
}
async close(id: string): Promise<void> {
const task = await this.get(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
await this.update(id, { status: "done" });
}
}

View File

@@ -0,0 +1,48 @@
export type TaskStatus = "draft" | "open" | "in_progress" | "done";
export type AgentType = "claude" | "codex";
export interface Note {
timestamp: string; // ISO date
content: string; // markdown
}
export interface Task {
id: string; // random hash, e.g. "a1b2c3d4"
title: string;
status: TaskStatus;
deps: string[];
description: string; // long form markdown
notes: Note[];
created: string; // ISO date
assignee?: AgentType; // optional agent override
}
export interface CreateTaskOptions {
deps?: string[];
status?: TaskStatus;
description?: string;
assignee?: AgentType;
}
export interface TaskStore {
// Queries
list(): Promise<Task[]>;
get(id: string): Promise<Task | null>;
getDepTree(id: string): Promise<Task[]>; // all descendants in dep graph
getReady(scopeId?: string): Promise<Task[]>; // open + all deps done, optionally scoped
getBlocked(scopeId?: string): Promise<Task[]>; // open/in_progress but has unresolved deps
getClosed(scopeId?: string): Promise<Task[]>; // done tasks, optionally scoped
// Mutations
create(title: string, opts?: CreateTaskOptions): Promise<Task>;
update(id: string, changes: Partial<Omit<Task, "id" | "created">>): Promise<Task>;
addDep(id: string, depId: string): Promise<void>;
removeDep(id: string, depId: string): Promise<void>;
addNote(id: string, content: string): Promise<void>;
// Status transitions
open(id: string): Promise<void>; // draft -> open
start(id: string): Promise<void>; // open -> in_progress
close(id: string): Promise<void>; // any -> done
}

View File

@@ -0,0 +1,89 @@
import { z } from "zod";
// ---- Principal Parameter Extraction ----
// Schema for file entries in arrays (e.g., apply_patch files)
const FileEntrySchema = z.object({ path: z.string() });
// Schema for TodoWrite todos array
const TodoEntrySchema = z.object({
content: z.string(),
status: z.enum(["pending", "in_progress", "completed"]),
activeForm: z.string().optional(),
});
const PrincipalParamSchema = z.union([
// Direct path keys
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
// Command as string
z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })),
// Command as array (Codex sends this)
z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "text" as const, value: d.command.join(" ") })),
// Other text params
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
// Files array (Codex apply_patch)
z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })),
// TodoWrite - show in_progress item or count
z.object({ todos: z.array(TodoEntrySchema).nonempty() }).transform((d) => {
const inProgress = d.todos.find((t) => t.status === "in_progress");
if (inProgress) {
return { type: "text" as const, value: inProgress.activeForm ?? inProgress.content };
}
return { type: "text" as const, value: `${d.todos.length} tasks` };
}),
]);
export function stripCwdPrefix(filePath: string, cwd?: string): string {
if (!cwd || !filePath) return filePath;
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = filePath.replace(/\\/g, "/");
const prefix = `${normalizedCwd}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
if (normalizedPath === normalizedCwd) {
return ".";
}
return filePath;
}
export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined {
const parsed = PrincipalParamSchema.safeParse(args);
if (!parsed.success) {
return undefined;
}
const { type, value } = parsed.data;
return type === "path" ? stripCwdPrefix(value, cwd) : value;
}
// ---- TodoWrite Extraction ----
export interface TodoItem {
content: string;
status: "pending" | "in_progress" | "completed";
activeForm?: string;
}
export function extractTodos(value: unknown): TodoItem[] {
if (typeof value !== "object" || value === null) {
return [];
}
const obj = value as Record<string, unknown>;
if (!Array.isArray(obj.todos)) {
return [];
}
return obj.todos.filter(
(t): t is TodoItem =>
typeof t === "object" &&
t !== null &&
typeof (t as Record<string, unknown>).content === "string" &&
typeof (t as Record<string, unknown>).status === "string"
);
}