feat(mcp-server): add 6 read-only tools for project state queries (#3515)
Add gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, and gsd_knowledge tools that parse .gsd/ on disk — no session needed. Inline lightweight readers in src/readers/ keep the package standalone (zero new dependencies). 33 new tests, 64 total passing.
This commit is contained in:
parent
099e6f3120
commit
45b606744f
12 changed files with 1951 additions and 5 deletions
|
|
@ -15,7 +15,7 @@ const MCP_PKG = '@modelcontextprotocol/sdk';
|
|||
async function main(): Promise<void> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
119
packages/mcp-server/src/readers/captures.ts
Normal file
119
packages/mcp-server/src/readers/captures.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// GSD MCP Server — captures reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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<string>(['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 };
|
||||
}
|
||||
225
packages/mcp-server/src/readers/doctor-lite.ts
Normal file
225
packages/mcp-server/src/readers/doctor-lite.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// GSD MCP Server — lightweight structural health checks
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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 };
|
||||
}
|
||||
16
packages/mcp-server/src/readers/index.ts
Normal file
16
packages/mcp-server/src/readers/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// GSD MCP Server — readers barrel export
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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';
|
||||
111
packages/mcp-server/src/readers/knowledge.ts
Normal file
111
packages/mcp-server/src/readers/knowledge.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// GSD MCP Server — knowledge base reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
118
packages/mcp-server/src/readers/metrics.ts
Normal file
118
packages/mcp-server/src/readers/metrics.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// GSD MCP Server — metrics/history reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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<string, unknown>) => ({
|
||||
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<string, unknown>)?.input ?? 0),
|
||||
output: Number((u.tokens as Record<string, unknown>)?.output ?? 0),
|
||||
cacheRead: Number((u.tokens as Record<string, unknown>)?.cacheRead ?? 0),
|
||||
cacheWrite: Number((u.tokens as Record<string, unknown>)?.cacheWrite ?? 0),
|
||||
total: Number((u.tokens as Record<string, unknown>)?.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 };
|
||||
}
|
||||
217
packages/mcp-server/src/readers/paths.ts
Normal file
217
packages/mcp-server/src/readers/paths.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// GSD MCP Server — .gsd/ directory resolution
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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<string, { hasPlan: boolean; hasSummary: boolean }>();
|
||||
|
||||
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));
|
||||
}
|
||||
509
packages/mcp-server/src/readers/readers.test.ts
Normal file
509
packages/mcp-server/src/readers/readers.test.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
// GSD MCP Server — reader tests
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
263
packages/mcp-server/src/readers/roadmap.ts
Normal file
263
packages/mcp-server/src/readers/roadmap.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
// GSD MCP Server — roadmap structure reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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<typeof parseRoadmapTable> = [];
|
||||
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<typeof parseSlicePlanTasks> = [];
|
||||
if (planPath && existsSync(planPath)) {
|
||||
planTasks = parseSlicePlanTasks(readFileSync(planPath, 'utf-8'));
|
||||
}
|
||||
|
||||
const tasks: TaskInfo[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
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 };
|
||||
}
|
||||
223
packages/mcp-server/src/readers/state.ts
Normal file
223
packages/mcp-server/src/readers/state.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// GSD MCP Server — project state reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
const { projectDir } = args as { projectDir: string };
|
||||
try {
|
||||
return jsonContent(readKnowledge(projectDir));
|
||||
} catch (err) {
|
||||
return errorContent(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return { server };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue