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:
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+1
@@ -21246,6 +21246,7 @@
|
|||||||
"@lezer/json": "^1.0.3",
|
"@lezer/json": "^1.0.3",
|
||||||
"@lezer/markdown": "^1.6.2",
|
"@lezer/markdown": "^1.6.2",
|
||||||
"@lezer/python": "^1.1.18",
|
"@lezer/python": "^1.1.18",
|
||||||
|
"@paseo/server": "*",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@paseo/server": "*",
|
||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
MessageInput,
|
MessageInput,
|
||||||
type MessagePayload,
|
type MessagePayload,
|
||||||
type ImageAttachment,
|
type ImageAttachment,
|
||||||
|
type MessageInputRef,
|
||||||
} from "./message-input";
|
} from "./message-input";
|
||||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import { Theme } from "@/styles/theme";
|
import { Theme } from "@/styles/theme";
|
||||||
@@ -132,6 +133,7 @@ export function AgentInputArea({
|
|||||||
const agentIdRef = useRef(agentId);
|
const agentIdRef = useRef(agentId);
|
||||||
const sendAgentMessageRef = useRef(sendAgentMessage);
|
const sendAgentMessageRef = useRef(sendAgentMessage);
|
||||||
const onSubmitMessageRef = useRef(onSubmitMessage);
|
const onSubmitMessageRef = useRef(onSubmitMessage);
|
||||||
|
const messageInputRef = useRef<MessageInputRef>(null);
|
||||||
|
|
||||||
// Expose addImages function to parent for drag-and-drop support
|
// Expose addImages function to parent for drag-and-drop support
|
||||||
const addImages = useCallback((images: ImageAttachment[]) => {
|
const addImages = useCallback((images: ImageAttachment[]) => {
|
||||||
@@ -338,6 +340,7 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
setIsCancellingAgent(true);
|
setIsCancellingAgent(true);
|
||||||
cancelAgentRun(agentIdRef.current);
|
cancelAgentRun(agentIdRef.current);
|
||||||
|
messageInputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditQueuedMessage(id: string) {
|
function handleEditQueuedMessage(id: string) {
|
||||||
@@ -470,6 +473,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||||
<MessageInput
|
<MessageInput
|
||||||
|
ref={messageInputRef}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
BackHandler,
|
BackHandler,
|
||||||
} from "react-native";
|
} 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Mic, ArrowUp, Paperclip, X, Square } from "lucide-react-native";
|
import { Mic, ArrowUp, Paperclip, X, Square } from "lucide-react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -58,6 +65,10 @@ export interface MessageInputProps {
|
|||||||
onQueue?: (payload: MessagePayload) => void;
|
onQueue?: (payload: MessagePayload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageInputRef {
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const MIN_INPUT_HEIGHT = 30;
|
const MIN_INPUT_HEIGHT = 30;
|
||||||
const MAX_INPUT_HEIGHT = 160;
|
const MAX_INPUT_HEIGHT = 160;
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
@@ -78,35 +89,45 @@ type TextAreaHandle = {
|
|||||||
} & Record<string, unknown>;
|
} & Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MessageInput({
|
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||||
value,
|
function MessageInput(
|
||||||
onChangeText,
|
{
|
||||||
onSubmit,
|
value,
|
||||||
isSubmitDisabled = false,
|
onChangeText,
|
||||||
isSubmitLoading = false,
|
onSubmit,
|
||||||
images = [],
|
isSubmitDisabled = false,
|
||||||
onPickImages,
|
isSubmitLoading = false,
|
||||||
onRemoveImage,
|
images = [],
|
||||||
ws,
|
onPickImages,
|
||||||
sendAgentAudio,
|
onRemoveImage,
|
||||||
placeholder = "Message...",
|
ws,
|
||||||
autoFocus = false,
|
sendAgentAudio,
|
||||||
disabled = false,
|
placeholder = "Message...",
|
||||||
leftContent,
|
autoFocus = false,
|
||||||
rightContent,
|
disabled = false,
|
||||||
isAgentRunning = false,
|
leftContent,
|
||||||
onQueue,
|
rightContent,
|
||||||
}: MessageInputProps) {
|
isAgentRunning = false,
|
||||||
const { theme } = useUnistyles();
|
onQueue,
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
},
|
||||||
const textInputRef = useRef<
|
ref
|
||||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
) {
|
||||||
>(null);
|
const { theme } = useUnistyles();
|
||||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
const baselineInputHeightRef = useRef<number | null>(null);
|
const textInputRef = useRef<
|
||||||
const overlayTransition = useSharedValue(0);
|
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||||
const sendAfterTranscriptRef = useRef(false);
|
>(null);
|
||||||
const valueRef = useRef(value);
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
valueRef.current = value;
|
valueRef.current = value;
|
||||||
@@ -566,7 +587,8 @@ export function MessageInput({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const styles = StyleSheet.create(((theme: any) => ({
|
const styles = StyleSheet.create(((theme: any) => ({
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
@@ -754,39 +754,10 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Principal Parameter Extraction ----
|
// ---- Principal Parameter Extraction ----
|
||||||
|
// Re-export from server to avoid drift
|
||||||
const PrincipalParamSchema = z.union([
|
export {
|
||||||
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
|
extractPrincipalParam,
|
||||||
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
|
stripCwdPrefix,
|
||||||
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
|
extractTodos,
|
||||||
z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })),
|
type TodoItem,
|
||||||
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
|
} from "@paseo/server/utils/tool-call-parsers";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||||
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||||
|
|||||||
@@ -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;
|
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 {
|
function appendText(buffer: string, text: string): string {
|
||||||
const normalized = text.trim();
|
const normalized = text.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -36,37 +25,69 @@ function flushBuffers(lines: string[], buffers: { message: string; thought: stri
|
|||||||
buffers.thought = "";
|
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 } {
|
function flushAssistant() {
|
||||||
return (
|
if (assistantBuffer) {
|
||||||
isObject(value) &&
|
result.push({ type: "assistant_message", text: assistantBuffer });
|
||||||
typeof value.path === "string" &&
|
assistantBuffer = "";
|
||||||
typeof value.kind === "string"
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractFileChanges(value: unknown): { path: string; kind: string }[] {
|
|
||||||
if (!isObject(value) || !Array.isArray(value.files)) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
return value.files.filter(isFileChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractWebQuery(value: unknown): string {
|
function flushReasoning() {
|
||||||
if (!isObject(value) || typeof value.query !== "string") {
|
if (reasoningBuffer) {
|
||||||
return "";
|
result.push({ type: "reasoning", text: reasoningBuffer });
|
||||||
|
reasoningBuffer = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return value.query;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCommand(value: unknown): string {
|
function flushToolCalls() {
|
||||||
if (!isObject(value) || typeof value.command !== "string") {
|
for (const toolItem of toolCallMap.values()) {
|
||||||
return "";
|
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.";
|
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 maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||||
const recentItems =
|
const recentItems =
|
||||||
maxItems > 0 && timeline.length > maxItems
|
maxItems > 0 && collapsed.length > maxItems
|
||||||
? timeline.slice(-maxItems)
|
? collapsed.slice(-maxItems)
|
||||||
: timeline;
|
: collapsed;
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const buffers = { message: "", thought: "" };
|
const buffers = { message: "", thought: "" };
|
||||||
@@ -103,26 +127,11 @@ export function curateAgentActivity(
|
|||||||
break;
|
break;
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
flushBuffers(lines, buffers);
|
flushBuffers(lines, buffers);
|
||||||
const status = item.status ? ` ${item.status}` : "";
|
const principal = extractPrincipalParam(item.input);
|
||||||
const kind = getToolKind(item.name);
|
if (principal) {
|
||||||
if (kind === "execute") {
|
lines.push(`[${item.name}] ${principal}`);
|
||||||
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}`);
|
|
||||||
} else {
|
} else {
|
||||||
lines.push(`[Tool ${item.name}]${status}`);
|
lines.push(`[${item.name}]`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -143,5 +152,5 @@ export function curateAgentActivity(
|
|||||||
|
|
||||||
flushBuffers(lines, buffers);
|
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.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,11 +157,13 @@ async function waitForAgentWithTimeout(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "wait timeout") {
|
if (error instanceof Error && error.message === "wait timeout") {
|
||||||
const snapshot = agentManager.getAgent(agentId);
|
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 {
|
return {
|
||||||
status: snapshot?.lifecycle ?? "idle",
|
status: snapshot?.lifecycle ?? "idle",
|
||||||
permission: null,
|
permission: null,
|
||||||
lastMessage:
|
lastMessage: message,
|
||||||
"Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user