gsd-opencode 1.22.1 → 1.33.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 (188) hide show
  1. package/agents/gsd-advisor-researcher.md +112 -0
  2. package/agents/gsd-assumptions-analyzer.md +110 -0
  3. package/agents/gsd-codebase-mapper.md +0 -2
  4. package/agents/gsd-debugger.md +117 -2
  5. package/agents/gsd-doc-verifier.md +207 -0
  6. package/agents/gsd-doc-writer.md +608 -0
  7. package/agents/gsd-executor.md +45 -4
  8. package/agents/gsd-integration-checker.md +0 -2
  9. package/agents/gsd-nyquist-auditor.md +0 -2
  10. package/agents/gsd-phase-researcher.md +191 -5
  11. package/agents/gsd-plan-checker.md +152 -5
  12. package/agents/gsd-planner.md +131 -157
  13. package/agents/gsd-project-researcher.md +28 -3
  14. package/agents/gsd-research-synthesizer.md +0 -2
  15. package/agents/gsd-roadmapper.md +29 -2
  16. package/agents/gsd-security-auditor.md +129 -0
  17. package/agents/gsd-ui-auditor.md +485 -0
  18. package/agents/gsd-ui-checker.md +305 -0
  19. package/agents/gsd-ui-researcher.md +368 -0
  20. package/agents/gsd-user-profiler.md +173 -0
  21. package/agents/gsd-verifier.md +207 -22
  22. package/commands/gsd/gsd-add-backlog.md +76 -0
  23. package/commands/gsd/gsd-analyze-dependencies.md +34 -0
  24. package/commands/gsd/gsd-audit-uat.md +24 -0
  25. package/commands/gsd/gsd-autonomous.md +45 -0
  26. package/commands/gsd/gsd-cleanup.md +5 -0
  27. package/commands/gsd/gsd-debug.md +29 -21
  28. package/commands/gsd/gsd-discuss-phase.md +15 -36
  29. package/commands/gsd/gsd-do.md +30 -0
  30. package/commands/gsd/gsd-docs-update.md +48 -0
  31. package/commands/gsd/gsd-execute-phase.md +24 -2
  32. package/commands/gsd/gsd-fast.md +30 -0
  33. package/commands/gsd/gsd-forensics.md +56 -0
  34. package/commands/gsd/gsd-help.md +2 -0
  35. package/commands/gsd/gsd-join-discord.md +2 -1
  36. package/commands/gsd/gsd-list-workspaces.md +19 -0
  37. package/commands/gsd/gsd-manager.md +40 -0
  38. package/commands/gsd/gsd-milestone-summary.md +51 -0
  39. package/commands/gsd/gsd-new-project.md +4 -0
  40. package/commands/gsd/gsd-new-workspace.md +44 -0
  41. package/commands/gsd/gsd-next.md +24 -0
  42. package/commands/gsd/gsd-note.md +34 -0
  43. package/commands/gsd/gsd-plan-phase.md +8 -1
  44. package/commands/gsd/gsd-plant-seed.md +28 -0
  45. package/commands/gsd/gsd-pr-branch.md +25 -0
  46. package/commands/gsd/gsd-profile-user.md +46 -0
  47. package/commands/gsd/gsd-quick.md +7 -3
  48. package/commands/gsd/gsd-reapply-patches.md +178 -45
  49. package/commands/gsd/gsd-remove-workspace.md +26 -0
  50. package/commands/gsd/gsd-research-phase.md +7 -12
  51. package/commands/gsd/gsd-review-backlog.md +62 -0
  52. package/commands/gsd/gsd-review.md +38 -0
  53. package/commands/gsd/gsd-secure-phase.md +35 -0
  54. package/commands/gsd/gsd-session-report.md +19 -0
  55. package/commands/gsd/gsd-set-profile.md +24 -23
  56. package/commands/gsd/gsd-ship.md +23 -0
  57. package/commands/gsd/gsd-stats.md +18 -0
  58. package/commands/gsd/gsd-thread.md +127 -0
  59. package/commands/gsd/gsd-ui-phase.md +34 -0
  60. package/commands/gsd/gsd-ui-review.md +32 -0
  61. package/commands/gsd/gsd-workstreams.md +71 -0
  62. package/get-shit-done/bin/gsd-tools.cjs +450 -90
  63. package/get-shit-done/bin/lib/commands.cjs +489 -24
  64. package/get-shit-done/bin/lib/config.cjs +329 -48
  65. package/get-shit-done/bin/lib/core.cjs +1143 -102
  66. package/get-shit-done/bin/lib/docs.cjs +267 -0
  67. package/get-shit-done/bin/lib/frontmatter.cjs +125 -43
  68. package/get-shit-done/bin/lib/init.cjs +918 -106
  69. package/get-shit-done/bin/lib/milestone.cjs +65 -33
  70. package/get-shit-done/bin/lib/model-profiles.cjs +70 -0
  71. package/get-shit-done/bin/lib/phase.cjs +434 -404
  72. package/get-shit-done/bin/lib/profile-output.cjs +1048 -0
  73. package/get-shit-done/bin/lib/profile-pipeline.cjs +539 -0
  74. package/get-shit-done/bin/lib/roadmap.cjs +156 -101
  75. package/get-shit-done/bin/lib/schema-detect.cjs +238 -0
  76. package/get-shit-done/bin/lib/security.cjs +384 -0
  77. package/get-shit-done/bin/lib/state.cjs +711 -79
  78. package/get-shit-done/bin/lib/template.cjs +2 -2
  79. package/get-shit-done/bin/lib/uat.cjs +282 -0
  80. package/get-shit-done/bin/lib/verify.cjs +254 -42
  81. package/get-shit-done/bin/lib/workstream.cjs +495 -0
  82. package/get-shit-done/references/agent-contracts.md +79 -0
  83. package/get-shit-done/references/artifact-types.md +113 -0
  84. package/get-shit-done/references/checkpoints.md +12 -10
  85. package/get-shit-done/references/context-budget.md +49 -0
  86. package/get-shit-done/references/continuation-format.md +15 -15
  87. package/get-shit-done/references/decimal-phase-calculation.md +2 -3
  88. package/get-shit-done/references/domain-probes.md +125 -0
  89. package/get-shit-done/references/gate-prompts.md +100 -0
  90. package/get-shit-done/references/git-integration.md +47 -0
  91. package/get-shit-done/references/model-profile-resolution.md +2 -0
  92. package/get-shit-done/references/model-profiles.md +62 -16
  93. package/get-shit-done/references/phase-argument-parsing.md +2 -2
  94. package/get-shit-done/references/planner-gap-closure.md +62 -0
  95. package/get-shit-done/references/planner-reviews.md +39 -0
  96. package/get-shit-done/references/planner-revision.md +87 -0
  97. package/get-shit-done/references/planning-config.md +18 -1
  98. package/get-shit-done/references/revision-loop.md +97 -0
  99. package/get-shit-done/references/ui-brand.md +2 -2
  100. package/get-shit-done/references/universal-anti-patterns.md +58 -0
  101. package/get-shit-done/references/user-profiling.md +681 -0
  102. package/get-shit-done/references/workstream-flag.md +111 -0
  103. package/get-shit-done/templates/SECURITY.md +61 -0
  104. package/get-shit-done/templates/UAT.md +21 -3
  105. package/get-shit-done/templates/UI-SPEC.md +100 -0
  106. package/get-shit-done/templates/VALIDATION.md +3 -3
  107. package/get-shit-done/templates/claude-md.md +145 -0
  108. package/get-shit-done/templates/config.json +14 -3
  109. package/get-shit-done/templates/context.md +61 -6
  110. package/get-shit-done/templates/debug-subagent-prompt.md +2 -6
  111. package/get-shit-done/templates/dev-preferences.md +21 -0
  112. package/get-shit-done/templates/discussion-log.md +63 -0
  113. package/get-shit-done/templates/phase-prompt.md +46 -5
  114. package/get-shit-done/templates/planner-subagent-prompt.md +2 -10
  115. package/get-shit-done/templates/project.md +2 -0
  116. package/get-shit-done/templates/state.md +2 -2
  117. package/get-shit-done/templates/user-profile.md +146 -0
  118. package/get-shit-done/workflows/add-phase.md +4 -4
  119. package/get-shit-done/workflows/add-tests.md +4 -4
  120. package/get-shit-done/workflows/add-todo.md +4 -4
  121. package/get-shit-done/workflows/analyze-dependencies.md +96 -0
  122. package/get-shit-done/workflows/audit-milestone.md +20 -16
  123. package/get-shit-done/workflows/audit-uat.md +109 -0
  124. package/get-shit-done/workflows/autonomous.md +1036 -0
  125. package/get-shit-done/workflows/check-todos.md +4 -4
  126. package/get-shit-done/workflows/cleanup.md +4 -4
  127. package/get-shit-done/workflows/complete-milestone.md +22 -10
  128. package/get-shit-done/workflows/diagnose-issues.md +21 -7
  129. package/get-shit-done/workflows/discovery-phase.md +2 -2
  130. package/get-shit-done/workflows/discuss-phase-assumptions.md +671 -0
  131. package/get-shit-done/workflows/discuss-phase-power.md +291 -0
  132. package/get-shit-done/workflows/discuss-phase.md +558 -47
  133. package/get-shit-done/workflows/do.md +104 -0
  134. package/get-shit-done/workflows/docs-update.md +1093 -0
  135. package/get-shit-done/workflows/execute-phase.md +741 -58
  136. package/get-shit-done/workflows/execute-plan.md +77 -12
  137. package/get-shit-done/workflows/fast.md +105 -0
  138. package/get-shit-done/workflows/forensics.md +265 -0
  139. package/get-shit-done/workflows/health.md +28 -6
  140. package/get-shit-done/workflows/help.md +127 -7
  141. package/get-shit-done/workflows/insert-phase.md +4 -4
  142. package/get-shit-done/workflows/list-phase-assumptions.md +2 -2
  143. package/get-shit-done/workflows/list-workspaces.md +56 -0
  144. package/get-shit-done/workflows/manager.md +363 -0
  145. package/get-shit-done/workflows/map-codebase.md +83 -44
  146. package/get-shit-done/workflows/milestone-summary.md +223 -0
  147. package/get-shit-done/workflows/new-milestone.md +133 -25
  148. package/get-shit-done/workflows/new-project.md +216 -54
  149. package/get-shit-done/workflows/new-workspace.md +237 -0
  150. package/get-shit-done/workflows/next.md +97 -0
  151. package/get-shit-done/workflows/node-repair.md +92 -0
  152. package/get-shit-done/workflows/note.md +156 -0
  153. package/get-shit-done/workflows/pause-work.md +132 -15
  154. package/get-shit-done/workflows/plan-milestone-gaps.md +6 -7
  155. package/get-shit-done/workflows/plan-phase.md +513 -62
  156. package/get-shit-done/workflows/plant-seed.md +169 -0
  157. package/get-shit-done/workflows/pr-branch.md +129 -0
  158. package/get-shit-done/workflows/profile-user.md +450 -0
  159. package/get-shit-done/workflows/progress.md +154 -29
  160. package/get-shit-done/workflows/quick.md +285 -111
  161. package/get-shit-done/workflows/remove-phase.md +2 -2
  162. package/get-shit-done/workflows/remove-workspace.md +90 -0
  163. package/get-shit-done/workflows/research-phase.md +13 -9
  164. package/get-shit-done/workflows/resume-project.md +37 -18
  165. package/get-shit-done/workflows/review.md +281 -0
  166. package/get-shit-done/workflows/secure-phase.md +154 -0
  167. package/get-shit-done/workflows/session-report.md +146 -0
  168. package/get-shit-done/workflows/set-profile.md +2 -2
  169. package/get-shit-done/workflows/settings.md +91 -11
  170. package/get-shit-done/workflows/ship.md +237 -0
  171. package/get-shit-done/workflows/stats.md +60 -0
  172. package/get-shit-done/workflows/transition.md +150 -23
  173. package/get-shit-done/workflows/ui-phase.md +292 -0
  174. package/get-shit-done/workflows/ui-review.md +183 -0
  175. package/get-shit-done/workflows/update.md +262 -30
  176. package/get-shit-done/workflows/validate-phase.md +14 -17
  177. package/get-shit-done/workflows/verify-phase.md +143 -11
  178. package/get-shit-done/workflows/verify-work.md +141 -39
  179. package/package.json +1 -1
  180. package/skills/gsd-audit-milestone/SKILL.md +29 -0
  181. package/skills/gsd-cleanup/SKILL.md +19 -0
  182. package/skills/gsd-complete-milestone/SKILL.md +131 -0
  183. package/skills/gsd-discuss-phase/SKILL.md +54 -0
  184. package/skills/gsd-execute-phase/SKILL.md +49 -0
  185. package/skills/gsd-plan-phase/SKILL.md +37 -0
  186. package/skills/gsd-ui-phase/SKILL.md +24 -0
  187. package/skills/gsd-ui-review/SKILL.md +24 -0
  188. package/skills/gsd-verify-work/SKILL.md +30 -0
