#!/usr/bin/env bunx --bun ts-node /** * Task Management CLI * * Usage: bunx --bun ts-node task-cli.ts [feature] [args...] * * Commands: * status [feature] - Show task status summary * next [feature] - Show next eligible tasks * parallel [feature] - Show parallelizable tasks ready to run * deps - Show dependency tree for a task * blocked [feature] - Show blocked tasks and why * complete "summary" - Mark task completed * validate [feature] - Validate JSON files and dependencies * * Task files are stored in .tmp/tasks/ at the project root: * .tmp/tasks/{feature-slug}/task.json * .tmp/tasks/{feature-slug}/subtask_01.json * .tmp/tasks/completed/{feature-slug}/ */ const fs = require("fs"); const path = require("path"); // Find project root (look for .git or package.json) function findProjectRoot(): string { let dir = process.cwd(); while (dir !== path.dirname(dir)) { if ( fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, "package.json")) ) { return dir; } dir = path.dirname(dir); } return process.cwd(); } const PROJECT_ROOT = findProjectRoot(); const TASKS_DIR = path.join(PROJECT_ROOT, ".tmp", "tasks"); const COMPLETED_DIR = path.join(TASKS_DIR, "completed"); interface Task { id: string; name: string; status: "active" | "completed" | "blocked" | "archived"; objective: string; context_files: string[]; reference_files?: string[]; exit_criteria: string[]; subtask_count: number; completed_count: number; created_at: string; completed_at: string | null; } interface Subtask { id: string; seq: string; title: string; status: "pending" | "in_progress" | "completed" | "blocked"; depends_on: string[]; parallel: boolean; context_files: string[]; reference_files?: string[]; acceptance_criteria: string[]; deliverables: string[]; agent_id: string | null; suggested_agent?: string; started_at: string | null; completed_at: string | null; completion_summary: string | null; } // Helpers function getFeatureDirs(): string[] { if (!fs.existsSync(TASKS_DIR)) return []; return fs.readdirSync(TASKS_DIR).filter((f: string) => { const fullPath = path.join(TASKS_DIR, f); return fs.statSync(fullPath).isDirectory() && f !== "completed"; }); } function loadTask(feature: string): Task | null { const taskPath = path.join(TASKS_DIR, feature, "task.json"); if (!fs.existsSync(taskPath)) return null; return JSON.parse(fs.readFileSync(taskPath, "utf-8")); } function loadSubtasks(feature: string): Subtask[] { const featureDir = path.join(TASKS_DIR, feature); if (!fs.existsSync(featureDir)) return []; const files = fs .readdirSync(featureDir) .filter((f: string) => f.match(/^subtask_\d{2}\.json$/)) .sort(); return files.map((f: string) => JSON.parse(fs.readFileSync(path.join(featureDir, f), "utf-8")), ); } function saveSubtask(feature: string, subtask: Subtask): void { const subtaskPath = path.join( TASKS_DIR, feature, `subtask_${subtask.seq}.json`, ); fs.writeFileSync(subtaskPath, JSON.stringify(subtask, null, 2)); } function saveTask(feature: string, task: Task): void { const taskPath = path.join(TASKS_DIR, feature, "task.json"); fs.writeFileSync(taskPath, JSON.stringify(task, null, 2)); } // Commands function cmdStatus(feature?: string): void { const features = feature ? [feature] : getFeatureDirs(); if (features.length === 0) { console.log("No active features found."); return; } for (const f of features) { const task = loadTask(f); const subtasks = loadSubtasks(f); if (!task) { console.log(`\n[${f}] - No task.json found`); continue; } const counts = { pending: subtasks.filter((s) => s.status === "pending").length, in_progress: subtasks.filter((s) => s.status === "in_progress").length, completed: subtasks.filter((s) => s.status === "completed").length, blocked: subtasks.filter((s) => s.status === "blocked").length, }; const progress = subtasks.length > 0 ? Math.round((counts.completed / subtasks.length) * 100) : 0; console.log(`\n[${f}] ${task.name}`); console.log( ` Status: ${task.status} | Progress: ${progress}% (${counts.completed}/${subtasks.length})`, ); console.log( ` Pending: ${counts.pending} | In Progress: ${counts.in_progress} | Completed: ${counts.completed} | Blocked: ${counts.blocked}`, ); } } function cmdNext(feature?: string): void { const features = feature ? [feature] : getFeatureDirs(); console.log("\n=== Ready Tasks (deps satisfied) ===\n"); for (const f of features) { const subtasks = loadSubtasks(f); const completedSeqs = new Set( subtasks.filter((s) => s.status === "completed").map((s) => s.seq), ); const ready = subtasks.filter((s) => { if (s.status !== "pending") return false; return s.depends_on.every((dep) => completedSeqs.has(dep)); }); if (ready.length > 0) { console.log(`[${f}]`); for (const s of ready) { const parallel = s.parallel ? "[parallel]" : "[sequential]"; console.log(` ${s.seq} - ${s.title} ${parallel}`); } console.log(); } } } function cmdParallel(feature?: string): void { const features = feature ? [feature] : getFeatureDirs(); console.log("\n=== Parallelizable Tasks Ready Now ===\n"); for (const f of features) { const subtasks = loadSubtasks(f); const completedSeqs = new Set( subtasks.filter((s) => s.status === "completed").map((s) => s.seq), ); const parallel = subtasks.filter((s) => { if (s.status !== "pending") return false; if (!s.parallel) return false; return s.depends_on.every((dep) => completedSeqs.has(dep)); }); if (parallel.length > 0) { console.log(`[${f}] - ${parallel.length} parallel tasks:`); for (const s of parallel) { console.log(` ${s.seq} - ${s.title}`); } console.log(); } } } function cmdDeps(feature: string, seq: string): void { const subtasks = loadSubtasks(feature); const target = subtasks.find((s) => s.seq === seq); if (!target) { console.log(`Task ${seq} not found in ${feature}`); return; } console.log(`\n=== Dependency Tree: ${feature}/${seq} ===\n`); console.log(`${seq} - ${target.title} [${target.status}]`); if (target.depends_on.length === 0) { console.log(" └── (no dependencies)"); return; } const printDeps = (seqs: string[], indent: string = " "): void => { for (let i = 0; i < seqs.length; i++) { const depSeq = seqs[i]; const dep = subtasks.find((s) => s.seq === depSeq); const isLast = i === seqs.length - 1; const branch = isLast ? "└──" : "├──"; if (dep) { const statusIcon = dep.status === "completed" ? "✓" : dep.status === "in_progress" ? "~" : "○"; console.log( `${indent}${branch} ${statusIcon} ${depSeq} - ${dep.title} [${dep.status}]`, ); if (dep.depends_on.length > 0) { const newIndent = indent + (isLast ? " " : "│ "); printDeps(dep.depends_on, newIndent); } } else { console.log(`${indent}${branch} ? ${depSeq} - NOT FOUND`); } } }; printDeps(target.depends_on); } function cmdBlocked(feature?: string): void { const features = feature ? [feature] : getFeatureDirs(); console.log("\n=== Blocked Tasks ===\n"); for (const f of features) { const subtasks = loadSubtasks(f); const completedSeqs = new Set( subtasks.filter((s) => s.status === "completed").map((s) => s.seq), ); const blocked = subtasks.filter((s) => { if (s.status === "blocked") return true; if (s.status !== "pending") return false; return !s.depends_on.every((dep) => completedSeqs.has(dep)); }); if (blocked.length > 0) { console.log(`[${f}]`); for (const s of blocked) { const waitingFor = s.depends_on.filter( (dep) => !completedSeqs.has(dep), ); const reason = s.status === "blocked" ? "explicitly blocked" : `waiting: ${waitingFor.join(", ")}`; console.log(` ${s.seq} - ${s.title} (${reason})`); } console.log(); } } } function cmdComplete(feature: string, seq: string, summary: string): void { if (summary.length > 200) { console.log("Error: Summary must be max 200 characters"); process.exit(1); } const subtasks = loadSubtasks(feature); const subtask = subtasks.find((s) => s.seq === seq); if (!subtask) { console.log(`Task ${seq} not found in ${feature}`); process.exit(1); } subtask.status = "completed"; subtask.completed_at = new Date().toISOString(); subtask.completion_summary = summary; saveSubtask(feature, subtask); // Update task.json counts const task = loadTask(feature); if (task) { const newSubtasks = loadSubtasks(feature); task.completed_count = newSubtasks.filter( (s) => s.status === "completed", ).length; saveTask(feature, task); } console.log(`\n✓ Marked ${feature}/${seq} as completed`); console.log(` Summary: ${summary}`); if (task) { console.log(` Progress: ${task.completed_count}/${task.subtask_count}`); } } function cmdValidate(feature?: string): void { const features = feature ? [feature] : getFeatureDirs(); let hasErrors = false; const validTaskStatuses = new Set([ "active", "completed", "blocked", "archived", ]); const validSubtaskStatuses = new Set([ "pending", "in_progress", "completed", "blocked", ]); const requiredTaskFields = [ "id", "name", "status", "objective", "context_files", "exit_criteria", "subtask_count", "completed_count", "created_at", "completed_at", ]; const requiredSubtaskFields = [ "id", "seq", "title", "status", "depends_on", "parallel", "context_files", "acceptance_criteria", "deliverables", "agent_id", "started_at", "completed_at", "completion_summary", ]; const hasField = (obj: any, field: string): boolean => Object.prototype.hasOwnProperty.call(obj, field); const isStringArray = (value: any): boolean => Array.isArray(value) && value.every((v) => typeof v === "string"); console.log("\n=== Validation Results ===\n"); for (const f of features) { const errors: string[] = []; // Check task.json exists const task = loadTask(f); if (!task) { errors.push("Missing task.json"); } // Load and validate subtasks const subtasks = loadSubtasks(f); const seqCounts = new Map(); for (const s of subtasks) { const seq = typeof s.seq === "string" ? s.seq : ""; seqCounts.set(seq, (seqCounts.get(seq) || 0) + 1); } const seqs = new Set(subtasks.map((s) => s.seq)); if (task) { // Required fields in task.json for (const field of requiredTaskFields) { if (!hasField(task, field)) { errors.push(`task.json: missing required field '${field}'`); } } // Task ID should match feature slug if (task.id !== f) { errors.push( `task.json id ('${task.id}') should match feature slug ('${f}')`, ); } // Task status should be valid if (!validTaskStatuses.has(task.status)) { errors.push(`task.json: invalid status '${task.status}'`); } // Basic type checks for key task fields if (!isStringArray(task.context_files)) { errors.push("task.json: context_files must be string[]"); } if ( hasField(task, "reference_files") && task.reference_files !== undefined && !isStringArray(task.reference_files) ) { errors.push("task.json: reference_files must be string[] when present"); } if (!isStringArray(task.exit_criteria)) { errors.push("task.json: exit_criteria must be string[]"); } if (typeof task.subtask_count !== "number") { errors.push("task.json: subtask_count must be number"); } if (typeof task.completed_count !== "number") { errors.push("task.json: completed_count must be number"); } } for (const s of subtasks) { // Required fields in subtask files for (const field of requiredSubtaskFields) { if (!hasField(s, field)) { errors.push(`${s.seq || "??"}: missing required field '${field}'`); } } // Sequence format and uniqueness if (!/^\d{2}$/.test(s.seq)) { errors.push(`${s.seq}: sequence must be 2 digits (e.g., 01, 02)`); } if ((seqCounts.get(s.seq) || 0) > 1) { errors.push(`${s.seq}: duplicate sequence number`); } // Check ID format if (!s.id.startsWith(f)) { errors.push(`${s.seq}: ID should start with feature name`); } // Status should be valid if (!validSubtaskStatuses.has(s.status)) { errors.push(`${s.seq}: invalid status '${s.status}'`); } // Type checks if (!isStringArray(s.depends_on)) { errors.push(`${s.seq}: depends_on must be string[]`); } if (typeof s.parallel !== "boolean") { errors.push(`${s.seq}: parallel must be boolean`); } if (!isStringArray(s.context_files)) { errors.push(`${s.seq}: context_files must be string[]`); } if ( hasField(s, "reference_files") && s.reference_files !== undefined && !isStringArray(s.reference_files) ) { errors.push(`${s.seq}: reference_files must be string[] when present`); } if (!isStringArray(s.acceptance_criteria)) { errors.push(`${s.seq}: acceptance_criteria must be string[]`); } else if (s.acceptance_criteria.length === 0) { errors.push(`${s.seq}: No acceptance criteria defined`); } if (!isStringArray(s.deliverables)) { errors.push(`${s.seq}: deliverables must be string[]`); } else if (s.deliverables.length === 0) { errors.push(`${s.seq}: No deliverables defined`); } // Self dependency is invalid if (Array.isArray(s.depends_on) && s.depends_on.includes(s.seq)) { errors.push(`${s.seq}: task cannot depend on itself`); } // Check for missing dependencies for (const dep of Array.isArray(s.depends_on) ? s.depends_on : []) { if (!seqs.has(dep)) { errors.push(`${s.seq}: depends on non-existent task ${dep}`); } } // Check for circular dependencies const visited = new Set(); const checkCircular = (seq: string, path: string[]): boolean => { if (path.includes(seq)) { errors.push( `${s.seq}: circular dependency detected: ${[...path, seq].join(" -> ")}`, ); return true; } if (visited.has(seq)) return false; visited.add(seq); const task = subtasks.find((t) => t.seq === seq); if (task) { for (const dep of task.depends_on) { if (checkCircular(dep, [...path, seq])) return true; } } return false; }; checkCircular(s.seq, []); } // Check counts match if (task && task.subtask_count !== subtasks.length) { errors.push( `task.json subtask_count (${task.subtask_count}) doesn't match actual count (${subtasks.length})`, ); } // Print results console.log(`[${f}]`); if (errors.length === 0) { console.log(" ✓ All checks passed"); } else { for (const e of errors) { console.log(` ✗ ERROR: ${e}`); hasErrors = true; } } console.log(); } process.exit(hasErrors ? 1 : 0); } // Main const [, , command, ...args] = process.argv; switch (command) { case "status": cmdStatus(args[0]); break; case "next": cmdNext(args[0]); break; case "parallel": cmdParallel(args[0]); break; case "deps": if (args.length < 2) { console.log("Usage: deps "); process.exit(1); } cmdDeps(args[0], args[1]); break; case "blocked": cmdBlocked(args[0]); break; case "complete": if (args.length < 3) { console.log('Usage: complete "summary"'); process.exit(1); } cmdComplete(args[0], args[1], args.slice(2).join(" ")); break; case "validate": cmdValidate(args[0]); break; default: console.log(` Task Management CLI Usage: bunx --bun ts-node task-cli.ts [feature] [args...] Task files are stored in: .tmp/tasks/{feature-slug}/ Commands: status [feature] Show task status summary next [feature] Show next eligible tasks (deps satisfied) parallel [feature] Show parallelizable tasks ready to run deps Show dependency tree for a task blocked [feature] Show blocked tasks and why complete "summary" Mark task completed with summary validate [feature] Validate JSON files and dependencies Examples: bunx --bun ts-node task-cli.ts status bunx --bun ts-node task-cli.ts next my-feature bunx --bun ts-node task-cli.ts complete my-feature 02 "Implemented auth module" `); }