| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- #!/usr/bin/env npx ts-node
- /**
- * Task Management CLI
- *
- * Usage: npx ts-node task-cli.ts <command> [feature] [args...]
- *
- * Commands:
- * status [feature] - Show task status summary
- * next [feature] - Show next eligible tasks
- * parallel [feature] - Show parallelizable tasks ready to run
- * deps <feature> <seq> - Show dependency tree for a task
- * blocked [feature] - Show blocked tasks and why
- * complete <feature> <seq> "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<string, number>();
- 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<string>();
- 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 <feature> <seq>');
- 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 <feature> <seq> "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: npx ts-node task-cli.ts <command> [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 <feature> <seq> Show dependency tree for a task
- blocked [feature] Show blocked tasks and why
- complete <feature> <seq> "summary" Mark task completed with summary
- validate [feature] Validate JSON files and dependencies
- Examples:
- npx ts-node task-cli.ts status
- npx ts-node task-cli.ts next my-feature
- npx ts-node task-cli.ts complete my-feature 02 "Implemented auth module"
- `);
- }
|