gsd-pi 0.2.9 → 0.3.0

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 (29) hide show
  1. package/README.md +23 -0
  2. package/dist/cli.js +47 -5
  3. package/dist/wizard.js +2 -1
  4. package/package.json +1 -1
  5. package/src/resources/extensions/gsd/commands.ts +9 -3
  6. package/src/resources/extensions/gsd/dashboard-overlay.ts +6 -1
  7. package/src/resources/extensions/gsd/files.ts +7 -7
  8. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  9. package/src/resources/extensions/gsd/index.ts +36 -1
  10. package/src/resources/extensions/gsd/migrate/command.ts +215 -0
  11. package/src/resources/extensions/gsd/migrate/index.ts +42 -0
  12. package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
  13. package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
  14. package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
  15. package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
  16. package/src/resources/extensions/gsd/migrate/types.ts +370 -0
  17. package/src/resources/extensions/gsd/migrate/validator.ts +53 -0
  18. package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
  19. package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
  20. package/src/resources/extensions/gsd/prompts/worktree-merge.md +89 -0
  21. package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
  22. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
  23. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
  24. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
  25. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
  26. package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
  27. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
  28. package/src/resources/extensions/gsd/worktree-command.ts +527 -0
  29. package/src/resources/extensions/gsd/worktree-manager.ts +302 -0
