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
@@ -5,18 +5,77 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { execSync } = require('child_process');
8
- const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs');
8
+ const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, planningPaths, planningDir, planningRoot, toPosixPath, output, error, checkAgentsInstalled, phaseTokenMatches } = require('./core.cjs');
9
9
 
10
- function cmdInitExecutePhase(cwd, phase, raw) {
10
+ function getLatestCompletedMilestone(cwd) {
11
+ const milestonesPath = path.join(planningRoot(cwd), 'MILESTONES.md');
12
+ if (!fs.existsSync(milestonesPath)) return null;
13
+
14
+ try {
15
+ const content = fs.readFileSync(milestonesPath, 'utf-8');
16
+ const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m);
17
+ if (!match) return null;
18
+ return {
19
+ version: match[1],
20
+ name: match[2].trim(),
21
+ };
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Inject `project_root` into an init result object.
29
+ * Workflows use this to prefix `.planning/` paths correctly when OpenCode's CWD
30
+ * differs from the project root (e.g., inside a sub-repo).
31
+ */
32
+ function withProjectRoot(cwd, result) {
33
+ result.project_root = cwd;
34
+ // Inject agent installation status into all init outputs (#1371).
35
+ // Workflows that spawn named subagents use this to detect when agents
36
+ // are missing and would silently fall back to general-purpose.
37
+ const agentStatus = checkAgentsInstalled();
38
+ result.agents_installed = agentStatus.agents_installed;
39
+ result.missing_agents = agentStatus.missing_agents;
40
+ // Inject response_language into all init outputs (#1399).
41
+ // Workflows propagate this to subagent prompts so user-facing questions
42
+ // stay in the configured language across phase boundaries.
43
+ const config = loadConfig(cwd);
44
+ if (config.response_language) {
45
+ result.response_language = config.response_language;
46
+ }
47
+ return result;
48
+ }
49
+
50
+ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
11
51
  if (!phase) {
12
52
  error('phase required for init execute-phase');
13
53
  }
14
54
 
15
55
  const config = loadConfig(cwd);
16
- const phaseInfo = findPhaseInternal(cwd, phase);
56
+ let phaseInfo = findPhaseInternal(cwd, phase);
17
57
  const milestone = getMilestoneInfo(cwd);
18
58
 
19
59
  const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
60
+
61
+ // Fallback to ROADMAP.md if no phase directory exists yet
62
+ if (!phaseInfo && roadmapPhase?.found) {
63
+ const phaseName = roadmapPhase.phase_name;
64
+ phaseInfo = {
65
+ found: true,
66
+ directory: null,
67
+ phase_number: roadmapPhase.phase_number,
68
+ phase_name: phaseName,
69
+ phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
70
+ plans: [],
71
+ summaries: [],
72
+ incomplete_plans: [],
73
+ has_research: false,
74
+ has_context: false,
75
+ has_verification: false,
76
+ has_reviews: false,
77
+ };
78
+ }
20
79
  const reqMatch = roadmapPhase?.section?.match(/^\*\*Requirements\*\*:[^\S\n]*([^\n]*)$/m);
21
80
  const reqExtracted = reqMatch
22
81
  ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map(s => s.trim()).filter(Boolean).join(', ')
@@ -30,7 +89,9 @@ function cmdInitExecutePhase(cwd, phase, raw) {
30
89
 
31
90
  // Config flags
32
91
  commit_docs: config.commit_docs,
92
+ sub_repos: config.sub_repos,
33
93
  parallelization: config.parallelization,
94
+ context_window: config.context_window,
34
95
  branching_strategy: config.branching_strategy,
35
96
  phase_branch_template: config.phase_branch_template,
36
97
  milestone_branch_template: config.milestone_branch_template,
@@ -54,6 +115,7 @@ function cmdInitExecutePhase(cwd, phase, raw) {
54
115
  // Branch name (pre-computed)
55
116
  branch_name: config.branching_strategy === 'phase' && phaseInfo
56
117
  ? config.phase_branch_template
118
+ .replace('{project}', config.project_code || '')
57
119
  .replace('{phase}', phaseInfo.phase_number)
58
120
  .replace('{slug}', phaseInfo.phase_slug || 'phase')
59
121
  : config.branching_strategy === 'milestone'
@@ -68,27 +130,74 @@ function cmdInitExecutePhase(cwd, phase, raw) {
68
130
  milestone_slug: generateSlugInternal(milestone.name),
69
131
 
70
132
  // File existence
71
- state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
72
- roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
73
- config_exists: pathExistsInternal(cwd, '.planning/config.json'),
133
+ state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
134
+ roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
135
+ config_exists: fs.existsSync(path.join(planningDir(cwd), 'config.json')),
74
136
  // File paths
75
- state_path: '.planning/STATE.md',
76
- roadmap_path: '.planning/ROADMAP.md',
77
- config_path: '.planning/config.json',
137
+ state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))),
138
+ roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))),
139
+ config_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'config.json'))),
78
140
  };
79
141
 
