task-cli.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. #!/usr/bin/env npx ts-node
  2. /**
  3. * Task Management CLI
  4. *
  5. * Usage: npx ts-node task-cli.ts <command> [feature] [args...]
  6. *
  7. * Commands:
  8. * status [feature] - Show task status summary
  9. * next [feature] - Show next eligible tasks
  10. * parallel [feature] - Show parallelizable tasks ready to run
  11. * deps <feature> <seq> - Show dependency tree for a task
  12. * blocked [feature] - Show blocked tasks and why
  13. * complete <feature> <seq> "summary" - Mark task completed
  14. * validate [feature] - Validate JSON files and dependencies
  15. *
  16. * Task files are stored in .tmp/tasks/ at the project root:
  17. * .tmp/tasks/{feature-slug}/task.json
  18. * .tmp/tasks/{feature-slug}/subtask_01.json
  19. * .tmp/tasks/completed/{feature-slug}/
  20. */
  21. const fs = require('fs');
  22. const path = require('path');
  23. // Find project root (look for .git or package.json)
  24. function findProjectRoot(): string {
  25. let dir = process.cwd();
  26. while (dir !== path.dirname(dir)) {
  27. if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'package.json'))) {
  28. return dir;
  29. }
  30. dir = path.dirname(dir);
  31. }
  32. return process.cwd();
  33. }
  34. const PROJECT_ROOT = findProjectRoot();
  35. const TASKS_DIR = path.join(PROJECT_ROOT, '.tmp', 'tasks');
  36. const COMPLETED_DIR = path.join(TASKS_DIR, 'completed');
  37. interface Task {
  38. id: string;
  39. name: string;
  40. status: 'active' | 'completed' | 'blocked' | 'archived';
  41. objective: string;
  42. context_files: string[];
  43. reference_files?: string[];
  44. exit_criteria: string[];
  45. subtask_count: number;
  46. completed_count: number;
  47. created_at: string;
  48. completed_at: string | null;
  49. }
  50. interface Subtask {
  51. id: string;
  52. seq: string;
  53. title: string;
  54. status: 'pending' | 'in_progress' | 'completed' | 'blocked';
  55. depends_on: string[];
  56. parallel: boolean;
  57. context_files: string[];
  58. reference_files?: string[];
  59. acceptance_criteria: string[];
  60. deliverables: string[];
  61. agent_id: string | null;
  62. suggested_agent?: string;
  63. started_at: string | null;
  64. completed_at: string | null;
  65. completion_summary: string | null;
  66. }
  67. // Helpers
  68. function getFeatureDirs(): string[] {
  69. if (!fs.existsSync(TASKS_DIR)) return [];
  70. return fs.readdirSync(TASKS_DIR).filter((f: string) => {
  71. const fullPath = path.join(TASKS_DIR, f);
  72. return fs.statSync(fullPath).isDirectory() && f !== 'completed';
  73. });
  74. }
  75. function loadTask(feature: string): Task | null {
  76. const taskPath = path.join(TASKS_DIR, feature, 'task.json');
  77. if (!fs.existsSync(taskPath)) return null;
  78. return JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
  79. }
  80. function loadSubtasks(feature: string): Subtask[] {
  81. const featureDir = path.join(TASKS_DIR, feature);
  82. if (!fs.existsSync(featureDir)) return [];
  83. const files = fs.readdirSync(featureDir)
  84. .filter((f: string) => f.match(/^subtask_\d{2}\.json$/))
  85. .sort();
  86. return files.map((f: string) => JSON.parse(fs.readFileSync(path.join(featureDir, f), 'utf-8')));
  87. }
  88. function saveSubtask(feature: string, subtask: Subtask): void {
  89. const subtaskPath = path.join(TASKS_DIR, feature, `subtask_${subtask.seq}.json`);
  90. fs.writeFileSync(subtaskPath, JSON.stringify(subtask, null, 2));
  91. }
  92. function saveTask(feature: string, task: Task): void {
  93. const taskPath = path.join(TASKS_DIR, feature, 'task.json');
  94. fs.writeFileSync(taskPath, JSON.stringify(task, null, 2));
  95. }
  96. // Commands
  97. function cmdStatus(feature?: string): void {
  98. const features = feature ? [feature] : getFeatureDirs();
  99. if (features.length === 0) {
  100. console.log('No active features found.');
  101. return;
  102. }
  103. for (const f of features) {
  104. const task = loadTask(f);
  105. const subtasks = loadSubtasks(f);
  106. if (!task) {
  107. console.log(`\n[${f}] - No task.json found`);
  108. continue;
  109. }
  110. const counts = {
  111. pending: subtasks.filter(s => s.status === 'pending').length,
  112. in_progress: subtasks.filter(s => s.status === 'in_progress').length,
  113. completed: subtasks.filter(s => s.status === 'completed').length,
  114. blocked: subtasks.filter(s => s.status === 'blocked').length,
  115. };
  116. const progress = subtasks.length > 0
  117. ? Math.round((counts.completed / subtasks.length) * 100)
  118. : 0;
  119. console.log(`\n[${f}] ${task.name}`);
  120. console.log(` Status: ${task.status} | Progress: ${progress}% (${counts.completed}/${subtasks.length})`);
  121. console.log(` Pending: ${counts.pending} | In Progress: ${counts.in_progress} | Completed: ${counts.completed} | Blocked: ${counts.blocked}`);
  122. }
  123. }
  124. function cmdNext(feature?: string): void {
  125. const features = feature ? [feature] : getFeatureDirs();
  126. console.log('\n=== Ready Tasks (deps satisfied) ===\n');
  127. for (const f of features) {
  128. const subtasks = loadSubtasks(f);
  129. const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq));
  130. const ready = subtasks.filter(s => {
  131. if (s.status !== 'pending') return false;
  132. return s.depends_on.every(dep => completedSeqs.has(dep));
  133. });
  134. if (ready.length > 0) {
  135. console.log(`[${f}]`);
  136. for (const s of ready) {
  137. const parallel = s.parallel ? '[parallel]' : '[sequential]';
  138. console.log(` ${s.seq} - ${s.title} ${parallel}`);
  139. }
  140. console.log();
  141. }
  142. }
  143. }
  144. function cmdParallel(feature?: string): void {
  145. const features = feature ? [feature] : getFeatureDirs();
  146. console.log('\n=== Parallelizable Tasks Ready Now ===\n');
  147. for (const f of features) {
  148. const subtasks = loadSubtasks(f);
  149. const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq));
  150. const parallel = subtasks.filter(s => {
  151. if (s.status !== 'pending') return false;
  152. if (!s.parallel) return false;
  153. return s.depends_on.every(dep => completedSeqs.has(dep));
  154. });
  155. if (parallel.length > 0) {
  156. console.log(`[${f}] - ${parallel.length} parallel tasks:`);
  157. for (const s of parallel) {
  158. console.log(` ${s.seq} - ${s.title}`);
  159. }
  160. console.log();
  161. }
  162. }
  163. }
  164. function cmdDeps(feature: string, seq: string): void {
  165. const subtasks = loadSubtasks(feature);
  166. const target = subtasks.find(s => s.seq === seq);
  167. if (!target) {
  168. console.log(`Task ${seq} not found in ${feature}`);
  169. return;
  170. }
  171. console.log(`\n=== Dependency Tree: ${feature}/${seq} ===\n`);
  172. console.log(`${seq} - ${target.title} [${target.status}]`);
  173. if (target.depends_on.length === 0) {
  174. console.log(' └── (no dependencies)');
  175. return;
  176. }
  177. const printDeps = (seqs: string[], indent: string = ' '): void => {
  178. for (let i = 0; i < seqs.length; i++) {
  179. const depSeq = seqs[i];
  180. const dep = subtasks.find(s => s.seq === depSeq);
  181. const isLast = i === seqs.length - 1;
  182. const branch = isLast ? '└──' : '├──';
  183. if (dep) {
  184. const statusIcon = dep.status === 'completed' ? '✓' : dep.status === 'in_progress' ? '~' : '○';
  185. console.log(`${indent}${branch} ${statusIcon} ${depSeq} - ${dep.title} [${dep.status}]`);
  186. if (dep.depends_on.length > 0) {
  187. const newIndent = indent + (isLast ? ' ' : '│ ');
  188. printDeps(dep.depends_on, newIndent);
  189. }
  190. } else {
  191. console.log(`${indent}${branch} ? ${depSeq} - NOT FOUND`);
  192. }
  193. }
  194. };
  195. printDeps(target.depends_on);
  196. }
  197. function cmdBlocked(feature?: string): void {
  198. const features = feature ? [feature] : getFeatureDirs();
  199. console.log('\n=== Blocked Tasks ===\n');
  200. for (const f of features) {
  201. const subtasks = loadSubtasks(f);
  202. const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq));
  203. const blocked = subtasks.filter(s => {
  204. if (s.status === 'blocked') return true;
  205. if (s.status !== 'pending') return false;
  206. return !s.depends_on.every(dep => completedSeqs.has(dep));
  207. });
  208. if (blocked.length > 0) {
  209. console.log(`[${f}]`);
  210. for (const s of blocked) {
  211. const waitingFor = s.depends_on.filter(dep => !completedSeqs.has(dep));
  212. const reason = s.status === 'blocked'
  213. ? 'explicitly blocked'
  214. : `waiting: ${waitingFor.join(', ')}`;
  215. console.log(` ${s.seq} - ${s.title} (${reason})`);
  216. }
  217. console.log();
  218. }
  219. }
  220. }
  221. function cmdComplete(feature: string, seq: string, summary: string): void {
  222. if (summary.length > 200) {
  223. console.log('Error: Summary must be max 200 characters');
  224. process.exit(1);
  225. }
  226. const subtasks = loadSubtasks(feature);
  227. const subtask = subtasks.find(s => s.seq === seq);
  228. if (!subtask) {
  229. console.log(`Task ${seq} not found in ${feature}`);
  230. process.exit(1);
  231. }
  232. subtask.status = 'completed';
  233. subtask.completed_at = new Date().toISOString();
  234. subtask.completion_summary = summary;
  235. saveSubtask(feature, subtask);
  236. // Update task.json counts
  237. const task = loadTask(feature);
  238. if (task) {
  239. const newSubtasks = loadSubtasks(feature);
  240. task.completed_count = newSubtasks.filter(s => s.status === 'completed').length;
  241. saveTask(feature, task);
  242. }
  243. console.log(`\n✓ Marked ${feature}/${seq} as completed`);
  244. console.log(` Summary: ${summary}`);
  245. if (task) {
  246. console.log(` Progress: ${task.completed_count}/${task.subtask_count}`);
  247. }
  248. }
  249. function cmdValidate(feature?: string): void {
  250. const features = feature ? [feature] : getFeatureDirs();
  251. let hasErrors = false;
  252. const validTaskStatuses = new Set(['active', 'completed', 'blocked', 'archived']);
  253. const validSubtaskStatuses = new Set(['pending', 'in_progress', 'completed', 'blocked']);
  254. const requiredTaskFields = [
  255. 'id',
  256. 'name',
  257. 'status',
  258. 'objective',
  259. 'context_files',
  260. 'exit_criteria',
  261. 'subtask_count',
  262. 'completed_count',
  263. 'created_at',
  264. 'completed_at',
  265. ];
  266. const requiredSubtaskFields = [
  267. 'id',
  268. 'seq',
  269. 'title',
  270. 'status',
  271. 'depends_on',
  272. 'parallel',
  273. 'context_files',
  274. 'acceptance_criteria',
  275. 'deliverables',
  276. 'agent_id',
  277. 'started_at',
  278. 'completed_at',
  279. 'completion_summary',
  280. ];
  281. const hasField = (obj: any, field: string): boolean => Object.prototype.hasOwnProperty.call(obj, field);
  282. const isStringArray = (value: any): boolean => Array.isArray(value) && value.every(v => typeof v === 'string');
  283. console.log('\n=== Validation Results ===\n');
  284. for (const f of features) {
  285. const errors: string[] = [];
  286. // Check task.json exists
  287. const task = loadTask(f);
  288. if (!task) {
  289. errors.push('Missing task.json');
  290. }
  291. // Load and validate subtasks
  292. const subtasks = loadSubtasks(f);
  293. const seqCounts = new Map<string, number>();
  294. for (const s of subtasks) {
  295. const seq = typeof s.seq === 'string' ? s.seq : '';
  296. seqCounts.set(seq, (seqCounts.get(seq) || 0) + 1);
  297. }
  298. const seqs = new Set(subtasks.map(s => s.seq));
  299. if (task) {
  300. // Required fields in task.json
  301. for (const field of requiredTaskFields) {
  302. if (!hasField(task, field)) {
  303. errors.push(`task.json: missing required field '${field}'`);
  304. }
  305. }
  306. // Task ID should match feature slug
  307. if (task.id !== f) {
  308. errors.push(`task.json id ('${task.id}') should match feature slug ('${f}')`);
  309. }
  310. // Task status should be valid
  311. if (!validTaskStatuses.has(task.status)) {
  312. errors.push(`task.json: invalid status '${task.status}'`);
  313. }
  314. // Basic type checks for key task fields
  315. if (!isStringArray(task.context_files)) {
  316. errors.push('task.json: context_files must be string[]');
  317. }
  318. if (hasField(task, 'reference_files') && task.reference_files !== undefined && !isStringArray(task.reference_files)) {
  319. errors.push('task.json: reference_files must be string[] when present');
  320. }
  321. if (!isStringArray(task.exit_criteria)) {
  322. errors.push('task.json: exit_criteria must be string[]');
  323. }
  324. if (typeof task.subtask_count !== 'number') {
  325. errors.push('task.json: subtask_count must be number');
  326. }
  327. if (typeof task.completed_count !== 'number') {
  328. errors.push('task.json: completed_count must be number');
  329. }
  330. }
  331. for (const s of subtasks) {
  332. // Required fields in subtask files
  333. for (const field of requiredSubtaskFields) {
  334. if (!hasField(s, field)) {
  335. errors.push(`${s.seq || '??'}: missing required field '${field}'`);
  336. }
  337. }
  338. // Sequence format and uniqueness
  339. if (!/^\d{2}$/.test(s.seq)) {
  340. errors.push(`${s.seq}: sequence must be 2 digits (e.g., 01, 02)`);
  341. }
  342. if ((seqCounts.get(s.seq) || 0) > 1) {
  343. errors.push(`${s.seq}: duplicate sequence number`);
  344. }
  345. // Check ID format
  346. if (!s.id.startsWith(f)) {
  347. errors.push(`${s.seq}: ID should start with feature name`);
  348. }
  349. // Status should be valid
  350. if (!validSubtaskStatuses.has(s.status)) {
  351. errors.push(`${s.seq}: invalid status '${s.status}'`);
  352. }
  353. // Type checks
  354. if (!isStringArray(s.depends_on)) {
  355. errors.push(`${s.seq}: depends_on must be string[]`);
  356. }
  357. if (typeof s.parallel !== 'boolean') {
  358. errors.push(`${s.seq}: parallel must be boolean`);
  359. }
  360. if (!isStringArray(s.context_files)) {
  361. errors.push(`${s.seq}: context_files must be string[]`);
  362. }
  363. if (hasField(s, 'reference_files') && s.reference_files !== undefined && !isStringArray(s.reference_files)) {
  364. errors.push(`${s.seq}: reference_files must be string[] when present`);
  365. }
  366. if (!isStringArray(s.acceptance_criteria)) {
  367. errors.push(`${s.seq}: acceptance_criteria must be string[]`);
  368. } else if (s.acceptance_criteria.length === 0) {
  369. errors.push(`${s.seq}: No acceptance criteria defined`);
  370. }
  371. if (!isStringArray(s.deliverables)) {
  372. errors.push(`${s.seq}: deliverables must be string[]`);
  373. } else if (s.deliverables.length === 0) {
  374. errors.push(`${s.seq}: No deliverables defined`);
  375. }
  376. // Self dependency is invalid
  377. if (Array.isArray(s.depends_on) && s.depends_on.includes(s.seq)) {
  378. errors.push(`${s.seq}: task cannot depend on itself`);
  379. }
  380. // Check for missing dependencies
  381. for (const dep of (Array.isArray(s.depends_on) ? s.depends_on : [])) {
  382. if (!seqs.has(dep)) {
  383. errors.push(`${s.seq}: depends on non-existent task ${dep}`);
  384. }
  385. }
  386. // Check for circular dependencies
  387. const visited = new Set<string>();
  388. const checkCircular = (seq: string, path: string[]): boolean => {
  389. if (path.includes(seq)) {
  390. errors.push(`${s.seq}: circular dependency detected: ${[...path, seq].join(' -> ')}`);
  391. return true;
  392. }
  393. if (visited.has(seq)) return false;
  394. visited.add(seq);
  395. const task = subtasks.find(t => t.seq === seq);
  396. if (task) {
  397. for (const dep of task.depends_on) {
  398. if (checkCircular(dep, [...path, seq])) return true;
  399. }
  400. }
  401. return false;
  402. };
  403. checkCircular(s.seq, []);
  404. }
  405. // Check counts match
  406. if (task && task.subtask_count !== subtasks.length) {
  407. errors.push(`task.json subtask_count (${task.subtask_count}) doesn't match actual count (${subtasks.length})`);
  408. }
  409. // Print results
  410. console.log(`[${f}]`);
  411. if (errors.length === 0) {
  412. console.log(' ✓ All checks passed');
  413. } else {
  414. for (const e of errors) {
  415. console.log(` ✗ ERROR: ${e}`);
  416. hasErrors = true;
  417. }
  418. }
  419. console.log();
  420. }
  421. process.exit(hasErrors ? 1 : 0);
  422. }
  423. // Main
  424. const [,, command, ...args] = process.argv;
  425. switch (command) {
  426. case 'status':
  427. cmdStatus(args[0]);
  428. break;
  429. case 'next':
  430. cmdNext(args[0]);
  431. break;
  432. case 'parallel':
  433. cmdParallel(args[0]);
  434. break;
  435. case 'deps':
  436. if (args.length < 2) {
  437. console.log('Usage: deps <feature> <seq>');
  438. process.exit(1);
  439. }
  440. cmdDeps(args[0], args[1]);
  441. break;
  442. case 'blocked':
  443. cmdBlocked(args[0]);
  444. break;
  445. case 'complete':
  446. if (args.length < 3) {
  447. console.log('Usage: complete <feature> <seq> "summary"');
  448. process.exit(1);
  449. }
  450. cmdComplete(args[0], args[1], args.slice(2).join(' '));
  451. break;
  452. case 'validate':
  453. cmdValidate(args[0]);
  454. break;
  455. default:
  456. console.log(`
  457. Task Management CLI
  458. Usage: npx ts-node task-cli.ts <command> [feature] [args...]
  459. Task files are stored in: .tmp/tasks/{feature-slug}/
  460. Commands:
  461. status [feature] Show task status summary
  462. next [feature] Show next eligible tasks (deps satisfied)
  463. parallel [feature] Show parallelizable tasks ready to run
  464. deps <feature> <seq> Show dependency tree for a task
  465. blocked [feature] Show blocked tasks and why
  466. complete <feature> <seq> "summary" Mark task completed with summary
  467. validate [feature] Validate JSON files and dependencies
  468. Examples:
  469. npx ts-node task-cli.ts status
  470. npx ts-node task-cli.ts next my-feature
  471. npx ts-node task-cli.ts complete my-feature 02 "Implemented auth module"
  472. `);
  473. }