diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index eb4252d5a..744749d03 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -15,7 +15,7 @@ const MCP_PKG = '@modelcontextprotocol/sdk'; async function main(): Promise { const sessionManager = new SessionManager(); - // Create the configured MCP server with all 6 tools + // Create the configured MCP server with all 12 tools (6 session + 6 read-only) const { server } = await createMcpServer(sessionManager); // Dynamic import for StdioServerTransport (same TS subpath workaround) diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 7963926fc..c1b837305 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,5 +1,5 @@ /** - * @gsd-build/mcp-server — MCP server for GSD orchestration. + * @gsd-build/mcp-server — MCP server for GSD orchestration and project state. */ export { SessionManager } from './session-manager.js'; @@ -12,3 +12,17 @@ export type { CostAccumulator, } from './types.js'; export { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js'; + +// Read-only state readers (usable without a running session) +export { readProgress } from './readers/state.js'; +export type { ProgressResult } from './readers/state.js'; +export { readRoadmap } from './readers/roadmap.js'; +export type { RoadmapResult, MilestoneInfo, SliceInfo, TaskInfo } from './readers/roadmap.js'; +export { readHistory } from './readers/metrics.js'; +export type { HistoryResult, MetricsUnit } from './readers/metrics.js'; +export { readCaptures } from './readers/captures.js'; +export type { CapturesResult, CaptureEntry } from './readers/captures.js'; +export { readKnowledge } from './readers/knowledge.js'; +export type { KnowledgeResult, KnowledgeEntry } from './readers/knowledge.js'; +export { runDoctorLite } from './readers/doctor-lite.js'; +export type { DoctorResult, DoctorIssue } from './readers/doctor-lite.js'; diff --git a/packages/mcp-server/src/readers/captures.ts b/packages/mcp-server/src/readers/captures.ts new file mode 100644 index 000000000..9cbd71570 --- /dev/null +++ b/packages/mcp-server/src/readers/captures.ts @@ -0,0 +1,119 @@ +// GSD MCP Server — captures reader +// Copyright (c) 2026 Jeremy McSpadden + +import { readFileSync, existsSync } from 'node:fs'; +import { resolveGsdRoot, resolveRootFile } from './paths.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type CaptureStatus = 'pending' | 'triaged' | 'resolved'; +export type CaptureClassification = + | 'quick-task' | 'inject' | 'defer' | 'replan' | 'note' | 'stop' | 'backtrack'; + +export interface CaptureEntry { + id: string; + text: string; + timestamp: string; + status: CaptureStatus; + classification: CaptureClassification | null; + resolution: string | null; + rationale: string | null; + resolvedAt: string | null; + milestone: string | null; + executed: string | null; +} + +export interface CapturesResult { + captures: CaptureEntry[]; + counts: { + total: number; + pending: number; + resolved: number; + actionable: number; + }; +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +function parseCapturesMarkdown(content: string): CaptureEntry[] { + const entries: CaptureEntry[] = []; + + // Split on H3 headers: ### CAP-xxxxxxxx + const sections = content.split(/(?=^### CAP-)/m); + + for (const section of sections) { + const idMatch = section.match(/^### (CAP-[\da-f]+)/); + if (!idMatch) continue; + + const id = idMatch[1]; + const field = (label: string): string | null => { + const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, 'i'); + const m = section.match(re); + return m ? m[1].trim() : null; + }; + + const status = (field('Status') ?? 'pending').toLowerCase() as CaptureStatus; + const classification = field('Classification') as CaptureClassification | null; + + entries.push({ + id, + text: field('Text') ?? '', + timestamp: field('Captured') ?? '', + status, + classification, + resolution: field('Resolution'), + rationale: field('Rationale'), + resolvedAt: field('Resolved'), + milestone: field('Milestone'), + executed: field('Executed'), + }); + } + + return entries; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +const ACTIONABLE_CLASSIFICATIONS = new Set(['quick-task', 'inject', 'replan']); + +export function readCaptures( + projectDir: string, + filter: 'all' | 'pending' | 'actionable' = 'all', +): CapturesResult { + const gsd = resolveGsdRoot(projectDir); + const capturesPath = resolveRootFile(gsd, 'CAPTURES.md'); + + if (!existsSync(capturesPath)) { + return { captures: [], counts: { total: 0, pending: 0, resolved: 0, actionable: 0 } }; + } + + const content = readFileSync(capturesPath, 'utf-8'); + let captures = parseCapturesMarkdown(content); + + // Compute counts before filtering + const counts = { + total: captures.length, + pending: captures.filter((c) => c.status === 'pending').length, + resolved: captures.filter((c) => c.status === 'resolved').length, + actionable: captures.filter( + (c) => c.classification !== null && ACTIONABLE_CLASSIFICATIONS.has(c.classification), + ).length, + }; + + // Apply filter + if (filter === 'pending') { + captures = captures.filter((c) => c.status === 'pending'); + } else if (filter === 'actionable') { + captures = captures.filter( + (c) => c.classification !== null && ACTIONABLE_CLASSIFICATIONS.has(c.classification), + ); + } + + return { captures, counts }; +} diff --git a/packages/mcp-server/src/readers/doctor-lite.ts b/packages/mcp-server/src/readers/doctor-lite.ts new file mode 100644 index 000000000..8b826090c --- /dev/null +++ b/packages/mcp-server/src/readers/doctor-lite.ts @@ -0,0 +1,225 @@ +// GSD MCP Server — lightweight structural health checks +// Copyright (c) 2026 Jeremy McSpadden + +import { existsSync, readFileSync } from 'node:fs'; +import { + resolveGsdRoot, + resolveRootFile, + findMilestoneIds, + resolveMilestoneFile, + resolveMilestoneDir, + findSliceIds, + resolveSliceFile, + findTaskFiles, +} from './paths.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Severity = 'info' | 'warning' | 'error'; + +export interface DoctorIssue { + severity: Severity; + code: string; + scope: 'project' | 'milestone' | 'slice' | 'task'; + unitId: string; + message: string; + file?: string; +} + +export interface DoctorResult { + ok: boolean; + issues: DoctorIssue[]; + counts: { error: number; warning: number; info: number }; +} + +// --------------------------------------------------------------------------- +// Check implementations +// --------------------------------------------------------------------------- + +function checkProjectLevel(gsdRoot: string, issues: DoctorIssue[]): void { + // PROJECT.md should exist + const projectPath = resolveRootFile(gsdRoot, 'PROJECT.md'); + if (!existsSync(projectPath)) { + issues.push({ + severity: 'warning', + code: 'missing_project_md', + scope: 'project', + unitId: '', + message: 'PROJECT.md is missing — project lacks a description', + file: projectPath, + }); + } + + // STATE.md should exist if milestones exist + const milestones = findMilestoneIds(gsdRoot); + if (milestones.length > 0) { + const statePath = resolveRootFile(gsdRoot, 'STATE.md'); + if (!existsSync(statePath)) { + issues.push({ + severity: 'warning', + code: 'missing_state_md', + scope: 'project', + unitId: '', + message: 'STATE.md is missing — run /gsd status to regenerate', + file: statePath, + }); + } + } +} + +function checkMilestoneLevel(gsdRoot: string, mid: string, issues: DoctorIssue[]): void { + const mDir = resolveMilestoneDir(gsdRoot, mid); + if (!mDir) { + issues.push({ + severity: 'error', + code: 'missing_milestone_dir', + scope: 'milestone', + unitId: mid, + message: `Milestone directory for ${mid} not found`, + }); + return; + } + + // CONTEXT.md should exist + const ctxPath = resolveMilestoneFile(gsdRoot, mid, 'CONTEXT'); + if (!ctxPath || !existsSync(ctxPath)) { + // Check for draft + const draftPath = resolveMilestoneFile(gsdRoot, mid, 'CONTEXT-DRAFT'); + if (!draftPath || !existsSync(draftPath)) { + issues.push({ + severity: 'warning', + code: 'missing_context', + scope: 'milestone', + unitId: mid, + message: `${mid} has no CONTEXT.md — milestone lacks defined scope`, + }); + } + } + + // ROADMAP.md should exist if slices exist + const sliceIds = findSliceIds(gsdRoot, mid); + if (sliceIds.length > 0) { + const roadmapPath = resolveMilestoneFile(gsdRoot, mid, 'ROADMAP'); + if (!roadmapPath || !existsSync(roadmapPath)) { + issues.push({ + severity: 'warning', + code: 'missing_roadmap', + scope: 'milestone', + unitId: mid, + message: `${mid} has ${sliceIds.length} slices but no ROADMAP.md`, + }); + } + } + + // Check if all slices done but no SUMMARY + if (sliceIds.length > 0) { + const allDone = sliceIds.every((sid) => { + const tasks = findTaskFiles(gsdRoot, mid, sid); + return tasks.length > 0 && tasks.every((t) => t.hasSummary); + }); + const summaryPath = resolveMilestoneFile(gsdRoot, mid, 'SUMMARY'); + if (allDone && (!summaryPath || !existsSync(summaryPath))) { + issues.push({ + severity: 'error', + code: 'all_slices_done_missing_summary', + scope: 'milestone', + unitId: mid, + message: `${mid} has all slices completed but no SUMMARY.md`, + }); + } + } +} + +function checkSliceLevel( + gsdRoot: string, mid: string, sid: string, issues: DoctorIssue[], +): void { + const unitId = `${mid}/${sid}`; + + // PLAN.md should exist + const planPath = resolveSliceFile(gsdRoot, mid, sid, 'PLAN'); + if (!planPath || !existsSync(planPath)) { + issues.push({ + severity: 'error', + code: 'missing_slice_plan', + scope: 'slice', + unitId, + message: `${unitId} has no PLAN.md`, + }); + } + + // Tasks should have plans + const tasks = findTaskFiles(gsdRoot, mid, sid); + for (const task of tasks) { + const taskUnitId = `${unitId}/${task.id}`; + if (!task.hasPlan) { + issues.push({ + severity: 'warning', + code: 'missing_task_plan', + scope: 'task', + unitId: taskUnitId, + message: `${taskUnitId} has a summary but no plan file`, + }); + } + } + + // Check for empty slice (directory exists but no tasks or plan) + if (tasks.length === 0 && (!planPath || !existsSync(planPath))) { + issues.push({ + severity: 'warning', + code: 'empty_slice', + scope: 'slice', + unitId, + message: `${unitId} has no plan and no tasks — may be abandoned`, + }); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function runDoctorLite(projectDir: string, scope?: string): DoctorResult { + const gsdRoot = resolveGsdRoot(projectDir); + const issues: DoctorIssue[] = []; + + if (!existsSync(gsdRoot)) { + return { + ok: true, + issues: [{ + severity: 'info', + code: 'no_gsd_directory', + scope: 'project', + unitId: '', + message: 'No .gsd/ directory found — project not initialized', + }], + counts: { error: 0, warning: 0, info: 1 }, + }; + } + + // Project-level checks + checkProjectLevel(gsdRoot, issues); + + // Milestone + slice checks + const milestoneIds = scope + ? findMilestoneIds(gsdRoot).filter((id) => id === scope) + : findMilestoneIds(gsdRoot); + + for (const mid of milestoneIds) { + checkMilestoneLevel(gsdRoot, mid, issues); + + const sliceIds = findSliceIds(gsdRoot, mid); + for (const sid of sliceIds) { + checkSliceLevel(gsdRoot, mid, sid, issues); + } + } + + const counts = { + error: issues.filter((i) => i.severity === 'error').length, + warning: issues.filter((i) => i.severity === 'warning').length, + info: issues.filter((i) => i.severity === 'info').length, + }; + + return { ok: counts.error === 0, issues, counts }; +} diff --git a/packages/mcp-server/src/readers/index.ts b/packages/mcp-server/src/readers/index.ts new file mode 100644 index 000000000..d5b3368c7 --- /dev/null +++ b/packages/mcp-server/src/readers/index.ts @@ -0,0 +1,16 @@ +// GSD MCP Server — readers barrel export +// Copyright (c) 2026 Jeremy McSpadden + +export { resolveGsdRoot, resolveRootFile } from './paths.js'; +export { readProgress } from './state.js'; +export type { ProgressResult } from './state.js'; +export { readRoadmap } from './roadmap.js'; +export type { RoadmapResult, MilestoneInfo, SliceInfo, TaskInfo } from './roadmap.js'; +export { readHistory } from './metrics.js'; +export type { HistoryResult, MetricsUnit } from './metrics.js'; +export { readCaptures } from './captures.js'; +export type { CapturesResult, CaptureEntry } from './captures.js'; +export { readKnowledge } from './knowledge.js'; +export type { KnowledgeResult, KnowledgeEntry } from './knowledge.js'; +export { runDoctorLite } from './doctor-lite.js'; +export type { DoctorResult, DoctorIssue } from './doctor-lite.js'; diff --git a/packages/mcp-server/src/readers/knowledge.ts b/packages/mcp-server/src/readers/knowledge.ts new file mode 100644 index 000000000..134df44e0 --- /dev/null +++ b/packages/mcp-server/src/readers/knowledge.ts @@ -0,0 +1,111 @@ +// GSD MCP Server — knowledge base reader +// Copyright (c) 2026 Jeremy McSpadden + +import { readFileSync, existsSync } from 'node:fs'; +import { resolveGsdRoot, resolveRootFile } from './paths.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type KnowledgeType = 'rule' | 'pattern' | 'lesson'; + +export interface KnowledgeEntry { + id: string; + type: KnowledgeType; + scope: string; + content: string; + addedAt: string; +} + +export interface KnowledgeResult { + entries: KnowledgeEntry[]; + counts: { rules: number; patterns: number; lessons: number }; +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +function parseTableRows(section: string, type: KnowledgeType): KnowledgeEntry[] { + const entries: KnowledgeEntry[] = []; + const lines = section.split('\n'); + + for (const line of lines) { + if (!line.includes('|')) continue; + const cells = line.split('|').map((c) => c.trim()).filter(Boolean); + if (cells.length < 3) continue; + // Skip header/separator + if (cells[0].startsWith('#') || cells[0].startsWith('-')) continue; + + const id = cells[0]; + if (!/^[KPL]\d+$/i.test(id)) continue; + + if (type === 'rule' && cells.length >= 5) { + entries.push({ + id, type, scope: cells[1], content: cells[2], addedAt: cells[4] ?? '', + }); + } else if (type === 'pattern' && cells.length >= 4) { + entries.push({ + id, type, scope: cells[2] ?? '', content: cells[1], addedAt: cells[3] ?? '', + }); + } else if (type === 'lesson' && cells.length >= 5) { + entries.push({ + id, type, scope: cells[4] ?? '', + content: `${cells[1]} — Root cause: ${cells[2]} — Fix: ${cells[3]}`, + addedAt: '', + }); + } + } + + return entries; +} + +function parseKnowledgeMarkdown(content: string): KnowledgeEntry[] { + const entries: KnowledgeEntry[] = []; + + // Find ## Rules section + const rulesMatch = content.match(/## Rules\s*\n([\s\S]*?)(?=\n## |$)/i); + if (rulesMatch) { + entries.push(...parseTableRows(rulesMatch[1], 'rule')); + } + + // Find ## Patterns section + const patternsMatch = content.match(/## Patterns\s*\n([\s\S]*?)(?=\n## |$)/i); + if (patternsMatch) { + entries.push(...parseTableRows(patternsMatch[1], 'pattern')); + } + + // Find ## Lessons Learned section + const lessonsMatch = content.match(/## Lessons Learned\s*\n([\s\S]*?)(?=\n## |$)/i); + if (lessonsMatch) { + entries.push(...parseTableRows(lessonsMatch[1], 'lesson')); + } + + return entries; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function readKnowledge(projectDir: string): KnowledgeResult { + const gsd = resolveGsdRoot(projectDir); + const knowledgePath = resolveRootFile(gsd, 'KNOWLEDGE.md'); + + if (!existsSync(knowledgePath)) { + return { entries: [], counts: { rules: 0, patterns: 0, lessons: 0 } }; + } + + const content = readFileSync(knowledgePath, 'utf-8'); + const entries = parseKnowledgeMarkdown(content); + + return { + entries, + counts: { + rules: entries.filter((e) => e.type === 'rule').length, + patterns: entries.filter((e) => e.type === 'pattern').length, + lessons: entries.filter((e) => e.type === 'lesson').length, + }, + }; +} diff --git a/packages/mcp-server/src/readers/metrics.ts b/packages/mcp-server/src/readers/metrics.ts new file mode 100644 index 000000000..0b6635ceb --- /dev/null +++ b/packages/mcp-server/src/readers/metrics.ts @@ -0,0 +1,118 @@ +// GSD MCP Server — metrics/history reader +// Copyright (c) 2026 Jeremy McSpadden + +import { readFileSync, existsSync } from 'node:fs'; +import { resolveGsdRoot, resolveRootFile } from './paths.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MetricsUnit { + type: string; + id: string; + model: string; + startedAt: number; + finishedAt: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + cost: number; + toolCalls: number; + apiRequests: number; +} + +export interface HistoryResult { + entries: MetricsUnit[]; + totals: { + cost: number; + tokens: { input: number; output: number; total: number }; + units: number; + durationMs: number; + }; +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +function parseMetricsJson(content: string): MetricsUnit[] { + try { + const data = JSON.parse(content); + if (!data.units || !Array.isArray(data.units)) return []; + + return data.units.map((u: Record) => ({ + type: String(u.type ?? 'unknown'), + id: String(u.id ?? ''), + model: String(u.model ?? 'unknown'), + startedAt: Number(u.startedAt ?? 0), + finishedAt: Number(u.finishedAt ?? 0), + tokens: { + input: Number((u.tokens as Record)?.input ?? 0), + output: Number((u.tokens as Record)?.output ?? 0), + cacheRead: Number((u.tokens as Record)?.cacheRead ?? 0), + cacheWrite: Number((u.tokens as Record)?.cacheWrite ?? 0), + total: Number((u.tokens as Record)?.total ?? 0), + }, + cost: Number(u.cost ?? 0), + toolCalls: Number(u.toolCalls ?? 0), + apiRequests: Number(u.apiRequests ?? 0), + })); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function readHistory(projectDir: string, limit?: number): HistoryResult { + const gsd = resolveGsdRoot(projectDir); + + // metrics.json (primary) + const metricsPath = resolveRootFile(gsd, 'metrics.json'); + let units: MetricsUnit[] = []; + + if (existsSync(metricsPath)) { + const content = readFileSync(metricsPath, 'utf-8'); + units = parseMetricsJson(content); + } + + // Sort by startedAt descending (most recent first) + units.sort((a, b) => b.startedAt - a.startedAt); + + // Apply limit + if (limit && limit > 0) { + units = units.slice(0, limit); + } + + // Compute totals from ALL units (not just limited set) + const allUnits = existsSync(metricsPath) + ? parseMetricsJson(readFileSync(metricsPath, 'utf-8')) + : []; + + const totals = { + cost: 0, + tokens: { input: 0, output: 0, total: 0 }, + units: allUnits.length, + durationMs: 0, + }; + + for (const u of allUnits) { + totals.cost += u.cost; + totals.tokens.input += u.tokens.input; + totals.tokens.output += u.tokens.output; + totals.tokens.total += u.tokens.total; + totals.durationMs += (u.finishedAt - u.startedAt); + } + + // Round cost to 4 decimal places + totals.cost = Math.round(totals.cost * 10000) / 10000; + + return { entries: units, totals }; +} diff --git a/packages/mcp-server/src/readers/paths.ts b/packages/mcp-server/src/readers/paths.ts new file mode 100644 index 000000000..ad0418a36 --- /dev/null +++ b/packages/mcp-server/src/readers/paths.ts @@ -0,0 +1,217 @@ +// GSD MCP Server — .gsd/ directory resolution +// Copyright (c) 2026 Jeremy McSpadden + +import { existsSync, statSync, readdirSync } from 'node:fs'; +import { join, resolve, dirname, basename } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +/** + * Resolve the .gsd/ root directory for a project. + * + * Probes in order: + * 1. projectDir/.gsd (fast path) + * 2. git repo root/.gsd + * 3. Walk up from projectDir + * 4. Fallback: projectDir/.gsd (even if missing — for init) + */ +export function resolveGsdRoot(projectDir: string): string { + const resolved = resolve(projectDir); + + // Fast path: .gsd/ in the given directory + const direct = join(resolved, '.gsd'); + if (existsSync(direct) && statSync(direct).isDirectory()) { + return direct; + } + + // Try git repo root + try { + const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: resolved, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + const gitGsd = join(gitRoot, '.gsd'); + if (existsSync(gitGsd) && statSync(gitGsd).isDirectory()) { + return gitGsd; + } + } catch { + // Not a git repo or git not available + } + + // Walk up from projectDir + let dir = resolved; + while (dir !== dirname(dir)) { + const candidate = join(dir, '.gsd'); + if (existsSync(candidate) && statSync(candidate).isDirectory()) { + return candidate; + } + dir = dirname(dir); + } + + // Fallback + return direct; +} + +/** Resolve path to a .gsd/ root file (STATE.md, KNOWLEDGE.md, etc.) */ +export function resolveRootFile(gsdRoot: string, name: string): string { + return join(gsdRoot, name); +} + +/** Resolve path to milestones directory */ +export function milestonesDir(gsdRoot: string): string { + return join(gsdRoot, 'milestones'); +} + +/** + * Find all milestone directory IDs (M001, M002, etc.). + * Handles both bare (M001/) and descriptor (M001-FLIGHT-SIM/) naming. + */ +export function findMilestoneIds(gsdRoot: string): string[] { + const dir = milestonesDir(gsdRoot); + if (!existsSync(dir)) return []; + + const entries = readdirSync(dir, { withFileTypes: true }); + const ids: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const match = entry.name.match(/^(M\d+)/); + if (match) ids.push(match[1]); + } + + return ids.sort(); +} + +/** + * Resolve the actual directory name for a milestone ID. + * M001 might live in M001/ or M001-SOME-DESCRIPTOR/. + */ +export function resolveMilestoneDir(gsdRoot: string, milestoneId: string): string | null { + const dir = milestonesDir(gsdRoot); + if (!existsSync(dir)) return null; + + // Fast path: exact match + const exact = join(dir, milestoneId); + if (existsSync(exact) && statSync(exact).isDirectory()) return exact; + + // Prefix match + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith(milestoneId)) { + return join(dir, entry.name); + } + } + + return null; +} + +/** + * Resolve a milestone-level file (M001-ROADMAP.md, M001-CONTEXT.md, etc.). + * Handles various naming conventions. + */ +export function resolveMilestoneFile(gsdRoot: string, milestoneId: string, suffix: string): string | null { + const mDir = resolveMilestoneDir(gsdRoot, milestoneId); + if (!mDir) return null; + + const dirName = basename(mDir); + + // Try: M001-ROADMAP.md, then DIRNAME-ROADMAP.md + const candidates = [ + join(mDir, `${milestoneId}-${suffix}.md`), + join(mDir, `${dirName}-${suffix}.md`), + join(mDir, `${suffix}.md`), + ]; + + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +/** Find all slice IDs within a milestone (S01, S02, etc.) */ +export function findSliceIds(gsdRoot: string, milestoneId: string): string[] { + const mDir = resolveMilestoneDir(gsdRoot, milestoneId); + if (!mDir) return []; + + const slicesDir = join(mDir, 'slices'); + if (!existsSync(slicesDir)) return []; + + const entries = readdirSync(slicesDir, { withFileTypes: true }); + const ids: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const match = entry.name.match(/^(S\d+)/); + if (match) ids.push(match[1]); + } + + return ids.sort(); +} + +/** Resolve the actual directory for a slice */ +export function resolveSliceDir(gsdRoot: string, milestoneId: string, sliceId: string): string | null { + const mDir = resolveMilestoneDir(gsdRoot, milestoneId); + if (!mDir) return null; + + const slicesDir = join(mDir, 'slices'); + if (!existsSync(slicesDir)) return null; + + const exact = join(slicesDir, sliceId); + if (existsSync(exact) && statSync(exact).isDirectory()) return exact; + + const entries = readdirSync(slicesDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith(sliceId)) { + return join(slicesDir, entry.name); + } + } + return null; +} + +/** Resolve a slice-level file (S01-PLAN.md, etc.) */ +export function resolveSliceFile( + gsdRoot: string, milestoneId: string, sliceId: string, suffix: string, +): string | null { + const sDir = resolveSliceDir(gsdRoot, milestoneId, sliceId); + if (!sDir) return null; + + const dirName = basename(sDir); + const candidates = [ + join(sDir, `${sliceId}-${suffix}.md`), + join(sDir, `${dirName}-${suffix}.md`), + join(sDir, `${suffix}.md`), + ]; + + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +/** Find all task files in a slice's tasks/ directory */ +export function findTaskFiles( + gsdRoot: string, milestoneId: string, sliceId: string, +): Array<{ id: string; hasPlan: boolean; hasSummary: boolean }> { + const sDir = resolveSliceDir(gsdRoot, milestoneId, sliceId); + if (!sDir) return []; + + const tasksDir = join(sDir, 'tasks'); + if (!existsSync(tasksDir)) return []; + + const files = readdirSync(tasksDir); + const taskMap = new Map(); + + for (const f of files) { + const match = f.match(/^(T\d+).*-(PLAN|SUMMARY)\.md$/i); + if (!match) continue; + const [, id, type] = match; + const existing = taskMap.get(id) ?? { hasPlan: false, hasSummary: false }; + if (type.toUpperCase() === 'PLAN') existing.hasPlan = true; + if (type.toUpperCase() === 'SUMMARY') existing.hasSummary = true; + taskMap.set(id, existing); + } + + return Array.from(taskMap.entries()) + .map(([id, info]) => ({ id, ...info })) + .sort((a, b) => a.id.localeCompare(b.id)); +} diff --git a/packages/mcp-server/src/readers/readers.test.ts b/packages/mcp-server/src/readers/readers.test.ts new file mode 100644 index 000000000..98d157279 --- /dev/null +++ b/packages/mcp-server/src/readers/readers.test.ts @@ -0,0 +1,509 @@ +// GSD MCP Server — reader tests +// Copyright (c) 2026 Jeremy McSpadden + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomBytes } from 'node:crypto'; + +import { readProgress } from './state.js'; +import { readRoadmap } from './roadmap.js'; +import { readHistory } from './metrics.js'; +import { readCaptures } from './captures.js'; +import { readKnowledge } from './knowledge.js'; +import { runDoctorLite } from './doctor-lite.js'; + +// --------------------------------------------------------------------------- +// Test fixture helpers +// --------------------------------------------------------------------------- + +function tmpProject(): string { + const dir = join(tmpdir(), `gsd-mcp-test-${randomBytes(4).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeFixture(base: string, relPath: string, content: string): void { + const full = join(base, relPath); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, content, 'utf-8'); +} + +// --------------------------------------------------------------------------- +// readProgress tests +// --------------------------------------------------------------------------- + +describe('readProgress', () => { + let projectDir: string; + + before(() => { + projectDir = tmpProject(); + + writeFixture(projectDir, '.gsd/STATE.md', `# GSD State + +**Active Milestone:** M002: Auth System +**Active Slice:** S01: Login flow +**Phase:** execution +**Requirements Status:** 5 active · 2 validated · 1 deferred · 0 out of scope + +## Milestone Registry + +- ☑ **M001:** Core Setup +- 🔄 **M002:** Auth System +- ⬜ **M003:** Dashboard + +## Blockers + +- Waiting on OAuth provider approval + +## Next Action + +Execute T02 in S01 — implement token refresh. +`); + + // Create filesystem structure + const m1 = '.gsd/milestones/M001/slices/S01/tasks'; + writeFixture(projectDir, `${m1}/T01-PLAN.md`, '# T01'); + writeFixture(projectDir, `${m1}/T01-SUMMARY.md`, '# T01 done'); + + const m2 = '.gsd/milestones/M002/slices/S01/tasks'; + writeFixture(projectDir, `${m2}/T01-PLAN.md`, '# T01'); + writeFixture(projectDir, `${m2}/T01-SUMMARY.md`, '# T01 done'); + writeFixture(projectDir, `${m2}/T02-PLAN.md`, '# T02'); + + mkdirSync(join(projectDir, '.gsd/milestones/M003'), { recursive: true }); + }); + + after(() => rmSync(projectDir, { recursive: true, force: true })); + + it('parses active milestone from STATE.md', () => { + const result = readProgress(projectDir); + assert.deepEqual(result.activeMilestone, { id: 'M002', title: 'Auth System' }); + }); + + it('parses active slice', () => { + const result = readProgress(projectDir); + assert.deepEqual(result.activeSlice, { id: 'S01', title: 'Login flow' }); + }); + + it('parses phase', () => { + const result = readProgress(projectDir); + assert.equal(result.phase, 'execute'); + }); + + it('parses milestone counts from registry', () => { + const result = readProgress(projectDir); + assert.equal(result.milestones.total, 3); + assert.equal(result.milestones.done, 1); + assert.equal(result.milestones.active, 1); + assert.equal(result.milestones.pending, 1); + }); + + it('counts tasks from filesystem', () => { + const result = readProgress(projectDir); + assert.equal(result.tasks.total, 3); + assert.equal(result.tasks.done, 2); + assert.equal(result.tasks.pending, 1); + }); + + it('parses blockers', () => { + const result = readProgress(projectDir); + assert.equal(result.blockers.length, 1); + assert.ok(result.blockers[0].includes('OAuth')); + }); + + it('parses requirements', () => { + const result = readProgress(projectDir); + assert.equal(result.requirements?.active, 5); + assert.equal(result.requirements?.validated, 2); + assert.equal(result.requirements?.deferred, 1); + }); + + it('parses next action', () => { + const result = readProgress(projectDir); + assert.ok(result.nextAction.includes('T02')); + }); + + it('returns defaults for missing .gsd/', () => { + const empty = tmpProject(); + const result = readProgress(empty); + assert.equal(result.phase, 'unknown'); + assert.equal(result.milestones.total, 0); + rmSync(empty, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// readRoadmap tests +// --------------------------------------------------------------------------- + +describe('readRoadmap', () => { + let projectDir: string; + + before(() => { + projectDir = tmpProject(); + + writeFixture(projectDir, '.gsd/milestones/M001/M001-CONTEXT.md', '# M001: Core Setup\n'); + writeFixture(projectDir, '.gsd/milestones/M001/M001-ROADMAP.md', `# M001: Core Setup + +## Vision + +Build the foundation for the project. + +## Slice Overview + +| ID | Slice | Risk | Depends | Done | After this | +|----|-------|------|---------|------|------------| +| S01 | Database schema | low | — | ☑ | DB ready | +| S02 | API endpoints | medium | S01 | 🟫 | REST API live | +`); + + writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/S01-PLAN.md', `# S01: Database schema + +## Tasks + +- [x] **T01: Create migrations** — Set up schema +- [x] **T02: Seed data** — Initial seed +`); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01'); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 done'); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T02-PLAN.md', '# T02'); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T02-SUMMARY.md', '# T02 done'); + + writeFixture(projectDir, '.gsd/milestones/M001/slices/S02/S02-PLAN.md', `# S02: API endpoints + +## Tasks + +- [ ] **T01: Auth routes** — Implement auth +- [ ] **T02: User routes** — CRUD users +`); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S02/tasks/T01-PLAN.md', '# T01'); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S02/tasks/T02-PLAN.md', '# T02'); + }); + + after(() => rmSync(projectDir, { recursive: true, force: true })); + + it('returns milestone structure', () => { + const result = readRoadmap(projectDir); + assert.equal(result.milestones.length, 1); + assert.equal(result.milestones[0].id, 'M001'); + assert.equal(result.milestones[0].title, 'Core Setup'); + }); + + it('reads vision from roadmap', () => { + const result = readRoadmap(projectDir); + assert.ok(result.milestones[0].vision.includes('foundation')); + }); + + it('parses slices from roadmap table', () => { + const result = readRoadmap(projectDir); + const slices = result.milestones[0].slices; + assert.equal(slices.length, 2); + assert.equal(slices[0].id, 'S01'); + assert.equal(slices[0].title, 'Database schema'); + assert.equal(slices[1].id, 'S02'); + }); + + it('derives slice status from task summaries', () => { + const result = readRoadmap(projectDir); + const slices = result.milestones[0].slices; + assert.equal(slices[0].status, 'done'); + assert.equal(slices[1].status, 'pending'); + }); + + it('includes tasks in slices', () => { + const result = readRoadmap(projectDir); + const s01Tasks = result.milestones[0].slices[0].tasks; + assert.equal(s01Tasks.length, 2); + assert.equal(s01Tasks[0].status, 'done'); + }); + + it('filters by milestoneId', () => { + const result = readRoadmap(projectDir, 'M999'); + assert.equal(result.milestones.length, 0); + }); +}); + +// --------------------------------------------------------------------------- +// readHistory tests +// --------------------------------------------------------------------------- + +describe('readHistory', () => { + let projectDir: string; + + before(() => { + projectDir = tmpProject(); + writeFixture(projectDir, '.gsd/metrics.json', JSON.stringify({ + version: 1, + projectStartedAt: 1700000000000, + units: [ + { + type: 'execute-task', + id: 'M001/S01/T01', + model: 'claude-sonnet-4', + startedAt: 1700001000000, + finishedAt: 1700002000000, + tokens: { input: 10000, output: 3000, cacheRead: 2000, cacheWrite: 1000, total: 16000 }, + cost: 0.05, + toolCalls: 8, + apiRequests: 3, + }, + { + type: 'execute-task', + id: 'M001/S01/T02', + model: 'claude-sonnet-4', + startedAt: 1700003000000, + finishedAt: 1700004000000, + tokens: { input: 15000, output: 5000, cacheRead: 3000, cacheWrite: 1500, total: 24500 }, + cost: 0.08, + toolCalls: 12, + apiRequests: 5, + }, + ], + })); + }); + + after(() => rmSync(projectDir, { recursive: true, force: true })); + + it('returns all entries sorted by most recent', () => { + const result = readHistory(projectDir); + assert.equal(result.entries.length, 2); + assert.equal(result.entries[0].id, 'M001/S01/T02'); // most recent first + }); + + it('computes totals', () => { + const result = readHistory(projectDir); + assert.equal(result.totals.units, 2); + assert.equal(result.totals.cost, 0.13); + assert.equal(result.totals.tokens.total, 40500); + }); + + it('respects limit', () => { + const result = readHistory(projectDir, 1); + assert.equal(result.entries.length, 1); + assert.equal(result.totals.units, 2); // totals still reflect all + }); + + it('returns empty for missing metrics', () => { + const empty = tmpProject(); + mkdirSync(join(empty, '.gsd'), { recursive: true }); + const result = readHistory(empty); + assert.equal(result.entries.length, 0); + assert.equal(result.totals.units, 0); + rmSync(empty, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// readCaptures tests +// --------------------------------------------------------------------------- + +describe('readCaptures', () => { + let projectDir: string; + + before(() => { + projectDir = tmpProject(); + writeFixture(projectDir, '.gsd/CAPTURES.md', `# Captures + +### CAP-aaa11111 + +**Text:** Add rate limiting to API +**Captured:** 2026-04-01T10:00:00Z +**Status:** pending + +### CAP-bbb22222 + +**Text:** Refactor auth module +**Captured:** 2026-04-02T10:00:00Z +**Status:** resolved +**Classification:** inject +**Resolution:** Added to M003 roadmap +**Rationale:** Important for security +**Resolved:** 2026-04-03T10:00:00Z +**Milestone:** M003 + +### CAP-ccc33333 + +**Text:** Nice to have: dark mode +**Captured:** 2026-04-02T11:00:00Z +**Status:** resolved +**Classification:** defer +**Resolution:** Deferred to future +**Rationale:** Not blocking +**Resolved:** 2026-04-03T11:00:00Z +`); + }); + + after(() => rmSync(projectDir, { recursive: true, force: true })); + + it('reads all captures', () => { + const result = readCaptures(projectDir, 'all'); + assert.equal(result.captures.length, 3); + assert.equal(result.counts.total, 3); + }); + + it('filters pending captures', () => { + const result = readCaptures(projectDir, 'pending'); + assert.equal(result.captures.length, 1); + assert.equal(result.captures[0].id, 'CAP-aaa11111'); + }); + + it('filters actionable captures (inject, replan, quick-task)', () => { + const result = readCaptures(projectDir, 'actionable'); + assert.equal(result.captures.length, 1); + assert.equal(result.captures[0].id, 'CAP-bbb22222'); + }); + + it('counts correctly regardless of filter', () => { + const result = readCaptures(projectDir, 'pending'); + assert.equal(result.counts.total, 3); + assert.equal(result.counts.pending, 1); + assert.equal(result.counts.actionable, 1); + }); + + it('returns empty for missing CAPTURES.md', () => { + const empty = tmpProject(); + mkdirSync(join(empty, '.gsd'), { recursive: true }); + const result = readCaptures(empty); + assert.equal(result.captures.length, 0); + rmSync(empty, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// readKnowledge tests +// --------------------------------------------------------------------------- + +describe('readKnowledge', () => { + let projectDir: string; + + before(() => { + projectDir = tmpProject(); + writeFixture(projectDir, '.gsd/KNOWLEDGE.md', `# Project Knowledge + +## Rules + +| # | Scope | Rule | Why | Added | +|---|-------|------|-----|-------| +| K001 | auth | Hash passwords with bcrypt | Security requirement | manual | +| K002 | db | Use transactions for multi-table | Data consistency | auto | + +## Patterns + +| # | Pattern | Where | Notes | +|---|---------|-------|-------| +| P001 | Singleton services | services/ | Prevents duplication | + +## Lessons Learned + +| # | What Happened | Root Cause | Fix | Scope | +|---|--------------|------------|-----|-------| +| L001 | CI tests failed | Env diff | Added setup script | testing | +`); + }); + + after(() => rmSync(projectDir, { recursive: true, force: true })); + + it('reads all knowledge entries', () => { + const result = readKnowledge(projectDir); + assert.equal(result.entries.length, 4); + }); + + it('counts by type', () => { + const result = readKnowledge(projectDir); + assert.equal(result.counts.rules, 2); + assert.equal(result.counts.patterns, 1); + assert.equal(result.counts.lessons, 1); + }); + + it('parses rule fields correctly', () => { + const result = readKnowledge(projectDir); + const k001 = result.entries.find((e) => e.id === 'K001'); + assert.ok(k001); + assert.equal(k001.type, 'rule'); + assert.equal(k001.scope, 'auth'); + assert.ok(k001.content.includes('bcrypt')); + }); + + it('returns empty for missing KNOWLEDGE.md', () => { + const empty = tmpProject(); + mkdirSync(join(empty, '.gsd'), { recursive: true }); + const result = readKnowledge(empty); + assert.equal(result.entries.length, 0); + rmSync(empty, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// runDoctorLite tests +// --------------------------------------------------------------------------- + +describe('runDoctorLite', () => { + let projectDir: string; + + before(() => { + projectDir = tmpProject(); + + // M001: complete milestone (has summary) + writeFixture(projectDir, '.gsd/PROJECT.md', '# Test Project'); + writeFixture(projectDir, '.gsd/STATE.md', '# GSD State'); + writeFixture(projectDir, '.gsd/milestones/M001/M001-CONTEXT.md', '# M001'); + writeFixture(projectDir, '.gsd/milestones/M001/M001-ROADMAP.md', '# Roadmap'); + writeFixture(projectDir, '.gsd/milestones/M001/M001-SUMMARY.md', '# Done'); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/S01-PLAN.md', '# Plan'); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01'); + writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 done'); + + // M002: incomplete — has all tasks done but no SUMMARY + writeFixture(projectDir, '.gsd/milestones/M002/M002-CONTEXT.md', '# M002'); + writeFixture(projectDir, '.gsd/milestones/M002/M002-ROADMAP.md', '# Roadmap'); + writeFixture(projectDir, '.gsd/milestones/M002/slices/S01/S01-PLAN.md', '# Plan'); + writeFixture(projectDir, '.gsd/milestones/M002/slices/S01/tasks/T01-PLAN.md', '# T01'); + writeFixture(projectDir, '.gsd/milestones/M002/slices/S01/tasks/T01-SUMMARY.md', '# T01 done'); + + // M003: empty — no context, no slices + mkdirSync(join(projectDir, '.gsd/milestones/M003'), { recursive: true }); + }); + + after(() => rmSync(projectDir, { recursive: true, force: true })); + + it('detects all-slices-done-missing-summary', () => { + const result = runDoctorLite(projectDir); + const issue = result.issues.find((i) => i.code === 'all_slices_done_missing_summary'); + assert.ok(issue, 'Should detect M002 missing summary'); + assert.equal(issue.unitId, 'M002'); + }); + + it('detects missing context', () => { + const result = runDoctorLite(projectDir); + const issue = result.issues.find( + (i) => i.code === 'missing_context' && i.unitId === 'M003', + ); + assert.ok(issue, 'Should detect M003 missing context'); + }); + + it('scopes to a single milestone', () => { + const result = runDoctorLite(projectDir, 'M001'); + const m002Issues = result.issues.filter((i) => i.unitId.startsWith('M002')); + assert.equal(m002Issues.length, 0, 'Should not include M002 when scoped to M001'); + }); + + it('returns ok:true for healthy project', () => { + const healthy = tmpProject(); + writeFixture(healthy, '.gsd/PROJECT.md', '# Project'); + writeFixture(healthy, '.gsd/STATE.md', '# State'); + const result = runDoctorLite(healthy); + assert.equal(result.ok, true); + rmSync(healthy, { recursive: true, force: true }); + }); + + it('handles missing .gsd/ gracefully', () => { + const empty = tmpProject(); + const result = runDoctorLite(empty); + assert.equal(result.ok, true); + assert.equal(result.issues[0].code, 'no_gsd_directory'); + rmSync(empty, { recursive: true, force: true }); + }); +}); diff --git a/packages/mcp-server/src/readers/roadmap.ts b/packages/mcp-server/src/readers/roadmap.ts new file mode 100644 index 000000000..29a6e1941 --- /dev/null +++ b/packages/mcp-server/src/readers/roadmap.ts @@ -0,0 +1,263 @@ +// GSD MCP Server — roadmap structure reader +// Copyright (c) 2026 Jeremy McSpadden + +import { readFileSync, existsSync } from 'node:fs'; +import { + resolveGsdRoot, + findMilestoneIds, + resolveMilestoneFile, + findSliceIds, + resolveSliceFile, + findTaskFiles, +} from './paths.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TaskInfo { + id: string; + title: string; + status: 'done' | 'pending'; +} + +export interface SliceInfo { + id: string; + title: string; + status: 'done' | 'active' | 'pending'; + risk: string; + depends: string[]; + demo: string; + tasks: TaskInfo[]; +} + +export interface MilestoneInfo { + id: string; + title: string; + status: 'done' | 'active' | 'pending' | 'parked'; + vision: string; + slices: SliceInfo[]; +} + +export interface RoadmapResult { + milestones: MilestoneInfo[]; +} + +// --------------------------------------------------------------------------- +// ROADMAP.md table parser +// --------------------------------------------------------------------------- + +function parseRoadmapTable(content: string): Array<{ + id: string; title: string; risk: string; depends: string[]; done: boolean; demo: string; +}> { + const results: Array<{ + id: string; title: string; risk: string; depends: string[]; done: boolean; demo: string; + }> = []; + + // Try table format first: | S01 | Title | risk | depends | done-icon | demo | + const tableSection = content.match(/## (?:Slice[s]?|Slice Overview|Slice Table)\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); + if (tableSection) { + const lines = tableSection[1].split('\n'); + for (const line of lines) { + if (!line.includes('|')) continue; + const cells = line.split('|').map((c) => c.trim()).filter(Boolean); + if (cells.length < 4) continue; + if (cells[0] === 'ID' || cells[0].startsWith('--')) continue; + + const id = cells[0].match(/S\d+/)?.[0]; + if (!id) continue; + + const done = cells.some((c) => c === '\u2611' || c === '\u2705' || c.toLowerCase() === 'done'); + const depends = (cells[3] ?? '').replace(/\u2014/g, '').split(',').map((d) => d.trim()).filter(Boolean); + + results.push({ + id, + title: cells[1] ?? '', + risk: cells[2] ?? 'medium', + depends, + done, + demo: cells[5] ?? '', + }); + } + if (results.length > 0) return results; + } + + // Try checkbox format: - [x] **S01: Title** `risk:high` `depends:[S01]` + const checkboxRe = /^-\s+\[([ xX])\]\s+\*\*(S\d+):\s*(.+?)\*\*(?:.*?`risk:(\w+)`)?(?:.*?`depends:\[([^\]]*)\]`)?/gm; + let match: RegExpExecArray | null; + while ((match = checkboxRe.exec(content)) !== null) { + const [, checked, id, title, risk, deps] = match; + results.push({ + id, + title: title.trim(), + risk: risk ?? 'medium', + depends: deps ? deps.split(',').map((d) => d.trim()).filter(Boolean) : [], + done: checked !== ' ', + demo: '', + }); + } + if (results.length > 0) return results; + + // Try prose headers: ## S01: Title + const headerRe = /^##\s+(S\d+):\s*(.+)/gm; + while ((match = headerRe.exec(content)) !== null) { + results.push({ + id: match[1], + title: match[2].trim(), + risk: 'medium', + depends: [], + done: false, + demo: '', + }); + } + + return results; +} + +// --------------------------------------------------------------------------- +// PLAN.md task parser +// --------------------------------------------------------------------------- + +function parseSlicePlanTasks(content: string): Array<{ id: string; title: string; done: boolean }> { + const results: Array<{ id: string; title: string; done: boolean }> = []; + + // Checkbox format: - [x] **T01: Title** — description + const taskRe = /^-\s+\[([ xX])\]\s+\*\*(T\d+):\s*(.+?)\*\*/gm; + let match: RegExpExecArray | null; + while ((match = taskRe.exec(content)) !== null) { + results.push({ + id: match[2], + title: match[3].trim(), + done: match[1] !== ' ', + }); + } + if (results.length > 0) return results; + + // H3 format: ### T01: Title + const h3Re = /^###\s+(T\d+):\s*(.+)/gm; + while ((match = h3Re.exec(content)) !== null) { + results.push({ + id: match[1], + title: match[2].trim(), + done: false, + }); + } + + return results; +} + +// --------------------------------------------------------------------------- +// Milestone title from CONTEXT.md or ROADMAP.md H1 +// --------------------------------------------------------------------------- + +function readMilestoneTitle(gsdRoot: string, mid: string): string { + const ctxPath = resolveMilestoneFile(gsdRoot, mid, 'CONTEXT'); + if (ctxPath && existsSync(ctxPath)) { + const content = readFileSync(ctxPath, 'utf-8'); + const h1 = content.match(/^#\s+(?:M\d+:?\s*)?(.+)/m); + if (h1) return h1[1].trim(); + } + + const roadmapPath = resolveMilestoneFile(gsdRoot, mid, 'ROADMAP'); + if (roadmapPath && existsSync(roadmapPath)) { + const content = readFileSync(roadmapPath, 'utf-8'); + const h1 = content.match(/^#\s+(?:M\d+:?\s*)?(.+)/m); + if (h1) return h1[1].trim(); + } + + return mid; +} + +function readVision(gsdRoot: string, mid: string): string { + const roadmapPath = resolveMilestoneFile(gsdRoot, mid, 'ROADMAP'); + if (!roadmapPath || !existsSync(roadmapPath)) return ''; + + const content = readFileSync(roadmapPath, 'utf-8'); + const section = content.match(/## Vision\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); + return section ? section[1].trim() : ''; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function readRoadmap(projectDir: string, filterMilestoneId?: string): RoadmapResult { + const gsd = resolveGsdRoot(projectDir); + let milestoneIds = findMilestoneIds(gsd); + + if (filterMilestoneId) { + milestoneIds = milestoneIds.filter((id) => id === filterMilestoneId); + } + + const milestones: MilestoneInfo[] = []; + + for (const mid of milestoneIds) { + const title = readMilestoneTitle(gsd, mid); + const vision = readVision(gsd, mid); + + const summaryPath = resolveMilestoneFile(gsd, mid, 'SUMMARY'); + const hasSummary = summaryPath !== null && existsSync(summaryPath); + + const roadmapPath = resolveMilestoneFile(gsd, mid, 'ROADMAP'); + let roadmapSlices: ReturnType = []; + if (roadmapPath && existsSync(roadmapPath)) { + roadmapSlices = parseRoadmapTable(readFileSync(roadmapPath, 'utf-8')); + } + + const fsSliceIds = findSliceIds(gsd, mid); + const sliceIdSet = new Set([ + ...roadmapSlices.map((s) => s.id), + ...fsSliceIds, + ]); + + const slices: SliceInfo[] = []; + for (const sid of Array.from(sliceIdSet).sort()) { + const roadmapEntry = roadmapSlices.find((s) => s.id === sid); + const taskFiles = findTaskFiles(gsd, mid, sid); + + const planPath = resolveSliceFile(gsd, mid, sid, 'PLAN'); + let planTasks: ReturnType = []; + if (planPath && existsSync(planPath)) { + planTasks = parseSlicePlanTasks(readFileSync(planPath, 'utf-8')); + } + + const tasks: TaskInfo[] = []; + const seenIds = new Set(); + + for (const pt of planTasks) { + const fsTask = taskFiles.find((t) => t.id === pt.id); + const done = fsTask?.hasSummary ?? pt.done; + tasks.push({ id: pt.id, title: pt.title, status: done ? 'done' : 'pending' }); + seenIds.add(pt.id); + } + for (const ft of taskFiles) { + if (seenIds.has(ft.id)) continue; + tasks.push({ id: ft.id, title: ft.id, status: ft.hasSummary ? 'done' : 'pending' }); + } + + const allDone = tasks.length > 0 && tasks.every((t) => t.status === 'done'); + const anyDone = tasks.some((t) => t.status === 'done'); + const sliceStatus: SliceInfo['status'] = allDone ? 'done' : anyDone ? 'active' : 'pending'; + + slices.push({ + id: sid, + title: roadmapEntry?.title ?? sid, + status: sliceStatus, + risk: roadmapEntry?.risk ?? 'medium', + depends: roadmapEntry?.depends ?? [], + demo: roadmapEntry?.demo ?? '', + tasks, + }); + } + + const allSlicesDone = slices.length > 0 && slices.every((s) => s.status === 'done'); + const anySliceActive = slices.some((s) => s.status === 'active' || s.status === 'done'); + const milestoneStatus: MilestoneInfo['status'] = hasSummary + ? 'done' + : allSlicesDone ? 'done' : anySliceActive ? 'active' : 'pending'; + + milestones.push({ id: mid, title, status: milestoneStatus, vision, slices }); + } + + return { milestones }; +} diff --git a/packages/mcp-server/src/readers/state.ts b/packages/mcp-server/src/readers/state.ts new file mode 100644 index 000000000..93ea7d38f --- /dev/null +++ b/packages/mcp-server/src/readers/state.ts @@ -0,0 +1,223 @@ +// GSD MCP Server — project state reader +// Copyright (c) 2026 Jeremy McSpadden + +import { readFileSync, existsSync } from 'node:fs'; +import { + resolveGsdRoot, + resolveRootFile, + findMilestoneIds, + resolveMilestoneDir, + resolveMilestoneFile, + findSliceIds, + findTaskFiles, +} from './paths.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ProgressResult { + activeMilestone: { id: string; title: string } | null; + activeSlice: { id: string; title: string } | null; + activeTask: { id: string; title: string } | null; + phase: string; + milestones: { total: number; done: number; active: number; pending: number; parked: number }; + slices: { total: number; done: number; active: number; pending: number }; + tasks: { total: number; done: number; pending: number }; + requirements: { active: number; validated: number; deferred: number; outOfScope: number } | null; + blockers: string[]; + nextAction: string; +} + +// --------------------------------------------------------------------------- +// STATE.md parser +// --------------------------------------------------------------------------- + +function parseBoldField(content: string, label: string): string | null { + const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, 'i'); + const m = content.match(re); + return m ? m[1].trim() : null; +} + +function parseActiveRef(value: string | null): { id: string; title: string } | null { + if (!value || value.toLowerCase() === 'none' || value === '—') return null; + // "M001: Flight Simulator" or "M001" + const m = value.match(/^(M\d+|S\d+|T\d+):?\s*(.*)/); + if (m) return { id: m[1], title: m[2] || m[1] }; + return { id: value, title: value }; +} + +function parsePhase(value: string | null): string { + if (!value) return 'unknown'; + const lower = value.toLowerCase().trim(); + if (lower.includes('research') || lower.includes('discuss')) return 'research'; + if (lower.includes('plan')) return 'plan'; + if (lower.includes('execut')) return 'execute'; + if (lower.includes('complete') || lower.includes('done')) return 'complete'; + return lower; +} + +function parseRequirementsLine(value: string | null): ProgressResult['requirements'] | null { + if (!value) return null; + const active = value.match(/(\d+)\s*active/i); + const validated = value.match(/(\d+)\s*validated/i); + const deferred = value.match(/(\d+)\s*deferred/i); + const outOfScope = value.match(/(\d+)\s*out.of.scope/i); + if (!active && !validated && !deferred && !outOfScope) return null; + return { + active: active ? parseInt(active[1], 10) : 0, + validated: validated ? parseInt(validated[1], 10) : 0, + deferred: deferred ? parseInt(deferred[1], 10) : 0, + outOfScope: outOfScope ? parseInt(outOfScope[1], 10) : 0, + }; +} + +function parseBlockers(content: string): string[] { + const section = content.match(/## Blockers\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); + if (!section) return []; + return section[1] + .split('\n') + .map((l) => l.replace(/^[-*]\s*/, '').trim()) + .filter(Boolean); +} + +function parseNextAction(content: string): string { + const section = content.match(/## Next Action\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); + if (!section) return ''; + return section[1].trim().split('\n')[0] || ''; +} + +// --------------------------------------------------------------------------- +// Milestone registry from STATE.md +// --------------------------------------------------------------------------- + +interface RegistryEntry { id: string; status: 'done' | 'active' | 'pending' | 'parked' } + +function parseMilestoneRegistry(content: string): RegistryEntry[] { + const section = content.match(/## Milestone Registry\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); + if (!section) return []; + const entries: RegistryEntry[] = []; + for (const line of section[1].split('\n')) { + const m = line.match(/[-*]\s*(☑|✅|🔄|⬜|⏸)\s*\*\*(M\d+):\*\*/); + if (!m) continue; + const [, icon, id] = m; + let status: RegistryEntry['status'] = 'pending'; + if (icon === '☑' || icon === '✅') status = 'done'; + else if (icon === '🔄') status = 'active'; + else if (icon === '⏸') status = 'parked'; + entries.push({ id, status }); + } + return entries; +} + +// --------------------------------------------------------------------------- +// Count slices/tasks by walking filesystem +// --------------------------------------------------------------------------- + +function countSlicesAndTasks(gsdRoot: string, milestoneIds: string[]): { + slices: ProgressResult['slices']; + tasks: ProgressResult['tasks']; +} { + let sliceTotal = 0, sliceDone = 0, sliceActive = 0; + let taskTotal = 0, taskDone = 0; + + for (const mid of milestoneIds) { + const sliceIds = findSliceIds(gsdRoot, mid); + sliceTotal += sliceIds.length; + + for (const sid of sliceIds) { + const tasks = findTaskFiles(gsdRoot, mid, sid); + taskTotal += tasks.length; + + const allDone = tasks.length > 0 && tasks.every((t) => t.hasSummary); + const anyDone = tasks.some((t) => t.hasSummary); + + if (allDone) { + sliceDone++; + taskDone += tasks.length; + } else { + if (anyDone) sliceActive++; + taskDone += tasks.filter((t) => t.hasSummary).length; + } + } + } + + return { + slices: { + total: sliceTotal, + done: sliceDone, + active: sliceActive, + pending: sliceTotal - sliceDone - sliceActive, + }, + tasks: { total: taskTotal, done: taskDone, pending: taskTotal - taskDone }, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function readProgress(projectDir: string): ProgressResult { + const gsd = resolveGsdRoot(projectDir); + const statePath = resolveRootFile(gsd, 'STATE.md'); + + // Defaults + const result: ProgressResult = { + activeMilestone: null, + activeSlice: null, + activeTask: null, + phase: 'unknown', + milestones: { total: 0, done: 0, active: 0, pending: 0, parked: 0 }, + slices: { total: 0, done: 0, active: 0, pending: 0 }, + tasks: { total: 0, done: 0, pending: 0 }, + requirements: null, + blockers: [], + nextAction: '', + }; + + if (!existsSync(statePath)) { + // No STATE.md — derive from filesystem only + const milestoneIds = findMilestoneIds(gsd); + result.milestones.total = milestoneIds.length; + result.milestones.pending = milestoneIds.length; + const counts = countSlicesAndTasks(gsd, milestoneIds); + result.slices = counts.slices; + result.tasks = counts.tasks; + return result; + } + + const content = readFileSync(statePath, 'utf-8'); + + // Parse STATE.md fields + result.activeMilestone = parseActiveRef(parseBoldField(content, 'Active Milestone')); + result.activeSlice = parseActiveRef(parseBoldField(content, 'Active Slice')); + result.activeTask = parseActiveRef(parseBoldField(content, 'Active Task')); + result.phase = parsePhase(parseBoldField(content, 'Phase')); + result.requirements = parseRequirementsLine(parseBoldField(content, 'Requirements Status')); + result.blockers = parseBlockers(content); + result.nextAction = parseNextAction(content); + + // Milestone counts from registry + const registry = parseMilestoneRegistry(content); + if (registry.length > 0) { + result.milestones.total = registry.length; + result.milestones.done = registry.filter((e) => e.status === 'done').length; + result.milestones.active = registry.filter((e) => e.status === 'active').length; + result.milestones.parked = registry.filter((e) => e.status === 'parked').length; + result.milestones.pending = registry.length - + result.milestones.done - result.milestones.active - result.milestones.parked; + } else { + // Fallback: count directories + const milestoneIds = findMilestoneIds(gsd); + result.milestones.total = milestoneIds.length; + result.milestones.pending = milestoneIds.length; + } + + // Slice/task counts from filesystem + const milestoneIds = findMilestoneIds(gsd); + const counts = countSlicesAndTasks(gsd, milestoneIds); + result.slices = counts.slices; + result.tasks = counts.tasks; + + return result; +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 202b4731a..f684700ed 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,5 +1,8 @@ /** - * MCP Server — registers 6 GSD orchestration tools on McpServer. + * MCP Server — registers GSD orchestration + read-only project state tools. + * + * Session tools (6): gsd_execute, gsd_status, gsd_result, gsd_cancel, gsd_query, gsd_resolve_blocker + * Read-only tools (6): gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, gsd_knowledge * * Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16 * cannot resolve the SDK's subpath exports statically (same pattern as @@ -10,6 +13,12 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { z } from 'zod'; import type { SessionManager } from './session-manager.js'; +import { readProgress } from './readers/state.js'; +import { readRoadmap } from './readers/roadmap.js'; +import { readHistory } from './readers/metrics.js'; +import { readCaptures } from './readers/captures.js'; +import { readKnowledge } from './readers/knowledge.js'; +import { runDoctorLite } from './readers/doctor-lite.js'; // --------------------------------------------------------------------------- // Constants @@ -17,7 +26,7 @@ import type { SessionManager } from './session-manager.js'; const MCP_PKG = '@modelcontextprotocol/sdk'; const SERVER_NAME = 'gsd'; -const SERVER_VERSION = '2.51.0'; +const SERVER_VERSION = '2.53.0'; // --------------------------------------------------------------------------- // Tool result helpers @@ -106,7 +115,7 @@ interface McpServerInstance { // --------------------------------------------------------------------------- /** - * Create and configure an MCP server with 6 GSD orchestration tools. + * Create and configure an MCP server with 12 GSD tools (6 session + 6 read-only). * * Returns the McpServer instance — call `connect(transport)` to start serving. * Uses dynamic imports for the MCP SDK to avoid TS subpath resolution issues. @@ -274,5 +283,127 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{ }, ); + // ======================================================================= + // READ-ONLY TOOLS — no session required, pure filesystem reads + // ======================================================================= + + // ----------------------------------------------------------------------- + // gsd_progress — structured project progress metrics + // ----------------------------------------------------------------------- + server.tool( + 'gsd_progress', + 'Get structured project progress: active milestone/slice/task, phase, completion counts, blockers, and next action. No session required — reads directly from .gsd/ on disk.', + { + projectDir: z.string().describe('Absolute path to the project directory'), + }, + async (args: Record) => { + const { projectDir } = args as { projectDir: string }; + try { + return jsonContent(readProgress(projectDir)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); + + // ----------------------------------------------------------------------- + // gsd_roadmap — milestone/slice/task structure with status + // ----------------------------------------------------------------------- + server.tool( + 'gsd_roadmap', + 'Get the full project roadmap structure: milestones with their slices, tasks, status, risk, and dependencies. Optionally filter to a single milestone. No session required.', + { + projectDir: z.string().describe('Absolute path to the project directory'), + milestoneId: z.string().optional().describe('Filter to a specific milestone (e.g. "M001")'), + }, + async (args: Record) => { + const { projectDir, milestoneId } = args as { projectDir: string; milestoneId?: string }; + try { + return jsonContent(readRoadmap(projectDir, milestoneId)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); + + // ----------------------------------------------------------------------- + // gsd_history — execution history with cost/token metrics + // ----------------------------------------------------------------------- + server.tool( + 'gsd_history', + 'Get execution history with cost, token usage, model, and duration per unit. Returns totals across all units. No session required.', + { + projectDir: z.string().describe('Absolute path to the project directory'), + limit: z.number().optional().describe('Max entries to return (most recent first). Default: all.'), + }, + async (args: Record) => { + const { projectDir, limit } = args as { projectDir: string; limit?: number }; + try { + return jsonContent(readHistory(projectDir, limit)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); + + // ----------------------------------------------------------------------- + // gsd_doctor — lightweight structural health check + // ----------------------------------------------------------------------- + server.tool( + 'gsd_doctor', + 'Run a lightweight structural health check on the .gsd/ directory. Checks for missing files, status inconsistencies, and orphaned state. No session required.', + { + projectDir: z.string().describe('Absolute path to the project directory'), + scope: z.string().optional().describe('Limit checks to a specific milestone (e.g. "M001")'), + }, + async (args: Record) => { + const { projectDir, scope } = args as { projectDir: string; scope?: string }; + try { + return jsonContent(runDoctorLite(projectDir, scope)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); + + // ----------------------------------------------------------------------- + // gsd_captures — pending captures and ideas + // ----------------------------------------------------------------------- + server.tool( + 'gsd_captures', + 'Get captured ideas and thoughts from CAPTURES.md with triage status. Filter by pending, actionable, or all. No session required.', + { + projectDir: z.string().describe('Absolute path to the project directory'), + filter: z.enum(['all', 'pending', 'actionable']).optional().describe('Filter captures (default: "all")'), + }, + async (args: Record) => { + const { projectDir, filter } = args as { projectDir: string; filter?: 'all' | 'pending' | 'actionable' }; + try { + return jsonContent(readCaptures(projectDir, filter ?? 'all')); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); + + // ----------------------------------------------------------------------- + // gsd_knowledge — project knowledge base + // ----------------------------------------------------------------------- + server.tool( + 'gsd_knowledge', + 'Get the project knowledge base: rules, patterns, and lessons learned accumulated during development. No session required.', + { + projectDir: z.string().describe('Absolute path to the project directory'), + }, + async (args: Record) => { + const { projectDir } = args as { projectDir: string }; + try { + return jsonContent(readKnowledge(projectDir)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); + return { server }; }