@@ -4,12 +4,12 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error } = require('./core.cjs');
7
+ const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories, phaseTokenMatches } = require('./core.cjs');
8
8
  const { extractFrontmatter } = require('./frontmatter.cjs');
9
- const { writeStateMd } = require('./state.cjs');
9
+ const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, updatePerformanceMetricsSection } = require('./state.cjs');
10
10
 
11
11
  function cmdPhasesList(cwd, options, raw) {
12
- const phasesDir = path.join(cwd, '.planning', 'phases');
12
+ const phasesDir = path.join(planningDir(cwd), 'phases');
13
13
  const { type, phase, includeArchived } = options;
14
14
 
15
15
  // If no phases directory, return empty
@@ -41,7 +41,7 @@ function cmdPhasesList(cwd, options, raw) {
41
41
  // If filtering by phase number
42
42
  if (phase) {
43
43
  const normalized = normalizePhaseName(phase);
44
- const match = dirs.find(d => d.startsWith(normalized));
44
+ const match = dirs.find(d => phaseTokenMatches(d, normalized));
45
45
  if (!match) {
46
46
  output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
47
47
  return;
@@ -85,7 +85,7 @@ function cmdPhasesList(cwd, options, raw) {
85
85
  }
86
86
 
87
87
  function cmdPhaseNextDecimal(cwd, basePhase, raw) {
88
- const phasesDir = path.join(cwd, '.planning', 'phases');
88
+ const phasesDir = path.join(planningDir(cwd), 'phases');
89
89
  const normalized = normalizePhaseName(basePhase);
90
90
 
91
91
  // Check if phases directory exists
@@ -108,7 +108,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
108
108
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
109
109
 
110
110
  // Check if base phase exists
111
- const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized);
111
+ const baseExists = dirs.some(d => phaseTokenMatches(d, normalized));
112
112
 
113
113
  // Find existing decimal phases for this base
114
114
  const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
@@ -154,7 +154,7 @@ function cmdFindPhase(cwd, phase, raw) {
154
154
  error('phase identifier required');
155
155
  }
156
156
 
157
- const phasesDir = path.join(cwd, '.planning', 'phases');
157
+ const phasesDir = path.join(planningDir(cwd), 'phases');
158
158
  const normalized = normalizePhaseName(phase);
159
159
 
160
160
  const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] };
@@ -163,13 +163,15 @@ function cmdFindPhase(cwd, phase, raw) {
163
163
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
164
164
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
165
165
 
166
- const match = dirs.find(d => d.startsWith(normalized));
166
+ const match = dirs.find(d => phaseTokenMatches(d, normalized));
167
167
  if (!match) {
168
168
  output(notFound, raw, '');
169
169
  return;
170
170
  }
171
171
 
172
- const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
172
+ // Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs
173
+ const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
174
+ || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
173
175
  const phaseNumber = dirMatch ? dirMatch[1] : normalized;
174
176
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
175
177
 
@@ -180,7 +182,7 @@ function cmdFindPhase(cwd, phase, raw) {
180
182
 
181
183
  const result = {
182
184
  found: true,
183
- directory: toPosixPath(path.join('.planning', 'phases', match)),
185
+ directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', match)),
184
186
  phase_number: phaseNumber,
185
187
  phase_name: phaseName,
186
188
  plans,
@@ -203,7 +205,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
203
205
  error('phase required for phase-plan-index');
204
206
  }
205
207
 
206
- const phasesDir = path.join(cwd, '.planning', 'phases');
208
+ const phasesDir = path.join(planningDir(cwd), 'phases');
207
209
  const normalized = normalizePhaseName(phase);
208
210
 
209
211
  // Find phase directory
@@ -212,7 +214,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
212
214
  try {
213
215
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
214
216
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
215
- const match = dirs.find(d => d.startsWith(normalized));
217
+ const match = dirs.find(d => phaseTokenMatches(d, normalized));
216
218
  if (match) {
217
219
  phaseDir = path.join(phasesDir, match);
218
220
  phaseDirName = match;
@@ -308,60 +310,84 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
308
310
  output(result, raw);
309
311
  }
310
312
 
311
- function cmdPhaseAdd(cwd, description, raw) {
313
+ function cmdPhaseAdd(cwd, description, raw, customId) {
312
314
  if (!description) {
313
315
  error('description required for phase add');
314
316
  }
315
317
 
316
- const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
318
+ const config = loadConfig(cwd);
319
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
317
320
  if (!fs.existsSync(roadmapPath)) {
318
321
  error('ROADMAP.md not found');
319
322
  }
320
323
 
321
- const content = fs.readFileSync(roadmapPath, 'utf-8');
322
324
  const slug = generateSlugInternal(description);
323
325
 
324
- // Find highest integer phase number
325
- const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
326
- let maxPhase = 0;
327
- let m;
328
- while ((m = phasePattern.exec(content)) !== null) {
329
- const num = parseInt(m[1], 10);
330
- if (num > maxPhase) maxPhase = num;
331
- }
326
+ // Wrap entire read-modify-write in lock to prevent concurrent corruption
327
+ const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
328
+ const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
329
+ const content = extractCurrentMilestone(rawContent, cwd);
332
330
 
333
- const newPhaseNum = maxPhase + 1;
334
- const paddedNum = String(newPhaseNum).padStart(2, '0');
335
- const dirName = `${paddedNum}-${slug}`;
336
- const dirPath = path.join(cwd, '.planning', 'phases', dirName);
337
-
338
- // Create directory with .gitkeep so git tracks empty folders
339
- fs.mkdirSync(dirPath, { recursive: true });
340
- fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
341
-
342
- // Build phase entry
343
- const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${newPhaseNum} to break down)\n`;
344
-
345
- // Find insertion point: before last "---" or at end
346
- let updatedContent;
347
- const lastSeparator = content.lastIndexOf('\n---');
348
- if (lastSeparator > 0) {
349
- updatedContent = content.slice(0, lastSeparator) + phaseEntry + content.slice(lastSeparator);
350
- } else {
351
- updatedContent = content + phaseEntry;
352
- }
331
+ // Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
332
+ const projectCode = config.project_code || '';
333
+ const prefix = projectCode ? `${projectCode}-` : '';
334
+
335
+ let _newPhaseId;
336
+ let _dirName;
337
+
338
+ if (customId || config.phase_naming === 'custom') {
339
+ // Custom phase naming: use provided ID or generate from description
340
+ _newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
341
+ if (!_newPhaseId) error('--id required when phase_naming is "custom"');
342
+ _dirName = `${prefix}${_newPhaseId}-${slug}`;
343
+ } else {
344
+ // Sequential mode: find highest integer phase number (in current milestone only)
345
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
346
+ let maxPhase = 0;
347
+ let m;
348
+ while ((m = phasePattern.exec(content)) !== null) {
349
+ const num = parseInt(m[1], 10);
350
+ if (num > maxPhase) maxPhase = num;
351
+ }
352
+
353
+ _newPhaseId = maxPhase + 1;
354
+ const paddedNum = String(_newPhaseId).padStart(2, '0');
355
+ _dirName = `${prefix}${paddedNum}-${slug}`;
356
+ }
357
+
358
+ const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
359
+
360
+ // Create directory with .gitkeep so git tracks empty folders
361
+ fs.mkdirSync(dirPath, { recursive: true });
362
+ fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
353
363
 
354
- fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
364
+ // Build phase entry
365
+ const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
366
+ const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${_newPhaseId} to break down)\n`;
367
+
368
+ // Find insertion point: before last "---" or at end
369
+ let updatedContent;
370
+ const lastSeparator = rawContent.lastIndexOf('\n---');
371
+ if (lastSeparator > 0) {
372
+ updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
373
+ } else {
374
+ updatedContent = rawContent + phaseEntry;
375
+ }
376
+
377
+ fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
378
+ return { newPhaseId: _newPhaseId, dirName: _dirName };
379
+ });
355
380
 
356
381
  const result = {
357
- phase_number: newPhaseNum,
358
- padded: paddedNum,
382
+ phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
383
+ padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
359
384
  name: description,
360
385
  slug,
361
- directory: `.planning/phases/${dirName}`,
386
+ directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
387
+ naming_mode: config.phase_naming,
362
388
  };
363
389
 
364
- output(result, raw, paddedNum);
390
+ output(result, raw, result.padded);
365
391
  }
366
392
 
367
393
  function cmdPhaseInsert(cwd, afterPhase, description, raw) {
@@ -369,333 +395,258 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
369
395
  error('after-phase and description required for phase insert');
370
396
  }
371
397
 
372
- const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
398
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
373
399
  if (!fs.existsSync(roadmapPath)) {
374
400
  error('ROADMAP.md not found');
375
401
  }
376
402
 
377
- const content = fs.readFileSync(roadmapPath, 'utf-8');
378
403
  const slug = generateSlugInternal(description);
379
404
 
380
- // Normalize input then strip leading zeros for flexible matching
381
- const normalizedAfter = normalizePhaseName(afterPhase);
382
- const unpadded = normalizedAfter.replace(/^0+/, '');
383
- const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
384
- const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
385
- if (!targetPattern.test(content)) {
386
- error(`Phase ${afterPhase} not found in ROADMAP.md`);
387
- }
388
-
389
- // Calculate next decimal using existing logic
390
- const phasesDir = path.join(cwd, '.planning', 'phases');
391
- const normalizedBase = normalizePhaseName(afterPhase);
392
- let existingDecimals = [];
393
-
394
- try {
395
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
396
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
397
- const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
398
- for (const dir of dirs) {
399
- const dm = dir.match(decimalPattern);
400
- if (dm) existingDecimals.push(parseInt(dm[1], 10));
405
+ // Wrap entire read-modify-write in lock to prevent concurrent corruption
406
+ const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
407
+ const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
408
+ const content = extractCurrentMilestone(rawContent, cwd);
409
+
410
+ // Normalize input then strip leading zeros for flexible matching
411
+ const normalizedAfter = normalizePhaseName(afterPhase);
412
+ const unpadded = normalizedAfter.replace(/^0+/, '');
413
+ const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
414
+ const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
415
+ if (!targetPattern.test(content)) {
416
+ error(`Phase ${afterPhase} not found in ROADMAP.md`);
401
417
  }
402
- } catch {}
403
-
404
- const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
405
- const decimalPhase = `${normalizedBase}.${nextDecimal}`;
406
- const dirName = `${decimalPhase}-${slug}`;
407
- const dirPath = path.join(cwd, '.planning', 'phases', dirName);
408
-
409
- // Create directory with .gitkeep so git tracks empty folders
410
- fs.mkdirSync(dirPath, { recursive: true });
411
- fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
412
418
 
413
- // Build phase entry
414
- const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${decimalPhase} to break down)\n`;
419
+ // Calculate next decimal using existing logic
420
+ const phasesDir = path.join(planningDir(cwd), 'phases');
421
+ const normalizedBase = normalizePhaseName(afterPhase);
422
+ let existingDecimals = [];
415
423
 
416
- // Insert after the target phase section
417
- const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
418
- const headerMatch = content.match(headerPattern);
419
- if (!headerMatch) {
420
- error(`Could not find Phase ${afterPhase} header`);
421
- }
424
+ try {
425
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
426
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
427
+ const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`);
428
+ for (const dir of dirs) {
429
+ const dm = dir.match(decimalPattern);
430
+ if (dm) existingDecimals.push(parseInt(dm[1], 10));
431
+ }
432
+ } catch { /* intentionally empty */ }
433
+
434
+ const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
435
+ const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
436
+ // Optional project code prefix
437
+ const insertConfig = loadConfig(cwd);
438
+ const projectCode = insertConfig.project_code || '';
439
+ const pfx = projectCode ? `${projectCode}-` : '';
440
+ const _dirName = `${pfx}${_decimalPhase}-${slug}`;
441
+ const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
442
+
443
+ // Create directory with .gitkeep so git tracks empty folders
444
+ fs.mkdirSync(dirPath, { recursive: true });
445
+ fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
446
+
447
+ // Build phase entry
448
+ const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${_decimalPhase} to break down)\n`;
449
+
450
+ // Insert after the target phase section
451
+ const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
452
+ const headerMatch = rawContent.match(headerPattern);
453
+ if (!headerMatch) {
454
+ error(`Could not find Phase ${afterPhase} header`);
455
+ }
422
456
 
423
- const headerIdx = content.indexOf(headerMatch[0]);
424
- const afterHeader = content.slice(headerIdx + headerMatch[0].length);
425
- const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
457
+ const headerIdx = rawContent.indexOf(headerMatch[0]);
458
+ const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
459
+ const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
426
460
 
427
- let insertIdx;
428
- if (nextPhaseMatch) {
429
- insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
430
- } else {
431
- insertIdx = content.length;
432
- }
461
+ let insertIdx;
462
+ if (nextPhaseMatch) {
463
+ insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
464
+ } else {
465
+ insertIdx = rawContent.length;
466
+ }
433
467
 
434
- const updatedContent = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
435
- fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
468
+ const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
469
+ fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
470
+ return { decimalPhase: _decimalPhase, dirName: _dirName };
471
+ });
436
472
 
437
473
  const result = {
438
474
  phase_number: decimalPhase,
439
475
  after_phase: afterPhase,
440
476
  name: description,
441
477
  slug,
442
- directory: `.planning/phases/${dirName}`,
478
+ directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
443
479
  };
444
480
 
445
481
  output(result, raw, decimalPhase);
446
482
  }
447
483
 
448
- function cmdPhaseRemove(cwd, targetPhase, options, raw) {
449
- if (!targetPhase) {
450
- error('phase number required for phase remove');
484
+ /**
485
+ * Renumber sibling decimal phases after a decimal phase is removed.
486
+ * e.g. removing 06.2 → 06.3 becomes 06.2, 06.4 becomes 06.3, etc.
487
+ * Returns { renamedDirs, renamedFiles }.
488
+ */
489
+ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
490
+ const renamedDirs = [], renamedFiles = [];
491
+ const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
492
+ const dirs = readSubdirectories(phasesDir, true);
493
+ const toRename = dirs
494
+ .map(dir => { const m = dir.match(decPattern); return m ? { dir, oldDecimal: parseInt(m[1], 10), slug: m[2] } : null; })
495
+ .filter(item => item && item.oldDecimal > removedDecimal)
496
+ .sort((a, b) => b.oldDecimal - a.oldDecimal); // descending to avoid conflicts
497
+
498
+ for (const item of toRename) {
499
+ const newDecimal = item.oldDecimal - 1;
500
+ const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
501
+ const newPhaseId = `${baseInt}.${newDecimal}`;
502
+ const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
503
+ fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
504
+ renamedDirs.push({ from: item.dir, to: newDirName });
505
+ for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
506
+ if (f.includes(oldPhaseId)) {
507
+ const newFileName = f.replace(oldPhaseId, newPhaseId);
508
+ fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName));
509
+ renamedFiles.push({ from: f, to: newFileName });
510
+ }
511
+ }
451
512
  }
513
+ return { renamedDirs, renamedFiles };
514
+ }
452
515
 
453
- const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
454
- const phasesDir = path.join(cwd, '.planning', 'phases');
455
- const force = options.force || false;
456
-
457
- if (!fs.existsSync(roadmapPath)) {
458
- error('ROADMAP.md not found');
516
+ /**
517
+ * Renumber all integer phases after removedInt.
518
+ * e.g. removing phase 5 → phase 6 becomes 5, phase 7 becomes 6, etc.
519
+ * Returns { renamedDirs, renamedFiles }.
520
+ */
521
+ function renameIntegerPhases(phasesDir, removedInt) {
522
+ const renamedDirs = [], renamedFiles = [];
523
+ const dirs = readSubdirectories(phasesDir, true);
524
+ const toRename = dirs
525
+ .map(dir => {
526
+ const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
527
+ if (!m) return null;
528
+ const dirInt = parseInt(m[1], 10);
529
+ return dirInt > removedInt ? { dir, oldInt: dirInt, letter: m[2] ? m[2].toUpperCase() : '', decimal: m[3] ? parseInt(m[3], 10) : null, slug: m[4] } : null;
530
+ })
531
+ .filter(Boolean)
532
+ .sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
533
+
534
+ for (const item of toRename) {
535
+ const newInt = item.oldInt - 1;
536
+ const newPadded = String(newInt).padStart(2, '0');
537
+ const oldPadded = String(item.oldInt).padStart(2, '0');
538
+ const letterSuffix = item.letter || '';
539
+ const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
540
+ const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
541
+ const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
542
+ const newDirName = `${newPrefix}-${item.slug}`;
543
+ fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
544
+ renamedDirs.push({ from: item.dir, to: newDirName });
545
+ for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
546
+ if (f.startsWith(oldPrefix)) {
547
+ const newFileName = newPrefix + f.slice(oldPrefix.length);
548
+ fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName));
549
+ renamedFiles.push({ from: f, to: newFileName });
550
+ }
551
+ }
459
552
  }
553
+ return { renamedDirs, renamedFiles };
554
+ }
555
+
556
+ /**
557
+ * Remove a phase section from ROADMAP.md and renumber all subsequent integer phases.
558
+ */
559
+ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
560
+ // Wrap entire read-modify-write in lock to prevent concurrent corruption
561
+ withPlanningLock(cwd, () => {
562
+ let content = fs.readFileSync(roadmapPath, 'utf-8');
563
+ const escaped = escapeRegex(targetPhase);
564
+
565
+ content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), '');
566
+ content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
567
+ content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
568
+
569
+ if (!isDecimal) {
570
+ const MAX_PHASE = 99;
571
+ for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) {
572
+ const newNum = oldNum - 1;
573
+ const oldStr = String(oldNum), newStr = String(newNum);
574
+ const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0');
575
+ content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`);
576
+ content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`);
577
+ content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`);
578
+ content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `);
579
+ content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`);
580
+ }
581
+ }
582
+
583
+ fs.writeFileSync(roadmapPath, content, 'utf-8');
584
+ });
585
+ }
586
+
587
+ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
588
+ if (!targetPhase) error('phase number required for phase remove');
589
+
590
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
591
+ const phasesDir = path.join(planningDir(cwd), 'phases');
592
+
593
+ if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found');
460
594
 
