task-cli.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. #!/usr/bin/env node
  2. /**
  3. * Task Management CLI
  4. *
  5. * Usage: npx ts-node .opencode/scripts/task-cli.ts <command> [args...]
  6. *
  7. * Tasks are stored in: .tmp/tasks/active/{feature}/ and .tmp/tasks/completed/{feature}/
  8. *
  9. * Commands:
  10. * status [feature] - Show task status summary
  11. * next [feature] - Show next eligible tasks
  12. * parallel [feature] - Show parallelizable tasks ready to run
  13. * deps <feature> <seq> - Show dependency tree for a task
  14. * blocked [feature] - Show blocked tasks and why
  15. * complete <feature> <seq> "summary" - Mark task completed
  16. * validate [feature] - Validate JSON files and dependencies
  17. * init - Create .tmp/tasks/ directory structure
  18. */
  19. import * as fs from 'node:fs';
  20. import * as path from 'node:path';
  21. // Tasks stored in project root .tmp/tasks/
  22. const PROJECT_ROOT = process.cwd();
  23. const TASKS_ROOT = path.join(PROJECT_ROOT, '.tmp', 'tasks');
  24. const ACTIVE_DIR = path.join(TASKS_ROOT, 'active');
  25. const COMPLETED_DIR = path.join(TASKS_ROOT, 'completed');
  26. interface Task {
  27. id: string;
  28. name: string;
  29. status: 'active' | 'completed' | 'blocked' | 'archived';
  30. objective: string;
  31. context_files: string[];
  32. exit_criteria: string[];
  33. subtask_count: number;
  34. completed_count: number;
  35. created_at: string;
  36. completed_at: string | null;
  37. }
  38. interface Subtask {
  39. id: string;
  40. seq: string;
  41. title: string;
  42. status: 'pending' | 'in_progress' | 'completed' | 'blocked';
  43. depends_on: string[];
  44. parallel: boolean;
  45. context_files: string[];
  46. acceptance_criteria: string[];
  47. deliverables: string[];
  48. agent_id: string | null;
  49. started_at: string | null;
  50. completed_at: string | null;
  51. completion_summary: string | null;
  52. }
  53. // Helpers
  54. function ensureDir(dir: string): void {
  55. if (!fs.existsSync(dir)) {
  56. fs.mkdirSync(dir, { recursive: true });
  57. }
  58. }
  59. function getFeatureDirs(): string[] {
  60. if (!fs.existsSync(ACTIVE_DIR)) return [];
  61. return fs.readdirSync(ACTIVE_DIR).filter((f: string) =>
  62. fs.statSync(path.join(ACTIVE_DIR, f)).isDirectory()
  63. );
  64. }
  65. function loadTask(feature: string): Task | null {
  66. const taskPath = path.join(ACTIVE_DIR, feature, 'task.json');
  67. if (!fs.existsSync(taskPath)) return null;
  68. return JSON.parse(fs.readFileSync(taskPath, 'utf-8')) as Task;
  69. }
  70. function loadSubtasks(feature: string): Subtask[] {
  71. const featureDir = path.join(ACTIVE_DIR, feature);
  72. if (!fs.existsSync(featureDir)) return [];
  73. const files = fs.readdirSync(featureDir)
  74. .filter((f: string) => f.match(/^subtask_\d{2}\.json$/))
  75. .sort();
  76. return files.map((f: string) =>
  77. JSON.parse(fs.readFileSync(path.join(featureDir, f), 'utf-8')) as Subtask
  78. );
  79. }
  80. function saveSubtask(feature: string, subtask: Subtask): void {
  81. const subtaskPath = path.join(ACTIVE_DIR, feature, `subtask_${subtask.seq}.json`);
  82. fs.writeFileSync(subtaskPath, JSON.stringify(subtask, null, 2));
  83. }
  84. function saveTask(feature: string, task: Task): void {
  85. const taskPath = path.join(ACTIVE_DIR, feature, 'task.json');
  86. fs.writeFileSync(taskPath, JSON.stringify(task, null, 2));
  87. }
  88. // Commands
  89. function cmdInit(): void {
  90. ensureDir(ACTIVE_DIR);
  91. ensureDir(COMPLETED_DIR);
  92. console.log(`\n✓ Created task directories:`);
  93. console.log(` ${ACTIVE_DIR}`);
  94. console.log(` ${COMPLETED_DIR}`);
  95. }
  96. function cmdStatus(feature?: string): void {
  97. const features = feature ? [feature] : getFeatureDirs();
  98. if (features.length === 0) {
  99. console.log('No active features found in .tmp/tasks/active/');
  100. console.log('Run `task-cli.ts init` to create directories.');
  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: Subtask) => s.status === 'pending').length,
  112. in_progress: subtasks.filter((s: Subtask) => s.status === 'in_progress').length,
  113. completed: subtasks.filter((s: Subtask) => s.status === 'completed').length,
  114. blocked: subtasks.filter((s: Subtask) => 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(
  130. subtasks.filter((s: Subtask) => s.status === 'completed').map((s: Subtask) => s.seq)
  131. );
  132. const ready = subtasks.filter((s: Subtask) => {
  133. if (s.status !== 'pending') return false;
  134. return s.depends_on.every((dep: string) => completedSeqs.has(dep));
  135. });
  136. if (ready.length > 0) {
  137. console.log(`[${f}]`);
  138. for (const s of ready) {
  139. const parallel = s.parallel ? '[parallel]' : '[sequential]';
  140. console.log(` ${s.seq} - ${s.title} ${parallel}`);
  141. }
  142. console.log();
  143. }
  144. }
  145. }
  146. function cmdParallel(feature?: string): void {
  147. const features = feature ? [feature] : getFeatureDirs();
  148. console.log('\n=== Parallel Tasks Ready Now ===\n');
  149. for (const f of features) {
  150. const subtasks = loadSubtasks(f);
  151. const completedSeqs = new Set(
  152. subtasks.filter((s: Subtask) => s.status === 'completed').map((s: Subtask) => s.seq)
  153. );
  154. const parallel = subtasks.filter((s: Subtask) => {
  155. if (s.status !== 'pending') return false;
  156. if (!s.parallel) return false;
  157. return s.depends_on.every((dep: string) => completedSeqs.has(dep));
  158. });
  159. if (parallel.length > 0) {
  160. console.log(`[${f}] - ${parallel.length} parallel tasks:`);
  161. for (const s of parallel) {
  162. console.log(` ${s.seq} - ${s.title}`);
  163. }
  164. console.log();
  165. }
  166. }
  167. }
  168. function cmdDeps(feature: string, seq: string): void {
  169. const subtasks = loadSubtasks(feature);
  170. const target = subtasks.find((s: Subtask) => s.seq === seq);
  171. if (!target) {
  172. console.log(`Task ${seq} not found in ${feature}`);
  173. return;
  174. }
  175. console.log(`\n=== Dependency Tree: ${feature}/${seq} ===\n`);
  176. console.log(`${seq} - ${target.title} [${target.status}]`);
  177. if (target.depends_on.length === 0) {
  178. console.log(' └── (no dependencies)');
  179. return;
  180. }
  181. const printDeps = (seqs: string[], indent: string = ' '): void => {
  182. for (let i = 0; i < seqs.length; i++) {
  183. const depSeq = seqs[i];
  184. const dep = subtasks.find((s: Subtask) => s.seq === depSeq);
  185. const isLast = i === seqs.length - 1;
  186. const branch = isLast ? '└──' : '├──';
  187. if (dep) {
  188. const statusIcon = dep.status === 'completed' ? '✓' : dep.status === 'in_progress' ? '~' : '○';
  189. console.log(`${indent}${branch} ${statusIcon} ${depSeq} - ${dep.title} [${dep.status}]`);
  190. if (dep.depends_on.length > 0) {
  191. const newIndent = indent + (isLast ? ' ' : '│ ');
  192. printDeps(dep.depends_on, newIndent);
  193. }
  194. } else {
  195. console.log(`${indent}${branch} ? ${depSeq} - NOT FOUND`);
  196. }
  197. }
  198. };
  199. printDeps(target.depends_on);
  200. }
  201. function cmdBlocked(feature?: string): void {
  202. const features = feature ? [feature] : getFeatureDirs();
  203. console.log('\n=== Blocked Tasks ===\n');
  204. for (const f of features) {
  205. const subtasks = loadSubtasks(f);
  206. const completedSeqs = new Set(
  207. subtasks.filter((s: Subtask) => s.status === 'completed').map((s: Subtask) => s.seq)
  208. );
  209. const blocked = subtasks.filter((s: Subtask) => {
  210. if (s.status === 'blocked') return true;
  211. if (s.status !== 'pending') return false;
  212. return !s.depends_on.every((dep: string) => completedSeqs.has(dep));
  213. });
  214. if (blocked.length > 0) {
  215. console.log(`[${f}]`);
  216. for (const s of blocked) {
  217. const waitingFor = s.depends_on.filter((dep: string) => !completedSeqs.has(dep));
  218. const reason = s.status === 'blocked'
  219. ? 'explicitly blocked'
  220. : `waiting: ${waitingFor.join(', ')}`;
  221. console.log(` ${s.seq} - ${s.title} (${reason})`);
  222. }
  223. console.log();
  224. }
  225. }
  226. }
  227. function cmdComplete(feature: string, seq: string, summary: string): void {
  228. if (summary.length > 200) {
  229. console.log('Error: Summary must be max 200 characters');
  230. process.exit(1);
  231. }
  232. const subtasks = loadSubtasks(feature);
  233. const subtask = subtasks.find((s: Subtask) => s.seq === seq);
  234. if (!subtask) {
  235. console.log(`Task ${seq} not found in ${feature}`);
  236. process.exit(1);
  237. }
  238. subtask.status = 'completed';
  239. subtask.completed_at = new Date().toISOString();
  240. subtask.completion_summary = summary;
  241. saveSubtask(feature, subtask);
  242. // Update task.json counts
  243. const task = loadTask(feature);
  244. if (task) {
  245. const newSubtasks = loadSubtasks(feature);
  246. task.completed_count = newSubtasks.filter((s: Subtask) => s.status === 'completed').length;
  247. saveTask(feature, task);
  248. }
  249. console.log(`\n✓ Marked ${feature}/${seq} as completed`);
  250. console.log(` Summary: ${summary}`);
  251. if (task) {
  252. console.log(` Progress: ${task.completed_count}/${task.subtask_count}`);
  253. }
  254. }
  255. function cmdValidate(feature?: string): void {
  256. const features = feature ? [feature] : getFeatureDirs();
  257. let hasErrors = false;
  258. console.log('\n=== Validation Results ===\n');
  259. for (const f of features) {
  260. const errors: string[] = [];
  261. const warnings: string[] = [];
  262. // Check task.json exists
  263. const task = loadTask(f);
  264. if (!task) {
  265. errors.push('Missing task.json');
  266. }
  267. // Load and validate subtasks
  268. const subtasks = loadSubtasks(f);
  269. const seqs = new Set(subtasks.map((s: Subtask) => s.seq));
  270. for (const s of subtasks) {
  271. // Check ID format
  272. if (!s.id.startsWith(f)) {
  273. errors.push(`${s.seq}: ID should start with feature name`);
  274. }
  275. // Check for missing dependencies
  276. for (const dep of s.depends_on) {
  277. if (!seqs.has(dep)) {
  278. errors.push(`${s.seq}: depends on non-existent task ${dep}`);
  279. }
  280. }
  281. // Check for circular dependencies
  282. const visited = new Set<string>();
  283. const checkCircular = (currentSeq: string, pathArr: string[]): boolean => {
  284. if (pathArr.includes(currentSeq)) {
  285. errors.push(`${s.seq}: circular dependency detected: ${[...pathArr, currentSeq].join(' -> ')}`);
  286. return true;
  287. }
  288. if (visited.has(currentSeq)) return false;
  289. visited.add(currentSeq);
  290. const currentTask = subtasks.find((t: Subtask) => t.seq === currentSeq);
  291. if (currentTask) {
  292. for (const dep of currentTask.depends_on) {
  293. if (checkCircular(dep, [...pathArr, currentSeq])) return true;
  294. }
  295. }
  296. return false;
  297. };
  298. checkCircular(s.seq, []);
  299. // Warnings
  300. if (s.acceptance_criteria.length === 0) {
  301. warnings.push(`${s.seq}: No acceptance criteria defined`);
  302. }
  303. if (s.deliverables.length === 0) {
  304. warnings.push(`${s.seq}: No deliverables defined`);
  305. }
  306. }
  307. // Check counts match
  308. if (task && task.subtask_count !== subtasks.length) {
  309. errors.push(`task.json subtask_count (${task.subtask_count}) doesn't match actual count (${subtasks.length})`);
  310. }
  311. // Print results
  312. console.log(`[${f}]`);
  313. if (errors.length === 0 && warnings.length === 0) {
  314. console.log(' ✓ All checks passed');
  315. } else {
  316. for (const e of errors) {
  317. console.log(` ✗ ERROR: ${e}`);
  318. hasErrors = true;
  319. }
  320. for (const w of warnings) {
  321. console.log(` ⚠ WARNING: ${w}`);
  322. }
  323. }
  324. console.log();
  325. }
  326. process.exit(hasErrors ? 1 : 0);
  327. }
  328. function cmdArchive(feature: string): void {
  329. const task = loadTask(feature);
  330. if (!task) {
  331. console.log(`Feature ${feature} not found`);
  332. process.exit(1);
  333. }
  334. const subtasks = loadSubtasks(feature);
  335. const completedCount = subtasks.filter((s: Subtask) => s.status === 'completed').length;
  336. if (completedCount !== subtasks.length) {
  337. console.log(`Cannot archive: ${completedCount}/${subtasks.length} tasks completed`);
  338. process.exit(1);
  339. }
  340. // Update task status
  341. task.status = 'completed';
  342. task.completed_at = new Date().toISOString();
  343. saveTask(feature, task);
  344. // Move to completed
  345. const srcDir = path.join(ACTIVE_DIR, feature);
  346. const destDir = path.join(COMPLETED_DIR, feature);
  347. ensureDir(COMPLETED_DIR);
  348. fs.renameSync(srcDir, destDir);
  349. console.log(`\n✓ Archived ${feature} to .tmp/tasks/completed/`);
  350. }
  351. // Main
  352. const [,, command, ...args] = process.argv;
  353. switch (command) {
  354. case 'init':
  355. cmdInit();
  356. break;
  357. case 'status':
  358. cmdStatus(args[0]);
  359. break;
  360. case 'next':
  361. cmdNext(args[0]);
  362. break;
  363. case 'parallel':
  364. cmdParallel(args[0]);
  365. break;
  366. case 'deps':
  367. if (args.length < 2) {
  368. console.log('Usage: deps <feature> <seq>');
  369. process.exit(1);
  370. }
  371. cmdDeps(args[0], args[1]);
  372. break;
  373. case 'blocked':
  374. cmdBlocked(args[0]);
  375. break;
  376. case 'complete':
  377. if (args.length < 3) {
  378. console.log('Usage: complete <feature> <seq> "summary"');
  379. process.exit(1);
  380. }
  381. cmdComplete(args[0], args[1], args.slice(2).join(' '));
  382. break;
  383. case 'validate':
  384. cmdValidate(args[0]);
  385. break;
  386. case 'archive':
  387. if (!args[0]) {
  388. console.log('Usage: archive <feature>');
  389. process.exit(1);
  390. }
  391. cmdArchive(args[0]);
  392. break;
  393. default:
  394. console.log(`
  395. Task Management CLI
  396. Location: .tmp/tasks/active/{feature}/ and .tmp/tasks/completed/{feature}/
  397. Usage: npx ts-node .opencode/scripts/task-cli.ts <command> [args...]
  398. Commands:
  399. init Create .tmp/tasks/ directory structure
  400. status [feature] Show task status summary
  401. next [feature] Show next eligible tasks (deps satisfied)
  402. parallel [feature] Show parallel tasks ready to run
  403. deps <feature> <seq> Show dependency tree for a task
  404. blocked [feature] Show blocked tasks and why
  405. complete <feature> <seq> "summary" Mark task completed with summary
  406. validate [feature] Validate JSON files and dependencies
  407. archive <feature> Move completed feature to completed/
  408. Examples:
  409. npx ts-node .opencode/scripts/task-cli.ts init
  410. npx ts-node .opencode/scripts/task-cli.ts status
  411. npx ts-node .opencode/scripts/task-cli.ts next my-feature
  412. npx ts-node .opencode/scripts/task-cli.ts complete my-feature 02 "Implemented auth module"
  413. `);
  414. }