80
- output(result, raw);
142
+ // Optional --validate: run state validation and include warnings (#1627)
143
+ if (options.validate) {
144
+ try {
145
+ const { cmdStateValidate } = require('./state.cjs');
146
+ // Capture validate output by temporarily redirecting
147
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
148
+ if (fs.existsSync(statePath)) {
149
+ const stateContent = fs.readFileSync(statePath, 'utf-8');
150
+ const { stateExtractField } = require('./state.cjs');
151
+ const status = stateExtractField(stateContent, 'Status') || '';
152
+ result.state_validation_ran = true;
153
+ // Simple inline validation — check for obvious drift
154
+ const warnings = [];
155
+ const phasesPath = planningPaths(cwd).phases;
156
+ if (phaseInfo && phaseInfo.directory && fs.existsSync(path.join(cwd, phaseInfo.directory))) {
157
+ const files = fs.readdirSync(path.join(cwd, phaseInfo.directory));
158
+ const diskPlans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
159
+ const totalPlansRaw = stateExtractField(stateContent, 'Total Plans in Phase');
160
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
161
+ if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
162
+ warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase}, disk has ${diskPlans}`);
163
+ }
164
+ }
165
+ result.state_warnings = warnings;
166
+ }
167
+ } catch { /* intentionally empty */ }
168
+ }
169
+
170
+ output(withProjectRoot(cwd, result), raw);
81
171
  }
82
172
 
83
- function cmdInitPlanPhase(cwd, phase, raw) {
173
+ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
84
174
  if (!phase) {
85
175
  error('phase required for init plan-phase');
86
176
  }
87
177
 
88
178
  const config = loadConfig(cwd);
89
- const phaseInfo = findPhaseInternal(cwd, phase);
179
+ let phaseInfo = findPhaseInternal(cwd, phase);
90
180
 
91
181
  const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
182
+
183
+ // Fallback to ROADMAP.md if no phase directory exists yet
184
+ if (!phaseInfo && roadmapPhase?.found) {
185
+ const phaseName = roadmapPhase.phase_name;
186
+ phaseInfo = {
187
+ found: true,
188
+ directory: null,
189
+ phase_number: roadmapPhase.phase_number,
190
+ phase_name: phaseName,
191
+ phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
192
+ plans: [],
193
+ summaries: [],
194
+ incomplete_plans: [],
195
+ has_research: false,
196
+ has_context: false,
197
+ has_verification: false,
198
+ has_reviews: false,
199
+ };
200
+ }
92
201
  const reqMatch = roadmapPhase?.section?.match(/^\*\*Requirements\*\*:[^\S\n]*([^\n]*)$/m);
93
202
  const reqExtracted = reqMatch
94
203
  ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map(s => s.trim()).filter(Boolean).join(', ')
@@ -106,6 +215,7 @@ function cmdInitPlanPhase(cwd, phase, raw) {
106
215
  plan_checker_enabled: config.plan_checker,
107
216
  nyquist_validation_enabled: config.nyquist_validation,
108
217
  commit_docs: config.commit_docs,
218
+ text_mode: config.text_mode,
109
219
 
110
220
  // Phase info
111
221
  phase_found: !!phaseInfo,
@@ -113,23 +223,24 @@ function cmdInitPlanPhase(cwd, phase, raw) {
113
223
  phase_number: phaseInfo?.phase_number || null,
114
224
  phase_name: phaseInfo?.phase_name || null,
115
225
  phase_slug: phaseInfo?.phase_slug || null,
116
- padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null,
226
+ padded_phase: phaseInfo?.phase_number ? normalizePhaseName(phaseInfo.phase_number) : null,
117
227
  phase_req_ids,
118
228
 
119
229
  // Existing artifacts
120
230
  has_research: phaseInfo?.has_research || false,
121
231
  has_context: phaseInfo?.has_context || false,
232
+ has_reviews: phaseInfo?.has_reviews || false,
122
233
  has_plans: (phaseInfo?.plans?.length || 0) > 0,
123
234
  plan_count: phaseInfo?.plans?.length || 0,
124
235
 
125
236
  // Environment
126
- planning_exists: pathExistsInternal(cwd, '.planning'),
127
- roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
237
+ planning_exists: fs.existsSync(planningDir(cwd)),
238
+ roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
128
239
 
129
240
  // File paths
130
- state_path: '.planning/STATE.md',
131
- roadmap_path: '.planning/ROADMAP.md',
132
- requirements_path: '.planning/REQUIREMENTS.md',
241
+ state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))),
242
+ roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))),
243
+ requirements_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'REQUIREMENTS.md'))),
133
244
  };
134
245
 
135
246
  if (phaseInfo?.directory) {
@@ -153,10 +264,33 @@ function cmdInitPlanPhase(cwd, phase, raw) {
153
264
  if (uatFile) {
154
265
  result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile));
155
266
  }
156
- } catch {}
267
+ const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
268
+ if (reviewsFile) {
269
+ result.reviews_path = toPosixPath(path.join(phaseInfo.directory, reviewsFile));
270
+ }
271
+ } catch { /* intentionally empty */ }
157
272
  }
158
273
 
159
- output(result, raw);
274
+ // Optional --validate: run state validation and include warnings (#1627)
275
+ if (options.validate) {
276
+ try {
277
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
278
+ if (fs.existsSync(statePath)) {
279
+ const { stateExtractField } = require('./state.cjs');
280
+ const stateContent = fs.readFileSync(statePath, 'utf-8');
281
+ const warnings = [];
282
+ result.state_validation_ran = true;
283
+ const totalPlansRaw = stateExtractField(stateContent, 'Total Plans in Phase');
284
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
285
+ if (totalPlansInPhase !== null && phaseInfo && totalPlansInPhase !== (phaseInfo.plans?.length || 0)) {
286
+ warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase}, disk has ${phaseInfo.plans?.length || 0}`);
287
+ }
288
+ result.state_warnings = warnings;
289
+ }
290
+ } catch { /* intentionally empty */ }
291
+ }
292
+
293
+ output(withProjectRoot(cwd, result), raw);
160
294
  }
161
295
 
162
296
  function cmdInitNewProject(cwd, raw) {
@@ -167,23 +301,67 @@ function cmdInitNewProject(cwd, raw) {
167
301
  const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key');
168
302
  const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile));
169
303
 
170
- // Detect existing code
304
+ // Detect Firecrawl API key availability
305
+ const firecrawlKeyFile = path.join(homedir, '.gsd', 'firecrawl_api_key');
306
+ const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || fs.existsSync(firecrawlKeyFile));
307
+
308
+ // Detect Exa API key availability
309
+ const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key');
310
+ const hasExaSearch = !!(process.env.EXA_API_KEY || fs.existsSync(exaKeyFile));
311
+
312
+ // Detect existing code (cross-platform — no Unix `find` dependency)
171
313
  let hasCode = false;
172
314
  let hasPackageFile = false;
173
315
  try {
174
- const files = execSync('find . -maxdepth 3 \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.swift" -o -name "*.java" \\) 2>/dev/null | grep -v node_modules | grep -v .git | head -5', {
175
- cwd,
176
- encoding: 'utf-8',
177
- stdio: ['pipe', 'pipe', 'pipe'],
178
- });
179
- hasCode = files.trim().length > 0;
180
- } catch {}
316
+ const codeExtensions = new Set([
317
+ '.ts', '.js', '.py', '.go', '.rs', '.swift', '.java',
318
+ '.kt', '.kts', // Kotlin (Android, server-side)
319
+ '.c', '.cpp', '.h', // C/C++
320
+ '.cs', // C#
321
+ '.rb', // Ruby
322
+ '.php', // PHP
323
+ '.dart', // Dart (Flutter)
324
+ '.m', '.mm', // Objective-C / Objective-C++
325
+ '.scala', // Scala
326
+ '.groovy', // Groovy (Gradle build scripts)
327
+ '.lua', // Lua
328
+ '.r', '.R', // R
329
+ '.zig', // Zig
330
+ '.ex', '.exs', // Elixir
331
+ '.clj', // Clojure
332
+ ]);
333
+ const skipDirs = new Set(['node_modules', '.git', '.planning', '.OpenCode', '.codex', '__pycache__', 'target', 'dist', 'build']);
334
+ function findCodeFiles(dir, depth) {
335
+ if (depth > 3) return false;
336
+ let entries;
337
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return false; }
338
+ for (const entry of entries) {
339
+ if (entry.isFile() && codeExtensions.has(path.extname(entry.name))) return true;
340
+ if (entry.isDirectory() && !skipDirs.has(entry.name)) {
341
+ if (findCodeFiles(path.join(dir, entry.name), depth + 1)) return true;
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+ hasCode = findCodeFiles(cwd, 0);
347
+ } catch { /* intentionally empty — best-effort detection */ }
181
348
 
182
349
  hasPackageFile = pathExistsInternal(cwd, 'package.json') ||
183
350
  pathExistsInternal(cwd, 'requirements.txt') ||
184
351
  pathExistsInternal(cwd, 'Cargo.toml') ||
185
352
  pathExistsInternal(cwd, 'go.mod') ||
186
- pathExistsInternal(cwd, 'Package.swift');
353
+ pathExistsInternal(cwd, 'Package.swift') ||
354
+ pathExistsInternal(cwd, 'build.gradle') ||
355
+ pathExistsInternal(cwd, 'build.gradle.kts') ||
356
+ pathExistsInternal(cwd, 'pom.xml') ||
357
+ pathExistsInternal(cwd, 'Gemfile') ||
358
+ pathExistsInternal(cwd, 'composer.json') ||
359
+ pathExistsInternal(cwd, 'pubspec.yaml') ||
360
+ pathExistsInternal(cwd, 'CMakeLists.txt') ||
361
+ pathExistsInternal(cwd, 'Makefile') ||
362
+ pathExistsInternal(cwd, 'build.zig') ||
363
+ pathExistsInternal(cwd, 'mix.exs') ||
364
+ pathExistsInternal(cwd, 'project.clj');
187
365
 
188
366
  const result = {
189
367
  // Models
@@ -210,17 +388,30 @@ function cmdInitNewProject(cwd, raw) {
210
388
 
211
389
  // Enhanced search
212
390
  brave_search_available: hasBraveSearch,
391
+ firecrawl_available: hasFirecrawl,
392
+ exa_search_available: hasExaSearch,
213
393
 
214
394
  // File paths
215
395
  project_path: '.planning/PROJECT.md',
216
396
  };
217
397
 
218
- output(result, raw);
398
+ output(withProjectRoot(cwd, result), raw);
219
399
  }
220
400
 
221
401
  function cmdInitNewMilestone(cwd, raw) {
222
402
  const config = loadConfig(cwd);
223
403
  const milestone = getMilestoneInfo(cwd);
404
+ const latestCompleted = getLatestCompletedMilestone(cwd);
405
+ const phasesDir = path.join(planningDir(cwd), 'phases');
406
+ let phaseDirCount = 0;
407
+
408
+ try {
409
+ if (fs.existsSync(phasesDir)) {
410
+ phaseDirCount = fs.readdirSync(phasesDir, { withFileTypes: true })
411
+ .filter(entry => entry.isDirectory())
412
+ .length;
413
+ }
414
+ } catch {}
224
415
 
225
416
  const result = {
226
417
  // Models
@@ -235,19 +426,23 @@ function cmdInitNewMilestone(cwd, raw) {
235
426
  // Current milestone
236
427
  current_milestone: milestone.version,
237
428
  current_milestone_name: milestone.name,
429
+ latest_completed_milestone: latestCompleted?.version || null,
430
+ latest_completed_milestone_name: latestCompleted?.name || null,
431
+ phase_dir_count: phaseDirCount,
432
+ phase_archive_path: latestCompleted ? toPosixPath(path.relative(cwd, path.join(planningRoot(cwd), 'milestones', `${latestCompleted.version}-phases`))) : null,
238
433
 
239
434
  // File existence
240
435
  project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
241
- roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
242
- state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
436
+ roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
437
+ state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
243
438
 
244
439
  // File paths
245
440
  project_path: '.planning/PROJECT.md',
246
- roadmap_path: '.planning/ROADMAP.md',
247
- state_path: '.planning/STATE.md',
441
+ roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))),
442
+ state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))),
248
443
  };