461
- // Normalize the target
462
595
  const normalized = normalizePhaseName(targetPhase);
463
596
  const isDecimal = targetPhase.includes('.');
597
+ const force = options.force || false;
464
598
 
465
- // Find and validate target directory
466
- let targetDir = null;
467
- try {
468
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
469
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
470
- targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
471
- } catch {}
599
+ // Find target directory
600
+ const targetDir = readSubdirectories(phasesDir, true)
601
+ .find(d => phaseTokenMatches(d, normalized)) || null;
472
602
 
473
- // Check for executed work (SUMMARY.md files)
603
+ // Guard against removing executed work
474
604
  if (targetDir && !force) {
475
- const targetPath = path.join(phasesDir, targetDir);
476
- const files = fs.readdirSync(targetPath);
605
+ const files = fs.readdirSync(path.join(phasesDir, targetDir));
477
606
  const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
478
607
  if (summaries.length > 0) {
479
608
  error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
480
609
  }
481
610
  }
482
611
 
483
- // Delete target directory
484
- if (targetDir) {
485
- fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
486
- }
487
-
488
- // Renumber subsequent phases
489
- const renamedDirs = [];
490
- const renamedFiles = [];
491
-
492
- if (isDecimal) {
493
- // Decimal removal: renumber sibling decimals (e.g., removing 06.2 → 06.3 becomes 06.2)
494
- const baseParts = normalized.split('.');
495
- const baseInt = baseParts[0];
496
- const removedDecimal = parseInt(baseParts[1], 10);
497
-
498
- try {
499
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
500
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
501
-
502
- // Find sibling decimals with higher numbers
503
- const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
504
- const toRename = [];
505
- for (const dir of dirs) {
506
- const dm = dir.match(decPattern);
507
- if (dm && parseInt(dm[1], 10) > removedDecimal) {
508
- toRename.push({ dir, oldDecimal: parseInt(dm[1], 10), slug: dm[2] });
509
- }
510
- }
511
-
512
- // Sort descending to avoid conflicts
513
- toRename.sort((a, b) => b.oldDecimal - a.oldDecimal);
514
-
515
- for (const item of toRename) {
516
- const newDecimal = item.oldDecimal - 1;
517
- const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
518
- const newPhaseId = `${baseInt}.${newDecimal}`;
519
- const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
520
-
521
- // Rename directory
522
- fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
523
- renamedDirs.push({ from: item.dir, to: newDirName });
524
-
525
- // Rename files inside
526
- const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
527
- for (const f of dirFiles) {
528
- // Files may have phase prefix like "06.2-01-PLAN.md"
529
- if (f.includes(oldPhaseId)) {
530
- const newFileName = f.replace(oldPhaseId, newPhaseId);
531
- fs.renameSync(
532
- path.join(phasesDir, newDirName, f),
533
- path.join(phasesDir, newDirName, newFileName)
534
- );
535
- renamedFiles.push({ from: f, to: newFileName });
536
- }
537
- }
538
- }
539
- } catch {}
540
-
541
- } else {
542
- // Integer removal: renumber all subsequent integer phases
543
- const removedInt = parseInt(normalized, 10);
544
-
545
- try {
546
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
547
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
612
+ if (targetDir) fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
548
613
 
549
- // Collect directories that need renumbering (integer phases > removed, and their decimals/letters)
550
- const toRename = [];
551
- for (const dir of dirs) {
552
- const dm = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
553
- if (!dm) continue;
554
- const dirInt = parseInt(dm[1], 10);
555
- if (dirInt > removedInt) {
556
- toRename.push({
557
- dir,
558
- oldInt: dirInt,
559
- letter: dm[2] ? dm[2].toUpperCase() : '',
560
- decimal: dm[3] ? parseInt(dm[3], 10) : null,
561
- slug: dm[4],
562
- });
563
- }
564
- }
565
-
566
- // Sort descending to avoid conflicts
567
- toRename.sort((a, b) => {
568
- if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt;
569
- return (b.decimal || 0) - (a.decimal || 0);
570
- });
571
-
572
- for (const item of toRename) {
573
- const newInt = item.oldInt - 1;
574
- const newPadded = String(newInt).padStart(2, '0');
575
- const oldPadded = String(item.oldInt).padStart(2, '0');
576
- const letterSuffix = item.letter || '';
577
- const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
578
- const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
579
- const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
580
- const newDirName = `${newPrefix}-${item.slug}`;
581
-
582
- // Rename directory
583
- fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
584
- renamedDirs.push({ from: item.dir, to: newDirName });
585
-
586
- // Rename files inside
587
- const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
588
- for (const f of dirFiles) {
589
- if (f.startsWith(oldPrefix)) {
590
- const newFileName = newPrefix + f.slice(oldPrefix.length);
591
- fs.renameSync(
592
- path.join(phasesDir, newDirName, f),
593
- path.join(phasesDir, newDirName, newFileName)
594
- );
595
- renamedFiles.push({ from: f, to: newFileName });
596
- }
597
- }
598
- }
599
- } catch {}
600
- }
614
+ // Renumber subsequent phases on disk
615
+ let renamedDirs = [], renamedFiles = [];
616
+ try {
617
+ const renamed = isDecimal
618
+ ? renameDecimalPhases(phasesDir, normalized.split('.')[0], parseInt(normalized.split('.')[1], 10))
619
+ : renameIntegerPhases(phasesDir, parseInt(normalized, 10));
620
+ renamedDirs = renamed.renamedDirs;
621
+ renamedFiles = renamed.renamedFiles;
622
+ } catch { /* intentionally empty */ }
601
623
 
