taskplane 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -20
  3. package/bin/taskplane.mjs +706 -0
  4. package/dashboard/public/app.js +900 -0
  5. package/dashboard/public/index.html +92 -0
  6. package/dashboard/public/style.css +924 -0
  7. package/dashboard/server.cjs +531 -0
  8. package/extensions/task-orchestrator.ts +28 -0
  9. package/extensions/task-runner.ts +1923 -0
  10. package/extensions/taskplane/abort.ts +466 -0
  11. package/extensions/taskplane/config.ts +102 -0
  12. package/extensions/taskplane/discovery.ts +988 -0
  13. package/extensions/taskplane/engine.ts +758 -0
  14. package/extensions/taskplane/execution.ts +1752 -0
  15. package/extensions/taskplane/extension.ts +577 -0
  16. package/extensions/taskplane/formatting.ts +718 -0
  17. package/extensions/taskplane/git.ts +38 -0
  18. package/extensions/taskplane/index.ts +22 -0
  19. package/extensions/taskplane/merge.ts +795 -0
  20. package/extensions/taskplane/messages.ts +134 -0
  21. package/extensions/taskplane/persistence.ts +1121 -0
  22. package/extensions/taskplane/resume.ts +1092 -0
  23. package/extensions/taskplane/sessions.ts +92 -0
  24. package/extensions/taskplane/types.ts +1514 -0
  25. package/extensions/taskplane/waves.ts +900 -0
  26. package/extensions/taskplane/worktree.ts +1624 -0
  27. package/package.json +50 -4
  28. package/skills/create-taskplane-task/SKILL.md +326 -0
  29. package/skills/create-taskplane-task/references/context-template.md +78 -0
  30. package/skills/create-taskplane-task/references/prompt-template.md +246 -0
  31. package/templates/agents/task-merger.md +256 -0
  32. package/templates/agents/task-reviewer.md +81 -0
  33. package/templates/agents/task-worker.md +140 -0
  34. package/templates/config/task-orchestrator.yaml +89 -0
  35. package/templates/config/task-runner.yaml +99 -0
  36. package/templates/tasks/CONTEXT.md +31 -0
  37. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
  38. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
