@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.
Files changed (89) hide show
  1. package/README.md +8 -6
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/doctor-history.js +65 -0
  4. package/dist/resources/extensions/gsd/doctor-state-checks.js +509 -0
  5. package/dist/resources/extensions/gsd/doctor-workspace-checks.js +91 -0
  6. package/dist/resources/extensions/gsd/doctor.js +14 -653
  7. package/dist/resources/extensions/gsd/md-importer.js +7 -2
  8. package/dist/resources/extensions/gsd/migrate/planning-writer.js +8 -2
  9. package/dist/resources/extensions/gsd/migration-auto-check.js +4 -0
  10. package/dist/resources/extensions/gsd/state-reconciliation/drift/project-md.js +54 -14
  11. package/dist/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.js +48 -5
  12. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  13. package/dist/web/standalone/.next/BUILD_ID +1 -1
  14. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  15. package/dist/web/standalone/.next/build-manifest.json +2 -2
  16. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.html +1 -1
  33. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  40. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  41. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  42. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  43. package/dist/web/standalone/node_modules/@gsd/native/package.json +1 -1
  44. package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
  45. package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
  46. package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
  47. package/integrations/hermes/open_gsd_hermes/gsd_client.py +1 -1
  48. package/integrations/hermes/pyproject.toml +1 -1
  49. package/package.json +6 -6
  50. package/packages/cloud-mcp-gateway/package.json +2 -2
  51. package/packages/contracts/package.json +1 -1
  52. package/packages/daemon/dist/cloud-config.d.ts.map +1 -1
  53. package/packages/daemon/dist/cloud-config.js +25 -7
  54. package/packages/daemon/dist/cloud-config.js.map +1 -1
  55. package/packages/daemon/dist/cloud-config.test.js +19 -1
  56. package/packages/daemon/dist/cloud-config.test.js.map +1 -1
  57. package/packages/daemon/package.json +4 -4
  58. package/packages/gsd-agent-core/package.json +5 -5
  59. package/packages/gsd-agent-modes/package.json +7 -7
  60. package/packages/gsd-cloud/README.md +18 -4
  61. package/packages/gsd-cloud/bin/gsd-cloud.js +6 -5
  62. package/packages/gsd-cloud/package.json +6 -4
  63. package/packages/mcp-server/package.json +4 -4
  64. package/packages/native/package.json +1 -1
  65. package/packages/pi-agent-core/package.json +1 -1
  66. package/packages/pi-ai/package.json +1 -1
  67. package/packages/pi-coding-agent/package.json +7 -7
  68. package/packages/pi-tui/package.json +2 -2
  69. package/packages/rpc-client/package.json +2 -2
  70. package/pkg/package.json +1 -1
  71. package/src/resources/extensions/gsd/doctor-history.ts +81 -0
  72. package/src/resources/extensions/gsd/doctor-state-checks.ts +527 -0
  73. package/src/resources/extensions/gsd/doctor-workspace-checks.ts +97 -0
  74. package/src/resources/extensions/gsd/doctor.ts +14 -673
  75. package/src/resources/extensions/gsd/md-importer.ts +7 -2
  76. package/src/resources/extensions/gsd/migrate/planning-writer.ts +9 -3
  77. package/src/resources/extensions/gsd/migration-auto-check.ts +3 -0
  78. package/src/resources/extensions/gsd/state-reconciliation/drift/project-md.ts +67 -20
  79. package/src/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.ts +47 -5
  80. package/src/resources/extensions/gsd/tests/db-authority-regression.test.ts +76 -1
  81. package/src/resources/extensions/gsd/tests/doctor-history-public-api.test.ts +25 -0
  82. package/src/resources/extensions/gsd/tests/migrate-hierarchy.test.ts +53 -0
  83. package/src/resources/extensions/gsd/tests/migration-auto-check.test.ts +53 -0
  84. package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +4 -0
  85. package/src/resources/extensions/gsd/tests/planning-writer.test.ts +18 -1
  86. package/src/resources/extensions/gsd/tests/progressive-planning.test.ts +18 -3
  87. package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +177 -25
  88. /package/dist/web/standalone/.next/static/{enWQFRY-aLTUq3wmxG2Qz → UaXRrOYxBpG9QBINKhZkg}/_buildManifest.js +0 -0
  89. /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.7.0**
32
-
33
- - **cloud:** Add device-flow login to daemon + @opengsd/gsd-cloud agent (#1278).
34
- - **issue:** [Bug]: Unattended `gsd headless auto` has no resumer for the parallel/slice_parallel worker pause signal → terminal exit 10 after the first unit.
35
- - **issue:** Complete-slice's reopen reason never reaches the re-dispatched execute-task (follow-up to #1225).
36
- - **issue:** [Feature Request]: gsd-browser daemon has no automatic teardown orphaned Chrome process survives session/task completion.
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
- 1995c618cd0898a5
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
+ }