602
624
  // Update ROADMAP.md
603
- let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
604
-
605
- // Remove the target phase section
606
- const targetEscaped = escapeRegex(targetPhase);
607
- const sectionPattern = new RegExp(
608
- `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
609
- 'i'
610
- );
611
- roadmapContent = roadmapContent.replace(sectionPattern, '');
612
-
613
- // Remove from phase list (checkbox)
614
- const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
615
- roadmapContent = roadmapContent.replace(checkboxPattern, '');
616
-
617
- // Remove from progress table
618
- const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi');
619
- roadmapContent = roadmapContent.replace(tableRowPattern, '');
620
-
621
- // Renumber references in ROADMAP for subsequent phases
622
- if (!isDecimal) {
623
- const removedInt = parseInt(normalized, 10);
624
-
625
- // Collect all integer phases > removedInt
626
- const maxPhase = 99; // reasonable upper bound
627
- for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) {
628
- const newNum = oldNum - 1;
629
- const oldStr = String(oldNum);
630
- const newStr = String(newNum);
631
- const oldPad = oldStr.padStart(2, '0');
632
- const newPad = newStr.padStart(2, '0');
633
-
634
- // Phase headings: ## Phase 18: or ### Phase 18: → ## Phase 17: or ### Phase 17:
635
- roadmapContent = roadmapContent.replace(
636
- new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
637
- `$1${newStr}$2`
638
- );
639
-
640
- // Checkbox items: - [ ] **Phase 18:** → - [ ] **Phase 17:**
641
- roadmapContent = roadmapContent.replace(
642
- new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
643
- `$1${newStr}$2`
644
- );
645
-
646
- // Plan references: 18-01 → 17-01
647
- roadmapContent = roadmapContent.replace(
648
- new RegExp(`${oldPad}-(\\d{2})`, 'g'),
649
- `${newPad}-$1`
650
- );
651
-
652
- // Table rows: | 18. → | 17.
653
- roadmapContent = roadmapContent.replace(
654
- new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'),
655
- `$1${newStr}. `
656
- );
657
-
658
- // Depends on references
659
- roadmapContent = roadmapContent.replace(
660
- new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'),
661
- `$1${newStr}`
662
- );
663
- }
664
- }
665
-
666
- fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
625
+ updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
667
626
 
668
627
  // Update STATE.md phase count
669
- const statePath = path.join(cwd, '.planning', 'STATE.md');
628
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
670
629
  if (fs.existsSync(statePath)) {
671
630
  let stateContent = fs.readFileSync(statePath, 'utf-8');
672
- // Update "Total Phases" field
673
- const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/;
674
- const totalMatch = stateContent.match(totalPattern);
675
- if (totalMatch) {
676
- const oldTotal = parseInt(totalMatch[2], 10);
677
- stateContent = stateContent.replace(totalPattern, `$1${oldTotal - 1}`);
631
+ const totalRaw = stateExtractField(stateContent, 'Total Phases');
632
+ if (totalRaw) {
633
+ stateContent = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) || stateContent;
678
634
  }
679
- // Update "Phase: X of Y" pattern
680
- const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i;
681
- const ofMatch = stateContent.match(ofPattern);
635
+ const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
682
636
  if (ofMatch) {
683
- const oldTotal = parseInt(ofMatch[2], 10);
684
- stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`);
637
+ stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
685
638
  }
