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,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
|
||||
}
|
||||
Reference in New Issue
Block a user