249
444
 
250
- output(result, raw);
445
+ output(withProjectRoot(cwd, result), raw);
251
446
  }
252
447
 
253
448
  function cmdInitQuick(cwd, description, raw) {
@@ -255,18 +450,25 @@ function cmdInitQuick(cwd, description, raw) {
255
450
  const now = new Date();
256
451
  const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
257
452
 
258
- // Find next quick task number
259
- const quickDir = path.join(cwd, '.planning', 'quick');
260
- let nextNum = 1;
261
- try {
262
- const existing = fs.readdirSync(quickDir)
263
- .filter(f => /^\d+-/.test(f))
264
- .map(f => parseInt(f.split('-')[0], 10))
265
- .filter(n => !isNaN(n));
266
- if (existing.length > 0) {
267
- nextNum = Math.max(...existing) + 1;
268
- }
269
- } catch {}
453
+ // Generate collision-resistant quick task ID: YYMMDD-xxx
454
+ // xxx = 2-second precision blocks since midnight, encoded as 3-char Base36 (lowercase)
455
+ // Range: 000 (00:00:00) to xbz (23:59:58), guaranteed 3 chars for any time of day.
456
+ // Provides ~2s uniqueness window per user — practically collision-free across a team.
457
+ const yy = String(now.getFullYear()).slice(-2);
458
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
459
+ const dd = String(now.getDate()).padStart(2, '0');
460
+ const dateStr = yy + mm + dd;
461
+ const secondsSinceMidnight = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
462
+ const timeBlocks = Math.floor(secondsSinceMidnight / 2);
463
+ const timeEncoded = timeBlocks.toString(36).padStart(3, '0');
464
+ const quickId = dateStr + '-' + timeEncoded;
465
+ const branchSlug = slug || 'quick';
466
+ const quickBranchName = config.quick_branch_template
467
+ ? config.quick_branch_template
468
+ .replace('{num}', quickId)
469
+ .replace('{quick}', quickId)
470
+ .replace('{slug}', branchSlug)
471
+ : null;
270
472
 
271
473
  const result = {
272
474
  // Models
@@ -277,9 +479,10 @@ function cmdInitQuick(cwd, description, raw) {
277
479
 
278
480
  // Config
279
481
  commit_docs: config.commit_docs,
482
+ branch_name: quickBranchName,
280
483
 
281
484
  // Quick task info
282
- next_num: nextNum,
485
+ quick_id: quickId,
283
486
  slug: slug,
284
487
  description: description || null,
285
488
 
@@ -289,15 +492,15 @@ function cmdInitQuick(cwd, description, raw) {
289
492
 
290
493
  // Paths
291
494
  quick_dir: '.planning/quick',
292
- task_dir: slug ? `.planning/quick/${nextNum}-${slug}` : null,
495
+ task_dir: slug ? `.planning/quick/${quickId}-${slug}` : null,
293
496
 
294
497
  // File existence
295
- roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
296
- planning_exists: pathExistsInternal(cwd, '.planning'),
498
+ roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
499
+ planning_exists: fs.existsSync(planningRoot(cwd)),
297
500
 
298
501
  };
299
502
 
300
- output(result, raw);
503
+ output(withProjectRoot(cwd, result), raw);
301
504
  }
302
505
 
303
506
  function cmdInitResume(cwd, raw) {
@@ -306,19 +509,19 @@ function cmdInitResume(cwd, raw) {
306
509
  // Check for interrupted agent
307
510
  let interruptedAgentId = null;
308
511
  try {
309
- interruptedAgentId = fs.readFileSync(path.join(cwd, '.planning', 'current-agent-id.txt'), 'utf-8').trim();
310
- } catch {}
512
+ interruptedAgentId = fs.readFileSync(path.join(planningRoot(cwd), 'current-agent-id.txt'), 'utf-8').trim();
513
+ } catch { /* intentionally empty */ }
311
514
 
312
515
  const result = {
313
516
  // File existence
314
- state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
315
- roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
517
+ state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
518
+ roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
316
519
  project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
317
- planning_exists: pathExistsInternal(cwd, '.planning'),
520
+ planning_exists: fs.existsSync(planningRoot(cwd)),
318
521
 
319
522
  // File paths
320
- state_path: '.planning/STATE.md',
321
- roadmap_path: '.planning/ROADMAP.md',
523
+ state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))),
524
+ roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))),
322
525
  project_path: '.planning/PROJECT.md',
323
526
 
324
527
  // Agent state
@@ -329,7 +532,7 @@ function cmdInitResume(cwd, raw) {
329
532
  commit_docs: config.commit_docs,
330
533
  };
331
534
 
332
- output(result, raw);
535
+ output(withProjectRoot(cwd, result), raw);
333
536
  }
334
537
 
