* 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:
@@ -17,6 +17,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
@@ -53,9 +54,30 @@ export function stripShippedMilestones(content: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get milestone version and name from ROADMAP.md.
|
||||
* Read milestone + name from STATE.md frontmatter when ROADMAP does not encode them.
|
||||
*/
|
||||
async function parseMilestoneFromState(projectDir: string): Promise<{ version: string; name: string } | null> {
|
||||
try {
|
||||
const stateRaw = await readFile(planningPaths(projectDir).state, 'utf-8');
|
||||
const vm = stateRaw.match(/^milestone:\s*(.+)$/m);
|
||||
if (!vm) return null;
|
||||
const version = vm[1].trim().replace(/^["']|["']$/g, '');
|
||||
const nm = stateRaw.match(/^milestone_name:\s*(.+)$/m);
|
||||
const name = nm ? nm[1].trim().replace(/^["']|["']$/g, '') : 'milestone';
|
||||
return { version, name };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get milestone version and name from ROADMAP.md (and optionally STATE.md).
|
||||
*
|
||||
* Port of getMilestoneInfo from core.cjs lines 1367-1402.
|
||||
* Port of getMilestoneInfo from core.cjs lines 1367-1402, extended for:
|
||||
* - 🟡 in-flight marker (same list shape as 🚧)
|
||||
* - milestone bullets `**vX.Y Title**` before `## Phases` (last = current when listed in semver order)
|
||||
* - STATE.md frontmatter when ROADMAP has no parseable milestone
|
||||
* - **last** bare `vX.Y` fallback (first match was often v1.0 from the shipped list)
|
||||
*
|
||||
* @param projectDir - Project root directory
|
||||
* @returns Object with version and name
|
||||
@@ -64,26 +86,48 @@ export async function getMilestoneInfo(projectDir: string): Promise<{ version: s
|
||||
try {
|
||||
const roadmap = await readFile(planningPaths(projectDir).roadmap, 'utf-8');
|
||||
|
||||
// First: check for list-format using in-progress marker
|
||||
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
if (inProgressMatch) {
|
||||
return { version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() };
|
||||
// List-format: construction / blocked (legacy emoji)
|
||||
const barricadeMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
if (barricadeMatch) {
|
||||
return { version: 'v' + barricadeMatch[1], name: barricadeMatch[2].trim() };
|
||||
}
|
||||
|
||||
// Second: heading-format — strip shipped milestones
|
||||
// List-format: in flight / active (GSD ROADMAP template uses 🟡 for current milestone)
|
||||
const inFlightMatch = roadmap.match(/🟡\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
if (inFlightMatch) {
|
||||
return { version: 'v' + inFlightMatch[1], name: inFlightMatch[2].trim() };
|
||||
}
|
||||
|
||||
// Heading-format — strip shipped <details> blocks first
|
||||
const cleaned = stripShippedMilestones(roadmap);
|
||||
const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
||||
const headingMatch = cleaned.match(/##\s+.*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
||||
if (headingMatch) {
|
||||
return { version: 'v' + headingMatch[1], name: headingMatch[2].trim() };
|
||||
}
|
||||
|
||||
// Fallback: bare version match
|
||||
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
||||
return {
|
||||
version: versionMatch ? versionMatch[0] : 'v1.0',
|
||||
name: 'milestone',
|
||||
};
|
||||
// Milestone bullet list (## Milestones … ## Phases): use last **vX.Y Title** — typically the current row
|
||||
const beforePhases = roadmap.split(/^##\s+Phases\b/m)[0] ?? roadmap;
|
||||
const boldMatches = [...beforePhases.matchAll(/\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/g)];
|
||||
if (boldMatches.length > 0) {
|
||||
const last = boldMatches[boldMatches.length - 1];
|
||||
return { version: 'v' + last[1], name: last[2].trim() };
|
||||
}
|
||||
|
||||
const fromState = await parseMilestoneFromState(projectDir);
|
||||
if (fromState) {
|
||||
return fromState;
|
||||
}
|
||||
|
||||
const allBare = [...cleaned.matchAll(/\bv(\d+(?:\.\d+)+)\b/g)];
|
||||
if (allBare.length > 0) {
|
||||
const lastBare = allBare[allBare.length - 1];
|
||||
return { version: lastBare[0], name: 'milestone' };
|
||||
}
|
||||
|
||||
return { version: 'v1.0', name: 'milestone' };
|
||||
} catch {
|
||||
const fromState = await parseMilestoneFromState(projectDir);
|
||||
if (fromState) return fromState;
|
||||
return { version: 'v1.0', name: 'milestone' };
|
||||
}
|
||||
}
|
||||
@@ -110,7 +154,7 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
|
||||
|
||||
// Fallback: derive from ROADMAP in-progress marker
|
||||
if (!version) {
|
||||
const inProgressMatch = content.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s/);
|
||||
const inProgressMatch = content.match(/(?:🚧|🟡)\s*\*\*v(\d+(?:\.\d+)+)\s/);
|
||||
if (inProgressMatch) {
|
||||
version = 'v' + inProgressMatch[1];
|
||||
}
|
||||
@@ -135,14 +179,13 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
|
||||
const headingLevelMatch = sectionMatch[1].match(/^(#{1,3})\s/);
|
||||
const headingLevel = headingLevelMatch ? headingLevelMatch[1].length : 2;
|
||||
const restContent = content.slice(sectionStart + sectionMatch[0].length);
|
||||
|
||||
// Extract current version so same-version sub-headings are not treated as boundaries.
|
||||
// Capture full semver (major.minor.patch) so v2.0.1 is not collapsed to "2.0".
|
||||
const currentVersionMatch = version ? version.match(/v(\d+(?:\.\d+)+)/i) : null;
|
||||
const currentVersionStr = currentVersionMatch ? currentVersionMatch[1] : '';
|
||||
|
||||
const nextMilestoneRegex = new RegExp(
|
||||
`^#{1,${headingLevel}}\\s+(?:.*v(\\d+(?:\\.\\d+)+)[^\\n]*|.*(?:✅|📋|🚧))`,
|
||||
`^#{1,${headingLevel}}\\s+(?:.*v(\\d+(?:\\.\\d+)+)[^\\n]*|.*(?:✅|📋|🚧|🟡))`,
|
||||
'gm'
|
||||
);
|
||||
|
||||
@@ -419,57 +462,83 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir) => {
|
||||
return { data: result };
|
||||
};
|
||||
|
||||
// ─── roadmapUpdatePlanProgress ────────────────────────────────────────────
|
||||
|
||||
export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir) => {
|
||||
const phase = args[0];
|
||||
const paths = planningPaths(projectDir);
|
||||
|
||||
if (!phase) {
|
||||
return { data: { updated: false, reason: 'phase argument required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
let content = await readFile(paths.roadmap, 'utf-8');
|
||||
const phaseNum = normalizePhaseName(phase);
|
||||
const updated = content.replace(
|
||||
/(-\s*\[\s*\]\s*(?:Plan\s+\d+|plan\s+\d+|\*\*Plan))/gi,
|
||||
(match) => match.replace('[ ]', '[x]'),
|
||||
);
|
||||
if (updated !== content) {
|
||||
await writeFile(paths.roadmap, updated, 'utf-8');
|
||||
return { data: { updated: true, phase: phaseNum } };
|
||||
}
|
||||
return { data: { updated: false, phase: phaseNum, reason: 'no matching checkbox found' } };
|
||||
} catch {
|
||||
return { data: { updated: false, reason: 'ROADMAP.md not found or unreadable' } };
|
||||
}
|
||||
};
|
||||
|
||||
// ─── requirementsMarkComplete ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mark requirement IDs complete in REQUIREMENTS.md (checkbox + traceability table).
|
||||
* Port of `cmdRequirementsMarkComplete` from milestone.cjs lines 11–87.
|
||||
*/
|
||||
export const requirementsMarkComplete: QueryHandler = async (args, projectDir) => {
|
||||
const reqIds = args;
|
||||
const paths = planningPaths(projectDir);
|
||||
if (args.length === 0) {
|
||||
throw new GSDError(
|
||||
'requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
const reqIds = args
|
||||
.join(' ')
|
||||
.replace(/[\[\]]/g, '')
|
||||
.split(/[,\s]+/)
|
||||
.map(r => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (reqIds.length === 0) {
|
||||
return { data: { marked: false, reason: 'requirement IDs required' } };
|
||||
throw new GSDError('no valid requirement IDs found', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
try {
|
||||
let content = await readFile(paths.requirements, 'utf-8');
|
||||
let changeCount = 0;
|
||||
const paths = planningPaths(projectDir);
|
||||
if (!existsSync(paths.requirements)) {
|
||||
return { data: { updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds } };
|
||||
}
|
||||
|
||||
for (const id of reqIds) {
|
||||
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`(-\\s*\\[\\s*\\]\\s*)([^\\n]*${escaped})`, 'gi');
|
||||
content = content.replace(pattern, (_m, _bracket, rest) => `- [x] ${rest}`.trim() + '\n' || `- [x] ${rest}`);
|
||||
if (content.includes(`[x]`) && content.includes(id)) changeCount++;
|
||||
let reqContent = (await readFile(paths.requirements, 'utf-8')).replace(/\r\n/g, '\n');
|
||||
const updated: string[] = [];
|
||||
const alreadyComplete: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
let found = false;
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
|
||||
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
||||
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
|
||||
if (afterCheckbox !== reqContent) {
|
||||
reqContent = afterCheckbox;
|
||||
found = true;
|
||||
}
|
||||
|
||||
await writeFile(paths.requirements, content, 'utf-8');
|
||||
return { data: { marked: true, ids: reqIds, changed: changeCount } };
|
||||
} catch {
|
||||
return { data: { marked: false, reason: 'REQUIREMENTS.md not found or unreadable' } };
|
||||
const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
|
||||
const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
|
||||
if (afterTable !== reqContent) {
|
||||
reqContent = afterTable;
|
||||
found = true;
|
||||
}
|
||||
|
||||
if (found) {
|
||||
updated.push(reqId);
|
||||
} else {
|
||||
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
|
||||
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
|
||||
if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
|
||||
alreadyComplete.push(reqId);
|
||||
} else {
|
||||
notFound.push(reqId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updated.length > 0) {
|
||||
await writeFile(paths.requirements, reqContent, 'utf-8');
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
updated: updated.length > 0,
|
||||
marked_complete: updated,
|
||||
already_complete: alreadyComplete,
|
||||
not_found: notFound,
|
||||
total: reqIds.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user