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