335
538
  function cmdInitVerifyWork(cwd, phase, raw) {
@@ -338,7 +541,28 @@ function cmdInitVerifyWork(cwd, phase, raw) {
338
541
  }
339
542
 
340
543
  const config = loadConfig(cwd);
341
- const phaseInfo = findPhaseInternal(cwd, phase);
544
+ let phaseInfo = findPhaseInternal(cwd, phase);
545
+
546
+ // Fallback to ROADMAP.md if no phase directory exists yet
547
+ if (!phaseInfo) {
548
+ const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
549
+ if (roadmapPhase?.found) {
550
+ const phaseName = roadmapPhase.phase_name;
551
+ phaseInfo = {
552
+ found: true,
553
+ directory: null,
554
+ phase_number: roadmapPhase.phase_number,
555
+ phase_name: phaseName,
556
+ phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
557
+ plans: [],
558
+ summaries: [],
559
+ incomplete_plans: [],
560
+ has_research: false,
561
+ has_context: false,
562
+ has_verification: false,
563
+ };
564
+ }
565
+ }
342
566
 
343
567
  const result = {
344
568
  // Models
@@ -358,13 +582,36 @@ function cmdInitVerifyWork(cwd, phase, raw) {
358
582
  has_verification: phaseInfo?.has_verification || false,
359
583
  };
360
584
 
361
- output(result, raw);
585
+ output(withProjectRoot(cwd, result), raw);
362
586
  }
363
587
 
364
588
  function cmdInitPhaseOp(cwd, phase, raw) {
365
589
  const config = loadConfig(cwd);
366
590
  let phaseInfo = findPhaseInternal(cwd, phase);
367
591
 
592
+ // If the only disk match comes from an archived milestone, prefer the
593
+ // current milestone's ROADMAP entry so discuss-phase and similar flows
594
+ // don't attach to shipped work that reused the same phase number.
595
+ if (phaseInfo?.archived) {
596
+ const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
597
+ if (roadmapPhase?.found) {
598
+ const phaseName = roadmapPhase.phase_name;
599
+ phaseInfo = {
600
+ found: true,
601
+ directory: null,
602
+ phase_number: roadmapPhase.phase_number,
603
+ phase_name: phaseName,
604
+ phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
605
+ plans: [],
606
+ summaries: [],
607
+ incomplete_plans: [],
608
+ has_research: false,
609
+ has_context: false,
610
+ has_verification: false,
611
+ };
612
+ }
613
+ }
614
+
368
615
  // Fallback to ROADMAP.md if no directory exists (e.g., Plans: TBD)
369
616
  if (!phaseInfo) {
370
617
  const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
@@ -390,6 +637,8 @@ function cmdInitPhaseOp(cwd, phase, raw) {
390
637
  // Config
391
638
  commit_docs: config.commit_docs,
392
639
  brave_search: config.brave_search,
640
+ firecrawl: config.firecrawl,
641
+ exa_search: config.exa_search,
393
642
 
394
643
  // Phase info
395
644
  phase_found: !!phaseInfo,
@@ -397,23 +646,24 @@ function cmdInitPhaseOp(cwd, phase, raw) {
397
646
  phase_number: phaseInfo?.phase_number || null,
398
647
  phase_name: phaseInfo?.phase_name || null,
399
648
  phase_slug: phaseInfo?.phase_slug || null,
400
- padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null,
649
+ padded_phase: phaseInfo?.phase_number ? normalizePhaseName(phaseInfo.phase_number) : null,
401
650
 
402
651
  // Existing artifacts
403
652
  has_research: phaseInfo?.has_research || false,
404
653
  has_context: phaseInfo?.has_context || false,
405
654
  has_plans: (phaseInfo?.plans?.length || 0) > 0,
406
655
  has_verification: phaseInfo?.has_verification || false,
656
+ has_reviews: phaseInfo?.has_reviews || false,
407
657
  plan_count: phaseInfo?.plans?.length || 0,
408
658
 
409
659
  // File existence
410
- roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
411
- planning_exists: pathExistsInternal(cwd, '.planning'),
660
+ roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
661
+ planning_exists: fs.existsSync(planningDir(cwd)),
412
662
 
413
663
  // File paths
414
- state_path: '.planning/STATE.md',
415
- roadmap_path: '.planning/ROADMAP.md',
416
- requirements_path: '.planning/REQUIREMENTS.md',
664
+ state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))),
665
+ roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))),
666
+ requirements_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'REQUIREMENTS.md'))),
417
667
  };
418
668
 
419
669
  if (phaseInfo?.directory) {
@@ -436,10 +686,14 @@ function cmdInitPhaseOp(cwd, phase, raw) {
436
686
  if (uatFile) {
437
687
  result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile));
438
688
  }
439
- } catch {}
689
+ const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
690
+ if (reviewsFile) {
691
+ result.reviews_path = toPosixPath(path.join(phaseInfo.directory, reviewsFile));
692
+ }
693
+ } catch { /* intentionally empty */ }
440
694
  }
441
695
 
442
- output(result, raw);
696
+ output(withProjectRoot(cwd, result), raw);
443
697
  }
444
698
 
445
699
  function cmdInitTodos(cwd, area, raw) {
@@ -447,7 +701,7 @@ function cmdInitTodos(cwd, area, raw) {
447
701
  const now = new Date();
448
702
 
449
703
  // List todos (reuse existing logic)
450
- const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
704
+ const pendingDir = path.join(planningDir(cwd), 'todos', 'pending');
451
705
  let count = 0;
452
706
  const todos = [];
453
707
 
@@ -469,11 +723,11 @@ function cmdInitTodos(cwd, area, raw) {
469
723
  created: createdMatch ? createdMatch[1].trim() : 'unknown',
470
724
  title: titleMatch ? titleMatch[1].trim() : 'Untitled',
471
725
  area: todoArea,
472
- path: '.planning/todos/pending/' + file,
726
+ path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'todos', 'pending', file))),
473
727
  });
474
- } catch {}
728
+ } catch { /* intentionally empty */ }
475
729
  }
476
- } catch {}
730
+ } catch { /* intentionally empty */ }
477
731
 
478
732
  const result = {
479
733
  // Config
@@ -489,16 +743,16 @@ function cmdInitTodos(cwd, area, raw) {
489
743
  area_filter: area || null,
490
744
 
491
745
  // Paths
492
- pending_dir: '.planning/todos/pending',
493
- completed_dir: '.planning/todos/completed',
746
+ pending_dir: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'todos', 'pending'))),
747
+ completed_dir: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'todos', 'completed'))),
494
748
 
495
749
  // File existence
496
- planning_exists: pathExistsInternal(cwd, '.planning'),
497
- todos_dir_exists: pathExistsInternal(cwd, '.planning/todos'),
498
- pending_dir_exists: pathExistsInternal(cwd, '.planning/todos/pending'),
750
+ planning_exists: fs.existsSync(planningDir(cwd)),
751
+ todos_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos')),
752
+ pending_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos', 'pending')),
499
753
  };
500
754
 
501
- output(result, raw);
755
+ output(withProjectRoot(cwd, result), raw);
502
756
  }
503
757
 
504
758
  function cmdInitMilestoneOp(cwd, raw) {
@@ -508,7 +762,7 @@ function cmdInitMilestoneOp(cwd, raw) {
508
762
  // Count phases
509
763
  let phaseCount = 0;
510
764
  let completedPhases = 0;
511
- const phasesDir = path.join(cwd, '.planning', 'phases');
765
+ const phasesDir = path.join(planningDir(cwd), 'phases');
512
766
  try {
513
767
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
514
768
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
@@ -520,18 +774,18 @@ function cmdInitMilestoneOp(cwd, raw) {
520
774
  const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
521
775
  const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
522
776
  if (hasSummary) completedPhases++;
523
- } catch {}
777
+ } catch { /* intentionally empty */ }
524
778
  }
525
- } catch {}
779
+ } catch { /* intentionally empty */ }
526
780
 
