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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +454 -0
  3. package/bin/commands/_helpers.js +53 -0
  4. package/bin/commands/_usage.js +67 -0
  5. package/bin/commands/capture.js +46 -0
  6. package/bin/commands/codebase-status.js +41 -0
  7. package/bin/commands/complete-milestone.js +57 -0
  8. package/bin/commands/config.js +70 -0
  9. package/bin/commands/doctor.js +139 -0
  10. package/bin/commands/gsd-import.js +90 -0
  11. package/bin/commands/inbox.js +81 -0
  12. package/bin/commands/index.js +33 -0
  13. package/bin/commands/init.js +87 -0
  14. package/bin/commands/install.js +43 -0
  15. package/bin/commands/scaffold-codebase.js +53 -0
  16. package/bin/commands/scaffold-milestone.js +58 -0
  17. package/bin/commands/scaffold-phase.js +65 -0
  18. package/bin/commands/status.js +42 -0
  19. package/bin/commands/statusline.js +108 -0
  20. package/bin/commands/tick.js +49 -0
  21. package/bin/commands/version.js +9 -0
  22. package/bin/commands/worktree.js +218 -0
  23. package/bin/commands/write-summary.js +54 -0
  24. package/bin/cp.cmd +2 -0
  25. package/bin/cp.js +54 -0
  26. package/commands/cp/capture.md +107 -0
  27. package/commands/cp/complete-milestone.md +166 -0
  28. package/commands/cp/execute-phase.md +220 -0
  29. package/commands/cp/map-codebase.md +211 -0
  30. package/commands/cp/new-milestone.md +136 -0
  31. package/commands/cp/new-project.md +132 -0
  32. package/commands/cp/plan-phase.md +195 -0
  33. package/commands/cp/progress.md +147 -0
  34. package/commands/cp/quick.md +104 -0
  35. package/commands/cp/resume.md +125 -0
  36. package/commands/cp/write-summary.md +33 -0
  37. package/docs/MIGRATION-v0.5.md +140 -0
  38. package/docs/architecture.md +189 -0
  39. package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-01-design-md-infrastructure.md +1064 -0
  40. package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-02-review-log-infrastructure.md +418 -0
  41. package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-03-key-decisions-hard-block.md +295 -0
  42. package/docs/superpowers/specs/2026-05-20-generic-provider-harness-detection-design.md +380 -0
  43. package/docs/superpowers/specs/2026-05-20-v0-7-design-capture-design.md +400 -0
  44. package/docs/writing-providers.md +76 -0
  45. package/install/aider.js +204 -0
  46. package/install/claude.js +116 -0
  47. package/install/common.js +65 -0
  48. package/install/copilot.js +86 -0
  49. package/install/cursor.js +120 -0
  50. package/install/echo-provider.js +50 -0
  51. package/lib/codebase-mapper.js +169 -0
  52. package/lib/detect.js +280 -0
  53. package/lib/frontmatter.js +72 -0
  54. package/lib/gsd-compat.js +165 -0
  55. package/lib/import.js +543 -0
  56. package/lib/inbox.js +226 -0
  57. package/lib/lifecycle.js +929 -0
  58. package/lib/merge.js +157 -0
  59. package/lib/milestone.js +595 -0
  60. package/lib/paths.js +191 -0
  61. package/lib/provider.js +168 -0
  62. package/lib/roadmap.js +134 -0
  63. package/lib/state.js +99 -0
  64. package/lib/worktree.js +253 -0
  65. package/package.json +45 -0
  66. package/templates/DESIGN.md +78 -0
  67. package/templates/INBOX.md +13 -0
  68. package/templates/MILESTONE-CONTEXT.md +40 -0
  69. package/templates/MILESTONES.md +29 -0
  70. package/templates/PLAN.md +84 -0
  71. package/templates/PROJECT.md +43 -0
  72. package/templates/REVIEW-LOG.md +38 -0
  73. package/templates/ROADMAP.md +34 -0
  74. package/templates/STATE.md +78 -0
  75. package/templates/SUMMARY.md +75 -0
  76. package/templates/codebase/ARCHITECTURE.md +30 -0
  77. package/templates/codebase/CONCERNS.md +30 -0
  78. package/templates/codebase/CONVENTIONS.md +30 -0
  79. package/templates/codebase/INTEGRATIONS.md +30 -0
  80. package/templates/codebase/STACK.md +26 -0
  81. package/templates/codebase/STRUCTURE.md +32 -0
  82. package/templates/codebase/TESTING.md +39 -0
  83. package/templates/config.json +173 -0
  84. package/templates/phase-PLAN.md +32 -0
  85. package/templates/quick-PLAN.md +24 -0
  86. package/templates/quick-SUMMARY.md +25 -0
