context-planning 0.7.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.
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/bin/commands/_helpers.js +53 -0
- package/bin/commands/_usage.js +67 -0
- package/bin/commands/capture.js +46 -0
- package/bin/commands/codebase-status.js +41 -0
- package/bin/commands/complete-milestone.js +57 -0
- package/bin/commands/config.js +70 -0
- package/bin/commands/doctor.js +139 -0
- package/bin/commands/gsd-import.js +90 -0
- package/bin/commands/inbox.js +81 -0
- package/bin/commands/index.js +33 -0
- package/bin/commands/init.js +87 -0
- package/bin/commands/install.js +43 -0
- package/bin/commands/scaffold-codebase.js +53 -0
- package/bin/commands/scaffold-milestone.js +58 -0
- package/bin/commands/scaffold-phase.js +65 -0
- package/bin/commands/status.js +42 -0
- package/bin/commands/statusline.js +108 -0
- package/bin/commands/tick.js +49 -0
- package/bin/commands/version.js +9 -0
- package/bin/commands/worktree.js +218 -0
- package/bin/commands/write-summary.js +54 -0
- package/bin/cp.cmd +2 -0
- package/bin/cp.js +54 -0
- package/commands/cp/capture.md +107 -0
- package/commands/cp/complete-milestone.md +166 -0
- package/commands/cp/execute-phase.md +220 -0
- package/commands/cp/map-codebase.md +211 -0
- package/commands/cp/new-milestone.md +136 -0
- package/commands/cp/new-project.md +132 -0
- package/commands/cp/plan-phase.md +195 -0
- package/commands/cp/progress.md +147 -0
- package/commands/cp/quick.md +104 -0
- package/commands/cp/resume.md +125 -0
- package/commands/cp/write-summary.md +33 -0
- package/docs/MIGRATION-v0.5.md +140 -0
- package/docs/architecture.md +189 -0
- package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-01-design-md-infrastructure.md +1064 -0
- package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-02-review-log-infrastructure.md +418 -0
- package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-03-key-decisions-hard-block.md +295 -0
- package/docs/superpowers/specs/2026-05-20-generic-provider-harness-detection-design.md +380 -0
- package/docs/superpowers/specs/2026-05-20-v0-7-design-capture-design.md +400 -0
- package/docs/writing-providers.md +76 -0
- package/install/aider.js +204 -0
- package/install/claude.js +116 -0
- package/install/common.js +65 -0
- package/install/copilot.js +86 -0
- package/install/cursor.js +120 -0
- package/install/echo-provider.js +50 -0
- package/lib/codebase-mapper.js +169 -0
- package/lib/detect.js +280 -0
- package/lib/frontmatter.js +72 -0
- package/lib/gsd-compat.js +165 -0
- package/lib/import.js +543 -0
- package/lib/inbox.js +226 -0
- package/lib/lifecycle.js +929 -0
- package/lib/merge.js +157 -0
- package/lib/milestone.js +595 -0
- package/lib/paths.js +191 -0
- package/lib/provider.js +168 -0
- package/lib/roadmap.js +134 -0
- package/lib/state.js +99 -0
- package/lib/worktree.js +253 -0
- package/package.json +45 -0
- package/templates/DESIGN.md +78 -0
- package/templates/INBOX.md +13 -0
- package/templates/MILESTONE-CONTEXT.md +40 -0
- package/templates/MILESTONES.md +29 -0
- package/templates/PLAN.md +84 -0
- package/templates/PROJECT.md +43 -0
- package/templates/REVIEW-LOG.md +38 -0
- package/templates/ROADMAP.md +34 -0
- package/templates/STATE.md +78 -0
- package/templates/SUMMARY.md +75 -0
- package/templates/codebase/ARCHITECTURE.md +30 -0
- package/templates/codebase/CONCERNS.md +30 -0
- package/templates/codebase/CONVENTIONS.md +30 -0
- package/templates/codebase/INTEGRATIONS.md +30 -0
- package/templates/codebase/STACK.md +26 -0
- package/templates/codebase/STRUCTURE.md +32 -0
- package/templates/codebase/TESTING.md +39 -0
- package/templates/config.json +173 -0
- package/templates/phase-PLAN.md +32 -0
- package/templates/quick-PLAN.md +24 -0
- package/templates/quick-SUMMARY.md +25 -0
package/lib/milestone.js
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Milestone close-out helpers.
|
|
5
|
+
*
|
|
6
|
+
* These are pure functions wherever possible — they take strings/objects in
|
|
7
|
+
* and return new strings/objects out. The /cp-complete-milestone command
|
|
8
|
+
* stitches them together. Keeping them pure makes them testable and lets
|
|
9
|
+
* the command preview every change before writing.
|
|
10
|
+
*
|
|
11
|
+
* Aggregation is union/dedupe over SUMMARY.md frontmatter fields that GSD
|
|
12
|
+
* uses for dependency-graph context selection — see
|
|
13
|
+
* `templates/SUMMARY.md`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const fm = require('./frontmatter');
|
|
20
|
+
const roadmap = require('./roadmap');
|
|
21
|
+
const paths = require('./paths');
|
|
22
|
+
|
|
23
|
+
// ---------- ROADMAP traversal ----------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Find the milestone section in ROADMAP.md and return its name + every
|
|
27
|
+
* phase block heading that belongs to it.
|
|
28
|
+
*
|
|
29
|
+
* Milestone sections in GSD-shape ROADMAP look like:
|
|
30
|
+
* ### 🚧 v1.1 Sharing (In Progress)
|
|
31
|
+
* or
|
|
32
|
+
* ### ✅ v1.0 MVP (Shipped 2026-04-01)
|
|
33
|
+
* and contain `### Phase N: ...` blocks underneath until the next milestone
|
|
34
|
+
* heading or end of the `## Phases` section.
|
|
35
|
+
*
|
|
36
|
+
* Returns { name, status: 'in-progress' | 'planned' | 'shipped', phases: ['3', '3.1', '4'] }
|
|
37
|
+
* or null if not found.
|
|
38
|
+
*/
|
|
39
|
+
function findMilestoneInRoadmap(content, milestoneName) {
|
|
40
|
+
// Locate the `## Phases` section
|
|
41
|
+
const phasesStart = content.search(/^##\s+Phases\s*$/m);
|
|
42
|
+
if (phasesStart === -1) return null;
|
|
43
|
+
const after = content.slice(phasesStart);
|
|
44
|
+
const nextH2 = after.slice(2).search(/^##\s+/m);
|
|
45
|
+
const phasesSection = nextH2 === -1 ? after : after.slice(0, nextH2 + 2);
|
|
46
|
+
|
|
47
|
+
// Find every `### ` heading inside the Phases section, classify each as
|
|
48
|
+
// a milestone heading or a phase heading.
|
|
49
|
+
const headings = [];
|
|
50
|
+
const re = /^###\s+(.*)$/gm;
|
|
51
|
+
let m;
|
|
52
|
+
while ((m = re.exec(phasesSection)) !== null) {
|
|
53
|
+
headings.push({ text: m[1].trim(), index: m.index });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPhaseHeading(t) {
|
|
57
|
+
return /^Phase\s+[\d.]+:/i.test(t);
|
|
58
|
+
}
|
|
59
|
+
function milestoneStatus(t) {
|
|
60
|
+
if (/\u2705/.test(t) || /shipped/i.test(t)) return 'shipped';
|
|
61
|
+
if (/\uD83D\uDEA7/.test(t) || /in\s*progress/i.test(t)) return 'in-progress';
|
|
62
|
+
if (/\uD83D\uDCCB/.test(t) || /planned/i.test(t)) return 'planned';
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function stripMilestoneDecor(t) {
|
|
66
|
+
// Strip leading emoji/punctuation (surrogate-pair safe) and trailing
|
|
67
|
+
// "(In Progress)" / "(Shipped ...)" / "(Planned)" parenthetical.
|
|
68
|
+
return t
|
|
69
|
+
.replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}\u2705\u2611\u2713\u2714\u2728]+/u, '')
|
|
70
|
+
.replace(/\s*\((?:In Progress|Shipped[^)]*|Planned)\)\s*$/i, '')
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let active = null;
|
|
75
|
+
const milestones = {};
|
|
76
|
+
for (const h of headings) {
|
|
77
|
+
if (!isPhaseHeading(h.text)) {
|
|
78
|
+
const status = milestoneStatus(h.text);
|
|
79
|
+
if (status) {
|
|
80
|
+
const name = stripMilestoneDecor(h.text);
|
|
81
|
+
active = name;
|
|
82
|
+
milestones[name] = { name, status, phases: [], headingIndex: h.index };
|
|
83
|
+
} else {
|
|
84
|
+
active = null;
|
|
85
|
+
}
|
|
86
|
+
} else if (active) {
|
|
87
|
+
const numMatch = h.text.match(/^Phase\s+([\d.]+):/i);
|
|
88
|
+
if (numMatch) milestones[active].phases.push(numMatch[1]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Match by exact name first; else by case-insensitive substring.
|
|
93
|
+
let hit = milestones[milestoneName];
|
|
94
|
+
if (!hit) {
|
|
95
|
+
const lc = milestoneName.toLowerCase();
|
|
96
|
+
for (const m2 of Object.values(milestones)) {
|
|
97
|
+
if (m2.name.toLowerCase().includes(lc)) {
|
|
98
|
+
hit = m2;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!hit) return null;
|
|
104
|
+
return { name: hit.name, status: hit.status, phases: hit.phases };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- Completion verification ----------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Verify every phase in `phaseNums` has all plans done in ROADMAP AND every
|
|
111
|
+
* planned plan has a SUMMARY file on disk.
|
|
112
|
+
*
|
|
113
|
+
* Returns: { ok, reports: [{ phaseNum, name, plansDone, plansTotal, summariesPresent, summariesMissing }] }
|
|
114
|
+
*/
|
|
115
|
+
function verifyMilestoneComplete(roadmapContent, phaseNums, root) {
|
|
116
|
+
const allPhases = roadmap.listPhases(roadmapContent);
|
|
117
|
+
const map = Object.fromEntries(allPhases.map((p) => [p.num, p]));
|
|
118
|
+
const reports = [];
|
|
119
|
+
let ok = true;
|
|
120
|
+
for (const num of phaseNums) {
|
|
121
|
+
const p = map[num];
|
|
122
|
+
if (!p) {
|
|
123
|
+
reports.push({ phaseNum: num, name: null, error: 'phase missing from ROADMAP' });
|
|
124
|
+
ok = false;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const plansTotal = p.plans.length;
|
|
128
|
+
const plansDone = p.plans.filter((pl) => pl.done).length;
|
|
129
|
+
const dir = paths.findPhaseDir(num, root);
|
|
130
|
+
const summariesMissing = [];
|
|
131
|
+
const summariesPresent = [];
|
|
132
|
+
if (dir) {
|
|
133
|
+
for (const pl of p.plans) {
|
|
134
|
+
const expected = path.join(dir, pl.id + '-SUMMARY.md');
|
|
135
|
+
if (fs.existsSync(expected)) summariesPresent.push(pl.id);
|
|
136
|
+
else summariesMissing.push(pl.id);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
for (const pl of p.plans) summariesMissing.push(pl.id);
|
|
140
|
+
}
|
|
141
|
+
const phaseOk = plansTotal > 0 && plansDone === plansTotal && summariesMissing.length === 0;
|
|
142
|
+
if (!phaseOk) ok = false;
|
|
143
|
+
reports.push({
|
|
144
|
+
phaseNum: num,
|
|
145
|
+
name: p.name,
|
|
146
|
+
plansDone,
|
|
147
|
+
plansTotal,
|
|
148
|
+
summariesPresent,
|
|
149
|
+
summariesMissing,
|
|
150
|
+
ok: phaseOk,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return { ok, reports };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------- SUMMARY aggregation ----------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Walk every SUMMARY.md under the listed phase dirs and return their parsed
|
|
160
|
+
* frontmatter objects. Skips files that aren't present.
|
|
161
|
+
*/
|
|
162
|
+
function readSummaries(phaseNums, root) {
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const num of phaseNums) {
|
|
165
|
+
const dir = paths.findPhaseDir(num, root);
|
|
166
|
+
if (!dir) continue;
|
|
167
|
+
for (const f of fs.readdirSync(dir)) {
|
|
168
|
+
if (!/^[\d.]+-\d+-SUMMARY\.md$/.test(f)) continue;
|
|
169
|
+
const full = path.join(dir, f);
|
|
170
|
+
const raw = fs.readFileSync(full, 'utf8');
|
|
171
|
+
const parsed = fm.parse(raw).frontmatter || {};
|
|
172
|
+
out.push({ phaseNum: num, phasePath: dir, file: full, fm: parsed });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Pure aggregator. Given a list of parsed SUMMARY frontmatter objects,
|
|
180
|
+
* union-and-dedupe the fields GSD uses for dependency-graph context.
|
|
181
|
+
*
|
|
182
|
+
* Returns:
|
|
183
|
+
* {
|
|
184
|
+
* subsystems: [unique subsystem strings],
|
|
185
|
+
* tags: [unique tags],
|
|
186
|
+
* requires: [unique require ids],
|
|
187
|
+
* provides: [unique provide ids],
|
|
188
|
+
* affects: [unique paths],
|
|
189
|
+
* techAdded: [unique tech deps],
|
|
190
|
+
* techPatterns: [unique pattern names],
|
|
191
|
+
* filesCreated: [unique paths],
|
|
192
|
+
* filesModified: [unique paths],
|
|
193
|
+
* keyDecisions: [{decision, phase}],
|
|
194
|
+
* patternsEstablished: [{pattern, phase}],
|
|
195
|
+
* requirementsCompleted:[unique req ids],
|
|
196
|
+
* durationSummary: string,
|
|
197
|
+
* planCount: number,
|
|
198
|
+
* phaseDesignRefs: [{ phase, path }],
|
|
199
|
+
* }
|
|
200
|
+
*/
|
|
201
|
+
function aggregateSummaries(summaries) {
|
|
202
|
+
const set = () => new Map(); // preserves insertion order
|
|
203
|
+
const subsystems = set();
|
|
204
|
+
const tags = set();
|
|
205
|
+
const requires = set();
|
|
206
|
+
const provides = set();
|
|
207
|
+
const affects = set();
|
|
208
|
+
const techAdded = set();
|
|
209
|
+
const techPatterns = set();
|
|
210
|
+
const filesCreated = set();
|
|
211
|
+
const filesModified = set();
|
|
212
|
+
const keyDecisions = [];
|
|
213
|
+
const patternsEstablished = [];
|
|
214
|
+
const requirementsCompleted = set();
|
|
215
|
+
const durations = [];
|
|
216
|
+
|
|
217
|
+
for (const s of summaries) {
|
|
218
|
+
const f = s.fm || s.data || {};
|
|
219
|
+
const phase = s.phase || s.phaseNum;
|
|
220
|
+
addOne(subsystems, f.subsystem);
|
|
221
|
+
addList(tags, f.tags);
|
|
222
|
+
addList(requires, f.requires);
|
|
223
|
+
addList(provides, f.provides);
|
|
224
|
+
addList(affects, f.affects);
|
|
225
|
+
const ts = f['tech-stack'] || f.tech_stack || {};
|
|
226
|
+
addList(techAdded, ts.added);
|
|
227
|
+
addList(techPatterns, ts.patterns);
|
|
228
|
+
const kf = f['key-files'] || f.key_files || {};
|
|
229
|
+
addList(filesCreated, kf.created);
|
|
230
|
+
addList(filesModified, kf.modified);
|
|
231
|
+
addList(requirementsCompleted, f['requirements-completed'] || f.requirements_completed);
|
|
232
|
+
if (Array.isArray(f['key-decisions'] || f.key_decisions)) {
|
|
233
|
+
for (const d of f['key-decisions'] || f.key_decisions) {
|
|
234
|
+
keyDecisions.push({ decision: String(d), phase });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (Array.isArray(f['patterns-established'] || f.patterns_established)) {
|
|
238
|
+
for (const p of f['patterns-established'] || f.patterns_established) {
|
|
239
|
+
patternsEstablished.push({ pattern: String(p), phase });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (f.duration) durations.push(String(f.duration));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// v0.7: scan each summary's phase dir for a DESIGN.md and emit a ref
|
|
246
|
+
// (deduped by phase since multiple plans share one DESIGN.md per phase).
|
|
247
|
+
const _designSeen = new Set();
|
|
248
|
+
const phaseDesignRefs = [];
|
|
249
|
+
for (const s of summaries) {
|
|
250
|
+
if (!s) continue;
|
|
251
|
+
const phase = s.phase || s.phaseNum;
|
|
252
|
+
const phasePath = s.phasePath || s.phaseDir || s.path || null;
|
|
253
|
+
if (!phasePath) continue;
|
|
254
|
+
if (_designSeen.has(phase)) continue;
|
|
255
|
+
const designPath = path.join(phasePath, 'DESIGN.md');
|
|
256
|
+
if (fs.existsSync(designPath)) {
|
|
257
|
+
_designSeen.add(phase);
|
|
258
|
+
phaseDesignRefs.push({ phase, path: designPath });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const _reviewSeen = new Set();
|
|
263
|
+
const reviewLogRefs = [];
|
|
264
|
+
let reviewCount = 0;
|
|
265
|
+
for (const s of summaries) {
|
|
266
|
+
if (!s) continue;
|
|
267
|
+
const phase = s.phase || s.phaseNum;
|
|
268
|
+
const phasePath = s.phasePath || s.phaseDir || s.path || null;
|
|
269
|
+
if (!phasePath) continue;
|
|
270
|
+
if (_reviewSeen.has(phase)) continue;
|
|
271
|
+
const rlPath = path.join(phasePath, 'REVIEW-LOG.md');
|
|
272
|
+
if (fs.existsSync(rlPath)) {
|
|
273
|
+
_reviewSeen.add(phase);
|
|
274
|
+
reviewLogRefs.push({ phase, path: rlPath });
|
|
275
|
+
const body = fs.readFileSync(rlPath, 'utf8');
|
|
276
|
+
const matches = body.match(/^##\s+\d{4}-\d{2}-\d{2}/gm);
|
|
277
|
+
reviewCount += matches ? matches.length : 0;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
subsystems: Array.from(subsystems.keys()),
|
|
283
|
+
tags: Array.from(tags.keys()),
|
|
284
|
+
requires: Array.from(requires.keys()),
|
|
285
|
+
provides: Array.from(provides.keys()),
|
|
286
|
+
affects: Array.from(affects.keys()),
|
|
287
|
+
techAdded: Array.from(techAdded.keys()),
|
|
288
|
+
techPatterns: Array.from(techPatterns.keys()),
|
|
289
|
+
filesCreated: Array.from(filesCreated.keys()),
|
|
290
|
+
filesModified: Array.from(filesModified.keys()),
|
|
291
|
+
keyDecisions,
|
|
292
|
+
patternsEstablished,
|
|
293
|
+
requirementsCompleted: Array.from(requirementsCompleted.keys()),
|
|
294
|
+
durationSummary: durations.join(', ') || '—',
|
|
295
|
+
planCount: summaries.length,
|
|
296
|
+
phaseDesignRefs,
|
|
297
|
+
reviewLogRefs,
|
|
298
|
+
reviewCount,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Promote .planning/MILESTONE-CONTEXT.md (transient) into the milestone-tier
|
|
304
|
+
* DESIGN.md as a "Brainstorm transcript" appendix. Returns { action, path,
|
|
305
|
+
* after, contextPath } or null if there's nothing to promote.
|
|
306
|
+
*
|
|
307
|
+
* Caller is responsible for writing `after` to `path` and deleting
|
|
308
|
+
* `contextPath` (so cp can do both inside a writeBatch for atomicity).
|
|
309
|
+
*/
|
|
310
|
+
function promoteMilestoneContext(root, milestoneName, options = {}) {
|
|
311
|
+
const fs = require('fs');
|
|
312
|
+
const path = require('path');
|
|
313
|
+
const paths = require('./paths');
|
|
314
|
+
|
|
315
|
+
const contextPath = path.join(paths.planningDir(root), 'MILESTONE-CONTEXT.md');
|
|
316
|
+
if (!fs.existsSync(contextPath)) return null;
|
|
317
|
+
|
|
318
|
+
const body = fs.readFileSync(contextPath, 'utf8').trim();
|
|
319
|
+
if (!body) return null;
|
|
320
|
+
|
|
321
|
+
const designPath = paths.milestoneDesignFile(milestoneName, root);
|
|
322
|
+
const exists = fs.existsSync(designPath);
|
|
323
|
+
|
|
324
|
+
let after;
|
|
325
|
+
if (exists) {
|
|
326
|
+
const current = fs.readFileSync(designPath, 'utf8').replace(/\n+$/, '');
|
|
327
|
+
after = `${current}\n\n## Brainstorm transcript\n\n${body}\n`;
|
|
328
|
+
} else {
|
|
329
|
+
after = [
|
|
330
|
+
'---',
|
|
331
|
+
`milestone_slug: "${paths.milestoneSlug(milestoneName)}"`,
|
|
332
|
+
`milestone: ${milestoneName}`,
|
|
333
|
+
'status: accepted',
|
|
334
|
+
`created: ${options.today || new Date().toISOString().slice(0, 10)}`,
|
|
335
|
+
'---',
|
|
336
|
+
'',
|
|
337
|
+
`# Design: ${milestoneName}`,
|
|
338
|
+
'',
|
|
339
|
+
'## Brainstorm transcript',
|
|
340
|
+
'',
|
|
341
|
+
body,
|
|
342
|
+
'',
|
|
343
|
+
].join('\n');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { action: exists ? 'appended' : 'created', path: designPath, after, contextPath };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function addOne(map, v) {
|
|
350
|
+
if (v == null || v === '') return;
|
|
351
|
+
map.set(String(v), true);
|
|
352
|
+
}
|
|
353
|
+
function addList(map, v) {
|
|
354
|
+
if (!Array.isArray(v)) return;
|
|
355
|
+
for (const x of v) addOne(map, x);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------- Digest rendering ----------
|
|
359
|
+
|
|
360
|
+
function renderDigest(name, isoDate, phaseNums, agg, phaseNames = {}) {
|
|
361
|
+
const lines = [];
|
|
362
|
+
const first = phaseNums[0];
|
|
363
|
+
const last = phaseNums[phaseNums.length - 1];
|
|
364
|
+
lines.push(`## ${name} — shipped ${isoDate}`);
|
|
365
|
+
lines.push('');
|
|
366
|
+
lines.push(
|
|
367
|
+
`**Phases:** ${first}-${last} **Plans:** ${agg.planCount} **Duration:** ${agg.durationSummary}`
|
|
368
|
+
);
|
|
369
|
+
lines.push('');
|
|
370
|
+
if (agg.requirementsCompleted.length) {
|
|
371
|
+
lines.push(`**Requirements delivered:** ${agg.requirementsCompleted.join(', ')}`);
|
|
372
|
+
lines.push('');
|
|
373
|
+
}
|
|
374
|
+
if (agg.subsystems.length) {
|
|
375
|
+
lines.push(`**Subsystems touched:** ${agg.subsystems.join(', ')}`);
|
|
376
|
+
lines.push('');
|
|
377
|
+
}
|
|
378
|
+
if (agg.techAdded.length) {
|
|
379
|
+
lines.push(`**Tech added:** ${agg.techAdded.join(', ')}`);
|
|
380
|
+
lines.push('');
|
|
381
|
+
}
|
|
382
|
+
if (agg.keyDecisions.length) {
|
|
383
|
+
lines.push(`**Key decisions:**`);
|
|
384
|
+
for (const d of agg.keyDecisions) {
|
|
385
|
+
lines.push(`- ${d.decision} _(phase ${d.phase})_`);
|
|
386
|
+
}
|
|
387
|
+
lines.push('');
|
|
388
|
+
}
|
|
389
|
+
if (agg.patternsEstablished.length) {
|
|
390
|
+
lines.push(`**Patterns established:**`);
|
|
391
|
+
for (const p of agg.patternsEstablished) {
|
|
392
|
+
lines.push(`- ${p.pattern} _(phase ${p.phase})_`);
|
|
393
|
+
}
|
|
394
|
+
lines.push('');
|
|
395
|
+
}
|
|
396
|
+
if (agg.filesCreated.length) {
|
|
397
|
+
lines.push(`**Files (created):** ${agg.filesCreated.join(', ')}`);
|
|
398
|
+
}
|
|
399
|
+
if (agg.filesModified.length) {
|
|
400
|
+
lines.push(`**Files (modified):** ${agg.filesModified.join(', ')}`);
|
|
401
|
+
}
|
|
402
|
+
if (agg.filesCreated.length || agg.filesModified.length) lines.push('');
|
|
403
|
+
lines.push('**Phase summaries:**');
|
|
404
|
+
for (const num of phaseNums) {
|
|
405
|
+
const dir = `.planning/phases/${paths.padPhaseNum(num)}-${paths.slugifyPhase(phaseNames[num] || '')}/`;
|
|
406
|
+
lines.push(`- Phase ${num}${phaseNames[num] ? `: ${phaseNames[num]}` : ''} — see \`${dir}\``);
|
|
407
|
+
}
|
|
408
|
+
lines.push('');
|
|
409
|
+
return lines.join('\n');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function appendToMilestonesMd(currentContent, digest) {
|
|
413
|
+
const trimmed = currentContent.replace(/\s+$/, '');
|
|
414
|
+
return trimmed + '\n\n' + digest.trim() + '\n';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------- write-summary ----------
|
|
418
|
+
|
|
419
|
+
const KEY_DECISIONS_ERROR = "Error: 'key-decisions' is required and must have ≥1 entry. See spec at docs/superpowers/specs/2026-05-20-v0-7-design-capture-design.md";
|
|
420
|
+
|
|
421
|
+
class ValidationError extends Error {
|
|
422
|
+
constructor(message) {
|
|
423
|
+
super(message);
|
|
424
|
+
this.name = 'ValidationError';
|
|
425
|
+
this.code = 'EVALIDATION';
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function writeFile(p, content) {
|
|
430
|
+
if (typeof content !== 'string') {
|
|
431
|
+
throw new TypeError(`writeFile(${p}): content must be string, got ${typeof content}`);
|
|
432
|
+
}
|
|
433
|
+
const dir = path.dirname(p);
|
|
434
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
435
|
+
const suffix = `.cp-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
436
|
+
const tmp = p + suffix;
|
|
437
|
+
try {
|
|
438
|
+
fs.writeFileSync(tmp, content);
|
|
439
|
+
fs.renameSync(tmp, p);
|
|
440
|
+
} catch (e) {
|
|
441
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
442
|
+
throw e;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function parsePlanId(planId) {
|
|
447
|
+
const m = String(planId).match(/^(\d+(?:\.\d+)?)-(\d+)$/);
|
|
448
|
+
if (!m) throw new Error(`Invalid plan id "${planId}" — expected "NN-MM" (e.g. "01-02").`);
|
|
449
|
+
const phaseNum = /^\d+$/.test(m[1]) ? String(parseInt(m[1], 10)) : m[1];
|
|
450
|
+
return { phaseNum, planSeq: m[2], id: `${m[1]}-${m[2]}` };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function _normaliseSummary(input) {
|
|
454
|
+
const out = {};
|
|
455
|
+
const aliases = {
|
|
456
|
+
subsystems: 'subsystem',
|
|
457
|
+
files_created: ['key-files', 'created'],
|
|
458
|
+
files_modified: ['key-files', 'modified'],
|
|
459
|
+
requirements_completed: 'requirements-completed',
|
|
460
|
+
key_decisions: 'key-decisions',
|
|
461
|
+
patterns_established: 'patterns-established',
|
|
462
|
+
tech_stack: 'tech-stack',
|
|
463
|
+
};
|
|
464
|
+
for (const [k, v] of Object.entries(input || {})) {
|
|
465
|
+
const target = aliases[k];
|
|
466
|
+
if (Array.isArray(target)) {
|
|
467
|
+
out[target[0]] = out[target[0]] || {};
|
|
468
|
+
out[target[0]][target[1]] = v;
|
|
469
|
+
} else if (typeof target === 'string') {
|
|
470
|
+
if (target === 'subsystem' && Array.isArray(v)) out.subsystem = v[0];
|
|
471
|
+
else out[target] = v;
|
|
472
|
+
} else {
|
|
473
|
+
out[k] = v;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function writeSummary(root, planId, summaryData, options = {}) {
|
|
480
|
+
const { dryRun = false, body, overwrite = false } = options;
|
|
481
|
+
const { phaseNum, id } = parsePlanId(planId);
|
|
482
|
+
|
|
483
|
+
const phaseDir = paths.findPhaseDir(phaseNum, root);
|
|
484
|
+
if (!phaseDir) {
|
|
485
|
+
throw new Error(`Phase ${phaseNum} dir not found. Run \`/cp-plan-phase ${phaseNum}\` first.`);
|
|
486
|
+
}
|
|
487
|
+
const summaryPath = path.join(phaseDir, `${id}-SUMMARY.md`);
|
|
488
|
+
if (fs.existsSync(summaryPath) && !overwrite) {
|
|
489
|
+
throw new Error(`${summaryPath} already exists. Pass overwrite:true to replace.`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const normalised = _normaliseSummary(summaryData);
|
|
493
|
+
const keyDecisions = normalised['key-decisions'];
|
|
494
|
+
if (!Array.isArray(keyDecisions) || keyDecisions.length === 0) {
|
|
495
|
+
throw new ValidationError(KEY_DECISIONS_ERROR);
|
|
496
|
+
}
|
|
497
|
+
if (!('phase' in normalised)) normalised.phase = parseInt(phaseNum, 10);
|
|
498
|
+
if (!('plan' in normalised)) normalised.plan = id;
|
|
499
|
+
if (!('completed' in normalised)) normalised.completed = new Date().toISOString().slice(0, 10);
|
|
500
|
+
|
|
501
|
+
const text = fm.stringify(normalised, body || `# Summary ${id}\n\nPlan ${id} completed.\n`);
|
|
502
|
+
if (!dryRun) writeFile(summaryPath, text);
|
|
503
|
+
return { path: summaryPath, action: dryRun ? 'dryrun' : 'written', fm: normalised };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---------- ROADMAP collapse ----------
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Wrap the milestone heading + every phase block belonging to it in a
|
|
510
|
+
* `<details>` block, and rewrite the `## Milestones` bullet for this
|
|
511
|
+
* milestone to ✅ shipped.
|
|
512
|
+
*
|
|
513
|
+
* Preserves every phase block byte-for-byte (so GSD parsers still see
|
|
514
|
+
* the same `### Phase N: ...` headings inside the <details>).
|
|
515
|
+
*/
|
|
516
|
+
function collapseMilestoneInRoadmap(content, milestoneName, isoDate) {
|
|
517
|
+
const info = findMilestoneInRoadmap(content, milestoneName);
|
|
518
|
+
if (!info) return { content, changed: false, reason: 'milestone-not-found' };
|
|
519
|
+
|
|
520
|
+
// Find the milestone heading line and the end of its block in `## Phases`.
|
|
521
|
+
const phasesStart = content.search(/^##\s+Phases\s*$/m);
|
|
522
|
+
if (phasesStart === -1)
|
|
523
|
+
return { content, changed: false, reason: 'phases-section-missing' };
|
|
524
|
+
const after = content.slice(phasesStart);
|
|
525
|
+
const nextH2Rel = after.slice(2).search(/^##\s+/m);
|
|
526
|
+
const phasesEnd = nextH2Rel === -1 ? content.length : phasesStart + 2 + nextH2Rel;
|
|
527
|
+
|
|
528
|
+
// Locate the milestone heading inside phases section.
|
|
529
|
+
// Allow an optional leading emoji/picto cluster before the milestone name.
|
|
530
|
+
const escName = escapeRegex(info.name);
|
|
531
|
+
const headingRe = new RegExp(
|
|
532
|
+
`^###\\s+(?:[\\s\\p{Emoji_Presentation}\\p{Extended_Pictographic}\\u2705\\u2611\\u2713\\u2714\\u2728]+)?${escName}[^\\n]*$`,
|
|
533
|
+
'mu'
|
|
534
|
+
);
|
|
535
|
+
const headingMatch = content.slice(phasesStart, phasesEnd).match(headingRe);
|
|
536
|
+
if (!headingMatch)
|
|
537
|
+
return { content, changed: false, reason: 'milestone-heading-not-found' };
|
|
538
|
+
const headingRelative = content.slice(phasesStart, phasesEnd).indexOf(headingMatch[0]);
|
|
539
|
+
const headingAbs = phasesStart + headingRelative;
|
|
540
|
+
|
|
541
|
+
// Block ends at the next `### ` that is a milestone heading (not a phase),
|
|
542
|
+
// or at phasesEnd. We need to include ALL phase headings under this milestone
|
|
543
|
+
// but stop at the next milestone heading.
|
|
544
|
+
const restRel = content.slice(headingAbs + headingMatch[0].length, phasesEnd);
|
|
545
|
+
const nextMilestoneRel = restRel.search(
|
|
546
|
+
/^###\s+(?:[\s\p{Emoji_Presentation}\p{Extended_Pictographic}\u2705\u2611\u2713\u2714\u2728]+)(?!Phase\s)/mu
|
|
547
|
+
);
|
|
548
|
+
const blockEnd =
|
|
549
|
+
nextMilestoneRel === -1
|
|
550
|
+
? phasesEnd
|
|
551
|
+
: headingAbs + headingMatch[0].length + nextMilestoneRel;
|
|
552
|
+
|
|
553
|
+
const original = content.slice(headingAbs, blockEnd).replace(/\s+$/, '');
|
|
554
|
+
const first = info.phases[0];
|
|
555
|
+
const last = info.phases[info.phases.length - 1];
|
|
556
|
+
|
|
557
|
+
// Rewrite the heading INSIDE the details so it shows shipped status for
|
|
558
|
+
// anyone reading the collapsed block, but ALSO keep all the `### Phase N`
|
|
559
|
+
// blocks unchanged. We replace just the milestone heading line.
|
|
560
|
+
const innerBody = original.replace(headingRe, '').replace(/^\n+/, '');
|
|
561
|
+
const wrapped =
|
|
562
|
+
`<details>\n<summary>\u2705 ${info.name} (Phases ${first}-${last}) \u2014 SHIPPED ${isoDate}</summary>\n\n` +
|
|
563
|
+
innerBody.trim() +
|
|
564
|
+
`\n\n</details>`;
|
|
565
|
+
|
|
566
|
+
const next = content.slice(0, headingAbs) + wrapped + '\n' + content.slice(blockEnd);
|
|
567
|
+
|
|
568
|
+
// Now update the `## Milestones` bullet (above `## Phases`):
|
|
569
|
+
const updated = next.replace(
|
|
570
|
+
new RegExp(
|
|
571
|
+
`^-\\s+[\\s\\p{Emoji_Presentation}\\p{Extended_Pictographic}\\u2705\\u2611\\u2713\\u2714\\u2728]+\\*\\*${escName}\\*\\*[^\\n]*$`,
|
|
572
|
+
'mu'
|
|
573
|
+
),
|
|
574
|
+
`- \u2705 **${info.name}** \u2014 Phases ${first}-${last} (shipped ${isoDate})`
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
return { content: updated, changed: true };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function escapeRegex(s) {
|
|
581
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
module.exports = {
|
|
585
|
+
findMilestoneInRoadmap,
|
|
586
|
+
verifyMilestoneComplete,
|
|
587
|
+
readSummaries,
|
|
588
|
+
aggregateSummaries,
|
|
589
|
+
promoteMilestoneContext,
|
|
590
|
+
renderDigest,
|
|
591
|
+
appendToMilestonesMd,
|
|
592
|
+
writeSummary,
|
|
593
|
+
ValidationError,
|
|
594
|
+
collapseMilestoneInRoadmap,
|
|
595
|
+
};
|