527
781
  // Check archive
528
- const archiveDir = path.join(cwd, '.planning', 'archive');
782
+ const archiveDir = path.join(planningRoot(cwd), 'archive');
529
783
  let archivedMilestones = [];
530
784
  try {
531
785
  archivedMilestones = fs.readdirSync(archiveDir, { withFileTypes: true })
532
786
  .filter(e => e.isDirectory())
533
787
  .map(e => e.name);
534
- } catch {}
788
+ } catch { /* intentionally empty */ }
535
789
 
536
790
  const result = {
537
791
  // Config
@@ -553,24 +807,24 @@ function cmdInitMilestoneOp(cwd, raw) {
553
807
 
554
808
  // File existence
555
809
  project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
556
- roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
557
- state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
558
- archive_exists: pathExistsInternal(cwd, '.planning/archive'),
559
- phases_dir_exists: pathExistsInternal(cwd, '.planning/phases'),
810
+ roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
811
+ state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
812
+ archive_exists: fs.existsSync(path.join(planningRoot(cwd), 'archive')),
813
+ phases_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'phases')),
560
814
  };
561
815
 
562
- output(result, raw);
816
+ output(withProjectRoot(cwd, result), raw);
563
817
  }
564
818
 
565
819
  function cmdInitMapCodebase(cwd, raw) {
566
820
  const config = loadConfig(cwd);
567
821
 
568
822
  // Check for existing codebase maps
569
- const codebaseDir = path.join(cwd, '.planning', 'codebase');
823
+ const codebaseDir = path.join(planningRoot(cwd), 'codebase');
570
824
  let existingMaps = [];
571
825
  try {
572
826
  existingMaps = fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md'));
573
- } catch {}
827
+ } catch { /* intentionally empty */ }
574
828
 
