feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A) (#2341)

* feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A)

Golden/read-only parity tests and registry alignment, query handler fixes
(check-completion, state-mutation, commit, validate, summary, etc.), and
WAITING.json dual-write for .gsd/.planning readers.

Refs gsd-build/get-shit-done#2341

* fix(sdk): getMilestoneInfo matches GSD ROADMAP (🟡, last bold, STATE fallback)

- Recognize in-flight 🟡 milestone bullets like 🚧.
- Derive from last **vX.Y Title** before ## Phases when emoji absent.
- Fall back to STATE.md milestone when ROADMAP is missing; use last bare vX.Y
  in cleaned text instead of first (avoids v1.0 from shipped list).
- Fixes init.execute-phase milestone_version and buildStateFrontmatter after
  state.begin-phase (syncStateFrontmatter).

* feat(sdk): phase list, plan task structure, requirements extract handlers

- Register phase.list-plans, phase.list-artifacts, plan.task-structure,
  requirements.extract-from-plans (SDK-only; golden-policy exceptions).
- Add unit tests; document in QUERY-HANDLERS.md.
- writeProfile: honor --output, render dimensions, return profile_path and dimensions_scored.

* feat(sdk): centralize getGsdAgentsDir in query helpers

Extract agent directory resolution to helpers (GSD_AGENTS_DIR, primary
~/.claude/agents, legacy path). Use from init and docs-init init bundles.

docs(15): add 15-CONTEXT for autonomous phase-15 run.

* feat(sdk): query CLI CJS fallback and session correlation

- createRegistry(eventStream, sessionId) threads correlation into mutation events
- gsd-sdk query falls back to gsd-tools.cjs when no native handler matches
  (disable with GSD_QUERY_FALLBACK=off); stderr bridge warnings
- Export createRegistry from @gsd-build/sdk; add sdk/README.md
- Update QUERY-HANDLERS.md and registry module docs for fallback + sessionId
- Agents: prefer node dist/cli.js query over cat/grep for STATE and plans

* fix(sdk): init phase_found parity, docs-init agents path, state field extract

- Normalize findPhase not-found to null before roadmap fallback (matches findPhaseInternal)

- docs-init: use detectRuntime + resolveAgentsDir for checkAgentsInstalled

- state.cjs stateExtractField: horizontal whitespace only after colon (YAML progress guard)

- Tests: commit_docs default true; config-get golden uses temp config; golden integration green

Refs: #2302

* refactor(sdk): share SessionJsonlRecord in profile-extract-messages

CodeRabbit nit: dedupe JSONL record shape for isGenuineUserMessage and streamExtractMessages.

* fix(sdk): address CodeRabbit major threads (paths, gates, audit, verify)

- Resolve @file: and CLI JSON indirection relative to projectDir; guard empty normalized query command

- plan.task-structure + intel extract/patch-meta: resolvePathUnderProject containment

- check.config-gates: safe string booleans; plan_checker alias precedence over plan_check default

- state.validate/sync: phaseTokenMatches + comparePhaseNum ordering

- verify.schema-drift: token match phase dirs; files_modified from parsed frontmatter

- audit-open: has_scan_errors, unreadable rows, human report when scans fail

- requirements PLANNED key PLAN for root PLAN.md; gsd-tools timeout note

- ingest-docs: repo-root path containment; classifier output slug-hash

Golden parity test strips has_scan_errors until CJS adds field.

* fix: Resolve CodeRabbit security and quality findings
- Secure intel.ts and cli.ts against path traversal
- Catch and validate git add status in commit.ts
- Expand roadmap milestone marker extraction
- Fix parsing array-of-objects in frontmatter YAML
- Fix unhandled config evaluations
- Improve coverage test parity mapping

* test: raise planner character extraction limit to 48K

* fix(sdk): resolve TS build error in docs-init passing config
This commit is contained in:
Rezolv
2026-04-20 18:09:02 -04:00
committed by GitHub
parent c8807e38d7
commit c5b1445529
109 changed files with 11588 additions and 1030 deletions

View File

