2026-02-22 19:57:40 -08:00
|
|
|
/**
|
|
|
|
|
* collector.ts
|
|
|
|
|
* Reads finished project artifacts and writes a RunMetrics JSON file.
|
|
|
|
|
* Zero dependency on plan2code-loop internals — reads files directly.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
import crypto from 'crypto';
|
|
|
|
|
import type {
|
|
|
|
|
RunMetrics,
|
|
|
|
|
PromptVersions,
|
|
|
|
|
Step1PlanMetrics,
|
|
|
|
|
Step2DocumentMetrics,
|
|
|
|
|
Step3ImplementMetrics,
|
|
|
|
|
Step4FinalizeMetrics,
|
2026-02-22 21:38:58 -08:00
|
|
|
UserFeedback,
|
2026-02-22 19:57:40 -08:00
|
|
|
} from './types.js';
|
|
|
|
|
|
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function sha256File(filePath: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
|
|
|
return 'sha256:' + crypto.createHash('sha256').update(content).digest('hex');
|
|
|
|
|
} catch {
|
|
|
|
|
return 'sha256:missing';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readFileSafe(filePath: string): string | null {
|
|
|
|
|
try {
|
|
|
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
/**
|
|
|
|
|
* Extract METRICS_JSON HTML comment blocks from a file.
|
|
|
|
|
* Format: <!-- METRICS_JSON {"key": value, ...} -->
|
|
|
|
|
* A file may contain multiple blocks (e.g., overview.md has document + finalize).
|
|
|
|
|
* If `stepFilter` is provided, returns only the block with matching "step" field.
|
|
|
|
|
* Returns parsed object or null if not found / invalid.
|
|
|
|
|
*/
|
|
|
|
|
function extractMetricsJson(content: string, stepFilter?: string): Record<string, unknown> | null {
|
|
|
|
|
const re = /<!--\s*METRICS_JSON\s+(\{[\s\S]*?\})\s*-->/g;
|
|
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
while ((match = re.exec(content)) !== null) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
|
|
|
|
if (!stepFilter || parsed['step'] === stepFilter) {
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// try next match
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
/** Strip the "## Success Criteria" section so its checkboxes aren't counted as tasks. */
|
|
|
|
|
function stripSuccessCriteriaSection(overview: string): string {
|
|
|
|
|
return overview.replace(/^## Success Criteria\s*\n[\s\S]*?(?=\n## |\n*$)/m, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract canonical task counts from the Completion Summary section.
|
|
|
|
|
* Supports two formats found in real specs:
|
|
|
|
|
* - Bold text: "**Completion Rate:** 100% (5/5 tasks)"
|
|
|
|
|
* - Table row: "| Total Tasks | 28 (28/28 complete — 100%) |"
|
|
|
|
|
* Returns null when no parseable counts are found.
|
|
|
|
|
*/
|
|
|
|
|
function parseCompletionSummaryTaskCount(overview: string): { completed: number; total: number } | null {
|
|
|
|
|
const summaryMatch = overview.match(/## Completion Summary\s*\n([\s\S]*?)(?=\n## |\n*$)/);
|
|
|
|
|
if (!summaryMatch) return null;
|
|
|
|
|
const summary = summaryMatch[1];
|
|
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// "Completion Rate: X% (Y/Z tasks)" — handles bold markdown and various separators
|
|
|
|
|
const rateMatch = summary.match(/\**Completion(?:\s+Rate)?\**[:\s*]+\d{1,3}%\s*\((\d+)\/(\d+)\s*tasks?\)/i);
|
2026-03-01 12:24:11 -08:00
|
|
|
if (rateMatch) return { completed: parseInt(rateMatch[1], 10), total: parseInt(rateMatch[2], 10) };
|
|
|
|
|
|
|
|
|
|
// "Total Tasks | 28 (28/28 complete"
|
|
|
|
|
const tableMatch = summary.match(/Total Tasks\s*\|\s*(\d+)\s*\((\d+)\/(\d+)\s*complete/i);
|
|
|
|
|
if (tableMatch) return { completed: parseInt(tableMatch[2], 10), total: parseInt(tableMatch[3], 10) };
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:57:40 -08:00
|
|
|
function generateRunId(): string {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
|
|
|
const rand = crypto.randomBytes(2).toString('hex');
|
|
|
|
|
return `run-${ts.slice(0, 8)}-${ts.slice(8, 14)}-${rand}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Prompt version hashing ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function collectPromptVersions(plan2codeRoot: string): PromptVersions {
|
|
|
|
|
const srcDir = path.join(plan2codeRoot, 'src');
|
|
|
|
|
return {
|
2026-05-23 06:58:20 -07:00
|
|
|
plan: sha256File(path.join(srcDir, 'plan2code-1-plan.md')),
|
|
|
|
|
revise_plan: sha256File(path.join(srcDir, 'plan2code-1b-revise-plan.md')),
|
|
|
|
|
document: sha256File(path.join(srcDir, 'plan2code-2-document.md')),
|
|
|
|
|
implement: sha256File(path.join(srcDir, 'plan2code-3-implement.md')),
|
|
|
|
|
finalize: sha256File(path.join(srcDir, 'plan2code-4-finalize.md')),
|
|
|
|
|
init: sha256File(path.join(srcDir, 'plan2code-init.md')),
|
|
|
|
|
init_update: sha256File(path.join(srcDir, 'plan2code-init-update.md')),
|
|
|
|
|
quick_task: sha256File(path.join(srcDir, 'plan2code-quick-task.md')),
|
2026-02-22 19:57:40 -08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Step 1: Plan ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function collectStep1(specDir: string): Step1PlanMetrics {
|
|
|
|
|
// Find PLAN-DRAFT-*.md files
|
|
|
|
|
let draftFiles: string[] = [];
|
|
|
|
|
let convFiles: string[] = [];
|
|
|
|
|
try {
|
|
|
|
|
const files = fs.readdirSync(specDir);
|
|
|
|
|
draftFiles = files
|
|
|
|
|
.filter(f => f.startsWith('PLAN-DRAFT-') && f.endsWith('.md'))
|
|
|
|
|
.map(f => path.join(specDir, f));
|
|
|
|
|
convFiles = files
|
|
|
|
|
.filter(f => f.startsWith('PLAN-CONVERSATION-') && f.endsWith('.md'))
|
|
|
|
|
.map(f => path.join(specDir, f));
|
|
|
|
|
} catch {
|
|
|
|
|
return { present: false, final_confidence: null, confidence_breakdown: null,
|
|
|
|
|
clarification_rounds: null, tech_stack_revision_rounds: null,
|
|
|
|
|
verification_gaps_found: null, functional_requirements_count: null,
|
|
|
|
|
non_functional_requirements_count: null, risk_count: null, phase_count: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (draftFiles.length === 0) {
|
|
|
|
|
return { present: false, final_confidence: null, confidence_breakdown: null,
|
|
|
|
|
clarification_rounds: null, tech_stack_revision_rounds: null,
|
|
|
|
|
verification_gaps_found: null, functional_requirements_count: null,
|
|
|
|
|
non_functional_requirements_count: null, risk_count: null, phase_count: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use the latest draft file
|
|
|
|
|
draftFiles.sort();
|
|
|
|
|
const latestDraft = readFileSafe(draftFiles[draftFiles.length - 1]) ?? '';
|
|
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// ── Primary source: METRICS_JSON HTML comment ──
|
|
|
|
|
// Format: <!-- METRICS_JSON {"confidence": 95, "clarification_rounds": 0, ...} -->
|
|
|
|
|
const metricsJson = extractMetricsJson(latestDraft);
|
|
|
|
|
if (metricsJson) {
|
|
|
|
|
const num = (key: string): number | null => {
|
|
|
|
|
const v = metricsJson[key];
|
|
|
|
|
return typeof v === 'number' ? v : null;
|
|
|
|
|
};
|
|
|
|
|
const bd = metricsJson['confidence_breakdown'] as Record<string, number> | undefined;
|
|
|
|
|
return {
|
|
|
|
|
present: true,
|
|
|
|
|
final_confidence: num('confidence'),
|
|
|
|
|
confidence_breakdown: bd ? {
|
|
|
|
|
requirements: bd.requirements ?? null,
|
|
|
|
|
feasibility: bd.feasibility ?? null,
|
|
|
|
|
integration: bd.integration ?? null,
|
|
|
|
|
risk: bd.risk ?? null,
|
|
|
|
|
} : null,
|
|
|
|
|
clarification_rounds: num('clarification_rounds'),
|
|
|
|
|
tech_stack_revision_rounds: num('tech_stack_revision_rounds'),
|
|
|
|
|
verification_gaps_found: num('verification_gaps_found'),
|
|
|
|
|
functional_requirements_count: num('functional_requirements_count'),
|
|
|
|
|
non_functional_requirements_count: num('non_functional_requirements_count'),
|
|
|
|
|
risk_count: num('risk_count'),
|
|
|
|
|
phase_count: num('phase_count'),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Fallback: structured ## Planning Metrics appendix ──
|
|
|
|
|
// Handles both plain (confidence: 95) and bold (**confidence:** 95) field formats
|
|
|
|
|
// Planning Metrics is typically the last section, so we capture greedily to end,
|
|
|
|
|
// but stop at the next ## heading or --- if present.
|
|
|
|
|
const metricsSection = latestDraft.match(/^##\s+Planning\s+Metrics\s*\n([\s\S]+?)(?:\n##\s|\n---)/m)
|
|
|
|
|
?? latestDraft.match(/^##\s+Planning\s+Metrics\s*\n([\s\S]+)/m);
|
|
|
|
|
const metricsBlock = metricsSection?.[1] ?? '';
|
|
|
|
|
const parseMetricField = (field: string): number | null => {
|
|
|
|
|
// Match plain "field: N" or bold "**field:** N"
|
|
|
|
|
const m = metricsBlock.match(new RegExp(`^\\**${field}\\**[:\\s*]+?(\\d+)`, 'm'));
|
|
|
|
|
return m ? parseInt(m[1], 10) : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Confidence ──
|
|
|
|
|
let finalConfidence = parseMetricField('confidence');
|
|
|
|
|
if (finalConfidence === null) {
|
|
|
|
|
// Fallback: prose scraping — handle bold markdown like **Confidence:** 95%
|
|
|
|
|
const confMatch = latestDraft.match(/(?:Overall|Final|Total)?\s*\**[Cc]onfidence\**[:\s*]+(\d{1,3})%/);
|
|
|
|
|
if (confMatch) {
|
|
|
|
|
finalConfidence = parseInt(confMatch[1], 10);
|
|
|
|
|
} else {
|
|
|
|
|
const tableMatch = latestDraft.match(/[|]\s*[Cc]onfidence\s*[|]\s*(\d{1,3})/);
|
|
|
|
|
if (tableMatch) finalConfidence = parseInt(tableMatch[1], 10);
|
|
|
|
|
}
|
2026-02-22 19:57:40 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Confidence breakdown (requirements, feasibility, integration, risk)
|
|
|
|
|
let confidenceBreakdown: Step1PlanMetrics['confidence_breakdown'] = null;
|
|
|
|
|
const reqMatch = latestDraft.match(/[Rr]equirements?[:\s|]+(\d{1,2})/);
|
|
|
|
|
const feasMatch = latestDraft.match(/[Ff]easibility[:\s|]+(\d{1,2})/);
|
|
|
|
|
const intMatch = latestDraft.match(/[Ii]ntegration[:\s|]+(\d{1,2})/);
|
|
|
|
|
const riskMatch = latestDraft.match(/[Rr]isk[:\s|]+(\d{1,2})/);
|
|
|
|
|
if (reqMatch || feasMatch || intMatch || riskMatch) {
|
|
|
|
|
confidenceBreakdown = {
|
|
|
|
|
requirements: reqMatch ? parseInt(reqMatch[1], 10) : null,
|
|
|
|
|
feasibility: feasMatch ? parseInt(feasMatch[1], 10) : null,
|
|
|
|
|
integration: intMatch ? parseInt(intMatch[1], 10) : null,
|
|
|
|
|
risk: riskMatch ? parseInt(riskMatch[1], 10) : null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// ── Clarification rounds ──
|
|
|
|
|
let clarificationRounds = parseMetricField('clarification_rounds');
|
|
|
|
|
|
|
|
|
|
// ── Verification gaps ──
|
|
|
|
|
let verificationGaps = parseMetricField('verification_gaps_found');
|
|
|
|
|
|
|
|
|
|
// ── Functional / non-functional requirements ──
|
|
|
|
|
let frCount = parseMetricField('functional_requirements_count');
|
|
|
|
|
if (frCount === null) {
|
|
|
|
|
frCount = (latestDraft.match(/###\s+FR-/g) ?? []).length || null;
|
|
|
|
|
}
|
|
|
|
|
let nfrCount = parseMetricField('non_functional_requirements_count');
|
|
|
|
|
if (nfrCount === null) {
|
|
|
|
|
nfrCount = (latestDraft.match(/###\s+NFR-/g) ?? []).length || null;
|
|
|
|
|
}
|
2026-02-22 19:57:40 -08:00
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// ── Risk count ──
|
|
|
|
|
let riskCount = parseMetricField('risk_count');
|
|
|
|
|
if (riskCount === null) {
|
|
|
|
|
const riskRows = latestDraft.match(/^\s*[|][^|]*(?:High|Medium|Low|Critical)[^|]*[|]/gm) ?? [];
|
|
|
|
|
riskCount = riskRows.length || null;
|
|
|
|
|
}
|
2026-02-22 19:57:40 -08:00
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// ── Phase count ──
|
|
|
|
|
let phaseCount = parseMetricField('phase_count');
|
|
|
|
|
if (phaseCount === null) {
|
|
|
|
|
phaseCount = (latestDraft.match(/^##\s+Phase\s+\d/gm) ?? []).length || null;
|
|
|
|
|
}
|
2026-02-22 19:57:40 -08:00
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// ── Tech stack revision rounds (conversation file only) ──
|
2026-02-22 19:57:40 -08:00
|
|
|
let techStackRevisions: number | null = null;
|
|
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// Fall back to conversation file scraping for fields not found in appendix
|
2026-02-22 19:57:40 -08:00
|
|
|
if (convFiles.length > 0) {
|
|
|
|
|
convFiles.sort();
|
|
|
|
|
const latestConv = readFileSafe(convFiles[convFiles.length - 1]) ?? '';
|
2026-05-11 13:14:15 -07:00
|
|
|
if (clarificationRounds === null) {
|
|
|
|
|
const clarRounds = (latestConv.match(/^##\s+(?:Clarification|Round)\s+\d/gm) ?? []).length;
|
|
|
|
|
clarificationRounds = clarRounds || null;
|
|
|
|
|
}
|
2026-02-22 19:57:40 -08:00
|
|
|
const techRounds = (latestConv.match(/^##\s+(?:Tech\s+Stack|Technology)\s+Revision/gmi) ?? []).length;
|
|
|
|
|
techStackRevisions = techRounds || null;
|
2026-05-11 13:14:15 -07:00
|
|
|
if (verificationGaps === null) {
|
|
|
|
|
const gapMatches = latestConv.match(/(?:verification\s+gap|gap\s+found|missing\s+requirement)/gi) ?? [];
|
|
|
|
|
verificationGaps = gapMatches.length || null;
|
|
|
|
|
}
|
2026-02-22 19:57:40 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
present: true,
|
|
|
|
|
final_confidence: finalConfidence,
|
|
|
|
|
confidence_breakdown: confidenceBreakdown,
|
|
|
|
|
clarification_rounds: clarificationRounds,
|
|
|
|
|
tech_stack_revision_rounds: techStackRevisions,
|
|
|
|
|
verification_gaps_found: verificationGaps,
|
2026-05-11 13:14:15 -07:00
|
|
|
functional_requirements_count: frCount,
|
|
|
|
|
non_functional_requirements_count: nfrCount,
|
2026-02-22 19:57:40 -08:00
|
|
|
risk_count: riskCount,
|
|
|
|
|
phase_count: phaseCount,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Step 2: Document ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function collectStep2(specDir: string): Step2DocumentMetrics {
|
|
|
|
|
const overviewPath = path.join(specDir, 'overview.md');
|
|
|
|
|
const overview = readFileSafe(overviewPath);
|
|
|
|
|
|
|
|
|
|
if (!overview) {
|
|
|
|
|
return { present: false, total_tasks: null, tasks_per_phase: null,
|
|
|
|
|
phase_count: null, parallel_groups_identified: null,
|
|
|
|
|
requirement_coverage_percent: null, verification_items_added: null };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// ── Primary source: METRICS_JSON in overview.md ──
|
|
|
|
|
const step2Json = extractMetricsJson(overview, 'document');
|
|
|
|
|
if (step2Json) {
|
|
|
|
|
const num = (key: string): number | null => {
|
|
|
|
|
const v = step2Json[key];
|
|
|
|
|
return typeof v === 'number' ? v : null;
|
|
|
|
|
};
|
|
|
|
|
const tpp = step2Json['tasks_per_phase'];
|
|
|
|
|
return {
|
|
|
|
|
present: true,
|
|
|
|
|
total_tasks: num('total_tasks'),
|
|
|
|
|
tasks_per_phase: Array.isArray(tpp) ? tpp : null,
|
|
|
|
|
phase_count: num('phase_count'),
|
|
|
|
|
parallel_groups_identified: num('parallel_groups_identified') ?? 0,
|
|
|
|
|
requirement_coverage_percent: num('requirement_coverage_percent'),
|
|
|
|
|
verification_items_added: num('verification_items_added'),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Fallback: regex scraping ──
|
|
|
|
|
|
2026-02-22 19:57:40 -08:00
|
|
|
// Detect Parallel Execution Groups table
|
|
|
|
|
const parallelGroups = (overview.match(/Parallel\s+Execution\s+Group/gi) ?? []).length;
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// Count phase files (scanned first so we can use sum as a total_tasks fallback)
|
2026-02-22 19:57:40 -08:00
|
|
|
let phaseCount = 0;
|
|
|
|
|
let tasksPerPhase: number[] = [];
|
|
|
|
|
try {
|
|
|
|
|
const files = fs.readdirSync(specDir);
|
|
|
|
|
const phaseFiles = files
|
|
|
|
|
.filter(f => /^phase-\d+\.md$/i.test(f))
|
|
|
|
|
.sort();
|
|
|
|
|
phaseCount = phaseFiles.length;
|
|
|
|
|
for (const pf of phaseFiles) {
|
|
|
|
|
const content = readFileSafe(path.join(specDir, pf)) ?? '';
|
2026-03-01 12:24:11 -08:00
|
|
|
const count = (content.match(/^\s*-\s+\[[ x!/?]\]\s+\*\*Task\s+\d/gm) ?? []).length;
|
2026-02-22 19:57:40 -08:00
|
|
|
tasksPerPhase.push(count);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// leave empty
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// total_tasks priority:
|
|
|
|
|
// 1. Completion Summary canonical count
|
|
|
|
|
// 2. Sum of phase file task counts
|
|
|
|
|
// 3. Stripped overview checkboxes (excluding Success Criteria)
|
|
|
|
|
const summaryCount = parseCompletionSummaryTaskCount(overview);
|
|
|
|
|
const phaseSum = tasksPerPhase.length > 0 ? tasksPerPhase.reduce((a, b) => a + b, 0) : 0;
|
|
|
|
|
const strippedOverview = stripSuccessCriteriaSection(overview);
|
|
|
|
|
const strippedCheckboxCount = (strippedOverview.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
|
|
|
|
|
|
|
|
|
let totalTasks: number;
|
|
|
|
|
if (summaryCount) {
|
|
|
|
|
totalTasks = summaryCount.total;
|
|
|
|
|
} else if (phaseSum > 0) {
|
|
|
|
|
totalTasks = phaseSum;
|
|
|
|
|
} else {
|
|
|
|
|
totalTasks = strippedCheckboxCount;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:57:40 -08:00
|
|
|
// Requirement coverage: look for coverage percentage in overview
|
|
|
|
|
let reqCoverage: number | null = null;
|
|
|
|
|
const covMatch = overview.match(/[Cc]overage[:\s]+(\d{1,3})%/);
|
|
|
|
|
if (covMatch) reqCoverage = parseInt(covMatch[1], 10);
|
|
|
|
|
|
|
|
|
|
// Verification items added (look for verification checklist items)
|
|
|
|
|
const verifItems = (overview.match(/(?:verify|verification|test|check):\s*\[[ x]\]/gi) ?? []).length;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
present: true,
|
2026-03-01 12:24:11 -08:00
|
|
|
total_tasks: totalTasks || null,
|
2026-02-22 19:57:40 -08:00
|
|
|
tasks_per_phase: tasksPerPhase.length > 0 ? tasksPerPhase : null,
|
|
|
|
|
phase_count: phaseCount || null,
|
|
|
|
|
parallel_groups_identified: parallelGroups || 0,
|
|
|
|
|
requirement_coverage_percent: reqCoverage,
|
|
|
|
|
verification_items_added: verifItems || null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// ── Step 3: Implement ──────────────────────────────────────────────────────────
|
2026-02-22 19:57:40 -08:00
|
|
|
|
|
|
|
|
export function collectStep3(specDir: string): Step3ImplementMetrics {
|
2026-03-01 12:24:11 -08:00
|
|
|
// Discover phase-*.md files
|
|
|
|
|
let phaseFiles: string[] = [];
|
|
|
|
|
try {
|
|
|
|
|
const files = fs.readdirSync(specDir);
|
|
|
|
|
phaseFiles = files
|
|
|
|
|
.filter(f => /^phase-\d+\.md$/i.test(f))
|
|
|
|
|
.sort();
|
|
|
|
|
} catch {
|
|
|
|
|
// leave empty
|
2026-02-22 19:57:40 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// Read overview.md for Completion Summary
|
|
|
|
|
const overviewPath = path.join(specDir, 'overview.md');
|
|
|
|
|
const overview = readFileSafe(overviewPath);
|
|
|
|
|
const summaryCount = overview ? parseCompletionSummaryTaskCount(overview) : null;
|
2026-02-22 19:57:40 -08:00
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// ── Primary source: METRICS_JSON in overview.md (from finalize step) ──
|
|
|
|
|
if (overview) {
|
|
|
|
|
const step3Json = extractMetricsJson(overview, 'finalize');
|
|
|
|
|
if (step3Json) {
|
|
|
|
|
const num = (key: string): number | null => {
|
|
|
|
|
const v = step3Json[key];
|
|
|
|
|
return typeof v === 'number' ? v : null;
|
|
|
|
|
};
|
|
|
|
|
const total = num('tasks_total');
|
|
|
|
|
const completed = num('tasks_completed');
|
|
|
|
|
return {
|
|
|
|
|
present: true,
|
|
|
|
|
task_completion_rate: total && total > 0 && completed != null ? completed / total : null,
|
|
|
|
|
tasks_completed: completed,
|
|
|
|
|
tasks_total: total,
|
|
|
|
|
blocker_count: num('blocker_count') ?? 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Fallback: regex scraping ──
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// present = true if phase files exist OR overview has completion data
|
|
|
|
|
const present = phaseFiles.length > 0 || summaryCount != null;
|
|
|
|
|
if (!present) {
|
|
|
|
|
return { present: false, task_completion_rate: null, tasks_completed: null,
|
|
|
|
|
tasks_total: null, blocker_count: null };
|
2026-02-22 19:57:40 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// Count checkboxes in phase files
|
|
|
|
|
let phaseTotal = 0;
|
|
|
|
|
let phaseCompleted = 0;
|
|
|
|
|
let blockerCount = 0;
|
|
|
|
|
for (const pf of phaseFiles) {
|
|
|
|
|
const content = readFileSafe(path.join(specDir, pf)) ?? '';
|
|
|
|
|
phaseTotal += (content.match(/^\s*-\s+\[[ x!/?]\]\s+\*\*Task\s+\d/gm) ?? []).length;
|
|
|
|
|
phaseCompleted += (content.match(/^\s*-\s+\[x\]\s+\*\*Task\s+\d/gm) ?? []).length;
|
|
|
|
|
blockerCount += (content.match(/^\s*-\s+\[!\]\s+\*\*Task\s+\d/gm) ?? []).length;
|
2026-02-22 19:57:40 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// Count checkboxes in overview (stripped of Success Criteria)
|
|
|
|
|
let overviewTotal = 0;
|
|
|
|
|
let overviewCompleted = 0;
|
|
|
|
|
if (overview) {
|
|
|
|
|
const stripped = stripSuccessCriteriaSection(overview);
|
|
|
|
|
overviewTotal = (stripped.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
|
|
|
|
overviewCompleted = (stripped.match(/^\s*-\s+\[x\]/gm) ?? []).length;
|
2026-02-22 19:57:40 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// Task counts priority:
|
|
|
|
|
// 1. Completion Summary in overview.md
|
|
|
|
|
// 2. Checkbox counting in phase files
|
|
|
|
|
// 3. Checkbox counting in overview.md (stripped of Success Criteria)
|
2026-02-22 19:57:40 -08:00
|
|
|
let tasksTotal: number | null = null;
|
|
|
|
|
let tasksCompleted: number | null = null;
|
2026-03-01 12:24:11 -08:00
|
|
|
if (summaryCount) {
|
|
|
|
|
tasksTotal = summaryCount.total;
|
|
|
|
|
tasksCompleted = summaryCount.completed;
|
|
|
|
|
} else if (phaseTotal > 0) {
|
|
|
|
|
tasksTotal = phaseTotal;
|
|
|
|
|
tasksCompleted = phaseCompleted;
|
|
|
|
|
} else if (overviewTotal > 0) {
|
|
|
|
|
tasksTotal = overviewTotal;
|
|
|
|
|
tasksCompleted = overviewCompleted;
|
2026-02-22 19:57:40 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const taskCompletionRate = tasksTotal && tasksTotal > 0 && tasksCompleted != null
|
|
|
|
|
? tasksCompleted / tasksTotal
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
present: true,
|
|
|
|
|
task_completion_rate: taskCompletionRate,
|
2026-03-01 12:24:11 -08:00
|
|
|
tasks_completed: tasksCompleted,
|
2026-02-22 19:57:40 -08:00
|
|
|
tasks_total: tasksTotal,
|
|
|
|
|
blocker_count: blockerCount,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Step 4: Finalize ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function collectStep4(specDir: string): Step4FinalizeMetrics {
|
|
|
|
|
// Check if spec was archived (specs--completed exists at parent level)
|
|
|
|
|
const specDirName = path.basename(specDir);
|
|
|
|
|
const parentDir = path.dirname(specDir);
|
|
|
|
|
const completedPath = path.join(parentDir, '..', 'specs--completed', specDirName);
|
|
|
|
|
const archivalSucceeded = fs.existsSync(completedPath);
|
|
|
|
|
|
|
|
|
|
// Read overview for completion metrics
|
|
|
|
|
const overviewPath = path.join(specDir, 'overview.md');
|
|
|
|
|
|
|
|
|
|
// If archived, try from archived location
|
|
|
|
|
const effectiveOverviewPath = archivalSucceeded
|
|
|
|
|
? path.join(completedPath, 'overview.md')
|
|
|
|
|
: overviewPath;
|
|
|
|
|
|
|
|
|
|
const overview = readFileSafe(effectiveOverviewPath) ?? readFileSafe(overviewPath);
|
|
|
|
|
if (!overview) {
|
|
|
|
|
return { present: false, completion_rate_at_audit: null,
|
|
|
|
|
verification_failures_found: null, documentation_updates_needed: null,
|
|
|
|
|
archival_succeeded: archivalSucceeded };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:14:15 -07:00
|
|
|
// ── Primary source: METRICS_JSON in overview.md ──
|
|
|
|
|
const step4Json = extractMetricsJson(overview, 'finalize');
|
|
|
|
|
if (step4Json) {
|
|
|
|
|
const num = (key: string): number | null => {
|
|
|
|
|
const v = step4Json[key];
|
|
|
|
|
return typeof v === 'number' ? v : null;
|
|
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
present: true,
|
|
|
|
|
completion_rate_at_audit: num('completion_rate_at_audit'),
|
|
|
|
|
verification_failures_found: num('verification_failures_found') ?? 0,
|
|
|
|
|
documentation_updates_needed: num('documentation_updates_needed'),
|
|
|
|
|
archival_succeeded: archivalSucceeded,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Fallback: regex scraping ──
|
|
|
|
|
|
2026-03-01 12:24:11 -08:00
|
|
|
// Completion rate: prefer Completion Summary, fall back to stripped overview checkboxes
|
|
|
|
|
let completionRate: number | null = null;
|
|
|
|
|
const summaryCount = parseCompletionSummaryTaskCount(overview);
|
|
|
|
|
if (summaryCount) {
|
|
|
|
|
completionRate = summaryCount.total > 0 ? summaryCount.completed / summaryCount.total : null;
|
|
|
|
|
} else {
|
|
|
|
|
const stripped = stripSuccessCriteriaSection(overview);
|
|
|
|
|
const totalTasks = (stripped.match(/^\s*-\s+\[[ x!/?]\]/gm) ?? []).length;
|
|
|
|
|
const completedTasks = (stripped.match(/^\s*-\s+\[x\]/gm) ?? []).length;
|
|
|
|
|
completionRate = totalTasks > 0 ? completedTasks / totalTasks : null;
|
|
|
|
|
}
|
2026-02-22 19:57:40 -08:00
|
|
|
|
|
|
|
|
// Verification failures: look for [!] tasks or "verification failed" text
|
|
|
|
|
const blockedTasks = (overview.match(/^\s*-\s+\[!\]/gm) ?? []).length;
|
|
|
|
|
|
|
|
|
|
// Documentation updates needed (look for TODO or "update" markers)
|
|
|
|
|
const docUpdates = (overview.match(/(?:TODO|FIXME|update\s+(?:README|CHANGELOG|docs))/gi) ?? []).length;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
present: true,
|
|
|
|
|
completion_rate_at_audit: completionRate,
|
|
|
|
|
verification_failures_found: blockedTasks || 0,
|
|
|
|
|
documentation_updates_needed: docUpdates || null,
|
|
|
|
|
archival_succeeded: archivalSucceeded,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 21:38:58 -08:00
|
|
|
// ── User Feedback ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function collectUserFeedback(specDir: string): UserFeedback | null {
|
|
|
|
|
// Try both active and archived locations for overview.md
|
|
|
|
|
const overviewPath = path.join(specDir, 'overview.md');
|
|
|
|
|
const specDirName = path.basename(specDir);
|
|
|
|
|
const parentDir = path.dirname(specDir);
|
|
|
|
|
const completedPath = path.join(parentDir, '..', 'specs--completed', specDirName, 'overview.md');
|
|
|
|
|
|
|
|
|
|
const overview = readFileSafe(overviewPath) ?? readFileSafe(completedPath);
|
|
|
|
|
if (!overview) return null;
|
|
|
|
|
|
|
|
|
|
// Look for ## User Feedback section with a markdown table
|
|
|
|
|
const feedbackMatch = overview.match(
|
|
|
|
|
/## User Feedback\s*\n\s*\|[^\n]*\|\s*\n\s*\|[-| ]+\|\s*\n([\s\S]*?)(?=\n##\s|\n*$)/
|
|
|
|
|
);
|
|
|
|
|
if (!feedbackMatch) return null;
|
|
|
|
|
|
|
|
|
|
const tableBody = feedbackMatch[1];
|
|
|
|
|
|
|
|
|
|
// Parse table rows: | Field | Value |
|
|
|
|
|
// Use regex to split only on unescaped pipes, preserving whitespace in values
|
|
|
|
|
const rows = tableBody.split('\n').filter(l => l.trim().startsWith('|'));
|
|
|
|
|
const fields: Record<string, string> = {};
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
// Split on unescaped pipes (not preceded by backslash)
|
|
|
|
|
const cells = row.split(/(?<!\\)\|/).map(c => c.trim()).filter(c => c.length > 0);
|
|
|
|
|
if (cells.length >= 2) {
|
|
|
|
|
const value = cells.slice(1).join('|').replace(/\\\|/g, '|');
|
|
|
|
|
fields[cells[0].toLowerCase()] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rating = parseInt(fields['rating'] ?? '', 10);
|
|
|
|
|
if (isNaN(rating) || rating < 1 || rating > 10) return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
overall_rating: rating,
|
|
|
|
|
rating_reason: fields['reason'] ?? '',
|
|
|
|
|
what_went_well: fields['went well'] ?? '',
|
|
|
|
|
what_went_poorly: fields['went poorly'] ?? '',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:57:40 -08:00
|
|
|
// ── Main collector ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface CollectorOptions {
|
|
|
|
|
specDir: string; // Path to the spec directory (specs/<feature-name>)
|
|
|
|
|
projectName: string; // Human-readable project name
|
|
|
|
|
plan2codeRoot: string; // Path to the plan2code repo (for prompt hashing)
|
|
|
|
|
plan2codeVersion: string; // e.g. "1.7.0"
|
|
|
|
|
outputDir: string; // Where to write <run-id>.json
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function collectRun(opts: CollectorOptions): Promise<RunMetrics> {
|
|
|
|
|
const {
|
|
|
|
|
specDir,
|
|
|
|
|
projectName,
|
|
|
|
|
plan2codeRoot,
|
|
|
|
|
plan2codeVersion,
|
|
|
|
|
outputDir,
|
|
|
|
|
} = opts;
|
|
|
|
|
|
|
|
|
|
const runId = generateRunId();
|
|
|
|
|
|
|
|
|
|
// Get started_at / completed_at from spec directory mtime / iteration.log
|
|
|
|
|
let startedAt: string | null = null;
|
|
|
|
|
let completedAt: string | null = null;
|
|
|
|
|
try {
|
|
|
|
|
const stat = fs.statSync(specDir);
|
|
|
|
|
startedAt = stat.birthtime.toISOString();
|
|
|
|
|
completedAt = stat.mtime.toISOString();
|
|
|
|
|
} catch {
|
|
|
|
|
// leave null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const metrics: RunMetrics = {
|
|
|
|
|
schema_version: '1.0',
|
|
|
|
|
run_id: runId,
|
|
|
|
|
plan2code_version: plan2codeVersion,
|
|
|
|
|
prompt_versions: collectPromptVersions(plan2codeRoot),
|
|
|
|
|
project: {
|
|
|
|
|
name: projectName,
|
|
|
|
|
started_at: startedAt,
|
|
|
|
|
completed_at: completedAt,
|
|
|
|
|
},
|
|
|
|
|
step1_plan: collectStep1(specDir),
|
|
|
|
|
step2_document: collectStep2(specDir),
|
|
|
|
|
step3_implement: collectStep3(specDir),
|
|
|
|
|
step4_finalize: collectStep4(specDir),
|
2026-02-22 21:38:58 -08:00
|
|
|
user_feedback: collectUserFeedback(specDir),
|
2026-02-22 19:57:40 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Write to output dir
|
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
|
const outPath = path.join(outputDir, `${runId}.json`);
|
|
|
|
|
fs.writeFileSync(outPath, JSON.stringify(metrics, null, 2), 'utf8');
|
|
|
|
|
|
|
|
|
|
return metrics;
|
|
|
|
|
}
|