@@ -0,0 +1,346 @@
1
+ // Migration transformer — converts parsed PlanningProject into GSDProject.
2
+ // Pure function: no I/O, no side effects, no imports outside migrate/.
3
+
4
+ import type {
5
+ PlanningProject,
6
+ PlanningPhase,
7
+ PlanningPlan,
8
+ PlanningSummary,
9
+ PlanningRoadmapEntry,
10
+ PlanningRoadmapMilestone,
11
+ PlanningResearch,
12
+ PlanningRequirement,
13
+ GSDProject,
14
+ GSDMilestone,
15
+ GSDSlice,
16
+ GSDTask,
17
+ GSDRequirement,
18
+ GSDSliceSummaryData,
19
+ GSDTaskSummaryData,
20
+ GSDBoundaryEntry,
21
+ } from './types.ts';
22
+
23
+ // ─── Helpers ───────────────────────────────────────────────────────────────
24
+
25
+ function padId(prefix: string, n: number, width = 2): string {
26
+ return `${prefix}${String(n).padStart(width, '0')}`;
27
+ }
28
+
29
+ function milestoneId(n: number): string {
30
+ return padId('M', n, 3);
31
+ }
32
+
33
+ function kebabToTitle(slug: string): string {
34
+ return slug
35
+ .split('-')
36
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
37
+ .join(' ');
38
+ }
39
+
40
+ function firstSentence(text: string): string {
41
+ const trimmed = text.trim();
42
+ const match = trimmed.match(/^[^.!?]*[.!?]/);
43
+ return match ? match[0].trim() : trimmed;
44
+ }
45
+
46
+ /** Preferred research ordering for consolidation. */
47
+ const RESEARCH_ORDER = ['SUMMARY.md', 'ARCHITECTURE.md', 'STACK.md', 'FEATURES.md', 'PITFALLS.md'];
48
+
49
+ function sortResearch(files: PlanningResearch[]): PlanningResearch[] {
50
+ return [...files].sort((a, b) => {
51
+ const ai = RESEARCH_ORDER.indexOf(a.fileName);
52
+ const bi = RESEARCH_ORDER.indexOf(b.fileName);
53
+ const aw = ai === -1 ? RESEARCH_ORDER.length : ai;
54
+ const bw = bi === -1 ? RESEARCH_ORDER.length : bi;
55
+ if (aw !== bw) return aw - bw;
56
+ return a.fileName.localeCompare(b.fileName);
57
+ });
58
+ }
59
+
60
+ function consolidateResearch(files: PlanningResearch[]): string | null {
61
+ if (files.length === 0) return null;
62
+ return sortResearch(files)
63
+ .map((f) => f.content.trim())
64
+ .join('\n\n');
65
+ }
66
+
67
+ // ─── Task Mapping ──────────────────────────────────────────────────────────
68
+
69
+ function buildTaskSummary(summary: PlanningSummary): GSDTaskSummaryData {
70
+ return {
71
+ completedAt: summary.frontmatter.completed ?? '',
72
+ provides: summary.frontmatter.provides ?? [],
73
+ keyFiles: summary.frontmatter['key-files'] ?? [],
74
+ duration: summary.frontmatter.duration ?? '',
75
+ whatHappened: summary.body?.trim() ?? '',
76
+ };
77
+ }
78
+
79
+ function mapTask(plan: PlanningPlan, index: number, summaries: Record<string, PlanningSummary>): GSDTask {
80
+ const summary = summaries[plan.planNumber];
81
+ const done = summary !== undefined;
82
+ return {
83
+ id: padId('T', index + 1),
84
+ title: buildTaskTitle(plan),
85
+ description: plan.objective ?? '',
86
+ done,
87
+ estimate: done ? (summary.frontmatter.duration ?? '') : '',
88
+ files: plan.frontmatter.files_modified ?? [],
89
+ mustHaves: plan.frontmatter.must_haves?.truths ?? [],
90
+ summary: done ? buildTaskSummary(summary) : null,
91
+ };
92
+ }
93
+
94
+ function buildTaskTitle(plan: PlanningPlan): string {
95
+ const fm = plan.frontmatter;
96
+ if (fm.phase && fm.plan) {
97
+ return `${fm.phase} ${fm.plan}`;
98
+ }
99
+ return `Plan ${plan.planNumber}`;
100
+ }
101
+
102
+ // ─── Slice Mapping ─────────────────────────────────────────────────────────
103
+
104
+ function buildSliceSummary(phase: PlanningPhase): GSDSliceSummaryData | null {
105
+ // Aggregate from all summaries in the phase
106
+ const summaryEntries = Object.values(phase.summaries);
107
+ if (summaryEntries.length === 0) return null;
108
+
109
+ const provides: string[] = [];
110
+ const keyFiles: string[] = [];
111
+ const keyDecisions: string[] = [];
112
+ const patternsEstablished: string[] = [];
113
+ let lastCompleted = '';
114
+ let totalDuration = '';
115
+ const bodies: string[] = [];
116
+
117
+ for (const s of summaryEntries) {
118
+ provides.push(...(s.frontmatter.provides ?? []));
119
+ keyFiles.push(...(s.frontmatter['key-files'] ?? []));
120
+ keyDecisions.push(...(s.frontmatter['key-decisions'] ?? []));
121
+ patternsEstablished.push(...(s.frontmatter['patterns-established'] ?? []));
122
+ if (s.frontmatter.completed) lastCompleted = s.frontmatter.completed;
123
+ if (s.frontmatter.duration) totalDuration = s.frontmatter.duration;
124
+ if (s.body?.trim()) bodies.push(s.body.trim());
125
+ }
126
+
127
+ return {
128
+ completedAt: lastCompleted,
129
+ provides,
130
+ keyFiles,
131
+ keyDecisions,
132
+ patternsEstablished,
133
+ duration: totalDuration,
134
+ whatHappened: bodies.join('\n\n'),
135
+ };
136
+ }
137
+
138
+ function deriveDemo(phase: PlanningPhase, slug: string): string {
139
+ // First plan's objective, first sentence
140
+ const planNumbers = Object.keys(phase.plans).sort((a, b) => Number(a) - Number(b));
141
+ if (planNumbers.length > 0) {
142
+ const firstPlan = phase.plans[planNumbers[0]];
143
+ if (firstPlan?.objective) {
144
+ return firstSentence(firstPlan.objective);
145
+ }
146
+ }
147
+ return `unit tests prove ${slug} works`;
148
+ }
149
+
150
+ function mapSlice(
151
+ phase: PlanningPhase | undefined,
152
+ entry: PlanningRoadmapEntry,
153
+ index: number,
154
+ prevSliceId: string | null,
155
+ ): GSDSlice {
156
+ const sliceId = padId('S', index + 1);
157
+ const slug = phase?.slug ?? entry.title;
158
+ const demo = phase ? deriveDemo(phase, slug) : `unit tests prove ${entry.title} works`;
159
+
160
+ let tasks: GSDTask[] = [];
161
+ if (phase) {
162
+ const planNumbers = Object.keys(phase.plans).sort((a, b) => Number(a) - Number(b));
163
+ tasks = planNumbers.map((pn, i) => mapTask(phase.plans[pn], i, phase.summaries));
164
+ }
165
+
166
+ const done = entry.done;
167
+ const sliceSummary = done && phase ? buildSliceSummary(phase) : null;
168
+
169
+ return {
170
+ id: sliceId,
171
+ title: kebabToTitle(slug),
172
+ risk: 'medium',
173
+ depends: prevSliceId ? [prevSliceId] : [],
174
+ done,
175
+ demo,
176
+ goal: demo,
177
+ tasks,
178
+ research: phase ? consolidateResearch(phase.research) : null,
179
+ summary: sliceSummary,
180
+ };
181
+ }
182
+
183
+ // ─── Milestone Building ───────────────────────────────────────────────────
184
+
185
+ function findPhase(phases: Record<string, PlanningPhase>, phaseNumber: number, entryTitle?: string): PlanningPhase | undefined {
186
+ const matches = Object.values(phases).filter((p) => p.number === phaseNumber);
187
+ if (matches.length <= 1) return matches[0];
188
+ // Multiple phases with the same number — try to match by title/slug similarity
189
+ if (entryTitle) {
190
+ const normalizedTitle = entryTitle.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
191
+ const best = matches.find((p) => {
192
+ const normalizedSlug = p.slug.replace(/-/g, ' ').toLowerCase();
193
+ return normalizedSlug === normalizedTitle || normalizedTitle.includes(normalizedSlug) || normalizedSlug.includes(normalizedTitle);
194
+ });
195
+ if (best) return best;
196
+ }
197
+ return matches[0];
198
+ }
199
+
200
+ function buildMilestoneFromEntries(
201
+ id: string,
202
+ title: string,
203
+ entries: PlanningRoadmapEntry[],
204
+ phases: Record<string, PlanningPhase>,
205
+ research: PlanningResearch[],
206
+ ): GSDMilestone {
207
+ // Sort entries by phase number (float sort)
208
+ const sorted = [...entries].sort((a, b) => a.number - b.number);
209
+
210
+ const slices: GSDSlice[] = [];
211
+ for (let i = 0; i < sorted.length; i++) {
212
+ const entry = sorted[i];
213
+ const phase = findPhase(phases, entry.number, entry.title);
214
+ const prevId = i > 0 ? slices[i - 1].id : null;
215
+ slices.push(mapSlice(phase, entry, i, prevId));
216
+ }
217
+
218
+ return {
219
+ id,
220
+ title,
221
+ vision: '',
222
+ successCriteria: [],
223
+ slices,
224
+ research: consolidateResearch(research),
225
+ boundaryMap: [],
226
+ };
227
+ }
228
+
229
+ // ─── Requirements Mapping ──────────────────────────────────────────────────
230
+
231
+ const VALID_STATUSES = new Set(['active', 'validated', 'deferred']);
232
+ const COMPLETE_ALIASES = new Set(['complete', 'completed', 'done', 'shipped']);
233
+
234
+ function normalizeStatus(status: string): 'active' | 'validated' | 'deferred' {
235
+ const lower = status.toLowerCase().trim();
236
+ if (VALID_STATUSES.has(lower)) return lower as 'active' | 'validated' | 'deferred';
237
+ if (COMPLETE_ALIASES.has(lower)) return 'validated';
238
+ return 'active';
239
+ }
240
+
241
+ function mapRequirements(reqs: PlanningRequirement[]): GSDRequirement[] {
242
+ let autoId = 0;
243
+ return reqs.map((req) => {
244
+ autoId++;
245
+ return {
246
+ id: req.id && req.id.trim() !== '' ? req.id : padId('R', autoId, 3),
247
+ title: req.title,
248
+ class: 'core-capability',
249
+ status: normalizeStatus(req.status),
250
+ description: req.description,
251
+ source: 'inferred',
252
+ primarySlice: 'none yet',
253
+ };
254
+ });
255
+ }
256
+
257
+ // ─── Project-Level Derivation ──────────────────────────────────────────────
258
+
259
+ function deriveVision(parsed: PlanningProject): string {
260
+ // Try first non-heading line from PROJECT.md
261
+ if (parsed.project) {
262
+ const lines = parsed.project.split('\n');
263
+ for (const line of lines) {
264
+ const trimmed = line.trim();
265
+ if (trimmed && !trimmed.startsWith('#')) {
266
+ return firstSentence(trimmed);
267
+ }
268
+ }
269
+ }
270
+ // Fallback: roadmap title
271
+ if (parsed.roadmap) {
272
+ if (parsed.roadmap.milestones.length > 0) {
273
+ return parsed.roadmap.milestones[0].title;
274
+ }
275
+ }
276
+ return 'Project migration from .planning format';
277
+ }
278
+
279
+ function deriveDecisions(parsed: PlanningProject): string {
280
+ // Extract key decisions from phase summaries if available
281
+ const decisions: string[] = [];
282
+ for (const phase of Object.values(parsed.phases)) {
283
+ for (const summary of Object.values(phase.summaries)) {
284
+ const kd = summary.frontmatter['key-decisions'] ?? [];
285
+ decisions.push(...kd);
286
+ }
287
+ }
288
+ if (decisions.length === 0) return '';
289
+ return decisions.map((d) => `- ${d}`).join('\n');
290
+ }
291
+
292
+ // ─── Main Entry Point ──────────────────────────────────────────────────────
293
+
294
+ export function transformToGSD(parsed: PlanningProject): GSDProject {
295
+ const milestones: GSDMilestone[] = [];
296
+
297
+ const roadmap = parsed.roadmap;
298
+ const isMultiMilestone = roadmap !== null && roadmap.milestones.length > 0;
299
+ const hasFlatPhases = roadmap !== null && roadmap.phases.length > 0;
300
+
301
+ if (isMultiMilestone) {
302
+ // Multi-milestone mode: each roadmap milestone section → one GSDMilestone
303
+ for (let mi = 0; mi < roadmap!.milestones.length; mi++) {
304
+ const rm = roadmap!.milestones[mi];
305
+ milestones.push(
306
+ buildMilestoneFromEntries(
307
+ milestoneId(mi + 1),
308
+ rm.title,
309
+ rm.phases,
310
+ parsed.phases,
311
+ mi === 0 ? parsed.research : [],
312
+ ),
313
+ );
314
+ }
315
+ } else if (hasFlatPhases) {
316
+ // Single-milestone mode from roadmap phases
317
+ milestones.push(
318
+ buildMilestoneFromEntries('M001', 'Migration', roadmap!.phases, parsed.phases, parsed.research),
319
+ );
320
+ } else {
321
+ // Null/empty roadmap fallback: use filesystem phases, all not-done
322
+ const fsPhases = Object.values(parsed.phases).sort((a, b) => a.number - b.number);
323
+ const entries: PlanningRoadmapEntry[] = fsPhases.map((p) => ({
324
+ number: p.number,
325
+ title: p.slug,
326
+ done: false,
327
+ raw: '',
328
+ }));
329
+ milestones.push(
330
+ buildMilestoneFromEntries('M001', 'Migration', entries, parsed.phases, parsed.research),
331
+ );
332
+ }
333
+
334
+ // Set vision on first milestone (or all if multi)
335
+ const vision = deriveVision(parsed);
336
+ for (const m of milestones) {
337
+ if (!m.vision) m.vision = vision;
338
+ }
339
+
340
+ return {
341
+ milestones,
342
+ projectContent: parsed.project ?? '',
343
+ requirements: mapRequirements(parsed.requirements),
344
+ decisionsContent: deriveDecisions(parsed),
345
+ };
346
+ }