@pi-agents/orchid 0.1.0-beta.0

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