@@ -0,0 +1,988 @@
1
+ /**
2
+ * Task discovery, PROMPT.md parsing, dependency resolution
3
+ * @module orch/discovery
4
+ */
5
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
6
+ import { join, dirname, basename, resolve } from "path";
7
+
8
+ import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea } from "./types.ts";
9
+
10
+ // ── PROMPT.md Parsing ────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Extract the task ID from a folder name.
14
+ * Convention: "TO-014-accrual-engine" → "TO-014"
15
+ * Matches prefix-number patterns like "COMP-006", "TS-004", "TO-014".
16
+ */
17
+ export function extractTaskIdFromFolderName(folderName: string): string | null {
18
+ const match = folderName.match(/^([A-Z]+-\d+)/);
19
+ return match ? match[1] : null;
20
+ }
21
+
22
+ export interface DependencyRef {
23
+ raw: string;
24
+ taskId: string;
25
+ areaName?: string;
26
+ }
27
+
28
+ export function parseDependencyReference(raw: string): DependencyRef {
29
+ const trimmed = raw.trim();
30
+ const qualified = trimmed.match(/^([a-z0-9-]+)\/([A-Z]+-\d+)$/i);
31
+ if (qualified) {
32
+ return {
33
+ raw: trimmed,
34
+ areaName: qualified[1].toLowerCase(),
35
+ taskId: qualified[2].toUpperCase(),
36
+ };
37
+ }
38
+
39
+ const idOnly = trimmed.match(/^([A-Z]+-\d+)$/i);
40
+ if (idOnly) {
41
+ return {
42
+ raw: trimmed,
43
+ taskId: idOnly[1].toUpperCase(),
44
+ };
45
+ }
46
+
47
+ return {
48
+ raw: trimmed,
49
+ taskId: trimmed.toUpperCase(),
50
+ };
51
+ }
52
+
53
+ export function normalizeDependencyReference(raw: string): string {
54
+ const parsed = parseDependencyReference(raw);
55
+ return parsed.areaName ? `${parsed.areaName}/${parsed.taskId}` : parsed.taskId;
56
+ }
57
+
58
+ /**
59
+ * Parse a PROMPT.md file and extract orchestrator-relevant metadata.
60
+ *
61
+ * Required fields (hard fail if missing):
62
+ * - Task ID: extracted from `# Task: XX-NNN - Name` heading OR from folder name
63
+ *
64
+ * Optional fields (defaults used if absent):
65
+ * - Dependencies: defaults to [] (no dependencies)
66
+ * - Review Level: defaults to 2
67
+ * - Size: defaults to "M"
68
+ * - File Scope: defaults to []
69
+ * - Task Name: defaults to folder name
70
+ *
71
+ * Dependency syntax accepted:
72
+ * - "**None**" or "None" → empty list
73
+ * - "**Requires:** COMP-005 ..." → ["COMP-005"]
74
+ * - "**Requires:** time-off/TO-014 ..." → ["time-off/TO-014"]
75
+ * - "- COMP-005 (description)" → ["COMP-005"]
76
+ * - "- **time-off/TO-014** — description" → ["time-off/TO-014"]
77
+ * - Multiple bullet points → multiple dependencies
78
+ */
79
+ export function parsePromptForOrchestrator(
80
+ promptPath: string,
81
+ taskFolder: string,
82
+ areaName: string,
83
+ ): { task: ParsedTask | null; error: DiscoveryError | null } {
84
+ const folderName = basename(taskFolder);
85
+ let content: string;
86
+
87
+ try {
88
+ content = readFileSync(promptPath, "utf-8");
89
+ } catch {
90
+ return {
91
+ task: null,
92
+ error: {
93
+ code: "PARSE_MALFORMED",
94
+ message: `Cannot read PROMPT.md: ${promptPath}`,
95
+ taskPath: promptPath,
96
+ },
97
+ };
98
+ }
99
+
100
+ // ── Extract task ID ──────────────────────────────────────────
101
+ // Try from heading first: "# Task: COMP-006 - Pay Bands Implementation"
102
+ let taskId: string | null = null;
103
+ let taskName = folderName;
104
+
105
+ const headingMatch = content.match(/^#\s+Task:\s+([A-Z]+-\d+)\s*[-—]\s*(.+)$/m);
106
+ if (headingMatch) {
107
+ taskId = headingMatch[1];
108
+ taskName = headingMatch[2].trim();
109
+ }
110
+
111
+ // Fallback: extract from folder name
112
+ if (!taskId) {
113
+ taskId = extractTaskIdFromFolderName(folderName);
114
+ }
115
+
116
+ if (!taskId) {
117
+ return {
118
+ task: null,
119
+ error: {
120
+ code: "PARSE_MISSING_ID",
121
+ message: `Cannot extract task ID from heading or folder name "${folderName}" in ${promptPath}`,
122
+ taskPath: promptPath,
123
+ },
124
+ };
125
+ }
126
+
127
+ // ── Extract review level ─────────────────────────────────────
128
+ // "## Review Level: 1 (Plan Only)" or "## Review Level: 2"
129
+ let reviewLevel = 2;
130
+ const reviewMatch = content.match(/^##\s+Review Level:\s*(\d+)/m);
131
+ if (reviewMatch) {
132
+ reviewLevel = parseInt(reviewMatch[1], 10);
133
+ }
134
+
135
+ // ── Extract size ─────────────────────────────────────────────
136
+ // "**Size:** M" (usually near top, after Created date)
137
+ let size = "M";
138
+ const sizeMatch = content.match(/\*\*Size:\*\*\s*([SMLsml])/);
139
+ if (sizeMatch) {
140
+ size = sizeMatch[1].toUpperCase();
141
+ }
142
+
143
+ // ── Extract dependencies ─────────────────────────────────────
144
+ const dependencies: string[] = [];
145
+ const depSectionMatch = content.match(
146
+ /^##\s+Dependencies\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m,
147
+ );
148
+
149
+ if (depSectionMatch) {
150
+ const depBody = depSectionMatch[1].trim();
151
+
152
+ // Check for "None" variants
153
+ if (!/\*?\*?None\*?\*?/i.test(depBody) && depBody.length > 0) {
154
+ // Pattern 1: "**Requires:** COMP-005 ..." or "**Requires:** time-off/TO-014 ..."
155
+ const requiresMatches = depBody.matchAll(
156
+ /\*?\*?Requires:?\*?\*?\s*((?:[a-z0-9-]+\/)?[A-Z]+-\d+)/gi,
157
+ );
158
+ for (const m of requiresMatches) {
159
+ const dep = normalizeDependencyReference(m[1]);
160
+ if (!dependencies.includes(dep)) dependencies.push(dep);
161
+ }
162
+
163
+ // Pattern 2: Bullet list "- COMP-005 ...", "- **time-off/TO-014** ..."
164
+ const bulletMatches = depBody.matchAll(
165
+ /^[\s-]*\*?\*?((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\*?\*?/gim,
166
+ );
167
+ for (const m of bulletMatches) {
168
+ const dep = normalizeDependencyReference(m[1]);
169
+ if (!dependencies.includes(dep)) dependencies.push(dep);
170
+ }
171
+
172
+ // Pattern 3: Inline dependency references not caught above
173
+ if (dependencies.length === 0) {
174
+ const inlineMatches = depBody.matchAll(/\b((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\b/gi);
175
+ for (const m of inlineMatches) {
176
+ const dep = parseDependencyReference(m[1]);
177
+ if (dep.taskId === taskId) continue; // Don't add self-references
178
+ const normalized = normalizeDependencyReference(m[1]);
179
+ if (!dependencies.includes(normalized)) {
180
+ dependencies.push(normalized);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // ── Extract file scope ───────────────────────────────────────
188
+ const fileScope: string[] = [];
189
+ const fileScopeMatch = content.match(
190
+ /^##\s+File Scope\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m,
191
+ );
192
+
193
+ if (fileScopeMatch) {
194
+ const scopeBody = fileScopeMatch[1].trim();
195
+ const scopeLines = scopeBody.split("\n");
196
+ for (const line of scopeLines) {
197
+ // "- extensions/task-orchestrator.ts" or "- .pi/task-orchestrator.yaml"
198
+ const trimmed = line.replace(/^[\s-*]+/, "").trim();
199
+ if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("```")) {
200
+ fileScope.push(trimmed);
201
+ }
202
+ }
203
+ }
204
+
205
+ return {
206
+ task: {
207
+ taskId,
208
+ taskName,
209
+ reviewLevel,
210
+ size,
211
+ dependencies,
212
+ fileScope,
213
+ taskFolder: resolve(taskFolder),
214
+ promptPath: resolve(promptPath),
215
+ areaName,
216
+ status: "pending",
217
+ },
218
+ error: null,
219
+ };
220
+ }
221
+
222
+
223
+ // ── Area Scanning ────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * Scan an area path for pending tasks.
227
+ *
228
+ * Lists immediate subdirectories only (no recursion).
229
+ * Skips "archive" directories and folders with .DONE files.
230
+ * Parses PROMPT.md in each remaining subdirectory.
231
+ */
232
+ export function scanAreaForTasks(
233
+ areaPath: string,
234
+ areaName: string,
235
+ ): { tasks: ParsedTask[]; errors: DiscoveryError[] } {
236
+ const tasks: ParsedTask[] = [];
237
+ const errors: DiscoveryError[] = [];
238
+
239
+ const resolvedPath = resolve(areaPath);
240
+ if (!existsSync(resolvedPath)) {
241
+ errors.push({
242
+ code: "SCAN_ERROR",
243
+ message: `Area path does not exist: ${resolvedPath}`,
244
+ taskPath: resolvedPath,
245
+ });
246
+ return { tasks, errors };
247
+ }
248
+
249
+ let entries: string[];
250
+ try {
251
+ entries = readdirSync(resolvedPath);
252
+ } catch {
253
+ errors.push({
254
+ code: "SCAN_ERROR",
255
+ message: `Cannot read area directory: ${resolvedPath}`,
256
+ taskPath: resolvedPath,
257
+ });
258
+ return { tasks, errors };
259
+ }
260
+
261
+ for (const entry of entries) {
262
+ // Skip archive directory
263
+ if (entry.toLowerCase() === "archive") continue;
264
+
265
+ const entryPath = join(resolvedPath, entry);
266
+
267
+ // Only process directories
268
+ try {
269
+ if (!statSync(entryPath).isDirectory()) continue;
270
+ } catch {
271
+ continue;
272
+ }
273
+
274
+ // Skip if .DONE exists (already complete)
275
+ if (existsSync(join(entryPath, ".DONE"))) continue;
276
+
277
+ // Skip if no PROMPT.md
278
+ const promptPath = join(entryPath, "PROMPT.md");
279
+ if (!existsSync(promptPath)) continue;
280
+
281
+ // Parse PROMPT.md
282
+ const result = parsePromptForOrchestrator(promptPath, entryPath, areaName);
283
+ if (result.error) {
284
+ errors.push(result.error);
285
+ }
286
+ if (result.task) {
287
+ tasks.push(result.task);
288
+ }
289
+ }
290
+
291
+ return { tasks, errors };
292
+ }
293
+
294
+
295
+ // ── Completed Task Set ───────────────────────────────────────────────
296
+
297
+ /**
298
+ * Build a set of completed task IDs by scanning:
299
+ * 1. archive/ subdirectories for .DONE markers
300
+ * 2. Active task folders that have .DONE files (caught during scanAreaForTasks skip)
301
+ *
302
+ * This set is used only for dependency resolution — completed tasks are never re-executed.
303
+ */
304
+ export function buildCompletedTaskSet(areaPaths: string[]): Set<string> {
305
+ const completed = new Set<string>();
306
+
307
+ for (const areaPath of areaPaths) {
308
+ const resolvedPath = resolve(areaPath);
309
+ if (!existsSync(resolvedPath)) continue;
310
+
311
+ let entries: string[];
312
+ try {
313
+ entries = readdirSync(resolvedPath);
314
+ } catch {
315
+ continue;
316
+ }
317
+
318
+ for (const entry of entries) {
319
+ const entryPath = join(resolvedPath, entry);
320
+
321
+ try {
322
+ if (!statSync(entryPath).isDirectory()) continue;
323
+ } catch {
324
+ continue;
325
+ }
326
+
327
+ if (entry.toLowerCase() === "archive") {
328
+ // Scan archive subdirectories for completed tasks
329
+ let archiveEntries: string[];
330
+ try {
331
+ archiveEntries = readdirSync(entryPath);
332
+ } catch {
333
+ continue;
334
+ }
335
+ for (const archiveEntry of archiveEntries) {
336
+ const archiveFolderPath = join(entryPath, archiveEntry);
337
+ try {
338
+ if (!statSync(archiveFolderPath).isDirectory()) continue;
339
+ } catch {
340
+ continue;
341
+ }
342
+ // Only treat archive tasks as complete when .DONE marker exists
343
+ if (!existsSync(join(archiveFolderPath, ".DONE"))) continue;
344
+ const taskId = extractTaskIdFromFolderName(archiveEntry);
345
+ if (taskId) {
346
+ completed.add(taskId);
347
+ }
348
+ }
349
+ } else {
350
+ // Active folder with .DONE = completed
351
+ if (existsSync(join(entryPath, ".DONE"))) {
352
+ const taskId = extractTaskIdFromFolderName(entry);
353
+ if (taskId) {
354
+ completed.add(taskId);
355
+ }
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ return completed;
362
+ }
363
+
364
+
365
+ // ── Argument Resolution ──────────────────────────────────────────────
366
+
367
+ /**
368
+ * Resolve command arguments into area scan paths and direct task folders.
369
+ *
370
+ * Accepts mixed arguments:
371
+ * - "all" → all areas from task_areas
372
+ * - area name → looked up in task_areas
373
+ * - directory path → used as-is
374
+ * - PROMPT.md path → single task (dirname used as task folder)
375
+ */
376
+ export function resolveArguments(
377
+ args: string,
378
+ taskAreas: Record<string, TaskArea>,
379
+ cwd: string,
380
+ ): { areaScanPaths: string[]; directTaskFolders: string[]; errors: DiscoveryError[] } {
381
+ const areaScanPaths: string[] = [];
382
+ const directTaskFolders: string[] = [];
383
+ const errors: DiscoveryError[] = [];
384
+
385
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
386
+
387
+ for (const token of tokens) {
388
+ if (token.toLowerCase() === "all") {
389
+ // Expand to all areas
390
+ for (const area of Object.values(taskAreas)) {
391
+ const fullPath = resolve(cwd, area.path);
392
+ if (!areaScanPaths.includes(fullPath)) {
393
+ areaScanPaths.push(fullPath);
394
+ }
395
+ }
396
+ } else if (taskAreas[token]) {
397
+ // Known area name
398
+ const fullPath = resolve(cwd, taskAreas[token].path);
399
+ if (!areaScanPaths.includes(fullPath)) {
400
+ areaScanPaths.push(fullPath);
401
+ }
402
+ } else if (
403
+ token.endsWith("PROMPT.md") &&
404
+ existsSync(resolve(cwd, token))
405
+ ) {
406
+ // Single PROMPT.md file
407
+ directTaskFolders.push(resolve(cwd, dirname(token)));
408
+ } else if (existsSync(resolve(cwd, token))) {
409
+ // Directory path
410
+ const fullPath = resolve(cwd, token);
411
+ try {
412
+ if (statSync(fullPath).isDirectory()) {
413
+ if (!areaScanPaths.includes(fullPath)) {
414
+ areaScanPaths.push(fullPath);
415
+ }
416
+ } else {
417
+ errors.push({
418
+ code: "UNKNOWN_ARG",
419
+ message: `Not a directory or PROMPT.md file: ${token}`,
420
+ });
421
+ }
422
+ } catch {
423
+ errors.push({
424
+ code: "UNKNOWN_ARG",
425
+ message: `Cannot stat path: ${token}`,
426
+ });
427
+ }
428
+ } else {
429
+ errors.push({
430
+ code: "UNKNOWN_ARG",
431
+ message: `Unknown area, path, or file: "${token}"`,
432
+ });
433
+ }
434
+ }
435
+
436
+ return { areaScanPaths, directTaskFolders, errors };
437
+ }
438
+
439
+ export interface DiscoveryOptions {
440
+ refreshDependencies?: boolean;
441
+ dependencySource?: "prompt" | "agent";
442
+ useDependencyCache?: boolean;
443
+ }
444
+
445
+ export interface DependencyCacheFile {
446
+ version: number;
447
+ generatedAt: string;
448
+ source: string;
449
+ tasks: Record<string, string[]>;
450
+ }
451
+
452
+ export function normalizePathForCompare(p: string): string {
453
+ return resolve(p).replace(/\\/g, "/").toLowerCase();
454
+ }
455
+
456
+ export function isPathWithin(childPath: string, parentPath: string): boolean {
457
+ const child = normalizePathForCompare(childPath);
458
+ const parent = normalizePathForCompare(parentPath);
459
+ return child === parent || child.startsWith(`${parent}/`);
460
+ }
461
+
462
+ export function dedupeAndNormalizeDeps(deps: string[]): string[] {
463
+ const seen = new Set<string>();
464
+ const out: string[] = [];
465
+ for (const dep of deps) {
466
+ const norm = normalizeDependencyReference(dep);
467
+ if (!norm || seen.has(norm)) continue;
468
+ seen.add(norm);
469
+ out.push(norm);
470
+ }
471
+ return out;
472
+ }
473
+
474
+ export function loadAreaDependencyCache(areaPath: string): DependencyCacheFile | null {
475
+ const cachePath = join(areaPath, "dependencies.json");
476
+ if (!existsSync(cachePath)) return null;
477
+ try {
478
+ const raw = readFileSync(cachePath, "utf-8");
479
+ const parsed = JSON.parse(raw) as DependencyCacheFile;
480
+ if (!parsed || typeof parsed !== "object" || !parsed.tasks) return null;
481
+ return parsed;
482
+ } catch {
483
+ return null;
484
+ }
485
+ }
486
+
487
+ export function writeAreaDependencyCache(
488
+ areaPath: string,
489
+ pending: Map<string, ParsedTask>,
490
+ source: "prompt" | "agent",
491
+ ): void {
492
+ const tasks: Record<string, string[]> = {};
493
+ for (const task of pending.values()) {
494
+ if (!isPathWithin(task.taskFolder, areaPath)) continue;
495
+ tasks[task.taskId] = dedupeAndNormalizeDeps(task.dependencies);
496
+ }
497
+
498
+ const cachePath = join(areaPath, "dependencies.json");
499
+ const payload: DependencyCacheFile = {
500
+ version: 1,
501
+ generatedAt: new Date().toISOString(),
502
+ source,
503
+ tasks,
504
+ };
505
+
506
+ try {
507
+ // Keep deterministic formatting for easy diffs
508
+ const json = JSON.stringify(payload, null, 2);
509
+ writeFileSync(cachePath, `${json}\n`, "utf-8");
510
+ } catch {
511
+ // Non-fatal: discovery should still succeed without cache persistence
512
+ }
513
+ }
514
+
515
+ export function applyDependenciesFromCache(
516
+ discovery: DiscoveryResult,
517
+ areaScanPaths: string[],
518
+ ): { applied: boolean } {
519
+ let applied = false;
520
+ for (const areaPath of areaScanPaths) {
521
+ const cache = loadAreaDependencyCache(areaPath);
522
+ if (!cache) continue;
523
+ for (const task of discovery.pending.values()) {
524
+ if (!isPathWithin(task.taskFolder, areaPath)) continue;
525
+ const cachedDeps = cache.tasks[task.taskId];
526
+ if (!cachedDeps) continue;
527
+ task.dependencies = dedupeAndNormalizeDeps(cachedDeps);
528
+ applied = true;
529
+ }
530
+ }
531
+ return { applied };
532
+ }
533
+
534
+
535
+ // ── Task Registry ────────────────────────────────────────────────────
536
+
537
+ /**
538
+ * Build the full task registry: pending tasks + completed set.
539
+ *
540
+ * Enforces global uniqueness of task IDs across all areas.
541
+ * If duplicates are found, returns a fail-fast error listing all collision locations.
542
+ */
543
+ export function buildTaskRegistry(
544
+ areaScanPaths: string[],
545
+ directTaskFolders: string[],
546
+ taskAreas: Record<string, TaskArea>,
547
+ cwd: string,
548
+ ): DiscoveryResult {
549
+ const pending = new Map<string, ParsedTask>();
550
+ const errors: DiscoveryError[] = [];
551
+
552
+ // Track all locations per task ID for duplicate detection
553
+ const idLocations = new Map<string, string[]>();
554
+
555
+ function trackId(taskId: string, location: string) {
556
+ const existing = idLocations.get(taskId) || [];
557
+ existing.push(location);
558
+ idLocations.set(taskId, existing);
559
+ }
560
+
561
+ // Resolve area names for scan paths
562
+ const areaNameByPath = new Map<string, string>();
563
+ for (const [name, area] of Object.entries(taskAreas)) {
564
+ areaNameByPath.set(resolve(cwd, area.path), name);
565
+ }
566
+
567
+ // 1. Scan area paths for pending tasks
568
+ for (const areaPath of areaScanPaths) {
569
+ const areaName = areaNameByPath.get(areaPath) || basename(areaPath);
570
+ const result = scanAreaForTasks(areaPath, areaName);
571
+ errors.push(...result.errors);
572
+
573
+ for (const task of result.tasks) {
574
+ trackId(task.taskId, task.promptPath);
575
+ pending.set(task.taskId, task);
576
+ }
577
+ }
578
+
579
+ // 2. Process direct task folders (single PROMPT.md files)
580
+ for (const taskFolder of directTaskFolders) {
581
+ const promptPath = join(taskFolder, "PROMPT.md");
582
+ if (!existsSync(promptPath)) {
583
+ errors.push({
584
+ code: "SCAN_ERROR",
585
+ message: `No PROMPT.md found in direct task folder: ${taskFolder}`,
586
+ taskPath: taskFolder,
587
+ });
588
+ continue;
589
+ }
590
+
591
+ // Try to determine area name from path
592
+ let areaName = "unknown";
593
+ for (const [name, area] of Object.entries(taskAreas)) {
594
+ const resolvedAreaPath = resolve(cwd, area.path);
595
+ if (taskFolder.startsWith(resolvedAreaPath)) {
596
+ areaName = name;
597
+ break;
598
+ }
599
+ }
600
+
601
+ // Skip if .DONE exists
602
+ if (existsSync(join(taskFolder, ".DONE"))) continue;
603
+
604
+ const result = parsePromptForOrchestrator(promptPath, taskFolder, areaName);
605
+ if (result.error) {
606
+ errors.push(result.error);
607
+ }
608
+ if (result.task) {
609
+ trackId(result.task.taskId, result.task.promptPath);
610
+ pending.set(result.task.taskId, result.task);
611
+ }
612
+ }
613
+
614
+ // 3. Build completed task set from all scanned areas
615
+ const completed = buildCompletedTaskSet(areaScanPaths);
616
+
617
+ // Also scan all task_areas for completed tasks (needed for cross-area dep resolution)
618
+ const allAreaPaths = Object.values(taskAreas).map((a) => resolve(cwd, a.path));
619
+ const globalCompleted = buildCompletedTaskSet(allAreaPaths);
620
+ for (const id of globalCompleted) {
621
+ completed.add(id);
622
+ }
623
+
624
+ // 4. Check for duplicate task IDs (global uniqueness enforcement)
625
+ for (const [taskId, locations] of idLocations) {
626
+ if (locations.length > 1) {
627
+ errors.push({
628
+ code: "DUPLICATE_ID",
629
+ message:
630
+ `Duplicate task ID "${taskId}" found in ${locations.length} locations:\n` +
631
+ locations.map((l) => ` - ${l}`).join("\n"),
632
+ taskId,
633
+ });
634
+ }
635
+ }
636
+
637
+ return { pending, completed, errors };
638
+ }
639
+
640
+
641
+ // ── Cross-Area Dependency Resolution ─────────────────────────────────
642
+
643
+ /** Candidate match for a dependency reference found in task areas. */
644
+ export interface DependencyCandidate {
645
+ areaName: string;
646
+ path: string;
647
+ status: "pending" | "complete";
648
+ }
649
+
650
+ export function findDependencyCandidates(
651
+ depRef: DependencyRef,
652
+ taskAreas: Record<string, TaskArea>,
653
+ cwd: string,
654
+ ): DependencyCandidate[] {
655
+ const candidates: DependencyCandidate[] = [];
656
+ const sortedAreas = Object.entries(taskAreas).sort((a, b) => a[0].localeCompare(b[0]));
657
+
658
+ for (const [areaName, area] of sortedAreas) {
659
+ if (depRef.areaName && depRef.areaName !== areaName.toLowerCase()) {
660
+ continue;
661
+ }
662
+
663
+ const areaPath = resolve(cwd, area.path);
664
+ if (!existsSync(areaPath)) continue;
665
+
666
+ let entries: string[];
667
+ try {
668
+ entries = readdirSync(areaPath);
669
+ } catch {
670
+ continue;
671
+ }
672
+
673
+ // Active tasks (skip archive)
674
+ for (const entry of entries) {
675
+ if (entry.toLowerCase() === "archive") continue;
676
+ const entryTaskId = extractTaskIdFromFolderName(entry);
677
+ if (entryTaskId !== depRef.taskId) continue;
678
+
679
+ const entryPath = join(areaPath, entry);
680
+ try {
681
+ if (!statSync(entryPath).isDirectory()) continue;
682
+ } catch {
683
+ continue;
684
+ }
685
+
686
+ candidates.push({
687
+ areaName,
688
+ path: entryPath,
689
+ status: existsSync(join(entryPath, ".DONE")) ? "complete" : "pending",
690
+ });
691
+ }
692
+
693
+ // Archived tasks (require .DONE marker)
694
+ const archivePath = join(areaPath, "archive");
695
+ if (!existsSync(archivePath)) continue;
696
+ try {
697
+ const archiveEntries = readdirSync(archivePath);
698
+ for (const archiveEntry of archiveEntries) {
699
+ const entryTaskId = extractTaskIdFromFolderName(archiveEntry);
700
+ if (entryTaskId !== depRef.taskId) continue;
701
+
702
+ const archiveTaskPath = join(archivePath, archiveEntry);
703
+ candidates.push({
704
+ areaName,
705
+ path: archiveTaskPath,
706
+ status: existsSync(join(archiveTaskPath, ".DONE")) ? "complete" : "pending",
707
+ });
708
+ }
709
+ } catch {
710
+ // Ignore archive read errors for discovery resilience
711
+ }
712
+ }
713
+
714
+ return candidates;
715
+ }
716
+
717
+ /**
718
+ * Resolve dependencies for all pending tasks.
719
+ *
720
+ * Supports both dependency formats:
721
+ * - TASK-ID (unqualified)
722
+ * - area-name/TASK-ID (area-qualified)
723
+ */
724
+ export function resolveDependencies(
725
+ discovery: DiscoveryResult,
726
+ taskAreas: Record<string, TaskArea>,
727
+ cwd: string,
728
+ ): DiscoveryError[] {
729
+ const errors: DiscoveryError[] = [];
730
+
731
+ for (const [taskId, task] of discovery.pending) {
732
+ for (const depRaw of task.dependencies) {
733
+ const depRef = parseDependencyReference(depRaw);
734
+ const depId = depRef.taskId;
735
+
736
+ // Fast path for unqualified refs already in registry
737
+ if (!depRef.areaName) {
738
+ if (discovery.pending.has(depId)) continue;
739
+ if (discovery.completed.has(depId)) continue;
740
+ } else {
741
+ const pendingTask = discovery.pending.get(depId);
742
+ if (pendingTask && pendingTask.areaName.toLowerCase() === depRef.areaName) {
743
+ continue;
744
+ }
745
+ }
746
+
747
+ const candidates = findDependencyCandidates(depRef, taskAreas, cwd);
748
+
749
+ if (candidates.length === 0) {
750
+ errors.push({
751
+ code: "DEP_UNRESOLVED",
752
+ message: `${taskId} depends on ${depRaw} which does not exist in any task area`,
753
+ taskId,
754
+ taskPath: task.promptPath,
755
+ });
756
+ continue;
757
+ }
758
+
759
+ if (!depRef.areaName && candidates.length > 1) {
760
+ const options = candidates
761
+ .map((c) => ` - ${c.areaName}/${depId} [${c.status}] (${c.path})`)
762
+ .join("\n");
763
+ errors.push({
764
+ code: "DEP_AMBIGUOUS",
765
+ message:
766
+ `${taskId} depends on ${depId}, but multiple tasks match across areas. ` +
767
+ `Use an area-qualified dependency (area/${depId}).\n${options}`,
768
+ taskId,
769
+ taskPath: task.promptPath,
770
+ });
771
+ continue;
772
+ }
773
+
774
+ if (depRef.areaName && candidates.length > 1) {
775
+ const options = candidates
776
+ .map((c) => ` - ${c.areaName}/${depId} [${c.status}] (${c.path})`)
777
+ .join("\n");
778
+ errors.push({
779
+ code: "DEP_AMBIGUOUS",
780
+ message:
781
+ `${taskId} depends on ${depRaw}, but multiple matching task folders were found. ` +
782
+ `Resolve duplicate task IDs.\n${options}`,
783
+ taskId,
784
+ taskPath: task.promptPath,
785
+ });
786
+ continue;
787
+ }
788
+
789
+ const match = candidates[0];
790
+ if (match.status === "complete") {
791
+ discovery.completed.add(depId);
792
+ continue;
793
+ }
794
+
795
+ errors.push({
796
+ code: "DEP_PENDING",
797
+ message:
798
+ `${taskId} depends on ${depRaw} which is pending in "${match.areaName}". ` +
799
+ `Include that area: /orch ${match.areaName}`,
800
+ taskId,
801
+ taskPath: task.promptPath,
802
+ });
803
+ }
804
+ }
805
+
806
+ return errors;
807
+ }
808
+
809
+
810
+ // ── Discovery Pipeline (Public) ──────────────────────────────────────
811
+
812
+ /**
813
+ * Run the full discovery pipeline:
814
+ * 1. Resolve arguments to scan paths and direct task folders
815
+ * 2. Build task registry (scan, parse, deduplicate)
816
+ * 3. Resolve cross-area dependencies
817
+ *
818
+ * Returns a DiscoveryResult with pending tasks, completed set, and any errors.
819
+ */
820
+ export function runDiscovery(
821
+ args: string,
822
+ taskAreas: Record<string, TaskArea>,
823
+ cwd: string,
824
+ options: DiscoveryOptions = {},
825
+ ): DiscoveryResult {
826
+ const dependencySource = options.dependencySource ?? "prompt";
827
+ const useDependencyCache = options.useDependencyCache ?? false;
828
+ const refreshDependencies = options.refreshDependencies ?? false;
829
+
830
+ // Step 1: Resolve arguments
831
+ const resolved = resolveArguments(args, taskAreas, cwd);
832
+ if (resolved.errors.length > 0) {
833
+ return {
834
+ pending: new Map(),
835
+ completed: new Set(),
836
+ errors: resolved.errors,
837
+ };
838
+ }
839
+
840
+ if (resolved.areaScanPaths.length === 0 && resolved.directTaskFolders.length === 0) {
841
+ return {
842
+ pending: new Map(),
843
+ completed: new Set(),
844
+ errors: [
845
+ {
846
+ code: "UNKNOWN_ARG",
847
+ message: "No valid areas, paths, or PROMPT.md files found in arguments",
848
+ },
849
+ ],
850
+ };
851
+ }
852
+
853
+ // Step 2: Build task registry (prompt-parsed dependencies as baseline)
854
+ const discovery = buildTaskRegistry(
855
+ resolved.areaScanPaths,
856
+ resolved.directTaskFolders,
857
+ taskAreas,
858
+ cwd,
859
+ );
860
+
861
+ // If we have duplicate ID errors, stop early (fail-fast)
862
+ const duplicateErrors = discovery.errors.filter((e) => e.code === "DUPLICATE_ID");
863
+ if (duplicateErrors.length > 0) {
864
+ return discovery;
865
+ }
866
+
867
+ // Step 3: Dependency source + cache policy
868
+ // TS-004 scaffold supports prompt parsing and cached dependency maps.
869
+ // Agent-based analysis is deferred to later tasks; when selected, we
870
+ // attempt cache first and fall back to prompt parsing if unavailable.
871
+ let effectiveDependencySource: "prompt" | "agent" = dependencySource;
872
+ if (useDependencyCache && !refreshDependencies) {
873
+ const { applied } = applyDependenciesFromCache(discovery, resolved.areaScanPaths);
874
+ if (dependencySource === "agent" && !applied) {
875
+ effectiveDependencySource = "prompt";
876
+ discovery.errors.push({
877
+ code: "DEP_SOURCE_FALLBACK",
878
+ message:
879
+ "dependencies.source=agent requested, but no dependency cache was found for " +
880
+ "the selected areas. Falling back to PROMPT.md dependencies.",
881
+ });
882
+ }
883
+ } else if (dependencySource === "agent") {
884
+ effectiveDependencySource = "prompt";
885
+ discovery.errors.push({
886
+ code: "DEP_SOURCE_FALLBACK",
887
+ message:
888
+ "dependencies.source=agent requested, but agent-based dependency analysis " +
889
+ "is not implemented in TS-004 scaffold. Falling back to PROMPT.md dependencies.",
890
+ });
891
+ }
892
+
893
+ // Step 4: Resolve cross-area dependencies using effective dependencies
894
+ const depErrors = resolveDependencies(discovery, taskAreas, cwd);
895
+ discovery.errors.push(...depErrors);
896
+
897
+ // Step 5: Persist cache (if enabled) for next run / non-refresh runs
898
+ if (useDependencyCache) {
899
+ for (const areaPath of resolved.areaScanPaths) {
900
+ writeAreaDependencyCache(areaPath, discovery.pending, effectiveDependencySource);
901
+ }
902
+ }
903
+
904
+ return discovery;
905
+ }
906
+
907
+ /**
908
+ * Format discovery results as a readable string for display.
909
+ */
910
+ export function formatDiscoveryResults(result: DiscoveryResult): string {
911
+ const lines: string[] = [];
912
+
913
+ // Summary
914
+ lines.push(`📋 Discovery Results`);
915
+ lines.push(` Pending tasks: ${result.pending.size}`);
916
+ lines.push(` Completed tasks: ${result.completed.size}`);
917
+ lines.push("");
918
+
919
+ // List pending tasks grouped by area (deterministic: sorted by area name, then task ID)
920
+ if (result.pending.size > 0) {
921
+ const byArea = new Map<string, ParsedTask[]>();
922
+ for (const task of result.pending.values()) {
923
+ const existing = byArea.get(task.areaName) || [];
924
+ existing.push(task);
925
+ byArea.set(task.areaName, existing);
926
+ }
927
+
928
+ lines.push("Pending Tasks:");
929
+ const sortedAreas = [...byArea.entries()].sort((a, b) =>
930
+ a[0].localeCompare(b[0]),
931
+ );
932
+ for (const [area, tasks] of sortedAreas) {
933
+ lines.push(` ${area}:`);
934
+ const sortedTasks = [...tasks].sort((a, b) =>
935
+ a.taskId.localeCompare(b.taskId),
936
+ );
937
+ for (const task of sortedTasks) {
938
+ const deps =
939
+ task.dependencies.length > 0
940
+ ? ` → depends on: ${task.dependencies.join(", ")}`
941
+ : "";
942
+ lines.push(
943
+ ` ${task.taskId} [${task.size}] ${task.taskName}${deps}`,
944
+ );
945
+ }
946
+ }
947
+ lines.push("");
948
+ }
949
+
950
+ // Show errors
951
+ if (result.errors.length > 0) {
952
+ const fatalErrors = result.errors.filter(
953
+ (e) =>
954
+ e.code === "DUPLICATE_ID" ||
955
+ e.code === "DEP_UNRESOLVED" ||
956
+ e.code === "DEP_PENDING" ||
957
+ e.code === "DEP_AMBIGUOUS" ||
958
+ e.code === "PARSE_MISSING_ID",
959
+ );
960
+ const warnings = result.errors.filter(
961
+ (e) =>
962
+ e.code !== "DUPLICATE_ID" &&
963
+ e.code !== "DEP_UNRESOLVED" &&
964
+ e.code !== "DEP_PENDING" &&
965
+ e.code !== "DEP_AMBIGUOUS" &&
966
+ e.code !== "PARSE_MISSING_ID",
967
+ );
968
+
969
+ if (fatalErrors.length > 0) {
970
+ lines.push("❌ Errors:");
971
+ for (const err of fatalErrors) {
972
+ lines.push(` [${err.code}] ${err.message}`);
973
+ }
974
+ lines.push("");
975
+ }
976
+
977
+ if (warnings.length > 0) {
978
+ lines.push("⚠️ Warnings:");
979
+ for (const err of warnings) {
980
+ lines.push(` [${err.code}] ${err.message}`);
981
+ }
982
+ lines.push("");
983
+ }
984
+ }
985
+
986
+ return lines.join("\n");
987
+ }
988
+