gsd-pi 2.29.0-dev.2ccf3fb → 2.29.0-dev.4c155ee

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 (116) hide show
  1. package/dist/headless.js +4 -0
  2. package/dist/resources/extensions/gsd/auto-dashboard.ts +31 -0
  3. package/dist/resources/extensions/gsd/auto-dispatch.ts +32 -3
  4. package/dist/resources/extensions/gsd/auto-post-unit.ts +39 -10
  5. package/dist/resources/extensions/gsd/auto-prompts.ts +40 -17
  6. package/dist/resources/extensions/gsd/auto-recovery.ts +2 -1
  7. package/dist/resources/extensions/gsd/auto-start.ts +18 -32
  8. package/dist/resources/extensions/gsd/auto-worktree.ts +21 -182
  9. package/dist/resources/extensions/gsd/auto.ts +2 -9
  10. package/dist/resources/extensions/gsd/captures.ts +4 -10
  11. package/dist/resources/extensions/gsd/commands-handlers.ts +2 -1
  12. package/dist/resources/extensions/gsd/commands.ts +2 -1
  13. package/dist/resources/extensions/gsd/detection.ts +2 -1
  14. package/dist/resources/extensions/gsd/doctor-checks.ts +49 -1
  15. package/dist/resources/extensions/gsd/doctor-types.ts +3 -1
  16. package/dist/resources/extensions/gsd/forensics.ts +2 -2
  17. package/dist/resources/extensions/gsd/git-service.ts +3 -2
  18. package/dist/resources/extensions/gsd/gitignore.ts +9 -63
  19. package/dist/resources/extensions/gsd/gsd-db.ts +1 -165
  20. package/dist/resources/extensions/gsd/guided-flow.ts +8 -5
  21. package/dist/resources/extensions/gsd/index.ts +3 -3
  22. package/dist/resources/extensions/gsd/md-importer.ts +3 -2
  23. package/dist/resources/extensions/gsd/mechanical-completion.ts +430 -0
  24. package/dist/resources/extensions/gsd/migrate/command.ts +3 -2
  25. package/dist/resources/extensions/gsd/migrate/writer.ts +2 -1
  26. package/dist/resources/extensions/gsd/migrate-external.ts +123 -0
  27. package/dist/resources/extensions/gsd/paths.ts +24 -2
  28. package/dist/resources/extensions/gsd/post-unit-hooks.ts +6 -5
  29. package/dist/resources/extensions/gsd/preferences-models.ts +7 -1
  30. package/dist/resources/extensions/gsd/preferences-validation.ts +2 -1
  31. package/dist/resources/extensions/gsd/preferences.ts +10 -5
  32. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +4 -2
  33. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +26 -2
  34. package/dist/resources/extensions/gsd/prompts/plan-slice.md +15 -1
  35. package/dist/resources/extensions/gsd/repo-identity.ts +148 -0
  36. package/dist/resources/extensions/gsd/resource-version.ts +99 -0
  37. package/dist/resources/extensions/gsd/session-forensics.ts +4 -3
  38. package/dist/resources/extensions/gsd/tests/activity-log.test.ts +2 -2
  39. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +3 -3
  40. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +0 -58
  41. package/dist/resources/extensions/gsd/tests/doctor-runtime.test.ts +3 -4
  42. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +5 -18
  43. package/dist/resources/extensions/gsd/tests/git-service.test.ts +10 -37
  44. package/dist/resources/extensions/gsd/tests/knowledge.test.ts +4 -4
  45. package/dist/resources/extensions/gsd/tests/mechanical-completion.test.ts +356 -0
  46. package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +1 -0
  47. package/dist/resources/extensions/gsd/tests/token-profile.test.ts +14 -16
  48. package/dist/resources/extensions/gsd/triage-resolution.ts +2 -1
  49. package/dist/resources/extensions/gsd/types.ts +2 -0
  50. package/dist/resources/extensions/gsd/worktree-command.ts +1 -11
  51. package/dist/resources/extensions/gsd/worktree-manager.ts +3 -2
  52. package/dist/resources/extensions/gsd/worktree.ts +42 -5
  53. package/dist/resources/skills/react-best-practices/SKILL.md +1 -1
  54. package/package.json +1 -1
  55. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/core/lsp/client.js +3 -0
  57. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  58. package/packages/pi-coding-agent/src/core/lsp/client.ts +3 -0
  59. package/src/resources/extensions/gsd/auto-dashboard.ts +31 -0
  60. package/src/resources/extensions/gsd/auto-dispatch.ts +32 -3
  61. package/src/resources/extensions/gsd/auto-post-unit.ts +39 -10
  62. package/src/resources/extensions/gsd/auto-prompts.ts +40 -17
  63. package/src/resources/extensions/gsd/auto-recovery.ts +2 -1
  64. package/src/resources/extensions/gsd/auto-start.ts +18 -32
  65. package/src/resources/extensions/gsd/auto-worktree.ts +21 -182
  66. package/src/resources/extensions/gsd/auto.ts +2 -9
  67. package/src/resources/extensions/gsd/captures.ts +4 -10
  68. package/src/resources/extensions/gsd/commands-handlers.ts +2 -1
  69. package/src/resources/extensions/gsd/commands.ts +2 -1
  70. package/src/resources/extensions/gsd/detection.ts +2 -1
  71. package/src/resources/extensions/gsd/doctor-checks.ts +49 -1
  72. package/src/resources/extensions/gsd/doctor-types.ts +3 -1
  73. package/src/resources/extensions/gsd/forensics.ts +2 -2
  74. package/src/resources/extensions/gsd/git-service.ts +3 -2
  75. package/src/resources/extensions/gsd/gitignore.ts +9 -63
  76. package/src/resources/extensions/gsd/gsd-db.ts +1 -165
  77. package/src/resources/extensions/gsd/guided-flow.ts +8 -5
  78. package/src/resources/extensions/gsd/index.ts +3 -3
  79. package/src/resources/extensions/gsd/md-importer.ts +3 -2
  80. package/src/resources/extensions/gsd/mechanical-completion.ts +430 -0
  81. package/src/resources/extensions/gsd/migrate/command.ts +3 -2
  82. package/src/resources/extensions/gsd/migrate/writer.ts +2 -1
  83. package/src/resources/extensions/gsd/migrate-external.ts +123 -0
  84. package/src/resources/extensions/gsd/paths.ts +24 -2
  85. package/src/resources/extensions/gsd/post-unit-hooks.ts +6 -5
  86. package/src/resources/extensions/gsd/preferences-models.ts +7 -1
  87. package/src/resources/extensions/gsd/preferences-validation.ts +2 -1
  88. package/src/resources/extensions/gsd/preferences.ts +10 -5
  89. package/src/resources/extensions/gsd/prompts/discuss-headless.md +4 -2
  90. package/src/resources/extensions/gsd/prompts/plan-milestone.md +26 -2
  91. package/src/resources/extensions/gsd/prompts/plan-slice.md +15 -1
  92. package/src/resources/extensions/gsd/repo-identity.ts +148 -0
  93. package/src/resources/extensions/gsd/resource-version.ts +99 -0
  94. package/src/resources/extensions/gsd/session-forensics.ts +4 -3
  95. package/src/resources/extensions/gsd/tests/activity-log.test.ts +2 -2
  96. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +3 -3
  97. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +0 -58
  98. package/src/resources/extensions/gsd/tests/doctor-runtime.test.ts +3 -4
  99. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +5 -18
  100. package/src/resources/extensions/gsd/tests/git-service.test.ts +10 -37
  101. package/src/resources/extensions/gsd/tests/knowledge.test.ts +4 -4
  102. package/src/resources/extensions/gsd/tests/mechanical-completion.test.ts +356 -0
  103. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +1 -0
  104. package/src/resources/extensions/gsd/tests/token-profile.test.ts +14 -16
  105. package/src/resources/extensions/gsd/triage-resolution.ts +2 -1
  106. package/src/resources/extensions/gsd/types.ts +2 -0
  107. package/src/resources/extensions/gsd/worktree-command.ts +1 -11
  108. package/src/resources/extensions/gsd/worktree-manager.ts +3 -2
  109. package/src/resources/extensions/gsd/worktree.ts +42 -5
  110. package/src/resources/skills/react-best-practices/SKILL.md +1 -1
  111. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +0 -199
  112. package/dist/resources/extensions/gsd/tests/worktree-db-integration.test.ts +0 -205
  113. package/dist/resources/extensions/gsd/tests/worktree-db.test.ts +0 -442
  114. package/src/resources/extensions/gsd/auto-worktree-sync.ts +0 -199
  115. package/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +0 -205
  116. package/src/resources/extensions/gsd/tests/worktree-db.test.ts +0 -442
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Mechanical Completion — deterministic post-verification artifact generation.
3
+ *
4
+ * Pure functions that aggregate task-level outputs into slice/milestone summaries,
5
+ * UAT stubs, roadmap checkbox updates, and validation reports. Zero orchestration
6
+ * dependencies — operates on filesystem paths and parsed structures only.
7
+ *
8
+ * ADR-003: replaces LLM-driven complete-slice and validate-milestone units with
9
+ * mechanical aggregation when the data is sufficient.
10
+ */
11
+
12
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { atomicWriteSync } from "./atomic-write.js";
15
+ import { loadFile, parseSummary } from "./files.js";
16
+ import { extractMarkdownSection } from "./auto-prompts.js";
17
+ import {
18
+ resolveTaskFiles,
19
+ resolveTaskJsonFiles,
20
+ resolveTasksDir,
21
+ resolveSliceFile,
22
+ resolveSlicePath,
23
+ resolveMilestoneFile,
24
+ resolveMilestonePath,
25
+ resolveGsdRootFile,
26
+ } from "./paths.js";
27
+ import type { Summary, SummaryFrontmatter } from "./types.js";
28
+ import type { EvidenceJSON } from "./verification-evidence.js";
29
+
30
+ // ─── Slice Completion ────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Mechanically complete a slice by aggregating task summaries into:
34
+ * - S##-SUMMARY.md (aggregated frontmatter + task one-liners)
35
+ * - S##-UAT.md (extracted from plan Verification section)
36
+ * - Roadmap checkbox [x] update
37
+ *
38
+ * Returns true if completion succeeded, false if data is insufficient
39
+ * (serves as quality gate — caller falls back to LLM completion).
40
+ */
41
+ export async function mechanicalSliceCompletion(
42
+ base: string, mid: string, sid: string,
43
+ ): Promise<boolean> {
44
+ const tDir = resolveTasksDir(base, mid, sid);
45
+ if (!tDir) return false;
46
+
47
+ // Read all task summaries
48
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
49
+ if (summaryFiles.length === 0) return false;
50
+
51
+ const taskSummaries: Array<{ taskId: string; summary: Summary }> = [];
52
+ for (const file of summaryFiles) {
53
+ const content = readFileSync(join(tDir, file), "utf-8");
54
+ if (!content.trim()) continue;
55
+ const summary = parseSummary(content);
56
+ const taskId = file.match(/^(T\d+)/)?.[1] ?? file;
57
+ taskSummaries.push({ taskId, summary });
58
+ }
59
+
60
+ if (taskSummaries.length === 0) return false;
61
+
62
+ // Quality gate: multi-task slices need substantive summaries
63
+ if (taskSummaries.length > 1) {
64
+ const totalContent = taskSummaries
65
+ .map(ts => ts.summary.whatHappened || ts.summary.oneLiner || "")
66
+ .join("");
67
+ if (totalContent.length < 200) return false;
68
+ }
69
+
70
+ // Aggregate frontmatter
71
+ const aggregated = aggregateFrontmatter(taskSummaries.map(ts => ts.summary.frontmatter));
72
+
73
+ // Build SUMMARY.md
74
+ const summaryLines: string[] = [
75
+ "---",
76
+ `id: ${sid}`,
77
+ `parent: ${mid}`,
78
+ `milestone: ${mid}`,
79
+ ];
80
+ if (aggregated.provides.length > 0)
81
+ summaryLines.push(`provides:\n${aggregated.provides.map(p => ` - ${p}`).join("\n")}`);
82
+ if (aggregated.key_files.length > 0)
83
+ summaryLines.push(`key_files:\n${aggregated.key_files.map(f => ` - ${f}`).join("\n")}`);
84
+ if (aggregated.key_decisions.length > 0)
85
+ summaryLines.push(`key_decisions:\n${aggregated.key_decisions.map(d => ` - ${d}`).join("\n")}`);
86
+ if (aggregated.patterns_established.length > 0)
87
+ summaryLines.push(`patterns_established:\n${aggregated.patterns_established.map(p => ` - ${p}`).join("\n")}`);
88
+ if (aggregated.affects.length > 0)
89
+ summaryLines.push(`affects:\n${aggregated.affects.map(a => ` - ${a}`).join("\n")}`);
90
+ if (aggregated.observability_surfaces.length > 0)
91
+ summaryLines.push(`observability_surfaces:\n${aggregated.observability_surfaces.map(o => ` - ${o}`).join("\n")}`);
92
+ const allPassed = taskSummaries.every(ts => ts.summary.frontmatter.verification_result === "passed");
93
+ summaryLines.push(`verification_result: ${allPassed ? "passed" : "mixed"}`);
94
+ summaryLines.push(`completed_at: ${new Date().toISOString()}`);
95
+ summaryLines.push("---");
96
+ summaryLines.push("");
97
+ summaryLines.push(`# ${sid}: Slice Summary`);
98
+ summaryLines.push("");
99
+
100
+ // Task one-liners
101
+ for (const { taskId, summary } of taskSummaries) {
102
+ const line = summary.oneLiner || summary.title || taskId;
103
+ summaryLines.push(`- **${taskId}**: ${line}`);
104
+ }
105
+ summaryLines.push("");
106
+
107
+ const sDir = resolveSlicePath(base, mid, sid);
108
+ if (!sDir) return false;
109
+
110
+ const summaryPath = join(sDir, `${sid}-SUMMARY.md`);
111
+ atomicWriteSync(summaryPath, summaryLines.join("\n"));
112
+ process.stderr.write(`gsd-mechanical: wrote ${summaryPath}\n`);
113
+
114
+ // Build UAT.md from plan's Verification section
115
+ const planPath = resolveSliceFile(base, mid, sid, "PLAN");
116
+ if (planPath) {
117
+ const planContent = readFileSync(planPath, "utf-8");
118
+ const verification = extractMarkdownSection(planContent, "Verification");
119
+ if (verification) {
120
+ const uatContent = [
121
+ "---",
122
+ `id: ${sid}`,
123
+ `parent: ${mid}`,
124
+ "type: artifact-driven",
125
+ "---",
126
+ "",
127
+ `# ${sid}: UAT`,
128
+ "",
129
+ verification,
130
+ "",
131
+ ].join("\n");
132
+ const uatPath = join(sDir, `${sid}-UAT.md`);
133
+ atomicWriteSync(uatPath, uatContent);
134
+ process.stderr.write(`gsd-mechanical: wrote ${uatPath}\n`);
135
+ }
136
+ }
137
+
138
+ // Mark slice [x] in ROADMAP
139
+ await markSliceInRoadmap(base, mid, sid);
140
+
141
+ // Append new decisions if any
142
+ await appendNewDecisions(base, taskSummaries.map(ts => ts.summary));
143
+
144
+ // Update requirements if all passed
145
+ if (allPassed) {
146
+ await mechanicalRequirementsUpdate(base, mid, sid, taskSummaries.map(ts => ts.summary));
147
+ }
148
+
149
+ return true;
150
+ }
151
+
152
+ // ─── Requirements Update ─────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Conservative requirements update: mark requirements Validated only if
156
+ * all tasks' verification passed.
157
+ */
158
+ export async function mechanicalRequirementsUpdate(
159
+ _base: string, _mid: string, _sid: string, _taskSummaries: Summary[],
160
+ ): Promise<void> {
161
+ // Conservative: requirements validation requires human or LLM judgment
162
+ // about whether the requirement is truly met. Mechanical completion only
163
+ // marks the slice done — requirement status updates are left to the
164
+ // existing validation pipeline.
165
+ }
166
+
167
+ // ─── Decision Aggregation ────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Collect key_decisions from task summaries, deduplicate against existing
171
+ * DECISIONS.md, and append new ones.
172
+ */
173
+ export async function appendNewDecisions(
174
+ base: string, taskSummaries: Summary[],
175
+ ): Promise<void> {
176
+ const allDecisions = taskSummaries.flatMap(s => s.frontmatter.key_decisions);
177
+ if (allDecisions.length === 0) return;
178
+
179
+ const decisionsPath = resolveGsdRootFile(base, "DECISIONS");
180
+ const existing = existsSync(decisionsPath)
181
+ ? readFileSync(decisionsPath, "utf-8")
182
+ : "";
183
+
184
+ // Deduplicate — skip decisions whose text already appears in the file
185
+ const newDecisions = allDecisions.filter(d =>
186
+ d.trim() && !existing.includes(d.trim()),
187
+ );
188
+ if (newDecisions.length === 0) return;
189
+
190
+ const entries = newDecisions
191
+ .map(d => `- ${d} _(auto-aggregated from task summaries)_`)
192
+ .join("\n");
193
+
194
+ const updated = existing.trimEnd() + "\n\n### Auto-aggregated Decisions\n\n" + entries + "\n";
195
+ atomicWriteSync(decisionsPath, updated);
196
+ process.stderr.write(`gsd-mechanical: appended ${newDecisions.length} decision(s) to DECISIONS.md\n`);
197
+ }
198
+
199
+ // ─── Milestone Verification ──────────────────────────────────────────────────
200
+
201
+ export interface MilestoneVerificationResult {
202
+ verdict: "passed" | "failed" | "mixed";
203
+ checks: EvidenceJSON[];
204
+ uatResults: string[];
205
+ markdown: string;
206
+ }
207
+
208
+ /**
209
+ * Aggregate T##-VERIFY.json files and S##-UAT-RESULT.md files across all
210
+ * slices in a milestone to produce VALIDATION.md.
211
+ */
212
+ export async function aggregateMilestoneVerification(
213
+ base: string, mid: string,
214
+ ): Promise<MilestoneVerificationResult> {
215
+ const mDir = resolveMilestonePath(base, mid);
216
+ if (!mDir) return { verdict: "failed", checks: [], uatResults: [], markdown: "" };
217
+
218
+ const allChecks: EvidenceJSON[] = [];
219
+ const allUatResults: string[] = [];
220
+
221
+ // Scan all slices
222
+ const slicesDir = join(mDir, "slices");
223
+ if (!existsSync(slicesDir)) return { verdict: "failed", checks: [], uatResults: [], markdown: "" };
224
+
225
+ const sliceDirs = readdirSyncSafe(slicesDir).filter(name => /^S\d+/i.test(name)).sort();
226
+
227
+ for (const sliceName of sliceDirs) {
228
+ const sid = sliceName.match(/^(S\d+)/i)?.[1] ?? sliceName;
229
+ const tDir = resolveTasksDir(base, mid, sid);
230
+ if (tDir) {
231
+ const verifyFiles = resolveTaskJsonFiles(tDir, "VERIFY");
232
+ for (const vf of verifyFiles) {
233
+ try {
234
+ const content = readFileSync(join(tDir, vf), "utf-8");
235
+ const evidence = JSON.parse(content) as EvidenceJSON;
236
+ allChecks.push(evidence);
237
+ } catch {
238
+ // Skip malformed JSON
239
+ }
240
+ }
241
+ }
242
+
243
+ // Check for UAT result
244
+ const uatResultPath = resolveSliceFile(base, mid, sid, "UAT-RESULT");
245
+ if (uatResultPath) {
246
+ try {
247
+ const uatContent = readFileSync(uatResultPath, "utf-8");
248
+ allUatResults.push(`### ${sid}\n\n${uatContent}`);
249
+ } catch {
250
+ // Non-fatal
251
+ }
252
+ }
253
+ }
254
+
255
+ // Determine verdict
256
+ const allPassed = allChecks.length > 0 && allChecks.every(c => c.passed);
257
+ const anyFailed = allChecks.some(c => !c.passed);
258
+ const verdict: "passed" | "failed" | "mixed" = allPassed
259
+ ? "passed"
260
+ : anyFailed
261
+ ? (allChecks.some(c => c.passed) ? "mixed" : "failed")
262
+ : "passed"; // No checks = vacuously passed
263
+
264
+ // Build VALIDATION.md
265
+ const mdLines: string[] = [
266
+ "---",
267
+ `milestone: ${mid}`,
268
+ `verdict: ${verdict}`,
269
+ "remediation_round: 0",
270
+ `validated_at: ${new Date().toISOString()}`,
271
+ "---",
272
+ "",
273
+ `# ${mid}: Milestone Validation`,
274
+ "",
275
+ `**Verdict:** ${verdict}`,
276
+ "",
277
+ "## Verification Results",
278
+ "",
279
+ ];
280
+
281
+ if (allChecks.length === 0) {
282
+ mdLines.push("_No verification evidence found._");
283
+ } else {
284
+ mdLines.push("| Task | Passed | Checks | Failed |");
285
+ mdLines.push("|------|--------|--------|--------|");
286
+ for (const check of allChecks) {
287
+ const failedCount = check.checks.filter(c => c.verdict === "fail").length;
288
+ mdLines.push(
289
+ `| ${check.taskId} | ${check.passed ? "yes" : "no"} | ${check.checks.length} | ${failedCount} |`,
290
+ );
291
+ }
292
+ }
293
+
294
+ if (allUatResults.length > 0) {
295
+ mdLines.push("");
296
+ mdLines.push("## UAT Results");
297
+ mdLines.push("");
298
+ mdLines.push(...allUatResults);
299
+ }
300
+
301
+ mdLines.push("");
302
+
303
+ const markdown = mdLines.join("\n");
304
+
305
+ // Write VALIDATION.md
306
+ const validationPath = join(mDir, `${mid}-VALIDATION.md`);
307
+ atomicWriteSync(validationPath, markdown);
308
+ process.stderr.write(`gsd-mechanical: wrote ${validationPath}\n`);
309
+
310
+ return { verdict, checks: allChecks, uatResults: allUatResults, markdown };
311
+ }
312
+
313
+ // ─── Milestone Summary ──────────────────────────────────────────────────────
314
+
315
+ /**
316
+ * Read all S##-SUMMARY.md files and produce M##-SUMMARY.md.
317
+ */
318
+ export async function generateMilestoneSummary(
319
+ base: string, mid: string,
320
+ ): Promise<string> {
321
+ const mDir = resolveMilestonePath(base, mid);
322
+ if (!mDir) return "";
323
+
324
+ const slicesDir = join(mDir, "slices");
325
+ if (!existsSync(slicesDir)) return "";
326
+
327
+ const sliceDirs = readdirSyncSafe(slicesDir).filter(name => /^S\d+/i.test(name)).sort();
328
+
329
+ const aggregatedProvides: string[] = [];
330
+ const aggregatedKeyFiles: string[] = [];
331
+ const aggregatedKeyDecisions: string[] = [];
332
+ const aggregatedPatterns: string[] = [];
333
+ const sliceOneLinerList: string[] = [];
334
+
335
+ for (const sliceName of sliceDirs) {
336
+ const sid = sliceName.match(/^(S\d+)/i)?.[1] ?? sliceName;
337
+ const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY");
338
+ if (!summaryPath) continue;
339
+
340
+ try {
341
+ const content = readFileSync(summaryPath, "utf-8");
342
+ const summary = parseSummary(content);
343
+ aggregatedProvides.push(...summary.frontmatter.provides);
344
+ aggregatedKeyFiles.push(...summary.frontmatter.key_files);
345
+ aggregatedKeyDecisions.push(...summary.frontmatter.key_decisions);
346
+ aggregatedPatterns.push(...summary.frontmatter.patterns_established);
347
+ sliceOneLinerList.push(`- **${sid}**: ${summary.oneLiner || summary.title || sid}`);
348
+ } catch {
349
+ sliceOneLinerList.push(`- **${sid}**: _(summary unavailable)_`);
350
+ }
351
+ }
352
+
353
+ const mdLines: string[] = [
354
+ "---",
355
+ `id: ${mid}`,
356
+ ];
357
+ if (dedup(aggregatedProvides).length > 0)
358
+ mdLines.push(`provides:\n${dedup(aggregatedProvides).map(p => ` - ${p}`).join("\n")}`);
359
+ if (dedup(aggregatedKeyFiles).length > 0)
360
+ mdLines.push(`key_files:\n${dedup(aggregatedKeyFiles).map(f => ` - ${f}`).join("\n")}`);
361
+ if (dedup(aggregatedKeyDecisions).length > 0)
362
+ mdLines.push(`key_decisions:\n${dedup(aggregatedKeyDecisions).map(d => ` - ${d}`).join("\n")}`);
363
+ if (dedup(aggregatedPatterns).length > 0)
364
+ mdLines.push(`patterns_established:\n${dedup(aggregatedPatterns).map(p => ` - ${p}`).join("\n")}`);
365
+ mdLines.push(`completed_at: ${new Date().toISOString()}`);
366
+ mdLines.push("---");
367
+ mdLines.push("");
368
+ mdLines.push(`# ${mid}: Milestone Summary`);
369
+ mdLines.push("");
370
+ mdLines.push("## Slices");
371
+ mdLines.push("");
372
+ mdLines.push(...sliceOneLinerList);
373
+ mdLines.push("");
374
+
375
+ const content = mdLines.join("\n");
376
+
377
+ // Write M##-SUMMARY.md
378
+ const summaryPath = join(mDir, `${mid}-SUMMARY.md`);
379
+ atomicWriteSync(summaryPath, content);
380
+ process.stderr.write(`gsd-mechanical: wrote ${summaryPath}\n`);
381
+
382
+ return content;
383
+ }
384
+
385
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
386
+
387
+ function aggregateFrontmatter(fms: SummaryFrontmatter[]): {
388
+ provides: string[];
389
+ key_files: string[];
390
+ key_decisions: string[];
391
+ patterns_established: string[];
392
+ affects: string[];
393
+ observability_surfaces: string[];
394
+ } {
395
+ return {
396
+ provides: dedup(fms.flatMap(f => f.provides)),
397
+ key_files: dedup(fms.flatMap(f => f.key_files)),
398
+ key_decisions: dedup(fms.flatMap(f => f.key_decisions)),
399
+ patterns_established: dedup(fms.flatMap(f => f.patterns_established)),
400
+ affects: dedup(fms.flatMap(f => f.affects)),
401
+ observability_surfaces: dedup(fms.flatMap(f => f.observability_surfaces)),
402
+ };
403
+ }
404
+
405
+ function dedup(arr: string[]): string[] {
406
+ return [...new Set(arr.filter(s => s.trim()))];
407
+ }
408
+
409
+ async function markSliceInRoadmap(base: string, mid: string, sid: string): Promise<void> {
410
+ const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
411
+ if (!roadmapPath) return;
412
+ const content = await loadFile(roadmapPath);
413
+ if (!content) return;
414
+ const updated = content.replace(
415
+ new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sid}:`, "m"),
416
+ `$1[x] **${sid}:`,
417
+ );
418
+ if (updated !== content) {
419
+ atomicWriteSync(roadmapPath, updated);
420
+ process.stderr.write(`gsd-mechanical: marked ${sid} done in ROADMAP\n`);
421
+ }
422
+ }
423
+
424
+ function readdirSyncSafe(dir: string): string[] {
425
+ try {
426
+ return readdirSync(dir);
427
+ } catch {
428
+ return [];
429
+ }
430
+ }
@@ -12,6 +12,7 @@
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
13
13
  import { existsSync, readFileSync } from "node:fs";
14
14
  import { resolve, join, dirname } from "node:path";
15
+ import { gsdRoot } from "../paths.js";
15
16
  import { fileURLToPath } from "node:url";
16
17
  import { showNextAction } from "../../shared/mod.js";
17
18
  import {
@@ -144,7 +145,7 @@ export async function handleMigrate(
144
145
  );
145
146
  }
146
147
 
147
- const targetGsdExists = existsSync(join(process.cwd(), ".gsd"));
148
+ const targetGsdExists = existsSync(gsdRoot(process.cwd()));
148
149
  if (targetGsdExists) {
149
150
  lines.push("");
150
151
  lines.push("⚠ A .gsd directory already exists in the current working directory — it will be overwritten.");
@@ -179,7 +180,7 @@ export async function handleMigrate(
179
180
  ctx.ui.notify("Writing .gsd directory…", "info");
180
181
 
181
182
  const result = await writeGSDDirectory(project, process.cwd());
182
- const gsdPath = join(process.cwd(), ".gsd");
183
+ const gsdPath = gsdRoot(process.cwd());
183
184
 
184
185
  ctx.ui.notify(
185
186
  `✓ Migration complete — ${result.paths.length} file(s) written to .gsd/`,
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { join } from 'node:path';
7
7
  import { saveFile } from '../files.js';
8
+ import { gsdRoot } from '../paths.js';
8
9
 
9
10
  import type {
10
11
  GSDMilestone,
@@ -421,7 +422,7 @@ export async function writeGSDDirectory(
421
422
  project: GSDProject,
422
423
  targetPath: string,
423
424
  ): Promise<WrittenFiles> {
424
- const gsdDir = join(targetPath, '.gsd');
425
+ const gsdDir = gsdRoot(targetPath);
425
426
  const milestonesBase = join(gsdDir, 'milestones');
426
427
  const paths: string[] = [];
427
428
  const counts: WrittenFiles['counts'] = {
@@ -0,0 +1,123 @@
1
+ /**
2
+ * GSD External State Migration
3
+ *
4
+ * Migrates legacy in-project `.gsd/` directories to the external
5
+ * `~/.gsd/projects/<hash>/` state directory. After migration, a
6
+ * symlink replaces the original directory so all paths remain valid.
7
+ */
8
+
9
+ import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { externalGsdRoot } from "./repo-identity.js";
12
+
13
+ export interface MigrationResult {
14
+ migrated: boolean;
15
+ error?: string;
16
+ }
17
+
18
+ /**
19
+ * Migrate a legacy in-project `.gsd/` directory to external storage.
20
+ *
21
+ * Algorithm:
22
+ * 1. If `<project>/.gsd` is a symlink or doesn't exist -> skip
23
+ * 2. If `<project>/.gsd` is a real directory:
24
+ * a. Compute external path from repoIdentity
25
+ * b. mkdir -p external dir
26
+ * c. Rename `.gsd` -> `.gsd.migrating` (atomic on same FS, acts as lock)
27
+ * d. Copy contents to external dir (skip `worktrees/` subdirectory)
28
+ * e. Create symlink `.gsd -> external path`
29
+ * f. Remove `.gsd.migrating`
30
+ * 3. On failure: rename `.gsd.migrating` back to `.gsd` (rollback)
31
+ */
32
+ export function migrateToExternalState(basePath: string): MigrationResult {
33
+ const localGsd = join(basePath, ".gsd");
34
+
35
+ // Skip if doesn't exist
36
+ if (!existsSync(localGsd)) {
37
+ return { migrated: false };
38
+ }
39
+
40
+ // Skip if already a symlink
41
+ try {
42
+ const stat = lstatSync(localGsd);
43
+ if (stat.isSymbolicLink()) {
44
+ return { migrated: false };
45
+ }
46
+ if (!stat.isDirectory()) {
47
+ return { migrated: false, error: ".gsd exists but is not a directory or symlink" };
48
+ }
49
+ } catch (err) {
50
+ return { migrated: false, error: `Cannot stat .gsd: ${err instanceof Error ? err.message : String(err)}` };
51
+ }
52
+
53
+ const externalPath = externalGsdRoot(basePath);
54
+ const migratingPath = join(basePath, ".gsd.migrating");
55
+
56
+ try {
57
+ // mkdir -p the external dir
58
+ mkdirSync(externalPath, { recursive: true });
59
+
60
+ // Rename .gsd -> .gsd.migrating (atomic lock)
61
+ renameSync(localGsd, migratingPath);
62
+
63
+ // Copy contents to external dir, skipping worktrees/
64
+ const entries = readdirSync(migratingPath, { withFileTypes: true });
65
+ for (const entry of entries) {
66
+ if (entry.name === "worktrees") continue; // worktrees stay local
67
+
68
+ const src = join(migratingPath, entry.name);
69
+ const dst = join(externalPath, entry.name);
70
+
71
+ try {
72
+ if (entry.isDirectory()) {
73
+ cpSync(src, dst, { recursive: true, force: true });
74
+ } else {
75
+ cpSync(src, dst, { force: true });
76
+ }
77
+ } catch {
78
+ // Non-fatal: continue with other files
79
+ }
80
+ }
81
+
82
+ // Create symlink .gsd -> external path
83
+ symlinkSync(externalPath, localGsd, "junction");
84
+
85
+ // Remove .gsd.migrating
86
+ rmSync(migratingPath, { recursive: true, force: true });
87
+
88
+ return { migrated: true };
89
+ } catch (err) {
90
+ // Rollback: rename .gsd.migrating back to .gsd
91
+ try {
92
+ if (existsSync(migratingPath) && !existsSync(localGsd)) {
93
+ renameSync(migratingPath, localGsd);
94
+ }
95
+ } catch {
96
+ // Rollback failed -- leave .gsd.migrating for doctor to detect
97
+ }
98
+
99
+ return {
100
+ migrated: false,
101
+ error: `Migration failed: ${err instanceof Error ? err.message : String(err)}`,
102
+ };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Recover from a failed migration (`.gsd.migrating` exists).
108
+ * Moves `.gsd.migrating` back to `.gsd` if `.gsd` doesn't exist.
109
+ */
110
+ export function recoverFailedMigration(basePath: string): boolean {
111
+ const localGsd = join(basePath, ".gsd");
112
+ const migratingPath = join(basePath, ".gsd.migrating");
113
+
114
+ if (!existsSync(migratingPath)) return false;
115
+ if (existsSync(localGsd)) return false; // both exist -- ambiguous, don't touch
116
+
117
+ try {
118
+ renameSync(migratingPath, localGsd);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
@@ -9,7 +9,7 @@
9
9
  * via prefix matching, so existing projects work without migration.
10
10
  */
11
11
 
12
- import { readdirSync, existsSync, Dirent } from "node:fs";
12
+ import { readdirSync, existsSync, realpathSync, Dirent } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { nativeScanGsdTree, type GsdTreeEntry } from "./native-parser-bridge.js";
15
15
  import { DIR_CACHE_MAX } from "./constants.js";
@@ -236,6 +236,23 @@ export function resolveTaskFiles(tasksDir: string, suffix: string): string[] {
236
236
  }
237
237
  }
238
238
 
239
+ /**
240
+ * Find all task JSON files matching a pattern in a tasks directory.
241
+ * Returns sorted file names matching T##-SUFFIX.json or legacy T##-*-SUFFIX.json
242
+ */
243
+ export function resolveTaskJsonFiles(tasksDir: string, suffix: string): string[] {
244
+ if (!existsSync(tasksDir)) return [];
245
+ try {
246
+ const currentPattern = new RegExp(`^T\\d+-${suffix}\\.json$`, "i");
247
+ const legacyPattern = new RegExp(`^T\\d+-.*-${suffix}\\.json$`, "i");
248
+ return cachedReaddir(tasksDir)
249
+ .filter(f => currentPattern.test(f) || legacyPattern.test(f))
250
+ .sort();
251
+ } catch {
252
+ return [];
253
+ }
254
+ }
255
+
239
256
  // ─── Full Path Builders ────────────────────────────────────────────────────
240
257
 
241
258
  export const GSD_ROOT_FILES = {
@@ -261,7 +278,12 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
261
278
  };
262
279
 
263
280
  export function gsdRoot(basePath: string): string {
264
- return join(basePath, ".gsd");
281
+ const local = join(basePath, ".gsd");
282
+ try {
283
+ const resolved = realpathSync(local);
284
+ if (resolved !== local) return resolved; // symlink resolved
285
+ } catch { /* doesn't exist yet — fall through */ }
286
+ return local; // backwards compat: unmigrated projects
265
287
  }
266
288
 
267
289
  export function milestonesDir(basePath: string): string {
@@ -14,6 +14,7 @@ import type {
14
14
  import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js";
15
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
16
  import { join } from "node:path";
17
+ import { gsdRoot } from "./paths.js";
17
18
 
18
19
  // ─── Hook Queue State ──────────────────────────────────────────────────────
19
20
 
@@ -210,13 +211,13 @@ export function resolveHookArtifactPath(basePath: string, unitId: string, artifa
210
211
  const parts = unitId.split("/");
211
212
  if (parts.length === 3) {
212
213
  const [mid, sid, tid] = parts;
213
- return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
214
+ return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
214
215
  }
215
216
  if (parts.length === 2) {
216
217
  const [mid, sid] = parts;
217
- return join(basePath, ".gsd", mid, "slices", sid, artifactName);
218
+ return join(gsdRoot(basePath), mid, "slices", sid, artifactName);
218
219
  }
219
- return join(basePath, ".gsd", parts[0], artifactName);
220
+ return join(gsdRoot(basePath), parts[0], artifactName);
220
221
  }
221
222
 
222
223
  // ═══════════════════════════════════════════════════════════════════════════
@@ -310,7 +311,7 @@ export function runPreDispatchHooks(
310
311
  const HOOK_STATE_FILE = "hook-state.json";
311
312
 
312
313
  function hookStatePath(basePath: string): string {
313
- return join(basePath, ".gsd", HOOK_STATE_FILE);
314
+ return join(gsdRoot(basePath), HOOK_STATE_FILE);
314
315
  }
315
316
 
316
317
  /**
@@ -323,7 +324,7 @@ export function persistHookState(basePath: string): void {
323
324
  savedAt: new Date().toISOString(),
324
325
  };
325
326
  try {
326
- const dir = join(basePath, ".gsd");
327
+ const dir = gsdRoot(basePath);
327
328
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
328
329
  writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8");
329
330
  } catch {