686
639
  writeStateMd(statePath, stateContent, cwd);
687
640
  }
688
641
 
689
- const result = {
642
+ output({
690
643
  removed: targetPhase,
691
- directory_deleted: targetDir || null,
644
+ directory_deleted: targetDir,
692
645
  renamed_directories: renamedDirs,
693
646
  renamed_files: renamedFiles,
694
647
  roadmap_updated: true,
695
648
  state_updated: fs.existsSync(statePath),
696
- };
697
-
698
- output(result, raw);
649
+ }, raw);
699
650
  }
700
651
 
701
652
  function cmdPhaseComplete(cwd, phaseNum, raw) {
@@ -703,9 +654,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
703
654
  error('phase number required for phase complete');
704
655
  }
705
656
 
706
- const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
707
- const statePath = path.join(cwd, '.planning', 'STATE.md');
708
- const phasesDir = path.join(cwd, '.planning', 'phases');
657
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
658
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
659
+ const phasesDir = path.join(planningDir(cwd), 'phases');
709
660
  const normalized = normalizePhaseName(phaseNum);
710
661
  const today = new Date().toISOString().split('T')[0];
711
662
 
@@ -717,70 +668,124 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
717
668
 
718
669
  const planCount = phaseInfo.plans.length;
719
670
  const summaryCount = phaseInfo.summaries.length;
