fix(2137): skip worktree isolation when .gitmodules detected (#2144)

* feat(sdk): add typed query foundation and gsd-sdk query (Phase 1)

Add sdk/src/query registry and handlers with tests, GSDQueryError, CLI query wiring, and supporting type/tool-scoping hooks. Update CHANGELOG. Vitest 4 constructor mock fixes in milestone-runner tests.

Made-with: Cursor

* fix(2137): skip worktree isolation when .gitmodules detected

When a project contains git submodules, worktree isolation cannot
correctly handle submodule commits — three separate gaps exist in
worktree setup, executor commit protocol, and merge-back. Rather
than patch each gap individually, detect .gitmodules at phase start
and fall back to sequential execution, which handles submodules
transparently (Option B).

Affected workflows: execute-phase.md, quick.md

---------

Co-authored-by: David Sienkowski <dave@sienkowski.com>
This commit is contained in:
Tom Boucher
2026-04-12 08:33:04 -04:00
committed by GitHub
parent 58632e0718
commit 002bcf2a8a
63 changed files with 17872 additions and 23 deletions

395
sdk/src/query/state.ts Normal file
View File

@@ -0,0 +1,395 @@
/**
* State query handlers — STATE.md loading, field extraction, and snapshots.
*
* Ported from get-shit-done/bin/lib/state.cjs and core.cjs.
* Provides state.load (rebuild frontmatter from body + disk), state.get
* (field/section extraction), and state-snapshot (structured snapshot).
*
* @example
* ```typescript
* import { stateLoad, stateGet, stateSnapshot } from './state.js';
*
* const loaded = await stateLoad([], '/project');
* // { data: { gsd_state_version: '1.0', milestone: 'v3.0', ... } }
*
* const field = await stateGet(['Status'], '/project');
* // { data: { Status: 'executing' } }
*
* const snap = await stateSnapshot([], '/project');
* // { data: { current_phase: '10', status: 'executing', decisions: [...], ... } }
* ```
*/
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { extractFrontmatter, stripFrontmatter } from './frontmatter.js';
import { stateExtractField, planningPaths, escapeRegex } from './helpers.js';
import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js';
import type { QueryHandler } from './utils.js';
// ─── Internal helpers ──────────────────────────────────────────────────────
/**
* Build a filter function that checks if a phase directory belongs to the current milestone.
*
* Port of getMilestonePhaseFilter from core.cjs lines 1409-1442.
*/
export async function getMilestonePhaseFilter(projectDir: string): Promise<((dirName: string) => boolean) & { phaseCount: number }> {
const milestonePhaseNums = new Set<string>();
try {
const roadmapContent = await readFile(planningPaths(projectDir).roadmap, 'utf-8');
const roadmap = await extractCurrentMilestone(roadmapContent, projectDir);
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(roadmap)) !== null) {
milestonePhaseNums.add(m[1]);
}
} catch { /* intentionally empty */ }
if (milestonePhaseNums.size === 0) {
const passAllFn = (_dirName: string): boolean => true;
const passAll = passAllFn as typeof passAllFn & { phaseCount: number };
passAll.phaseCount = 0;
return passAll;
}
const normalized = new Set<string>(
[...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase())
);
const isDirInMilestone = ((dirName: string): boolean => {
// Try numeric match first
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
if (m && normalized.has(m[1].toLowerCase())) return true;
// Try custom ID match
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
return false;
}) as ((dirName: string) => boolean) & { phaseCount: number };
isDirInMilestone.phaseCount = milestonePhaseNums.size;
return isDirInMilestone;
}
/**
* Build state frontmatter from STATE.md body content and disk scanning.
*
* Port of buildStateFrontmatter from state.cjs lines 650-760.
* HIGH complexity: extracts fields, scans disk, computes progress.
*/
export async function buildStateFrontmatter(bodyContent: string, projectDir: string): Promise<Record<string, unknown>> {
const currentPhase = stateExtractField(bodyContent, 'Current Phase');
const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name');
const currentPlan = stateExtractField(bodyContent, 'Current Plan');
const totalPhasesRaw = stateExtractField(bodyContent, 'Total Phases');
const totalPlansRaw = stateExtractField(bodyContent, 'Total Plans in Phase');
const status = stateExtractField(bodyContent, 'Status');
const progressRaw = stateExtractField(bodyContent, 'Progress');
const lastActivity = stateExtractField(bodyContent, 'Last Activity');
const stoppedAt = stateExtractField(bodyContent, 'Stopped At') || stateExtractField(bodyContent, 'Stopped at');
const pausedAt = stateExtractField(bodyContent, 'Paused At');
let milestone: string | null = null;
let milestoneName: string | null = null;
try {
const info = await getMilestoneInfo(projectDir);
milestone = info.version;
milestoneName = info.name;
} catch { /* intentionally empty */ }
let totalPhases: number | null = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
let completedPhases: number | null = null;
let totalPlans: number | null = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
let completedPlans: number | null = null;
try {
const phasesDir = planningPaths(projectDir).phases;
const isDirInMilestone = await getMilestonePhaseFilter(projectDir);
const entries = await readdir(phasesDir, { withFileTypes: true });
const phaseDirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(isDirInMilestone);
let diskTotalPlans = 0;
let diskTotalSummaries = 0;
let diskCompletedPhases = 0;
for (const dir of phaseDirs) {
const files = await readdir(join(phasesDir, dir));
const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length;
const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
diskTotalPlans += plans;
diskTotalSummaries += summaries;
if (plans > 0 && summaries >= plans) diskCompletedPhases++;
}
totalPhases = isDirInMilestone.phaseCount > 0
? Math.max(phaseDirs.length, isDirInMilestone.phaseCount)
: phaseDirs.length;
completedPhases = diskCompletedPhases;
totalPlans = diskTotalPlans;
completedPlans = diskTotalSummaries;
} catch { /* intentionally empty */ }
// Derive percent from disk counts (ground truth)
let progressPercent: number | null = null;
if (totalPlans !== null && totalPlans > 0 && completedPlans !== null) {
progressPercent = Math.min(100, Math.round(completedPlans / totalPlans * 100));
} else if (progressRaw) {
const pctMatch = progressRaw.match(/(\d+)%/);
if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
}
// Normalize status
let normalizedStatus = status || 'unknown';
const statusLower = (status || '').toLowerCase();
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
normalizedStatus = 'paused';
} else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
normalizedStatus = 'executing';
} else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
normalizedStatus = 'planning';
} else if (statusLower.includes('discussing')) {
normalizedStatus = 'discussing';
} else if (statusLower.includes('verif')) {
normalizedStatus = 'verifying';
} else if (statusLower.includes('complete') || statusLower.includes('done')) {
normalizedStatus = 'completed';
} else if (statusLower.includes('ready to execute')) {
normalizedStatus = 'executing';
}
const fm: Record<string, unknown> = { gsd_state_version: '1.0' };
if (milestone) fm.milestone = milestone;
if (milestoneName) fm.milestone_name = milestoneName;
if (currentPhase) fm.current_phase = currentPhase;
if (currentPhaseName) fm.current_phase_name = currentPhaseName;
if (currentPlan) fm.current_plan = currentPlan;
fm.status = normalizedStatus;
if (stoppedAt) fm.stopped_at = stoppedAt;
if (pausedAt) fm.paused_at = pausedAt;
fm.last_updated = new Date().toISOString();
if (lastActivity) fm.last_activity = lastActivity;
const progress: Record<string, unknown> = {};
if (totalPhases !== null) progress.total_phases = totalPhases;
if (completedPhases !== null) progress.completed_phases = completedPhases;
if (totalPlans !== null) progress.total_plans = totalPlans;
if (completedPlans !== null) progress.completed_plans = completedPlans;
if (progressPercent !== null) progress.percent = progressPercent;
if (Object.keys(progress).length > 0) fm.progress = progress;
return fm;
}
// ─── Exported handlers ─────────────────────────────────────────────────────
/**
* Query handler for state.load / state.json.
*
* Reads STATE.md, rebuilds frontmatter from body + disk scanning.
* Returns cached frontmatter-only fields (stopped_at, paused_at) when not in body.
*
* Port of cmdStateJson from state.cjs lines 872-901.
*
* @param args - Unused
* @param projectDir - Project root directory
* @returns QueryResult with rebuilt state frontmatter
*/
export const stateLoad: QueryHandler = async (_args, projectDir) => {
const statePath = planningPaths(projectDir).state;
let content: string;
try {
content = await readFile(statePath, 'utf-8');
} catch {
return { data: { error: 'STATE.md not found' } };
}
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
// Always rebuild from body + disk so progress reflects current state
const built = await buildStateFrontmatter(body, projectDir);
// Preserve frontmatter-only fields that cannot be recovered from body
if (existingFm && existingFm.stopped_at && !built.stopped_at) {
built.stopped_at = existingFm.stopped_at;
}
if (existingFm && existingFm.paused_at && !built.paused_at) {
built.paused_at = existingFm.paused_at;
}
// Preserve existing non-unknown status when body-derived is 'unknown'
if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') {
built.status = existingFm.status;
}
return { data: built };
};
/**
* Query handler for state.get.
*
* Reads STATE.md and extracts a specific field or section.
* Returns full content when no field specified.
*
* Port of cmdStateGet from state.cjs lines 72-113.
*
* @param args - args[0] is optional field/section name
* @param projectDir - Project root directory
* @returns QueryResult with field value or full content
*/
export const stateGet: QueryHandler = async (args, projectDir) => {
const statePath = planningPaths(projectDir).state;
let content: string;
try {
content = await readFile(statePath, 'utf-8');
} catch {
return { data: { error: 'STATE.md not found' } };
}
const section = args[0];
if (!section) {
return { data: { content } };
}
const fieldEscaped = escapeRegex(section);
// Check for **field:** value (bold format)
const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) {
return { data: { [section]: boldMatch[1].trim() } };
}
// Check for field: value (plain format)
const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
const plainMatch = content.match(plainPattern);
if (plainMatch) {
return { data: { [section]: plainMatch[1].trim() } };
}
// Check for ## Section
const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
const sectionMatch = content.match(sectionPattern);
if (sectionMatch) {
return { data: { [section]: sectionMatch[1].trim() } };
}
return { data: { error: `Section or field "${section}" not found` } };
};
/**
* Query handler for state-snapshot.
*
* Returns a structured snapshot of project state with decisions, blockers, and session.
*
* Port of cmdStateSnapshot from state.cjs lines 546-641.
*
* @param args - Unused
* @param projectDir - Project root directory
* @returns QueryResult with structured snapshot
*/
export const stateSnapshot: QueryHandler = async (_args, projectDir) => {
const statePath = planningPaths(projectDir).state;
let content: string;
try {
content = await readFile(statePath, 'utf-8');
} catch {
return { data: { error: 'STATE.md not found' } };
}
// Extract basic fields
const currentPhase = stateExtractField(content, 'Current Phase');
const currentPhaseName = stateExtractField(content, 'Current Phase Name');
const totalPhasesRaw = stateExtractField(content, 'Total Phases');
const currentPlan = stateExtractField(content, 'Current Plan');
const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
const status = stateExtractField(content, 'Status');
const progressRaw = stateExtractField(content, 'Progress');
const lastActivity = stateExtractField(content, 'Last Activity');
const lastActivityDesc = stateExtractField(content, 'Last Activity Description');
const pausedAt = stateExtractField(content, 'Paused At');
// Parse numeric fields
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
const progressPercent = progressRaw ? (() => {
const m = progressRaw.match(/(\d+)%/);
return m ? parseInt(m[1], 10) : null;
})() : null;
// Extract decisions table
const decisions: Array<{ phase: string; summary: string; rationale: string }> = [];
const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (decisionsMatch) {
const tableBody = decisionsMatch[1];
const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
for (const row of rows) {
const cells = row.split('|').map(c => c.trim()).filter(Boolean);
if (cells.length >= 3) {
decisions.push({
phase: cells[0],
summary: cells[1],
rationale: cells[2],
});
}
}
}
// Extract blockers list
const blockers: string[] = [];
const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
if (blockersMatch) {
const blockersSection = blockersMatch[1];
const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
for (const item of items) {
blockers.push(item.replace(/^-\s+/, '').trim());
}
}
// Extract session info
const session: { last_date: string | null; stopped_at: string | null; resume_file: string | null } = {
last_date: null,
stopped_at: null,
resume_file: null,
};
const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
if (sessionMatch) {
const sessionSection = sessionMatch[1];
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
|| sessionSection.match(/^Last Date:\s*(.+)/im);
const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
|| sessionSection.match(/^Stopped At:\s*(.+)/im);
const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
|| sessionSection.match(/^Resume File:\s*(.+)/im);
if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim();
}
const result = {
current_phase: currentPhase,
current_phase_name: currentPhaseName,
total_phases: totalPhases,
current_plan: currentPlan,
total_plans_in_phase: totalPlansInPhase,
status,
progress_percent: progressPercent,
last_activity: lastActivity,
last_activity_desc: lastActivityDesc,
decisions,
blockers,
paused_at: pausedAt,
session,
};
return { data: result };
};