feat(app): add maestro tests, download progress, and task hierarchy
Add Maestro E2E test flows for app launch and sidebar interactions. Implement download progress tracking with speed/ETA display in file explorer. Extend task CLI with parent-child hierarchy, body content from stdin, and ancestor context in task show. Fix explorer sidebar gesture to allow horizontal scrolling while preserving close swipe. Replace hardcoded monospace font with Fonts.mono constant. Fix RefreshControl to only show spinner on manual pull-to-refresh.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, openSync } from "node:fs";
|
||||
import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { FileTaskStore } from "./task-store.js";
|
||||
import type { AgentType, Task } from "./types.js";
|
||||
@@ -9,6 +9,14 @@ import type { AgentType, Task } from "./types.js";
|
||||
const TASKS_DIR = resolve(process.cwd(), ".tasks");
|
||||
const store = new FileTaskStore(TASKS_DIR);
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf-8").trim();
|
||||
}
|
||||
|
||||
const program = new Command()
|
||||
.name("task")
|
||||
.description("Minimal task management with dependency tracking")
|
||||
@@ -17,12 +25,23 @@ const program = new Command()
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
# Create an epic with subtasks (top-down)
|
||||
# Create an epic with subtasks (hierarchical)
|
||||
task create "Build auth system"
|
||||
task create "Add login endpoint" --parent abc123
|
||||
task create "Add logout endpoint" --parent abc123
|
||||
|
||||
# Create with dependencies (bottom-up)
|
||||
# Create with body from stdin (use "-" for body)
|
||||
cat spec.md | task create "Implement feature" --body -
|
||||
|
||||
# Update task body
|
||||
task update abc123 --body "New body content"
|
||||
cat updated-spec.md | task update abc123 --body -
|
||||
|
||||
# Move task to different parent
|
||||
task move abc123 --parent def456
|
||||
task move abc123 --root # make it a root task
|
||||
|
||||
# Create with dependencies (separate from hierarchy)
|
||||
task create "Setup database"
|
||||
task create "Add user model" --deps def456
|
||||
|
||||
@@ -33,6 +52,9 @@ Examples:
|
||||
task create "Future feature" --draft
|
||||
task open abc123 # make it actionable
|
||||
|
||||
# View task with parent context
|
||||
task show abc123
|
||||
|
||||
# View the work breakdown
|
||||
task tree abc123
|
||||
|
||||
@@ -47,31 +69,41 @@ Examples:
|
||||
task run abc123
|
||||
task run abc123 --agent codex
|
||||
task run --watch
|
||||
|
||||
Body vs Notes:
|
||||
The BODY is the task's markdown document - edit it while grooming/defining the task.
|
||||
NOTES are timestamped entries added during implementation to document progress.
|
||||
|
||||
- While defining a task: edit the body with "task update <id> --body ..."
|
||||
- While implementing: add notes with "task note <id> ..."
|
||||
- When done: add a final note explaining what was done, then close
|
||||
`
|
||||
);
|
||||
|
||||
program
|
||||
.command("create <title>")
|
||||
.description("Create a new task")
|
||||
.option("-d, --description <text>", "Task description")
|
||||
.option("-b, --body <text>", "Task body (use '-' to read from stdin)")
|
||||
.option("--deps <ids>", "Comma-separated dependency IDs")
|
||||
.option("--parent <id>", "Parent task (parent will depend on this new task)")
|
||||
.option("--parent <id>", "Parent task ID (for hierarchy)")
|
||||
.option("--assignee <agent>", "Agent to assign (claude or codex)")
|
||||
.option("--draft", "Create as draft (not actionable)")
|
||||
.action(async (title, opts) => {
|
||||
let body = opts.body ?? "";
|
||||
if (body === "-") {
|
||||
body = await readStdin();
|
||||
}
|
||||
|
||||
const task = await store.create(title, {
|
||||
description: opts.description,
|
||||
body,
|
||||
deps: opts.deps
|
||||
? opts.deps.split(",").map((s: string) => s.trim())
|
||||
: [],
|
||||
parentId: opts.parent,
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -80,38 +112,67 @@ program
|
||||
.alias("ls")
|
||||
.description("List all tasks")
|
||||
.option("-s, --status <status>", "Filter by status")
|
||||
.option("--roots", "Show only root tasks (no parent)")
|
||||
.action(async (opts) => {
|
||||
const tasks = await store.list();
|
||||
const filtered = opts.status
|
||||
let filtered = opts.status
|
||||
? tasks.filter((t) => t.status === opts.status)
|
||||
: tasks;
|
||||
|
||||
if (opts.roots) {
|
||||
filtered = filtered.filter((t) => !t.parentId);
|
||||
}
|
||||
|
||||
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}`);
|
||||
const parent = t.parentId ? ` ^${t.parentId}` : "";
|
||||
console.log(`${t.id} [${t.status}] ${t.title}${assignee}${parent}${deps}`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("show <id>")
|
||||
.description("Show task details")
|
||||
.description("Show task details with parent context")
|
||||
.action(async (id) => {
|
||||
const task = await store.get(id);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get ancestors (parent chain from immediate to root)
|
||||
const ancestors = await store.getAncestors(id);
|
||||
|
||||
// Print ancestors first (root to immediate parent)
|
||||
if (ancestors.length > 0) {
|
||||
console.log("# Parent Context\n");
|
||||
for (const ancestor of ancestors.reverse()) {
|
||||
console.log(`## ${ancestor.title} (${ancestor.id}) [${ancestor.status}]`);
|
||||
if (ancestor.body) {
|
||||
console.log(`\n${ancestor.body}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
console.log("---\n");
|
||||
}
|
||||
|
||||
// Print current task
|
||||
console.log(`# ${task.title}\n`);
|
||||
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.parentId) {
|
||||
console.log(`parent: ${task.parentId}`);
|
||||
}
|
||||
if (task.deps.length) {
|
||||
console.log(`deps: [${task.deps.join(", ")}]`);
|
||||
}
|
||||
if (task.body) {
|
||||
console.log(`\n${task.body}`);
|
||||
}
|
||||
if (task.notes.length) {
|
||||
console.log("\n## Notes");
|
||||
@@ -157,42 +218,65 @@ program
|
||||
|
||||
program
|
||||
.command("tree <id>")
|
||||
.description("Show dependency tree")
|
||||
.description("Show task hierarchy with dependencies")
|
||||
.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]));
|
||||
// Build a map of all tasks for dependency lookups
|
||||
const allTasks = await store.list();
|
||||
const taskMap = new Map(allTasks.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);
|
||||
// Print a task line with optional dependency info
|
||||
const printTask = (task: Task, prefix: string, connector: string) => {
|
||||
const assignee = task.assignee ? ` @${task.assignee}` : "";
|
||||
console.log(
|
||||
`${prefix}${connector}${task.id} [${task.status}] ${task.title}${assignee}`
|
||||
);
|
||||
// Print dependencies on next line with arrow
|
||||
if (task.deps.length > 0) {
|
||||
const depNames = task.deps
|
||||
.map((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep ? `${dep.title} (${depId})` : depId;
|
||||
})
|
||||
.join(", ");
|
||||
const depPrefix = prefix + (connector === "└── " ? " " : "│ ");
|
||||
console.log(`${depPrefix}→ depends on: ${depNames}`);
|
||||
}
|
||||
};
|
||||
|
||||
await printDeps(id, "");
|
||||
// Print root task
|
||||
const rootAssignee = root.assignee ? ` @${root.assignee}` : "";
|
||||
console.log(`${root.id} [${root.status}] ${root.title}${rootAssignee}`);
|
||||
if (root.deps.length > 0) {
|
||||
const depNames = root.deps
|
||||
.map((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep ? `${dep.title} (${depId})` : depId;
|
||||
})
|
||||
.join(", ");
|
||||
console.log(`→ depends on: ${depNames}`);
|
||||
}
|
||||
|
||||
// Recursively print children (hierarchy)
|
||||
const printChildren = async (parentId: string, prefix: string) => {
|
||||
const children = await store.getChildren(parentId);
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
const isLast = i === children.length - 1;
|
||||
const connector = isLast ? "└── " : "├── ";
|
||||
const childPrefix = prefix + (isLast ? " " : "│ ");
|
||||
|
||||
printTask(child, prefix, connector);
|
||||
await printChildren(child.id, childPrefix);
|
||||
}
|
||||
};
|
||||
|
||||
await printChildren(id, "");
|
||||
});
|
||||
|
||||
program
|
||||
@@ -211,6 +295,88 @@ program
|
||||
console.log(`Removed: ${id} -> ${depId}`);
|
||||
});
|
||||
|
||||
program
|
||||
.command("update <id>")
|
||||
.description("Update task properties")
|
||||
.option("-t, --title <text>", "New title")
|
||||
.option("-b, --body <text>", "New body (use '-' to read from stdin)")
|
||||
.option("--assignee <agent>", "New assignee (claude or codex)")
|
||||
.action(async (id, opts) => {
|
||||
const task = await store.get(id);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const changes: Partial<Task> = {};
|
||||
|
||||
if (opts.title) {
|
||||
changes.title = opts.title;
|
||||
}
|
||||
|
||||
if (opts.body !== undefined) {
|
||||
changes.body = opts.body === "-" ? await readStdin() : opts.body;
|
||||
}
|
||||
|
||||
if (opts.assignee) {
|
||||
changes.assignee = opts.assignee as AgentType;
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
console.error("No changes specified");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await store.update(id, changes);
|
||||
console.log(`Updated: ${id}`);
|
||||
});
|
||||
|
||||
program
|
||||
.command("move <id>")
|
||||
.description("Move task to a different parent")
|
||||
.option("--parent <id>", "New parent task ID")
|
||||
.option("--root", "Make this a root task (remove parent)")
|
||||
.action(async (id, opts) => {
|
||||
if (!opts.parent && !opts.root) {
|
||||
console.error("Must specify --parent <id> or --root");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.parent && opts.root) {
|
||||
console.error("Cannot specify both --parent and --root");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await store.setParent(id, opts.root ? null : opts.parent);
|
||||
if (opts.root) {
|
||||
console.log(`${id} is now a root task`);
|
||||
} else {
|
||||
console.log(`${id} moved to parent ${opts.parent}`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("children <id>")
|
||||
.description("List direct children of a task")
|
||||
.action(async (id) => {
|
||||
const task = await store.get(id);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const children = await store.getChildren(id);
|
||||
if (children.length === 0) {
|
||||
console.log("No children");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
const assignee = child.assignee ? ` @${child.assignee}` : "";
|
||||
console.log(`${child.id} [${child.status}] ${child.title}${assignee}`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("note <id> <content>")
|
||||
.description("Add a timestamped note")
|
||||
@@ -251,38 +417,52 @@ async function makePrompt(
|
||||
): Promise<string> {
|
||||
const scopeArg = scopeId ? ` --scope ${scopeId}` : "";
|
||||
|
||||
// Build parent context from ancestor chain
|
||||
const ancestors = await store.getAncestors(task.id);
|
||||
let parentContext = "";
|
||||
if (ancestors.length > 0) {
|
||||
parentContext = "# Parent Context\n\n";
|
||||
for (const ancestor of ancestors.reverse()) {
|
||||
parentContext += `## ${ancestor.title} (${ancestor.id})\n`;
|
||||
if (ancestor.body) {
|
||||
parentContext += `\n${ancestor.body}\n`;
|
||||
}
|
||||
parentContext += "\n";
|
||||
}
|
||||
parentContext += "---\n\n";
|
||||
}
|
||||
|
||||
let scopeContext = "";
|
||||
if (scopeId) {
|
||||
if (scopeId && !ancestors.some((a) => a.id === scopeId)) {
|
||||
const scope = await store.get(scopeId);
|
||||
if (scope) {
|
||||
scopeContext = `Scope: ${scope.title} (${scopeId})
|
||||
${scope.description ? `\n${scope.description}\n` : ""}`;
|
||||
${scope.body ? `\n${scope.body}\n` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `Working directory: ${process.cwd()}
|
||||
${scopeContext}
|
||||
---
|
||||
${scopeContext}${parentContext}
|
||||
# YOUR TASK (${task.id}): ${task.title}
|
||||
|
||||
YOUR TASK (${task.id}): ${task.title}
|
||||
|
||||
${task.description || "(no description)"}
|
||||
${task.body || "(no body)"}
|
||||
|
||||
---
|
||||
|
||||
STEPS:
|
||||
1. UNDERSTAND CONTEXT FIRST - Before any implementation:
|
||||
- Run \`task tree ${task.id}\` to see the full dependency graph
|
||||
- Run \`task show ${task.id}\` to see full context with parent chain
|
||||
- Run \`task children ${task.id}\` to see subtasks if any
|
||||
- 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 show <id>\` - view task details with parent context
|
||||
- \`task children <id>\` - list subtasks
|
||||
- \`task update <id> --body "..."\` - update task body (for grooming/clarifying)
|
||||
- \`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
|
||||
|
||||
@@ -25,7 +25,8 @@ describe("FileTaskStore", () => {
|
||||
expect(task.title).toBe("My first task");
|
||||
expect(task.status).toBe("open");
|
||||
expect(task.deps).toEqual([]);
|
||||
expect(task.description).toBe("");
|
||||
expect(task.parentId).toBeUndefined();
|
||||
expect(task.body).toBe("");
|
||||
expect(task.notes).toEqual([]);
|
||||
expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(task.assignee).toBeUndefined();
|
||||
@@ -47,16 +48,29 @@ describe("FileTaskStore", () => {
|
||||
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.",
|
||||
it("creates a task with body", async () => {
|
||||
const task = await store.create("Task with body", {
|
||||
body: "This is a **long** body\n\nWith multiple lines.",
|
||||
});
|
||||
|
||||
expect(task.description).toBe(
|
||||
"This is a **long** description\n\nWith multiple lines."
|
||||
expect(task.body).toBe(
|
||||
"This is a **long** body\n\nWith multiple lines."
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a task with parentId", async () => {
|
||||
const parent = await store.create("Parent task");
|
||||
const child = await store.create("Child task", { parentId: parent.id });
|
||||
|
||||
expect(child.parentId).toBe(parent.id);
|
||||
});
|
||||
|
||||
it("throws when creating task with non-existent parent", async () => {
|
||||
await expect(
|
||||
store.create("Child", { parentId: "nonexistent" })
|
||||
).rejects.toThrow("Parent task not found");
|
||||
});
|
||||
|
||||
it("creates a task with assignee", async () => {
|
||||
const task = await store.create("Task for Claude", {
|
||||
assignee: "claude",
|
||||
@@ -138,13 +152,13 @@ describe("FileTaskStore", () => {
|
||||
expect(updated.id).toBe(task.id);
|
||||
});
|
||||
|
||||
it("updates task description", async () => {
|
||||
it("updates task body", async () => {
|
||||
const task = await store.create("Task");
|
||||
const updated = await store.update(task.id, {
|
||||
description: "New description",
|
||||
body: "New body",
|
||||
});
|
||||
|
||||
expect(updated.description).toBe("New description");
|
||||
expect(updated.body).toBe("New body");
|
||||
});
|
||||
|
||||
it("updates task assignee", async () => {
|
||||
@@ -656,7 +670,7 @@ describe("FileTaskStore", () => {
|
||||
describe("file persistence", () => {
|
||||
it("persists tasks across store instances", async () => {
|
||||
const task = await store.create("Persistent task", {
|
||||
description: "With description",
|
||||
body: "With body",
|
||||
assignee: "claude",
|
||||
});
|
||||
await store.addNote(task.id, "A note");
|
||||
@@ -667,11 +681,152 @@ describe("FileTaskStore", () => {
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.title).toBe("Persistent task");
|
||||
expect(retrieved?.description).toBe("With description");
|
||||
expect(retrieved?.body).toBe("With body");
|
||||
expect(retrieved?.assignee).toBe("claude");
|
||||
expect(retrieved?.created).toBe(task.created);
|
||||
expect(retrieved?.notes).toHaveLength(1);
|
||||
expect(retrieved?.notes[0].content).toBe("A note");
|
||||
});
|
||||
|
||||
it("persists parentId across store instances", async () => {
|
||||
const parent = await store.create("Parent");
|
||||
const child = await store.create("Child", { parentId: parent.id });
|
||||
|
||||
const store2 = new FileTaskStore(tempDir);
|
||||
const retrieved = await store2.get(child.id);
|
||||
|
||||
expect(retrieved?.parentId).toBe(parent.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parent-child hierarchy", () => {
|
||||
describe("getAncestors", () => {
|
||||
it("returns empty array for task with no parent", async () => {
|
||||
const task = await store.create("Root task");
|
||||
|
||||
const ancestors = await store.getAncestors(task.id);
|
||||
|
||||
expect(ancestors).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns parent for child task", async () => {
|
||||
const parent = await store.create("Parent");
|
||||
const child = await store.create("Child", { parentId: parent.id });
|
||||
|
||||
const ancestors = await store.getAncestors(child.id);
|
||||
|
||||
expect(ancestors).toHaveLength(1);
|
||||
expect(ancestors[0].id).toBe(parent.id);
|
||||
});
|
||||
|
||||
it("returns full ancestor chain in order", async () => {
|
||||
const grandparent = await store.create("Grandparent");
|
||||
const parent = await store.create("Parent", {
|
||||
parentId: grandparent.id,
|
||||
});
|
||||
const child = await store.create("Child", { parentId: parent.id });
|
||||
|
||||
const ancestors = await store.getAncestors(child.id);
|
||||
|
||||
expect(ancestors).toHaveLength(2);
|
||||
expect(ancestors[0].id).toBe(parent.id);
|
||||
expect(ancestors[1].id).toBe(grandparent.id);
|
||||
});
|
||||
|
||||
it("throws for non-existent task", async () => {
|
||||
await expect(store.getAncestors("nonexistent")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChildren", () => {
|
||||
it("returns empty array for task with no children", async () => {
|
||||
const task = await store.create("Leaf task");
|
||||
|
||||
const children = await store.getChildren(task.id);
|
||||
|
||||
expect(children).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns direct children", async () => {
|
||||
const parent = await store.create("Parent");
|
||||
const child1 = await store.create("Child 1", { parentId: parent.id });
|
||||
const child2 = await store.create("Child 2", { parentId: parent.id });
|
||||
|
||||
const children = await store.getChildren(parent.id);
|
||||
|
||||
expect(children).toHaveLength(2);
|
||||
expect(children.map((c) => c.id).sort()).toEqual(
|
||||
[child1.id, child2.id].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("does not return grandchildren", async () => {
|
||||
const grandparent = await store.create("Grandparent");
|
||||
const parent = await store.create("Parent", {
|
||||
parentId: grandparent.id,
|
||||
});
|
||||
await store.create("Grandchild", { parentId: parent.id });
|
||||
|
||||
const children = await store.getChildren(grandparent.id);
|
||||
|
||||
expect(children).toHaveLength(1);
|
||||
expect(children[0].id).toBe(parent.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setParent", () => {
|
||||
it("sets parent on a task", async () => {
|
||||
const parent = await store.create("Parent");
|
||||
const task = await store.create("Task");
|
||||
|
||||
await store.setParent(task.id, parent.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.parentId).toBe(parent.id);
|
||||
});
|
||||
|
||||
it("removes parent when set to null", async () => {
|
||||
const parent = await store.create("Parent");
|
||||
const task = await store.create("Task", { parentId: parent.id });
|
||||
|
||||
await store.setParent(task.id, null);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws for non-existent task", async () => {
|
||||
const parent = await store.create("Parent");
|
||||
|
||||
await expect(store.setParent("nonexistent", parent.id)).rejects.toThrow(
|
||||
"Task not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for non-existent parent", async () => {
|
||||
const task = await store.create("Task");
|
||||
|
||||
await expect(store.setParent(task.id, "nonexistent")).rejects.toThrow(
|
||||
"Parent task not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when setting task as its own parent", async () => {
|
||||
const task = await store.create("Task");
|
||||
|
||||
await expect(store.setParent(task.id, task.id)).rejects.toThrow(
|
||||
"Task cannot be its own parent"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when creating circular reference", async () => {
|
||||
const parent = await store.create("Parent");
|
||||
const child = await store.create("Child", { parentId: parent.id });
|
||||
|
||||
await expect(store.setParent(parent.id, child.id)).rejects.toThrow(
|
||||
"circular reference"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ function serializeTask(task: Task): string {
|
||||
`created: ${task.created}`,
|
||||
];
|
||||
|
||||
if (task.parentId) {
|
||||
frontmatterLines.push(`parentId: ${task.parentId}`);
|
||||
}
|
||||
|
||||
if (task.assignee) {
|
||||
frontmatterLines.push(`assignee: ${task.assignee}`);
|
||||
}
|
||||
@@ -31,19 +35,19 @@ function serializeTask(task: Task): string {
|
||||
|
||||
const frontmatter = frontmatterLines.join("\n");
|
||||
|
||||
let body = "";
|
||||
if (task.description) {
|
||||
body += task.description + "\n";
|
||||
let content = "";
|
||||
if (task.body) {
|
||||
content += task.body + "\n";
|
||||
}
|
||||
|
||||
if (task.notes.length > 0) {
|
||||
body += "\n## Notes\n";
|
||||
content += "\n## Notes\n";
|
||||
for (const note of task.notes) {
|
||||
body += `\n**${note.timestamp}**\n\n${note.content}\n`;
|
||||
content += `\n**${note.timestamp}**\n\n${note.content}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter + "\n\n" + body;
|
||||
return frontmatter + "\n\n" + content;
|
||||
}
|
||||
|
||||
function parseTask(content: string): Task {
|
||||
@@ -53,7 +57,7 @@ function parseTask(content: string): Task {
|
||||
}
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const body = content.slice(frontmatterMatch[0].length);
|
||||
const fileBody = content.slice(frontmatterMatch[0].length);
|
||||
|
||||
const getValue = (key: string): string => {
|
||||
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
|
||||
@@ -72,7 +76,7 @@ function parseTask(content: string): Task {
|
||||
|
||||
// Parse notes from body
|
||||
const notes: Task["notes"] = [];
|
||||
const notesSection = body.match(/## Notes\n([\s\S]*?)$/);
|
||||
const notesSection = fileBody.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
|
||||
@@ -85,21 +89,23 @@ function parseTask(content: string): Task {
|
||||
}
|
||||
}
|
||||
|
||||
// Description is everything before ## Notes
|
||||
let description = body;
|
||||
// Body is everything before ## Notes (backwards compat: also check for old "description" field name)
|
||||
let taskBody = fileBody;
|
||||
if (notesSection) {
|
||||
description = body.slice(0, body.indexOf("## Notes")).trim();
|
||||
taskBody = fileBody.slice(0, fileBody.indexOf("## Notes")).trim();
|
||||
}
|
||||
description = description.trim();
|
||||
taskBody = taskBody.trim();
|
||||
|
||||
const assignee = getValue("assignee") as AgentType | "";
|
||||
const parentId = getValue("parentId");
|
||||
|
||||
return {
|
||||
id: getValue("id"),
|
||||
title: getValue("title"),
|
||||
status: getValue("status") as TaskStatus,
|
||||
deps,
|
||||
description,
|
||||
parentId: parentId || undefined,
|
||||
body: taskBody,
|
||||
notes,
|
||||
created: getValue("created") || new Date().toISOString(),
|
||||
assignee: assignee || undefined,
|
||||
@@ -148,7 +154,8 @@ export class FileTaskStore implements TaskStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
// Sort by created date (oldest first) for consistent ordering
|
||||
return tasks.sort((a, b) => a.created.localeCompare(b.created));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
@@ -192,6 +199,32 @@ export class FileTaskStore implements TaskStore {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAncestors(id: string): Promise<Task[]> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
const ancestors: Task[] = [];
|
||||
let currentId = task.parentId;
|
||||
|
||||
while (currentId) {
|
||||
const parent = await this.get(currentId);
|
||||
if (!parent) break;
|
||||
ancestors.push(parent);
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
async getChildren(id: string): Promise<Task[]> {
|
||||
const allTasks = await this.list();
|
||||
return allTasks
|
||||
.filter((t) => t.parentId === id)
|
||||
.sort((a, b) => a.created.localeCompare(b.created));
|
||||
}
|
||||
|
||||
async getReady(scopeId?: string): Promise<Task[]> {
|
||||
const allTasks = await this.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
@@ -251,19 +284,28 @@ export class FileTaskStore implements TaskStore {
|
||||
candidates = await this.list();
|
||||
}
|
||||
|
||||
// Sort by created date (most recent first) for closed tasks
|
||||
// Sort by created date (oldest first) for consistent ordering
|
||||
return candidates
|
||||
.filter((t) => t.status === "done")
|
||||
.sort((a, b) => b.created.localeCompare(a.created));
|
||||
.sort((a, b) => a.created.localeCompare(b.created));
|
||||
}
|
||||
|
||||
async create(title: string, opts?: CreateTaskOptions): Promise<Task> {
|
||||
// Validate parent exists if provided
|
||||
if (opts?.parentId) {
|
||||
const parent = await this.get(opts.parentId);
|
||||
if (!parent) {
|
||||
throw new Error(`Parent task not found: ${opts.parentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const task: Task = {
|
||||
id: generateId(),
|
||||
title,
|
||||
status: opts?.status ?? "open",
|
||||
deps: opts?.deps ?? [],
|
||||
description: opts?.description ?? "",
|
||||
parentId: opts?.parentId,
|
||||
body: opts?.body ?? "",
|
||||
notes: [],
|
||||
created: new Date().toISOString(),
|
||||
assignee: opts?.assignee,
|
||||
@@ -314,6 +356,45 @@ export class FileTaskStore implements TaskStore {
|
||||
await this.writeTask(task);
|
||||
}
|
||||
|
||||
async setParent(id: string, parentId: string | null): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parent = await this.get(parentId);
|
||||
if (!parent) {
|
||||
throw new Error(`Parent task not found: ${parentId}`);
|
||||
}
|
||||
// Prevent circular reference
|
||||
if (parentId === id) {
|
||||
throw new Error("Task cannot be its own parent");
|
||||
}
|
||||
// Check that the new parent isn't a descendant of this task
|
||||
const ancestors = await this.getAncestorsFrom(parentId);
|
||||
if (ancestors.some((a) => a.id === id)) {
|
||||
throw new Error("Cannot set parent: would create circular reference");
|
||||
}
|
||||
}
|
||||
|
||||
await this.update(id, { parentId: parentId ?? undefined });
|
||||
}
|
||||
|
||||
private async getAncestorsFrom(id: string): Promise<Task[]> {
|
||||
const ancestors: Task[] = [];
|
||||
let currentId: string | undefined = id;
|
||||
|
||||
while (currentId) {
|
||||
const task = await this.get(currentId);
|
||||
if (!task) break;
|
||||
ancestors.push(task);
|
||||
currentId = task.parentId;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
async addNote(id: string, content: string): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
|
||||
@@ -11,8 +11,9 @@ export interface Task {
|
||||
id: string; // random hash, e.g. "a1b2c3d4"
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
deps: string[];
|
||||
description: string; // long form markdown
|
||||
deps: string[]; // task IDs this task depends on (must be done before this can start)
|
||||
parentId?: string; // parent task ID for hierarchical structure (context inheritance)
|
||||
body: string; // long form markdown document
|
||||
notes: Note[];
|
||||
created: string; // ISO date
|
||||
assignee?: AgentType; // optional agent override
|
||||
@@ -20,8 +21,9 @@ export interface Task {
|
||||
|
||||
export interface CreateTaskOptions {
|
||||
deps?: string[];
|
||||
parentId?: string;
|
||||
status?: TaskStatus;
|
||||
description?: string;
|
||||
body?: string;
|
||||
assignee?: AgentType;
|
||||
}
|
||||
|
||||
@@ -30,6 +32,8 @@ export interface TaskStore {
|
||||
list(): Promise<Task[]>;
|
||||
get(id: string): Promise<Task | null>;
|
||||
getDepTree(id: string): Promise<Task[]>; // all descendants in dep graph
|
||||
getAncestors(id: string): Promise<Task[]>; // parent chain from immediate parent to root
|
||||
getChildren(id: string): Promise<Task[]>; // direct children of a task
|
||||
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
|
||||
@@ -39,6 +43,7 @@ export interface TaskStore {
|
||||
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>;
|
||||
setParent(id: string, parentId: string | null): Promise<void>;
|
||||
addNote(id: string, content: string): Promise<void>;
|
||||
|
||||
// Status transitions
|
||||
|
||||
Reference in New Issue
Block a user