/** * Open Artifact Audit — Cross-type unresolved state scanner * * Scans all .planning/ artifact categories for items with open/unresolved state. * Returns structured JSON for workflow consumption. * Called by: gsd-tools.cjs audit-open * Used by: /gsd:complete-milestone pre-close gate * * ADR-457 build-at-publish: the hand-written bin/lib/audit.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only strict types are added. */ import fs from 'node:fs'; import path from 'node:path'; import { platformReadSync } from './shell-command-projection.cjs'; import { collectSection } from './markdown-sectionizer.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); const { extractFrontmatter } = frontmatter; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; import { requireSafePath, sanitizeForDisplay } from './security.cjs'; // ─── Types ──────────────────────────────────────────────────────────────────── interface DebugSessionItem { slug: string; status: string; updated: string; hypothesis: string; scan_error?: boolean; } interface QuickTaskItem { slug: string; date: string; status: string; description: string; scan_error?: boolean; } interface ThreadItem { slug: string; status: string; updated: string; title: string; scan_error?: boolean; } interface TodoItem { filename: string; priority: string; area: string; summary: string; scan_error?: boolean; _remainder_count?: number; } interface SeedItem { seed_id: string; slug: string; status: string; title: string; scan_error?: boolean; } interface UatGapItem { phase: string; file: string; status: string; open_scenario_count: number; scan_error?: boolean; } interface VerificationGapItem { phase: string; file: string; status: string; scan_error?: boolean; } interface ContextQuestionItem { phase: string; file: string; question_count: number; questions: string[]; scan_error?: boolean; } interface DeferredItem { phase: string; file: string; text: string; scan_error?: boolean; } /** * Minimal structural view of `uat.cjs` — only the export `scanDeferredItems` * lazily requires. Mirrors the local-interface convention in * `audit-command-router.cts`, which types its lazy requires the same way. */ interface UatDeferredModule { parseDeferredItems(content: string): Array<{ name: string }>; } interface AuditCounts { debug_sessions: number; quick_tasks: number; threads: number; todos: number; seeds: number; uat_gaps: number; verification_gaps: number; context_questions: number; deferred_items: number; total: number; } interface AuditResult { scanned_at: string; has_open_items: boolean; counts: AuditCounts; items: { debug_sessions: DebugSessionItem[]; quick_tasks: QuickTaskItem[]; threads: ThreadItem[]; todos: TodoItem[]; seeds: SeedItem[]; uat_gaps: UatGapItem[]; verification_gaps: VerificationGapItem[]; context_questions: ContextQuestionItem[]; deferred_items: DeferredItem[]; }; } // The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared // verbatim with the #2287 phase-boundary reader in `uat.cts`. const DEFERRED_ITEMS_FILENAME = 'deferred-items.md'; // Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure // per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is // not recreated on each loop iteration. const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']); // ─── scanDebugSessions ──────────────────────────────────────────────────────── /** * Scan .planning/debug/ for open sessions. * Open = status NOT in ['resolved', 'complete']. * Ignores the resolved/ subdirectory. */ function scanDebugSessions(planDir: string): DebugSessionItem[] { const debugDir = path.join(planDir, 'debug'); if (!fs.existsSync(debugDir)) return []; const results: DebugSessionItem[] = []; let files: fs.Dirent[]; try { files = fs.readdirSync(debugDir, { withFileTypes: true }); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }]; } for (const entry of files) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; const filePath = path.join(debugDir, entry.name); let safeFilePath: string; try { safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeFilePath); if (content === null) continue; const fm = extractFrontmatter(content, safeFilePath); const status = ((fm.status as string) || 'unknown').toLowerCase(); if (status === 'resolved' || status === 'complete') continue; // Extract hypothesis from "Current Focus" block if parseable let hypothesis = ''; const focusSection = collectSection(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('current focus'), { levelBounded: true }); if (focusSection) { const focusText = focusSection.body.trim().split('\n')[0].trim(); hypothesis = sanitizeForDisplay(focusText.slice(0, 100)); } const slug = path.basename(entry.name, '.md'); results.push({ slug: sanitizeForDisplay(slug), status: sanitizeForDisplay(status), updated: sanitizeForDisplay(fm.updated || fm.date || ''), hypothesis, }); } return results; } // ─── scanQuickTasks ─────────────────────────────────────────────────────────── /** * Scan .planning/quick/ for incomplete tasks. * Incomplete if SUMMARY.md missing or status !== 'complete'. */ function scanQuickTasks(planDir: string): QuickTaskItem[] { const quickDir = path.join(planDir, 'quick'); if (!fs.existsSync(quickDir)) return []; let entries: fs.Dirent[]; try { entries = fs.readdirSync(quickDir, { withFileTypes: true }); } catch { return [{ scan_error: true, slug: '', date: '', status: '', description: '' }]; } const results: QuickTaskItem[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const dirName = entry.name; const taskDir = path.join(quickDir, dirName); let safeTaskDir: string; try { safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true }); } catch { continue; } // workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used // bare `SUMMARY.md`. Accept either to avoid false-positive "missing". // // #3183 (ADR-3180 Decision 4(a) — bucket B, out of scope for the // scanPhasePlans migration): this scans a quick task's OWN directory // (`.planning/quick//`) for THAT task's single completion record — // "does this one quick task have a SUMMARY.md" — not a phase directory's // live-plan/summary counting question. scanPhasePlans is the wrong tool // here; there is no plan/summary PAIRING to derive, only a single // filename presence check local to a non-phase directory. let summaryPath: string | null = null; try { const summaryFiles = fs.readdirSync(safeTaskDir, { withFileTypes: true }) .filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md'))); if (summaryFiles.length > 0) { // Prefer the per-task `${quick_id}-SUMMARY.md` form when present. const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`) || summaryFiles.find(e => e.name.endsWith('-SUMMARY.md')) || summaryFiles[0]; summaryPath = path.join(safeTaskDir, preferred.name); } } catch { // fall through with summaryPath = null → status: missing } let status = 'missing'; const description = ''; if (summaryPath && fs.existsSync(summaryPath)) { let safeSum: string; try { safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeSum); if (content === null) { status = 'unreadable'; } else { const fm = extractFrontmatter(content, safeSum); status = ((fm.status as string) || 'unknown').toLowerCase(); } } if (status === 'complete') continue; // Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug let date = ''; let slug = sanitizeForDisplay(dirName); const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/); if (dateMatch) { date = dateMatch[1]; slug = sanitizeForDisplay(dateMatch[2]); } results.push({ slug, date, status: sanitizeForDisplay(status), description, }); } return results; } // ─── scanThreads ────────────────────────────────────────────────────────────── /** * Scan .planning/threads/ for open threads. * Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive). */ function scanThreads(planDir: string): ThreadItem[] { const threadsDir = path.join(planDir, 'threads'); if (!fs.existsSync(threadsDir)) return []; let files: fs.Dirent[]; try { files = fs.readdirSync(threadsDir, { withFileTypes: true }); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }]; } const openStatuses = new Set(['open', 'in_progress', 'in progress']); const results: ThreadItem[] = []; for (const entry of files) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; const filePath = path.join(threadsDir, entry.name); let safeFilePath: string; try { safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeFilePath); if (content === null) continue; const fm = extractFrontmatter(content, safeFilePath); let status = ((fm.status as string) || '').toLowerCase().trim(); // Fall back to scanning body for ## Status: OPEN / IN PROGRESS if (!status) { const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i); if (bodyStatusMatch) { status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_'); } } if (!openStatuses.has(status)) continue; // Extract title from # Thread: heading or frontmatter title let title = sanitizeForDisplay(fm.title || ''); if (!title) { const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m); if (headingMatch) { title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100)); } } const slug = path.basename(entry.name, '.md'); results.push({ slug: sanitizeForDisplay(slug), status: sanitizeForDisplay(status), updated: sanitizeForDisplay(fm.updated || fm.date || ''), title, }); } return results; } // ─── scanTodos ──────────────────────────────────────────────────────────────── /** * Scan .planning/todos/pending/ for pending todos. * Returns array of { filename, priority, area, summary }. * Display limited to first 5 + count of remainder. */ function scanTodos(planDir: string): TodoItem[] { const pendingDir = path.join(planDir, 'todos', 'pending'); if (!fs.existsSync(pendingDir)) return []; let files: fs.Dirent[]; try { files = fs.readdirSync(pendingDir, { withFileTypes: true }); } catch { return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }]; } const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md')); const results: TodoItem[] = []; const displayFiles = mdFiles.slice(0, 5); for (const entry of displayFiles) { const filePath = path.join(pendingDir, entry.name); let safeFilePath: string; try { safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeFilePath); if (content === null) continue; const fm = extractFrontmatter(content, safeFilePath); // Extract first line of body after frontmatter const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, ''); const firstLine = bodyMatch.trim().split('\n')[0] || ''; const summary = sanitizeForDisplay(firstLine.slice(0, 100)); results.push({ filename: sanitizeForDisplay(entry.name), priority: sanitizeForDisplay(fm.priority || ''), area: sanitizeForDisplay(fm.area || ''), summary, }); } if (mdFiles.length > 5) { results.push({ _remainder_count: mdFiles.length - 5, filename: '', priority: '', area: '', summary: '' }); } return results; } // ─── scanSeeds ──────────────────────────────────────────────────────────────── /** * Scan .planning/seeds/SEED-*.md for unimplemented seeds. * Unimplemented if status in ['dormant', 'active', 'triggered']. */ function scanSeeds(planDir: string): SeedItem[] { const seedsDir = path.join(planDir, 'seeds'); if (!fs.existsSync(seedsDir)) return []; let files: fs.Dirent[]; try { files = fs.readdirSync(seedsDir, { withFileTypes: true }); } catch { return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }]; } const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']); const results: SeedItem[] = []; for (const entry of files) { if (!entry.isFile()) continue; if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue; const filePath = path.join(seedsDir, entry.name); let safeFilePath: string; try { safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeFilePath); if (content === null) continue; const fm = extractFrontmatter(content, safeFilePath); const status = ((fm.status as string) || 'dormant').toLowerCase(); if (!unimplementedStatuses.has(status)) continue; // Extract seed_id from filename or frontmatter const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/); const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md'); const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, '')); let title = sanitizeForDisplay(fm.title || ''); if (!title) { const headingMatch = content.match(/^#\s*(.+)$/m); if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100)); } results.push({ seed_id: sanitizeForDisplay(seed_id), slug, status: sanitizeForDisplay(status), title, }); } return results; } // ─── scanUatGaps ────────────────────────────────────────────────────────────── /** * Scan .planning/phases for UAT gaps (UAT files with status != 'complete'). */ function scanUatGaps(planDir: string): UatGapItem[] { const phasesDir = path.join(planDir, 'phases'); if (!fs.existsSync(phasesDir)) return []; let dirs: string[]; try { dirs = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .sort(); } catch { return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }]; } const results: UatGapItem[] = []; for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; let files: string[]; try { files = fs.readdirSync(phaseDir); } catch { continue; } for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) { const filePath = path.join(phaseDir, file); let safeFilePath: string; try { safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeFilePath); if (content === null) continue; const fm = extractFrontmatter(content, safeFilePath); const status = ((fm.status as string) || 'unknown').toLowerCase(); const result = ((fm.result as string) || '').toLowerCase(); // Also accept `result: all_pass` as a fallback when status is absent // — covers UATs that omit `status:`. if (TERMINAL_UAT_STATUSES.has(status)) continue; if (status === 'unknown' && result === 'all_pass') continue; // Count open scenarios const pendingMatches = (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length; results.push({ phase: sanitizeForDisplay(phaseNum), file: sanitizeForDisplay(file), status: sanitizeForDisplay(status), open_scenario_count: pendingMatches, }); } } return results; } // ─── scanVerificationGaps ───────────────────────────────────────────────────── /** * Scan .planning/phases for VERIFICATION gaps. */ function scanVerificationGaps(planDir: string): VerificationGapItem[] { const phasesDir = path.join(planDir, 'phases'); if (!fs.existsSync(phasesDir)) return []; let dirs: string[]; try { dirs = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .sort(); } catch { return [{ scan_error: true, phase: '', file: '', status: '' }]; } const results: VerificationGapItem[] = []; for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; let files: string[]; try { files = fs.readdirSync(phaseDir); } catch { continue; } for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) { const filePath = path.join(phaseDir, file); let safeFilePath: string; try { safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeFilePath); if (content === null) continue; const fm = extractFrontmatter(content, safeFilePath); const status = ((fm.status as string) || 'unknown').toLowerCase(); if (status !== 'gaps_found' && status !== 'human_needed') continue; results.push({ phase: sanitizeForDisplay(phaseNum), file: sanitizeForDisplay(file), status: sanitizeForDisplay(status), }); } } return results; } // ─── scanContextQuestions ───────────────────────────────────────────────────── /** * Scan .planning/phases for CONTEXT files with open_questions. */ function scanContextQuestions(planDir: string): ContextQuestionItem[] { const phasesDir = path.join(planDir, 'phases'); if (!fs.existsSync(phasesDir)) return []; let dirs: string[]; try { dirs = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .sort(); } catch { return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }]; } const results: ContextQuestionItem[] = []; for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; let files: string[]; try { files = fs.readdirSync(phaseDir); } catch { continue; } for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) { const filePath = path.join(phaseDir, file); let safeFilePath: string; try { safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeFilePath); if (content === null) continue; const fm = extractFrontmatter(content, safeFilePath); // Check frontmatter open_questions field let questions: string[] = []; if (fm.open_questions) { if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) { questions = (fm.open_questions as unknown[]).map(q => sanitizeForDisplay(String(q).slice(0, 200))); } } // Also check for ## Open Questions section in body if (questions.length === 0) { const oqSection = collectSection(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true }); if (oqSection) { const oqBody = oqSection.body.trim(); if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) { const items = oqBody.split('\n') .map((l: string) => l.trim()) .filter((l: string) => l && l !== '-' && l !== '*') .filter((l: string) => /^[-*\d]/.test(l) || l.includes('?')); questions = items.slice(0, 3).map((q: string) => sanitizeForDisplay(q.slice(0, 200))); } } } if (questions.length === 0) continue; results.push({ phase: sanitizeForDisplay(phaseNum), file: sanitizeForDisplay(file), question_count: questions.length, questions: questions.slice(0, 3), }); } } return results; } // ─── scanDeferredItems ──────────────────────────────────────────────────────── /** * Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646). * * The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent * log an out-of-scope discovery here rather than fix it. #2287 made that file * readable at the PHASE boundary (`/gsd-progress` check 7, `audit-uat`); this * scanner closes the remaining reader gap one boundary up, so an entry still * unresolved at MILESTONE close surfaces in the pre-close audit alongside the * other eight categories and the existing `[R]/[A]/[C]` prompt applies to it. * Without this, phase directories archive to `milestones/vX.Y-phases/` (#1871) * and the entry leaves the live tree having never been triaged. * * The resolved/unresolved predicate is NOT reimplemented here: `uat.cjs` * already exports `parseDeferredItems`, which owns the parsing rule (entries * under a `## Deferred Items` level-2 heading, else the whole file fail-safe; * RESOLVED only on an explicit case-insensitive `status: resolved` field). * Duplicating that inequality is how two readers of the same file drift into * disagreeing about what "open" means. The require is deliberately LAZY, * inside the scan, to preserve `audit-command-router.cts`'s property that a * route never loads the module it does not need. */ function scanDeferredItems(planDir: string): DeferredItem[] { const phasesDir = path.join(planDir, 'phases'); if (!fs.existsSync(phasesDir)) return []; let dirs: string[]; try { dirs = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .sort(); } catch { return [{ scan_error: true, phase: '', file: '', text: '' }]; } // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const uat: UatDeferredModule = require('./uat.cjs'); const results: DeferredItem[] = []; for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; const filePath = path.join(phaseDir, DEFERRED_ITEMS_FILENAME); if (!fs.existsSync(filePath)) continue; let safeFilePath: string; try { safeFilePath = requireSafePath(filePath, planDir, 'deferred items file', { allowAbsolute: true }); } catch { continue; } const content = platformReadSync(safeFilePath); if (content === null) continue; for (const item of uat.parseDeferredItems(content)) { results.push({ phase: sanitizeForDisplay(phaseNum), file: DEFERRED_ITEMS_FILENAME, text: sanitizeForDisplay(item.name), }); } } return results; } // ─── auditOpenArtifacts ─────────────────────────────────────────────────────── /** * Main audit function. Scans all .planning/ artifact categories. * * @param cwd - Project root directory * @returns Structured audit result */ function auditOpenArtifacts(cwd: string): AuditResult { const planDir = planningDir(cwd); const debugSessions = (() => { try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }]; } })(); const quickTasks = (() => { try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true, slug: '', date: '', status: '', description: '' }]; } })(); const threads = (() => { try { return scanThreads(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }]; } })(); const todos = (() => { try { return scanTodos(planDir); } catch { return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }]; } })(); const seeds = (() => { try { return scanSeeds(planDir); } catch { return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }]; } })(); const uatGaps = (() => { try { return scanUatGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }]; } })(); const verificationGaps = (() => { try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '' }]; } })(); const contextQuestions = (() => { try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }]; } })(); const deferredItems = (() => { try { return scanDeferredItems(planDir); } catch { return [{ scan_error: true, phase: '', file: '', text: '' }]; } })(); // Count real items (not scan_error sentinels) const countReal = (arr: Array<{ scan_error?: boolean; _remainder_count?: number }>) => arr.filter(i => !i.scan_error && !i._remainder_count).length; const counts: AuditCounts = { debug_sessions: countReal(debugSessions), quick_tasks: countReal(quickTasks), threads: countReal(threads), todos: countReal(todos), seeds: countReal(seeds), uat_gaps: countReal(uatGaps), verification_gaps: countReal(verificationGaps), context_questions: countReal(contextQuestions), deferred_items: countReal(deferredItems), total: 0, }; counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions + counts.deferred_items; return { scanned_at: new Date().toISOString(), has_open_items: counts.total > 0, counts, items: { debug_sessions: debugSessions, quick_tasks: quickTasks, threads, todos, seeds, uat_gaps: uatGaps, verification_gaps: verificationGaps, context_questions: contextQuestions, deferred_items: deferredItems, }, }; } // ─── formatAuditReport ──────────────────────────────────────────────────────── /** * Format the audit result as a human-readable report. * * @param auditResult - Result from auditOpenArtifacts() * @returns Formatted report */ function formatAuditReport(auditResult: AuditResult): string { const { counts, items, has_open_items } = auditResult; const lines: string[] = []; const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; lines.push(hr); lines.push(' Milestone Close: Open Artifact Audit'); lines.push(hr); if (!has_open_items) { lines.push(''); lines.push(' All artifact types clear. Safe to proceed.'); lines.push(''); lines.push(hr); return lines.join('\n'); } // Debug sessions (blocking quality — red) if (counts.debug_sessions > 0) { lines.push(''); lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`); for (const item of items.debug_sessions.filter(i => !i.scan_error)) { const hyp = item.hypothesis ? ` — ${item.hypothesis}` : ''; lines.push(` • ${item.slug} [${item.status}]${hyp}`); } } // UAT gaps (blocking quality — red) if (counts.uat_gaps > 0) { lines.push(''); lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`); for (const item of items.uat_gaps.filter(i => !i.scan_error)) { lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`); } } // Verification gaps (blocking quality — red) if (counts.verification_gaps > 0) { lines.push(''); lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`); for (const item of items.verification_gaps.filter(i => !i.scan_error)) { lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}]`); } } // Quick tasks (incomplete work — yellow) if (counts.quick_tasks > 0) { lines.push(''); lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`); for (const item of items.quick_tasks.filter(i => !i.scan_error)) { const d = item.date ? ` (${item.date})` : ''; lines.push(` • ${item.slug}${d} [${item.status}]`); } } // Todos (incomplete work — yellow) if (counts.todos > 0) { const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count); const remainder = items.todos.find(i => i._remainder_count); lines.push(''); lines.push(`🟡 Pending Todos (${counts.todos} pending)`); for (const item of realTodos) { const area = item.area ? ` [${item.area}]` : ''; const pri = item.priority ? ` (${item.priority})` : ''; lines.push(` • ${item.filename}${area}${pri}`); if (item.summary) lines.push(` ${item.summary}`); } if (remainder) { lines.push(` ... and ${remainder._remainder_count} more`); } } // Threads (deferred decisions — blue) if (counts.threads > 0) { lines.push(''); lines.push(`🔵 Open Threads (${counts.threads} active)`); for (const item of items.threads.filter(i => !i.scan_error)) { const title = item.title ? ` — ${item.title}` : ''; lines.push(` • ${item.slug} [${item.status}]${title}`); } } // Seeds (deferred decisions — blue) if (counts.seeds > 0) { lines.push(''); lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`); for (const item of items.seeds.filter(i => !i.scan_error)) { const title = item.title ? ` — ${item.title}` : ''; lines.push(` • ${item.seed_id} [${item.status}]${title}`); } } // Context questions (deferred decisions — blue) if (counts.context_questions > 0) { lines.push(''); lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`); for (const item of items.context_questions.filter(i => !i.scan_error)) { lines.push(` • Phase ${item.phase}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`); for (const q of item.questions) { lines.push(` - ${q}`); } } } // Deferred items (deferred decisions — blue). Out-of-scope discoveries a // phase agent recorded rather than fixed, still unresolved at close (#2646). if (counts.deferred_items > 0) { lines.push(''); lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved)`); for (const item of items.deferred_items.filter(i => !i.scan_error)) { lines.push(` • Phase ${item.phase}: ${item.text}`); } } lines.push(''); lines.push(hr); lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`); lines.push(hr); return lines.join('\n'); } export = { auditOpenArtifacts, formatAuditReport };