/** * Task discovery, PROMPT.md parsing, dependency resolution * @module orch/discovery */ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs"; import { join, dirname, basename, resolve } from "path"; import { FATAL_DISCOVERY_CODES } from "./types.ts"; import type { DiscoveryError, DiscoveryResult, ParsedTask, PromptSegmentDagMetadata, SegmentCheckboxGroup, StepSegmentMapping, TaskArea, WorkspaceConfig, } from "./types.ts"; // ── PROMPT.md Parsing ──────────────────────────────────────────────── /** * Extract the task ID from a folder name. * Convention: "TO-014-accrual-engine" → "TO-014" * Matches prefix-number patterns like "COMP-006", "TS-004", "TO-014". */ export function extractTaskIdFromFolderName(folderName: string): string | null { const match = folderName.match(/^([A-Z]+-\d+)/); return match ? match[1] : null; } export interface DependencyRef { raw: string; taskId: string; areaName?: string; } export function parseDependencyReference(raw: string): DependencyRef { const trimmed = raw.trim(); const qualified = trimmed.match(/^([a-z0-9-]+)\/([A-Z]+-\d+)$/i); if (qualified) { return { raw: trimmed, areaName: qualified[1].toLowerCase(), taskId: qualified[2].toUpperCase(), }; } const idOnly = trimmed.match(/^([A-Z]+-\d+)$/i); if (idOnly) { return { raw: trimmed, taskId: idOnly[1].toUpperCase(), }; } return { raw: trimmed, taskId: trimmed.toUpperCase(), }; } export function normalizeDependencyReference(raw: string): string { const parsed = parseDependencyReference(raw); return parsed.areaName ? `${parsed.areaName}/${parsed.taskId}` : parsed.taskId; } const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; function normalizeSegmentRepoToken(raw: string): string { let token = raw.trim(); token = token.replace(/^`(.+)`$/, "$1").trim(); token = token.replace(/^\*\*(.+)\*\*$/, "$1").trim(); return token.toLowerCase(); } interface ParsedSegmentDagBody { metadata: PromptSegmentDagMetadata | null; error: DiscoveryError | null; } /** * Parse optional explicit segment DAG metadata from `## Segment DAG`. * * Supported v1 syntax: * * ## Segment DAG * Repos: * - api * - web-client * Edges: * - api -> web-client * * Notes: * - `Repos:` / `Edges:` keys accept markdown decoration (`**Repos:**`) and whitespace. * - Repo IDs are normalized to lowercase and validated against routing repo ID rules. * - Unknown edge endpoints (not present in explicit repo list) fail fast. * - Self-edges and cycles fail fast with `SEGMENT_DAG_INVALID`. */ function parseSegmentDagMetadata( content: string, taskId: string, promptPath: string, ): ParsedSegmentDagBody { const headerMatch = content.match(/^##\s+Segment DAG\s*$/im); if (!headerMatch || headerMatch.index === undefined) { return { metadata: null, error: null }; } const headerIndex = headerMatch.index; const afterHeaderIndex = content.indexOf("\n", headerIndex); if (afterHeaderIndex === -1) { return { metadata: null, error: null }; } const rest = content.slice(afterHeaderIndex + 1); const nextBoundary = rest.search(/^##\s|^---/m); const body = nextBoundary !== -1 ? rest.slice(0, nextBoundary) : rest; const repoIds: string[] = []; const repoSet = new Set(); const edgePairs = new Set(); const edges: Array<{ fromRepoId: string; toRepoId: string }> = []; const baseLine = content.slice(0, afterHeaderIndex + 1).split(/\r?\n/).length; let mode: "repos" | "edges" | null = null; const lines = body.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const rawLine = lines[i]; const trimmed = rawLine.trim(); if (!trimmed) continue; if (/^\*?\*?Repos:?\*?\*?\s*$/i.test(trimmed)) { mode = "repos"; continue; } if (/^\*?\*?Edges:?\*?\*?\s*$/i.test(trimmed)) { mode = "edges"; continue; } if (!mode) { return { metadata: null, error: { code: "SEGMENT_DAG_INVALID", message: `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` + `expected a Repos: or Edges: subsection header before entries.`, taskId, taskPath: promptPath, }, }; } const bulletMatch = rawLine.match(/^\s*[-*]\s+(.+)$/); if (!bulletMatch) { return { metadata: null, error: { code: "SEGMENT_DAG_INVALID", message: `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` + `expected a bullet entry ("- ...").`, taskId, taskPath: promptPath, }, }; } const entry = bulletMatch[1].trim(); if (!entry) continue; if (mode === "repos") { if (entry.includes("->")) { return { metadata: null, error: { code: "SEGMENT_DAG_INVALID", message: `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` + `repo list entries must be a single repo ID.`, taskId, taskPath: promptPath, }, }; } const repoId = normalizeSegmentRepoToken(entry); if (!SEGMENT_REPO_ID_PATTERN.test(repoId)) { return { metadata: null, error: { code: "SEGMENT_DAG_INVALID", message: `Task ${taskId} has invalid repo ID "${entry}" in ## Segment DAG at line ${baseLine + i}. ` + `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`, taskId, taskPath: promptPath, }, }; } if (!repoSet.has(repoId)) { repoSet.add(repoId); repoIds.push(repoId); } continue; } const edgeMatch = entry.match(/^(.+?)\s*->\s*(.+)$/); if (!edgeMatch) { return { metadata: null, error: { code: "SEGMENT_DAG_INVALID", message: `Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` + `Expected format: -> .`, taskId, taskPath: promptPath, }, }; } const fromRepoId = normalizeSegmentRepoToken(edgeMatch[1]); const toRepoId = normalizeSegmentRepoToken(edgeMatch[2]); if (!SEGMENT_REPO_ID_PATTERN.test(fromRepoId) || !SEGMENT_REPO_ID_PATTERN.test(toRepoId)) { return { metadata: null, error: { code: "SEGMENT_DAG_INVALID", message: `Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` + `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`, taskId, taskPath: promptPath, }, }; } if (fromRepoId === toRepoId) { return { metadata: null, error: { code: "SEGMENT_DAG_INVALID", message: `Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`, taskId, taskPath: promptPath, }, }; } const edgeKey = `${fromRepoId}->${toRepoId}`; if (!edgePairs.has(edgeKey)) { edgePairs.add(edgeKey); edges.push({ fromRepoId, toRepoId }); } } if (repoIds.length === 0 && edges.length === 0) { return { metadata: null, error: null }; } for (const edge of edges) { if (!repoSet.has(edge.fromRepoId)) { return { metadata: null, error: { code: "SEGMENT_REPO_UNKNOWN", message: `Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`, taskId, taskPath: promptPath, }, }; } if (!repoSet.has(edge.toRepoId)) { return { metadata: null, error: { code: "SEGMENT_REPO_UNKNOWN", message: `Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`, taskId, taskPath: promptPath, }, }; } } const sortedEdges = [...edges].sort((a, b) => { if (a.fromRepoId !== b.fromRepoId) return a.fromRepoId.localeCompare(b.fromRepoId); return a.toRepoId.localeCompare(b.toRepoId); }); const adjacency = new Map(); for (const repoId of repoIds) { adjacency.set(repoId, []); } for (const edge of sortedEdges) { adjacency.get(edge.fromRepoId)!.push(edge.toRepoId); } for (const neighbors of adjacency.values()) { neighbors.sort(); } const visited = new Set(); const stack = new Set(); const path: string[] = []; let cycle: string[] | null = null; function dfs(repoId: string): void { if (cycle) return; visited.add(repoId); stack.add(repoId); path.push(repoId); const neighbors = adjacency.get(repoId) || []; for (const next of neighbors) { if (cycle) return; if (!visited.has(next)) { dfs(next); continue; } if (stack.has(next)) { const start = path.indexOf(next); cycle = [...path.slice(start), next]; return; } } path.pop(); stack.delete(repoId); } for (const repoId of [...repoIds].sort()) { if (!visited.has(repoId)) dfs(repoId); if (cycle) break; } if (cycle) { return { metadata: null, error: { code: "SEGMENT_DAG_INVALID", message: `Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`, taskId, taskPath: promptPath, }, }; } return { metadata: { repoIds, edges: sortedEdges, }, error: null, }; } // ── Step-Segment Mapping (Phase A) ─────────────────────────────────── /** * Sentinel repo ID used when the task's primary repo is not yet known at parse time. * Replaced by the resolved repo during routing (resolveTaskRouting). */ export const SEGMENT_FALLBACK_REPO_PLACEHOLDER = "__primary__"; /** * Simple suggestion helper: find known repo IDs that share a prefix or * have small edit distance from the unknown repo ID. */ function suggestRepoMatches(unknown: string, known: string[]): string[] { const suggestions: string[] = []; for (const k of known) { // Prefix match (either direction) if (k.startsWith(unknown) || unknown.startsWith(k)) { suggestions.push(k); continue; } // Simple overlap: share at least 3 chars of a common substring const shorter = unknown.length < k.length ? unknown : k; const longer = unknown.length < k.length ? k : unknown; if (shorter.length >= 3 && longer.includes(shorter.slice(0, 3))) { suggestions.push(k); } } return suggestions; } interface StepSegmentParseResult { mapping: StepSegmentMapping[]; /** True if at least one step had an explicit `#### Segment:` marker. */ hasExplicitMarkers: boolean; warnings: DiscoveryError[]; errors: DiscoveryError[]; } /** * Parse `#### Segment: ` markers within `### Step N:` sections of a PROMPT.md. * * Builds a StepSegmentMapping[] that maps each step to its repo-scoped checkbox groups. * * Rules: * - Checkboxes before any segment header (or in steps with no segment headers) * belong to the task's primary repoId (fallbackRepoId / packet repo). * - A repoId may appear at most once within a step (duplicate → error). * - Empty segments (header but no checkboxes) produce a warning. * - Unknown repoIds are flagged as warnings (validation deferred to routing). */ export function parseStepSegmentMapping( content: string, taskId: string, fallbackRepoId: string, ): StepSegmentParseResult { const mapping: StepSegmentMapping[] = []; const warnings: DiscoveryError[] = []; const errors: DiscoveryError[] = []; let hasExplicitMarkers = false; // Find ## Steps section const stepsSectionMatch = content.match(/^##\s+Steps\s*$/im); if (!stepsSectionMatch || stepsSectionMatch.index === undefined) { return { mapping, hasExplicitMarkers, warnings, errors }; } const stepsStart = stepsSectionMatch.index; // Get body from ## Steps to next ## top-level section or --- divider const afterStepsHeader = content.indexOf("\n", stepsStart); if (afterStepsHeader === -1) { return { mapping, hasExplicitMarkers, warnings, errors }; } const rest = content.slice(afterStepsHeader + 1); // Find the next top-level section (## but not ###) or --- divider const nextSectionMatch = rest.search(/^##\s+[^#]|^---/m); const stepsBody = nextSectionMatch !== -1 ? rest.slice(0, nextSectionMatch) : rest; // Split into step sections by ### Step N: headers const stepHeaderRegex = /^###\s+Step\s+(\d+):\s*(.+)$/gm; const stepHeaders: { index: number; stepNumber: number; stepName: string }[] = []; let match: RegExpExecArray | null; while ((match = stepHeaderRegex.exec(stepsBody)) !== null) { stepHeaders.push({ index: match.index, stepNumber: parseInt(match[1], 10), stepName: match[2].trim(), }); } if (stepHeaders.length === 0) { return { mapping, hasExplicitMarkers, warnings, errors }; } for (let i = 0; i < stepHeaders.length; i++) { const header = stepHeaders[i]; const nextHeaderIndex = i + 1 < stepHeaders.length ? stepHeaders[i + 1].index : stepsBody.length; const stepContent = stepsBody.slice(header.index, nextHeaderIndex); // Parse segment groups within this step const segmentHeaderRegex = /^####\s+Segment:\s*(.+)$/gm; const segmentHeaders: { index: number; repoId: string; rawRepoId: string }[] = []; let segMatch: RegExpExecArray | null; while ((segMatch = segmentHeaderRegex.exec(stepContent)) !== null) { const rawRepoId = segMatch[1].trim(); const repoId = normalizeSegmentRepoToken(rawRepoId); segmentHeaders.push({ index: segMatch.index, repoId, rawRepoId, }); } const segments: SegmentCheckboxGroup[] = []; if (segmentHeaders.length === 0) { // No segment markers — all checkboxes belong to fallback repo const checkboxes = extractCheckboxes(stepContent); segments.push({ repoId: fallbackRepoId, checkboxes }); // Don't set hasExplicitMarkers — this is a fallback, not an explicit marker } else { hasExplicitMarkers = true; // Check for checkboxes before the first segment header (pre-segment) const preSegmentContent = stepContent.slice(0, segmentHeaders[0].index); const preCheckboxes = extractCheckboxes(preSegmentContent); if (preCheckboxes.length > 0) { segments.push({ repoId: fallbackRepoId, checkboxes: preCheckboxes }); } // Track seen repoIds for duplicate detection // Include fallback repo if pre-segment checkboxes exist and it's a concrete ID const seenRepoIds = new Set(); if (preCheckboxes.length > 0 && fallbackRepoId !== SEGMENT_FALLBACK_REPO_PLACEHOLDER) { seenRepoIds.add(fallbackRepoId); } let hasDuplicateError = false; for (let j = 0; j < segmentHeaders.length; j++) { const seg = segmentHeaders[j]; // Validate repo ID format (warning only — keep checkboxes for safety) if (!SEGMENT_REPO_ID_PATTERN.test(seg.repoId)) { warnings.push({ code: "SEGMENT_STEP_REPO_INVALID", message: `Task ${taskId} Step ${header.stepNumber} has invalid segment repo ID "${seg.rawRepoId}". ` + `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`, taskId, }); // Still extract checkboxes — don't drop work } // Check for duplicates if (seenRepoIds.has(seg.repoId)) { errors.push({ code: "SEGMENT_STEP_DUPLICATE_REPO", message: `Task ${taskId} Step ${header.stepNumber} has duplicate segment repo ID "${seg.repoId}". ` + `A repoId may appear at most once within a step.`, taskId, }); hasDuplicateError = true; continue; } seenRepoIds.add(seg.repoId); const nextSegIndex = j + 1 < segmentHeaders.length ? segmentHeaders[j + 1].index : stepContent.length; const segContent = stepContent.slice(seg.index, nextSegIndex); const checkboxes = extractCheckboxes(segContent); if (checkboxes.length === 0) { warnings.push({ code: "SEGMENT_STEP_EMPTY", message: `Task ${taskId} Step ${header.stepNumber} has empty segment "${seg.repoId}" with no checkboxes.`, taskId, }); } segments.push({ repoId: seg.repoId, checkboxes }); } if (hasDuplicateError) { // Still add what we collected, but errors are flagged } } mapping.push({ stepNumber: header.stepNumber, stepName: header.stepName, segments, }); } return { mapping, hasExplicitMarkers, warnings, errors }; } /** * Extract checkbox text lines from a content block. * Matches `- [ ] text` and `- [x] text` patterns. */ function extractCheckboxes(content: string): string[] { const checkboxes: string[] = []; const lines = content.split(/\r?\n/); for (const line of lines) { const match = line.match(/^\s*-\s+\[[ x]\]\s+(.+)$/); if (match) { checkboxes.push(match[1].trim()); } } return checkboxes; } /** * Parse a PROMPT.md file and extract orchestrator-relevant metadata. * * Required fields (hard fail if missing): * - Task ID: extracted from `# Task: XX-NNN - Name` heading OR from folder name * * Optional fields (defaults used if absent): * - Dependencies: defaults to [] (no dependencies) * - Review Level: defaults to 2 * - Size: defaults to "M" * - File Scope: defaults to [] * - Task Name: defaults to folder name * * Dependency syntax accepted: * - "**None**" or "None" → empty list * - "**Requires:** COMP-005 ..." → ["COMP-005"] * - "**Requires:** time-off/TO-014 ..." → ["time-off/TO-014"] * - "- COMP-005 (description)" → ["COMP-005"] * - "- **time-off/TO-014** — description" → ["time-off/TO-014"] * - Multiple bullet points → multiple dependencies */ export function parsePromptForOrchestrator( promptPath: string, taskFolder: string, areaName: string, ): { task: ParsedTask | null; error: DiscoveryError | null; warnings?: DiscoveryError[] } { const folderName = basename(taskFolder); let content: string; try { content = readFileSync(promptPath, "utf-8"); } catch { return { task: null, error: { code: "PARSE_MALFORMED", message: `Cannot read PROMPT.md: ${promptPath}`, taskPath: promptPath, }, }; } // ── Extract task ID ────────────────────────────────────────── // Try from heading first: "# Task: COMP-006 - Pay Bands Implementation" let taskId: string | null = null; let taskName = folderName; const headingMatch = content.match(/^#\s+Task:\s+([A-Z]+-\d+)\s*[-—]\s*(.+)$/m); if (headingMatch) { taskId = headingMatch[1]; taskName = headingMatch[2].trim(); } // Fallback: extract from folder name if (!taskId) { taskId = extractTaskIdFromFolderName(folderName); } if (!taskId) { return { task: null, error: { code: "PARSE_MISSING_ID", message: `Cannot extract task ID from heading or folder name "${folderName}" in ${promptPath}`, taskPath: promptPath, }, }; } // ── Extract review level ───────────────────────────────────── // "## Review Level: 1 (Plan Only)" or "## Review Level: 2" let reviewLevel = 2; const reviewMatch = content.match(/^##\s+Review Level:\s*(\d+)/m); if (reviewMatch) { reviewLevel = parseInt(reviewMatch[1], 10); } // ── Extract size ───────────────────────────────────────────── // "**Size:** M" (usually near top, after Created date) let size = "M"; const sizeMatch = content.match(/\*\*Size:\*\*\s*([SMLsml])/); if (sizeMatch) { size = sizeMatch[1].toUpperCase(); } // ── Extract dependencies ───────────────────────────────────── const dependencies: string[] = []; const depSectionMatch = content.match(/^##\s+Dependencies\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m); if (depSectionMatch) { const depBody = depSectionMatch[1].trim(); // Check for "None" variants if (!/\*?\*?None\*?\*?/i.test(depBody) && depBody.length > 0) { // Pattern 1: "**Requires:** COMP-005 ..." or "**Task:** TO-014 ..." const labeledMatches = depBody.matchAll( /\*?\*?(?:Requires|Task):?\*?\*?\s*((?:[a-z0-9-]+\/)?[A-Z]+-\d+)/gi, ); for (const m of labeledMatches) { const dep = normalizeDependencyReference(m[1]); if (!dependencies.includes(dep)) dependencies.push(dep); } // Pattern 2: Bullet list "- COMP-005 ...", "- **time-off/TO-014** ..." const bulletMatches = depBody.matchAll(/^[\s-]*\*?\*?((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\*?\*?/gim); for (const m of bulletMatches) { const dep = normalizeDependencyReference(m[1]); if (!dependencies.includes(dep)) dependencies.push(dep); } // Pattern 3: Inline dependency references not caught above if (dependencies.length === 0) { const inlineMatches = depBody.matchAll(/\b((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\b/gi); for (const m of inlineMatches) { const dep = parseDependencyReference(m[1]); if (dep.taskId === taskId) continue; // Don't add self-references const normalized = normalizeDependencyReference(m[1]); if (!dependencies.includes(normalized)) { dependencies.push(normalized); } } } } } // ── Extract execution target (repo ID) ────────────────────── // Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; let promptRepoId: string | undefined; // Priority 1: Section-based "## Execution Target" with "Repo: " line // Capture everything from section header to the next heading or --- divider. // We avoid \n$ (which in multiline mode matches blank lines) by using a two-pass // approach: find the section start, then slice to the next section boundary. const execTargetHeaderIdx = content.search(/^##\s+Execution Target\s*$/m); let execTargetSectionBody: string | null = null; if (execTargetHeaderIdx !== -1) { const afterHeader = content.indexOf("\n", execTargetHeaderIdx); if (afterHeader !== -1) { const rest = content.slice(afterHeader + 1); const nextSectionMatch = rest.search(/^##\s|^---/m); execTargetSectionBody = nextSectionMatch !== -1 ? rest.slice(0, nextSectionMatch) : rest; } } if (execTargetSectionBody !== null) { // Match "Repo: api" or "**Repo:** api" or "Workspace: api" with whitespace const repoLineMatch = execTargetSectionBody.match( /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/im, ); if (repoLineMatch) { const candidate = repoLineMatch[1].trim().toLowerCase(); if (REPO_ID_PATTERN.test(candidate)) { promptRepoId = candidate; } } } // Priority 2 (fallback): Inline "**Repo:** " or "**Workspace:** " anywhere in content if (!promptRepoId) { const inlineRepoMatch = content.match(/^\*\*(?:Repo|Workspace):\*\*\s+(\S+)/m); if (inlineRepoMatch) { const candidate = inlineRepoMatch[1].trim().toLowerCase(); if (REPO_ID_PATTERN.test(candidate)) { promptRepoId = candidate; } } } // ── Extract file scope ─────────────────────────────────────── const fileScope: string[] = []; const fileScopeMatch = content.match(/^##\s+File Scope\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m); if (fileScopeMatch) { const scopeBody = fileScopeMatch[1].trim(); const scopeLines = scopeBody.split("\n"); for (const line of scopeLines) { // "- extensions/task-orchestrator.ts" or "- `api-service/src/health.js`" let trimmed = line.replace(/^[\s-*]+/, "").trim(); // Strip inline backticks: `path/to/file` → path/to/file if (trimmed.startsWith("`") && trimmed.endsWith("`")) { trimmed = trimmed.slice(1, -1); } if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("```")) { fileScope.push(trimmed); } } } // ── Extract optional explicit segment DAG metadata ────────── const segmentDagResult = parseSegmentDagMetadata(content, taskId, resolve(promptPath)); if (segmentDagResult.error) { return { task: null, error: segmentDagResult.error, }; } const explicitSegmentDag = segmentDagResult.metadata; // ── Parse step-segment mapping (Phase A, TP-173) ──────── // Use promptRepoId as fallback; if not set, use a placeholder // sentinel that resolveTaskRouting replaces with the actual resolved repo. const segFallbackRepo = promptRepoId || SEGMENT_FALLBACK_REPO_PLACEHOLDER; const stepSegResult = parseStepSegmentMapping(content, taskId, segFallbackRepo); // Duplicate repoId in a step is a hard error — fail the task. if (stepSegResult.errors.length > 0) { return { task: null, error: stepSegResult.errors[0], }; } // Only populate stepSegmentMap when PROMPT.md has explicit #### Segment: markers. // The parser produces fallback entries (repoId = primary repo) even without markers, // but those should NOT trigger segment-scoped mode — they exist only for validation. const stepSegmentMap = stepSegResult.hasExplicitMarkers ? stepSegResult.mapping : undefined; return { task: { taskId, taskName, reviewLevel, size, dependencies, fileScope, taskFolder: resolve(taskFolder), promptPath: resolve(promptPath), areaName, status: "pending", ...(promptRepoId ? { promptRepoId } : {}), ...(explicitSegmentDag ? { explicitSegmentDag } : {}), ...(stepSegmentMap ? { stepSegmentMap } : {}), }, error: null, warnings: stepSegResult.warnings, }; } // ── Area Scanning ──────────────────────────────────────────────────── /** * TP-196 / #462 — Discovery safeguard for `.DONE` authority drift. * * Discovery has no access to persisted segment state, so it cannot make a * hard `.DONE` vs. segment-frontier authority decision (that lives in the * monitor/resume guards). What it CAN do cheaply is detect the most common * symptom of a stale or premature `.DONE`: a `.DONE` file exists alongside * a STATUS.md that still has unchecked checkboxes. When that pattern is * found, emit a one-line `console.warn` so operators see the inconsistency * during scan. Behaviour of `scanAreaForTasks` is unchanged — the task is * still skipped — this is a doctor-style warning only. * * Returns `true` when the safeguard issued a warning (used by tests). */ export function checkDoneAuthoritySafeguard( taskFolder: string, logger: (msg: string) => void = console.warn, ): boolean { const statusPath = join(taskFolder, "STATUS.md"); if (!existsSync(statusPath)) return false; let content: string; try { content = readFileSync(statusPath, "utf-8"); } catch { return false; } // Look for any unchecked checkbox `- [ ]` on its own line. const hasUnchecked = /^\s*-\s*\[\s\]\s+/m.test(content); if (!hasUnchecked) return false; logger( `[discovery] WARN: .DONE present in ${taskFolder} but STATUS.md contains unchecked checkboxes — possible stale/premature .DONE (#462 safeguard).`, ); return true; } /** * Scan an area path for pending tasks. * * Lists immediate subdirectories only (no recursion). * Skips "archive" directories and folders with .DONE files. * Parses PROMPT.md in each remaining subdirectory. */ export function scanAreaForTasks( areaPath: string, areaName: string, ): { tasks: ParsedTask[]; errors: DiscoveryError[] } { const tasks: ParsedTask[] = []; const errors: DiscoveryError[] = []; const resolvedPath = resolve(areaPath); if (!existsSync(resolvedPath)) { errors.push({ code: "SCAN_ERROR", message: `Area path does not exist: ${resolvedPath}`, taskPath: resolvedPath, }); return { tasks, errors }; } let entries: string[]; try { entries = readdirSync(resolvedPath); } catch { errors.push({ code: "SCAN_ERROR", message: `Cannot read area directory: ${resolvedPath}`, taskPath: resolvedPath, }); return { tasks, errors }; } for (const entry of entries) { // Skip archive directory if (entry.toLowerCase() === "archive") continue; const entryPath = join(resolvedPath, entry); // Only process directories try { if (!statSync(entryPath).isDirectory()) continue; } catch { continue; } // Skip if .DONE exists (already complete). // TP-196 / #462: doctor-style safeguard — if .DONE coexists with // unchecked checkboxes in STATUS.md, warn so operators can investigate // before the task is silently treated as complete. if (existsSync(join(entryPath, ".DONE"))) { checkDoneAuthoritySafeguard(entryPath); continue; } // Skip if no PROMPT.md const promptPath = join(entryPath, "PROMPT.md"); if (!existsSync(promptPath)) continue; // Parse PROMPT.md const result = parsePromptForOrchestrator(promptPath, entryPath, areaName); if (result.error) { errors.push(result.error); } if (result.warnings) { errors.push(...result.warnings); } if (result.task) { tasks.push(result.task); } } return { tasks, errors }; } // ── Completed Task Set ─────────────────────────────────────────────── /** * Build a set of completed task IDs by scanning: * 1. archive/ subdirectories for .DONE markers * 2. Active task folders that have .DONE files (caught during scanAreaForTasks skip) * * This set is used only for dependency resolution — completed tasks are never re-executed. */ export function buildCompletedTaskSet(areaPaths: string[]): Set { const completed = new Set(); for (const areaPath of areaPaths) { const resolvedPath = resolve(areaPath); if (!existsSync(resolvedPath)) continue; let entries: string[]; try { entries = readdirSync(resolvedPath); } catch { continue; } for (const entry of entries) { const entryPath = join(resolvedPath, entry); try { if (!statSync(entryPath).isDirectory()) continue; } catch { continue; } if (entry.toLowerCase() === "archive") { // Scan archive subdirectories for completed tasks let archiveEntries: string[]; try { archiveEntries = readdirSync(entryPath); } catch { continue; } for (const archiveEntry of archiveEntries) { const archiveFolderPath = join(entryPath, archiveEntry); try { if (!statSync(archiveFolderPath).isDirectory()) continue; } catch { continue; } // Only treat archive tasks as complete when .DONE marker exists if (!existsSync(join(archiveFolderPath, ".DONE"))) continue; const taskId = extractTaskIdFromFolderName(archiveEntry); if (taskId) { completed.add(taskId); } } } else { // Active folder with .DONE = completed if (existsSync(join(entryPath, ".DONE"))) { const taskId = extractTaskIdFromFolderName(entry); if (taskId) { completed.add(taskId); } } } } } return completed; } // ── Argument Resolution ────────────────────────────────────────────── /** * Resolve command arguments into area scan paths and direct task folders. * * Accepts mixed arguments: * - "all" → all areas from task_areas * - area name → looked up in task_areas * - directory path → used as-is * - PROMPT.md path → single task (dirname used as task folder) */ export function resolveArguments( args: string, taskAreas: Record, cwd: string, ): { areaScanPaths: string[]; directTaskFolders: string[]; errors: DiscoveryError[] } { const areaScanPaths: string[] = []; const directTaskFolders: string[] = []; const errors: DiscoveryError[] = []; const tokens = args.trim().split(/\s+/).filter(Boolean); for (const token of tokens) { if (token.toLowerCase() === "all") { // Expand to all areas for (const area of Object.values(taskAreas)) { const fullPath = resolve(cwd, area.path); if (!areaScanPaths.includes(fullPath)) { areaScanPaths.push(fullPath); } } } else if (taskAreas[token]) { // Known area name const fullPath = resolve(cwd, taskAreas[token].path); if (!areaScanPaths.includes(fullPath)) { areaScanPaths.push(fullPath); } } else if (token.endsWith("PROMPT.md") && existsSync(resolve(cwd, token))) { // Single PROMPT.md file directTaskFolders.push(resolve(cwd, dirname(token))); } else if (existsSync(resolve(cwd, token))) { // Directory path const fullPath = resolve(cwd, token); try { if (statSync(fullPath).isDirectory()) { if (!areaScanPaths.includes(fullPath)) { areaScanPaths.push(fullPath); } } else { errors.push({ code: "UNKNOWN_ARG", message: `Not a directory or PROMPT.md file: ${token}`, }); } } catch { errors.push({ code: "UNKNOWN_ARG", message: `Cannot stat path: ${token}`, }); } } else { errors.push({ code: "UNKNOWN_ARG", message: `Unknown area, path, or file: "${token}"`, }); } } return { areaScanPaths, directTaskFolders, errors }; } export interface DiscoveryOptions { refreshDependencies?: boolean; dependencySource?: "prompt" | "agent"; useDependencyCache?: boolean; /** Workspace config for repo routing (null/undefined = repo mode, no routing). */ workspaceConfig?: WorkspaceConfig | null; } export interface DependencyCacheFile { version: number; generatedAt: string; source: string; tasks: Record; } export function normalizePathForCompare(p: string): string { return resolve(p).replace(/\\/g, "/").toLowerCase(); } export function isPathWithin(childPath: string, parentPath: string): boolean { const child = normalizePathForCompare(childPath); const parent = normalizePathForCompare(parentPath); return child === parent || child.startsWith(`${parent}/`); } export function dedupeAndNormalizeDeps(deps: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const dep of deps) { const norm = normalizeDependencyReference(dep); if (!norm || seen.has(norm)) continue; seen.add(norm); out.push(norm); } return out; } export function loadAreaDependencyCache(areaPath: string): DependencyCacheFile | null { const cachePath = join(areaPath, "dependencies.json"); if (!existsSync(cachePath)) return null; try { const raw = readFileSync(cachePath, "utf-8"); const parsed = JSON.parse(raw) as DependencyCacheFile; if (!parsed || typeof parsed !== "object" || !parsed.tasks) return null; return parsed; } catch { return null; } } export function writeAreaDependencyCache( areaPath: string, pending: Map, source: "prompt" | "agent", ): void { const tasks: Record = {}; for (const task of pending.values()) { if (!isPathWithin(task.taskFolder, areaPath)) continue; tasks[task.taskId] = dedupeAndNormalizeDeps(task.dependencies); } const cachePath = join(areaPath, "dependencies.json"); const payload: DependencyCacheFile = { version: 1, generatedAt: new Date().toISOString(), source, tasks, }; try { // Keep deterministic formatting for easy diffs const json = JSON.stringify(payload, null, 2); writeFileSync(cachePath, `${json}\n`, "utf-8"); } catch { // Non-fatal: discovery should still succeed without cache persistence } } export function applyDependenciesFromCache( discovery: DiscoveryResult, areaScanPaths: string[], ): { applied: boolean } { let applied = false; for (const areaPath of areaScanPaths) { const cache = loadAreaDependencyCache(areaPath); if (!cache) continue; for (const task of discovery.pending.values()) { if (!isPathWithin(task.taskFolder, areaPath)) continue; const cachedDeps = cache.tasks[task.taskId]; if (!cachedDeps) continue; task.dependencies = dedupeAndNormalizeDeps(cachedDeps); applied = true; } } return { applied }; } // ── Task Registry ──────────────────────────────────────────────────── /** * Build the full task registry: pending tasks + completed set. * * Enforces global uniqueness of task IDs across all areas. * If duplicates are found, returns a fail-fast error listing all collision locations. */ export function buildTaskRegistry( areaScanPaths: string[], directTaskFolders: string[], taskAreas: Record, cwd: string, ): DiscoveryResult { const pending = new Map(); const errors: DiscoveryError[] = []; // Track all locations per task ID for duplicate detection const idLocations = new Map(); function trackId(taskId: string, location: string) { const existing = idLocations.get(taskId) || []; existing.push(location); idLocations.set(taskId, existing); } // Resolve area names for scan paths const areaNameByPath = new Map(); for (const [name, area] of Object.entries(taskAreas)) { areaNameByPath.set(resolve(cwd, area.path), name); } // 1. Scan area paths for pending tasks for (const areaPath of areaScanPaths) { const areaName = areaNameByPath.get(areaPath) || basename(areaPath); const result = scanAreaForTasks(areaPath, areaName); errors.push(...result.errors); for (const task of result.tasks) { trackId(task.taskId, task.promptPath); pending.set(task.taskId, task); } } // 2. Process direct task folders (single PROMPT.md files) for (const taskFolder of directTaskFolders) { const promptPath = join(taskFolder, "PROMPT.md"); if (!existsSync(promptPath)) { errors.push({ code: "SCAN_ERROR", message: `No PROMPT.md found in direct task folder: ${taskFolder}`, taskPath: taskFolder, }); continue; } // Try to determine area name from path let areaName = "unknown"; for (const [name, area] of Object.entries(taskAreas)) { const resolvedAreaPath = resolve(cwd, area.path); if (taskFolder.startsWith(resolvedAreaPath)) { areaName = name; break; } } // Skip if .DONE exists if (existsSync(join(taskFolder, ".DONE"))) continue; const result = parsePromptForOrchestrator(promptPath, taskFolder, areaName); if (result.error) { errors.push(result.error); } if (result.warnings) { errors.push(...result.warnings); } if (result.task) { trackId(result.task.taskId, result.task.promptPath); pending.set(result.task.taskId, result.task); } } // 3. Build completed task set from all scanned areas const completed = buildCompletedTaskSet(areaScanPaths); // Also scan all task_areas for completed tasks (needed for cross-area dep resolution) const allAreaPaths = Object.values(taskAreas).map((a) => resolve(cwd, a.path)); const globalCompleted = buildCompletedTaskSet(allAreaPaths); for (const id of globalCompleted) { completed.add(id); } // 4. Check for duplicate task IDs (global uniqueness enforcement) for (const [taskId, locations] of idLocations) { if (locations.length > 1) { errors.push({ code: "DUPLICATE_ID", message: `Duplicate task ID "${taskId}" found in ${locations.length} locations:\n` + locations.map((l) => ` - ${l}`).join("\n"), taskId, }); } } return { pending, completed, errors }; } // ── Cross-Area Dependency Resolution ───────────────────────────────── /** Candidate match for a dependency reference found in task areas. */ export interface DependencyCandidate { areaName: string; path: string; status: "pending" | "complete"; } export function findDependencyCandidates( depRef: DependencyRef, taskAreas: Record, cwd: string, ): DependencyCandidate[] { const candidates: DependencyCandidate[] = []; const sortedAreas = Object.entries(taskAreas).sort((a, b) => a[0].localeCompare(b[0])); for (const [areaName, area] of sortedAreas) { if (depRef.areaName && depRef.areaName !== areaName.toLowerCase()) { continue; } const areaPath = resolve(cwd, area.path); if (!existsSync(areaPath)) continue; let entries: string[]; try { entries = readdirSync(areaPath); } catch { continue; } // Active tasks (skip archive) for (const entry of entries) { if (entry.toLowerCase() === "archive") continue; const entryTaskId = extractTaskIdFromFolderName(entry); if (entryTaskId !== depRef.taskId) continue; const entryPath = join(areaPath, entry); try { if (!statSync(entryPath).isDirectory()) continue; } catch { continue; } candidates.push({ areaName, path: entryPath, status: existsSync(join(entryPath, ".DONE")) ? "complete" : "pending", }); } // Archived tasks (require .DONE marker) const archivePath = join(areaPath, "archive"); if (!existsSync(archivePath)) continue; try { const archiveEntries = readdirSync(archivePath); for (const archiveEntry of archiveEntries) { const entryTaskId = extractTaskIdFromFolderName(archiveEntry); if (entryTaskId !== depRef.taskId) continue; const archiveTaskPath = join(archivePath, archiveEntry); candidates.push({ areaName, path: archiveTaskPath, status: existsSync(join(archiveTaskPath, ".DONE")) ? "complete" : "pending", }); } } catch { // Ignore archive read errors for discovery resilience } } return candidates; } /** * Resolve dependencies for all pending tasks. * * Supports both dependency formats: * - TASK-ID (unqualified) * - area-name/TASK-ID (area-qualified) */ export function resolveDependencies( discovery: DiscoveryResult, taskAreas: Record, cwd: string, ): DiscoveryError[] { const errors: DiscoveryError[] = []; for (const [taskId, task] of discovery.pending) { for (const depRaw of task.dependencies) { const depRef = parseDependencyReference(depRaw); const depId = depRef.taskId; // Fast path for unqualified refs already in registry if (!depRef.areaName) { if (discovery.pending.has(depId)) continue; if (discovery.completed.has(depId)) continue; } else { const pendingTask = discovery.pending.get(depId); if (pendingTask && pendingTask.areaName.toLowerCase() === depRef.areaName) { continue; } } const candidates = findDependencyCandidates(depRef, taskAreas, cwd); if (candidates.length === 0) { errors.push({ code: "DEP_UNRESOLVED", message: `${taskId} depends on ${depRaw} which does not exist in any task area`, taskId, taskPath: task.promptPath, }); continue; } if (!depRef.areaName && candidates.length > 1) { const options = candidates .map((c) => ` - ${c.areaName}/${depId} [${c.status}] (${c.path})`) .join("\n"); errors.push({ code: "DEP_AMBIGUOUS", message: `${taskId} depends on ${depId}, but multiple tasks match across areas. ` + `Use an area-qualified dependency (area/${depId}).\n${options}`, taskId, taskPath: task.promptPath, }); continue; } if (depRef.areaName && candidates.length > 1) { const options = candidates .map((c) => ` - ${c.areaName}/${depId} [${c.status}] (${c.path})`) .join("\n"); errors.push({ code: "DEP_AMBIGUOUS", message: `${taskId} depends on ${depRaw}, but multiple matching task folders were found. ` + `Resolve duplicate task IDs.\n${options}`, taskId, taskPath: task.promptPath, }); continue; } const match = candidates[0]; if (match.status === "complete") { discovery.completed.add(depId); continue; } errors.push({ code: "DEP_PENDING", message: `${taskId} depends on ${depRaw} which is pending in "${match.areaName}". ` + `Include that area: /orch ${match.areaName}`, taskId, taskPath: task.promptPath, }); } } return errors; } // ── Task-to-Repo Routing ───────────────────────────────────────────── /** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */ const ROUTING_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; /** * Resolve the target repo for each discovered task using the routing * precedence chain: * * 1. `task.promptRepoId` — declared in PROMPT.md metadata * 2. `taskArea.repoId` — area-level config from task-runner.yaml * 3. `workspaceConfig.routing.defaultRepo` — workspace-level default * * Only applied in workspace mode (when `workspaceConfig` is provided). * In repo mode this function is never called. * * Returns an array of DiscoveryError for routing failures: * - TASK_REPO_UNRESOLVED: no source provided a repo ID * - TASK_REPO_UNKNOWN: resolved repo ID is not in workspace repos map */ export function resolveTaskRouting( discovery: DiscoveryResult, taskAreas: Record, workspaceConfig: WorkspaceConfig, ): DiscoveryError[] { const errors: DiscoveryError[] = []; const validRepoIds = workspaceConfig.repos; const strictMode = workspaceConfig.routing.strict === true; for (const task of discovery.pending.values()) { // ── Explicit segment DAG repo validation (workspace IDs) ─ if (task.explicitSegmentDag) { const unknownRepos = task.explicitSegmentDag.repoIds.filter( (repoId) => !validRepoIds.has(repoId), ); if (unknownRepos.length > 0) { errors.push({ code: "SEGMENT_REPO_UNKNOWN", message: `Task ${task.taskId} declares unknown repo ID(s) in ## Segment DAG: ${unknownRepos.join(", ")}. ` + `Known repos: ${[...validRepoIds.keys()].join(", ")}`, taskId: task.taskId, taskPath: task.promptPath, }); continue; } } // ── Strict mode enforcement ────────────────────────────── // When strict routing is enabled, every task MUST declare an // explicit execution target in PROMPT.md. Area-level and // workspace-default fallbacks are NOT used for resolution. if (strictMode && !task.promptRepoId) { errors.push({ code: "TASK_ROUTING_STRICT", message: `Task ${task.taskId} has no explicit execution target, but strict routing is enabled ` + `(routing.strict: true in workspace config). ` + `Add an execution target to the task's PROMPT.md:\n` + `\n` + ` ## Execution Target\n` + `\n` + ` Repo: \n` + `\n` + `Available repos: ${[...validRepoIds.keys()].join(", ")}`, taskId: task.taskId, taskPath: task.promptPath, }); continue; } // Precedence 1: prompt-declared repo let resolvedId = task.promptRepoId; let source = "prompt"; // Precedence 2: area-level repo if (!resolvedId) { const area = taskAreas[task.areaName]; if (area?.repoId) { const candidate = area.repoId.trim().toLowerCase(); if (ROUTING_REPO_ID_PATTERN.test(candidate)) { resolvedId = candidate; source = "area"; } } } // Precedence 3: file scope inference — match file path prefixes against // known workspace repo IDs. If file scope entries like "web-client/src/..." // start with a repo name, route the task to that repo. if (!resolvedId && task.fileScope && task.fileScope.length > 0) { const repoIds = [...validRepoIds.keys()]; const repoCounts = new Map(); for (const filePath of task.fileScope) { const normalized = filePath.replace(/\\/g, "/"); for (const repoId of repoIds) { if (normalized.startsWith(repoId + "/") || normalized === repoId) { repoCounts.set(repoId, (repoCounts.get(repoId) || 0) + 1); break; // first matching repo wins for this path } } } // Use the repo with the most file scope matches (majority vote) if (repoCounts.size === 1) { resolvedId = repoCounts.keys().next().value!; source = "file-scope"; } else if (repoCounts.size > 1) { // Multiple repos in file scope — pick the one with most entries. // (Future: #51 will handle multi-repo tasks properly) let maxCount = 0; for (const [repoId, count] of repoCounts) { if (count > maxCount) { maxCount = count; resolvedId = repoId; } } source = "file-scope"; } } // Precedence 4: workspace default repo if (!resolvedId) { resolvedId = workspaceConfig.routing.defaultRepo; source = "default"; } // Validate resolution if (!resolvedId) { errors.push({ code: "TASK_REPO_UNRESOLVED", message: `Task ${task.taskId} has no resolved repo. ` + `Add file scope paths prefixed with the repo name (e.g., "web-client/src/..."), ` + `set repo_id on area "${task.areaName}", ` + `or set routing.default_repo in the workspace config.`, taskId: task.taskId, taskPath: task.promptPath, }); continue; } if (!validRepoIds.has(resolvedId)) { errors.push({ code: "TASK_REPO_UNKNOWN", message: `Task ${task.taskId} resolved to repo "${resolvedId}" (via ${source}), ` + `but no repo with that ID exists in the workspace config. ` + `Known repos: ${[...validRepoIds.keys()].join(", ")}`, taskId: task.taskId, taskPath: task.promptPath, }); continue; } // Attach resolved repo to the task task.resolvedRepoId = resolvedId; // ── Step-segment mapping: resolve placeholders and validate repo IDs (TP-173) ── if (task.stepSegmentMap) { const knownRepoList = [...validRepoIds.keys()].join(", "); for (const step of task.stepSegmentMap) { for (const seg of step.segments) { // Replace placeholder with resolved primary repo if (seg.repoId === SEGMENT_FALLBACK_REPO_PLACEHOLDER) { seg.repoId = resolvedId; continue; } // Validate explicit segment repoIds against workspace repos if (!validRepoIds.has(seg.repoId)) { const knownRepos = [...validRepoIds.keys()]; const suggestions = suggestRepoMatches(seg.repoId, knownRepos); const suggestionHint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : ""; errors.push({ code: "SEGMENT_STEP_REPO_INVALID", message: `Task ${task.taskId} Step ${step.stepNumber} has segment repo "${seg.repoId}" ` + `which is not in the workspace config. Known repos: ${knownRepoList}.${suggestionHint}`, taskId: task.taskId, taskPath: task.promptPath, }); } } // Duplicate detection for post-placeholder resolution is handled // by the shared pass in runDiscovery() (Step 7). } } } return errors; } // ── Discovery Pipeline (Public) ────────────────────────────────────── /** * Run the full discovery pipeline: * 1. Resolve arguments to scan paths and direct task folders * 2. Build task registry (scan, parse, deduplicate) * 3. Resolve cross-area dependencies * * Returns a DiscoveryResult with pending tasks, completed set, and any errors. */ export function runDiscovery( args: string, taskAreas: Record, cwd: string, options: DiscoveryOptions = {}, ): DiscoveryResult { const dependencySource = options.dependencySource ?? "prompt"; const useDependencyCache = options.useDependencyCache ?? false; const refreshDependencies = options.refreshDependencies ?? false; // Step 1: Resolve arguments const resolved = resolveArguments(args, taskAreas, cwd); if (resolved.errors.length > 0) { return { pending: new Map(), completed: new Set(), errors: resolved.errors, }; } if (resolved.areaScanPaths.length === 0 && resolved.directTaskFolders.length === 0) { return { pending: new Map(), completed: new Set(), errors: [ { code: "UNKNOWN_ARG", message: "No valid areas, paths, or PROMPT.md files found in arguments", }, ], }; } // Step 2: Build task registry (prompt-parsed dependencies as baseline) const discovery = buildTaskRegistry( resolved.areaScanPaths, resolved.directTaskFolders, taskAreas, cwd, ); // If we have duplicate ID errors, stop early (fail-fast) const duplicateErrors = discovery.errors.filter((e) => e.code === "DUPLICATE_ID"); if (duplicateErrors.length > 0) { return discovery; } // Step 3: Dependency source + cache policy // TS-004 scaffold supports prompt parsing and cached dependency maps. // Agent-based analysis is deferred to later tasks; when selected, we // attempt cache first and fall back to prompt parsing if unavailable. let effectiveDependencySource: "prompt" | "agent" = dependencySource; if (useDependencyCache && !refreshDependencies) { const { applied } = applyDependenciesFromCache(discovery, resolved.areaScanPaths); if (dependencySource === "agent" && !applied) { effectiveDependencySource = "prompt"; discovery.errors.push({ code: "DEP_SOURCE_FALLBACK", message: "dependencies.source=agent requested, but no dependency cache was found for " + "the selected areas. Falling back to PROMPT.md dependencies.", }); } } else if (dependencySource === "agent") { effectiveDependencySource = "prompt"; discovery.errors.push({ code: "DEP_SOURCE_FALLBACK", message: "dependencies.source=agent requested, but agent-based dependency analysis " + "is not implemented in TS-004 scaffold. Falling back to PROMPT.md dependencies.", }); } // Step 4: Resolve cross-area dependencies using effective dependencies const depErrors = resolveDependencies(discovery, taskAreas, cwd); discovery.errors.push(...depErrors); // Step 5: Persist cache (if enabled) for next run / non-refresh runs if (useDependencyCache) { for (const areaPath of resolved.areaScanPaths) { writeAreaDependencyCache(areaPath, discovery.pending, effectiveDependencySource); } } // Step 6: Task-to-repo routing (workspace mode only) const workspaceConfig = options.workspaceConfig; if (workspaceConfig && workspaceConfig.mode === "workspace") { const routingErrors = resolveTaskRouting(discovery, taskAreas, workspaceConfig); discovery.errors.push(...routingErrors); } else { // Repo mode: resolve any placeholder fallback repo IDs to "default" // (single-repo mode has no workspace routing, so the placeholder // must be normalized here for backward compatibility). for (const task of discovery.pending.values()) { if (!task.stepSegmentMap) continue; for (const step of task.stepSegmentMap) { for (const seg of step.segments) { if (seg.repoId === SEGMENT_FALLBACK_REPO_PLACEHOLDER) { seg.repoId = "default"; } } } } } // Step 7: Post-normalization duplicate segment detection (TP-173) // After all placeholder resolution (workspace or repo mode), check each // step for duplicate repoIds that may have emerged from placeholder → real ID. for (const task of discovery.pending.values()) { if (!task.stepSegmentMap) continue; for (const step of task.stepSegmentMap) { const stepRepoIds = step.segments.map((s) => s.repoId); const seen = new Set(); for (const rid of stepRepoIds) { if (seen.has(rid)) { discovery.errors.push({ code: "SEGMENT_STEP_DUPLICATE_REPO", message: `Task ${task.taskId} Step ${step.stepNumber} has duplicate segment repo ID "${rid}" ` + `(after resolving primary repo fallback). A repoId may appear at most once within a step.`, taskId: task.taskId, taskPath: task.promptPath, }); break; } seen.add(rid); } } } return discovery; } /** * Format discovery results as a readable string for display. */ export function formatDiscoveryResults(result: DiscoveryResult): string { const lines: string[] = []; // Summary lines.push(`📋 Discovery Results`); lines.push(` Pending tasks: ${result.pending.size}`); lines.push(` Completed tasks: ${result.completed.size}`); lines.push(""); // List pending tasks grouped by area (deterministic: sorted by area name, then task ID) if (result.pending.size > 0) { const byArea = new Map(); for (const task of result.pending.values()) { const existing = byArea.get(task.areaName) || []; existing.push(task); byArea.set(task.areaName, existing); } lines.push("Pending Tasks:"); const sortedAreas = [...byArea.entries()].sort((a, b) => a[0].localeCompare(b[0])); for (const [area, tasks] of sortedAreas) { lines.push(` ${area}:`); const sortedTasks = [...tasks].sort((a, b) => a.taskId.localeCompare(b.taskId)); for (const task of sortedTasks) { const deps = task.dependencies.length > 0 ? ` → depends on: ${task.dependencies.join(", ")}` : ""; const repo = task.resolvedRepoId ? ` → repo: ${task.resolvedRepoId}` : ""; lines.push(` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`); } } lines.push(""); } // Show errors if (result.errors.length > 0) { const fatalCodes = new Set(FATAL_DISCOVERY_CODES); const fatalErrors = result.errors.filter((e) => fatalCodes.has(e.code)); const warnings = result.errors.filter((e) => !fatalCodes.has(e.code)); if (fatalErrors.length > 0) { lines.push("❌ Errors:"); for (const err of fatalErrors) { lines.push(` [${err.code}] ${err.message}`); } lines.push(""); } if (warnings.length > 0) { lines.push("⚠️ Warnings:"); for (const err of warnings) { lines.push(` [${err.code}] ${err.message}`); } lines.push(""); } } return lines.join("\n"); }