@opengsd/gsd-pi 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/doctor-history.js +65 -0
- package/dist/resources/extensions/gsd/doctor-state-checks.js +509 -0
- package/dist/resources/extensions/gsd/doctor-workspace-checks.js +91 -0
- package/dist/resources/extensions/gsd/doctor.js +14 -653
- package/dist/resources/extensions/gsd/md-importer.js +7 -2
- package/dist/resources/extensions/gsd/migrate/planning-writer.js +8 -2
- package/dist/resources/extensions/gsd/migration-auto-check.js +4 -0
- package/dist/resources/extensions/gsd/state-reconciliation/drift/project-md.js +54 -14
- package/dist/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.js +48 -5
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/node_modules/@gsd/native/package.json +1 -1
- package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
- package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
- package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
- package/integrations/hermes/open_gsd_hermes/gsd_client.py +1 -1
- package/integrations/hermes/pyproject.toml +1 -1
- package/package.json +6 -6
- package/packages/cloud-mcp-gateway/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/dist/cloud-config.d.ts.map +1 -1
- package/packages/daemon/dist/cloud-config.js +25 -7
- package/packages/daemon/dist/cloud-config.js.map +1 -1
- package/packages/daemon/dist/cloud-config.test.js +19 -1
- package/packages/daemon/dist/cloud-config.test.js.map +1 -1
- package/packages/daemon/package.json +4 -4
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/gsd-cloud/README.md +18 -4
- package/packages/gsd-cloud/bin/gsd-cloud.js +6 -5
- package/packages/gsd-cloud/package.json +6 -4
- package/packages/mcp-server/package.json +4 -4
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/package.json +7 -7
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/doctor-history.ts +81 -0
- package/src/resources/extensions/gsd/doctor-state-checks.ts +527 -0
- package/src/resources/extensions/gsd/doctor-workspace-checks.ts +97 -0
- package/src/resources/extensions/gsd/doctor.ts +14 -673
- package/src/resources/extensions/gsd/md-importer.ts +7 -2
- package/src/resources/extensions/gsd/migrate/planning-writer.ts +9 -3
- package/src/resources/extensions/gsd/migration-auto-check.ts +3 -0
- package/src/resources/extensions/gsd/state-reconciliation/drift/project-md.ts +67 -20
- package/src/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.ts +47 -5
- package/src/resources/extensions/gsd/tests/db-authority-regression.test.ts +76 -1
- package/src/resources/extensions/gsd/tests/doctor-history-public-api.test.ts +25 -0
- package/src/resources/extensions/gsd/tests/migrate-hierarchy.test.ts +53 -0
- package/src/resources/extensions/gsd/tests/migration-auto-check.test.ts +53 -0
- package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +4 -0
- package/src/resources/extensions/gsd/tests/planning-writer.test.ts +18 -1
- package/src/resources/extensions/gsd/tests/progressive-planning.test.ts +18 -3
- package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +177 -25
- /package/dist/web/standalone/.next/static/{enWQFRY-aLTUq3wmxG2Qz → UaXRrOYxBpG9QBINKhZkg}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{enWQFRY-aLTUq3wmxG2Qz → UaXRrOYxBpG9QBINKhZkg}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -28,12 +28,14 @@ See [CHANGELOG.md](./CHANGELOG.md) for release-by-release fixes and [Legacy Rele
|
|
|
28
28
|
## Latest Release Highlights
|
|
29
29
|
|
|
30
30
|
<!-- release-highlights:start -->
|
|
31
|
-
Latest release: **v1.
|
|
32
|
-
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **issue:**
|
|
36
|
-
- **issue:** [
|
|
31
|
+
Latest release: **v1.8.1**
|
|
32
|
+
|
|
33
|
+
- **doctor:** Extract state checks.
|
|
34
|
+
- **doctor:** Extract history and workspace checks.
|
|
35
|
+
- **issue:** Unregistered-milestone false positive: flat-phase ID extractor derives bare M### from descriptive slug, missing suffixed DB row (M###-abcdef) (#1282).
|
|
36
|
+
- **issue:** [Bug] reconcile triggers mass re-projection of all historical PLAN.md files, resetting completed task checkboxes to unchecked (#1279).
|
|
37
|
+
- **issue:** [Bug]: `migrateHierarchyToDb` imports a `[sketch]` slice's stub PLAN tasks as real DB rows, flipping planning→executing.
|
|
38
|
+
- **issue:** [Bug]: `sketchFlagHandler` clears `is_sketch` on mere PLAN-file existence, including a stub or leftover PLAN.
|
|
37
39
|
|
|
38
40
|
<!-- release-highlights:end -->
|
|
39
41
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
f38cd5787a6340c5
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { saveFile } from "./files.js";
|
|
4
|
+
import { gsdRoot } from "./paths.js";
|
|
5
|
+
function buildDoctorHistorySummary(report, errorCount, warningCount, issueDetails) {
|
|
6
|
+
const summaryParts = [];
|
|
7
|
+
if (report.ok) {
|
|
8
|
+
summaryParts.push("Clean");
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
const counts = [];
|
|
12
|
+
if (errorCount > 0)
|
|
13
|
+
counts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
14
|
+
if (warningCount > 0)
|
|
15
|
+
counts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`);
|
|
16
|
+
summaryParts.push(counts.join(", "));
|
|
17
|
+
}
|
|
18
|
+
if (report.fixesApplied.length > 0) {
|
|
19
|
+
summaryParts.push(`${report.fixesApplied.length} fixed`);
|
|
20
|
+
}
|
|
21
|
+
if (issueDetails && issueDetails.length > 0) {
|
|
22
|
+
const topIssue = issueDetails.find(i => i.severity === "error") ?? issueDetails[0];
|
|
23
|
+
summaryParts.push(topIssue.message);
|
|
24
|
+
}
|
|
25
|
+
return summaryParts.join(" · ");
|
|
26
|
+
}
|
|
27
|
+
export async function appendDoctorHistory(basePath, report) {
|
|
28
|
+
try {
|
|
29
|
+
const historyPath = join(gsdRoot(basePath), "doctor-history.jsonl");
|
|
30
|
+
const errorCount = report.issues.filter(i => i.severity === "error").length;
|
|
31
|
+
const warningCount = report.issues.filter(i => i.severity === "warning").length;
|
|
32
|
+
const issueDetails = report.issues
|
|
33
|
+
.filter(i => i.severity === "error" || i.severity === "warning")
|
|
34
|
+
.slice(0, 10) // cap to keep JSONL lines bounded
|
|
35
|
+
.map(i => ({ severity: i.severity, code: i.code, message: i.message, unitId: i.unitId }));
|
|
36
|
+
const entry = JSON.stringify({
|
|
37
|
+
ts: new Date().toISOString(),
|
|
38
|
+
ok: report.ok,
|
|
39
|
+
errors: errorCount,
|
|
40
|
+
warnings: warningCount,
|
|
41
|
+
fixes: report.fixesApplied.length,
|
|
42
|
+
codes: [...new Set(report.issues.map(i => i.code))],
|
|
43
|
+
issues: issueDetails.length > 0 ? issueDetails : undefined,
|
|
44
|
+
fixDescriptions: report.fixesApplied.length > 0 ? report.fixesApplied : undefined,
|
|
45
|
+
scope: report.scope,
|
|
46
|
+
summary: buildDoctorHistorySummary(report, errorCount, warningCount, issueDetails),
|
|
47
|
+
});
|
|
48
|
+
const existing = existsSync(historyPath) ? readFileSync(historyPath, "utf-8") : "";
|
|
49
|
+
await saveFile(historyPath, existing + entry + "\n");
|
|
50
|
+
}
|
|
51
|
+
catch { /* non-fatal */ }
|
|
52
|
+
}
|
|
53
|
+
/** Read the last N doctor history entries. Returns most-recent-first. */
|
|
54
|
+
export async function readDoctorHistory(basePath, lastN = 50) {
|
|
55
|
+
try {
|
|
56
|
+
const historyPath = join(gsdRoot(basePath), "doctor-history.jsonl");
|
|
57
|
+
if (!existsSync(historyPath))
|
|
58
|
+
return [];
|
|
59
|
+
const lines = readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim());
|
|
60
|
+
return lines.slice(-lastN).reverse().map(l => JSON.parse(l));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadFile, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
|
|
4
|
+
import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
|
|
5
|
+
import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js";
|
|
6
|
+
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, legacyMilestonesDir, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile, relMilestonePath } from "./paths.js";
|
|
7
|
+
import { findMilestoneIds } from "./milestone-ids.js";
|
|
8
|
+
import { deriveState } from "./state.js";
|
|
9
|
+
import { isClosedStatus } from "./status-guards.js";
|
|
10
|
+
import { runProviderChecks } from "./doctor-providers.js";
|
|
11
|
+
import { validateTitle } from "./validation.js";
|
|
12
|
+
function matchesScope(unitId, scope) {
|
|
13
|
+
if (!scope)
|
|
14
|
+
return true;
|
|
15
|
+
return unitId === scope || unitId.startsWith(`${scope}/`);
|
|
16
|
+
}
|
|
17
|
+
function auditRequirements(content) {
|
|
18
|
+
if (!content)
|
|
19
|
+
return [];
|
|
20
|
+
const issues = [];
|
|
21
|
+
const blocks = content.split(/^###\s+/m).slice(1);
|
|
22
|
+
for (const block of blocks) {
|
|
23
|
+
const idMatch = block.match(/^(R\d+)/);
|
|
24
|
+
if (!idMatch)
|
|
25
|
+
continue;
|
|
26
|
+
const requirementId = idMatch[1];
|
|
27
|
+
const status = block.match(/^-\s+Status:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? "";
|
|
28
|
+
const owner = block.match(/^-\s+Primary owning slice:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? "";
|
|
29
|
+
const notes = block.match(/^-\s+Notes:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? "";
|
|
30
|
+
if (status === "active" && (!owner || owner === "none" || owner === "none yet")) {
|
|
31
|
+
// #4414: Downgrade to warning. A newly-created requirement has
|
|
32
|
+
// primary_owner='' by default until the planning agent wires it to
|
|
33
|
+
// a slice via gsd_requirement_update. Flagging this as an error
|
|
34
|
+
// during normal planning is noisy — the real failure mode is when
|
|
35
|
+
// it persists past milestone completion, which is covered by other
|
|
36
|
+
// audits. Keep the signal but don't treat it as a blocker.
|
|
37
|
+
issues.push({
|
|
38
|
+
severity: "warning",
|
|
39
|
+
code: "active_requirement_missing_owner",
|
|
40
|
+
scope: "project",
|
|
41
|
+
unitId: requirementId,
|
|
42
|
+
message: `${requirementId} is Active but has no primary owning slice`,
|
|
43
|
+
file: relGsdRootFile("REQUIREMENTS"),
|
|
44
|
+
fixable: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (status === "blocked" && !notes) {
|
|
48
|
+
issues.push({
|
|
49
|
+
severity: "warning",
|
|
50
|
+
code: "blocked_requirement_missing_reason",
|
|
51
|
+
scope: "project",
|
|
52
|
+
unitId: requirementId,
|
|
53
|
+
message: `${requirementId} is Blocked but has no reason in Notes`,
|
|
54
|
+
file: relGsdRootFile("REQUIREMENTS"),
|
|
55
|
+
fixable: false,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return issues;
|
|
60
|
+
}
|
|
61
|
+
// ── Helper: circular dependency detection ──────────────────────────────────
|
|
62
|
+
function detectCircularDependencies(slices) {
|
|
63
|
+
const known = new Set(slices.map(s => s.id));
|
|
64
|
+
const adj = new Map();
|
|
65
|
+
for (const s of slices)
|
|
66
|
+
adj.set(s.id, s.depends.filter(d => known.has(d)));
|
|
67
|
+
const state = new Map();
|
|
68
|
+
for (const s of slices)
|
|
69
|
+
state.set(s.id, "unvisited");
|
|
70
|
+
const cycles = [];
|
|
71
|
+
function dfs(id, path) {
|
|
72
|
+
const st = state.get(id);
|
|
73
|
+
if (st === "done")
|
|
74
|
+
return;
|
|
75
|
+
if (st === "visiting") {
|
|
76
|
+
cycles.push([...path.slice(path.indexOf(id)), id]);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
state.set(id, "visiting");
|
|
80
|
+
for (const dep of adj.get(id) ?? [])
|
|
81
|
+
dfs(dep, [...path, id]);
|
|
82
|
+
state.set(id, "done");
|
|
83
|
+
}
|
|
84
|
+
for (const s of slices)
|
|
85
|
+
if (state.get(s.id) === "unvisited")
|
|
86
|
+
dfs(s.id, []);
|
|
87
|
+
return cycles;
|
|
88
|
+
}
|
|
89
|
+
export async function checkGsdStateHealth(basePath, issues, fixesApplied, options) {
|
|
90
|
+
const { fix, shouldFix, scope } = options;
|
|
91
|
+
const requirementsPath = resolveGsdRootFile(basePath, "REQUIREMENTS");
|
|
92
|
+
const requirementsContent = await loadFile(requirementsPath);
|
|
93
|
+
issues.push(...auditRequirements(requirementsContent));
|
|
94
|
+
const state = await deriveState(basePath);
|
|
95
|
+
// Provider / auth health checks — only relevant when there is active work to dispatch.
|
|
96
|
+
// Skipped for idle projects (no active milestone) to avoid noise in environments
|
|
97
|
+
// where CI/test runners have no API key configured.
|
|
98
|
+
if (state.activeMilestone) {
|
|
99
|
+
try {
|
|
100
|
+
const providerResults = runProviderChecks();
|
|
101
|
+
for (const result of providerResults) {
|
|
102
|
+
if (!result.required)
|
|
103
|
+
continue;
|
|
104
|
+
if (result.status === "error") {
|
|
105
|
+
issues.push({
|
|
106
|
+
severity: "warning",
|
|
107
|
+
code: "provider_key_missing",
|
|
108
|
+
scope: "project",
|
|
109
|
+
unitId: "project",
|
|
110
|
+
message: result.message + (result.detail ? ` — ${result.detail}` : ""),
|
|
111
|
+
fixable: false,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
else if (result.status === "warning") {
|
|
115
|
+
issues.push({
|
|
116
|
+
severity: "warning",
|
|
117
|
+
code: "provider_key_backedoff",
|
|
118
|
+
scope: "project",
|
|
119
|
+
unitId: "project",
|
|
120
|
+
message: result.message + (result.detail ? ` — ${result.detail}` : ""),
|
|
121
|
+
fixable: false,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Non-fatal — provider check failure should not block other checks
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// When DB is unavailable, state.registry is empty. Fall back to a direct
|
|
131
|
+
// filesystem scan so the doctor can still report issues (e.g. missing ROADMAP)
|
|
132
|
+
// for milestone dirs that exist on disk.
|
|
133
|
+
const milestoneEntries = state.registry.length > 0
|
|
134
|
+
? state.registry
|
|
135
|
+
: findMilestoneIds(basePath).map(id => ({ id, title: id }));
|
|
136
|
+
for (const milestone of milestoneEntries) {
|
|
137
|
+
const milestoneId = milestone.id;
|
|
138
|
+
const milestonePath = resolveMilestonePath(basePath, milestoneId);
|
|
139
|
+
if (!milestonePath)
|
|
140
|
+
continue;
|
|
141
|
+
// Validate milestone title for delimiter characters that break state documents.
|
|
142
|
+
const milestoneTitleIssue = validateTitle(milestone.title);
|
|
143
|
+
if (milestoneTitleIssue) {
|
|
144
|
+
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
145
|
+
let wasFixed = false;
|
|
146
|
+
if (shouldFix("delimiter_in_title") && roadmapFile) {
|
|
147
|
+
try {
|
|
148
|
+
const raw = readFileSync(roadmapFile, "utf-8");
|
|
149
|
+
// Replace em/en dashes with " - " in the H1 title line only
|
|
150
|
+
const sanitized = raw.replace(/^(# .*)$/m, (line) => line.replace(/[\u2014\u2013]/g, "-"));
|
|
151
|
+
if (sanitized !== raw) {
|
|
152
|
+
await saveFile(roadmapFile, sanitized);
|
|
153
|
+
fixesApplied.push(`sanitized delimiter characters in ${milestoneId} title`);
|
|
154
|
+
wasFixed = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch { /* non-fatal — report the warning below */ }
|
|
158
|
+
}
|
|
159
|
+
if (!wasFixed) {
|
|
160
|
+
issues.push({
|
|
161
|
+
severity: "warning",
|
|
162
|
+
code: "delimiter_in_title",
|
|
163
|
+
scope: "milestone",
|
|
164
|
+
unitId: milestoneId,
|
|
165
|
+
message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`,
|
|
166
|
+
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
167
|
+
fixable: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
172
|
+
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
173
|
+
if (!roadmapContent) {
|
|
174
|
+
issues.push({
|
|
175
|
+
severity: "error",
|
|
176
|
+
code: "missing_roadmap",
|
|
177
|
+
scope: "milestone",
|
|
178
|
+
unitId: milestoneId,
|
|
179
|
+
message: `Milestone ${milestoneId} is missing its ROADMAP.md file.`,
|
|
180
|
+
fixable: false,
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
let slices;
|
|
185
|
+
if (isDbAvailable()) {
|
|
186
|
+
const dbSlices = getMilestoneSlices(milestoneId);
|
|
187
|
+
slices = dbSlices.map(s => ({
|
|
188
|
+
id: s.id,
|
|
189
|
+
title: s.title,
|
|
190
|
+
done: isClosedStatus(s.status),
|
|
191
|
+
pending: s.status === "pending",
|
|
192
|
+
skipped: s.status === "skipped",
|
|
193
|
+
risk: (s.risk || "medium"),
|
|
194
|
+
depends: s.depends,
|
|
195
|
+
demo: s.demo,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const activeMilestoneId = state.activeMilestone?.id;
|
|
200
|
+
const activeSliceId = state.activeSlice?.id;
|
|
201
|
+
slices = parseLegacyRoadmap(roadmapContent).slices.map(s => ({
|
|
202
|
+
...s,
|
|
203
|
+
// Legacy roadmaps only encode done vs not-done. For doctor's
|
|
204
|
+
// missing-directory checks, treat every undone slice except the
|
|
205
|
+
// current active slice as effectively pending/unstarted.
|
|
206
|
+
pending: !s.done && (milestoneId !== activeMilestoneId || s.id !== activeSliceId),
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
// Wrap in Roadmap-compatible shape for detectCircularDependencies
|
|
210
|
+
const roadmap = { slices };
|
|
211
|
+
// ── Circular dependency detection ──────────────────────────────────────
|
|
212
|
+
for (const cycle of detectCircularDependencies(roadmap.slices)) {
|
|
213
|
+
issues.push({
|
|
214
|
+
severity: "error",
|
|
215
|
+
code: "circular_slice_dependency",
|
|
216
|
+
scope: "milestone",
|
|
217
|
+
unitId: milestoneId,
|
|
218
|
+
message: `Circular dependency detected: ${cycle.join(" → ")}`,
|
|
219
|
+
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
220
|
+
fixable: false,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// ── Orphaned slice directories ─────────────────────────────────────────
|
|
224
|
+
try {
|
|
225
|
+
const slicesDir = join(milestonePath, "slices");
|
|
226
|
+
if (existsSync(slicesDir)) {
|
|
227
|
+
const knownSliceIds = new Set(roadmap.slices.map(s => s.id));
|
|
228
|
+
for (const entry of readdirSync(slicesDir)) {
|
|
229
|
+
try {
|
|
230
|
+
if (!lstatSync(join(slicesDir, entry)).isDirectory())
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (entry === "parallel-research")
|
|
237
|
+
continue;
|
|
238
|
+
if (!knownSliceIds.has(entry)) {
|
|
239
|
+
const quarantineExample = `.gsd/quarantine/milestones/${milestoneId}/slices/${entry}-manual-review`;
|
|
240
|
+
issues.push({
|
|
241
|
+
severity: "warning",
|
|
242
|
+
code: "orphaned_slice_directory",
|
|
243
|
+
scope: "milestone",
|
|
244
|
+
unitId: milestoneId,
|
|
245
|
+
message: `Directory "${entry}" exists in ${milestoneId}/slices/ but is not referenced in the roadmap or DB. ` +
|
|
246
|
+
`Review it; if stale, move or delete it. To preserve it, move it under ${quarantineExample}. ` +
|
|
247
|
+
"If it contains work to keep, copy or merge that content into a DB-backed slice before resuming.",
|
|
248
|
+
file: `${relMilestonePath(basePath, milestoneId)}/slices/${entry}`,
|
|
249
|
+
fixable: false,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch { /* non-fatal */ }
|
|
256
|
+
for (const slice of roadmap.slices) {
|
|
257
|
+
const unitId = `${milestoneId}/${slice.id}`;
|
|
258
|
+
if (scope && !matchesScope(unitId, scope) && scope !== milestoneId)
|
|
259
|
+
continue;
|
|
260
|
+
// Validate slice title for delimiter characters.
|
|
261
|
+
const sliceTitleIssue = validateTitle(slice.title);
|
|
262
|
+
if (sliceTitleIssue) {
|
|
263
|
+
// Slice titles live inside the roadmap H1/checkbox lines — the milestone-level
|
|
264
|
+
// fix above already sanitizes the roadmap file. For slices we only report, because
|
|
265
|
+
// the title comes from the checkbox text and requires careful regex to fix safely.
|
|
266
|
+
issues.push({
|
|
267
|
+
severity: "warning",
|
|
268
|
+
code: "delimiter_in_title",
|
|
269
|
+
scope: "slice",
|
|
270
|
+
unitId,
|
|
271
|
+
message: `Slice ${unitId} ${sliceTitleIssue}. Rename the slice to remove these characters to prevent state corruption.`,
|
|
272
|
+
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
273
|
+
fixable: false,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// Check for unresolvable dependency IDs
|
|
277
|
+
const knownSliceIds = new Set(roadmap.slices.map(s => s.id));
|
|
278
|
+
for (const dep of slice.depends) {
|
|
279
|
+
if (!knownSliceIds.has(dep)) {
|
|
280
|
+
issues.push({
|
|
281
|
+
severity: "warning",
|
|
282
|
+
code: "unresolvable_dependency",
|
|
283
|
+
scope: "slice",
|
|
284
|
+
unitId,
|
|
285
|
+
message: `Slice ${unitId} depends on "${dep}" which is not a slice ID in this roadmap. This permanently blocks the slice. Use comma-separated IDs: \`depends:[S01,S02]\``,
|
|
286
|
+
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
287
|
+
fixable: false,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const slicePath = resolveSlicePath(basePath, milestoneId, slice.id);
|
|
292
|
+
if (!slicePath) {
|
|
293
|
+
// Pending slices haven't been planned yet — directories are created
|
|
294
|
+
// lazily by ensurePreconditions() at dispatch time. Skipped slices are
|
|
295
|
+
// intentionally allowed to remain summary-less and directory-less.
|
|
296
|
+
if (slice.pending || slice.skipped)
|
|
297
|
+
continue;
|
|
298
|
+
const expectedPath = relSlicePath(basePath, milestoneId, slice.id);
|
|
299
|
+
issues.push({
|
|
300
|
+
severity: slice.done ? "warning" : "error",
|
|
301
|
+
code: "missing_slice_dir",
|
|
302
|
+
scope: "slice",
|
|
303
|
+
unitId,
|
|
304
|
+
message: slice.done
|
|
305
|
+
? `Missing slice directory for ${unitId} (slice is complete — cosmetic only)`
|
|
306
|
+
: `Missing slice directory for ${unitId}`,
|
|
307
|
+
file: expectedPath,
|
|
308
|
+
fixable: true,
|
|
309
|
+
});
|
|
310
|
+
if (fix) {
|
|
311
|
+
const absoluteSliceDir = join(milestonePath, "slices", slice.id);
|
|
312
|
+
mkdirSync(absoluteSliceDir, { recursive: true });
|
|
313
|
+
fixesApplied.push(`created ${absoluteSliceDir}`);
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id);
|
|
318
|
+
if (!tasksDir) {
|
|
319
|
+
// Pending slices haven't been planned yet — tasks/ is created on demand.
|
|
320
|
+
// Skipped slices may legitimately never create tasks/.
|
|
321
|
+
if (slice.pending || slice.skipped)
|
|
322
|
+
continue;
|
|
323
|
+
// Flat-phase: tasks are embedded in plan files; no tasks/ subdir expected.
|
|
324
|
+
if (!existsSync(legacyMilestonesDir(basePath)))
|
|
325
|
+
continue;
|
|
326
|
+
issues.push({
|
|
327
|
+
severity: slice.done ? "warning" : "error",
|
|
328
|
+
code: "missing_tasks_dir",
|
|
329
|
+
scope: "slice",
|
|
330
|
+
unitId,
|
|
331
|
+
message: slice.done
|
|
332
|
+
? `Missing tasks directory for ${unitId} (slice is complete \u2014 cosmetic only)`
|
|
333
|
+
: `Missing tasks directory for ${unitId}`,
|
|
334
|
+
file: relSlicePath(basePath, milestoneId, slice.id),
|
|
335
|
+
fixable: true,
|
|
336
|
+
});
|
|
337
|
+
if (fix) {
|
|
338
|
+
mkdirSync(join(slicePath, "tasks"), { recursive: true });
|
|
339
|
+
fixesApplied.push(`created ${join(slicePath, "tasks")}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const planPath = resolveSliceFile(basePath, milestoneId, slice.id, "PLAN");
|
|
343
|
+
const planContent = planPath ? await loadFile(planPath) : null;
|
|
344
|
+
// Normalize plan tasks: prefer DB, fall back to parsers-legacy
|
|
345
|
+
let plan = null;
|
|
346
|
+
if (isDbAvailable()) {
|
|
347
|
+
const dbTasks = getSliceTasks(milestoneId, slice.id);
|
|
348
|
+
if (dbTasks.length > 0) {
|
|
349
|
+
plan = { tasks: dbTasks.map(t => ({ id: t.id, done: t.status === "complete" || t.status === "done", title: t.title, estimate: t.estimate || undefined })) };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (!plan && planContent) {
|
|
353
|
+
plan = parseLegacyPlan(planContent);
|
|
354
|
+
}
|
|
355
|
+
if (!plan) {
|
|
356
|
+
if (!slice.done) {
|
|
357
|
+
issues.push({
|
|
358
|
+
severity: "warning",
|
|
359
|
+
code: "missing_slice_plan",
|
|
360
|
+
scope: "slice",
|
|
361
|
+
unitId,
|
|
362
|
+
message: `Slice ${unitId} has no plan file`,
|
|
363
|
+
file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"),
|
|
364
|
+
fixable: false,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
// ── Duplicate task IDs ───────────────────────────────────────────────
|
|
370
|
+
const taskIdCounts = new Map();
|
|
371
|
+
for (const task of plan.tasks)
|
|
372
|
+
taskIdCounts.set(task.id, (taskIdCounts.get(task.id) ?? 0) + 1);
|
|
373
|
+
for (const [taskId, count] of taskIdCounts) {
|
|
374
|
+
if (count > 1) {
|
|
375
|
+
issues.push({ severity: "error", code: "duplicate_task_id", scope: "slice", unitId,
|
|
376
|
+
message: `Task ID "${taskId}" appears ${count} times in ${slice.id}-PLAN.md — duplicate IDs cause dispatch failures`,
|
|
377
|
+
file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), fixable: false });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// ── Task files on disk not in plan ────────────────────────────────────
|
|
381
|
+
try {
|
|
382
|
+
if (tasksDir) {
|
|
383
|
+
const planTaskIds = new Set(plan.tasks.map(t => t.id));
|
|
384
|
+
for (const f of readdirSync(tasksDir)) {
|
|
385
|
+
if (!f.endsWith("-SUMMARY.md"))
|
|
386
|
+
continue;
|
|
387
|
+
const diskTaskId = f.replace(/-SUMMARY\.md$/, "");
|
|
388
|
+
if (!planTaskIds.has(diskTaskId)) {
|
|
389
|
+
issues.push({ severity: "info", code: "task_file_not_in_plan", scope: "slice", unitId,
|
|
390
|
+
message: `Task summary "${f}" exists on disk but "${diskTaskId}" is not in ${slice.id}-PLAN.md`,
|
|
391
|
+
file: relTaskFile(basePath, milestoneId, slice.id, diskTaskId, "SUMMARY"), fixable: false });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch { /* non-fatal */ }
|
|
397
|
+
let allTasksDone = plan.tasks.length > 0;
|
|
398
|
+
for (const task of plan.tasks) {
|
|
399
|
+
const taskUnitId = `${unitId}/${task.id}`;
|
|
400
|
+
const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY");
|
|
401
|
+
const hasSummary = !!(summaryPath && await loadFile(summaryPath));
|
|
402
|
+
// Must-have verification
|
|
403
|
+
if (task.done && hasSummary) {
|
|
404
|
+
const taskPlanPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "PLAN");
|
|
405
|
+
if (taskPlanPath) {
|
|
406
|
+
const taskPlanContent = await loadFile(taskPlanPath);
|
|
407
|
+
if (taskPlanContent) {
|
|
408
|
+
const mustHaves = parseTaskPlanMustHaves(taskPlanContent);
|
|
409
|
+
if (mustHaves.length > 0) {
|
|
410
|
+
const summaryContent = await loadFile(summaryPath);
|
|
411
|
+
const mentionedCount = summaryContent
|
|
412
|
+
? countMustHavesMentionedInSummary(mustHaves, summaryContent)
|
|
413
|
+
: 0;
|
|
414
|
+
if (mentionedCount < mustHaves.length) {
|
|
415
|
+
issues.push({
|
|
416
|
+
severity: "warning",
|
|
417
|
+
code: "task_done_must_haves_not_verified",
|
|
418
|
+
scope: "task",
|
|
419
|
+
unitId: taskUnitId,
|
|
420
|
+
message: `Task ${task.id} has ${mustHaves.length} must-haves but summary addresses only ${mentionedCount}`,
|
|
421
|
+
file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"),
|
|
422
|
+
fixable: false,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// ── Future timestamp check ─────────────────────────────────────
|
|
430
|
+
if (task.done && hasSummary && summaryPath) {
|
|
431
|
+
try {
|
|
432
|
+
const rawSummary = await loadFile(summaryPath);
|
|
433
|
+
const m = rawSummary?.match(/^completed_at:\s*(.+)$/m);
|
|
434
|
+
if (m) {
|
|
435
|
+
const ts = new Date(m[1].trim());
|
|
436
|
+
if (!isNaN(ts.getTime()) && ts.getTime() > Date.now() + 24 * 60 * 60 * 1000) {
|
|
437
|
+
issues.push({ severity: "warning", code: "future_timestamp", scope: "task", unitId: taskUnitId,
|
|
438
|
+
message: `Task ${task.id} has completed_at "${m[1].trim()}" which is more than 24h in the future`,
|
|
439
|
+
file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), fixable: false });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch { /* non-fatal */ }
|
|
444
|
+
}
|
|
445
|
+
allTasksDone = allTasksDone && task.done;
|
|
446
|
+
}
|
|
447
|
+
// Blocker-without-replan detection
|
|
448
|
+
// Skip when all tasks are done — the blocker was implicitly resolved
|
|
449
|
+
// within the task and the slice is not stuck (#3105 Bug 2).
|
|
450
|
+
const replanPath = resolveSliceFile(basePath, milestoneId, slice.id, "REPLAN");
|
|
451
|
+
if (!replanPath && !allTasksDone) {
|
|
452
|
+
for (const task of plan.tasks) {
|
|
453
|
+
if (!task.done)
|
|
454
|
+
continue;
|
|
455
|
+
const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY");
|
|
456
|
+
if (!summaryPath)
|
|
457
|
+
continue;
|
|
458
|
+
const summaryContent = await loadFile(summaryPath);
|
|
459
|
+
if (!summaryContent)
|
|
460
|
+
continue;
|
|
461
|
+
const summary = parseSummary(summaryContent);
|
|
462
|
+
if (summary.frontmatter.blocker_discovered) {
|
|
463
|
+
issues.push({
|
|
464
|
+
severity: "warning",
|
|
465
|
+
code: "blocker_discovered_no_replan",
|
|
466
|
+
scope: "slice",
|
|
467
|
+
unitId,
|
|
468
|
+
message: `Task ${task.id} reported blocker_discovered but no REPLAN.md exists for ${slice.id} \u2014 slice may be stuck`,
|
|
469
|
+
file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"),
|
|
470
|
+
fixable: false,
|
|
471
|
+
});
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// ── Stale REPLAN: exists but all tasks done ────────────────────────
|
|
477
|
+
if (replanPath && allTasksDone) {
|
|
478
|
+
issues.push({ severity: "info", code: "stale_replan_file", scope: "slice", unitId,
|
|
479
|
+
message: `${slice.id} has a REPLAN.md but all tasks are done — REPLAN.md may be stale`,
|
|
480
|
+
file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), fixable: false });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Milestone-level check: all slices done but no validation file
|
|
484
|
+
const milestoneComplete = roadmap.slices.length > 0 && roadmap.slices.every(s => s.done);
|
|
485
|
+
if (milestoneComplete && !resolveMilestoneFile(basePath, milestoneId, "VALIDATION") && !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) {
|
|
486
|
+
issues.push({
|
|
487
|
+
severity: "info",
|
|
488
|
+
code: "all_slices_done_missing_milestone_validation",
|
|
489
|
+
scope: "milestone",
|
|
490
|
+
unitId: milestoneId,
|
|
491
|
+
message: `All slices are done but the milestone VALIDATION.md is missing \u2014 milestone is in validating-milestone phase`,
|
|
492
|
+
file: relMilestoneFile(basePath, milestoneId, "VALIDATION"),
|
|
493
|
+
fixable: false,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
// Milestone-level check: all slices done but no milestone summary
|
|
497
|
+
if (milestoneComplete && !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) {
|
|
498
|
+
issues.push({
|
|
499
|
+
severity: "warning",
|
|
500
|
+
code: "all_slices_done_missing_milestone_summary",
|
|
501
|
+
scope: "milestone",
|
|
502
|
+
unitId: milestoneId,
|
|
503
|
+
message: `All slices are done but ${milestoneId}-SUMMARY.md is missing \u2014 milestone is stuck in completing-milestone phase`,
|
|
504
|
+
file: relMilestoneFile(basePath, milestoneId, "SUMMARY"),
|
|
505
|
+
fixable: false,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|