@@ -19,7 +19,9 @@ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, unlink
import { join, relative } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { comparePhaseNum, normalizePhaseName, planningPaths, toPosixPath } from './helpers.js';
import { getMilestoneInfo, roadmapAnalyze } from './roadmap.js';
import { getMilestoneInfo, extractCurrentMilestone, roadmapGetPhase } from './roadmap.js';
import { getMilestonePhaseFilter } from './state.js';
import { findPhase } from './phase.js';
import type { QueryHandler } from './utils.js';
// ─── Internal helpers ─────────────────────────────────────────────────────
@@ -34,8 +36,13 @@ import type { QueryHandler } from './utils.js';
* @param phaseDir - Absolute path to the phase directory
* @returns Status string: Pending, Planned, In Progress, Executed, Complete, Needs Review
*/
export async function determinePhaseStatus(plans: number, summaries: number, phaseDir: string): Promise<string> {
if (plans === 0) return 'Pending';
export async function determinePhaseStatus(
plans: number,
summaries: number,
phaseDir: string,
defaultWhenNoPlans: string = 'Pending',
): Promise<string> {
if (plans === 0) return defaultWhenNoPlans;
if (summaries < plans && summaries > 0) return 'In Progress';
if (summaries < plans) return 'Planned';
@@ -117,77 +124,369 @@ export const progressJson: QueryHandler = async (_args, projectDir) => {
// ─── progressBar ─────────────────────────────────────────────────────────
/**
* Progress bar line — port of `cmdProgressRender` `format === 'bar'` from commands.cjs (lines 588–593).
* Uses the same plan/summary counts as `progressJson` / CJS (not `roadmap.analyze` percent).
*/
export const progressBar: QueryHandler = async (_args, projectDir) => {
const analysis = await roadmapAnalyze([], projectDir);
const data = analysis.data as Record<string, unknown>;
const percent = (data.progress_percent as number) || 0;
const total = 20;
const filled = Math.round((percent / 100) * total);
const bar = '[' + '#'.repeat(filled) + '-'.repeat(total - filled) + ']';
return { data: { bar: `${bar} ${percent}%`, percent } };
const json = await progressJson([], projectDir);
const d = json.data as {
total_plans: number;
total_summaries: number;
percent: number;
};
const totalPlans = d.total_plans;
const totalSummaries = d.total_summaries;
const percent = d.percent;
const barWidth = 20;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
return { data: { bar: text, percent, completed: totalSummaries, total: totalPlans } };
};
/**
* Markdown progress table — port of `cmdProgressRender` `format === 'table'` from commands.cjs (lines 575–587).
*/
export const progressTable: QueryHandler = async (_args, projectDir) => {
const json = await progressJson([], projectDir);
const d = json.data as {
milestone_version: string;
milestone_name: string;
phases: Array<{
number: string;
name: string;
plans: number;
summaries: number;
status: string;
}>;
total_plans: number;
total_summaries: number;
percent: number;
};
const { milestone_version, milestone_name, phases, total_plans, total_summaries, percent } = d;
const barWidth = 10;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
let out = `# ${milestone_version} ${milestone_name}\n\n`;
out += `**Progress:** [${bar}] ${total_summaries}/${total_plans} plans (${percent}%)\n\n`;
out += '| Phase | Name | Plans | Status |\n';
out += '|-------|------|-------|--------|\n';
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
}
return { data: { rendered: out } };
};
// ─── statsJson ───────────────────────────────────────────────────────────
export const statsJson: QueryHandler = async (_args, projectDir) => {
const paths = planningPaths(projectDir);
let phasesTotal = 0;
let plansTotal = 0;
let summariesTotal = 0;
let completedPhases = 0;
/**
* Statistics aggregate — port of `cmdStats` JSON/table output from commands.cjs lines 816–971.
*/
export const statsJson: QueryHandler = async (args, projectDir) => {
const format = args[0] || 'json';
const phasesDir = planningPaths(projectDir).phases;
const roadmapPath = planningPaths(projectDir).roadmap;
const reqPath = planningPaths(projectDir).requirements;
const statePath = planningPaths(projectDir).state;
const milestone = await getMilestoneInfo(projectDir);
const isDirInMilestone = await getMilestonePhaseFilter(projectDir);
if (existsSync(paths.phases)) {
try {
const entries = readdirSync(paths.phases, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
phasesTotal++;
const phaseDir = join(paths.phases, entry.name);
const files = readdirSync(phaseDir);
const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
plansTotal += plans.length;
summariesTotal += summaries.length;
if (summaries.length >= plans.length && plans.length > 0) completedPhases++;
}
} catch { /* skip */ }
const phasesByNumber = new Map<
string,
{ number: string; name: string; plans: number; summaries: number; status: string }
>();
let totalPlans = 0;
let totalSummaries = 0;
try {
const roadmapContent = await extractCurrentMilestone(await readFile(roadmapPath, 'utf-8'), projectDir);
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let match: RegExpExecArray | null;
while ((match = headingPattern.exec(roadmapContent)) !== null) {
const key = normalizePhaseName(match[1]);
phasesByNumber.set(key, {
number: key,
name: match[2].replace(/\(INSERTED\)/i, '').trim(),
plans: 0,
summaries: 0,
status: 'Not Started',
});
}
} catch { /* intentionally empty */ }
try {
const entries = readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(isDirInMilestone)
.sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const phaseNum = dm ? dm[1] : dir;
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
const phaseFiles = readdirSync(join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
totalPlans += plans;
totalSummaries += summaries;
const status = await determinePhaseStatus(plans, summaries, join(phasesDir, dir), 'Not Started');
const normalizedNum = normalizePhaseName(phaseNum);
const existing = phasesByNumber.get(normalizedNum);
phasesByNumber.set(normalizedNum, {
number: normalizedNum,
name: existing?.name || phaseName,
plans: (existing?.plans || 0) + plans,
summaries: (existing?.summaries || 0) + summaries,
status,
});
}
} catch { /* intentionally empty */ }
const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
const completedPhases = phases.filter(p => p.status === 'Complete').length;
const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
const percent =
phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0;
let requirementsTotal = 0;
let requirementsComplete = 0;
try {
if (existsSync(reqPath)) {
const reqContent = readFileSync(reqPath, 'utf-8');
const checked = reqContent.match(/^- \[x\] \*\*/gm);
const unchecked = reqContent.match(/^- \[ \] \*\*/gm);
requirementsComplete = checked ? checked.length : 0;
requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0);
}
} catch { /* intentionally empty */ }
let lastActivity: string | null = null;
try {
if (existsSync(statePath)) {
const stateContent = readFileSync(statePath, 'utf-8');
const activityMatch =
stateContent.match(/^last_activity:\s*(.+)$/im)
|| stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i)
|| stateContent.match(/^Last Activity:\s*(.+)$/im)
|| stateContent.match(/^Last activity:\s*(.+)$/im);
if (activityMatch) lastActivity = activityMatch[1].trim();
}
} catch { /* intentionally empty */ }
const { execGit } = await import('./commit.js');
let gitCommits = 0;
let gitFirstCommitDate: string | null = null;
const commitCount = execGit(projectDir, ['rev-list', '--count', 'HEAD']);
if (commitCount.exitCode === 0) {
gitCommits = parseInt(commitCount.stdout, 10) || 0;
}
const rootHash = execGit(projectDir, ['rev-list', '--max-parents=0', 'HEAD']);
if (rootHash.exitCode === 0 && rootHash.stdout) {
const firstCommit = rootHash.stdout.split('\n')[0].trim();
const firstDate = execGit(projectDir, ['show', '-s', '--format=%as', firstCommit]);
if (firstDate.exitCode === 0) {
gitFirstCommitDate = firstDate.stdout.trim() || null;
}
}
const progressPercent = phasesTotal > 0 ? Math.round((completedPhases / phasesTotal) * 100) : 0;
return {
data: {
phases_total: phasesTotal,
plans_total: plansTotal,
summaries_total: summariesTotal,
completed_phases: completedPhases,
in_progress_phases: phasesTotal - completedPhases,
progress_percent: progressPercent,
},
const result = {
milestone_version: milestone.version,
milestone_name: milestone.name,
phases,
phases_completed: completedPhases,
phases_total: phases.length,
total_plans: totalPlans,
total_summaries: totalSummaries,
percent,
plan_percent: planPercent,
requirements_total: requirementsTotal,
requirements_complete: requirementsComplete,
git_commits: gitCommits,
git_first_commit_date: gitFirstCommitDate,
last_activity: lastActivity,
};
if (format === 'table') {
const barWidth = 10;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
let out = `# ${milestone.version} ${milestone.name} \u2014 Statistics\n\n`;
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
if (totalPlans > 0) {
out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
}
out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
if (requirementsTotal > 0) {
out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
}
out += '\n';
out += '| Phase | Name | Plans | Completed | Status |\n';
out += '|-------|------|-------|-----------|--------|\n';
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
}
if (gitCommits > 0) {
out += `\n**Git:** ${gitCommits} commits`;
if (gitFirstCommitDate) out += ` (since ${gitFirstCommitDate})`;
out += '\n';
}
if (lastActivity) out += `**Last activity:** ${lastActivity}\n`;
return { data: { rendered: out } };
}
return { data: result };
};
/**
* Markdown statistics table — port of `cmdStats` `format === 'table'` from commands.cjs (lines 942–967).
* Delegates to `statsJson` with `['table']` (same `rendered` string as CJS).
*/
export const statsTable: QueryHandler = async (_args, projectDir) => {
return statsJson(['table'], projectDir);
};
// ─── todoMatchPhase ──────────────────────────────────────────────────────
/**
* Match pending todos against a phase — port of `cmdTodoMatchPhase` from commands.cjs lines 612–729.
*/
export const todoMatchPhase: QueryHandler = async (args, projectDir) => {
const phase = args[0];
const todosDir = join(projectDir, '.planning', 'todos');
const todos: Array<{ file: string; phase: string }> = [];
if (!existsSync(todosDir)) {
return { data: { todos: [], count: 0, phase: phase || null } };
if (!phase) {
throw new GSDError('phase required for todo match-phase', ErrorClassification.Validation);
}
const pendingDir = join(projectDir, '.planning', 'todos', 'pending');
const todos: Array<{
file: string;
title: string;
area: string;
files: string[];
body: string;
}> = [];
try {
const files = readdirSync(todosDir).filter(f => f.endsWith('.md') || f.endsWith('.json'));
const files = readdirSync(pendingDir).filter(f => f.endsWith('.md'));
for (const file of files) {
if (!phase || file.includes(normalizePhaseName(phase)) || file.includes(phase)) {
todos.push({ file: toPosixPath(join('.planning', 'todos', file)), phase: phase || 'all' });
}
try {
const content = readFileSync(join(pendingDir, file), 'utf-8');
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
const filesMatch = content.match(/^files:\s*(.+)$/m);
const body = content.replace(/^(title|area|files|created|priority):.*$/gm, '').trim();
todos.push({
file,
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: areaMatch ? areaMatch[1].trim() : 'general',
files: filesMatch ? filesMatch[1].trim().split(/[,\s]+/).filter(Boolean) : [],
body: body.slice(0, 200),
});
} catch { /* skip */ }
}
} catch { /* skip */ }
return { data: { todos, count: todos.length, phase: phase || null } };
if (todos.length === 0) {
return { data: { phase, matches: [], todo_count: 0 } };
}
const rp = await roadmapGetPhase([phase], projectDir);
const pd = rp.data as Record<string, unknown>;
let phaseName = '';
let phaseGoal = '';
let phaseSection = '';
if (pd && pd.found === true) {
phaseName = String(pd.phase_name || '');
phaseGoal = pd.goal != null ? String(pd.goal) : '';
phaseSection = String(pd.section || '');
}
const phaseText = `${phaseName} ${phaseGoal} ${phaseSection}`.toLowerCase();
const stopWords = new Set([
'the', 'and', 'for', 'with', 'from', 'that', 'this', 'will', 'are', 'was', 'has', 'have',
'been', 'not', 'but', 'all', 'can', 'into', 'each', 'when', 'any', 'use', 'new',
]);
const phaseKeywords = new Set(
phaseText
.split(/[\s\-_/.,;:()\[\]{}|]+/)
.map(w => w.replace(/[^a-z0-9]/g, ''))
.filter(w => w.length > 2 && !stopWords.has(w)),
);
const fp = await findPhase([phase], projectDir);
const phaseInfoDisk = fp.data as Record<string, unknown>;
const phasePlans: string[] = [];
if (phaseInfoDisk && phaseInfoDisk.found) {
try {
const phaseDir = join(projectDir, phaseInfoDisk.directory as string);
const planFiles = readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
for (const pf of planFiles) {
try {
const planContent = readFileSync(join(phaseDir, pf), 'utf-8');
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
if (fmFiles) {
phasePlans.push(
...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean),
);
}
} catch { /* skip */ }
}
} catch { /* skip */ }
}
const matches: Array<{ file: string; title: string; area: string; score: number; reasons: string[] }> = [];
for (const todo of todos) {
let score = 0;
const reasons: string[] = [];
const todoWords = `${todo.title} ${todo.body}`
.toLowerCase()
.split(/[\s\-_/.,;:()\[\]{}|]+/)
.map(w => w.replace(/[^a-z0-9]/g, ''))
.filter(w => w.length > 2 && !stopWords.has(w));
const matchedKeywords = todoWords.filter(w => phaseKeywords.has(w));
if (matchedKeywords.length > 0) {
score += Math.min(matchedKeywords.length * 0.2, 0.6);
reasons.push(`keywords: ${[...new Set(matchedKeywords)].slice(0, 5).join(', ')}`);
}
if (todo.area !== 'general' && phaseText.includes(todo.area.toLowerCase())) {
score += 0.3;
reasons.push(`area: ${todo.area}`);
}
if (todo.files.length > 0 && phasePlans.length > 0) {
const fileOverlap = todo.files.filter(f =>
phasePlans.some(pf => pf.includes(f) || f.includes(pf)),
);
if (fileOverlap.length > 0) {
score += 0.4;
reasons.push(`files: ${fileOverlap.slice(0, 3).join(', ')}`);
}
}
if (score > 0) {
matches.push({
file: todo.file,
title: todo.title,
area: todo.area,
score: Math.round(score * 100) / 100,
reasons,
});
}
}
matches.sort((a, b) => b.score - a.score);
return { data: { phase, matches, todo_count: todos.length } };
};
// ─── listTodos ──────────────────────────────────────────────────────────