575
829
  const result = {
576
830
  // Models
@@ -580,6 +834,7 @@ function cmdInitMapCodebase(cwd, raw) {
580
834
  commit_docs: config.commit_docs,
581
835
  search_gitignored: config.search_gitignored,
582
836
  parallelization: config.parallelization,
837
+ subagent_timeout: config.subagent_timeout,
583
838
 
584
839
  // Paths
585
840
  codebase_dir: '.planning/codebase',
@@ -593,27 +848,324 @@ function cmdInitMapCodebase(cwd, raw) {
593
848
  codebase_dir_exists: pathExistsInternal(cwd, '.planning/codebase'),
594
849
  };
595
850
 
596
- output(result, raw);
851
+ output(withProjectRoot(cwd, result), raw);
852
+ }
853
+
854
+ function cmdInitManager(cwd, raw) {
855
+ const config = loadConfig(cwd);
856
+ const milestone = getMilestoneInfo(cwd);
857
+
858
+ // Use planningPaths for forward-compatibility with workstream scoping (#1268)
859
+ const paths = planningPaths(cwd);
860
+
861
+ // Validate prerequisites
862
+ if (!fs.existsSync(paths.roadmap)) {
863
+ error('No ROADMAP.md found. Run /gsd-new-milestone first.');
864
+ }
865
+ if (!fs.existsSync(paths.state)) {
866
+ error('No STATE.md found. Run /gsd-new-milestone first.');
867
+ }
868
+ const rawContent = fs.readFileSync(paths.roadmap, 'utf-8');
869
+ const content = extractCurrentMilestone(rawContent, cwd);
870
+ const phasesDir = paths.phases;
871
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
872
+
873
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
874
+ const phases = [];
875
+ let match;
876
+
877
+ while ((match = phasePattern.exec(content)) !== null) {
878
+ const phaseNum = match[1];
879
+ const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
880
+
881
+ const sectionStart = match.index;
882
+ const restOfContent = content.slice(sectionStart);
883
+ const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
884
+ const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
885
+ const section = content.slice(sectionStart, sectionEnd);
886
+
887
+ const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
888
+ const goal = goalMatch ? goalMatch[1].trim() : null;
889
+
890
+ const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
891
+ const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
892
+
893
+ const normalized = normalizePhaseName(phaseNum);
894
+ let diskStatus = 'no_directory';
895
+ let planCount = 0;
896
+ let summaryCount = 0;
897
+ let hasContext = false;
898
+ let hasResearch = false;
899
+ let lastActivity = null;
900
+ let isActive = false;
901
+
902
+ try {
903
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
904
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(isDirInMilestone);
905
+ const dirMatch = dirs.find(d => phaseTokenMatches(d, normalized));
906
+
907
+ if (dirMatch) {
908
+ const fullDir = path.join(phasesDir, dirMatch);
909
+ const phaseFiles = fs.readdirSync(fullDir);
910
+ planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
911
+ summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
912
+ hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
913
+ hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
914
+
915
+ if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
916
+ else if (summaryCount > 0) diskStatus = 'partial';
917
+ else if (planCount > 0) diskStatus = 'planned';
918
+ else if (hasResearch) diskStatus = 'researched';
919
+ else if (hasContext) diskStatus = 'discussed';
920
+ else diskStatus = 'empty';
921
+
922
+ // Activity detection: check most recent file mtime
923
+ const now = Date.now();
924
+ let newestMtime = 0;
925
+ for (const f of phaseFiles) {
926
+ try {
927
+ const stat = fs.statSync(path.join(fullDir, f));
928
+ if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs;
929
+ } catch { /* intentionally empty */ }
930
+ }
931
+ if (newestMtime > 0) {
932
+ lastActivity = new Date(newestMtime).toISOString();
933
+ isActive = (now - newestMtime) < 300000; // 5 minutes
934
+ }
935
+ }
936
+ } catch { /* intentionally empty */ }
937
+
938
+ // Check ROADMAP checkbox status
939
+ const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[:\\s]`, 'i');
940
+ const checkboxMatch = content.match(checkboxPattern);
941
+ const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
942
+ if (roadmapComplete && diskStatus !== 'complete') {
943
+ diskStatus = 'complete';
944
+ }
945
+
946
+ phases.push({
947
+ number: phaseNum,
948
+ name: phaseName,
949
+ goal,
950
+ depends_on,
951
+ disk_status: diskStatus,
952
+ has_context: hasContext,
953
+ has_research: hasResearch,
954
+ plan_count: planCount,
955
+ summary_count: summaryCount,
956
+ roadmap_complete: roadmapComplete,
957
+ last_activity: lastActivity,
958
+ is_active: isActive,
959
+ });
960
+ }
961
+
962
+ // Compute display names: truncate to keep table aligned
963
+ const MAX_NAME_WIDTH = 20;
964
+ for (const phase of phases) {
965
+ if (phase.name.length > MAX_NAME_WIDTH) {
966
+ phase.display_name = phase.name.slice(0, MAX_NAME_WIDTH - 1) + '…';
967
+ } else {
968
+ phase.display_name = phase.name;
969
+ }
970
+ }
971
+
972
+ // Dependency satisfaction: check if all depends_on phases are complete
973
+ const completedNums = new Set(phases.filter(p => p.disk_status === 'complete').map(p => p.number));
974
+ for (const phase of phases) {
975
+ if (!phase.depends_on || /^none$/i.test(phase.depends_on.trim())) {
976
+ phase.deps_satisfied = true;
977
+ } else {
978
+ // Parse "Phase 1, Phase 3" or "1, 3" formats
979
+ const depNums = phase.depends_on.match(/\d+(?:\.\d+)*/g) || [];
980
+ phase.deps_satisfied = depNums.every(n => completedNums.has(n));
981
+ phase.dep_phases = depNums;
982
+ }
983
+ }
984
+
985
+ // Compact dependency display for dashboard
986
+ for (const phase of phases) {
987
+ phase.deps_display = (phase.dep_phases && phase.dep_phases.length > 0)
988
+ ? phase.dep_phases.join(',')
989
+ : '—';
990
+ }
991
+
992
+ // Sliding window: discuss is sequential — only the first undiscussed phase is available
993
+ let foundNextToDiscuss = false;
994
+ for (const phase of phases) {
995
+ if (!foundNextToDiscuss && (phase.disk_status === 'empty' || phase.disk_status === 'no_directory')) {
996
+ phase.is_next_to_discuss = true;
997
+ foundNextToDiscuss = true;
998
+ } else {
999
+ phase.is_next_to_discuss = false;
1000
+ }
1001
+ }
1002
+
1003
+ // Check for WAITING.json signal
1004
+ let waitingSignal = null;
1005
+ try {
1006
+ const waitingPath = path.join(cwd, '.planning', 'WAITING.json');
1007
+ if (fs.existsSync(waitingPath)) {
1008
+ waitingSignal = JSON.parse(fs.readFileSync(waitingPath, 'utf-8'));
1009
+ }
1010
+ } catch { /* intentionally empty */ }
1011
+
1012
+ // Compute recommended actions (execute > plan > discuss)
1013
+ // Skip BACKLOG phases (999.x numbering) — they are parked ideas, not active work
1014
+ const recommendedActions = [];
1015
+ for (const phase of phases) {
1016
+ if (phase.disk_status === 'complete') continue;
1017
+ if (/^999(?:\.|$)/.test(phase.number)) continue;
1018
+
1019
+ if (phase.disk_status === 'planned' && phase.deps_satisfied) {
1020
+ recommendedActions.push({
1021
+ phase: phase.number,
1022
+ phase_name: phase.name,
1023
+ action: 'execute',
1024
+ reason: `${phase.plan_count} plans ready, dependencies met`,
1025
+ command: `/gsd-execute-phase ${phase.number}`,
1026
+ });
1027
+ } else if (phase.disk_status === 'discussed' || phase.disk_status === 'researched') {
1028
+ recommendedActions.push({
1029
+ phase: phase.number,
1030
+ phase_name: phase.name,
1031
+ action: 'plan',
1032
+ reason: 'Context gathered, ready for planning',
1033
+ command: `/gsd-plan-phase ${phase.number}`,
1034
+ });
1035
+ } else if ((phase.disk_status === 'empty' || phase.disk_status === 'no_directory') && phase.is_next_to_discuss) {
1036
+ recommendedActions.push({
1037
+ phase: phase.number,
1038
+ phase_name: phase.name,
1039
+ action: 'discuss',
1040
+ reason: 'Unblocked, ready to gather context',
1041
+ command: `/gsd-discuss-phase ${phase.number}`,
1042
+ });
1043
+ }
1044
+ }
1045
+
1046
+ // Filter recommendations: no parallel execute/plan unless phases are independent
1047
+ // Two phases are "independent" if neither depends on the other (directly or transitively)
1048
+ const phaseMap = new Map(phases.map(p => [p.number, p]));
1049
+
1050
+ function reaches(from, to, visited = new Set()) {
1051
+ if (visited.has(from)) return false;
1052
+ visited.add(from);
1053
+ const p = phaseMap.get(from);
1054
+ if (!p || !p.dep_phases || p.dep_phases.length === 0) return false;
1055
+ if (p.dep_phases.includes(to)) return true;
1056
+ return p.dep_phases.some(dep => reaches(dep, to, visited));
1057
+ }
1058
+
1059
+ function hasDepRelationship(numA, numB) {
1060
+ return reaches(numA, numB) || reaches(numB, numA);
1061
+ }
1062
+
1063
+ // Detect phases with active work (file modified in last 5 min)
1064
+ const activeExecuting = phases.filter(p =>
1065
+ p.disk_status === 'partial' ||
1066
+ (p.disk_status === 'planned' && p.is_active)
1067
+ );
1068
+ const activePlanning = phases.filter(p =>
1069
+ p.is_active && (p.disk_status === 'discussed' || p.disk_status === 'researched')
1070
+ );
1071
+
1072
+ const filteredActions = recommendedActions.filter(action => {
1073
+ if (action.action === 'execute' && activeExecuting.length > 0) {
1074
+ // Only allow if independent of ALL actively-executing phases
1075
+ return activeExecuting.every(active => !hasDepRelationship(action.phase, active.number));
1076
+ }
1077
+ if (action.action === 'plan' && activePlanning.length > 0) {
1078
+ // Only allow if independent of ALL actively-planning phases
1079
+ return activePlanning.every(active => !hasDepRelationship(action.phase, active.number));
1080
+ }
1081
+ return true;
1082
+ });
1083
+
1084
+ const completedCount = phases.filter(p => p.disk_status === 'complete').length;
1085
+
1086
+ // read manager flags from config (passthrough flags for each step)
1087
+ // Validate: flags must be CLI-safe (only --flags, alphanumeric, hyphens, spaces)
1088
+ const sanitizeFlags = (raw) => {
1089
+ const val = typeof raw === 'string' ? raw : '';
1090
+ if (!val) return '';
1091
+ // Allow only --flag patterns with alphanumeric/hyphen values separated by spaces
1092
+ const tokens = val.split(/\s+/).filter(Boolean);
1093
+ const safe = tokens.every(t => /^--[a-zA-Z0-9][-a-zA-Z0-9]*$/.test(t) || /^[a-zA-Z0-9][-a-zA-Z0-9_.]*$/.test(t));
1094
+ if (!safe) {
1095
+ process.stderr.write(`gsd-tools: warning: manager.flags contains invalid tokens, ignoring: ${val}\n`);
1096
+ return '';
1097
+ }
1098
+ return val;
1099
+ };
1100
+ const managerFlags = {
1101
+ discuss: sanitizeFlags(config.manager && config.manager.flags && config.manager.flags.discuss),
1102
+ plan: sanitizeFlags(config.manager && config.manager.flags && config.manager.flags.plan),
1103
+ execute: sanitizeFlags(config.manager && config.manager.flags && config.manager.flags.execute),
1104
+ };
1105
+
1106
+ const result = {
1107
+ milestone_version: milestone.version,
1108
+ milestone_name: milestone.name,
1109
+ phases,
1110
+ phase_count: phases.length,
1111
+ completed_count: completedCount,
1112
+ in_progress_count: phases.filter(p => ['partial', 'planned', 'discussed', 'researched'].includes(p.disk_status)).length,
1113
+ recommended_actions: filteredActions,
1114
+ waiting_signal: waitingSignal,
1115
+ all_complete: completedCount === phases.length && phases.length > 0,
1116
+ project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
1117
+ roadmap_exists: true,
1118
+ state_exists: true,
1119
+ manager_flags: managerFlags,
1120
+ };
1121
+
1122
+ output(withProjectRoot(cwd, result), raw);
597
1123
  }
598
1124
 
599
1125
  function cmdInitProgress(cwd, raw) {
600
1126
  const config = loadConfig(cwd);
601
1127
  const milestone = getMilestoneInfo(cwd);
602
1128
 
603
- // Analyze phases
604
- const phasesDir = path.join(cwd, '.planning', 'phases');
1129
+ // Analyze phases — filter to current milestone and include ROADMAP-only phases
1130
+ const phasesDir = path.join(planningDir(cwd), 'phases');
605
1131
  const phases = [];
606
1132
  let currentPhase = null;
607
1133
  let nextPhase = null;
608
1134
 
1135
+ // Build set of phases defined in ROADMAP for the current milestone
1136
+ const roadmapPhaseNums = new Set();
1137
+ const roadmapPhaseNames = new Map();
1138
+ try {
1139
+ const roadmapContent = extractCurrentMilestone(
1140
+ fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd
1141
+ );
1142
+ const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1143
+ let hm;
1144
+ while ((hm = headingPattern.exec(roadmapContent)) !== null) {
1145
+ roadmapPhaseNums.add(hm[1]);
1146
+ roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
1147
+ }
1148
+ } catch { /* intentionally empty */ }
1149
+
1150
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
1151
+ const seenPhaseNums = new Set();
1152
+
609
1153
  try {
610
1154
  const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
611
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
1155
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
1156
+ .filter(isDirInMilestone)
1157
+ .sort((a, b) => {
1158
+ const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1159
+ const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1160
+ if (!pa || !pb) return a.localeCompare(b);
1161
+ return parseInt(pa[1], 10) - parseInt(pb[1], 10);
1162
+ });
612
1163
 
613
1164
  for (const dir of dirs) {
614
- const match = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
1165
+ const match = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
615
1166
  const phaseNumber = match ? match[1] : dir;
616
1167
  const phaseName = match && match[2] ? match[2] : null;
1168
+ seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0');
617
1169
 
618
1170
  const phasePath = path.join(phasesDir, dir);
619
1171
  const phaseFiles = fs.readdirSync(phasePath);
@@ -629,7 +1181,7 @@ function cmdInitProgress(cwd, raw) {
629
1181
  const phaseInfo = {
630
1182
  number: phaseNumber,
631
1183
  name: phaseName,
632
- directory: '.planning/phases/' + dir,
1184
+ directory: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'phases', dir))),
633
1185
  status,
634
1186
  plan_count: plans.length,
635
1187
  summary_count: summaries.length,
@@ -646,15 +1198,38 @@ function cmdInitProgress(cwd, raw) {
646
1198
  nextPhase = phaseInfo;
647
1199
  }
648
1200
  }
649
- } catch {}
1201
+ } catch { /* intentionally empty */ }
1202
+
1203
+ // Add phases defined in ROADMAP but not yet scaffolded to disk
1204
+ for (const [num, name] of roadmapPhaseNames) {
1205
+ const stripped = num.replace(/^0+/, '') || '0';
1206
+ if (!seenPhaseNums.has(stripped)) {
1207
+ const phaseInfo = {
1208
+ number: num,
1209
+ name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
1210
+ directory: null,
1211
+ status: 'not_started',
1212
+ plan_count: 0,
1213
+ summary_count: 0,
1214
+ has_research: false,
1215
+ };
1216
+ phases.push(phaseInfo);
1217
+ if (!nextPhase && !currentPhase) {
1218
+ nextPhase = phaseInfo;
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ // Re-sort phases by number after adding ROADMAP-only phases
1224
+ phases.sort((a, b) => parseInt(a.number, 10) - parseInt(b.number, 10));
650
1225
 
651
1226
  // Check for paused work
652
1227
  let pausedAt = null;
653
1228
  try {
654
- const state = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf-8');
1229
+ const state = fs.readFileSync(path.join(planningDir(cwd), 'STATE.md'), 'utf-8');
655
1230
  const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/);
656
1231
  if (pauseMatch) pausedAt = pauseMatch[1].trim();
657
- } catch {}
1232
+ } catch { /* intentionally empty */ }
658
1233
 
659
1234
  const result = {
660
1235
  // Models
@@ -682,18 +1257,248 @@ function cmdInitProgress(cwd, raw) {
682
1257
 
683
1258
  // File existence
684
1259
  project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
685
- roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'),
686
- state_exists: pathExistsInternal(cwd, '.planning/STATE.md'),
1260
+ roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
1261
+ state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
687
1262
  // File paths
688
- state_path: '.planning/STATE.md',
689
- roadmap_path: '.planning/ROADMAP.md',
1263
+ state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))),
1264
+ roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))),
690
1265
  project_path: '.planning/PROJECT.md',
691
- config_path: '.planning/config.json',
1266
+ config_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'config.json'))),
1267
+ };
1268
+
1269
+ output(withProjectRoot(cwd, result), raw);
1270
+ }
1271
+
1272
+ /**
1273
+ * Detect child git repos in a directory (one level deep).
1274
+ * Returns array of { name, path, has_uncommitted } objects.
1275
+ */
1276
+ function detectChildRepos(dir) {
1277
+ const repos = [];
1278
+ let entries;
1279
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return repos; }
1280
+ for (const entry of entries) {
1281
+ if (!entry.isDirectory()) continue;
1282
+ if (entry.name.startsWith('.')) continue;
1283
+ const fullPath = path.join(dir, entry.name);
1284
+ const gitDir = path.join(fullPath, '.git');
1285
+ if (fs.existsSync(gitDir)) {
1286
+ let hasUncommitted = false;
1287
+ try {
1288
+ const status = execSync('git status --porcelain', { cwd: fullPath, encoding: 'utf8', timeout: 5000 });
1289
+ hasUncommitted = status.trim().length > 0;
1290
+ } catch { /* best-effort */ }
1291
+ repos.push({ name: entry.name, path: fullPath, has_uncommitted: hasUncommitted });
1292
+ }
1293
+ }
1294
+ return repos;
1295
+ }
1296
+
1297
+ function cmdInitNewWorkspace(cwd, raw) {
1298
+ const homedir = process.env.HOME || require('os').homedir();
1299
+ const defaultBase = path.join(homedir, 'gsd-workspaces');
1300
+
1301
+ // Detect child git repos for interactive selection
1302
+ const childRepos = detectChildRepos(cwd);
1303
+
1304
+ // Check if git worktree is available
1305
+ let worktreeAvailable = false;
1306
+ try {
1307
+ execSync('git --version', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
1308
+ worktreeAvailable = true;
1309
+ } catch { /* no git at all */ }
1310
+
1311
+ const result = {
1312
+ default_workspace_base: defaultBase,
1313
+ child_repos: childRepos,
1314
+ child_repo_count: childRepos.length,
1315
+ worktree_available: worktreeAvailable,
1316
+ is_git_repo: pathExistsInternal(cwd, '.git'),
1317
+ cwd_repo_name: path.basename(cwd),
1318
+ };
1319
+
1320
+ output(withProjectRoot(cwd, result), raw);
1321
+ }
1322
+
1323
+ function cmdInitListWorkspaces(cwd, raw) {
1324
+ const homedir = process.env.HOME || require('os').homedir();
1325
+ const defaultBase = path.join(homedir, 'gsd-workspaces');
1326
+
1327
+ const workspaces = [];
1328
+ if (fs.existsSync(defaultBase)) {
1329
+ let entries;
1330
+ try { entries = fs.readdirSync(defaultBase, { withFileTypes: true }); } catch { entries = []; }
1331
+ for (const entry of entries) {
1332
+ if (!entry.isDirectory()) continue;
1333
+ const wsPath = path.join(defaultBase, entry.name);
1334
+ const manifestPath = path.join(wsPath, 'WORKSPACE.md');
1335
+ if (!fs.existsSync(manifestPath)) continue;
1336
+
1337
+ let repoCount = 0;
1338
+ let hasProject = false;
1339
+ let strategy = 'unknown';
1340
+ try {
1341
+ const manifest = fs.readFileSync(manifestPath, 'utf8');
1342
+ const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
1343
+ if (strategyMatch) strategy = strategyMatch[1].trim();
1344
+ // Count table rows (lines starting with |, excluding header and separator)
1345
+ const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---'));
1346
+ repoCount = tableRows.length;
1347
+ } catch { /* best-effort */ }
1348
+ hasProject = fs.existsSync(path.join(wsPath, '.planning', 'PROJECT.md'));
1349
+
1350
+ workspaces.push({
1351
+ name: entry.name,
1352
+ path: wsPath,
1353
+ repo_count: repoCount,
1354
+ strategy,
1355
+ has_project: hasProject,
1356
+ });
1357
+ }
1358
+ }
1359
+
1360
+ const result = {
1361
+ workspace_base: defaultBase,
1362
+ workspaces,
1363
+ workspace_count: workspaces.length,
692
1364
  };
693
1365
 
694
1366
  output(result, raw);
695
1367
  }
696
1368
 
1369
+ function cmdInitRemoveWorkspace(cwd, name, raw) {
1370
+ const homedir = process.env.HOME || require('os').homedir();
1371
+ const defaultBase = path.join(homedir, 'gsd-workspaces');
1372
+
1373
+ if (!name) {
1374
+ error('workspace name required for init remove-workspace');
1375
+ }
1376
+
1377
+ const wsPath = path.join(defaultBase, name);
1378
+ const manifestPath = path.join(wsPath, 'WORKSPACE.md');
1379
+
1380
+ if (!fs.existsSync(wsPath)) {
1381
+ error(`Workspace not found: ${wsPath}`);
1382
+ }
1383
+
1384
+ // Parse manifest for repo info
1385
+ const repos = [];
1386
+ let strategy = 'unknown';
1387
+ if (fs.existsSync(manifestPath)) {
1388
+ try {
1389
+ const manifest = fs.readFileSync(manifestPath, 'utf8');
1390
+ const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
1391
+ if (strategyMatch) strategy = strategyMatch[1].trim();
1392
+
1393
+ // Parse table rows for repo names and source paths
1394
+ const lines = manifest.split('\n');
1395
+ for (const line of lines) {
1396
+ const match = line.match(/^\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|$/);
1397
+ if (match && match[1] !== 'Repo' && !match[1].includes('---')) {
1398
+ repos.push({ name: match[1], source: match[2], branch: match[3], strategy: match[4] });
1399
+ }
1400
+ }
1401
+ } catch { /* best-effort */ }
1402
+ }
1403
+
1404
+ // Check for uncommitted changes in workspace repos
1405
+ const dirtyRepos = [];
1406
+ for (const repo of repos) {
1407
+ const repoPath = path.join(wsPath, repo.name);
1408
+ if (!fs.existsSync(repoPath)) continue;
1409
+ try {
1410
+ const status = execSync('git status --porcelain', { cwd: repoPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
1411
+ if (status.trim().length > 0) {
1412
+ dirtyRepos.push(repo.name);
1413
+ }
1414
+ } catch { /* best-effort */ }
1415
+ }
1416
+
1417
+ const result = {
1418
+ workspace_name: name,
1419
+ workspace_path: wsPath,
1420
+ has_manifest: fs.existsSync(manifestPath),
1421
+ strategy,
1422
+ repos,
1423
+ repo_count: repos.length,
1424
+ dirty_repos: dirtyRepos,
1425
+ has_dirty_repos: dirtyRepos.length > 0,
1426
+ };
1427
+
1428
+ output(result, raw);
1429
+ }
1430
+
1431
+ /**
1432
+ * Build a formatted agent skills block for injection into task() prompts.
1433
+ *
1434
+ * Reads `config.agent_skills[agentType]` and validates each skill path exists
1435
+ * within the project root. Returns a formatted `<agent_skills>` block or empty
1436
+ * string if no skills are configured.
1437
+ *
1438
+ * @param {object} config - Loaded project config
1439
+ * @param {string} agentType - The agent type (e.g., 'gsd-executor', 'gsd-planner')
1440
+ * @param {string} projectRoot - Absolute path to project root (for path validation)
1441
+ * @returns {string} Formatted skills block or empty string
1442
+ */
1443
+ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1444
+ const { validatePath } = require('./security.cjs');
1445
+
1446
+ if (!config || !config.agent_skills || !agentType) return '';
1447
+
1448
+ let skillPaths = config.agent_skills[agentType];
1449
+ if (!skillPaths) return '';
1450
+
1451
+ // Normalize single string to array
1452
+ if (typeof skillPaths === 'string') skillPaths = [skillPaths];
1453
+ if (!Array.isArray(skillPaths) || skillPaths.length === 0) return '';
1454
+
1455
+ const validPaths = [];
1456
+ for (const skillPath of skillPaths) {
1457
+ if (typeof skillPath !== 'string') continue;
1458
+
1459
+ // Validate path safety — must resolve within project root
1460
+ const pathCheck = validatePath(skillPath, projectRoot);
1461
+ if (!pathCheck.safe) {
1462
+ process.stderr.write(`[agent-skills] WARNING: Skipping unsafe path "${skillPath}": ${pathCheck.error}\n`);
1463
+ continue;
1464
+ }
1465
+
1466
+ // Check that the skill directory and SKILL.md exist
1467
+ const skillMdPath = path.join(projectRoot, skillPath, 'SKILL.md');
1468
+ if (!fs.existsSync(skillMdPath)) {
1469
+ process.stderr.write(`[agent-skills] WARNING: skill not found at "${skillPath}/SKILL.md" — skipping\n`);
1470
+ continue;
1471
+ }
1472
+
1473
+ validPaths.push(skillPath);
1474
+ }
1475
+
1476
+ if (validPaths.length === 0) return '';
1477
+
1478
+ const lines = validPaths.map(p => `- @${p}/SKILL.md`).join('\n');
1479
+ return `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
1480
+ }
1481
+
1482
+ /**
1483
+ * Command: output the agent skills block for a given agent type.
1484
+ * Used by workflows: SKILLS=$(node "$TOOLS" agent-skills gsd-executor 2>/dev/null)
1485
+ */
1486
+ function cmdAgentSkills(cwd, agentType, raw) {
1487
+ if (!agentType) {
1488
+ // No agent type — output empty string silently
1489
+ output('', raw, '');
1490
+ return;
1491
+ }
1492
+
1493
+ const config = loadConfig(cwd);
1494
+ const block = buildAgentSkillsBlock(config, agentType, cwd);
1495
+ // Output raw text (not JSON) so workflows can embed it directly
1496
+ if (block) {
1497
+ process.stdout.write(block);
1498
+ }
1499
+ process.exit(0);
1500
+ }
1501
+
697
1502
  module.exports = {
698
1503
  cmdInitExecutePhase,
699
1504
  cmdInitPlanPhase,
@@ -707,4 +1512,11 @@ module.exports = {
707
1512
  cmdInitMilestoneOp,
708
1513
  cmdInitMapCodebase,
709
1514
  cmdInitProgress,
1515
+ cmdInitManager,
1516
+ cmdInitNewWorkspace,
1517
+ cmdInitListWorkspaces,
1518
+ cmdInitRemoveWorkspace,
1519
+ detectChildRepos,
1520
+ buildAgentSkillsBlock,
1521
+ cmdAgentSkills,
710
1522
  };