671
+ let requirementsUpdated = false;
720
672
 
721
- // Update ROADMAP.md: mark phase complete
722
- if (fs.existsSync(roadmapPath)) {
723
- let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
673
+ // Check for unresolved verification debt (non-blocking warnings)
674
+ const warnings = [];
675
+ try {
676
+ const phaseFullDir = path.join(cwd, phaseInfo.directory);
677
+ const phaseFiles = fs.readdirSync(phaseFullDir);
678
+
679
+ for (const file of phaseFiles.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
680
+ const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
681
+ if (/result: pending/.test(content)) warnings.push(`${file}: has pending tests`);
682
+ if (/result: blocked/.test(content)) warnings.push(`${file}: has blocked tests`);
683
+ if (/status: partial/.test(content)) warnings.push(`${file}: testing incomplete (partial)`);
684
+ if (/status: diagnosed/.test(content)) warnings.push(`${file}: has diagnosed gaps`);
685
+ }
724
686
 
725
- // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
726
- const checkboxPattern = new RegExp(
727
- `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
728
- 'i'
729
- );
730
- roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
687
+ for (const file of phaseFiles.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
688
+ const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
689
+ if (/status: human_needed/.test(content)) warnings.push(`${file}: needs human verification`);
690
+ if (/status: gaps_found/.test(content)) warnings.push(`${file}: has unresolved gaps`);
691
+ }
692
+ } catch {}
731
693
 
732
- // Progress table: update Status to Complete, add date
733
- const phaseEscaped = escapeRegex(phaseNum);
734
- const tablePattern = new RegExp(
735
- `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
736
- 'i'
737
- );
738
- roadmapContent = roadmapContent.replace(
739
- tablePattern,
740
- `$1 Complete $2 ${today} $3`
741
- );
694
+ // Update ROADMAP.md and REQUIREMENTS.md atomically under lock
695
+ if (fs.existsSync(roadmapPath)) {
696
+ withPlanningLock(cwd, () => {
697
+ let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
742
698
 
743
- // Update plan count in phase section
744
- const planCountPattern = new RegExp(
745
- `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
746
- 'i'
747
- );
748
- roadmapContent = roadmapContent.replace(
749
- planCountPattern,
750
- `$1${summaryCount}/${planCount} plans complete`
751
- );
699
+ // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
700
+ const checkboxPattern = new RegExp(
701
+ `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
702
+ 'i'
703
+ );
704
+ roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
752
705
 
753
- fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
706
+ // Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
707
+ const phaseEscaped = escapeRegex(phaseNum);
708
+ const tableRowPattern = new RegExp(
709
+ `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
710
+ 'im'
711
+ );
712
+ roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
713
+ const cells = fullRow.split('|').slice(1, -1);
714
+ if (cells.length === 5) {
715
+ // 5-col: Phase | Milestone | Plans | Status | Completed
716
+ cells[2] = ` ${summaryCount}/${planCount} `;
717
+ cells[3] = ' Complete ';
718
+ cells[4] = ` ${today} `;
719
+ } else if (cells.length === 4) {
720
+ // 4-col: Phase | Plans | Status | Completed
721
+ cells[1] = ` ${summaryCount}/${planCount} `;
722
+ cells[2] = ' Complete ';
723
+ cells[3] = ` ${today} `;
724
+ }
725
+ return '|' + cells.join('|') + '|';
726
+ });
754
727
 
755
- // Update REQUIREMENTS.md traceability for this phase's requirements
756
- const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
757
- if (fs.existsSync(reqPath)) {
758
- // Extract Requirements line from roadmap for this phase
759
- const reqMatch = roadmapContent.match(
760
- new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i')
728
+ // Update plan count in phase section
729
+ const planCountPattern = new RegExp(
730
+ `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
731
+ 'i'
732
+ );
733
+ roadmapContent = replaceInCurrentMilestone(
734
+ roadmapContent, planCountPattern,
735
+ `$1${summaryCount}/${planCount} plans complete`
761
736
  );
762
737
 
763
- if (reqMatch) {
764
- const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
765
- let reqContent = fs.readFileSync(reqPath, 'utf-8');
738
+ // Mark completed plan checkboxes (safety net for missed per-plan updates)
739
+ // Handles both plain IDs ("- [ ] 01-01-PLAN.md") and bold-wrapped IDs ("- [ ] **01-01**")
740
+ for (const summaryFile of phaseInfo.summaries) {
741
+ const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
742
+ if (!planId) continue;
743
+ const planEscaped = escapeRegex(planId);
744
+ const planCheckboxPattern = new RegExp(
745
+ `(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
746
+ 'i'
747
+ );
748
+ roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
749
+ }
766
750
 
767
- for (const reqId of reqIds) {
768
- const reqEscaped = escapeRegex(reqId);
769
- // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
770
- reqContent = reqContent.replace(
771
- new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
772
- '$1x$2'
773
- );
774
- // Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
775
- reqContent = reqContent.replace(
776
- new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'),
777
- '$1 Complete $2'
778
- );
779
- }
751
+ fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
752
+
753
+ // Update REQUIREMENTS.md traceability for this phase's requirements
754
+ const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
755
+ if (fs.existsSync(reqPath)) {
756
+ // Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
757
+ const phaseEsc = escapeRegex(phaseNum);
758
+ const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
759
+ const phaseSectionMatch = currentMilestoneRoadmap.match(
760
+ new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
761
+ );
762
+
763
+ const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
764
+ const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
765
+
766
+ if (reqMatch) {
767
+ const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
768
+ let reqContent = fs.readFileSync(reqPath, 'utf-8');
769
+
770
+ for (const reqId of reqIds) {
771
+ const reqEscaped = escapeRegex(reqId);
772
+ // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
773
+ reqContent = reqContent.replace(
774
+ new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
775
+ '$1x$2'
776
+ );
777
+ // Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
778
+ reqContent = reqContent.replace(
779
+ new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
780
+ '$1 Complete $2'
781
+ );
782
+ }
780
783
 
781
- fs.writeFileSync(reqPath, reqContent, 'utf-8');
784
+ fs.writeFileSync(reqPath, reqContent, 'utf-8');
785
+ requirementsUpdated = true;
786
+ }
782
787
  }
783
- }
788
+ });
784
789
  }
785
790
 
786
791
  // Find next phase — check both filesystem AND roadmap
@@ -809,13 +814,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
809
814
  }
810
815
  }
811
816
  }
812
- } catch {}
817
+ } catch { /* intentionally empty */ }
813
818
 
814
819
  // Fallback: if filesystem found no next phase, check ROADMAP.md
815
820
  // for phases that are defined but not yet planned (no directory on disk)
816
821
  if (isLastPhase && fs.existsSync(roadmapPath)) {
817
822
  try {
818
- const roadmapForPhases = fs.readFileSync(roadmapPath, 'utf-8');
823
+ const roadmapForPhases = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
819
824
  const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
820
825
  let pm;
821
826
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
@@ -826,50 +831,72 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
826
831
  break;
827
832
  }
828
833
  }
829
- } catch {}
834
+ } catch { /* intentionally empty */ }
830
835
  }
831
836
 
832
- // Update STATE.md
837
+ // Update STATE.md — use shared helpers that handle both **bold:** and plain Field: formats
833
838
  if (fs.existsSync(statePath)) {
834
839
  let stateContent = fs.readFileSync(statePath, 'utf-8');
835
840
 
836
- // Update Current Phase
837
- stateContent = stateContent.replace(
838
- /(\*\*Current Phase:\*\*\s*).*/,
839
- `$1${nextPhaseNum || phaseNum}`
840
- );
841
+ // Update Current Phase — preserve "X of Y (Name)" compound format
842
+ const phaseValue = nextPhaseNum || phaseNum;
843
+ const existingPhaseField = stateExtractField(stateContent, 'Current Phase')
844
+ || stateExtractField(stateContent, 'Phase');
845
+ let newPhaseValue = String(phaseValue);
846
+ if (existingPhaseField) {
847
+ const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
848
+ const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
849
+ if (totalMatch) {
850
+ const total = totalMatch[1];
851
+ const nameStr = nextPhaseName ? ` (${nextPhaseName.replace(/-/g, ' ')})` : (nameMatch ? ` (${nameMatch[1]})` : '');
852
+ newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
853
+ }
854
+ }
855
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase', 'Phase', newPhaseValue);
841
856
 
842
857
  // Update Current Phase Name
843
858
  if (nextPhaseName) {
844
- stateContent = stateContent.replace(
845
- /(\*\*Current Phase Name:\*\*\s*).*/,
846
- `$1${nextPhaseName.replace(/-/g, ' ')}`
847
- );
859
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase Name', null, nextPhaseName.replace(/-/g, ' '));
848
860
  }
849
861
 
850
862
  // Update Status
851
- stateContent = stateContent.replace(
852
- /(\*\*Status:\*\*\s*).*/,
853
- `$1${isLastPhase ? 'Milestone complete' : 'Ready to plan'}`
854
- );
863
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null,
864
+ isLastPhase ? 'Milestone complete' : 'Ready to plan');
855
865
 
856
866
  // Update Current Plan
857
- stateContent = stateContent.replace(
858
- /(\*\*Current Plan:\*\*\s*).*/,
859
- `$1Not started`
860
- );
867
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Plan', 'Plan', 'Not started');
861
868
 
862
869
  // Update Last Activity
863
- stateContent = stateContent.replace(
864
- /(\*\*Last Activity:\*\*\s*).*/,
865
- `$1${today}`
866
- );
870
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
867
871
 
868
872
  // Update Last Activity Description
869
- stateContent = stateContent.replace(
870
- /(\*\*Last Activity Description:\*\*\s*).*/,
871
- `$1Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`
872
- );
873
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
874
+ `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`);
875
+
876
+ // Increment Completed Phases counter (#956)
877
+ const completedRaw = stateExtractField(stateContent, 'Completed Phases');
878
+ if (completedRaw) {
879
+ const newCompleted = parseInt(completedRaw, 10) + 1;
880
+ stateContent = stateReplaceField(stateContent, 'Completed Phases', String(newCompleted)) || stateContent;
881
+
882
+ // Recalculate percent based on completed / total (#956)
883
+ const totalRaw = stateExtractField(stateContent, 'Total Phases');
884
+ if (totalRaw) {
885
+ const totalPhases = parseInt(totalRaw, 10);
886
+ if (totalPhases > 0) {
887
+ const newPercent = Math.round((newCompleted / totalPhases) * 100);
888
+ stateContent = stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
889
+ // Also update percent field if it exists separately
890
+ stateContent = stateContent.replace(
891
+ /(percent:\s*)\d+/,
892
+ `$1${newPercent}`
893
+ );
894
+ }
895
+ }
896
+ }
897
+
898
+ // Gate 4: Update Performance Metrics section (#1627)
899
+ stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
873
900
 
874
901
  writeStateMd(statePath, stateContent, cwd);
875
902
  }
@@ -884,6 +911,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
884
911
  date: today,
885
912
  roadmap_updated: fs.existsSync(roadmapPath),
886
913
  state_updated: fs.existsSync(statePath),
914
+ requirements_updated: requirementsUpdated,
915
+ warnings,
916
+ has_warnings: warnings.length > 0,
887
917
  };
888
918
 
889
919
  output(result, raw);