refactor: decompose doctor.ts into types, format, and checks modules (#1096)
Extract three modules from the 1,348-line doctor.ts god file: - doctor-types.ts: DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary - doctor-format.ts: summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt - doctor-checks.ts: checkGitHealth, checkRuntimeHealth All public exports are re-exported from doctor.ts so existing imports from "./doctor.js" continue to work unchanged. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
51c259e778
commit
5ef52b8a59
4 changed files with 753 additions and 692 deletions
564
src/resources/extensions/gsd/doctor-checks.ts
Normal file
564
src/resources/extensions/gsd/doctor-checks.ts
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
|
||||
import { loadFile, parseRoadmap } from "./files.js";
|
||||
import { resolveMilestoneFile, milestonesDir, gsdRoot, resolveGsdRootFile, relGsdRootFile } from "./paths.js";
|
||||
import { deriveState, isMilestoneComplete } from "./state.js";
|
||||
import { saveFile } from "./files.js";
|
||||
import { listWorktrees, resolveGitDir } from "./worktree-manager.js";
|
||||
import { abortAndReset } from "./git-self-heal.js";
|
||||
import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
|
||||
import { nativeIsRepo, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js";
|
||||
import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js";
|
||||
import { ensureGitignore } from "./gitignore.js";
|
||||
import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js";
|
||||
|
||||
export async function checkGitHealth(
|
||||
basePath: string,
|
||||
issues: DoctorIssue[],
|
||||
fixesApplied: string[],
|
||||
shouldFix: (code: DoctorIssueCode) => boolean,
|
||||
isolationMode: "none" | "worktree" | "branch" = "worktree",
|
||||
): Promise<void> {
|
||||
// Degrade gracefully if not a git repo
|
||||
if (!nativeIsRepo(basePath)) {
|
||||
return; // Not a git repo — skip all git health checks
|
||||
}
|
||||
|
||||
const gitDir = resolveGitDir(basePath);
|
||||
|
||||
// ── Orphaned auto-worktrees & Stale milestone branches ────────────────
|
||||
// These checks only apply in worktree/branch modes — skip in none mode
|
||||
// where no milestone worktrees or branches are created.
|
||||
if (isolationMode !== "none") {
|
||||
try {
|
||||
const worktrees = listWorktrees(basePath);
|
||||
const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/"));
|
||||
|
||||
// Load roadmap state once for cross-referencing
|
||||
const state = await deriveState(basePath);
|
||||
|
||||
for (const wt of milestoneWorktrees) {
|
||||
// Extract milestone ID from branch name "milestone/M001" → "M001"
|
||||
const milestoneId = wt.branch.replace(/^milestone\//, "");
|
||||
const milestoneEntry = state.registry.find(m => m.id === milestoneId);
|
||||
|
||||
// Check if milestone is complete via roadmap
|
||||
let isComplete = false;
|
||||
if (milestoneEntry) {
|
||||
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
||||
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
||||
if (roadmapContent) {
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
isComplete = isMilestoneComplete(roadmap);
|
||||
}
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "orphaned_auto_worktree",
|
||||
scope: "milestone",
|
||||
unitId: milestoneId,
|
||||
message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("orphaned_auto_worktree")) {
|
||||
// Never remove a worktree matching current working directory
|
||||
const cwd = process.cwd();
|
||||
if (wt.path === cwd || cwd.startsWith(wt.path + sep)) {
|
||||
fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
|
||||
} else {
|
||||
try {
|
||||
nativeWorktreeRemove(basePath, wt.path, true);
|
||||
fixesApplied.push(`removed orphaned worktree ${wt.path}`);
|
||||
} catch {
|
||||
fixesApplied.push(`failed to remove worktree ${wt.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stale milestone branches ─────────────────────────────────────────
|
||||
try {
|
||||
const branches = nativeBranchList(basePath, "milestone/*");
|
||||
if (branches.length > 0) {
|
||||
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
|
||||
|
||||
for (const branch of branches) {
|
||||
// Skip branches that have a worktree (handled above)
|
||||
if (worktreeBranches.has(branch)) continue;
|
||||
|
||||
const milestoneId = branch.replace(/^milestone\//, "");
|
||||
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
||||
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
||||
if (!roadmapContent) continue;
|
||||
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
if (isMilestoneComplete(roadmap)) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "stale_milestone_branch",
|
||||
scope: "milestone",
|
||||
unitId: milestoneId,
|
||||
message: `Branch ${branch} exists for completed milestone ${milestoneId}`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("stale_milestone_branch")) {
|
||||
try {
|
||||
nativeBranchDelete(basePath, branch, true);
|
||||
fixesApplied.push(`deleted stale branch ${branch}`);
|
||||
} catch {
|
||||
fixesApplied.push(`failed to delete branch ${branch}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// git branch list failed — skip stale branch check
|
||||
}
|
||||
} catch {
|
||||
// listWorktrees or deriveState failed — skip worktree/branch checks
|
||||
}
|
||||
} // end isolationMode !== "none"
|
||||
|
||||
// ── Corrupt merge state ────────────────────────────────────────────────
|
||||
try {
|
||||
const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"];
|
||||
const mergeStateDirs = ["rebase-apply", "rebase-merge"];
|
||||
const found: string[] = [];
|
||||
|
||||
for (const f of mergeStateFiles) {
|
||||
if (existsSync(join(gitDir, f))) found.push(f);
|
||||
}
|
||||
for (const d of mergeStateDirs) {
|
||||
if (existsSync(join(gitDir, d))) found.push(d);
|
||||
}
|
||||
|
||||
if (found.length > 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "corrupt_merge_state",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `Corrupt merge/rebase state detected: ${found.join(", ")}`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("corrupt_merge_state")) {
|
||||
const result = abortAndReset(basePath);
|
||||
fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't check .git dir — skip
|
||||
}
|
||||
|
||||
// ── Tracked runtime files ──────────────────────────────────────────────
|
||||
try {
|
||||
const trackedPaths: string[] = [];
|
||||
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
||||
try {
|
||||
const files = nativeLsFiles(basePath, exclusion);
|
||||
if (files.length > 0) {
|
||||
trackedPaths.push(...files);
|
||||
}
|
||||
} catch {
|
||||
// Individual ls-files can fail — continue
|
||||
}
|
||||
}
|
||||
|
||||
if (trackedPaths.length > 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "tracked_runtime_files",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("tracked_runtime_files")) {
|
||||
try {
|
||||
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
||||
nativeRmCached(basePath, [exclusion]);
|
||||
}
|
||||
fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
|
||||
} catch {
|
||||
fixesApplied.push("failed to untrack runtime files");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// git ls-files failed — skip
|
||||
}
|
||||
|
||||
// ── Legacy slice branches ──────────────────────────────────────────────
|
||||
try {
|
||||
const branchList = nativeBranchList(basePath, "gsd/*/*");
|
||||
if (branchList.length > 0) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "legacy_slice_branches",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture). Delete with: git branch -D ${branchList.join(" ")}`,
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// git branch list failed — skip
|
||||
}
|
||||
}
|
||||
|
||||
// ── Runtime Health Checks ──────────────────────────────────────────────────
|
||||
// Checks for stale crash locks, orphaned completed-units, stale hook state,
|
||||
// activity log bloat, STATE.md drift, and gitignore drift.
|
||||
|
||||
export async function checkRuntimeHealth(
|
||||
basePath: string,
|
||||
issues: DoctorIssue[],
|
||||
fixesApplied: string[],
|
||||
shouldFix: (code: DoctorIssueCode) => boolean,
|
||||
): Promise<void> {
|
||||
const root = gsdRoot(basePath);
|
||||
|
||||
// ── Stale crash lock ──────────────────────────────────────────────────
|
||||
try {
|
||||
const lock = readCrashLock(basePath);
|
||||
if (lock) {
|
||||
const alive = isLockProcessAlive(lock);
|
||||
if (!alive) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "stale_crash_lock",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `Stale auto.lock from PID ${lock.pid} (started ${lock.startedAt}, was executing ${lock.unitType} ${lock.unitId}) — process is no longer running`,
|
||||
file: ".gsd/auto.lock",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("stale_crash_lock")) {
|
||||
clearLock(basePath);
|
||||
fixesApplied.push("cleared stale auto.lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — crash lock check failed
|
||||
}
|
||||
|
||||
// ── Stale parallel sessions ────────────────────────────────────────────
|
||||
try {
|
||||
const parallelStatuses = readAllSessionStatuses(basePath);
|
||||
for (const status of parallelStatuses) {
|
||||
if (isSessionStale(status)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "stale_parallel_session",
|
||||
scope: "project",
|
||||
unitId: status.milestoneId,
|
||||
message: `Stale parallel session for ${status.milestoneId} (PID ${status.pid}, started ${new Date(status.startedAt).toISOString()}, last heartbeat ${new Date(status.lastHeartbeat).toISOString()}) — process is no longer running`,
|
||||
file: `.gsd/parallel/${status.milestoneId}.status.json`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("stale_parallel_session")) {
|
||||
removeSessionStatus(basePath, status.milestoneId);
|
||||
fixesApplied.push(`cleaned up stale parallel session for ${status.milestoneId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — parallel session check failed
|
||||
}
|
||||
|
||||
// ── Orphaned completed-units keys ─────────────────────────────────────
|
||||
try {
|
||||
const completedKeysFile = join(root, "completed-units.json");
|
||||
if (existsSync(completedKeysFile)) {
|
||||
const raw = readFileSync(completedKeysFile, "utf-8");
|
||||
const keys: string[] = JSON.parse(raw);
|
||||
const orphaned: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
// Key format: "unitType/unitId" e.g. "execute-task/M001/S01/T01"
|
||||
const slashIdx = key.indexOf("/");
|
||||
if (slashIdx === -1) continue;
|
||||
const unitType = key.slice(0, slashIdx);
|
||||
const unitId = key.slice(slashIdx + 1);
|
||||
|
||||
// Only validate artifact-producing unit types
|
||||
const { verifyExpectedArtifact } = await import("./auto-recovery.js");
|
||||
if (!verifyExpectedArtifact(unitType, unitId, basePath)) {
|
||||
orphaned.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphaned.length > 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "orphaned_completed_units",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${orphaned.length} completed-unit key(s) reference missing artifacts: ${orphaned.slice(0, 3).join(", ")}${orphaned.length > 3 ? "..." : ""}`,
|
||||
file: ".gsd/completed-units.json",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("orphaned_completed_units")) {
|
||||
const { removePersistedKey } = await import("./auto-recovery.js");
|
||||
for (const key of orphaned) {
|
||||
removePersistedKey(basePath, key);
|
||||
}
|
||||
fixesApplied.push(`removed ${orphaned.length} orphaned completed-unit key(s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — completed-units check failed
|
||||
}
|
||||
|
||||
// ── Stale hook state ──────────────────────────────────────────────────
|
||||
try {
|
||||
const hookStateFile = join(root, "hook-state.json");
|
||||
if (existsSync(hookStateFile)) {
|
||||
const raw = readFileSync(hookStateFile, "utf-8");
|
||||
const state = JSON.parse(raw);
|
||||
const hasCycleCounts = state.cycleCounts && typeof state.cycleCounts === "object"
|
||||
&& Object.keys(state.cycleCounts).length > 0;
|
||||
|
||||
// Only flag if there are actual cycle counts AND no auto-mode is running
|
||||
if (hasCycleCounts) {
|
||||
const lock = readCrashLock(basePath);
|
||||
const autoRunning = lock ? isLockProcessAlive(lock) : false;
|
||||
|
||||
if (!autoRunning) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "stale_hook_state",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `hook-state.json has ${Object.keys(state.cycleCounts).length} residual cycle count(s) from a previous session`,
|
||||
file: ".gsd/hook-state.json",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("stale_hook_state")) {
|
||||
const { clearPersistedHookState } = await import("./post-unit-hooks.js");
|
||||
clearPersistedHookState(basePath);
|
||||
fixesApplied.push("cleared stale hook-state.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — hook state check failed
|
||||
}
|
||||
|
||||
// ── Activity log bloat ────────────────────────────────────────────────
|
||||
try {
|
||||
const activityDir = join(root, "activity");
|
||||
if (existsSync(activityDir)) {
|
||||
const files = readdirSync(activityDir);
|
||||
let totalSize = 0;
|
||||
for (const f of files) {
|
||||
try {
|
||||
totalSize += statSync(join(activityDir, f)).size;
|
||||
} catch {
|
||||
// stat failed — skip
|
||||
}
|
||||
}
|
||||
|
||||
const totalMB = totalSize / (1024 * 1024);
|
||||
const BLOAT_FILE_THRESHOLD = 500;
|
||||
const BLOAT_SIZE_MB = 100;
|
||||
|
||||
if (files.length > BLOAT_FILE_THRESHOLD || totalMB > BLOAT_SIZE_MB) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "activity_log_bloat",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `Activity logs: ${files.length} files, ${totalMB.toFixed(1)}MB (thresholds: ${BLOAT_FILE_THRESHOLD} files / ${BLOAT_SIZE_MB}MB)`,
|
||||
file: ".gsd/activity/",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("activity_log_bloat")) {
|
||||
const { pruneActivityLogs } = await import("./activity-log.js");
|
||||
pruneActivityLogs(activityDir, 7); // 7-day retention
|
||||
fixesApplied.push("pruned activity logs (7-day retention)");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — activity log check failed
|
||||
}
|
||||
|
||||
// ── STATE.md health ───────────────────────────────────────────────────
|
||||
try {
|
||||
const stateFilePath = resolveGsdRootFile(basePath, "STATE");
|
||||
const milestonesPath = milestonesDir(basePath);
|
||||
|
||||
if (existsSync(milestonesPath)) {
|
||||
if (!existsSync(stateFilePath)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "state_file_missing",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: "STATE.md is missing — state display will not work",
|
||||
file: ".gsd/STATE.md",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("state_file_missing")) {
|
||||
const state = await deriveState(basePath);
|
||||
await saveFile(stateFilePath, buildStateMarkdownForCheck(state));
|
||||
fixesApplied.push("created STATE.md from derived state");
|
||||
}
|
||||
} else {
|
||||
// Check if STATE.md is stale by comparing active milestone/slice/phase
|
||||
const currentContent = readFileSync(stateFilePath, "utf-8");
|
||||
const state = await deriveState(basePath);
|
||||
const freshContent = buildStateMarkdownForCheck(state);
|
||||
|
||||
// Extract key fields for comparison — don't compare full content
|
||||
// since timestamp/formatting differences are normal
|
||||
const extractFields = (content: string) => {
|
||||
const milestone = content.match(/\*\*Active Milestone:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
|
||||
const slice = content.match(/\*\*Active Slice:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
|
||||
const phase = content.match(/\*\*Phase:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
|
||||
return { milestone, slice, phase };
|
||||
};
|
||||
|
||||
const current = extractFields(currentContent);
|
||||
const fresh = extractFields(freshContent);
|
||||
|
||||
if (current.milestone !== fresh.milestone || current.slice !== fresh.slice || current.phase !== fresh.phase) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "state_file_stale",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `STATE.md is stale — shows "${current.phase}" but derived state is "${fresh.phase}"`,
|
||||
file: ".gsd/STATE.md",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("state_file_stale")) {
|
||||
await saveFile(stateFilePath, freshContent);
|
||||
fixesApplied.push("rebuilt STATE.md from derived state");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — STATE.md check failed
|
||||
}
|
||||
|
||||
// ── Gitignore drift ───────────────────────────────────────────────────
|
||||
try {
|
||||
const gitignorePath = join(basePath, ".gitignore");
|
||||
if (existsSync(gitignorePath) && nativeIsRepo(basePath)) {
|
||||
const content = readFileSync(gitignorePath, "utf-8");
|
||||
const existingLines = new Set(
|
||||
content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")),
|
||||
);
|
||||
|
||||
// Check for critical runtime patterns that must be present
|
||||
const criticalPatterns = [
|
||||
".gsd/activity/",
|
||||
".gsd/runtime/",
|
||||
".gsd/auto.lock",
|
||||
".gsd/gsd.db",
|
||||
".gsd/completed-units.json",
|
||||
];
|
||||
|
||||
// If blanket .gsd/ or .gsd is present, all patterns are covered
|
||||
const hasBlanketIgnore = existingLines.has(".gsd/") || existingLines.has(".gsd");
|
||||
|
||||
if (!hasBlanketIgnore) {
|
||||
const missing = criticalPatterns.filter(p => !existingLines.has(p));
|
||||
if (missing.length > 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "gitignore_missing_patterns",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${missing.length} critical GSD runtime pattern(s) missing from .gitignore: ${missing.join(", ")}`,
|
||||
file: ".gitignore",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("gitignore_missing_patterns")) {
|
||||
ensureGitignore(basePath);
|
||||
fixesApplied.push("added missing GSD runtime patterns to .gitignore");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — gitignore check failed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build STATE.md markdown content from derived state.
|
||||
* Local helper used by checkRuntimeHealth for STATE.md drift detection and repair.
|
||||
*/
|
||||
function buildStateMarkdownForCheck(state: Awaited<ReturnType<typeof deriveState>>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# GSD State", "");
|
||||
|
||||
const activeMilestone = state.activeMilestone
|
||||
? `${state.activeMilestone.id}: ${state.activeMilestone.title}`
|
||||
: "None";
|
||||
const activeSlice = state.activeSlice
|
||||
? `${state.activeSlice.id}: ${state.activeSlice.title}`
|
||||
: "None";
|
||||
|
||||
lines.push(`**Active Milestone:** ${activeMilestone}`);
|
||||
lines.push(`**Active Slice:** ${activeSlice}`);
|
||||
lines.push(`**Phase:** ${state.phase}`);
|
||||
if (state.requirements) {
|
||||
lines.push(`**Requirements Status:** ${state.requirements.active} active · ${state.requirements.validated} validated · ${state.requirements.deferred} deferred · ${state.requirements.outOfScope} out of scope`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("## Milestone Registry");
|
||||
|
||||
for (const entry of state.registry) {
|
||||
const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : "\u2B1C";
|
||||
lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Recent Decisions");
|
||||
if (state.recentDecisions.length > 0) {
|
||||
for (const decision of state.recentDecisions) lines.push(`- ${decision}`);
|
||||
} else {
|
||||
lines.push("- None recorded");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Blockers");
|
||||
if (state.blockers.length > 0) {
|
||||
for (const blocker of state.blockers) lines.push(`- ${blocker}`);
|
||||
} else {
|
||||
lines.push("- None");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Next Action");
|
||||
lines.push(state.nextAction || "None");
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
78
src/resources/extensions/gsd/doctor-format.ts
Normal file
78
src/resources/extensions/gsd/doctor-format.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { DoctorIssue, DoctorIssueCode, DoctorReport, DoctorSummary } from "./doctor-types.js";
|
||||
|
||||
function matchesScope(unitId: string, scope?: string): boolean {
|
||||
if (!scope) return true;
|
||||
return unitId === scope || unitId.startsWith(`${scope}/`) || unitId.startsWith(`${scope}`);
|
||||
}
|
||||
|
||||
export function summarizeDoctorIssues(issues: DoctorIssue[]): DoctorSummary {
|
||||
const errors = issues.filter(issue => issue.severity === "error").length;
|
||||
const warnings = issues.filter(issue => issue.severity === "warning").length;
|
||||
const infos = issues.filter(issue => issue.severity === "info").length;
|
||||
const fixable = issues.filter(issue => issue.fixable).length;
|
||||
const byCodeMap = new Map<DoctorIssueCode, number>();
|
||||
for (const issue of issues) {
|
||||
byCodeMap.set(issue.code, (byCodeMap.get(issue.code) ?? 0) + 1);
|
||||
}
|
||||
const byCode = [...byCodeMap.entries()]
|
||||
.map(([code, count]) => ({ code, count }))
|
||||
.sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
|
||||
return { total: issues.length, errors, warnings, infos, fixable, byCode };
|
||||
}
|
||||
|
||||
export function filterDoctorIssues(issues: DoctorIssue[], options?: { scope?: string; includeWarnings?: boolean; includeHistorical?: boolean }): DoctorIssue[] {
|
||||
let filtered = issues;
|
||||
if (options?.scope) filtered = filtered.filter(issue => matchesScope(issue.unitId, options.scope));
|
||||
if (!options?.includeWarnings) filtered = filtered.filter(issue => issue.severity === "error");
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function formatDoctorReport(
|
||||
report: DoctorReport,
|
||||
options?: { scope?: string; includeWarnings?: boolean; maxIssues?: number; title?: string },
|
||||
): string {
|
||||
const scopedIssues = filterDoctorIssues(report.issues, {
|
||||
scope: options?.scope,
|
||||
includeWarnings: options?.includeWarnings ?? true,
|
||||
});
|
||||
const summary = summarizeDoctorIssues(scopedIssues);
|
||||
const maxIssues = options?.maxIssues ?? 12;
|
||||
const lines: string[] = [];
|
||||
lines.push(options?.title ?? (summary.errors > 0 ? "GSD doctor found blocking issues." : "GSD doctor report."));
|
||||
lines.push(`Scope: ${options?.scope ?? "all milestones"}`);
|
||||
lines.push(`Issues: ${summary.total} total · ${summary.errors} error(s) · ${summary.warnings} warning(s) · ${summary.fixable} fixable`);
|
||||
|
||||
if (summary.byCode.length > 0) {
|
||||
lines.push("Top issue types:");
|
||||
for (const item of summary.byCode.slice(0, 5)) {
|
||||
lines.push(`- ${item.code}: ${item.count}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scopedIssues.length > 0) {
|
||||
lines.push("Priority issues:");
|
||||
for (const issue of scopedIssues.slice(0, maxIssues)) {
|
||||
const prefix = issue.severity === "error" ? "ERROR" : issue.severity === "warning" ? "WARN" : "INFO";
|
||||
lines.push(`- [${prefix}] ${issue.unitId}: ${issue.message}${issue.file ? ` (${issue.file})` : ""}`);
|
||||
}
|
||||
if (scopedIssues.length > maxIssues) {
|
||||
lines.push(`- ...and ${scopedIssues.length - maxIssues} more in scope`);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.fixesApplied.length > 0) {
|
||||
lines.push("Fixes applied:");
|
||||
for (const fix of report.fixesApplied.slice(0, maxIssues)) lines.push(`- ${fix}`);
|
||||
if (report.fixesApplied.length > maxIssues) lines.push(`- ...and ${report.fixesApplied.length - maxIssues} more`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
|
||||
if (issues.length === 0) return "- No remaining issues in scope.";
|
||||
return issues.map(issue => {
|
||||
const prefix = issue.severity === "error" ? "ERROR" : issue.severity === "warning" ? "WARN" : "INFO";
|
||||
return `- [${prefix}] ${issue.unitId} | ${issue.code} | ${issue.message}${issue.file ? ` | file: ${issue.file}` : ""} | fixable: ${issue.fixable ? "yes" : "no"}`;
|
||||
}).join("\n");
|
||||
}
|
||||
59
src/resources/extensions/gsd/doctor-types.ts
Normal file
59
src/resources/extensions/gsd/doctor-types.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export type DoctorSeverity = "info" | "warning" | "error";
|
||||
export type DoctorIssueCode =
|
||||
| "invalid_preferences"
|
||||
| "missing_tasks_dir"
|
||||
| "missing_slice_plan"
|
||||
| "task_done_missing_summary"
|
||||
| "task_summary_without_done_checkbox"
|
||||
| "all_tasks_done_missing_slice_summary"
|
||||
| "all_tasks_done_missing_slice_uat"
|
||||
| "all_tasks_done_roadmap_not_checked"
|
||||
| "slice_checked_missing_summary"
|
||||
| "slice_checked_missing_uat"
|
||||
| "all_slices_done_missing_milestone_validation"
|
||||
| "all_slices_done_missing_milestone_summary"
|
||||
| "task_done_must_haves_not_verified"
|
||||
| "active_requirement_missing_owner"
|
||||
| "blocked_requirement_missing_reason"
|
||||
| "blocker_discovered_no_replan"
|
||||
| "delimiter_in_title"
|
||||
| "orphaned_auto_worktree"
|
||||
| "stale_milestone_branch"
|
||||
| "corrupt_merge_state"
|
||||
| "tracked_runtime_files"
|
||||
| "legacy_slice_branches"
|
||||
| "stale_crash_lock"
|
||||
| "stale_parallel_session"
|
||||
| "orphaned_completed_units"
|
||||
| "stale_hook_state"
|
||||
| "activity_log_bloat"
|
||||
| "state_file_stale"
|
||||
| "state_file_missing"
|
||||
| "gitignore_missing_patterns"
|
||||
| "unresolvable_dependency";
|
||||
|
||||
export interface DoctorIssue {
|
||||
severity: DoctorSeverity;
|
||||
code: DoctorIssueCode;
|
||||
scope: "project" | "milestone" | "slice" | "task";
|
||||
unitId: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
fixable: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorReport {
|
||||
ok: boolean;
|
||||
basePath: string;
|
||||
issues: DoctorIssue[];
|
||||
fixesApplied: string[];
|
||||
}
|
||||
|
||||
export interface DoctorSummary {
|
||||
total: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
infos: number;
|
||||
fixable: number;
|
||||
byCode: Array<{ code: DoctorIssueCode; count: number }>;
|
||||
}
|
||||
|
|
@ -1,80 +1,49 @@
|
|||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
|
||||
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
|
||||
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
|
||||
import { deriveState, isMilestoneComplete } from "./state.js";
|
||||
import { invalidateAllCaches } from "./cache.js";
|
||||
import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js";
|
||||
import { listWorktrees, resolveGitDir } from "./worktree-manager.js";
|
||||
import { abortAndReset } from "./git-self-heal.js";
|
||||
import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
|
||||
import { nativeIsRepo, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js";
|
||||
import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js";
|
||||
import { ensureGitignore } from "./gitignore.js";
|
||||
import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js";
|
||||
|
||||
export type DoctorSeverity = "info" | "warning" | "error";
|
||||
export type DoctorIssueCode =
|
||||
| "invalid_preferences"
|
||||
| "missing_tasks_dir"
|
||||
| "missing_slice_plan"
|
||||
| "task_done_missing_summary"
|
||||
| "task_summary_without_done_checkbox"
|
||||
| "all_tasks_done_missing_slice_summary"
|
||||
| "all_tasks_done_missing_slice_uat"
|
||||
| "all_tasks_done_roadmap_not_checked"
|
||||
| "slice_checked_missing_summary"
|
||||
| "slice_checked_missing_uat"
|
||||
| "all_slices_done_missing_milestone_validation"
|
||||
| "all_slices_done_missing_milestone_summary"
|
||||
| "task_done_must_haves_not_verified"
|
||||
| "active_requirement_missing_owner"
|
||||
| "blocked_requirement_missing_reason"
|
||||
| "blocker_discovered_no_replan"
|
||||
| "delimiter_in_title"
|
||||
| "orphaned_auto_worktree"
|
||||
| "stale_milestone_branch"
|
||||
| "corrupt_merge_state"
|
||||
| "tracked_runtime_files"
|
||||
| "legacy_slice_branches"
|
||||
| "stale_crash_lock"
|
||||
| "stale_parallel_session"
|
||||
| "orphaned_completed_units"
|
||||
| "stale_hook_state"
|
||||
| "activity_log_bloat"
|
||||
| "state_file_stale"
|
||||
| "state_file_missing"
|
||||
| "gitignore_missing_patterns"
|
||||
| "unresolvable_dependency";
|
||||
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
|
||||
import { checkGitHealth, checkRuntimeHealth } from "./doctor-checks.js";
|
||||
|
||||
export interface DoctorIssue {
|
||||
severity: DoctorSeverity;
|
||||
code: DoctorIssueCode;
|
||||
scope: "project" | "milestone" | "slice" | "task";
|
||||
unitId: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
fixable: boolean;
|
||||
// ── Re-exports ─────────────────────────────────────────────────────────────
|
||||
// All public types and functions from extracted modules are re-exported here
|
||||
// so that existing imports from "./doctor.js" continue to work unchanged.
|
||||
export type { DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary } from "./doctor-types.js";
|
||||
export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt } from "./doctor-format.js";
|
||||
|
||||
/**
|
||||
* Characters that are used as delimiters in GSD state management documents
|
||||
* and should not appear in milestone or slice titles.
|
||||
*
|
||||
* - "\u2014" (em dash, U+2014): used as a display separator in STATE.md and other docs.
|
||||
* A title containing "\u2014" makes the separator ambiguous, corrupting state display
|
||||
* and confusing the LLM agent that reads and writes these files.
|
||||
* - "\u2013" (en dash, U+2013): visually similar to em dash; same ambiguity risk.
|
||||
* - "/" (forward slash, U+002F): used as the path separator in unit IDs (M001/S01)
|
||||
* and git branch names (gsd/M001/S01). A slash in a title can break path resolution.
|
||||
*/
|
||||
const TITLE_DELIMITER_RE = /[\u2014\u2013\/]/; // em dash, en dash, forward slash
|
||||
|
||||
/**
|
||||
* Check whether a milestone or slice title contains characters that conflict
|
||||
* with GSD's state document delimiter conventions.
|
||||
* Returns a human-readable description of the problem, or null if the title is safe.
|
||||
*/
|
||||
export function validateTitle(title: string): string | null {
|
||||
if (TITLE_DELIMITER_RE.test(title)) {
|
||||
const found: string[] = [];
|
||||
if (/[\u2014\u2013]/.test(title)) found.push("em/en dash (\u2014 or \u2013)");
|
||||
if (/\//.test(title)) found.push("forward slash (/)");
|
||||
return `title contains ${found.join(" and ")}, which conflict with GSD state document delimiters`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface DoctorReport {
|
||||
ok: boolean;
|
||||
basePath: string;
|
||||
issues: DoctorIssue[];
|
||||
fixesApplied: string[];
|
||||
}
|
||||
|
||||
export interface DoctorSummary {
|
||||
total: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
infos: number;
|
||||
fixable: number;
|
||||
byCode: Array<{ code: DoctorIssueCode; count: number }>;
|
||||
}
|
||||
|
||||
|
||||
function validatePreferenceShape(preferences: GSDPreferences): string[] {
|
||||
const issues: string[] = [];
|
||||
const listFields = ["always_use_skills", "prefer_skills", "avoid_skills", "custom_instructions"] as const;
|
||||
|
|
@ -110,34 +79,6 @@ function validatePreferenceShape(preferences: GSDPreferences): string[] {
|
|||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that are used as delimiters in GSD state management documents
|
||||
* and should not appear in milestone or slice titles.
|
||||
*
|
||||
* - "—" (em dash, U+2014): used as a display separator in STATE.md and other docs.
|
||||
* A title containing "—" makes the separator ambiguous, corrupting state display
|
||||
* and confusing the LLM agent that reads and writes these files.
|
||||
* - "–" (en dash, U+2013): visually similar to em dash; same ambiguity risk.
|
||||
* - "/" (forward slash, U+002F): used as the path separator in unit IDs (M001/S01)
|
||||
* and git branch names (gsd/M001/S01). A slash in a title can break path resolution.
|
||||
*/
|
||||
const TITLE_DELIMITER_RE = /[\u2014\u2013\/]/; // em dash, en dash, forward slash
|
||||
|
||||
/**
|
||||
* Check whether a milestone or slice title contains characters that conflict
|
||||
* with GSD's state document delimiter conventions.
|
||||
* Returns a human-readable description of the problem, or null if the title is safe.
|
||||
*/
|
||||
export function validateTitle(title: string): string | null {
|
||||
if (TITLE_DELIMITER_RE.test(title)) {
|
||||
const found: string[] = [];
|
||||
if (/[\u2014\u2013]/.test(title)) found.push("em/en dash (\u2014 or \u2013)");
|
||||
if (/\//.test(title)) found.push("forward slash (/)");
|
||||
return `title contains ${found.join(" and ")}, which conflict with GSD state document delimiters`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# GSD State", "");
|
||||
|
|
@ -153,13 +94,13 @@ function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): str
|
|||
lines.push(`**Active Slice:** ${activeSlice}`);
|
||||
lines.push(`**Phase:** ${state.phase}`);
|
||||
if (state.requirements) {
|
||||
lines.push(`**Requirements Status:** ${state.requirements.active} active · ${state.requirements.validated} validated · ${state.requirements.deferred} deferred · ${state.requirements.outOfScope} out of scope`);
|
||||
lines.push(`**Requirements Status:** ${state.requirements.active} active \u00b7 ${state.requirements.validated} validated \u00b7 ${state.requirements.deferred} deferred \u00b7 ${state.requirements.outOfScope} out of scope`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("## Milestone Registry");
|
||||
|
||||
for (const entry of state.registry) {
|
||||
const glyph = entry.status === "complete" ? "✅" : entry.status === "active" ? "🔄" : "⬜";
|
||||
const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : "\u2B1C";
|
||||
lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`);
|
||||
}
|
||||
|
||||
|
|
@ -217,7 +158,7 @@ async function ensureSliceSummaryStub(basePath: string, milestoneId: string, sli
|
|||
"key_decisions: []",
|
||||
"patterns_established: []",
|
||||
"observability_surfaces:",
|
||||
" - none yet — doctor created placeholder summary; replace with real diagnostics before treating as complete",
|
||||
" - none yet \u2014 doctor created placeholder summary; replace with real diagnostics before treating as complete",
|
||||
"drill_down_paths: []",
|
||||
"duration: unknown",
|
||||
"verification_result: unknown",
|
||||
|
|
@ -244,7 +185,7 @@ async function ensureSliceSummaryStub(basePath: string, milestoneId: string, sli
|
|||
"- Regenerate this summary from task summaries.",
|
||||
"",
|
||||
"## Files Created/Modified",
|
||||
`- \`${relSliceFile(basePath, milestoneId, sliceId, "SUMMARY")}\` — doctor-created placeholder summary`,
|
||||
`- \`${relSliceFile(basePath, milestoneId, sliceId, "SUMMARY")}\` \u2014 doctor-created placeholder summary`,
|
||||
"",
|
||||
"## Forward Intelligence",
|
||||
"",
|
||||
|
|
@ -255,7 +196,7 @@ async function ensureSliceSummaryStub(basePath: string, milestoneId: string, sli
|
|||
"- Placeholder summary exists solely to unblock invariant checks.",
|
||||
"",
|
||||
"### Authoritative diagnostics",
|
||||
"- Task summaries in the slice tasks/ directory — they are the actual authoritative source until this summary is rewritten.",
|
||||
"- Task summaries in the slice tasks/ directory \u2014 they are the actual authoritative source until this summary is rewritten.",
|
||||
"",
|
||||
"### What assumptions changed",
|
||||
"- The system assumed completion would always write a slice summary; in practice doctor may need to restore missing artifacts.",
|
||||
|
|
@ -308,9 +249,6 @@ async function markTaskDoneInPlan(basePath: string, milestoneId: string, sliceId
|
|||
if (!planPath) return;
|
||||
const content = await loadFile(planPath);
|
||||
if (!content) return;
|
||||
// Allow optional leading whitespace to match the same patterns the plan parser
|
||||
// accepts. Capture the leading whitespace + "- " so the replacement preserves
|
||||
// indentation instead of collapsing it (#1063).
|
||||
const updated = content.replace(
|
||||
new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${taskId}:`, "m"),
|
||||
`$1[x] **${taskId}:`,
|
||||
|
|
@ -326,9 +264,6 @@ async function markSliceDoneInRoadmap(basePath: string, milestoneId: string, sli
|
|||
if (!roadmapPath) return;
|
||||
const content = await loadFile(roadmapPath);
|
||||
if (!content) return;
|
||||
// Allow optional leading whitespace to match the same patterns the roadmap
|
||||
// parser accepts (^\s*-\s+ in roadmap-slices.ts). Capture the prefix so the
|
||||
// replacement preserves original indentation (#1063).
|
||||
const updated = content.replace(
|
||||
new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sliceId}:`, "m"),
|
||||
`$1[x] **${sliceId}:`,
|
||||
|
|
@ -385,21 +320,6 @@ function auditRequirements(content: string | null): DoctorIssue[] {
|
|||
return issues;
|
||||
}
|
||||
|
||||
export function summarizeDoctorIssues(issues: DoctorIssue[]): DoctorSummary {
|
||||
const errors = issues.filter(issue => issue.severity === "error").length;
|
||||
const warnings = issues.filter(issue => issue.severity === "warning").length;
|
||||
const infos = issues.filter(issue => issue.severity === "info").length;
|
||||
const fixable = issues.filter(issue => issue.fixable).length;
|
||||
const byCodeMap = new Map<DoctorIssueCode, number>();
|
||||
for (const issue of issues) {
|
||||
byCodeMap.set(issue.code, (byCodeMap.get(issue.code) ?? 0) + 1);
|
||||
}
|
||||
const byCode = [...byCodeMap.entries()]
|
||||
.map(([code, count]) => ({ code, count }))
|
||||
.sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
|
||||
return { total: issues.length, errors, warnings, infos, fixable, byCode };
|
||||
}
|
||||
|
||||
export async function selectDoctorScope(basePath: string, requestedScope?: string): Promise<string | undefined> {
|
||||
if (requestedScope) return requestedScope;
|
||||
|
||||
|
|
@ -425,560 +345,7 @@ export async function selectDoctorScope(basePath: string, requestedScope?: strin
|
|||
return state.registry[0]?.id;
|
||||
}
|
||||
|
||||
export function filterDoctorIssues(issues: DoctorIssue[], options?: { scope?: string; includeWarnings?: boolean; includeHistorical?: boolean }): DoctorIssue[] {
|
||||
let filtered = issues;
|
||||
if (options?.scope) filtered = filtered.filter(issue => matchesScope(issue.unitId, options.scope));
|
||||
if (!options?.includeWarnings) filtered = filtered.filter(issue => issue.severity === "error");
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function formatDoctorReport(
|
||||
report: DoctorReport,
|
||||
options?: { scope?: string; includeWarnings?: boolean; maxIssues?: number; title?: string },
|
||||
): string {
|
||||
const scopedIssues = filterDoctorIssues(report.issues, {
|
||||
scope: options?.scope,
|
||||
includeWarnings: options?.includeWarnings ?? true,
|
||||
});
|
||||
const summary = summarizeDoctorIssues(scopedIssues);
|
||||
const maxIssues = options?.maxIssues ?? 12;
|
||||
const lines: string[] = [];
|
||||
lines.push(options?.title ?? (summary.errors > 0 ? "GSD doctor found blocking issues." : "GSD doctor report."));
|
||||
lines.push(`Scope: ${options?.scope ?? "all milestones"}`);
|
||||
lines.push(`Issues: ${summary.total} total · ${summary.errors} error(s) · ${summary.warnings} warning(s) · ${summary.fixable} fixable`);
|
||||
|
||||
if (summary.byCode.length > 0) {
|
||||
lines.push("Top issue types:");
|
||||
for (const item of summary.byCode.slice(0, 5)) {
|
||||
lines.push(`- ${item.code}: ${item.count}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scopedIssues.length > 0) {
|
||||
lines.push("Priority issues:");
|
||||
for (const issue of scopedIssues.slice(0, maxIssues)) {
|
||||
const prefix = issue.severity === "error" ? "ERROR" : issue.severity === "warning" ? "WARN" : "INFO";
|
||||
lines.push(`- [${prefix}] ${issue.unitId}: ${issue.message}${issue.file ? ` (${issue.file})` : ""}`);
|
||||
}
|
||||
if (scopedIssues.length > maxIssues) {
|
||||
lines.push(`- ...and ${scopedIssues.length - maxIssues} more in scope`);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.fixesApplied.length > 0) {
|
||||
lines.push("Fixes applied:");
|
||||
for (const fix of report.fixesApplied.slice(0, maxIssues)) lines.push(`- ${fix}`);
|
||||
if (report.fixesApplied.length > maxIssues) lines.push(`- ...and ${report.fixesApplied.length - maxIssues} more`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
|
||||
if (issues.length === 0) return "- No remaining issues in scope.";
|
||||
return issues.map(issue => {
|
||||
const prefix = issue.severity === "error" ? "ERROR" : issue.severity === "warning" ? "WARN" : "INFO";
|
||||
return `- [${prefix}] ${issue.unitId} | ${issue.code} | ${issue.message}${issue.file ? ` | file: ${issue.file}` : ""} | fixable: ${issue.fixable ? "yes" : "no"}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
async function checkGitHealth(
|
||||
basePath: string,
|
||||
issues: DoctorIssue[],
|
||||
fixesApplied: string[],
|
||||
shouldFix: (code: DoctorIssueCode) => boolean,
|
||||
isolationMode: "none" | "worktree" | "branch" = "worktree",
|
||||
): Promise<void> {
|
||||
// Degrade gracefully if not a git repo
|
||||
if (!nativeIsRepo(basePath)) {
|
||||
return; // Not a git repo — skip all git health checks
|
||||
}
|
||||
|
||||
const gitDir = resolveGitDir(basePath);
|
||||
|
||||
// ── Orphaned auto-worktrees & Stale milestone branches ────────────────
|
||||
// These checks only apply in worktree/branch modes — skip in none mode
|
||||
// where no milestone worktrees or branches are created.
|
||||
if (isolationMode !== "none") {
|
||||
try {
|
||||
const worktrees = listWorktrees(basePath);
|
||||
const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/"));
|
||||
|
||||
// Load roadmap state once for cross-referencing
|
||||
const state = await deriveState(basePath);
|
||||
|
||||
for (const wt of milestoneWorktrees) {
|
||||
// Extract milestone ID from branch name "milestone/M001" → "M001"
|
||||
const milestoneId = wt.branch.replace(/^milestone\//, "");
|
||||
const milestoneEntry = state.registry.find(m => m.id === milestoneId);
|
||||
|
||||
// Check if milestone is complete via roadmap
|
||||
let isComplete = false;
|
||||
if (milestoneEntry) {
|
||||
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
||||
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
||||
if (roadmapContent) {
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
isComplete = isMilestoneComplete(roadmap);
|
||||
}
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "orphaned_auto_worktree",
|
||||
scope: "milestone",
|
||||
unitId: milestoneId,
|
||||
message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("orphaned_auto_worktree")) {
|
||||
// Never remove a worktree matching current working directory
|
||||
const cwd = process.cwd();
|
||||
if (wt.path === cwd || cwd.startsWith(wt.path + sep)) {
|
||||
fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
|
||||
} else {
|
||||
try {
|
||||
nativeWorktreeRemove(basePath, wt.path, true);
|
||||
fixesApplied.push(`removed orphaned worktree ${wt.path}`);
|
||||
} catch {
|
||||
fixesApplied.push(`failed to remove worktree ${wt.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stale milestone branches ─────────────────────────────────────────
|
||||
try {
|
||||
const branches = nativeBranchList(basePath, "milestone/*");
|
||||
if (branches.length > 0) {
|
||||
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
|
||||
|
||||
for (const branch of branches) {
|
||||
// Skip branches that have a worktree (handled above)
|
||||
if (worktreeBranches.has(branch)) continue;
|
||||
|
||||
const milestoneId = branch.replace(/^milestone\//, "");
|
||||
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
||||
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
||||
if (!roadmapContent) continue;
|
||||
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
if (isMilestoneComplete(roadmap)) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "stale_milestone_branch",
|
||||
scope: "milestone",
|
||||
unitId: milestoneId,
|
||||
message: `Branch ${branch} exists for completed milestone ${milestoneId}`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("stale_milestone_branch")) {
|
||||
try {
|
||||
nativeBranchDelete(basePath, branch, true);
|
||||
fixesApplied.push(`deleted stale branch ${branch}`);
|
||||
} catch {
|
||||
fixesApplied.push(`failed to delete branch ${branch}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// git branch list failed — skip stale branch check
|
||||
}
|
||||
} catch {
|
||||
// listWorktrees or deriveState failed — skip worktree/branch checks
|
||||
}
|
||||
} // end isolationMode !== "none"
|
||||
|
||||
// ── Corrupt merge state ────────────────────────────────────────────────
|
||||
try {
|
||||
const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"];
|
||||
const mergeStateDirs = ["rebase-apply", "rebase-merge"];
|
||||
const found: string[] = [];
|
||||
|
||||
for (const f of mergeStateFiles) {
|
||||
if (existsSync(join(gitDir, f))) found.push(f);
|
||||
}
|
||||
for (const d of mergeStateDirs) {
|
||||
if (existsSync(join(gitDir, d))) found.push(d);
|
||||
}
|
||||
|
||||
if (found.length > 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "corrupt_merge_state",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `Corrupt merge/rebase state detected: ${found.join(", ")}`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("corrupt_merge_state")) {
|
||||
const result = abortAndReset(basePath);
|
||||
fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't check .git dir — skip
|
||||
}
|
||||
|
||||
// ── Tracked runtime files ──────────────────────────────────────────────
|
||||
try {
|
||||
const trackedPaths: string[] = [];
|
||||
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
||||
try {
|
||||
const files = nativeLsFiles(basePath, exclusion);
|
||||
if (files.length > 0) {
|
||||
trackedPaths.push(...files);
|
||||
}
|
||||
} catch {
|
||||
// Individual ls-files can fail — continue
|
||||
}
|
||||
}
|
||||
|
||||
if (trackedPaths.length > 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "tracked_runtime_files",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("tracked_runtime_files")) {
|
||||
try {
|
||||
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
||||
nativeRmCached(basePath, [exclusion]);
|
||||
}
|
||||
fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
|
||||
} catch {
|
||||
fixesApplied.push("failed to untrack runtime files");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// git ls-files failed — skip
|
||||
}
|
||||
|
||||
// ── Legacy slice branches ──────────────────────────────────────────────
|
||||
try {
|
||||
const branchList = nativeBranchList(basePath, "gsd/*/*");
|
||||
if (branchList.length > 0) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "legacy_slice_branches",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture). Delete with: git branch -D ${branchList.join(" ")}`,
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// git branch list failed — skip
|
||||
}
|
||||
}
|
||||
|
||||
// ── Runtime Health Checks ──────────────────────────────────────────────────
|
||||
// Checks for stale crash locks, orphaned completed-units, stale hook state,
|
||||
// activity log bloat, STATE.md drift, and gitignore drift.
|
||||
|
||||
async function checkRuntimeHealth(
|
||||
basePath: string,
|
||||
issues: DoctorIssue[],
|
||||
fixesApplied: string[],
|
||||
shouldFix: (code: DoctorIssueCode) => boolean,
|
||||
): Promise<void> {
|
||||
const root = gsdRoot(basePath);
|
||||
|
||||
// ── Stale crash lock ──────────────────────────────────────────────────
|
||||
try {
|
||||
const lock = readCrashLock(basePath);
|
||||
if (lock) {
|
||||
const alive = isLockProcessAlive(lock);
|
||||
if (!alive) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "stale_crash_lock",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `Stale auto.lock from PID ${lock.pid} (started ${lock.startedAt}, was executing ${lock.unitType} ${lock.unitId}) — process is no longer running`,
|
||||
file: ".gsd/auto.lock",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("stale_crash_lock")) {
|
||||
clearLock(basePath);
|
||||
fixesApplied.push("cleared stale auto.lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — crash lock check failed
|
||||
}
|
||||
|
||||
// ── Stale parallel sessions ────────────────────────────────────────────
|
||||
try {
|
||||
const parallelStatuses = readAllSessionStatuses(basePath);
|
||||
for (const status of parallelStatuses) {
|
||||
if (isSessionStale(status)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "stale_parallel_session",
|
||||
scope: "project",
|
||||
unitId: status.milestoneId,
|
||||
message: `Stale parallel session for ${status.milestoneId} (PID ${status.pid}, started ${new Date(status.startedAt).toISOString()}, last heartbeat ${new Date(status.lastHeartbeat).toISOString()}) — process is no longer running`,
|
||||
file: `.gsd/parallel/${status.milestoneId}.status.json`,
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("stale_parallel_session")) {
|
||||
removeSessionStatus(basePath, status.milestoneId);
|
||||
fixesApplied.push(`cleaned up stale parallel session for ${status.milestoneId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — parallel session check failed
|
||||
}
|
||||
|
||||
// ── Orphaned completed-units keys ─────────────────────────────────────
|
||||
try {
|
||||
const completedKeysFile = join(root, "completed-units.json");
|
||||
if (existsSync(completedKeysFile)) {
|
||||
const raw = readFileSync(completedKeysFile, "utf-8");
|
||||
const keys: string[] = JSON.parse(raw);
|
||||
const orphaned: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
// Key format: "unitType/unitId" e.g. "execute-task/M001/S01/T01"
|
||||
const slashIdx = key.indexOf("/");
|
||||
if (slashIdx === -1) continue;
|
||||
const unitType = key.slice(0, slashIdx);
|
||||
const unitId = key.slice(slashIdx + 1);
|
||||
|
||||
// Only validate artifact-producing unit types
|
||||
const { verifyExpectedArtifact } = await import("./auto-recovery.js");
|
||||
if (!verifyExpectedArtifact(unitType, unitId, basePath)) {
|
||||
orphaned.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphaned.length > 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "orphaned_completed_units",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${orphaned.length} completed-unit key(s) reference missing artifacts: ${orphaned.slice(0, 3).join(", ")}${orphaned.length > 3 ? "..." : ""}`,
|
||||
file: ".gsd/completed-units.json",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("orphaned_completed_units")) {
|
||||
const { removePersistedKey } = await import("./auto-recovery.js");
|
||||
for (const key of orphaned) {
|
||||
removePersistedKey(basePath, key);
|
||||
}
|
||||
fixesApplied.push(`removed ${orphaned.length} orphaned completed-unit key(s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — completed-units check failed
|
||||
}
|
||||
|
||||
// ── Stale hook state ──────────────────────────────────────────────────
|
||||
try {
|
||||
const hookStateFile = join(root, "hook-state.json");
|
||||
if (existsSync(hookStateFile)) {
|
||||
const raw = readFileSync(hookStateFile, "utf-8");
|
||||
const state = JSON.parse(raw);
|
||||
const hasCycleCounts = state.cycleCounts && typeof state.cycleCounts === "object"
|
||||
&& Object.keys(state.cycleCounts).length > 0;
|
||||
|
||||
// Only flag if there are actual cycle counts AND no auto-mode is running
|
||||
if (hasCycleCounts) {
|
||||
const lock = readCrashLock(basePath);
|
||||
const autoRunning = lock ? isLockProcessAlive(lock) : false;
|
||||
|
||||
if (!autoRunning) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "stale_hook_state",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `hook-state.json has ${Object.keys(state.cycleCounts).length} residual cycle count(s) from a previous session`,
|
||||
file: ".gsd/hook-state.json",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("stale_hook_state")) {
|
||||
const { clearPersistedHookState } = await import("./post-unit-hooks.js");
|
||||
clearPersistedHookState(basePath);
|
||||
fixesApplied.push("cleared stale hook-state.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — hook state check failed
|
||||
}
|
||||
|
||||
// ── Activity log bloat ────────────────────────────────────────────────
|
||||
try {
|
||||
const activityDir = join(root, "activity");
|
||||
if (existsSync(activityDir)) {
|
||||
const files = readdirSync(activityDir);
|
||||
let totalSize = 0;
|
||||
for (const f of files) {
|
||||
try {
|
||||
totalSize += statSync(join(activityDir, f)).size;
|
||||
} catch {
|
||||
// stat failed — skip
|
||||
}
|
||||
}
|
||||
|
||||
const totalMB = totalSize / (1024 * 1024);
|
||||
const BLOAT_FILE_THRESHOLD = 500;
|
||||
const BLOAT_SIZE_MB = 100;
|
||||
|
||||
if (files.length > BLOAT_FILE_THRESHOLD || totalMB > BLOAT_SIZE_MB) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "activity_log_bloat",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `Activity logs: ${files.length} files, ${totalMB.toFixed(1)}MB (thresholds: ${BLOAT_FILE_THRESHOLD} files / ${BLOAT_SIZE_MB}MB)`,
|
||||
file: ".gsd/activity/",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("activity_log_bloat")) {
|
||||
const { pruneActivityLogs } = await import("./activity-log.js");
|
||||
pruneActivityLogs(activityDir, 7); // 7-day retention
|
||||
fixesApplied.push("pruned activity logs (7-day retention)");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — activity log check failed
|
||||
}
|
||||
|
||||
// ── STATE.md health ───────────────────────────────────────────────────
|
||||
try {
|
||||
const stateFilePath = resolveGsdRootFile(basePath, "STATE");
|
||||
const milestonesPath = milestonesDir(basePath);
|
||||
|
||||
if (existsSync(milestonesPath)) {
|
||||
if (!existsSync(stateFilePath)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "state_file_missing",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: "STATE.md is missing — state display will not work",
|
||||
file: ".gsd/STATE.md",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("state_file_missing")) {
|
||||
const state = await deriveState(basePath);
|
||||
await saveFile(stateFilePath, buildStateMarkdown(state));
|
||||
fixesApplied.push("created STATE.md from derived state");
|
||||
}
|
||||
} else {
|
||||
// Check if STATE.md is stale by comparing active milestone/slice/phase
|
||||
const currentContent = readFileSync(stateFilePath, "utf-8");
|
||||
const state = await deriveState(basePath);
|
||||
const freshContent = buildStateMarkdown(state);
|
||||
|
||||
// Extract key fields for comparison — don't compare full content
|
||||
// since timestamp/formatting differences are normal
|
||||
const extractFields = (content: string) => {
|
||||
const milestone = content.match(/\*\*Active Milestone:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
|
||||
const slice = content.match(/\*\*Active Slice:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
|
||||
const phase = content.match(/\*\*Phase:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
|
||||
return { milestone, slice, phase };
|
||||
};
|
||||
|
||||
const current = extractFields(currentContent);
|
||||
const fresh = extractFields(freshContent);
|
||||
|
||||
if (current.milestone !== fresh.milestone || current.slice !== fresh.slice || current.phase !== fresh.phase) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "state_file_stale",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `STATE.md is stale — shows "${current.phase}" but derived state is "${fresh.phase}"`,
|
||||
file: ".gsd/STATE.md",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("state_file_stale")) {
|
||||
await saveFile(stateFilePath, freshContent);
|
||||
fixesApplied.push("rebuilt STATE.md from derived state");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — STATE.md check failed
|
||||
}
|
||||
|
||||
// ── Gitignore drift ───────────────────────────────────────────────────
|
||||
try {
|
||||
const gitignorePath = join(basePath, ".gitignore");
|
||||
if (existsSync(gitignorePath) && nativeIsRepo(basePath)) {
|
||||
const content = readFileSync(gitignorePath, "utf-8");
|
||||
const existingLines = new Set(
|
||||
content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")),
|
||||
);
|
||||
|
||||
// Check for critical runtime patterns that must be present
|
||||
const criticalPatterns = [
|
||||
".gsd/activity/",
|
||||
".gsd/runtime/",
|
||||
".gsd/auto.lock",
|
||||
".gsd/gsd.db",
|
||||
".gsd/completed-units.json",
|
||||
];
|
||||
|
||||
// If blanket .gsd/ or .gsd is present, all patterns are covered
|
||||
const hasBlanketIgnore = existingLines.has(".gsd/") || existingLines.has(".gsd");
|
||||
|
||||
if (!hasBlanketIgnore) {
|
||||
const missing = criticalPatterns.filter(p => !existingLines.has(p));
|
||||
if (missing.length > 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "gitignore_missing_patterns",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${missing.length} critical GSD runtime pattern(s) missing from .gitignore: ${missing.join(", ")}`,
|
||||
file: ".gitignore",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("gitignore_missing_patterns")) {
|
||||
ensureGitignore(basePath);
|
||||
fixesApplied.push("added missing GSD runtime patterns to .gitignore");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — gitignore check failed
|
||||
}
|
||||
}
|
||||
|
||||
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {
|
||||
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<import("./doctor-types.js").DoctorReport> {
|
||||
const issues: DoctorIssue[] = [];
|
||||
const fixesApplied: string[] = [];
|
||||
const fix = options?.fix === true;
|
||||
|
|
@ -1079,9 +446,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
});
|
||||
}
|
||||
|
||||
// Check for unresolvable dependency IDs — catches range syntax like "S01-S04"
|
||||
// that the parser expanded but that don't match any actual slice in the roadmap.
|
||||
// Also catches plain typos or IDs referencing slices not yet defined.
|
||||
// Check for unresolvable dependency IDs
|
||||
const knownSliceIds = new Set(roadmap.slices.map(s => s.id));
|
||||
for (const dep of slice.depends) {
|
||||
if (!knownSliceIds.has(dep)) {
|
||||
|
|
@ -1108,7 +473,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
scope: "slice",
|
||||
unitId,
|
||||
message: slice.done
|
||||
? `Missing tasks directory for ${unitId} (slice is complete — cosmetic only)`
|
||||
? `Missing tasks directory for ${unitId} (slice is complete \u2014 cosmetic only)`
|
||||
: `Missing tasks directory for ${unitId}`,
|
||||
file: relSlicePath(basePath, milestoneId, slice.id),
|
||||
fixable: true,
|
||||
|
|
@ -1153,9 +518,6 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"),
|
||||
fixable: true,
|
||||
});
|
||||
// Write a stub summary so validate-milestone can proceed.
|
||||
// This prevents infinite skip loops when tasks are marked done
|
||||
// without summaries (#820).
|
||||
if (shouldFix("task_done_missing_summary")) {
|
||||
const stubPath = join(
|
||||
basePath, ".gsd", "milestones", milestoneId, "slices", slice.id, "tasks",
|
||||
|
|
@ -1170,7 +532,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
``,
|
||||
`# ${task.id}: ${task.title || "Unknown"}`,
|
||||
``,
|
||||
`Summary stub generated by \`/gsd doctor\` — task was marked done but no summary existed.`,
|
||||
`Summary stub generated by \`/gsd doctor\` \u2014 task was marked done but no summary existed.`,
|
||||
``,
|
||||
].join("\n");
|
||||
await saveFile(stubPath, stubContent);
|
||||
|
|
@ -1191,7 +553,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
if (fix) await markTaskDoneInPlan(basePath, milestoneId, slice.id, task.id, fixesApplied);
|
||||
}
|
||||
|
||||
// Must-have verification: done task with summary — check if must-haves are addressed
|
||||
// Must-have verification
|
||||
if (task.done && hasSummary) {
|
||||
const taskPlanPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "PLAN");
|
||||
if (taskPlanPath) {
|
||||
|
|
@ -1222,8 +584,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
allTasksDone = allTasksDone && task.done;
|
||||
}
|
||||
|
||||
// Blocker-without-replan detection: a completed task reported blocker_discovered
|
||||
// but no REPLAN.md exists yet — the slice is stuck
|
||||
// Blocker-without-replan detection
|
||||
const replanPath = resolveSliceFile(basePath, milestoneId, slice.id, "REPLAN");
|
||||
if (!replanPath) {
|
||||
for (const task of plan.tasks) {
|
||||
|
|
@ -1239,11 +600,11 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
code: "blocker_discovered_no_replan",
|
||||
scope: "slice",
|
||||
unitId,
|
||||
message: `Task ${task.id} reported blocker_discovered but no REPLAN.md exists for ${slice.id} — slice may be stuck`,
|
||||
message: `Task ${task.id} reported blocker_discovered but no REPLAN.md exists for ${slice.id} \u2014 slice may be stuck`,
|
||||
file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"),
|
||||
fixable: false,
|
||||
});
|
||||
break; // one issue per slice is sufficient
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1326,7 +687,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
code: "all_slices_done_missing_milestone_validation",
|
||||
scope: "milestone",
|
||||
unitId: milestoneId,
|
||||
message: `All slices are done but ${milestoneId}-VALIDATION.md is missing — milestone is in validating-milestone phase`,
|
||||
message: `All slices are done but ${milestoneId}-VALIDATION.md is missing \u2014 milestone is in validating-milestone phase`,
|
||||
file: relMilestoneFile(basePath, milestoneId, "VALIDATION"),
|
||||
fixable: false,
|
||||
});
|
||||
|
|
@ -1339,7 +700,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
code: "all_slices_done_missing_milestone_summary",
|
||||
scope: "milestone",
|
||||
unitId: milestoneId,
|
||||
message: `All slices are done but ${milestoneId}-SUMMARY.md is missing — milestone is stuck in completing-milestone phase`,
|
||||
message: `All slices are done but ${milestoneId}-SUMMARY.md is missing \u2014 milestone is stuck in completing-milestone phase`,
|
||||
file: relMilestoneFile(basePath, milestoneId, "SUMMARY"),
|
||||
fixable: false,
|
||||
});
|
||||
|
|
@ -1357,4 +718,3 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|||
fixesApplied,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue