taskplane 0.27.0 → 0.28.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.
@@ -1,1508 +1,1818 @@
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 { FATAL_DISCOVERY_CODES } from "./types.ts";
9
- import type { DiscoveryError, DiscoveryResult, ParsedTask, PromptSegmentDagMetadata, TaskArea, WorkspaceConfig } from "./types.ts";
10
-
11
- // ── PROMPT.md Parsing ────────────────────────────────────────────────
12
-
13
- /**
14
- * Extract the task ID from a folder name.
15
- * Convention: "TO-014-accrual-engine" → "TO-014"
16
- * Matches prefix-number patterns like "COMP-006", "TS-004", "TO-014".
17
- */
18
- export function extractTaskIdFromFolderName(folderName: string): string | null {
19
- const match = folderName.match(/^([A-Z]+-\d+)/);
20
- return match ? match[1] : null;
21
- }
22
-
23
- export interface DependencyRef {
24
- raw: string;
25
- taskId: string;
26
- areaName?: string;
27
- }
28
-
29
- export function parseDependencyReference(raw: string): DependencyRef {
30
- const trimmed = raw.trim();
31
- const qualified = trimmed.match(/^([a-z0-9-]+)\/([A-Z]+-\d+)$/i);
32
- if (qualified) {
33
- return {
34
- raw: trimmed,
35
- areaName: qualified[1].toLowerCase(),
36
- taskId: qualified[2].toUpperCase(),
37
- };
38
- }
39
-
40
- const idOnly = trimmed.match(/^([A-Z]+-\d+)$/i);
41
- if (idOnly) {
42
- return {
43
- raw: trimmed,
44
- taskId: idOnly[1].toUpperCase(),
45
- };
46
- }
47
-
48
- return {
49
- raw: trimmed,
50
- taskId: trimmed.toUpperCase(),
51
- };
52
- }
53
-
54
- export function normalizeDependencyReference(raw: string): string {
55
- const parsed = parseDependencyReference(raw);
56
- return parsed.areaName ? `${parsed.areaName}/${parsed.taskId}` : parsed.taskId;
57
- }
58
-
59
- const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
60
-
61
- function normalizeSegmentRepoToken(raw: string): string {
62
- let token = raw.trim();
63
- token = token.replace(/^`(.+)`$/, "$1").trim();
64
- token = token.replace(/^\*\*(.+)\*\*$/, "$1").trim();
65
- return token.toLowerCase();
66
- }
67
-
68
- interface ParsedSegmentDagBody {
69
- metadata: PromptSegmentDagMetadata | null;
70
- error: DiscoveryError | null;
71
- }
72
-
73
- /**
74
- * Parse optional explicit segment DAG metadata from `## Segment DAG`.
75
- *
76
- * Supported v1 syntax:
77
- *
78
- * ## Segment DAG
79
- * Repos:
80
- * - api
81
- * - web-client
82
- * Edges:
83
- * - api -> web-client
84
- *
85
- * Notes:
86
- * - `Repos:` / `Edges:` keys accept markdown decoration (`**Repos:**`) and whitespace.
87
- * - Repo IDs are normalized to lowercase and validated against routing repo ID rules.
88
- * - Unknown edge endpoints (not present in explicit repo list) fail fast.
89
- * - Self-edges and cycles fail fast with `SEGMENT_DAG_INVALID`.
90
- */
91
- function parseSegmentDagMetadata(
92
- content: string,
93
- taskId: string,
94
- promptPath: string,
95
- ): ParsedSegmentDagBody {
96
- const headerMatch = content.match(/^##\s+Segment DAG\s*$/im);
97
- if (!headerMatch || headerMatch.index === undefined) {
98
- return { metadata: null, error: null };
99
- }
100
-
101
- const headerIndex = headerMatch.index;
102
- const afterHeaderIndex = content.indexOf("\n", headerIndex);
103
- if (afterHeaderIndex === -1) {
104
- return { metadata: null, error: null };
105
- }
106
-
107
- const rest = content.slice(afterHeaderIndex + 1);
108
- const nextBoundary = rest.search(/^##\s|^---/m);
109
- const body = nextBoundary !== -1 ? rest.slice(0, nextBoundary) : rest;
110
-
111
- const repoIds: string[] = [];
112
- const repoSet = new Set<string>();
113
- const edgePairs = new Set<string>();
114
- const edges: Array<{ fromRepoId: string; toRepoId: string }> = [];
115
- const baseLine = content.slice(0, afterHeaderIndex + 1).split(/\r?\n/).length;
116
-
117
- let mode: "repos" | "edges" | null = null;
118
- const lines = body.split(/\r?\n/);
119
-
120
- for (let i = 0; i < lines.length; i++) {
121
- const rawLine = lines[i];
122
- const trimmed = rawLine.trim();
123
- if (!trimmed) continue;
124
-
125
- if (/^\*?\*?Repos:?\*?\*?\s*$/i.test(trimmed)) {
126
- mode = "repos";
127
- continue;
128
- }
129
- if (/^\*?\*?Edges:?\*?\*?\s*$/i.test(trimmed)) {
130
- mode = "edges";
131
- continue;
132
- }
133
-
134
- if (!mode) {
135
- return {
136
- metadata: null,
137
- error: {
138
- code: "SEGMENT_DAG_INVALID",
139
- message:
140
- `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
141
- `expected a Repos: or Edges: subsection header before entries.`,
142
- taskId,
143
- taskPath: promptPath,
144
- },
145
- };
146
- }
147
-
148
- const bulletMatch = rawLine.match(/^\s*[-*]\s+(.+)$/);
149
- if (!bulletMatch) {
150
- return {
151
- metadata: null,
152
- error: {
153
- code: "SEGMENT_DAG_INVALID",
154
- message:
155
- `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
156
- `expected a bullet entry ("- ...").`,
157
- taskId,
158
- taskPath: promptPath,
159
- },
160
- };
161
- }
162
-
163
- const entry = bulletMatch[1].trim();
164
- if (!entry) continue;
165
-
166
- if (mode === "repos") {
167
- if (entry.includes("->")) {
168
- return {
169
- metadata: null,
170
- error: {
171
- code: "SEGMENT_DAG_INVALID",
172
- message:
173
- `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
174
- `repo list entries must be a single repo ID.`,
175
- taskId,
176
- taskPath: promptPath,
177
- },
178
- };
179
- }
180
- const repoId = normalizeSegmentRepoToken(entry);
181
- if (!SEGMENT_REPO_ID_PATTERN.test(repoId)) {
182
- return {
183
- metadata: null,
184
- error: {
185
- code: "SEGMENT_DAG_INVALID",
186
- message:
187
- `Task ${taskId} has invalid repo ID "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
188
- `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
189
- taskId,
190
- taskPath: promptPath,
191
- },
192
- };
193
- }
194
- if (!repoSet.has(repoId)) {
195
- repoSet.add(repoId);
196
- repoIds.push(repoId);
197
- }
198
- continue;
199
- }
200
-
201
- const edgeMatch = entry.match(/^(.+?)\s*->\s*(.+)$/);
202
- if (!edgeMatch) {
203
- return {
204
- metadata: null,
205
- error: {
206
- code: "SEGMENT_DAG_INVALID",
207
- message:
208
- `Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
209
- `Expected format: <repo-a> -> <repo-b>.`,
210
- taskId,
211
- taskPath: promptPath,
212
- },
213
- };
214
- }
215
-
216
- const fromRepoId = normalizeSegmentRepoToken(edgeMatch[1]);
217
- const toRepoId = normalizeSegmentRepoToken(edgeMatch[2]);
218
- if (!SEGMENT_REPO_ID_PATTERN.test(fromRepoId) || !SEGMENT_REPO_ID_PATTERN.test(toRepoId)) {
219
- return {
220
- metadata: null,
221
- error: {
222
- code: "SEGMENT_DAG_INVALID",
223
- message:
224
- `Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
225
- `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
226
- taskId,
227
- taskPath: promptPath,
228
- },
229
- };
230
- }
231
- if (fromRepoId === toRepoId) {
232
- return {
233
- metadata: null,
234
- error: {
235
- code: "SEGMENT_DAG_INVALID",
236
- message:
237
- `Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`,
238
- taskId,
239
- taskPath: promptPath,
240
- },
241
- };
242
- }
243
-
244
- const edgeKey = `${fromRepoId}->${toRepoId}`;
245
- if (!edgePairs.has(edgeKey)) {
246
- edgePairs.add(edgeKey);
247
- edges.push({ fromRepoId, toRepoId });
248
- }
249
- }
250
-
251
- if (repoIds.length === 0 && edges.length === 0) {
252
- return { metadata: null, error: null };
253
- }
254
-
255
- for (const edge of edges) {
256
- if (!repoSet.has(edge.fromRepoId)) {
257
- return {
258
- metadata: null,
259
- error: {
260
- code: "SEGMENT_REPO_UNKNOWN",
261
- message:
262
- `Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`,
263
- taskId,
264
- taskPath: promptPath,
265
- },
266
- };
267
- }
268
- if (!repoSet.has(edge.toRepoId)) {
269
- return {
270
- metadata: null,
271
- error: {
272
- code: "SEGMENT_REPO_UNKNOWN",
273
- message:
274
- `Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`,
275
- taskId,
276
- taskPath: promptPath,
277
- },
278
- };
279
- }
280
- }
281
-
282
- const sortedEdges = [...edges].sort((a, b) => {
283
- if (a.fromRepoId !== b.fromRepoId) return a.fromRepoId.localeCompare(b.fromRepoId);
284
- return a.toRepoId.localeCompare(b.toRepoId);
285
- });
286
-
287
- const adjacency = new Map<string, string[]>();
288
- for (const repoId of repoIds) {
289
- adjacency.set(repoId, []);
290
- }
291
- for (const edge of sortedEdges) {
292
- adjacency.get(edge.fromRepoId)!.push(edge.toRepoId);
293
- }
294
- for (const neighbors of adjacency.values()) {
295
- neighbors.sort();
296
- }
297
-
298
- const visited = new Set<string>();
299
- const stack = new Set<string>();
300
- const path: string[] = [];
301
- let cycle: string[] | null = null;
302
-
303
- function dfs(repoId: string): void {
304
- if (cycle) return;
305
- visited.add(repoId);
306
- stack.add(repoId);
307
- path.push(repoId);
308
-
309
- const neighbors = adjacency.get(repoId) || [];
310
- for (const next of neighbors) {
311
- if (cycle) return;
312
- if (!visited.has(next)) {
313
- dfs(next);
314
- continue;
315
- }
316
- if (stack.has(next)) {
317
- const start = path.indexOf(next);
318
- cycle = [...path.slice(start), next];
319
- return;
320
- }
321
- }
322
-
323
- path.pop();
324
- stack.delete(repoId);
325
- }
326
-
327
- for (const repoId of [...repoIds].sort()) {
328
- if (!visited.has(repoId)) dfs(repoId);
329
- if (cycle) break;
330
- }
331
-
332
- if (cycle) {
333
- return {
334
- metadata: null,
335
- error: {
336
- code: "SEGMENT_DAG_INVALID",
337
- message:
338
- `Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`,
339
- taskId,
340
- taskPath: promptPath,
341
- },
342
- };
343
- }
344
-
345
- return {
346
- metadata: {
347
- repoIds,
348
- edges: sortedEdges,
349
- },
350
- error: null,
351
- };
352
- }
353
-
354
- /**
355
- * Parse a PROMPT.md file and extract orchestrator-relevant metadata.
356
- *
357
- * Required fields (hard fail if missing):
358
- * - Task ID: extracted from `# Task: XX-NNN - Name` heading OR from folder name
359
- *
360
- * Optional fields (defaults used if absent):
361
- * - Dependencies: defaults to [] (no dependencies)
362
- * - Review Level: defaults to 2
363
- * - Size: defaults to "M"
364
- * - File Scope: defaults to []
365
- * - Task Name: defaults to folder name
366
- *
367
- * Dependency syntax accepted:
368
- * - "**None**" or "None" → empty list
369
- * - "**Requires:** COMP-005 ..." → ["COMP-005"]
370
- * - "**Requires:** time-off/TO-014 ..." → ["time-off/TO-014"]
371
- * - "- COMP-005 (description)" → ["COMP-005"]
372
- * - "- **time-off/TO-014** — description" → ["time-off/TO-014"]
373
- * - Multiple bullet points → multiple dependencies
374
- */
375
- export function parsePromptForOrchestrator(
376
- promptPath: string,
377
- taskFolder: string,
378
- areaName: string,
379
- ): { task: ParsedTask | null; error: DiscoveryError | null } {
380
- const folderName = basename(taskFolder);
381
- let content: string;
382
-
383
- try {
384
- content = readFileSync(promptPath, "utf-8");
385
- } catch {
386
- return {
387
- task: null,
388
- error: {
389
- code: "PARSE_MALFORMED",
390
- message: `Cannot read PROMPT.md: ${promptPath}`,
391
- taskPath: promptPath,
392
- },
393
- };
394
- }
395
-
396
- // ── Extract task ID ──────────────────────────────────────────
397
- // Try from heading first: "# Task: COMP-006 - Pay Bands Implementation"
398
- let taskId: string | null = null;
399
- let taskName = folderName;
400
-
401
- const headingMatch = content.match(/^#\s+Task:\s+([A-Z]+-\d+)\s*[-—]\s*(.+)$/m);
402
- if (headingMatch) {
403
- taskId = headingMatch[1];
404
- taskName = headingMatch[2].trim();
405
- }
406
-
407
- // Fallback: extract from folder name
408
- if (!taskId) {
409
- taskId = extractTaskIdFromFolderName(folderName);
410
- }
411
-
412
- if (!taskId) {
413
- return {
414
- task: null,
415
- error: {
416
- code: "PARSE_MISSING_ID",
417
- message: `Cannot extract task ID from heading or folder name "${folderName}" in ${promptPath}`,
418
- taskPath: promptPath,
419
- },
420
- };
421
- }
422
-
423
- // ── Extract review level ─────────────────────────────────────
424
- // "## Review Level: 1 (Plan Only)" or "## Review Level: 2"
425
- let reviewLevel = 2;
426
- const reviewMatch = content.match(/^##\s+Review Level:\s*(\d+)/m);
427
- if (reviewMatch) {
428
- reviewLevel = parseInt(reviewMatch[1], 10);
429
- }
430
-
431
- // ── Extract size ─────────────────────────────────────────────
432
- // "**Size:** M" (usually near top, after Created date)
433
- let size = "M";
434
- const sizeMatch = content.match(/\*\*Size:\*\*\s*([SMLsml])/);
435
- if (sizeMatch) {
436
- size = sizeMatch[1].toUpperCase();
437
- }
438
-
439
- // ── Extract dependencies ─────────────────────────────────────
440
- const dependencies: string[] = [];
441
- const depSectionMatch = content.match(
442
- /^##\s+Dependencies\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m,
443
- );
444
-
445
- if (depSectionMatch) {
446
- const depBody = depSectionMatch[1].trim();
447
-
448
- // Check for "None" variants
449
- if (!/\*?\*?None\*?\*?/i.test(depBody) && depBody.length > 0) {
450
- // Pattern 1: "**Requires:** COMP-005 ..." or "**Requires:** time-off/TO-014 ..."
451
- const requiresMatches = depBody.matchAll(
452
- /\*?\*?Requires:?\*?\*?\s*((?:[a-z0-9-]+\/)?[A-Z]+-\d+)/gi,
453
- );
454
- for (const m of requiresMatches) {
455
- const dep = normalizeDependencyReference(m[1]);
456
- if (!dependencies.includes(dep)) dependencies.push(dep);
457
- }
458
-
459
- // Pattern 2: Bullet list "- COMP-005 ...", "- **time-off/TO-014** ..."
460
- const bulletMatches = depBody.matchAll(
461
- /^[\s-]*\*?\*?((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\*?\*?/gim,
462
- );
463
- for (const m of bulletMatches) {
464
- const dep = normalizeDependencyReference(m[1]);
465
- if (!dependencies.includes(dep)) dependencies.push(dep);
466
- }
467
-
468
- // Pattern 3: Inline dependency references not caught above
469
- if (dependencies.length === 0) {
470
- const inlineMatches = depBody.matchAll(/\b((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\b/gi);
471
- for (const m of inlineMatches) {
472
- const dep = parseDependencyReference(m[1]);
473
- if (dep.taskId === taskId) continue; // Don't add self-references
474
- const normalized = normalizeDependencyReference(m[1]);
475
- if (!dependencies.includes(normalized)) {
476
- dependencies.push(normalized);
477
- }
478
- }
479
- }
480
- }
481
- }
482
-
483
- // ── Extract execution target (repo ID) ──────────────────────
484
- // Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum
485
- const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
486
-
487
- let promptRepoId: string | undefined;
488
-
489
- // Priority 1: Section-based "## Execution Target" with "Repo: <id>" line
490
- // Capture everything from section header to the next heading or --- divider.
491
- // We avoid \n$ (which in multiline mode matches blank lines) by using a two-pass
492
- // approach: find the section start, then slice to the next section boundary.
493
- const execTargetHeaderIdx = content.search(/^##\s+Execution Target\s*$/m);
494
- let execTargetSectionBody: string | null = null;
495
- if (execTargetHeaderIdx !== -1) {
496
- const afterHeader = content.indexOf("\n", execTargetHeaderIdx);
497
- if (afterHeader !== -1) {
498
- const rest = content.slice(afterHeader + 1);
499
- const nextSectionMatch = rest.search(/^##\s|^---/m);
500
- execTargetSectionBody = nextSectionMatch !== -1
501
- ? rest.slice(0, nextSectionMatch)
502
- : rest;
503
- }
504
- }
505
- if (execTargetSectionBody !== null) {
506
- // Match "Repo: api" or "**Repo:** api" or "Workspace: api" with whitespace
507
- const repoLineMatch = execTargetSectionBody.match(
508
- /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/mi,
509
- );
510
- if (repoLineMatch) {
511
- const candidate = repoLineMatch[1].trim().toLowerCase();
512
- if (REPO_ID_PATTERN.test(candidate)) {
513
- promptRepoId = candidate;
514
- }
515
- }
516
- }
517
-
518
- // Priority 2 (fallback): Inline "**Repo:** <id>" or "**Workspace:** <id>" anywhere in content
519
- if (!promptRepoId) {
520
- const inlineRepoMatch = content.match(
521
- /^\*\*(?:Repo|Workspace):\*\*\s+(\S+)/m,
522
- );
523
- if (inlineRepoMatch) {
524
- const candidate = inlineRepoMatch[1].trim().toLowerCase();
525
- if (REPO_ID_PATTERN.test(candidate)) {
526
- promptRepoId = candidate;
527
- }
528
- }
529
- }
530
-
531
- // ── Extract file scope ───────────────────────────────────────
532
- const fileScope: string[] = [];
533
- const fileScopeMatch = content.match(
534
- /^##\s+File Scope\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m,
535
- );
536
-
537
- if (fileScopeMatch) {
538
- const scopeBody = fileScopeMatch[1].trim();
539
- const scopeLines = scopeBody.split("\n");
540
- for (const line of scopeLines) {
541
- // "- extensions/task-orchestrator.ts" or "- `api-service/src/health.js`"
542
- let trimmed = line.replace(/^[\s-*]+/, "").trim();
543
- // Strip inline backticks: `path/to/file` → path/to/file
544
- if (trimmed.startsWith("`") && trimmed.endsWith("`")) {
545
- trimmed = trimmed.slice(1, -1);
546
- }
547
- if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("```")) {
548
- fileScope.push(trimmed);
549
- }
550
- }
551
- }
552
-
553
- // ── Extract optional explicit segment DAG metadata ──────────
554
- const segmentDagResult = parseSegmentDagMetadata(content, taskId, resolve(promptPath));
555
- if (segmentDagResult.error) {
556
- return {
557
- task: null,
558
- error: segmentDagResult.error,
559
- };
560
- }
561
- const explicitSegmentDag = segmentDagResult.metadata;
562
-
563
- return {
564
- task: {
565
- taskId,
566
- taskName,
567
- reviewLevel,
568
- size,
569
- dependencies,
570
- fileScope,
571
- taskFolder: resolve(taskFolder),
572
- promptPath: resolve(promptPath),
573
- areaName,
574
- status: "pending",
575
- ...(promptRepoId ? { promptRepoId } : {}),
576
- ...(explicitSegmentDag ? { explicitSegmentDag } : {}),
577
- },
578
- error: null,
579
- };
580
- }
581
-
582
-
583
- // ── Area Scanning ────────────────────────────────────────────────────
584
-
585
- /**
586
- * Scan an area path for pending tasks.
587
- *
588
- * Lists immediate subdirectories only (no recursion).
589
- * Skips "archive" directories and folders with .DONE files.
590
- * Parses PROMPT.md in each remaining subdirectory.
591
- */
592
- export function scanAreaForTasks(
593
- areaPath: string,
594
- areaName: string,
595
- ): { tasks: ParsedTask[]; errors: DiscoveryError[] } {
596
- const tasks: ParsedTask[] = [];
597
- const errors: DiscoveryError[] = [];
598
-
599
- const resolvedPath = resolve(areaPath);
600
- if (!existsSync(resolvedPath)) {
601
- errors.push({
602
- code: "SCAN_ERROR",
603
- message: `Area path does not exist: ${resolvedPath}`,
604
- taskPath: resolvedPath,
605
- });
606
- return { tasks, errors };
607
- }
608
-
609
- let entries: string[];
610
- try {
611
- entries = readdirSync(resolvedPath);
612
- } catch {
613
- errors.push({
614
- code: "SCAN_ERROR",
615
- message: `Cannot read area directory: ${resolvedPath}`,
616
- taskPath: resolvedPath,
617
- });
618
- return { tasks, errors };
619
- }
620
-
621
- for (const entry of entries) {
622
- // Skip archive directory
623
- if (entry.toLowerCase() === "archive") continue;
624
-
625
- const entryPath = join(resolvedPath, entry);
626
-
627
- // Only process directories
628
- try {
629
- if (!statSync(entryPath).isDirectory()) continue;
630
- } catch {
631
- continue;
632
- }
633
-
634
- // Skip if .DONE exists (already complete)
635
- if (existsSync(join(entryPath, ".DONE"))) continue;
636
-
637
- // Skip if no PROMPT.md
638
- const promptPath = join(entryPath, "PROMPT.md");
639
- if (!existsSync(promptPath)) continue;
640
-
641
- // Parse PROMPT.md
642
- const result = parsePromptForOrchestrator(promptPath, entryPath, areaName);
643
- if (result.error) {
644
- errors.push(result.error);
645
- }
646
- if (result.task) {
647
- tasks.push(result.task);
648
- }
649
- }
650
-
651
- return { tasks, errors };
652
- }
653
-
654
-
655
- // ── Completed Task Set ───────────────────────────────────────────────
656
-
657
- /**
658
- * Build a set of completed task IDs by scanning:
659
- * 1. archive/ subdirectories for .DONE markers
660
- * 2. Active task folders that have .DONE files (caught during scanAreaForTasks skip)
661
- *
662
- * This set is used only for dependency resolution — completed tasks are never re-executed.
663
- */
664
- export function buildCompletedTaskSet(areaPaths: string[]): Set<string> {
665
- const completed = new Set<string>();
666
-
667
- for (const areaPath of areaPaths) {
668
- const resolvedPath = resolve(areaPath);
669
- if (!existsSync(resolvedPath)) continue;
670
-
671
- let entries: string[];
672
- try {
673
- entries = readdirSync(resolvedPath);
674
- } catch {
675
- continue;
676
- }
677
-
678
- for (const entry of entries) {
679
- const entryPath = join(resolvedPath, entry);
680
-
681
- try {
682
- if (!statSync(entryPath).isDirectory()) continue;
683
- } catch {
684
- continue;
685
- }
686
-
687
- if (entry.toLowerCase() === "archive") {
688
- // Scan archive subdirectories for completed tasks
689
- let archiveEntries: string[];
690
- try {
691
- archiveEntries = readdirSync(entryPath);
692
- } catch {
693
- continue;
694
- }
695
- for (const archiveEntry of archiveEntries) {
696
- const archiveFolderPath = join(entryPath, archiveEntry);
697
- try {
698
- if (!statSync(archiveFolderPath).isDirectory()) continue;
699
- } catch {
700
- continue;
701
- }
702
- // Only treat archive tasks as complete when .DONE marker exists
703
- if (!existsSync(join(archiveFolderPath, ".DONE"))) continue;
704
- const taskId = extractTaskIdFromFolderName(archiveEntry);
705
- if (taskId) {
706
- completed.add(taskId);
707
- }
708
- }
709
- } else {
710
- // Active folder with .DONE = completed
711
- if (existsSync(join(entryPath, ".DONE"))) {
712
- const taskId = extractTaskIdFromFolderName(entry);
713
- if (taskId) {
714
- completed.add(taskId);
715
- }
716
- }
717
- }
718
- }
719
- }
720
-
721
- return completed;
722
- }
723
-
724
-
725
- // ── Argument Resolution ──────────────────────────────────────────────
726
-
727
- /**
728
- * Resolve command arguments into area scan paths and direct task folders.
729
- *
730
- * Accepts mixed arguments:
731
- * - "all" → all areas from task_areas
732
- * - area name → looked up in task_areas
733
- * - directory path → used as-is
734
- * - PROMPT.md path → single task (dirname used as task folder)
735
- */
736
- export function resolveArguments(
737
- args: string,
738
- taskAreas: Record<string, TaskArea>,
739
- cwd: string,
740
- ): { areaScanPaths: string[]; directTaskFolders: string[]; errors: DiscoveryError[] } {
741
- const areaScanPaths: string[] = [];
742
- const directTaskFolders: string[] = [];
743
- const errors: DiscoveryError[] = [];
744
-
745
- const tokens = args.trim().split(/\s+/).filter(Boolean);
746
-
747
- for (const token of tokens) {
748
- if (token.toLowerCase() === "all") {
749
- // Expand to all areas
750
- for (const area of Object.values(taskAreas)) {
751
- const fullPath = resolve(cwd, area.path);
752
- if (!areaScanPaths.includes(fullPath)) {
753
- areaScanPaths.push(fullPath);
754
- }
755
- }
756
- } else if (taskAreas[token]) {
757
- // Known area name
758
- const fullPath = resolve(cwd, taskAreas[token].path);
759
- if (!areaScanPaths.includes(fullPath)) {
760
- areaScanPaths.push(fullPath);
761
- }
762
- } else if (
763
- token.endsWith("PROMPT.md") &&
764
- existsSync(resolve(cwd, token))
765
- ) {
766
- // Single PROMPT.md file
767
- directTaskFolders.push(resolve(cwd, dirname(token)));
768
- } else if (existsSync(resolve(cwd, token))) {
769
- // Directory path
770
- const fullPath = resolve(cwd, token);
771
- try {
772
- if (statSync(fullPath).isDirectory()) {
773
- if (!areaScanPaths.includes(fullPath)) {
774
- areaScanPaths.push(fullPath);
775
- }
776
- } else {
777
- errors.push({
778
- code: "UNKNOWN_ARG",
779
- message: `Not a directory or PROMPT.md file: ${token}`,
780
- });
781
- }
782
- } catch {
783
- errors.push({
784
- code: "UNKNOWN_ARG",
785
- message: `Cannot stat path: ${token}`,
786
- });
787
- }
788
- } else {
789
- errors.push({
790
- code: "UNKNOWN_ARG",
791
- message: `Unknown area, path, or file: "${token}"`,
792
- });
793
- }
794
- }
795
-
796
- return { areaScanPaths, directTaskFolders, errors };
797
- }
798
-
799
- export interface DiscoveryOptions {
800
- refreshDependencies?: boolean;
801
- dependencySource?: "prompt" | "agent";
802
- useDependencyCache?: boolean;
803
- /** Workspace config for repo routing (null/undefined = repo mode, no routing). */
804
- workspaceConfig?: WorkspaceConfig | null;
805
- }
806
-
807
- export interface DependencyCacheFile {
808
- version: number;
809
- generatedAt: string;
810
- source: string;
811
- tasks: Record<string, string[]>;
812
- }
813
-
814
- export function normalizePathForCompare(p: string): string {
815
- return resolve(p).replace(/\\/g, "/").toLowerCase();
816
- }
817
-
818
- export function isPathWithin(childPath: string, parentPath: string): boolean {
819
- const child = normalizePathForCompare(childPath);
820
- const parent = normalizePathForCompare(parentPath);
821
- return child === parent || child.startsWith(`${parent}/`);
822
- }
823
-
824
- export function dedupeAndNormalizeDeps(deps: string[]): string[] {
825
- const seen = new Set<string>();
826
- const out: string[] = [];
827
- for (const dep of deps) {
828
- const norm = normalizeDependencyReference(dep);
829
- if (!norm || seen.has(norm)) continue;
830
- seen.add(norm);
831
- out.push(norm);
832
- }
833
- return out;
834
- }
835
-
836
- export function loadAreaDependencyCache(areaPath: string): DependencyCacheFile | null {
837
- const cachePath = join(areaPath, "dependencies.json");
838
- if (!existsSync(cachePath)) return null;
839
- try {
840
- const raw = readFileSync(cachePath, "utf-8");
841
- const parsed = JSON.parse(raw) as DependencyCacheFile;
842
- if (!parsed || typeof parsed !== "object" || !parsed.tasks) return null;
843
- return parsed;
844
- } catch {
845
- return null;
846
- }
847
- }
848
-
849
- export function writeAreaDependencyCache(
850
- areaPath: string,
851
- pending: Map<string, ParsedTask>,
852
- source: "prompt" | "agent",
853
- ): void {
854
- const tasks: Record<string, string[]> = {};
855
- for (const task of pending.values()) {
856
- if (!isPathWithin(task.taskFolder, areaPath)) continue;
857
- tasks[task.taskId] = dedupeAndNormalizeDeps(task.dependencies);
858
- }
859
-
860
- const cachePath = join(areaPath, "dependencies.json");
861
- const payload: DependencyCacheFile = {
862
- version: 1,
863
- generatedAt: new Date().toISOString(),
864
- source,
865
- tasks,
866
- };
867
-
868
- try {
869
- // Keep deterministic formatting for easy diffs
870
- const json = JSON.stringify(payload, null, 2);
871
- writeFileSync(cachePath, `${json}\n`, "utf-8");
872
- } catch {
873
- // Non-fatal: discovery should still succeed without cache persistence
874
- }
875
- }
876
-
877
- export function applyDependenciesFromCache(
878
- discovery: DiscoveryResult,
879
- areaScanPaths: string[],
880
- ): { applied: boolean } {
881
- let applied = false;
882
- for (const areaPath of areaScanPaths) {
883
- const cache = loadAreaDependencyCache(areaPath);
884
- if (!cache) continue;
885
- for (const task of discovery.pending.values()) {
886
- if (!isPathWithin(task.taskFolder, areaPath)) continue;
887
- const cachedDeps = cache.tasks[task.taskId];
888
- if (!cachedDeps) continue;
889
- task.dependencies = dedupeAndNormalizeDeps(cachedDeps);
890
- applied = true;
891
- }
892
- }
893
- return { applied };
894
- }
895
-
896
-
897
- // ── Task Registry ────────────────────────────────────────────────────
898
-
899
- /**
900
- * Build the full task registry: pending tasks + completed set.
901
- *
902
- * Enforces global uniqueness of task IDs across all areas.
903
- * If duplicates are found, returns a fail-fast error listing all collision locations.
904
- */
905
- export function buildTaskRegistry(
906
- areaScanPaths: string[],
907
- directTaskFolders: string[],
908
- taskAreas: Record<string, TaskArea>,
909
- cwd: string,
910
- ): DiscoveryResult {
911
- const pending = new Map<string, ParsedTask>();
912
- const errors: DiscoveryError[] = [];
913
-
914
- // Track all locations per task ID for duplicate detection
915
- const idLocations = new Map<string, string[]>();
916
-
917
- function trackId(taskId: string, location: string) {
918
- const existing = idLocations.get(taskId) || [];
919
- existing.push(location);
920
- idLocations.set(taskId, existing);
921
- }
922
-
923
- // Resolve area names for scan paths
924
- const areaNameByPath = new Map<string, string>();
925
- for (const [name, area] of Object.entries(taskAreas)) {
926
- areaNameByPath.set(resolve(cwd, area.path), name);
927
- }
928
-
929
- // 1. Scan area paths for pending tasks
930
- for (const areaPath of areaScanPaths) {
931
- const areaName = areaNameByPath.get(areaPath) || basename(areaPath);
932
- const result = scanAreaForTasks(areaPath, areaName);
933
- errors.push(...result.errors);
934
-
935
- for (const task of result.tasks) {
936
- trackId(task.taskId, task.promptPath);
937
- pending.set(task.taskId, task);
938
- }
939
- }
940
-
941
- // 2. Process direct task folders (single PROMPT.md files)
942
- for (const taskFolder of directTaskFolders) {
943
- const promptPath = join(taskFolder, "PROMPT.md");
944
- if (!existsSync(promptPath)) {
945
- errors.push({
946
- code: "SCAN_ERROR",
947
- message: `No PROMPT.md found in direct task folder: ${taskFolder}`,
948
- taskPath: taskFolder,
949
- });
950
- continue;
951
- }
952
-
953
- // Try to determine area name from path
954
- let areaName = "unknown";
955
- for (const [name, area] of Object.entries(taskAreas)) {
956
- const resolvedAreaPath = resolve(cwd, area.path);
957
- if (taskFolder.startsWith(resolvedAreaPath)) {
958
- areaName = name;
959
- break;
960
- }
961
- }
962
-
963
- // Skip if .DONE exists
964
- if (existsSync(join(taskFolder, ".DONE"))) continue;
965
-
966
- const result = parsePromptForOrchestrator(promptPath, taskFolder, areaName);
967
- if (result.error) {
968
- errors.push(result.error);
969
- }
970
- if (result.task) {
971
- trackId(result.task.taskId, result.task.promptPath);
972
- pending.set(result.task.taskId, result.task);
973
- }
974
- }
975
-
976
- // 3. Build completed task set from all scanned areas
977
- const completed = buildCompletedTaskSet(areaScanPaths);
978
-
979
- // Also scan all task_areas for completed tasks (needed for cross-area dep resolution)
980
- const allAreaPaths = Object.values(taskAreas).map((a) => resolve(cwd, a.path));
981
- const globalCompleted = buildCompletedTaskSet(allAreaPaths);
982
- for (const id of globalCompleted) {
983
- completed.add(id);
984
- }
985
-
986
- // 4. Check for duplicate task IDs (global uniqueness enforcement)
987
- for (const [taskId, locations] of idLocations) {
988
- if (locations.length > 1) {
989
- errors.push({
990
- code: "DUPLICATE_ID",
991
- message:
992
- `Duplicate task ID "${taskId}" found in ${locations.length} locations:\n` +
993
- locations.map((l) => ` - ${l}`).join("\n"),
994
- taskId,
995
- });
996
- }
997
- }
998
-
999
- return { pending, completed, errors };
1000
- }
1001
-
1002
-
1003
- // ── Cross-Area Dependency Resolution ─────────────────────────────────
1004
-
1005
- /** Candidate match for a dependency reference found in task areas. */
1006
- export interface DependencyCandidate {
1007
- areaName: string;
1008
- path: string;
1009
- status: "pending" | "complete";
1010
- }
1011
-
1012
- export function findDependencyCandidates(
1013
- depRef: DependencyRef,
1014
- taskAreas: Record<string, TaskArea>,
1015
- cwd: string,
1016
- ): DependencyCandidate[] {
1017
- const candidates: DependencyCandidate[] = [];
1018
- const sortedAreas = Object.entries(taskAreas).sort((a, b) => a[0].localeCompare(b[0]));
1019
-
1020
- for (const [areaName, area] of sortedAreas) {
1021
- if (depRef.areaName && depRef.areaName !== areaName.toLowerCase()) {
1022
- continue;
1023
- }
1024
-
1025
- const areaPath = resolve(cwd, area.path);
1026
- if (!existsSync(areaPath)) continue;
1027
-
1028
- let entries: string[];
1029
- try {
1030
- entries = readdirSync(areaPath);
1031
- } catch {
1032
- continue;
1033
- }
1034
-
1035
- // Active tasks (skip archive)
1036
- for (const entry of entries) {
1037
- if (entry.toLowerCase() === "archive") continue;
1038
- const entryTaskId = extractTaskIdFromFolderName(entry);
1039
- if (entryTaskId !== depRef.taskId) continue;
1040
-
1041
- const entryPath = join(areaPath, entry);
1042
- try {
1043
- if (!statSync(entryPath).isDirectory()) continue;
1044
- } catch {
1045
- continue;
1046
- }
1047
-
1048
- candidates.push({
1049
- areaName,
1050
- path: entryPath,
1051
- status: existsSync(join(entryPath, ".DONE")) ? "complete" : "pending",
1052
- });
1053
- }
1054
-
1055
- // Archived tasks (require .DONE marker)
1056
- const archivePath = join(areaPath, "archive");
1057
- if (!existsSync(archivePath)) continue;
1058
- try {
1059
- const archiveEntries = readdirSync(archivePath);
1060
- for (const archiveEntry of archiveEntries) {
1061
- const entryTaskId = extractTaskIdFromFolderName(archiveEntry);
1062
- if (entryTaskId !== depRef.taskId) continue;
1063
-
1064
- const archiveTaskPath = join(archivePath, archiveEntry);
1065
- candidates.push({
1066
- areaName,
1067
- path: archiveTaskPath,
1068
- status: existsSync(join(archiveTaskPath, ".DONE")) ? "complete" : "pending",
1069
- });
1070
- }
1071
- } catch {
1072
- // Ignore archive read errors for discovery resilience
1073
- }
1074
- }
1075
-
1076
- return candidates;
1077
- }
1078
-
1079
- /**
1080
- * Resolve dependencies for all pending tasks.
1081
- *
1082
- * Supports both dependency formats:
1083
- * - TASK-ID (unqualified)
1084
- * - area-name/TASK-ID (area-qualified)
1085
- */
1086
- export function resolveDependencies(
1087
- discovery: DiscoveryResult,
1088
- taskAreas: Record<string, TaskArea>,
1089
- cwd: string,
1090
- ): DiscoveryError[] {
1091
- const errors: DiscoveryError[] = [];
1092
-
1093
- for (const [taskId, task] of discovery.pending) {
1094
- for (const depRaw of task.dependencies) {
1095
- const depRef = parseDependencyReference(depRaw);
1096
- const depId = depRef.taskId;
1097
-
1098
- // Fast path for unqualified refs already in registry
1099
- if (!depRef.areaName) {
1100
- if (discovery.pending.has(depId)) continue;
1101
- if (discovery.completed.has(depId)) continue;
1102
- } else {
1103
- const pendingTask = discovery.pending.get(depId);
1104
- if (pendingTask && pendingTask.areaName.toLowerCase() === depRef.areaName) {
1105
- continue;
1106
- }
1107
- }
1108
-
1109
- const candidates = findDependencyCandidates(depRef, taskAreas, cwd);
1110
-
1111
- if (candidates.length === 0) {
1112
- errors.push({
1113
- code: "DEP_UNRESOLVED",
1114
- message: `${taskId} depends on ${depRaw} which does not exist in any task area`,
1115
- taskId,
1116
- taskPath: task.promptPath,
1117
- });
1118
- continue;
1119
- }
1120
-
1121
- if (!depRef.areaName && candidates.length > 1) {
1122
- const options = candidates
1123
- .map((c) => ` - ${c.areaName}/${depId} [${c.status}] (${c.path})`)
1124
- .join("\n");
1125
- errors.push({
1126
- code: "DEP_AMBIGUOUS",
1127
- message:
1128
- `${taskId} depends on ${depId}, but multiple tasks match across areas. ` +
1129
- `Use an area-qualified dependency (area/${depId}).\n${options}`,
1130
- taskId,
1131
- taskPath: task.promptPath,
1132
- });
1133
- continue;
1134
- }
1135
-
1136
- if (depRef.areaName && candidates.length > 1) {
1137
- const options = candidates
1138
- .map((c) => ` - ${c.areaName}/${depId} [${c.status}] (${c.path})`)
1139
- .join("\n");
1140
- errors.push({
1141
- code: "DEP_AMBIGUOUS",
1142
- message:
1143
- `${taskId} depends on ${depRaw}, but multiple matching task folders were found. ` +
1144
- `Resolve duplicate task IDs.\n${options}`,
1145
- taskId,
1146
- taskPath: task.promptPath,
1147
- });
1148
- continue;
1149
- }
1150
-
1151
- const match = candidates[0];
1152
- if (match.status === "complete") {
1153
- discovery.completed.add(depId);
1154
- continue;
1155
- }
1156
-
1157
- errors.push({
1158
- code: "DEP_PENDING",
1159
- message:
1160
- `${taskId} depends on ${depRaw} which is pending in "${match.areaName}". ` +
1161
- `Include that area: /orch ${match.areaName}`,
1162
- taskId,
1163
- taskPath: task.promptPath,
1164
- });
1165
- }
1166
- }
1167
-
1168
- return errors;
1169
- }
1170
-
1171
-
1172
- // ── Task-to-Repo Routing ─────────────────────────────────────────────
1173
-
1174
- /** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */
1175
- const ROUTING_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1176
-
1177
- /**
1178
- * Resolve the target repo for each discovered task using the routing
1179
- * precedence chain:
1180
- *
1181
- * 1. `task.promptRepoId` — declared in PROMPT.md metadata
1182
- * 2. `taskArea.repoId` — area-level config from task-runner.yaml
1183
- * 3. `workspaceConfig.routing.defaultRepo` workspace-level default
1184
- *
1185
- * Only applied in workspace mode (when `workspaceConfig` is provided).
1186
- * In repo mode this function is never called.
1187
- *
1188
- * Returns an array of DiscoveryError for routing failures:
1189
- * - TASK_REPO_UNRESOLVED: no source provided a repo ID
1190
- * - TASK_REPO_UNKNOWN: resolved repo ID is not in workspace repos map
1191
- */
1192
- export function resolveTaskRouting(
1193
- discovery: DiscoveryResult,
1194
- taskAreas: Record<string, TaskArea>,
1195
- workspaceConfig: WorkspaceConfig,
1196
- ): DiscoveryError[] {
1197
- const errors: DiscoveryError[] = [];
1198
- const validRepoIds = workspaceConfig.repos;
1199
- const strictMode = workspaceConfig.routing.strict === true;
1200
-
1201
- for (const task of discovery.pending.values()) {
1202
- // ── Explicit segment DAG repo validation (workspace IDs)
1203
- if (task.explicitSegmentDag) {
1204
- const unknownRepos = task.explicitSegmentDag.repoIds.filter((repoId) => !validRepoIds.has(repoId));
1205
- if (unknownRepos.length > 0) {
1206
- errors.push({
1207
- code: "SEGMENT_REPO_UNKNOWN",
1208
- message:
1209
- `Task ${task.taskId} declares unknown repo ID(s) in ## Segment DAG: ${unknownRepos.join(", ")}. ` +
1210
- `Known repos: ${[...validRepoIds.keys()].join(", ")}`,
1211
- taskId: task.taskId,
1212
- taskPath: task.promptPath,
1213
- });
1214
- continue;
1215
- }
1216
- }
1217
-
1218
- // ── Strict mode enforcement ──────────────────────────────
1219
- // When strict routing is enabled, every task MUST declare an
1220
- // explicit execution target in PROMPT.md. Area-level and
1221
- // workspace-default fallbacks are NOT used for resolution.
1222
- if (strictMode && !task.promptRepoId) {
1223
- errors.push({
1224
- code: "TASK_ROUTING_STRICT",
1225
- message:
1226
- `Task ${task.taskId} has no explicit execution target, but strict routing is enabled ` +
1227
- `(routing.strict: true in workspace config). ` +
1228
- `Add an execution target to the task's PROMPT.md:\n` +
1229
- `\n` +
1230
- ` ## Execution Target\n` +
1231
- `\n` +
1232
- ` Repo: <repo-id>\n` +
1233
- `\n` +
1234
- `Available repos: ${[...validRepoIds.keys()].join(", ")}`,
1235
- taskId: task.taskId,
1236
- taskPath: task.promptPath,
1237
- });
1238
- continue;
1239
- }
1240
-
1241
- // Precedence 1: prompt-declared repo
1242
- let resolvedId = task.promptRepoId;
1243
- let source = "prompt";
1244
-
1245
- // Precedence 2: area-level repo
1246
- if (!resolvedId) {
1247
- const area = taskAreas[task.areaName];
1248
- if (area?.repoId) {
1249
- const candidate = area.repoId.trim().toLowerCase();
1250
- if (ROUTING_REPO_ID_PATTERN.test(candidate)) {
1251
- resolvedId = candidate;
1252
- source = "area";
1253
- }
1254
- }
1255
- }
1256
-
1257
- // Precedence 3: file scope inference — match file path prefixes against
1258
- // known workspace repo IDs. If file scope entries like "web-client/src/..."
1259
- // start with a repo name, route the task to that repo.
1260
- if (!resolvedId && task.fileScope && task.fileScope.length > 0) {
1261
- const repoIds = [...validRepoIds.keys()];
1262
- const repoCounts = new Map<string, number>();
1263
- for (const filePath of task.fileScope) {
1264
- const normalized = filePath.replace(/\\/g, "/");
1265
- for (const repoId of repoIds) {
1266
- if (normalized.startsWith(repoId + "/") || normalized === repoId) {
1267
- repoCounts.set(repoId, (repoCounts.get(repoId) || 0) + 1);
1268
- break; // first matching repo wins for this path
1269
- }
1270
- }
1271
- }
1272
- // Use the repo with the most file scope matches (majority vote)
1273
- if (repoCounts.size === 1) {
1274
- resolvedId = repoCounts.keys().next().value!;
1275
- source = "file-scope";
1276
- } else if (repoCounts.size > 1) {
1277
- // Multiple repos in file scope — pick the one with most entries.
1278
- // (Future: #51 will handle multi-repo tasks properly)
1279
- let maxCount = 0;
1280
- for (const [repoId, count] of repoCounts) {
1281
- if (count > maxCount) {
1282
- maxCount = count;
1283
- resolvedId = repoId;
1284
- }
1285
- }
1286
- source = "file-scope";
1287
- }
1288
- }
1289
-
1290
- // Precedence 4: workspace default repo
1291
- if (!resolvedId) {
1292
- resolvedId = workspaceConfig.routing.defaultRepo;
1293
- source = "default";
1294
- }
1295
-
1296
- // Validate resolution
1297
- if (!resolvedId) {
1298
- errors.push({
1299
- code: "TASK_REPO_UNRESOLVED",
1300
- message:
1301
- `Task ${task.taskId} has no resolved repo. ` +
1302
- `Add file scope paths prefixed with the repo name (e.g., "web-client/src/..."), ` +
1303
- `set repo_id on area "${task.areaName}", ` +
1304
- `or set routing.default_repo in the workspace config.`,
1305
- taskId: task.taskId,
1306
- taskPath: task.promptPath,
1307
- });
1308
- continue;
1309
- }
1310
-
1311
- if (!validRepoIds.has(resolvedId)) {
1312
- errors.push({
1313
- code: "TASK_REPO_UNKNOWN",
1314
- message:
1315
- `Task ${task.taskId} resolved to repo "${resolvedId}" (via ${source}), ` +
1316
- `but no repo with that ID exists in the workspace config. ` +
1317
- `Known repos: ${[...validRepoIds.keys()].join(", ")}`,
1318
- taskId: task.taskId,
1319
- taskPath: task.promptPath,
1320
- });
1321
- continue;
1322
- }
1323
-
1324
- // Attach resolved repo to the task
1325
- task.resolvedRepoId = resolvedId;
1326
- }
1327
-
1328
- return errors;
1329
- }
1330
-
1331
-
1332
- // ── Discovery Pipeline (Public) ──────────────────────────────────────
1333
-
1334
- /**
1335
- * Run the full discovery pipeline:
1336
- * 1. Resolve arguments to scan paths and direct task folders
1337
- * 2. Build task registry (scan, parse, deduplicate)
1338
- * 3. Resolve cross-area dependencies
1339
- *
1340
- * Returns a DiscoveryResult with pending tasks, completed set, and any errors.
1341
- */
1342
- export function runDiscovery(
1343
- args: string,
1344
- taskAreas: Record<string, TaskArea>,
1345
- cwd: string,
1346
- options: DiscoveryOptions = {},
1347
- ): DiscoveryResult {
1348
- const dependencySource = options.dependencySource ?? "prompt";
1349
- const useDependencyCache = options.useDependencyCache ?? false;
1350
- const refreshDependencies = options.refreshDependencies ?? false;
1351
-
1352
- // Step 1: Resolve arguments
1353
- const resolved = resolveArguments(args, taskAreas, cwd);
1354
- if (resolved.errors.length > 0) {
1355
- return {
1356
- pending: new Map(),
1357
- completed: new Set(),
1358
- errors: resolved.errors,
1359
- };
1360
- }
1361
-
1362
- if (resolved.areaScanPaths.length === 0 && resolved.directTaskFolders.length === 0) {
1363
- return {
1364
- pending: new Map(),
1365
- completed: new Set(),
1366
- errors: [
1367
- {
1368
- code: "UNKNOWN_ARG",
1369
- message: "No valid areas, paths, or PROMPT.md files found in arguments",
1370
- },
1371
- ],
1372
- };
1373
- }
1374
-
1375
- // Step 2: Build task registry (prompt-parsed dependencies as baseline)
1376
- const discovery = buildTaskRegistry(
1377
- resolved.areaScanPaths,
1378
- resolved.directTaskFolders,
1379
- taskAreas,
1380
- cwd,
1381
- );
1382
-
1383
- // If we have duplicate ID errors, stop early (fail-fast)
1384
- const duplicateErrors = discovery.errors.filter((e) => e.code === "DUPLICATE_ID");
1385
- if (duplicateErrors.length > 0) {
1386
- return discovery;
1387
- }
1388
-
1389
- // Step 3: Dependency source + cache policy
1390
- // TS-004 scaffold supports prompt parsing and cached dependency maps.
1391
- // Agent-based analysis is deferred to later tasks; when selected, we
1392
- // attempt cache first and fall back to prompt parsing if unavailable.
1393
- let effectiveDependencySource: "prompt" | "agent" = dependencySource;
1394
- if (useDependencyCache && !refreshDependencies) {
1395
- const { applied } = applyDependenciesFromCache(discovery, resolved.areaScanPaths);
1396
- if (dependencySource === "agent" && !applied) {
1397
- effectiveDependencySource = "prompt";
1398
- discovery.errors.push({
1399
- code: "DEP_SOURCE_FALLBACK",
1400
- message:
1401
- "dependencies.source=agent requested, but no dependency cache was found for " +
1402
- "the selected areas. Falling back to PROMPT.md dependencies.",
1403
- });
1404
- }
1405
- } else if (dependencySource === "agent") {
1406
- effectiveDependencySource = "prompt";
1407
- discovery.errors.push({
1408
- code: "DEP_SOURCE_FALLBACK",
1409
- message:
1410
- "dependencies.source=agent requested, but agent-based dependency analysis " +
1411
- "is not implemented in TS-004 scaffold. Falling back to PROMPT.md dependencies.",
1412
- });
1413
- }
1414
-
1415
- // Step 4: Resolve cross-area dependencies using effective dependencies
1416
- const depErrors = resolveDependencies(discovery, taskAreas, cwd);
1417
- discovery.errors.push(...depErrors);
1418
-
1419
- // Step 5: Persist cache (if enabled) for next run / non-refresh runs
1420
- if (useDependencyCache) {
1421
- for (const areaPath of resolved.areaScanPaths) {
1422
- writeAreaDependencyCache(areaPath, discovery.pending, effectiveDependencySource);
1423
- }
1424
- }
1425
-
1426
- // Step 6: Task-to-repo routing (workspace mode only)
1427
- const workspaceConfig = options.workspaceConfig;
1428
- if (workspaceConfig && workspaceConfig.mode === "workspace") {
1429
- const routingErrors = resolveTaskRouting(discovery, taskAreas, workspaceConfig);
1430
- discovery.errors.push(...routingErrors);
1431
- }
1432
-
1433
- return discovery;
1434
- }
1435
-
1436
- /**
1437
- * Format discovery results as a readable string for display.
1438
- */
1439
- export function formatDiscoveryResults(result: DiscoveryResult): string {
1440
- const lines: string[] = [];
1441
-
1442
- // Summary
1443
- lines.push(`📋 Discovery Results`);
1444
- lines.push(` Pending tasks: ${result.pending.size}`);
1445
- lines.push(` Completed tasks: ${result.completed.size}`);
1446
- lines.push("");
1447
-
1448
- // List pending tasks grouped by area (deterministic: sorted by area name, then task ID)
1449
- if (result.pending.size > 0) {
1450
- const byArea = new Map<string, ParsedTask[]>();
1451
- for (const task of result.pending.values()) {
1452
- const existing = byArea.get(task.areaName) || [];
1453
- existing.push(task);
1454
- byArea.set(task.areaName, existing);
1455
- }
1456
-
1457
- lines.push("Pending Tasks:");
1458
- const sortedAreas = [...byArea.entries()].sort((a, b) =>
1459
- a[0].localeCompare(b[0]),
1460
- );
1461
- for (const [area, tasks] of sortedAreas) {
1462
- lines.push(` ${area}:`);
1463
- const sortedTasks = [...tasks].sort((a, b) =>
1464
- a.taskId.localeCompare(b.taskId),
1465
- );
1466
- for (const task of sortedTasks) {
1467
- const deps =
1468
- task.dependencies.length > 0
1469
- ? ` depends on: ${task.dependencies.join(", ")}`
1470
- : "";
1471
- const repo =
1472
- task.resolvedRepoId
1473
- ? ` → repo: ${task.resolvedRepoId}`
1474
- : "";
1475
- lines.push(
1476
- ` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`,
1477
- );
1478
- }
1479
- }
1480
- lines.push("");
1481
- }
1482
-
1483
- // Show errors
1484
- if (result.errors.length > 0) {
1485
- const fatalCodes = new Set<string>(FATAL_DISCOVERY_CODES);
1486
- const fatalErrors = result.errors.filter((e) => fatalCodes.has(e.code));
1487
- const warnings = result.errors.filter((e) => !fatalCodes.has(e.code));
1488
-
1489
- if (fatalErrors.length > 0) {
1490
- lines.push("❌ Errors:");
1491
- for (const err of fatalErrors) {
1492
- lines.push(` [${err.code}] ${err.message}`);
1493
- }
1494
- lines.push("");
1495
- }
1496
-
1497
- if (warnings.length > 0) {
1498
- lines.push("⚠️ Warnings:");
1499
- for (const err of warnings) {
1500
- lines.push(` [${err.code}] ${err.message}`);
1501
- }
1502
- lines.push("");
1503
- }
1504
- }
1505
-
1506
- return lines.join("\n");
1507
- }
1508
-
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 { FATAL_DISCOVERY_CODES } from "./types.ts";
9
+ import type { DiscoveryError, DiscoveryResult, ParsedTask, PromptSegmentDagMetadata, SegmentCheckboxGroup, StepSegmentMapping, TaskArea, WorkspaceConfig } from "./types.ts";
10
+
11
+ // ── PROMPT.md Parsing ────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Extract the task ID from a folder name.
15
+ * Convention: "TO-014-accrual-engine" → "TO-014"
16
+ * Matches prefix-number patterns like "COMP-006", "TS-004", "TO-014".
17
+ */
18
+ export function extractTaskIdFromFolderName(folderName: string): string | null {
19
+ const match = folderName.match(/^([A-Z]+-\d+)/);
20
+ return match ? match[1] : null;
21
+ }
22
+
23
+ export interface DependencyRef {
24
+ raw: string;
25
+ taskId: string;
26
+ areaName?: string;
27
+ }
28
+
29
+ export function parseDependencyReference(raw: string): DependencyRef {
30
+ const trimmed = raw.trim();
31
+ const qualified = trimmed.match(/^([a-z0-9-]+)\/([A-Z]+-\d+)$/i);
32
+ if (qualified) {
33
+ return {
34
+ raw: trimmed,
35
+ areaName: qualified[1].toLowerCase(),
36
+ taskId: qualified[2].toUpperCase(),
37
+ };
38
+ }
39
+
40
+ const idOnly = trimmed.match(/^([A-Z]+-\d+)$/i);
41
+ if (idOnly) {
42
+ return {
43
+ raw: trimmed,
44
+ taskId: idOnly[1].toUpperCase(),
45
+ };
46
+ }
47
+
48
+ return {
49
+ raw: trimmed,
50
+ taskId: trimmed.toUpperCase(),
51
+ };
52
+ }
53
+
54
+ export function normalizeDependencyReference(raw: string): string {
55
+ const parsed = parseDependencyReference(raw);
56
+ return parsed.areaName ? `${parsed.areaName}/${parsed.taskId}` : parsed.taskId;
57
+ }
58
+
59
+ const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
60
+
61
+ function normalizeSegmentRepoToken(raw: string): string {
62
+ let token = raw.trim();
63
+ token = token.replace(/^`(.+)`$/, "$1").trim();
64
+ token = token.replace(/^\*\*(.+)\*\*$/, "$1").trim();
65
+ return token.toLowerCase();
66
+ }
67
+
68
+ interface ParsedSegmentDagBody {
69
+ metadata: PromptSegmentDagMetadata | null;
70
+ error: DiscoveryError | null;
71
+ }
72
+
73
+ /**
74
+ * Parse optional explicit segment DAG metadata from `## Segment DAG`.
75
+ *
76
+ * Supported v1 syntax:
77
+ *
78
+ * ## Segment DAG
79
+ * Repos:
80
+ * - api
81
+ * - web-client
82
+ * Edges:
83
+ * - api -> web-client
84
+ *
85
+ * Notes:
86
+ * - `Repos:` / `Edges:` keys accept markdown decoration (`**Repos:**`) and whitespace.
87
+ * - Repo IDs are normalized to lowercase and validated against routing repo ID rules.
88
+ * - Unknown edge endpoints (not present in explicit repo list) fail fast.
89
+ * - Self-edges and cycles fail fast with `SEGMENT_DAG_INVALID`.
90
+ */
91
+ function parseSegmentDagMetadata(
92
+ content: string,
93
+ taskId: string,
94
+ promptPath: string,
95
+ ): ParsedSegmentDagBody {
96
+ const headerMatch = content.match(/^##\s+Segment DAG\s*$/im);
97
+ if (!headerMatch || headerMatch.index === undefined) {
98
+ return { metadata: null, error: null };
99
+ }
100
+
101
+ const headerIndex = headerMatch.index;
102
+ const afterHeaderIndex = content.indexOf("\n", headerIndex);
103
+ if (afterHeaderIndex === -1) {
104
+ return { metadata: null, error: null };
105
+ }
106
+
107
+ const rest = content.slice(afterHeaderIndex + 1);
108
+ const nextBoundary = rest.search(/^##\s|^---/m);
109
+ const body = nextBoundary !== -1 ? rest.slice(0, nextBoundary) : rest;
110
+
111
+ const repoIds: string[] = [];
112
+ const repoSet = new Set<string>();
113
+ const edgePairs = new Set<string>();
114
+ const edges: Array<{ fromRepoId: string; toRepoId: string }> = [];
115
+ const baseLine = content.slice(0, afterHeaderIndex + 1).split(/\r?\n/).length;
116
+
117
+ let mode: "repos" | "edges" | null = null;
118
+ const lines = body.split(/\r?\n/);
119
+
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const rawLine = lines[i];
122
+ const trimmed = rawLine.trim();
123
+ if (!trimmed) continue;
124
+
125
+ if (/^\*?\*?Repos:?\*?\*?\s*$/i.test(trimmed)) {
126
+ mode = "repos";
127
+ continue;
128
+ }
129
+ if (/^\*?\*?Edges:?\*?\*?\s*$/i.test(trimmed)) {
130
+ mode = "edges";
131
+ continue;
132
+ }
133
+
134
+ if (!mode) {
135
+ return {
136
+ metadata: null,
137
+ error: {
138
+ code: "SEGMENT_DAG_INVALID",
139
+ message:
140
+ `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
141
+ `expected a Repos: or Edges: subsection header before entries.`,
142
+ taskId,
143
+ taskPath: promptPath,
144
+ },
145
+ };
146
+ }
147
+
148
+ const bulletMatch = rawLine.match(/^\s*[-*]\s+(.+)$/);
149
+ if (!bulletMatch) {
150
+ return {
151
+ metadata: null,
152
+ error: {
153
+ code: "SEGMENT_DAG_INVALID",
154
+ message:
155
+ `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
156
+ `expected a bullet entry ("- ...").`,
157
+ taskId,
158
+ taskPath: promptPath,
159
+ },
160
+ };
161
+ }
162
+
163
+ const entry = bulletMatch[1].trim();
164
+ if (!entry) continue;
165
+
166
+ if (mode === "repos") {
167
+ if (entry.includes("->")) {
168
+ return {
169
+ metadata: null,
170
+ error: {
171
+ code: "SEGMENT_DAG_INVALID",
172
+ message:
173
+ `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
174
+ `repo list entries must be a single repo ID.`,
175
+ taskId,
176
+ taskPath: promptPath,
177
+ },
178
+ };
179
+ }
180
+ const repoId = normalizeSegmentRepoToken(entry);
181
+ if (!SEGMENT_REPO_ID_PATTERN.test(repoId)) {
182
+ return {
183
+ metadata: null,
184
+ error: {
185
+ code: "SEGMENT_DAG_INVALID",
186
+ message:
187
+ `Task ${taskId} has invalid repo ID "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
188
+ `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
189
+ taskId,
190
+ taskPath: promptPath,
191
+ },
192
+ };
193
+ }
194
+ if (!repoSet.has(repoId)) {
195
+ repoSet.add(repoId);
196
+ repoIds.push(repoId);
197
+ }
198
+ continue;
199
+ }
200
+
201
+ const edgeMatch = entry.match(/^(.+?)\s*->\s*(.+)$/);
202
+ if (!edgeMatch) {
203
+ return {
204
+ metadata: null,
205
+ error: {
206
+ code: "SEGMENT_DAG_INVALID",
207
+ message:
208
+ `Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
209
+ `Expected format: <repo-a> -> <repo-b>.`,
210
+ taskId,
211
+ taskPath: promptPath,
212
+ },
213
+ };
214
+ }
215
+
216
+ const fromRepoId = normalizeSegmentRepoToken(edgeMatch[1]);
217
+ const toRepoId = normalizeSegmentRepoToken(edgeMatch[2]);
218
+ if (!SEGMENT_REPO_ID_PATTERN.test(fromRepoId) || !SEGMENT_REPO_ID_PATTERN.test(toRepoId)) {
219
+ return {
220
+ metadata: null,
221
+ error: {
222
+ code: "SEGMENT_DAG_INVALID",
223
+ message:
224
+ `Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
225
+ `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
226
+ taskId,
227
+ taskPath: promptPath,
228
+ },
229
+ };
230
+ }
231
+ if (fromRepoId === toRepoId) {
232
+ return {
233
+ metadata: null,
234
+ error: {
235
+ code: "SEGMENT_DAG_INVALID",
236
+ message:
237
+ `Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`,
238
+ taskId,
239
+ taskPath: promptPath,
240
+ },
241
+ };
242
+ }
243
+
244
+ const edgeKey = `${fromRepoId}->${toRepoId}`;
245
+ if (!edgePairs.has(edgeKey)) {
246
+ edgePairs.add(edgeKey);
247
+ edges.push({ fromRepoId, toRepoId });
248
+ }
249
+ }
250
+
251
+ if (repoIds.length === 0 && edges.length === 0) {
252
+ return { metadata: null, error: null };
253
+ }
254
+
255
+ for (const edge of edges) {
256
+ if (!repoSet.has(edge.fromRepoId)) {
257
+ return {
258
+ metadata: null,
259
+ error: {
260
+ code: "SEGMENT_REPO_UNKNOWN",
261
+ message:
262
+ `Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`,
263
+ taskId,
264
+ taskPath: promptPath,
265
+ },
266
+ };
267
+ }
268
+ if (!repoSet.has(edge.toRepoId)) {
269
+ return {
270
+ metadata: null,
271
+ error: {
272
+ code: "SEGMENT_REPO_UNKNOWN",
273
+ message:
274
+ `Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`,
275
+ taskId,
276
+ taskPath: promptPath,
277
+ },
278
+ };
279
+ }
280
+ }
281
+
282
+ const sortedEdges = [...edges].sort((a, b) => {
283
+ if (a.fromRepoId !== b.fromRepoId) return a.fromRepoId.localeCompare(b.fromRepoId);
284
+ return a.toRepoId.localeCompare(b.toRepoId);
285
+ });
286
+
287
+ const adjacency = new Map<string, string[]>();
288
+ for (const repoId of repoIds) {
289
+ adjacency.set(repoId, []);
290
+ }
291
+ for (const edge of sortedEdges) {
292
+ adjacency.get(edge.fromRepoId)!.push(edge.toRepoId);
293
+ }
294
+ for (const neighbors of adjacency.values()) {
295
+ neighbors.sort();
296
+ }
297
+
298
+ const visited = new Set<string>();
299
+ const stack = new Set<string>();
300
+ const path: string[] = [];
301
+ let cycle: string[] | null = null;
302
+
303
+ function dfs(repoId: string): void {
304
+ if (cycle) return;
305
+ visited.add(repoId);
306
+ stack.add(repoId);
307
+ path.push(repoId);
308
+
309
+ const neighbors = adjacency.get(repoId) || [];
310
+ for (const next of neighbors) {
311
+ if (cycle) return;
312
+ if (!visited.has(next)) {
313
+ dfs(next);
314
+ continue;
315
+ }
316
+ if (stack.has(next)) {
317
+ const start = path.indexOf(next);
318
+ cycle = [...path.slice(start), next];
319
+ return;
320
+ }
321
+ }
322
+
323
+ path.pop();
324
+ stack.delete(repoId);
325
+ }
326
+
327
+ for (const repoId of [...repoIds].sort()) {
328
+ if (!visited.has(repoId)) dfs(repoId);
329
+ if (cycle) break;
330
+ }
331
+
332
+ if (cycle) {
333
+ return {
334
+ metadata: null,
335
+ error: {
336
+ code: "SEGMENT_DAG_INVALID",
337
+ message:
338
+ `Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`,
339
+ taskId,
340
+ taskPath: promptPath,
341
+ },
342
+ };
343
+ }
344
+
345
+ return {
346
+ metadata: {
347
+ repoIds,
348
+ edges: sortedEdges,
349
+ },
350
+ error: null,
351
+ };
352
+ }
353
+
354
+ // ── Step-Segment Mapping (Phase A) ───────────────────────────────────
355
+
356
+ /**
357
+ * Sentinel repo ID used when the task's primary repo is not yet known at parse time.
358
+ * Replaced by the resolved repo during routing (resolveTaskRouting).
359
+ */
360
+ export const SEGMENT_FALLBACK_REPO_PLACEHOLDER = "__primary__";
361
+
362
+ /**
363
+ * Simple suggestion helper: find known repo IDs that share a prefix or
364
+ * have small edit distance from the unknown repo ID.
365
+ */
366
+ function suggestRepoMatches(unknown: string, known: string[]): string[] {
367
+ const suggestions: string[] = [];
368
+ for (const k of known) {
369
+ // Prefix match (either direction)
370
+ if (k.startsWith(unknown) || unknown.startsWith(k)) {
371
+ suggestions.push(k);
372
+ continue;
373
+ }
374
+ // Simple overlap: share at least 3 chars of a common substring
375
+ const shorter = unknown.length < k.length ? unknown : k;
376
+ const longer = unknown.length < k.length ? k : unknown;
377
+ if (shorter.length >= 3 && longer.includes(shorter.slice(0, 3))) {
378
+ suggestions.push(k);
379
+ }
380
+ }
381
+ return suggestions;
382
+ }
383
+
384
+ interface StepSegmentParseResult {
385
+ mapping: StepSegmentMapping[];
386
+ /** True if at least one step had an explicit `#### Segment:` marker. */
387
+ hasExplicitMarkers: boolean;
388
+ warnings: DiscoveryError[];
389
+ errors: DiscoveryError[];
390
+ }
391
+
392
+ /**
393
+ * Parse `#### Segment: <repoId>` markers within `### Step N:` sections of a PROMPT.md.
394
+ *
395
+ * Builds a StepSegmentMapping[] that maps each step to its repo-scoped checkbox groups.
396
+ *
397
+ * Rules:
398
+ * - Checkboxes before any segment header (or in steps with no segment headers)
399
+ * belong to the task's primary repoId (fallbackRepoId / packet repo).
400
+ * - A repoId may appear at most once within a step (duplicate → error).
401
+ * - Empty segments (header but no checkboxes) produce a warning.
402
+ * - Unknown repoIds are flagged as warnings (validation deferred to routing).
403
+ */
404
+ export function parseStepSegmentMapping(
405
+ content: string,
406
+ taskId: string,
407
+ fallbackRepoId: string,
408
+ ): StepSegmentParseResult {
409
+ const mapping: StepSegmentMapping[] = [];
410
+ const warnings: DiscoveryError[] = [];
411
+ const errors: DiscoveryError[] = [];
412
+ let hasExplicitMarkers = false;
413
+
414
+ // Find ## Steps section
415
+ const stepsSectionMatch = content.match(/^##\s+Steps\s*$/im);
416
+ if (!stepsSectionMatch || stepsSectionMatch.index === undefined) {
417
+ return { mapping, hasExplicitMarkers, warnings, errors };
418
+ }
419
+
420
+ const stepsStart = stepsSectionMatch.index;
421
+ // Get body from ## Steps to next ## top-level section or --- divider
422
+ const afterStepsHeader = content.indexOf("\n", stepsStart);
423
+ if (afterStepsHeader === -1) {
424
+ return { mapping, hasExplicitMarkers, warnings, errors };
425
+ }
426
+ const rest = content.slice(afterStepsHeader + 1);
427
+ // Find the next top-level section (## but not ###) or --- divider
428
+ const nextSectionMatch = rest.search(/^##\s+[^#]|^---/m);
429
+ const stepsBody = nextSectionMatch !== -1 ? rest.slice(0, nextSectionMatch) : rest;
430
+
431
+ // Split into step sections by ### Step N: headers
432
+ const stepHeaderRegex = /^###\s+Step\s+(\d+):\s*(.+)$/gm;
433
+ const stepHeaders: { index: number; stepNumber: number; stepName: string }[] = [];
434
+ let match: RegExpExecArray | null;
435
+ while ((match = stepHeaderRegex.exec(stepsBody)) !== null) {
436
+ stepHeaders.push({
437
+ index: match.index,
438
+ stepNumber: parseInt(match[1], 10),
439
+ stepName: match[2].trim(),
440
+ });
441
+ }
442
+
443
+ if (stepHeaders.length === 0) {
444
+ return { mapping, hasExplicitMarkers, warnings, errors };
445
+ }
446
+
447
+ for (let i = 0; i < stepHeaders.length; i++) {
448
+ const header = stepHeaders[i];
449
+ const nextHeaderIndex = i + 1 < stepHeaders.length ? stepHeaders[i + 1].index : stepsBody.length;
450
+ const stepContent = stepsBody.slice(header.index, nextHeaderIndex);
451
+
452
+ // Parse segment groups within this step
453
+ const segmentHeaderRegex = /^####\s+Segment:\s*(.+)$/gm;
454
+ const segmentHeaders: { index: number; repoId: string; rawRepoId: string }[] = [];
455
+ let segMatch: RegExpExecArray | null;
456
+ while ((segMatch = segmentHeaderRegex.exec(stepContent)) !== null) {
457
+ const rawRepoId = segMatch[1].trim();
458
+ const repoId = normalizeSegmentRepoToken(rawRepoId);
459
+ segmentHeaders.push({
460
+ index: segMatch.index,
461
+ repoId,
462
+ rawRepoId,
463
+ });
464
+ }
465
+
466
+ const segments: SegmentCheckboxGroup[] = [];
467
+
468
+ if (segmentHeaders.length === 0) {
469
+ // No segment markers — all checkboxes belong to fallback repo
470
+ const checkboxes = extractCheckboxes(stepContent);
471
+ segments.push({ repoId: fallbackRepoId, checkboxes });
472
+ // Don't set hasExplicitMarkers — this is a fallback, not an explicit marker
473
+ } else {
474
+ hasExplicitMarkers = true;
475
+ // Check for checkboxes before the first segment header (pre-segment)
476
+ const preSegmentContent = stepContent.slice(0, segmentHeaders[0].index);
477
+ const preCheckboxes = extractCheckboxes(preSegmentContent);
478
+ if (preCheckboxes.length > 0) {
479
+ segments.push({ repoId: fallbackRepoId, checkboxes: preCheckboxes });
480
+ }
481
+
482
+ // Track seen repoIds for duplicate detection
483
+ // Include fallback repo if pre-segment checkboxes exist and it's a concrete ID
484
+ const seenRepoIds = new Set<string>();
485
+ if (preCheckboxes.length > 0 && fallbackRepoId !== SEGMENT_FALLBACK_REPO_PLACEHOLDER) {
486
+ seenRepoIds.add(fallbackRepoId);
487
+ }
488
+ let hasDuplicateError = false;
489
+
490
+ for (let j = 0; j < segmentHeaders.length; j++) {
491
+ const seg = segmentHeaders[j];
492
+
493
+ // Validate repo ID format (warning only — keep checkboxes for safety)
494
+ if (!SEGMENT_REPO_ID_PATTERN.test(seg.repoId)) {
495
+ warnings.push({
496
+ code: "SEGMENT_STEP_REPO_INVALID",
497
+ message:
498
+ `Task ${taskId} Step ${header.stepNumber} has invalid segment repo ID "${seg.rawRepoId}". ` +
499
+ `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
500
+ taskId,
501
+ });
502
+ // Still extract checkboxes — don't drop work
503
+ }
504
+
505
+ // Check for duplicates
506
+ if (seenRepoIds.has(seg.repoId)) {
507
+ errors.push({
508
+ code: "SEGMENT_STEP_DUPLICATE_REPO",
509
+ message:
510
+ `Task ${taskId} Step ${header.stepNumber} has duplicate segment repo ID "${seg.repoId}". ` +
511
+ `A repoId may appear at most once within a step.`,
512
+ taskId,
513
+ });
514
+ hasDuplicateError = true;
515
+ continue;
516
+ }
517
+ seenRepoIds.add(seg.repoId);
518
+
519
+ const nextSegIndex = j + 1 < segmentHeaders.length ? segmentHeaders[j + 1].index : stepContent.length;
520
+ const segContent = stepContent.slice(seg.index, nextSegIndex);
521
+ const checkboxes = extractCheckboxes(segContent);
522
+
523
+ if (checkboxes.length === 0) {
524
+ warnings.push({
525
+ code: "SEGMENT_STEP_EMPTY",
526
+ message:
527
+ `Task ${taskId} Step ${header.stepNumber} has empty segment "${seg.repoId}" with no checkboxes.`,
528
+ taskId,
529
+ });
530
+ }
531
+
532
+ segments.push({ repoId: seg.repoId, checkboxes });
533
+ }
534
+
535
+ if (hasDuplicateError) {
536
+ // Still add what we collected, but errors are flagged
537
+ }
538
+ }
539
+
540
+ mapping.push({
541
+ stepNumber: header.stepNumber,
542
+ stepName: header.stepName,
543
+ segments,
544
+ });
545
+ }
546
+
547
+ return { mapping, hasExplicitMarkers, warnings, errors };
548
+ }
549
+
550
+ /**
551
+ * Extract checkbox text lines from a content block.
552
+ * Matches `- [ ] text` and `- [x] text` patterns.
553
+ */
554
+ function extractCheckboxes(content: string): string[] {
555
+ const checkboxes: string[] = [];
556
+ const lines = content.split(/\r?\n/);
557
+ for (const line of lines) {
558
+ const match = line.match(/^\s*-\s+\[[ x]\]\s+(.+)$/);
559
+ if (match) {
560
+ checkboxes.push(match[1].trim());
561
+ }
562
+ }
563
+ return checkboxes;
564
+ }
565
+
566
+ /**
567
+ * Parse a PROMPT.md file and extract orchestrator-relevant metadata.
568
+ *
569
+ * Required fields (hard fail if missing):
570
+ * - Task ID: extracted from `# Task: XX-NNN - Name` heading OR from folder name
571
+ *
572
+ * Optional fields (defaults used if absent):
573
+ * - Dependencies: defaults to [] (no dependencies)
574
+ * - Review Level: defaults to 2
575
+ * - Size: defaults to "M"
576
+ * - File Scope: defaults to []
577
+ * - Task Name: defaults to folder name
578
+ *
579
+ * Dependency syntax accepted:
580
+ * - "**None**" or "None" → empty list
581
+ * - "**Requires:** COMP-005 ..." → ["COMP-005"]
582
+ * - "**Requires:** time-off/TO-014 ..." → ["time-off/TO-014"]
583
+ * - "- COMP-005 (description)" → ["COMP-005"]
584
+ * - "- **time-off/TO-014** — description" → ["time-off/TO-014"]
585
+ * - Multiple bullet points → multiple dependencies
586
+ */
587
+ export function parsePromptForOrchestrator(
588
+ promptPath: string,
589
+ taskFolder: string,
590
+ areaName: string,
591
+ ): { task: ParsedTask | null; error: DiscoveryError | null; warnings?: DiscoveryError[] } {
592
+ const folderName = basename(taskFolder);
593
+ let content: string;
594
+
595
+ try {
596
+ content = readFileSync(promptPath, "utf-8");
597
+ } catch {
598
+ return {
599
+ task: null,
600
+ error: {
601
+ code: "PARSE_MALFORMED",
602
+ message: `Cannot read PROMPT.md: ${promptPath}`,
603
+ taskPath: promptPath,
604
+ },
605
+ };
606
+ }
607
+
608
+ // ── Extract task ID ──────────────────────────────────────────
609
+ // Try from heading first: "# Task: COMP-006 - Pay Bands Implementation"
610
+ let taskId: string | null = null;
611
+ let taskName = folderName;
612
+
613
+ const headingMatch = content.match(/^#\s+Task:\s+([A-Z]+-\d+)\s*[-—]\s*(.+)$/m);
614
+ if (headingMatch) {
615
+ taskId = headingMatch[1];
616
+ taskName = headingMatch[2].trim();
617
+ }
618
+
619
+ // Fallback: extract from folder name
620
+ if (!taskId) {
621
+ taskId = extractTaskIdFromFolderName(folderName);
622
+ }
623
+
624
+ if (!taskId) {
625
+ return {
626
+ task: null,
627
+ error: {
628
+ code: "PARSE_MISSING_ID",
629
+ message: `Cannot extract task ID from heading or folder name "${folderName}" in ${promptPath}`,
630
+ taskPath: promptPath,
631
+ },
632
+ };
633
+ }
634
+
635
+ // ── Extract review level ─────────────────────────────────────
636
+ // "## Review Level: 1 (Plan Only)" or "## Review Level: 2"
637
+ let reviewLevel = 2;
638
+ const reviewMatch = content.match(/^##\s+Review Level:\s*(\d+)/m);
639
+ if (reviewMatch) {
640
+ reviewLevel = parseInt(reviewMatch[1], 10);
641
+ }
642
+
643
+ // ── Extract size ─────────────────────────────────────────────
644
+ // "**Size:** M" (usually near top, after Created date)
645
+ let size = "M";
646
+ const sizeMatch = content.match(/\*\*Size:\*\*\s*([SMLsml])/);
647
+ if (sizeMatch) {
648
+ size = sizeMatch[1].toUpperCase();
649
+ }
650
+
651
+ // ── Extract dependencies ─────────────────────────────────────
652
+ const dependencies: string[] = [];
653
+ const depSectionMatch = content.match(
654
+ /^##\s+Dependencies\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m,
655
+ );
656
+
657
+ if (depSectionMatch) {
658
+ const depBody = depSectionMatch[1].trim();
659
+
660
+ // Check for "None" variants
661
+ if (!/\*?\*?None\*?\*?/i.test(depBody) && depBody.length > 0) {
662
+ // Pattern 1: "**Requires:** COMP-005 ..." or "**Task:** TO-014 ..."
663
+ const labeledMatches = depBody.matchAll(
664
+ /\*?\*?(?:Requires|Task):?\*?\*?\s*((?:[a-z0-9-]+\/)?[A-Z]+-\d+)/gi,
665
+ );
666
+ for (const m of labeledMatches) {
667
+ const dep = normalizeDependencyReference(m[1]);
668
+ if (!dependencies.includes(dep)) dependencies.push(dep);
669
+ }
670
+
671
+ // Pattern 2: Bullet list "- COMP-005 ...", "- **time-off/TO-014** ..."
672
+ const bulletMatches = depBody.matchAll(
673
+ /^[\s-]*\*?\*?((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\*?\*?/gim,
674
+ );
675
+ for (const m of bulletMatches) {
676
+ const dep = normalizeDependencyReference(m[1]);
677
+ if (!dependencies.includes(dep)) dependencies.push(dep);
678
+ }
679
+
680
+ // Pattern 3: Inline dependency references not caught above
681
+ if (dependencies.length === 0) {
682
+ const inlineMatches = depBody.matchAll(/\b((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\b/gi);
683
+ for (const m of inlineMatches) {
684
+ const dep = parseDependencyReference(m[1]);
685
+ if (dep.taskId === taskId) continue; // Don't add self-references
686
+ const normalized = normalizeDependencyReference(m[1]);
687
+ if (!dependencies.includes(normalized)) {
688
+ dependencies.push(normalized);
689
+ }
690
+ }
691
+ }
692
+ }
693
+ }
694
+
695
+ // ── Extract execution target (repo ID) ──────────────────────
696
+ // Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum
697
+ const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
698
+
699
+ let promptRepoId: string | undefined;
700
+
701
+ // Priority 1: Section-based "## Execution Target" with "Repo: <id>" line
702
+ // Capture everything from section header to the next heading or --- divider.
703
+ // We avoid \n$ (which in multiline mode matches blank lines) by using a two-pass
704
+ // approach: find the section start, then slice to the next section boundary.
705
+ const execTargetHeaderIdx = content.search(/^##\s+Execution Target\s*$/m);
706
+ let execTargetSectionBody: string | null = null;
707
+ if (execTargetHeaderIdx !== -1) {
708
+ const afterHeader = content.indexOf("\n", execTargetHeaderIdx);
709
+ if (afterHeader !== -1) {
710
+ const rest = content.slice(afterHeader + 1);
711
+ const nextSectionMatch = rest.search(/^##\s|^---/m);
712
+ execTargetSectionBody = nextSectionMatch !== -1
713
+ ? rest.slice(0, nextSectionMatch)
714
+ : rest;
715
+ }
716
+ }
717
+ if (execTargetSectionBody !== null) {
718
+ // Match "Repo: api" or "**Repo:** api" or "Workspace: api" with whitespace
719
+ const repoLineMatch = execTargetSectionBody.match(
720
+ /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/mi,
721
+ );
722
+ if (repoLineMatch) {
723
+ const candidate = repoLineMatch[1].trim().toLowerCase();
724
+ if (REPO_ID_PATTERN.test(candidate)) {
725
+ promptRepoId = candidate;
726
+ }
727
+ }
728
+ }
729
+
730
+ // Priority 2 (fallback): Inline "**Repo:** <id>" or "**Workspace:** <id>" anywhere in content
731
+ if (!promptRepoId) {
732
+ const inlineRepoMatch = content.match(
733
+ /^\*\*(?:Repo|Workspace):\*\*\s+(\S+)/m,
734
+ );
735
+ if (inlineRepoMatch) {
736
+ const candidate = inlineRepoMatch[1].trim().toLowerCase();
737
+ if (REPO_ID_PATTERN.test(candidate)) {
738
+ promptRepoId = candidate;
739
+ }
740
+ }
741
+ }
742
+
743
+ // ── Extract file scope ───────────────────────────────────────
744
+ const fileScope: string[] = [];
745
+ const fileScopeMatch = content.match(
746
+ /^##\s+File Scope\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m,
747
+ );
748
+
749
+ if (fileScopeMatch) {
750
+ const scopeBody = fileScopeMatch[1].trim();
751
+ const scopeLines = scopeBody.split("\n");
752
+ for (const line of scopeLines) {
753
+ // "- extensions/task-orchestrator.ts" or "- `api-service/src/health.js`"
754
+ let trimmed = line.replace(/^[\s-*]+/, "").trim();
755
+ // Strip inline backticks: `path/to/file` → path/to/file
756
+ if (trimmed.startsWith("`") && trimmed.endsWith("`")) {
757
+ trimmed = trimmed.slice(1, -1);
758
+ }
759
+ if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("```")) {
760
+ fileScope.push(trimmed);
761
+ }
762
+ }
763
+ }
764
+
765
+ // ── Extract optional explicit segment DAG metadata ──────────
766
+ const segmentDagResult = parseSegmentDagMetadata(content, taskId, resolve(promptPath));
767
+ if (segmentDagResult.error) {
768
+ return {
769
+ task: null,
770
+ error: segmentDagResult.error,
771
+ };
772
+ }
773
+ const explicitSegmentDag = segmentDagResult.metadata;
774
+
775
+ // ── Parse step-segment mapping (Phase A, TP-173) ────────
776
+ // Use promptRepoId as fallback; if not set, use a placeholder
777
+ // sentinel that resolveTaskRouting replaces with the actual resolved repo.
778
+ const segFallbackRepo = promptRepoId || SEGMENT_FALLBACK_REPO_PLACEHOLDER;
779
+ const stepSegResult = parseStepSegmentMapping(content, taskId, segFallbackRepo);
780
+
781
+ // Duplicate repoId in a step is a hard error — fail the task.
782
+ if (stepSegResult.errors.length > 0) {
783
+ return {
784
+ task: null,
785
+ error: stepSegResult.errors[0],
786
+ };
787
+ }
788
+
789
+ // Only populate stepSegmentMap when PROMPT.md has explicit #### Segment: markers.
790
+ // The parser produces fallback entries (repoId = primary repo) even without markers,
791
+ // but those should NOT trigger segment-scoped mode — they exist only for validation.
792
+ const stepSegmentMap = stepSegResult.hasExplicitMarkers ? stepSegResult.mapping : undefined;
793
+
794
+ return {
795
+ task: {
796
+ taskId,
797
+ taskName,
798
+ reviewLevel,
799
+ size,
800
+ dependencies,
801
+ fileScope,
802
+ taskFolder: resolve(taskFolder),
803
+ promptPath: resolve(promptPath),
804
+ areaName,
805
+ status: "pending",
806
+ ...(promptRepoId ? { promptRepoId } : {}),
807
+ ...(explicitSegmentDag ? { explicitSegmentDag } : {}),
808
+ ...(stepSegmentMap ? { stepSegmentMap } : {}),
809
+ },
810
+ error: null,
811
+ warnings: stepSegResult.warnings,
812
+ };
813
+ }
814
+
815
+
816
+ // ── Area Scanning ────────────────────────────────────────────────────
817
+
818
+ /**
819
+ * Scan an area path for pending tasks.
820
+ *
821
+ * Lists immediate subdirectories only (no recursion).
822
+ * Skips "archive" directories and folders with .DONE files.
823
+ * Parses PROMPT.md in each remaining subdirectory.
824
+ */
825
+ export function scanAreaForTasks(
826
+ areaPath: string,
827
+ areaName: string,
828
+ ): { tasks: ParsedTask[]; errors: DiscoveryError[] } {
829
+ const tasks: ParsedTask[] = [];
830
+ const errors: DiscoveryError[] = [];
831
+
832
+ const resolvedPath = resolve(areaPath);
833
+ if (!existsSync(resolvedPath)) {
834
+ errors.push({
835
+ code: "SCAN_ERROR",
836
+ message: `Area path does not exist: ${resolvedPath}`,
837
+ taskPath: resolvedPath,
838
+ });
839
+ return { tasks, errors };
840
+ }
841
+
842
+ let entries: string[];
843
+ try {
844
+ entries = readdirSync(resolvedPath);
845
+ } catch {
846
+ errors.push({
847
+ code: "SCAN_ERROR",
848
+ message: `Cannot read area directory: ${resolvedPath}`,
849
+ taskPath: resolvedPath,
850
+ });
851
+ return { tasks, errors };
852
+ }
853
+
854
+ for (const entry of entries) {
855
+ // Skip archive directory
856
+ if (entry.toLowerCase() === "archive") continue;
857
+
858
+ const entryPath = join(resolvedPath, entry);
859
+
860
+ // Only process directories
861
+ try {
862
+ if (!statSync(entryPath).isDirectory()) continue;
863
+ } catch {
864
+ continue;
865
+ }
866
+
867
+ // Skip if .DONE exists (already complete)
868
+ if (existsSync(join(entryPath, ".DONE"))) continue;
869
+
870
+ // Skip if no PROMPT.md
871
+ const promptPath = join(entryPath, "PROMPT.md");
872
+ if (!existsSync(promptPath)) continue;
873
+
874
+ // Parse PROMPT.md
875
+ const result = parsePromptForOrchestrator(promptPath, entryPath, areaName);
876
+ if (result.error) {
877
+ errors.push(result.error);
878
+ }
879
+ if (result.warnings) {
880
+ errors.push(...result.warnings);
881
+ }
882
+ if (result.task) {
883
+ tasks.push(result.task);
884
+ }
885
+ }
886
+
887
+ return { tasks, errors };
888
+ }
889
+
890
+
891
+ // ── Completed Task Set ───────────────────────────────────────────────
892
+
893
+ /**
894
+ * Build a set of completed task IDs by scanning:
895
+ * 1. archive/ subdirectories for .DONE markers
896
+ * 2. Active task folders that have .DONE files (caught during scanAreaForTasks skip)
897
+ *
898
+ * This set is used only for dependency resolution — completed tasks are never re-executed.
899
+ */
900
+ export function buildCompletedTaskSet(areaPaths: string[]): Set<string> {
901
+ const completed = new Set<string>();
902
+
903
+ for (const areaPath of areaPaths) {
904
+ const resolvedPath = resolve(areaPath);
905
+ if (!existsSync(resolvedPath)) continue;
906
+
907
+ let entries: string[];
908
+ try {
909
+ entries = readdirSync(resolvedPath);
910
+ } catch {
911
+ continue;
912
+ }
913
+
914
+ for (const entry of entries) {
915
+ const entryPath = join(resolvedPath, entry);
916
+
917
+ try {
918
+ if (!statSync(entryPath).isDirectory()) continue;
919
+ } catch {
920
+ continue;
921
+ }
922
+
923
+ if (entry.toLowerCase() === "archive") {
924
+ // Scan archive subdirectories for completed tasks
925
+ let archiveEntries: string[];
926
+ try {
927
+ archiveEntries = readdirSync(entryPath);
928
+ } catch {
929
+ continue;
930
+ }
931
+ for (const archiveEntry of archiveEntries) {
932
+ const archiveFolderPath = join(entryPath, archiveEntry);
933
+ try {
934
+ if (!statSync(archiveFolderPath).isDirectory()) continue;
935
+ } catch {
936
+ continue;
937
+ }
938
+ // Only treat archive tasks as complete when .DONE marker exists
939
+ if (!existsSync(join(archiveFolderPath, ".DONE"))) continue;
940
+ const taskId = extractTaskIdFromFolderName(archiveEntry);
941
+ if (taskId) {
942
+ completed.add(taskId);
943
+ }
944
+ }
945
+ } else {
946
+ // Active folder with .DONE = completed
947
+ if (existsSync(join(entryPath, ".DONE"))) {
948
+ const taskId = extractTaskIdFromFolderName(entry);
949
+ if (taskId) {
950
+ completed.add(taskId);
951
+ }
952
+ }
953
+ }
954
+ }
955
+ }
956
+
957
+ return completed;
958
+ }
959
+
960
+
961
+ // ── Argument Resolution ──────────────────────────────────────────────
962
+
963
+ /**
964
+ * Resolve command arguments into area scan paths and direct task folders.
965
+ *
966
+ * Accepts mixed arguments:
967
+ * - "all" → all areas from task_areas
968
+ * - area name → looked up in task_areas
969
+ * - directory path → used as-is
970
+ * - PROMPT.md path → single task (dirname used as task folder)
971
+ */
972
+ export function resolveArguments(
973
+ args: string,
974
+ taskAreas: Record<string, TaskArea>,
975
+ cwd: string,
976
+ ): { areaScanPaths: string[]; directTaskFolders: string[]; errors: DiscoveryError[] } {
977
+ const areaScanPaths: string[] = [];
978
+ const directTaskFolders: string[] = [];
979
+ const errors: DiscoveryError[] = [];
980
+
981
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
982
+
983
+ for (const token of tokens) {
984
+ if (token.toLowerCase() === "all") {
985
+ // Expand to all areas
986
+ for (const area of Object.values(taskAreas)) {
987
+ const fullPath = resolve(cwd, area.path);
988
+ if (!areaScanPaths.includes(fullPath)) {
989
+ areaScanPaths.push(fullPath);
990
+ }
991
+ }
992
+ } else if (taskAreas[token]) {
993
+ // Known area name
994
+ const fullPath = resolve(cwd, taskAreas[token].path);
995
+ if (!areaScanPaths.includes(fullPath)) {
996
+ areaScanPaths.push(fullPath);
997
+ }
998
+ } else if (
999
+ token.endsWith("PROMPT.md") &&
1000
+ existsSync(resolve(cwd, token))
1001
+ ) {
1002
+ // Single PROMPT.md file
1003
+ directTaskFolders.push(resolve(cwd, dirname(token)));
1004
+ } else if (existsSync(resolve(cwd, token))) {
1005
+ // Directory path
1006
+ const fullPath = resolve(cwd, token);
1007
+ try {
1008
+ if (statSync(fullPath).isDirectory()) {
1009
+ if (!areaScanPaths.includes(fullPath)) {
1010
+ areaScanPaths.push(fullPath);
1011
+ }
1012
+ } else {
1013
+ errors.push({
1014
+ code: "UNKNOWN_ARG",
1015
+ message: `Not a directory or PROMPT.md file: ${token}`,
1016
+ });
1017
+ }
1018
+ } catch {
1019
+ errors.push({
1020
+ code: "UNKNOWN_ARG",
1021
+ message: `Cannot stat path: ${token}`,
1022
+ });
1023
+ }
1024
+ } else {
1025
+ errors.push({
1026
+ code: "UNKNOWN_ARG",
1027
+ message: `Unknown area, path, or file: "${token}"`,
1028
+ });
1029
+ }
1030
+ }
1031
+
1032
+ return { areaScanPaths, directTaskFolders, errors };
1033
+ }
1034
+
1035
+ export interface DiscoveryOptions {
1036
+ refreshDependencies?: boolean;
1037
+ dependencySource?: "prompt" | "agent";
1038
+ useDependencyCache?: boolean;
1039
+ /** Workspace config for repo routing (null/undefined = repo mode, no routing). */
1040
+ workspaceConfig?: WorkspaceConfig | null;
1041
+ }
1042
+
1043
+ export interface DependencyCacheFile {
1044
+ version: number;
1045
+ generatedAt: string;
1046
+ source: string;
1047
+ tasks: Record<string, string[]>;
1048
+ }
1049
+
1050
+ export function normalizePathForCompare(p: string): string {
1051
+ return resolve(p).replace(/\\/g, "/").toLowerCase();
1052
+ }
1053
+
1054
+ export function isPathWithin(childPath: string, parentPath: string): boolean {
1055
+ const child = normalizePathForCompare(childPath);
1056
+ const parent = normalizePathForCompare(parentPath);
1057
+ return child === parent || child.startsWith(`${parent}/`);
1058
+ }
1059
+
1060
+ export function dedupeAndNormalizeDeps(deps: string[]): string[] {
1061
+ const seen = new Set<string>();
1062
+ const out: string[] = [];
1063
+ for (const dep of deps) {
1064
+ const norm = normalizeDependencyReference(dep);
1065
+ if (!norm || seen.has(norm)) continue;
1066
+ seen.add(norm);
1067
+ out.push(norm);
1068
+ }
1069
+ return out;
1070
+ }
1071
+
1072
+ export function loadAreaDependencyCache(areaPath: string): DependencyCacheFile | null {
1073
+ const cachePath = join(areaPath, "dependencies.json");
1074
+ if (!existsSync(cachePath)) return null;
1075
+ try {
1076
+ const raw = readFileSync(cachePath, "utf-8");
1077
+ const parsed = JSON.parse(raw) as DependencyCacheFile;
1078
+ if (!parsed || typeof parsed !== "object" || !parsed.tasks) return null;
1079
+ return parsed;
1080
+ } catch {
1081
+ return null;
1082
+ }
1083
+ }
1084
+
1085
+ export function writeAreaDependencyCache(
1086
+ areaPath: string,
1087
+ pending: Map<string, ParsedTask>,
1088
+ source: "prompt" | "agent",
1089
+ ): void {
1090
+ const tasks: Record<string, string[]> = {};
1091
+ for (const task of pending.values()) {
1092
+ if (!isPathWithin(task.taskFolder, areaPath)) continue;
1093
+ tasks[task.taskId] = dedupeAndNormalizeDeps(task.dependencies);
1094
+ }
1095
+
1096
+ const cachePath = join(areaPath, "dependencies.json");
1097
+ const payload: DependencyCacheFile = {
1098
+ version: 1,
1099
+ generatedAt: new Date().toISOString(),
1100
+ source,
1101
+ tasks,
1102
+ };
1103
+
1104
+ try {
1105
+ // Keep deterministic formatting for easy diffs
1106
+ const json = JSON.stringify(payload, null, 2);
1107
+ writeFileSync(cachePath, `${json}\n`, "utf-8");
1108
+ } catch {
1109
+ // Non-fatal: discovery should still succeed without cache persistence
1110
+ }
1111
+ }
1112
+
1113
+ export function applyDependenciesFromCache(
1114
+ discovery: DiscoveryResult,
1115
+ areaScanPaths: string[],
1116
+ ): { applied: boolean } {
1117
+ let applied = false;
1118
+ for (const areaPath of areaScanPaths) {
1119
+ const cache = loadAreaDependencyCache(areaPath);
1120
+ if (!cache) continue;
1121
+ for (const task of discovery.pending.values()) {
1122
+ if (!isPathWithin(task.taskFolder, areaPath)) continue;
1123
+ const cachedDeps = cache.tasks[task.taskId];
1124
+ if (!cachedDeps) continue;
1125
+ task.dependencies = dedupeAndNormalizeDeps(cachedDeps);
1126
+ applied = true;
1127
+ }
1128
+ }
1129
+ return { applied };
1130
+ }
1131
+
1132
+
1133
+ // ── Task Registry ────────────────────────────────────────────────────
1134
+
1135
+ /**
1136
+ * Build the full task registry: pending tasks + completed set.
1137
+ *
1138
+ * Enforces global uniqueness of task IDs across all areas.
1139
+ * If duplicates are found, returns a fail-fast error listing all collision locations.
1140
+ */
1141
+ export function buildTaskRegistry(
1142
+ areaScanPaths: string[],
1143
+ directTaskFolders: string[],
1144
+ taskAreas: Record<string, TaskArea>,
1145
+ cwd: string,
1146
+ ): DiscoveryResult {
1147
+ const pending = new Map<string, ParsedTask>();
1148
+ const errors: DiscoveryError[] = [];
1149
+
1150
+ // Track all locations per task ID for duplicate detection
1151
+ const idLocations = new Map<string, string[]>();
1152
+
1153
+ function trackId(taskId: string, location: string) {
1154
+ const existing = idLocations.get(taskId) || [];
1155
+ existing.push(location);
1156
+ idLocations.set(taskId, existing);
1157
+ }
1158
+
1159
+ // Resolve area names for scan paths
1160
+ const areaNameByPath = new Map<string, string>();
1161
+ for (const [name, area] of Object.entries(taskAreas)) {
1162
+ areaNameByPath.set(resolve(cwd, area.path), name);
1163
+ }
1164
+
1165
+ // 1. Scan area paths for pending tasks
1166
+ for (const areaPath of areaScanPaths) {
1167
+ const areaName = areaNameByPath.get(areaPath) || basename(areaPath);
1168
+ const result = scanAreaForTasks(areaPath, areaName);
1169
+ errors.push(...result.errors);
1170
+
1171
+ for (const task of result.tasks) {
1172
+ trackId(task.taskId, task.promptPath);
1173
+ pending.set(task.taskId, task);
1174
+ }
1175
+ }
1176
+
1177
+ // 2. Process direct task folders (single PROMPT.md files)
1178
+ for (const taskFolder of directTaskFolders) {
1179
+ const promptPath = join(taskFolder, "PROMPT.md");
1180
+ if (!existsSync(promptPath)) {
1181
+ errors.push({
1182
+ code: "SCAN_ERROR",
1183
+ message: `No PROMPT.md found in direct task folder: ${taskFolder}`,
1184
+ taskPath: taskFolder,
1185
+ });
1186
+ continue;
1187
+ }
1188
+
1189
+ // Try to determine area name from path
1190
+ let areaName = "unknown";
1191
+ for (const [name, area] of Object.entries(taskAreas)) {
1192
+ const resolvedAreaPath = resolve(cwd, area.path);
1193
+ if (taskFolder.startsWith(resolvedAreaPath)) {
1194
+ areaName = name;
1195
+ break;
1196
+ }
1197
+ }
1198
+
1199
+ // Skip if .DONE exists
1200
+ if (existsSync(join(taskFolder, ".DONE"))) continue;
1201
+
1202
+ const result = parsePromptForOrchestrator(promptPath, taskFolder, areaName);
1203
+ if (result.error) {
1204
+ errors.push(result.error);
1205
+ }
1206
+ if (result.warnings) {
1207
+ errors.push(...result.warnings);
1208
+ }
1209
+ if (result.task) {
1210
+ trackId(result.task.taskId, result.task.promptPath);
1211
+ pending.set(result.task.taskId, result.task);
1212
+ }
1213
+ }
1214
+
1215
+ // 3. Build completed task set from all scanned areas
1216
+ const completed = buildCompletedTaskSet(areaScanPaths);
1217
+
1218
+ // Also scan all task_areas for completed tasks (needed for cross-area dep resolution)
1219
+ const allAreaPaths = Object.values(taskAreas).map((a) => resolve(cwd, a.path));
1220
+ const globalCompleted = buildCompletedTaskSet(allAreaPaths);
1221
+ for (const id of globalCompleted) {
1222
+ completed.add(id);
1223
+ }
1224
+
1225
+ // 4. Check for duplicate task IDs (global uniqueness enforcement)
1226
+ for (const [taskId, locations] of idLocations) {
1227
+ if (locations.length > 1) {
1228
+ errors.push({
1229
+ code: "DUPLICATE_ID",
1230
+ message:
1231
+ `Duplicate task ID "${taskId}" found in ${locations.length} locations:\n` +
1232
+ locations.map((l) => ` - ${l}`).join("\n"),
1233
+ taskId,
1234
+ });
1235
+ }
1236
+ }
1237
+
1238
+ return { pending, completed, errors };
1239
+ }
1240
+
1241
+
1242
+ // ── Cross-Area Dependency Resolution ─────────────────────────────────
1243
+
1244
+ /** Candidate match for a dependency reference found in task areas. */
1245
+ export interface DependencyCandidate {
1246
+ areaName: string;
1247
+ path: string;
1248
+ status: "pending" | "complete";
1249
+ }
1250
+
1251
+ export function findDependencyCandidates(
1252
+ depRef: DependencyRef,
1253
+ taskAreas: Record<string, TaskArea>,
1254
+ cwd: string,
1255
+ ): DependencyCandidate[] {
1256
+ const candidates: DependencyCandidate[] = [];
1257
+ const sortedAreas = Object.entries(taskAreas).sort((a, b) => a[0].localeCompare(b[0]));
1258
+
1259
+ for (const [areaName, area] of sortedAreas) {
1260
+ if (depRef.areaName && depRef.areaName !== areaName.toLowerCase()) {
1261
+ continue;
1262
+ }
1263
+
1264
+ const areaPath = resolve(cwd, area.path);
1265
+ if (!existsSync(areaPath)) continue;
1266
+
1267
+ let entries: string[];
1268
+ try {
1269
+ entries = readdirSync(areaPath);
1270
+ } catch {
1271
+ continue;
1272
+ }
1273
+
1274
+ // Active tasks (skip archive)
1275
+ for (const entry of entries) {
1276
+ if (entry.toLowerCase() === "archive") continue;
1277
+ const entryTaskId = extractTaskIdFromFolderName(entry);
1278
+ if (entryTaskId !== depRef.taskId) continue;
1279
+
1280
+ const entryPath = join(areaPath, entry);
1281
+ try {
1282
+ if (!statSync(entryPath).isDirectory()) continue;
1283
+ } catch {
1284
+ continue;
1285
+ }
1286
+
1287
+ candidates.push({
1288
+ areaName,
1289
+ path: entryPath,
1290
+ status: existsSync(join(entryPath, ".DONE")) ? "complete" : "pending",
1291
+ });
1292
+ }
1293
+
1294
+ // Archived tasks (require .DONE marker)
1295
+ const archivePath = join(areaPath, "archive");
1296
+ if (!existsSync(archivePath)) continue;
1297
+ try {
1298
+ const archiveEntries = readdirSync(archivePath);
1299
+ for (const archiveEntry of archiveEntries) {
1300
+ const entryTaskId = extractTaskIdFromFolderName(archiveEntry);
1301
+ if (entryTaskId !== depRef.taskId) continue;
1302
+
1303
+ const archiveTaskPath = join(archivePath, archiveEntry);
1304
+ candidates.push({
1305
+ areaName,
1306
+ path: archiveTaskPath,
1307
+ status: existsSync(join(archiveTaskPath, ".DONE")) ? "complete" : "pending",
1308
+ });
1309
+ }
1310
+ } catch {
1311
+ // Ignore archive read errors for discovery resilience
1312
+ }
1313
+ }
1314
+
1315
+ return candidates;
1316
+ }
1317
+
1318
+ /**
1319
+ * Resolve dependencies for all pending tasks.
1320
+ *
1321
+ * Supports both dependency formats:
1322
+ * - TASK-ID (unqualified)
1323
+ * - area-name/TASK-ID (area-qualified)
1324
+ */
1325
+ export function resolveDependencies(
1326
+ discovery: DiscoveryResult,
1327
+ taskAreas: Record<string, TaskArea>,
1328
+ cwd: string,
1329
+ ): DiscoveryError[] {
1330
+ const errors: DiscoveryError[] = [];
1331
+
1332
+ for (const [taskId, task] of discovery.pending) {
1333
+ for (const depRaw of task.dependencies) {
1334
+ const depRef = parseDependencyReference(depRaw);
1335
+ const depId = depRef.taskId;
1336
+
1337
+ // Fast path for unqualified refs already in registry
1338
+ if (!depRef.areaName) {
1339
+ if (discovery.pending.has(depId)) continue;
1340
+ if (discovery.completed.has(depId)) continue;
1341
+ } else {
1342
+ const pendingTask = discovery.pending.get(depId);
1343
+ if (pendingTask && pendingTask.areaName.toLowerCase() === depRef.areaName) {
1344
+ continue;
1345
+ }
1346
+ }
1347
+
1348
+ const candidates = findDependencyCandidates(depRef, taskAreas, cwd);
1349
+
1350
+ if (candidates.length === 0) {
1351
+ errors.push({
1352
+ code: "DEP_UNRESOLVED",
1353
+ message: `${taskId} depends on ${depRaw} which does not exist in any task area`,
1354
+ taskId,
1355
+ taskPath: task.promptPath,
1356
+ });
1357
+ continue;
1358
+ }
1359
+
1360
+ if (!depRef.areaName && candidates.length > 1) {
1361
+ const options = candidates
1362
+ .map((c) => ` - ${c.areaName}/${depId} [${c.status}] (${c.path})`)
1363
+ .join("\n");
1364
+ errors.push({
1365
+ code: "DEP_AMBIGUOUS",
1366
+ message:
1367
+ `${taskId} depends on ${depId}, but multiple tasks match across areas. ` +
1368
+ `Use an area-qualified dependency (area/${depId}).\n${options}`,
1369
+ taskId,
1370
+ taskPath: task.promptPath,
1371
+ });
1372
+ continue;
1373
+ }
1374
+
1375
+ if (depRef.areaName && candidates.length > 1) {
1376
+ const options = candidates
1377
+ .map((c) => ` - ${c.areaName}/${depId} [${c.status}] (${c.path})`)
1378
+ .join("\n");
1379
+ errors.push({
1380
+ code: "DEP_AMBIGUOUS",
1381
+ message:
1382
+ `${taskId} depends on ${depRaw}, but multiple matching task folders were found. ` +
1383
+ `Resolve duplicate task IDs.\n${options}`,
1384
+ taskId,
1385
+ taskPath: task.promptPath,
1386
+ });
1387
+ continue;
1388
+ }
1389
+
1390
+ const match = candidates[0];
1391
+ if (match.status === "complete") {
1392
+ discovery.completed.add(depId);
1393
+ continue;
1394
+ }
1395
+
1396
+ errors.push({
1397
+ code: "DEP_PENDING",
1398
+ message:
1399
+ `${taskId} depends on ${depRaw} which is pending in "${match.areaName}". ` +
1400
+ `Include that area: /orch ${match.areaName}`,
1401
+ taskId,
1402
+ taskPath: task.promptPath,
1403
+ });
1404
+ }
1405
+ }
1406
+
1407
+ return errors;
1408
+ }
1409
+
1410
+
1411
+ // ── Task-to-Repo Routing ─────────────────────────────────────────────
1412
+
1413
+ /** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */
1414
+ const ROUTING_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1415
+
1416
+ /**
1417
+ * Resolve the target repo for each discovered task using the routing
1418
+ * precedence chain:
1419
+ *
1420
+ * 1. `task.promptRepoId` — declared in PROMPT.md metadata
1421
+ * 2. `taskArea.repoId` area-level config from task-runner.yaml
1422
+ * 3. `workspaceConfig.routing.defaultRepo` — workspace-level default
1423
+ *
1424
+ * Only applied in workspace mode (when `workspaceConfig` is provided).
1425
+ * In repo mode this function is never called.
1426
+ *
1427
+ * Returns an array of DiscoveryError for routing failures:
1428
+ * - TASK_REPO_UNRESOLVED: no source provided a repo ID
1429
+ * - TASK_REPO_UNKNOWN: resolved repo ID is not in workspace repos map
1430
+ */
1431
+ export function resolveTaskRouting(
1432
+ discovery: DiscoveryResult,
1433
+ taskAreas: Record<string, TaskArea>,
1434
+ workspaceConfig: WorkspaceConfig,
1435
+ ): DiscoveryError[] {
1436
+ const errors: DiscoveryError[] = [];
1437
+ const validRepoIds = workspaceConfig.repos;
1438
+ const strictMode = workspaceConfig.routing.strict === true;
1439
+
1440
+ for (const task of discovery.pending.values()) {
1441
+ // ── Explicit segment DAG repo validation (workspace IDs) ─
1442
+ if (task.explicitSegmentDag) {
1443
+ const unknownRepos = task.explicitSegmentDag.repoIds.filter((repoId) => !validRepoIds.has(repoId));
1444
+ if (unknownRepos.length > 0) {
1445
+ errors.push({
1446
+ code: "SEGMENT_REPO_UNKNOWN",
1447
+ message:
1448
+ `Task ${task.taskId} declares unknown repo ID(s) in ## Segment DAG: ${unknownRepos.join(", ")}. ` +
1449
+ `Known repos: ${[...validRepoIds.keys()].join(", ")}`,
1450
+ taskId: task.taskId,
1451
+ taskPath: task.promptPath,
1452
+ });
1453
+ continue;
1454
+ }
1455
+ }
1456
+
1457
+ // ── Strict mode enforcement ──────────────────────────────
1458
+ // When strict routing is enabled, every task MUST declare an
1459
+ // explicit execution target in PROMPT.md. Area-level and
1460
+ // workspace-default fallbacks are NOT used for resolution.
1461
+ if (strictMode && !task.promptRepoId) {
1462
+ errors.push({
1463
+ code: "TASK_ROUTING_STRICT",
1464
+ message:
1465
+ `Task ${task.taskId} has no explicit execution target, but strict routing is enabled ` +
1466
+ `(routing.strict: true in workspace config). ` +
1467
+ `Add an execution target to the task's PROMPT.md:\n` +
1468
+ `\n` +
1469
+ ` ## Execution Target\n` +
1470
+ `\n` +
1471
+ ` Repo: <repo-id>\n` +
1472
+ `\n` +
1473
+ `Available repos: ${[...validRepoIds.keys()].join(", ")}`,
1474
+ taskId: task.taskId,
1475
+ taskPath: task.promptPath,
1476
+ });
1477
+ continue;
1478
+ }
1479
+
1480
+ // Precedence 1: prompt-declared repo
1481
+ let resolvedId = task.promptRepoId;
1482
+ let source = "prompt";
1483
+
1484
+ // Precedence 2: area-level repo
1485
+ if (!resolvedId) {
1486
+ const area = taskAreas[task.areaName];
1487
+ if (area?.repoId) {
1488
+ const candidate = area.repoId.trim().toLowerCase();
1489
+ if (ROUTING_REPO_ID_PATTERN.test(candidate)) {
1490
+ resolvedId = candidate;
1491
+ source = "area";
1492
+ }
1493
+ }
1494
+ }
1495
+
1496
+ // Precedence 3: file scope inference — match file path prefixes against
1497
+ // known workspace repo IDs. If file scope entries like "web-client/src/..."
1498
+ // start with a repo name, route the task to that repo.
1499
+ if (!resolvedId && task.fileScope && task.fileScope.length > 0) {
1500
+ const repoIds = [...validRepoIds.keys()];
1501
+ const repoCounts = new Map<string, number>();
1502
+ for (const filePath of task.fileScope) {
1503
+ const normalized = filePath.replace(/\\/g, "/");
1504
+ for (const repoId of repoIds) {
1505
+ if (normalized.startsWith(repoId + "/") || normalized === repoId) {
1506
+ repoCounts.set(repoId, (repoCounts.get(repoId) || 0) + 1);
1507
+ break; // first matching repo wins for this path
1508
+ }
1509
+ }
1510
+ }
1511
+ // Use the repo with the most file scope matches (majority vote)
1512
+ if (repoCounts.size === 1) {
1513
+ resolvedId = repoCounts.keys().next().value!;
1514
+ source = "file-scope";
1515
+ } else if (repoCounts.size > 1) {
1516
+ // Multiple repos in file scope — pick the one with most entries.
1517
+ // (Future: #51 will handle multi-repo tasks properly)
1518
+ let maxCount = 0;
1519
+ for (const [repoId, count] of repoCounts) {
1520
+ if (count > maxCount) {
1521
+ maxCount = count;
1522
+ resolvedId = repoId;
1523
+ }
1524
+ }
1525
+ source = "file-scope";
1526
+ }
1527
+ }
1528
+
1529
+ // Precedence 4: workspace default repo
1530
+ if (!resolvedId) {
1531
+ resolvedId = workspaceConfig.routing.defaultRepo;
1532
+ source = "default";
1533
+ }
1534
+
1535
+ // Validate resolution
1536
+ if (!resolvedId) {
1537
+ errors.push({
1538
+ code: "TASK_REPO_UNRESOLVED",
1539
+ message:
1540
+ `Task ${task.taskId} has no resolved repo. ` +
1541
+ `Add file scope paths prefixed with the repo name (e.g., "web-client/src/..."), ` +
1542
+ `set repo_id on area "${task.areaName}", ` +
1543
+ `or set routing.default_repo in the workspace config.`,
1544
+ taskId: task.taskId,
1545
+ taskPath: task.promptPath,
1546
+ });
1547
+ continue;
1548
+ }
1549
+
1550
+ if (!validRepoIds.has(resolvedId)) {
1551
+ errors.push({
1552
+ code: "TASK_REPO_UNKNOWN",
1553
+ message:
1554
+ `Task ${task.taskId} resolved to repo "${resolvedId}" (via ${source}), ` +
1555
+ `but no repo with that ID exists in the workspace config. ` +
1556
+ `Known repos: ${[...validRepoIds.keys()].join(", ")}`,
1557
+ taskId: task.taskId,
1558
+ taskPath: task.promptPath,
1559
+ });
1560
+ continue;
1561
+ }
1562
+
1563
+ // Attach resolved repo to the task
1564
+ task.resolvedRepoId = resolvedId;
1565
+
1566
+ // ── Step-segment mapping: resolve placeholders and validate repo IDs (TP-173) ──
1567
+ if (task.stepSegmentMap) {
1568
+ const knownRepoList = [...validRepoIds.keys()].join(", ");
1569
+ for (const step of task.stepSegmentMap) {
1570
+ for (const seg of step.segments) {
1571
+ // Replace placeholder with resolved primary repo
1572
+ if (seg.repoId === SEGMENT_FALLBACK_REPO_PLACEHOLDER) {
1573
+ seg.repoId = resolvedId;
1574
+ continue;
1575
+ }
1576
+ // Validate explicit segment repoIds against workspace repos
1577
+ if (!validRepoIds.has(seg.repoId)) {
1578
+ const knownRepos = [...validRepoIds.keys()];
1579
+ const suggestions = suggestRepoMatches(seg.repoId, knownRepos);
1580
+ const suggestionHint = suggestions.length > 0
1581
+ ? ` Did you mean: ${suggestions.join(", ")}?`
1582
+ : "";
1583
+ errors.push({
1584
+ code: "SEGMENT_STEP_REPO_INVALID",
1585
+ message:
1586
+ `Task ${task.taskId} Step ${step.stepNumber} has segment repo "${seg.repoId}" ` +
1587
+ `which is not in the workspace config. Known repos: ${knownRepoList}.${suggestionHint}`,
1588
+ taskId: task.taskId,
1589
+ taskPath: task.promptPath,
1590
+ });
1591
+ }
1592
+ }
1593
+ // Duplicate detection for post-placeholder resolution is handled
1594
+ // by the shared pass in runDiscovery() (Step 7).
1595
+ }
1596
+ }
1597
+ }
1598
+
1599
+ return errors;
1600
+ }
1601
+
1602
+
1603
+ // ── Discovery Pipeline (Public) ──────────────────────────────────────
1604
+
1605
+ /**
1606
+ * Run the full discovery pipeline:
1607
+ * 1. Resolve arguments to scan paths and direct task folders
1608
+ * 2. Build task registry (scan, parse, deduplicate)
1609
+ * 3. Resolve cross-area dependencies
1610
+ *
1611
+ * Returns a DiscoveryResult with pending tasks, completed set, and any errors.
1612
+ */
1613
+ export function runDiscovery(
1614
+ args: string,
1615
+ taskAreas: Record<string, TaskArea>,
1616
+ cwd: string,
1617
+ options: DiscoveryOptions = {},
1618
+ ): DiscoveryResult {
1619
+ const dependencySource = options.dependencySource ?? "prompt";
1620
+ const useDependencyCache = options.useDependencyCache ?? false;
1621
+ const refreshDependencies = options.refreshDependencies ?? false;
1622
+
1623
+ // Step 1: Resolve arguments
1624
+ const resolved = resolveArguments(args, taskAreas, cwd);
1625
+ if (resolved.errors.length > 0) {
1626
+ return {
1627
+ pending: new Map(),
1628
+ completed: new Set(),
1629
+ errors: resolved.errors,
1630
+ };
1631
+ }
1632
+
1633
+ if (resolved.areaScanPaths.length === 0 && resolved.directTaskFolders.length === 0) {
1634
+ return {
1635
+ pending: new Map(),
1636
+ completed: new Set(),
1637
+ errors: [
1638
+ {
1639
+ code: "UNKNOWN_ARG",
1640
+ message: "No valid areas, paths, or PROMPT.md files found in arguments",
1641
+ },
1642
+ ],
1643
+ };
1644
+ }
1645
+
1646
+ // Step 2: Build task registry (prompt-parsed dependencies as baseline)
1647
+ const discovery = buildTaskRegistry(
1648
+ resolved.areaScanPaths,
1649
+ resolved.directTaskFolders,
1650
+ taskAreas,
1651
+ cwd,
1652
+ );
1653
+
1654
+ // If we have duplicate ID errors, stop early (fail-fast)
1655
+ const duplicateErrors = discovery.errors.filter((e) => e.code === "DUPLICATE_ID");
1656
+ if (duplicateErrors.length > 0) {
1657
+ return discovery;
1658
+ }
1659
+
1660
+ // Step 3: Dependency source + cache policy
1661
+ // TS-004 scaffold supports prompt parsing and cached dependency maps.
1662
+ // Agent-based analysis is deferred to later tasks; when selected, we
1663
+ // attempt cache first and fall back to prompt parsing if unavailable.
1664
+ let effectiveDependencySource: "prompt" | "agent" = dependencySource;
1665
+ if (useDependencyCache && !refreshDependencies) {
1666
+ const { applied } = applyDependenciesFromCache(discovery, resolved.areaScanPaths);
1667
+ if (dependencySource === "agent" && !applied) {
1668
+ effectiveDependencySource = "prompt";
1669
+ discovery.errors.push({
1670
+ code: "DEP_SOURCE_FALLBACK",
1671
+ message:
1672
+ "dependencies.source=agent requested, but no dependency cache was found for " +
1673
+ "the selected areas. Falling back to PROMPT.md dependencies.",
1674
+ });
1675
+ }
1676
+ } else if (dependencySource === "agent") {
1677
+ effectiveDependencySource = "prompt";
1678
+ discovery.errors.push({
1679
+ code: "DEP_SOURCE_FALLBACK",
1680
+ message:
1681
+ "dependencies.source=agent requested, but agent-based dependency analysis " +
1682
+ "is not implemented in TS-004 scaffold. Falling back to PROMPT.md dependencies.",
1683
+ });
1684
+ }
1685
+
1686
+ // Step 4: Resolve cross-area dependencies using effective dependencies
1687
+ const depErrors = resolveDependencies(discovery, taskAreas, cwd);
1688
+ discovery.errors.push(...depErrors);
1689
+
1690
+ // Step 5: Persist cache (if enabled) for next run / non-refresh runs
1691
+ if (useDependencyCache) {
1692
+ for (const areaPath of resolved.areaScanPaths) {
1693
+ writeAreaDependencyCache(areaPath, discovery.pending, effectiveDependencySource);
1694
+ }
1695
+ }
1696
+
1697
+ // Step 6: Task-to-repo routing (workspace mode only)
1698
+ const workspaceConfig = options.workspaceConfig;
1699
+ if (workspaceConfig && workspaceConfig.mode === "workspace") {
1700
+ const routingErrors = resolveTaskRouting(discovery, taskAreas, workspaceConfig);
1701
+ discovery.errors.push(...routingErrors);
1702
+ } else {
1703
+ // Repo mode: resolve any placeholder fallback repo IDs to "default"
1704
+ // (single-repo mode has no workspace routing, so the placeholder
1705
+ // must be normalized here for backward compatibility).
1706
+ for (const task of discovery.pending.values()) {
1707
+ if (!task.stepSegmentMap) continue;
1708
+ for (const step of task.stepSegmentMap) {
1709
+ for (const seg of step.segments) {
1710
+ if (seg.repoId === SEGMENT_FALLBACK_REPO_PLACEHOLDER) {
1711
+ seg.repoId = "default";
1712
+ }
1713
+ }
1714
+ }
1715
+ }
1716
+ }
1717
+
1718
+ // Step 7: Post-normalization duplicate segment detection (TP-173)
1719
+ // After all placeholder resolution (workspace or repo mode), check each
1720
+ // step for duplicate repoIds that may have emerged from placeholder → real ID.
1721
+ for (const task of discovery.pending.values()) {
1722
+ if (!task.stepSegmentMap) continue;
1723
+ for (const step of task.stepSegmentMap) {
1724
+ const stepRepoIds = step.segments.map(s => s.repoId);
1725
+ const seen = new Set<string>();
1726
+ for (const rid of stepRepoIds) {
1727
+ if (seen.has(rid)) {
1728
+ discovery.errors.push({
1729
+ code: "SEGMENT_STEP_DUPLICATE_REPO",
1730
+ message:
1731
+ `Task ${task.taskId} Step ${step.stepNumber} has duplicate segment repo ID "${rid}" ` +
1732
+ `(after resolving primary repo fallback). A repoId may appear at most once within a step.`,
1733
+ taskId: task.taskId,
1734
+ taskPath: task.promptPath,
1735
+ });
1736
+ break;
1737
+ }
1738
+ seen.add(rid);
1739
+ }
1740
+ }
1741
+ }
1742
+
1743
+ return discovery;
1744
+ }
1745
+
1746
+ /**
1747
+ * Format discovery results as a readable string for display.
1748
+ */
1749
+ export function formatDiscoveryResults(result: DiscoveryResult): string {
1750
+ const lines: string[] = [];
1751
+
1752
+ // Summary
1753
+ lines.push(`📋 Discovery Results`);
1754
+ lines.push(` Pending tasks: ${result.pending.size}`);
1755
+ lines.push(` Completed tasks: ${result.completed.size}`);
1756
+ lines.push("");
1757
+
1758
+ // List pending tasks grouped by area (deterministic: sorted by area name, then task ID)
1759
+ if (result.pending.size > 0) {
1760
+ const byArea = new Map<string, ParsedTask[]>();
1761
+ for (const task of result.pending.values()) {
1762
+ const existing = byArea.get(task.areaName) || [];
1763
+ existing.push(task);
1764
+ byArea.set(task.areaName, existing);
1765
+ }
1766
+
1767
+ lines.push("Pending Tasks:");
1768
+ const sortedAreas = [...byArea.entries()].sort((a, b) =>
1769
+ a[0].localeCompare(b[0]),
1770
+ );
1771
+ for (const [area, tasks] of sortedAreas) {
1772
+ lines.push(` ${area}:`);
1773
+ const sortedTasks = [...tasks].sort((a, b) =>
1774
+ a.taskId.localeCompare(b.taskId),
1775
+ );
1776
+ for (const task of sortedTasks) {
1777
+ const deps =
1778
+ task.dependencies.length > 0
1779
+ ? ` → depends on: ${task.dependencies.join(", ")}`
1780
+ : "";
1781
+ const repo =
1782
+ task.resolvedRepoId
1783
+ ? ` → repo: ${task.resolvedRepoId}`
1784
+ : "";
1785
+ lines.push(
1786
+ ` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`,
1787
+ );
1788
+ }
1789
+ }
1790
+ lines.push("");
1791
+ }
1792
+
1793
+ // Show errors
1794
+ if (result.errors.length > 0) {
1795
+ const fatalCodes = new Set<string>(FATAL_DISCOVERY_CODES);
1796
+ const fatalErrors = result.errors.filter((e) => fatalCodes.has(e.code));
1797
+ const warnings = result.errors.filter((e) => !fatalCodes.has(e.code));
1798
+
1799
+ if (fatalErrors.length > 0) {
1800
+ lines.push("❌ Errors:");
1801
+ for (const err of fatalErrors) {
1802
+ lines.push(` [${err.code}] ${err.message}`);
1803
+ }
1804
+ lines.push("");
1805
+ }
1806
+
1807
+ if (warnings.length > 0) {
1808
+ lines.push("⚠️ Warnings:");
1809
+ for (const err of warnings) {
1810
+ lines.push(` [${err.code}] ${err.message}`);
1811
+ }
1812
+ lines.push("");
1813
+ }
1814
+ }
1815
+
1816
+ return lines.join("\n");
1817
+ }
1818
+