@@ -0,0 +1,929 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * High-level lifecycle helpers — the public, user-friendly API that hides
5
+ * the lib-contract details that bit us during the linkmark demo:
6
+ * - SUMMARY filename format (`{NN-MM}-SUMMARY.md`, no slug)
7
+ * - SUMMARY frontmatter field names (`subsystem`, `key-files`, ...)
8
+ * - aggregateSummaries needs parsed-fm objects, not raw strings
9
+ * - collapseMilestoneInRoadmap returns `{content, changed}` not a string
10
+ * - state.updatePosition takes content, not a path
11
+ * - setPlanDone has to be called on BOTH ROADMAP and the phase PLAN.md
12
+ *
13
+ * Every function in this module is *transactional in intent*: it builds a
14
+ * list of `actions` (file writes + commits) and then runs them. Dry-run
15
+ * mode returns the action list without touching disk.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+ const { execSync } = require('child_process');
22
+
23
+ const fm = require('./frontmatter');
24
+ const roadmap = require('./roadmap');
25
+ const state = require('./state');
26
+ const milestone = require('./milestone');
27
+ const paths = require('./paths');
28
+
29
+ // ---------- shared helpers ----------
30
+
31
+ function readFile(p) { return fs.readFileSync(p, 'utf8'); }
32
+
33
+ /**
34
+ * Atomic single-file write — write to a sibling temp file then rename. The
35
+ * rename is atomic on POSIX, and on NTFS via `MoveFileEx`
36
+ * (`MOVEFILE_REPLACE_EXISTING`), which Node's `fs.renameSync` uses. So the
37
+ * worst observable state for a reader is "old content" or "new content",
38
+ * never "half-written".
39
+ *
40
+ * On failure during the write step, we clean up the temp file so we don't
41
+ * leak `.cp-tmp-*` siblings on disk.
42
+ *
43
+ * v0.3.2: closes the data-integrity gap surfaced by the live `/cp-map-codebase`
44
+ * dry-fire (CONCERNS.md → Critical).
45
+ */
46
+ function writeFile(p, content) {
47
+ if (typeof content !== 'string') {
48
+ throw new TypeError(`writeFile(${p}): content must be string, got ${typeof content}`);
49
+ }
50
+ const dir = path.dirname(p);
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ const suffix = `.cp-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
53
+ const tmp = p + suffix;
54
+ try {
55
+ fs.writeFileSync(tmp, content);
56
+ fs.renameSync(tmp, p);
57
+ } catch (e) {
58
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* ignore */ }
59
+ throw e;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Best-effort transactional batch apply for the multi-file lifecycle ops
65
+ * (currently `completeMilestone`; designed to wrap any future op that mutates
66
+ * 2+ planning files in one logical step).
67
+ *
68
+ * Strategy:
69
+ * 1. Stage every `write` action to a sibling `.cp-tmp-*` file FIRST. If any
70
+ * write fails (disk full, permissions, …), no temp gets renamed and we
71
+ * throw. The on-disk state is unchanged except for one stray temp we
72
+ * then unlink.
73
+ * 2. Snapshot pre-rename contents (or absence) of every destination, then
74
+ * rename temps into place one at a time. If ANY rename throws, walk the
75
+ * already-completed renames in reverse and restore the snapshots —
76
+ * bringing the on-disk state back to the pre-batch picture before
77
+ * re-throwing. (v0.3.4 — closes CONCERNS Medium "writeBatch is
78
+ * destructive-last but still not rollback-safe if a later renameSync
79
+ * fails after earlier renames already landed.")
80
+ * 3. ONLY AFTER all writes have landed, apply `delete` actions. This
81
+ * enforces the invariant "destructive ops never run before their
82
+ * replacement state is on disk" — the core fix for the
83
+ * `completeMilestone` Critical (v0.3.2).
84
+ *
85
+ * Limitations:
86
+ * - Rollback itself uses `writeFileSync` (atomic for our temp pattern but
87
+ * not for the on-disk dest path). A second-order failure mid-rollback
88
+ * (e.g. disk fills WHILE restoring) leaves a partial state, but the
89
+ * original error wins and is re-thrown so the caller knows.
90
+ * - The delete phase is not rolled back — by design. If a delete fails
91
+ * after all writes succeeded, the new content is already on disk and
92
+ * correct; the caller just sees a leftover file (logged in the thrown
93
+ * error message).
94
+ *
95
+ * Action shape: `{ kind: 'write', path, after }` or `{ kind: 'delete', path }`.
96
+ * Other kinds (`'skip'`, etc.) are silently ignored — they exist for callers'
97
+ * reporting only.
98
+ */
99
+ function writeBatch(actions) {
100
+ const writes = actions.filter((a) => a.kind === 'write');
101
+ const deletes = actions.filter((a) => a.kind === 'delete');
102
+
103
+ // Stage all writes to temp files first.
104
+ const staged = [];
105
+ try {
106
+ for (const a of writes) {
107
+ if (typeof a.after !== 'string') {
108
+ throw new TypeError(`writeBatch: write action for ${a.path} has no string \`after\` content`);
109
+ }
110
+ const dir = path.dirname(a.path);
111
+ fs.mkdirSync(dir, { recursive: true });
112
+ const suffix = `.cp-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
113
+ const tmp = a.path + suffix;
114
+ fs.writeFileSync(tmp, a.after);
115
+ staged.push({ tmp, dest: a.path });
116
+ }
117
+ } catch (e) {
118
+ // Cleanup: remove any temps we did manage to stage so we don't leak.
119
+ for (const s of staged) {
120
+ try { if (fs.existsSync(s.tmp)) fs.unlinkSync(s.tmp); } catch { /* ignore */ }
121
+ }
122
+ throw e;
123
+ }
124
+
125
+ // All temps staged — snapshot the destinations so we can roll back if a
126
+ // later rename throws after earlier ones already landed. Snapshot reads
127
+ // happen INSIDE a try so an unreadable dest (e.g. it's a directory, which
128
+ // means the upcoming rename will fail anyway) doesn't escape unwrapped.
129
+ const snapshots = [];
130
+ try {
131
+ for (const s of staged) {
132
+ if (!fs.existsSync(s.dest)) {
133
+ snapshots.push({ dest: s.dest, existed: false, prev: null });
134
+ continue;
135
+ }
136
+ // Only snapshot regular files. Directories / sockets / etc. can't be
137
+ // restored from a byte snapshot anyway; the rename will fail on them
138
+ // and the rollback loop will treat them as "skip restore" since prev
139
+ // is null but existed is true → handled below.
140
+ let stat;
141
+ try { stat = fs.statSync(s.dest); } catch { stat = null; }
142
+ if (stat && stat.isFile()) {
143
+ snapshots.push({ dest: s.dest, existed: true, prev: fs.readFileSync(s.dest) });
144
+ } else {
145
+ snapshots.push({ dest: s.dest, existed: true, prev: null, unsnapshottable: true });
146
+ }
147
+ }
148
+ } catch (snapErr) {
149
+ // Should be unreachable given the per-dest try above, but if it happens
150
+ // unwind staged temps so we don't leak.
151
+ for (const s of staged) {
152
+ try { if (fs.existsSync(s.tmp)) fs.unlinkSync(s.tmp); } catch { /* ignore */ }
153
+ }
154
+ throw snapErr;
155
+ }
156
+
157
+ const completed = [];
158
+ try {
159
+ for (let i = 0; i < staged.length; i++) {
160
+ fs.renameSync(staged[i].tmp, staged[i].dest);
161
+ completed.push(i);
162
+ }
163
+ } catch (renameErr) {
164
+ // Roll back the renames that DID land. Walk in reverse so dependent
165
+ // mkdirs (rare here, since all dests are inside .planning/) unwind cleanly.
166
+ const rollbackErrors = [];
167
+ for (let i = completed.length - 1; i >= 0; i--) {
168
+ const s = snapshots[completed[i]];
169
+ try {
170
+ if (s.unsnapshottable) {
171
+ // Skip — we never had a usable snapshot. (In practice this branch
172
+ // is unreachable: a non-file dest always fails the rename, so it
173
+ // would never appear in `completed`.)
174
+ continue;
175
+ }
176
+ if (s.existed) {
177
+ // Restore prior content. Use the same temp+rename pattern so
178
+ // readers never see a half-written rollback either.
179
+ const restoreSuffix = `.cp-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
180
+ const restoreTmp = s.dest + restoreSuffix;
181
+ fs.writeFileSync(restoreTmp, s.prev);
182
+ fs.renameSync(restoreTmp, s.dest);
183
+ } else {
184
+ // Path didn't exist before — remove the newly-renamed file.
185
+ if (fs.existsSync(s.dest)) fs.unlinkSync(s.dest);
186
+ }
187
+ } catch (rbErr) {
188
+ rollbackErrors.push({ path: s.dest, error: rbErr.message });
189
+ }
190
+ }
191
+ // Clean up any temps we never got to rename.
192
+ for (let i = completed.length; i < staged.length; i++) {
193
+ try { if (fs.existsSync(staged[i].tmp)) fs.unlinkSync(staged[i].tmp); } catch { /* ignore */ }
194
+ }
195
+ const wrapped = new Error(
196
+ `writeBatch: rename phase failed (${renameErr.message}). ` +
197
+ (rollbackErrors.length === 0
198
+ ? `Rolled back ${completed.length} rename(s) — on-disk state restored.`
199
+ : `Rolled back ${completed.length - rollbackErrors.length}/${completed.length} renames; ` +
200
+ `${rollbackErrors.length} rollback(s) ALSO failed: ` +
201
+ rollbackErrors.map((r) => `${r.path}: ${r.error}`).join('; '))
202
+ );
203
+ wrapped.cause = renameErr;
204
+ wrapped.rollbackErrors = rollbackErrors;
205
+ throw wrapped;
206
+ }
207
+
208
+ // Only then do the deletes (destructive ops last).
209
+ for (const a of deletes) {
210
+ if (fs.existsSync(a.path)) fs.unlinkSync(a.path);
211
+ }
212
+ }
213
+
214
+ /** Parse "01-02" → { phaseNum: '1', planSeq: '02', id: '01-02' }. */
215
+ function parsePlanId(planId) {
216
+ const m = String(planId).match(/^(\d+(?:\.\d+)?)-(\d+)$/);
217
+ if (!m) throw new Error(`Invalid plan id "${planId}" — expected "NN-MM" (e.g. "01-02").`);
218
+ // Preserve decimal phase numbers (e.g. "2.1"); only normalise leading zeros on integers.
219
+ const phaseNum = /^\d+$/.test(m[1]) ? String(parseInt(m[1], 10)) : m[1];
220
+ return { phaseNum, planSeq: m[2], id: `${m[1]}-${m[2]}` };
221
+ }
222
+
223
+ /**
224
+ * Commit changes with a cp:-prefixed message. Returns commit hash or null
225
+ * if nothing was staged.
226
+ *
227
+ * options.paths — array of file paths (absolute or repo-relative) to stage.
228
+ * When provided, ONLY these paths are staged. This is the right call-site
229
+ * pattern for lifecycle ops that compute an explicit `actions` list — use
230
+ * `pathsFromActions(actions)` to derive it.
231
+ * options.planningOnly — boolean, defaults TRUE. When no `paths` are given,
232
+ * stages just `.planning/` (the cp state layer) rather than the entire
233
+ * working tree. Avoids the v0.3.x footgun where running `cp scaffold-*`
234
+ * in a dirty repo would sweep unrelated edits into a misleading `cp:`
235
+ * commit. Set to false to opt back into `git add -A` legacy behavior.
236
+ *
237
+ * If the resulting `git add` selection has nothing staged for commit, the
238
+ * function returns null without invoking `git commit`.
239
+ *
240
+ * v0.3.3 — closes CONCERNS High: "gitCommit uses repo-wide `git add -A`".
241
+ */
242
+ function gitCommit(root, message, options = {}) {
243
+ const { paths: pathList, planningOnly = true } = options;
244
+ try {
245
+ if (Array.isArray(pathList) && pathList.length > 0) {
246
+ // Stage exactly the paths the caller produced. Normalize to repo-relative
247
+ // for readability in `git status` and avoid OS-path-separator issues.
248
+ const args = pathList.map((p) => {
249
+ const abs = path.isAbsolute(p) ? p : path.join(root, p);
250
+ const rel = path.relative(root, abs);
251
+ return JSON.stringify(rel.split(path.sep).join('/'));
252
+ }).join(' ');
253
+ execSync(`git add -- ${args}`, { cwd: root, stdio: 'pipe' });
254
+ } else if (planningOnly) {
255
+ // Default: stage only the state layer. Existing repos with .planning/
256
+ // gitignored will see this as a no-op (nothing staged) — safe.
257
+ execSync('git add -- .planning', { cwd: root, stdio: 'pipe' });
258
+ } else {
259
+ execSync('git add -A', { cwd: root, stdio: 'pipe' });
260
+ }
261
+ // Bail if nothing staged.
262
+ try {
263
+ execSync('git diff --cached --quiet', { cwd: root, stdio: 'pipe' });
264
+ return null;
265
+ } catch { /* there ARE staged changes */ }
266
+ execSync(`git commit -q -m ${JSON.stringify(message)}`, { cwd: root, stdio: 'pipe' });
267
+ const hash = execSync('git rev-parse --short HEAD', { cwd: root, stdio: 'pipe' }).toString().trim();
268
+ return hash;
269
+ } catch (e) {
270
+ // Not a git repo, or some other failure. Don't blow up the whole command.
271
+ return null;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Extract the unique set of file paths from a lifecycle actions list. Pass
277
+ * the result as `gitCommit(root, msg, { paths })` to scope the auto-commit
278
+ * exactly to what the op produced — no `git add -A` footgun.
279
+ *
280
+ * Handles both `write` and `delete` action kinds; ignores `skip` and unknown
281
+ * kinds. Returns an array (order preserved, dedup'd).
282
+ */
283
+ function pathsFromActions(actions) {
284
+ if (!Array.isArray(actions)) return [];
285
+ const seen = new Set();
286
+ const out = [];
287
+ for (const a of actions) {
288
+ if (!a || (a.kind !== 'write' && a.kind !== 'delete')) continue;
289
+ if (typeof a.path !== 'string') continue;
290
+ if (seen.has(a.path)) continue;
291
+ seen.add(a.path);
292
+ out.push(a.path);
293
+ }
294
+ return out;
295
+ }
296
+
297
+ // ---------- tick: mark a plan done ----------
298
+
299
+ /**
300
+ * Mark a plan done in ROADMAP.md AND the phase's PLAN.md.
301
+ * Returns { actions: [{path, before, after}], roadmapChanged, planChanged }.
302
+ *
303
+ * options:
304
+ * - dryRun: boolean — return actions without writing
305
+ * - done: boolean (default true) — pass false to un-tick
306
+ */
307
+ function tickPlan(root, planId, options = {}) {
308
+ const { dryRun = false, done = true } = options;
309
+ const { phaseNum, id } = parsePlanId(planId);
310
+
311
+ const planning = paths.planningDir(root);
312
+ const roadmapPath = path.join(planning, 'ROADMAP.md');
313
+ if (!fs.existsSync(roadmapPath)) {
314
+ throw new Error(`ROADMAP.md not found at ${roadmapPath}. Run \`cp init\` first.`);
315
+ }
316
+
317
+ const phaseDir = paths.findPhaseDir(phaseNum, root);
318
+ if (!phaseDir) {
319
+ throw new Error(`Phase ${phaseNum} dir not found under ${planning}/phases/. Have you run \`/cp-plan-phase ${phaseNum}\`?`);
320
+ }
321
+ const phasePlanPath = path.join(phaseDir, 'PLAN.md');
322
+
323
+ const actions = [];
324
+ const roadmapBefore = readFile(roadmapPath);
325
+ const roadmapAfter = roadmap.setPlanDone(roadmapBefore, id, done);
326
+ const roadmapChanged = roadmapBefore !== roadmapAfter;
327
+ if (roadmapChanged) actions.push({ path: roadmapPath, before: roadmapBefore, after: roadmapAfter });
328
+
329
+ let planChanged = false;
330
+ if (fs.existsSync(phasePlanPath)) {
331
+ const planBefore = readFile(phasePlanPath);
332
+ const planAfter = roadmap.setPlanDone(planBefore, id, done);
333
+ planChanged = planBefore !== planAfter;
334
+ if (planChanged) actions.push({ path: phasePlanPath, before: planBefore, after: planAfter });
335
+ }
336
+
337
+ if (!dryRun) {
338
+ for (const a of actions) writeFile(a.path, a.after);
339
+ }
340
+
341
+ return { actions, roadmapChanged, planChanged, phaseDir };
342
+ }
343
+
344
+ // ---------- scaffold-milestone ----------
345
+
346
+ /**
347
+ * Append a new `### 🚧 <name> (In Progress)` heading inside the `## Phases`
348
+ * H2 section of ROADMAP.md.
349
+ *
350
+ * Refuses if a milestone heading whose stripped name matches `name` already
351
+ * exists (any status — in-progress, planned, or shipped/collapsed).
352
+ *
353
+ * Returns { ok, actions: [{path, before, after}], milestone, dryRun? }.
354
+ *
355
+ * options:
356
+ * - dryRun: boolean
357
+ * - status: 'in-progress' | 'planned' (default 'in-progress')
358
+ */
359
+ function scaffoldMilestone(root, name, options = {}) {
360
+ const { dryRun = false, status = 'in-progress' } = options;
361
+ if (!name || typeof name !== 'string' || !name.trim()) {
362
+ throw new Error('scaffold-milestone: <name> is required.');
363
+ }
364
+ const cleanName = name.trim();
365
+
366
+ const planning = paths.planningDir(root);
367
+ const roadmapPath = path.join(planning, 'ROADMAP.md');
368
+ if (!fs.existsSync(roadmapPath)) {
369
+ throw new Error(`ROADMAP.md not found at ${roadmapPath}. Run \`cp init\` first.`);
370
+ }
371
+ const before = readFile(roadmapPath);
372
+
373
+ // Check for duplicate: look for any existing milestone H3 whose stripped
374
+ // name matches (case-insensitive). Reuse the lib/milestone parser to be
375
+ // exact about what counts.
376
+ const existing = milestone.findMilestoneInRoadmap(before, cleanName);
377
+ if (existing) {
378
+ return {
379
+ ok: false,
380
+ reason: 'milestone-exists',
381
+ milestone: existing.name,
382
+ status: existing.status,
383
+ actions: [],
384
+ };
385
+ }
386
+
387
+ // Locate `## Phases` H2. If missing, surface a clear error — the file is
388
+ // malformed beyond what we should auto-repair.
389
+ const phasesIdx = before.search(/^##\s+Phases\s*$/m);
390
+ if (phasesIdx === -1) {
391
+ throw new Error(
392
+ `ROADMAP.md has no \`## Phases\` section. Re-run \`cp init\` against an empty .planning/ or hand-add the section.`
393
+ );
394
+ }
395
+ // Find the end of the `## Phases` line (newline after it).
396
+ const lineEnd = before.indexOf('\n', phasesIdx);
397
+ const insertAt = lineEnd === -1 ? before.length : lineEnd;
398
+
399
+ const emoji = status === 'planned' ? '📋' : '🚧';
400
+ const suffix = status === 'planned' ? '(Planned)' : '(In Progress)';
401
+ const heading = `\n\n### ${emoji} ${cleanName} ${suffix}\n`;
402
+
403
+ const after = before.slice(0, insertAt) + heading + before.slice(insertAt);
404
+
405
+ // Milestone DESIGN.md (v0.7): persistent milestone-tier design doc.
406
+ const todayStr = (options.today || new Date().toISOString().slice(0, 10));
407
+ const mdSlug = paths.milestoneSlug(cleanName);
408
+ const mdPath = paths.milestoneDesignFile(cleanName, root);
409
+ const designTemplate = paths.readTemplate('DESIGN.md');
410
+ const designRendered = designTemplate
411
+ .replace(/\{\{TIER_KEY\}\}/g, `milestone_slug: "${mdSlug}"`)
412
+ .replace(/\{\{MILESTONE_NAME\}\}/g, cleanName)
413
+ .replace(/\{\{MILESTONE_SLUG\}\}/g, mdSlug)
414
+ .replace(/\{\{PHASE_NUM\}\}/g, '')
415
+ .replace(/\{\{TITLE\}\}/g, cleanName)
416
+ .replace(/\{\{DATE\}\}/g, todayStr);
417
+
418
+ const actions = [
419
+ { path: roadmapPath, before, after, kind: 'write' },
420
+ { path: mdPath, before: null, after: designRendered, kind: 'write' },
421
+ ];
422
+ if (!dryRun) {
423
+ fs.mkdirSync(path.dirname(mdPath), { recursive: true });
424
+ for (const a of actions) writeFile(a.path, a.after);
425
+ }
426
+ return { ok: true, milestone: cleanName, status, actions, dryRun: dryRun || undefined };
427
+ }
428
+
429
+ // ---------- scaffold-phase ----------
430
+
431
+ /**
432
+ * Add a new `### Phase N: <name>` heading inside the active milestone block
433
+ * in ROADMAP.md and create `.planning/phases/{NN-slug}/PLAN.md` from the
434
+ * phase-PLAN template.
435
+ *
436
+ * Returns { ok, actions, phaseDir, plans: ['NN-01', ...], dryRun? }.
437
+ *
438
+ * options:
439
+ * - dryRun: boolean
440
+ * - name: string (required) — human phase name
441
+ * - plans: number (default 0) — pre-fill N empty plan checkboxes
442
+ * - milestone: string (optional) — match a specific milestone by name;
443
+ * otherwise the first in-progress milestone is used
444
+ * - today: ISO date (for tests)
445
+ */
446
+ function scaffoldPhase(root, phaseNum, options = {}) {
447
+ const { dryRun = false, name, plans = 0, milestone: milestoneName, today: todayIso } = options;
448
+ if (!name || typeof name !== 'string' || !name.trim()) {
449
+ throw new Error('scaffold-phase: --name <name> is required.');
450
+ }
451
+ const cleanName = name.trim();
452
+ const numStr = String(phaseNum);
453
+ if (!/^\d+(\.\d+)?$/.test(numStr)) {
454
+ throw new Error(`scaffold-phase: phase number must be an integer or decimal (e.g. "2" or "2.1"), got "${numStr}".`);
455
+ }
456
+
457
+ const planning = paths.planningDir(root);
458
+ const roadmapPath = path.join(planning, 'ROADMAP.md');
459
+ if (!fs.existsSync(roadmapPath)) {
460
+ throw new Error(`ROADMAP.md not found at ${roadmapPath}. Run \`cp init\` first.`);
461
+ }
462
+ const roadmapBefore = readFile(roadmapPath);
463
+
464
+ // Refuse if a phase dir already exists for this number.
465
+ if (paths.findPhaseDir(numStr, root)) {
466
+ return {
467
+ ok: false,
468
+ reason: 'phase-exists',
469
+ phaseDir: paths.findPhaseDir(numStr, root),
470
+ actions: [],
471
+ };
472
+ }
473
+
474
+ // Resolve the milestone whose block we'll insert into.
475
+ // Locate `## Phases` section and enumerate H3 milestone headings.
476
+ const phasesIdx = roadmapBefore.search(/^##\s+Phases\s*$/m);
477
+ if (phasesIdx === -1) {
478
+ throw new Error(
479
+ `ROADMAP.md has no \`## Phases\` section. Run \`cp scaffold-milestone <name>\` first.`
480
+ );
481
+ }
482
+ const after = roadmapBefore.slice(phasesIdx);
483
+ const nextH2 = after.slice(2).search(/^##\s+/m);
484
+ const phasesSection = nextH2 === -1 ? after : after.slice(0, nextH2 + 2);
485
+ const phasesSectionStart = phasesIdx;
486
+ const phasesSectionEnd = phasesSectionStart + phasesSection.length;
487
+
488
+ // Enumerate H3 headings *within* `## Phases`.
489
+ const reH3 = /^###\s+(.*)$/gm;
490
+ const headings = [];
491
+ let m;
492
+ while ((m = reH3.exec(phasesSection)) !== null) {
493
+ headings.push({
494
+ text: m[1].trim(),
495
+ absStart: phasesSectionStart + m.index,
496
+ });
497
+ }
498
+ function isPhaseHeading(t) { return /^Phase\s+[\d.]+:/i.test(t); }
499
+ function milestoneStatus(t) {
500
+ if (/\u2705/.test(t) || /shipped/i.test(t)) return 'shipped';
501
+ if (/\uD83D\uDEA7/.test(t) || /in\s*progress/i.test(t)) return 'in-progress';
502
+ if (/\uD83D\uDCCB/.test(t) || /planned/i.test(t)) return 'planned';
503
+ return null;
504
+ }
505
+ function stripDecor(t) {
506
+ return t
507
+ .replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}\u2705\u2611\u2713\u2714\u2728]+/u, '')
508
+ .replace(/\s*\((?:In Progress|Shipped[^)]*|Planned)\)\s*$/i, '')
509
+ .trim();
510
+ }
511
+
512
+ // Find the active milestone heading (or matching by name) and the next
513
+ // milestone H3 (which serves as the insertion boundary).
514
+ let activeIdx = -1;
515
+ let activeName = null;
516
+ for (let i = 0; i < headings.length; i++) {
517
+ const h = headings[i];
518
+ if (isPhaseHeading(h.text)) continue;
519
+ const status = milestoneStatus(h.text);
520
+ if (!status) continue;
521
+ const cleanH = stripDecor(h.text);
522
+ if (milestoneName) {
523
+ if (cleanH.toLowerCase().includes(milestoneName.toLowerCase())) {
524
+ activeIdx = i;
525
+ activeName = cleanH;
526
+ break;
527
+ }
528
+ } else if (status === 'in-progress' && activeIdx === -1) {
529
+ activeIdx = i;
530
+ activeName = cleanH;
531
+ // don't break — but prefer FIRST in-progress milestone
532
+ break;
533
+ }
534
+ }
535
+ if (activeIdx === -1) {
536
+ return {
537
+ ok: false,
538
+ reason: milestoneName ? 'milestone-not-found' : 'no-active-milestone',
539
+ milestone: milestoneName || null,
540
+ actions: [],
541
+ };
542
+ }
543
+
544
+ // Determine insertion point: end of the active milestone's block —
545
+ // i.e., just before the next milestone H3, or end of `## Phases` section.
546
+ let insertAt = phasesSectionEnd;
547
+ for (let j = activeIdx + 1; j < headings.length; j++) {
548
+ if (!isPhaseHeading(headings[j].text) && milestoneStatus(headings[j].text)) {
549
+ insertAt = headings[j].absStart;
550
+ break;
551
+ }
552
+ }
553
+ // Trim trailing blank lines from the slice before insertion so we don't
554
+ // pile up newlines.
555
+ let leftSlice = roadmapBefore.slice(0, insertAt);
556
+ let rightSlice = roadmapBefore.slice(insertAt);
557
+ // Ensure single blank line before the new H3.
558
+ leftSlice = leftSlice.replace(/\n+$/, '\n');
559
+
560
+ // Build the new phase block.
561
+ const padded = paths.padPhaseNum(numStr);
562
+ const planLines = [];
563
+ for (let i = 1; i <= Math.max(0, plans); i++) {
564
+ const pp = paths.padPlanNum(i);
565
+ planLines.push(`- [ ] ${padded}-${pp}: TBD`);
566
+ }
567
+ const planSection = plans > 0 ? `\nPlans:\n${planLines.join('\n')}\n` : '';
568
+ const phaseBlock = `\n### Phase ${numStr}: ${cleanName}\n${planSection}`;
569
+
570
+ const roadmapAfter = leftSlice + phaseBlock + rightSlice;
571
+
572
+ // Create the phase PLAN.md from template.
573
+ const phaseDirPath = paths.phaseDir(numStr, cleanName, root);
574
+ const planPath = path.join(phaseDirPath, 'PLAN.md');
575
+
576
+ const planTemplate = paths.readTemplate('phase-PLAN.md');
577
+ const todayStr = todayIso || new Date().toISOString().slice(0, 10);
578
+ const planChecklist = plans > 0
579
+ ? planLines.map((l) => l.replace(/: TBD$/, ': {brief description}')).join('\n')
580
+ : '<!-- No plans yet. Add via `cp scaffold-plan` (coming in v0.4) or edit by hand. -->';
581
+ const planRendered = planTemplate
582
+ .replace(/\{\{PHASE_NUM\}\}/g, numStr)
583
+ .replace(/\{\{PHASE_NAME\}\}/g, cleanName)
584
+ .replace(/\{\{MILESTONE_NAME\}\}/g, activeName)
585
+ .replace(/\{\{DATE\}\}/g, todayStr)
586
+ .replace(/\{\{PLANS_LIST\}\}/g, planChecklist);
587
+
588
+ // DESIGN.md (v0.7): persistent phase-tier design doc, populated by SP
589
+ // brainstorming during /cp-plan-phase Step 3.5.
590
+ const designPath = path.join(phaseDirPath, 'DESIGN.md');
591
+ const designTemplate = paths.readTemplate('DESIGN.md');
592
+ const designRendered = designTemplate
593
+ .replace(/\{\{TIER_KEY\}\}/g, `phase: "${numStr}"`)
594
+ .replace(/\{\{MILESTONE_NAME\}\}/g, activeName)
595
+ .replace(/\{\{MILESTONE_SLUG\}\}/g, paths.milestoneSlug(activeName))
596
+ .replace(/\{\{PHASE_NUM\}\}/g, numStr)
597
+ .replace(/\{\{TITLE\}\}/g, `Phase ${numStr}: ${cleanName}`)
598
+ .replace(/\{\{DATE\}\}/g, todayStr);
599
+
600
+ const reviewLogPath = path.join(phaseDirPath, 'REVIEW-LOG.md');
601
+ const reviewTemplate = paths.readTemplate('REVIEW-LOG.md');
602
+ const reviewRendered = reviewTemplate
603
+ .replace(/\{\{PHASE_NUM\}\}/g, numStr)
604
+ .replace(/\{\{MILESTONE_NAME\}\}/g, activeName)
605
+ .replace(/\{\{TITLE\}\}/g, cleanName)
606
+ .replace(/\{\{DATE\}\}/g, todayStr);
607
+
608
+ const actions = [
609
+ { path: roadmapPath, before: roadmapBefore, after: roadmapAfter, kind: 'write' },
610
+ { path: planPath, before: null, after: planRendered, kind: 'write' },
611
+ { path: designPath, before: null, after: designRendered, kind: 'write' },
612
+ { path: reviewLogPath, before: null, after: reviewRendered, kind: 'write' },
613
+ ];
614
+
615
+ if (!dryRun) {
616
+ for (const a of actions) writeFile(a.path, a.after);
617
+ }
618
+ return {
619
+ ok: true,
620
+ phaseDir: phaseDirPath,
621
+ phaseNum: numStr,
622
+ milestone: activeName,
623
+ plans: planLines.map((l) => l.match(/^- \[ \] (\S+):/)[1]),
624
+ actions,
625
+ dryRun: dryRun || undefined,
626
+ };
627
+ }
628
+
629
+ // ---------- write-summary ----------
630
+
631
+ const SUMMARY_FIELDS = {
632
+ // Top-level keys aggregateSummaries reads (with the names it actually
633
+ // accepts — some have hyphen/underscore aliases).
634
+ scalar: ['phase', 'plan', 'completed', 'subsystem', 'duration'],
635
+ arrays: ['tags', 'requires', 'provides', 'affects'],
636
+ // requirements-completed / key-decisions / patterns-established
637
+ // are also arrays but with the kebab-case name preserred.
638
+ kebabArrays: ['requirements-completed', 'key-decisions', 'patterns-established'],
639
+ // nested objects with `created` / `modified` arrays
640
+ fileBuckets: 'key-files',
641
+ };
642
+
643
+ function _normaliseSummary(input) {
644
+ // Accept either kebab-case or snake_case keys; normalise to the kebab-case
645
+ // names that aggregateSummaries reads first.
646
+ const out = {};
647
+ const aliases = {
648
+ subsystems: 'subsystem', // common typo — collapse first one
649
+ files_created: ['key-files', 'created'],
650
+ files_modified: ['key-files', 'modified'],
651
+ requirements_completed: 'requirements-completed',
652
+ key_decisions: 'key-decisions',
653
+ patterns_established: 'patterns-established',
654
+ tech_stack: 'tech-stack',
655
+ };
656
+ for (const [k, v] of Object.entries(input)) {
657
+ const target = aliases[k];
658
+ if (Array.isArray(target)) {
659
+ out[target[0]] = out[target[0]] || {};
660
+ out[target[0]][target[1]] = v;
661
+ } else if (typeof target === 'string') {
662
+ // Collapse arrays-of-strings into the singular if needed.
663
+ if (target === 'subsystem' && Array.isArray(v)) out.subsystem = v[0];
664
+ else out[target] = v;
665
+ } else {
666
+ out[k] = v;
667
+ }
668
+ }
669
+ return out;
670
+ }
671
+
672
+ /**
673
+ * Write a phase plan summary at the correct path with validated/normalised
674
+ * frontmatter.
675
+ *
676
+ * Returns { path, action: 'written'|'dryrun' }.
677
+ *
678
+ * options:
679
+ * - dryRun: boolean
680
+ * - body: string (markdown body; defaults to a one-liner)
681
+ * - overwrite: boolean (default false)
682
+ */
683
+ function writeSummary(root, planId, summaryData, options = {}) {
684
+ const { dryRun = false, body, overwrite = false } = options;
685
+ const { phaseNum, id } = parsePlanId(planId);
686
+
687
+ const phaseDir = paths.findPhaseDir(phaseNum, root);
688
+ if (!phaseDir) {
689
+ throw new Error(`Phase ${phaseNum} dir not found. Run \`/cp-plan-phase ${phaseNum}\` first.`);
690
+ }
691
+ const summaryPath = path.join(phaseDir, `${id}-SUMMARY.md`);
692
+ if (fs.existsSync(summaryPath) && !overwrite) {
693
+ throw new Error(`${summaryPath} already exists. Pass overwrite:true to replace.`);
694
+ }
695
+
696
+ const normalised = _normaliseSummary(summaryData);
697
+ // Backfill phase/plan if missing.
698
+ if (!('phase' in normalised)) normalised.phase = parseInt(phaseNum, 10);
699
+ if (!('plan' in normalised)) normalised.plan = id;
700
+ if (!('completed' in normalised)) normalised.completed = new Date().toISOString().slice(0, 10);
701
+
702
+ const text = fm.stringify(normalised, body || `# Summary ${id}\n\nPlan ${id} completed.\n`);
703
+ if (!dryRun) writeFile(summaryPath, text);
704
+ return { path: summaryPath, action: dryRun ? 'dryrun' : 'written', fm: normalised };
705
+ }
706
+
707
+ // ---------- status ----------
708
+
709
+ /**
710
+ * Produce a "you are here" report by reading ROADMAP + STATE + on-disk
711
+ * phase dirs. Returns a plain object — caller decides how to render.
712
+ */
713
+ function statusReport(root) {
714
+ const planning = paths.planningDir(root);
715
+ const roadmapPath = path.join(planning, 'ROADMAP.md');
716
+ if (!fs.existsSync(roadmapPath)) {
717
+ return { ok: false, error: '.planning/ROADMAP.md missing — run `cp init` first.' };
718
+ }
719
+ const roadmapContent = readFile(roadmapPath);
720
+ const statePath = path.join(planning, 'STATE.md');
721
+ const stateContent = fs.existsSync(statePath) ? readFile(statePath) : null;
722
+
723
+ // Find the *current* milestone (the first non-collapsed `### ... (In Progress)` heading).
724
+ // Fall back to first milestone of any status.
725
+ const phasesIdx = roadmapContent.search(/^##\s+Phases\s*$/m);
726
+ const after = phasesIdx === -1 ? roadmapContent : roadmapContent.slice(phasesIdx);
727
+ const milestoneHeadings = [];
728
+ const reM = /^###\s+(?!Phase\s+[\d.]+:)(.+?)$/gm;
729
+ let mm;
730
+ while ((mm = reM.exec(after)) !== null) {
731
+ milestoneHeadings.push(mm[1].trim());
732
+ }
733
+ // Prefer "In Progress" status; otherwise first.
734
+ let currentMilestoneName = null;
735
+ for (const h of milestoneHeadings) {
736
+ if (/in\s*progress/i.test(h) || /\uD83D\uDEA7/.test(h)) {
737
+ currentMilestoneName = h
738
+ .replace(/^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}\u2705\u2611\u2713\u2714\u2728]+/u, '')
739
+ .replace(/\s*\((?:In Progress|Shipped[^)]*|Planned)\)\s*$/i, '')
740
+ .trim();
741
+ break;
742
+ }
743
+ }
744
+ const milestoneInfo = currentMilestoneName
745
+ ? milestone.findMilestoneInRoadmap(roadmapContent, currentMilestoneName)
746
+ : null;
747
+
748
+ const allPhases = roadmap.listPhases(roadmapContent);
749
+ const phases = milestoneInfo
750
+ ? allPhases.filter(p => milestoneInfo.phases.includes(p.num))
751
+ : allPhases;
752
+
753
+ // Next plan: first plan whose checkbox is unticked.
754
+ let nextPlan = null;
755
+ for (const ph of phases) {
756
+ const pend = ph.plans.find(pl => !pl.done);
757
+ if (pend) { nextPlan = { phase: ph, plan: pend }; break; }
758
+ }
759
+
760
+ const planCounts = phases.map(p => ({
761
+ num: p.num,
762
+ name: p.name,
763
+ done: p.plans.filter(x => x.done).length,
764
+ total: p.plans.length,
765
+ }));
766
+
767
+ return {
768
+ ok: true,
769
+ milestone: currentMilestoneName,
770
+ milestoneStatus: milestoneInfo ? milestoneInfo.status : null,
771
+ phases: planCounts,
772
+ nextPlan: nextPlan
773
+ ? { phaseNum: nextPlan.phase.num, phaseName: nextPlan.phase.name, planId: nextPlan.plan.id, desc: nextPlan.plan.desc }
774
+ : null,
775
+ stateContentPresent: !!stateContent,
776
+ };
777
+ }
778
+
779
+ // ---------- complete-milestone ----------
780
+
781
+ /**
782
+ * Full milestone close-out:
783
+ * 1. Find milestone (by name or current)
784
+ * 2. verifyMilestoneComplete — fail with structured error if not done
785
+ * 3. readSummaries → aggregateSummaries → renderDigest → appendToMilestonesMd
786
+ * 4. collapseMilestoneInRoadmap
787
+ * 5. delete MILESTONE-CONTEXT.md if present
788
+ * 6. reset STATE (Current Position → idle)
789
+ * 7. (optional) git commit
790
+ *
791
+ * Returns: {
792
+ * ok, reason?, milestone, phases, actions: [{path, kind: 'write'|'delete'|'skip'}],
793
+ * commit?, verify
794
+ * }
795
+ *
796
+ * options:
797
+ * - name: explicit milestone name override (default: current in-progress)
798
+ * - dryRun: boolean
799
+ * - noCommit: boolean (default false)
800
+ * - today: ISO date string (defaults to today)
801
+ */
802
+ function completeMilestone(root, options = {}) {
803
+ const { name, dryRun = false, noCommit = false } = options;
804
+ const today = options.today || new Date().toISOString().slice(0, 10);
805
+
806
+ const planning = paths.planningDir(root);
807
+ const roadmapPath = path.join(planning, 'ROADMAP.md');
808
+ const milestonesPath = path.join(planning, 'MILESTONES.md');
809
+ const milestoneContextPath = path.join(planning, 'MILESTONE-CONTEXT.md');
810
+ const statePath = path.join(planning, 'STATE.md');
811
+
812
+ if (!fs.existsSync(roadmapPath)) {
813
+ return { ok: false, reason: 'roadmap-missing', actions: [] };
814
+ }
815
+
816
+ // Resolve milestone name.
817
+ let milestoneName = name;
818
+ if (!milestoneName) {
819
+ const status = statusReport(root);
820
+ milestoneName = status.milestone;
821
+ }
822
+ if (!milestoneName) {
823
+ return { ok: false, reason: 'no-current-milestone', actions: [], hint: 'Pass --name "v0.1 MVP" explicitly.' };
824
+ }
825
+
826
+ const roadmapContent = readFile(roadmapPath);
827
+ const found = milestone.findMilestoneInRoadmap(roadmapContent, milestoneName);
828
+ if (!found) {
829
+ return { ok: false, reason: 'milestone-not-found', actions: [], milestone: milestoneName };
830
+ }
831
+
832
+ // Verify all phases done + summaries present.
833
+ const verify = milestone.verifyMilestoneComplete(roadmapContent, found.phases, root);
834
+ if (!verify.ok) {
835
+ return { ok: false, reason: 'incomplete', milestone: found.name, phases: found.phases, verify, actions: [] };
836
+ }
837
+
838
+ // Aggregate.
839
+ const summaries = milestone.readSummaries(found.phases, root);
840
+ const agg = milestone.aggregateSummaries(summaries);
841
+
842
+ // Phase-name map for digest.
843
+ const allPhases = roadmap.listPhases(roadmapContent);
844
+ const phaseNames = {};
845
+ for (const p of allPhases) if (found.phases.includes(p.num)) phaseNames[p.num] = p.name;
846
+
847
+ const digest = milestone.renderDigest(found.name, today, found.phases, agg, phaseNames);
848
+
849
+ const actions = [];
850
+
851
+ // 1. Append digest to MILESTONES.md
852
+ const existing = fs.existsSync(milestonesPath)
853
+ ? readFile(milestonesPath)
854
+ : '# Completed Milestones\n\n';
855
+ const newMilestones = milestone.appendToMilestonesMd(existing, digest);
856
+ actions.push({ path: milestonesPath, kind: 'write', after: newMilestones, label: 'append-digest' });
857
+
858
+ // 2. Collapse milestone in ROADMAP.md
859
+ const collapsed = milestone.collapseMilestoneInRoadmap(roadmapContent, found.name, today);
860
+ if (collapsed.changed) {
861
+ actions.push({ path: roadmapPath, kind: 'write', after: collapsed.content, label: 'collapse-milestone' });
862
+ } else {
863
+ actions.push({ path: roadmapPath, kind: 'skip', label: 'collapse-milestone', reason: collapsed.reason });
864
+ }
865
+
866
+ // 3. Promote MILESTONE-CONTEXT.md into the milestone DESIGN.md, then delete.
867
+ const promotion = milestone.promoteMilestoneContext(root, milestoneName, { today });
868
+ if (promotion) {
869
+ actions.push({ path: promotion.path, before: null, after: promotion.after, kind: 'write' });
870
+ actions.push({ path: promotion.contextPath, kind: 'delete' });
871
+ } else if (fs.existsSync(milestoneContextPath)) {
872
+ actions.push({ path: milestoneContextPath, kind: 'delete' });
873
+ }
874
+
875
+ // 4. Reset STATE: Current Position → idle
876
+ if (fs.existsSync(statePath)) {
877
+ const stateBefore = readFile(statePath);
878
+ let stateAfter = state.updatePosition(stateBefore, {
879
+ phase: '0 (ready for next milestone)',
880
+ plan: '0',
881
+ status: 'Idle',
882
+ lastActivity: `shipped ${found.name}`,
883
+ date: today,
884
+ });
885
+ stateAfter = state.updateProgressBar(stateAfter, 0);
886
+ stateAfter = state.updateSessionContinuity(stateAfter, {
887
+ date: today,
888
+ stoppedAt: `shipped ${found.name}`,
889
+ resumeFile: 'None',
890
+ });
891
+ if (stateAfter !== stateBefore) {
892
+ actions.push({ path: statePath, kind: 'write', after: stateAfter, label: 'reset-state' });
893
+ }
894
+ }
895
+
896
+ if (dryRun) {
897
+ return { ok: true, milestone: found.name, phases: found.phases, agg, verify, actions, dryRun: true };
898
+ }
899
+
900
+ // Apply actions transactionally: writes-as-temps first, then renames, then
901
+ // deletes. Guarantees the destructive `delete MILESTONE-CONTEXT.md` never
902
+ // runs before its replacement state (MILESTONES.md / ROADMAP.md / STATE.md)
903
+ // is durable on disk. v0.3.2 — closes the multi-file inconsistency gap.
904
+ writeBatch(actions);
905
+
906
+ // Commit. Scope staging to exactly the files this op touched.
907
+ let commit = null;
908
+ if (!noCommit) {
909
+ commit = gitCommit(root, `cp: /cp-complete-milestone ${found.name}`, {
910
+ paths: pathsFromActions(actions),
911
+ });
912
+ }
913
+
914
+ return { ok: true, milestone: found.name, phases: found.phases, agg, verify, actions, commit };
915
+ }
916
+
917
+ module.exports = {
918
+ parsePlanId,
919
+ gitCommit,
920
+ pathsFromActions,
921
+ writeFile,
922
+ writeBatch,
923
+ tickPlan,
924
+ writeSummary,
925
+ statusReport,
926
+ completeMilestone,
927
+ scaffoldMilestone,
928
+ scaffoldPhase,
929
+ };