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,8 +4,36 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { execSync } = require('child_process');
7
- const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
7
+ const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, planningDir, planningPaths, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs');
8
8
  const { extractFrontmatter } = require('./frontmatter.cjs');
9
+ const { MODEL_PROFILES } = require('./model-profiles.cjs');
10
+
11
+ /**
12
+ * Determine phase status by checking plan/summary counts AND verification state.
13
+ * Introduces "Executed" for phases with all summaries but no passing verification.
14
+ */
15
+ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
16
+ if (plans === 0) return defaultPending;
17
+ if (summaries < plans && summaries > 0) return 'In Progress';
18
+ if (summaries < plans) return 'Planned';
19
+
20
+ // summaries >= plans — check verification
21
+ try {
22
+ const files = fs.readdirSync(phaseDir);
23
+ const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
24
+ if (verificationFile) {
25
+ const content = fs.readFileSync(path.join(phaseDir, verificationFile), 'utf-8');
26
+ if (/status:\s*passed/i.test(content)) return 'Complete';
27
+ if (/status:\s*human_needed/i.test(content)) return 'Needs Review';
28
+ if (/status:\s*gaps_found/i.test(content)) return 'Executed';
29
+ // Verification exists but unrecognized status — treat as executed
30
+ return 'Executed';
31
+ }
32
+ } catch { /* directory read failed — fall through */ }
33
+
34
+ // No verification file — executed but not verified
35
+ return 'Executed';
36
+ }
9
37
 
10
38
  function cmdGenerateSlug(text, raw) {
11
39
  if (!text) {
@@ -15,7 +43,8 @@ function cmdGenerateSlug(text, raw) {
15
43
  const slug = text
16
44
  .toLowerCase()
17
45
  .replace(/[^a-z0-9]+/g, '-')
18
- .replace(/^-+|-+$/g, '');
46
+ .replace(/^-+|-+$/g, '')
47
+ .substring(0, 60);
19
48
 
20
49
  const result = { slug };
21
50
  output(result, raw, slug);
@@ -42,7 +71,7 @@ function cmdCurrentTimestamp(format, raw) {
42
71
  }
43
72
 
44
73
  function cmdListTodos(cwd, area, raw) {
45
- const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
74
+ const pendingDir = path.join(planningDir(cwd), 'todos', 'pending');
46
75
 
47
76
  let count = 0;
48
77
  const todos = [];
@@ -68,11 +97,11 @@ function cmdListTodos(cwd, area, raw) {
68
97
  created: createdMatch ? createdMatch[1].trim() : 'unknown',
69
98
  title: titleMatch ? titleMatch[1].trim() : 'Untitled',
70
99
  area: todoArea,
71
- path: toPosixPath(path.join('.planning', 'todos', 'pending', file)),
100
+ path: toPosixPath(path.relative(cwd, path.join(pendingDir, file))),
72
101
  });
73
- } catch {}
102
+ } catch { /* intentionally empty */ }
74
103
  }
75
- } catch {}
104
+ } catch { /* intentionally empty */ }
76
105
 
77
106
  const result = { count, todos };
78
107
  output(result, raw, count.toString());
@@ -83,6 +112,11 @@ function cmdVerifyPathExists(cwd, targetPath, raw) {
83
112
  error('path required for verification');
84
113
  }
85
114
 
115
+ // Reject null bytes and validate path does not contain traversal attempts
116
+ if (targetPath.includes('\0')) {
117
+ error('path contains null bytes');
118
+ }
119
+
86
120
  const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
87
121
 
88
122
  try {
@@ -97,7 +131,7 @@ function cmdVerifyPathExists(cwd, targetPath, raw) {
97
131
  }
98
132
 
99
133
  function cmdHistoryDigest(cwd, raw) {
100
- const phasesDir = path.join(cwd, '.planning', 'phases');
134
+ const phasesDir = planningPaths(cwd).phases;
101
135
  const digest = { phases: {}, decisions: [], tech_stack: new Set() };
102
136
 
103
137
  // Collect all phase directories: archived + current
@@ -119,7 +153,7 @@ function cmdHistoryDigest(cwd, raw) {
119
153
  for (const dir of currentDirs) {
120
154
  allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null });
121
155
  }
122
- } catch {}
156
+ } catch { /* intentionally empty */ }
123
157
  }
124
158
 
125
159
  if (allPhaseDirs.length === 0) {
@@ -213,11 +247,18 @@ function cmdResolveModel(cwd, agentType, raw) {
213
247
  output(result, raw, model);
214
248
  }
215
249
 
216
- function cmdCommit(cwd, message, files, raw, amend) {
250
+ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
217
251
  if (!message && !amend) {
218
252
  error('commit message required');
219
253
  }
220
254
 
255
+ // Sanitize commit message: strip invisible chars and injection markers
256
+ // that could hijack agent context when commit messages are read back
257
+ if (message) {
258
+ const { sanitizeForPrompt } = require('./security.cjs');
259
+ message = sanitizeForPrompt(message);
260
+ }
261
+
221
262
  const config = loadConfig(cwd);
222
263
 
223
264
  // Check commit_docs config
@@ -234,14 +275,58 @@ function cmdCommit(cwd, message, files, raw, amend) {
234
275
  return;
235
276
  }
236
277
 
278
+ // Ensure branching strategy branch exists before first commit (#1278).
279
+ // Pre-execution workflows (discuss, plan, research) commit artifacts but the branch
280
+ // was previously only created during execute-phase — too late.
281
+ if (config.branching_strategy && config.branching_strategy !== 'none') {
282
+ let branchName = null;
283
+ if (config.branching_strategy === 'phase') {
284
+ // Determine which phase we're committing for from the file paths
285
+ const phaseMatch = (files || []).join(' ').match(/(\d+(?:\.\d+)*)-/);
286
+ if (phaseMatch) {
287
+ const phaseNum = phaseMatch[1];
288
+ const phaseInfo = findPhaseInternal(cwd, phaseNum);
289
+ if (phaseInfo) {
290
+ branchName = config.phase_branch_template
291
+ .replace('{phase}', phaseInfo.phase_number)
292
+ .replace('{slug}', phaseInfo.phase_slug || 'phase');
293
+ }
294
+ }
295
+ } else if (config.branching_strategy === 'milestone') {
296
+ const milestone = getMilestoneInfo(cwd);
297
+ if (milestone && milestone.version) {
298
+ branchName = config.milestone_branch_template
299
+ .replace('{milestone}', milestone.version)
300
+ .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone');
301
+ }
302
+ }
303
+ if (branchName) {
304
+ const currentBranch = execGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
305
+ if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
306
+ // Create branch if it doesn't exist, or switch to it if it does
307
+ const create = execGit(cwd, ['checkout', '-b', branchName]);
308
+ if (create.exitCode !== 0) {
309
+ execGit(cwd, ['checkout', branchName]);
310
+ }
311
+ }
312
+ }
313
+ }
314
+
237
315
  // Stage files
238
316
  const filesToStage = files && files.length > 0 ? files : ['.planning/'];
239
317
  for (const file of filesToStage) {
240
- execGit(cwd, ['add', file]);
318
+ const fullPath = path.join(cwd, file);
319
+ if (!fs.existsSync(fullPath)) {
320
+ // File was deleted/moved — stage the deletion
321
+ execGit(cwd, ['rm', '--cached', '--ignore-unmatch', file]);
322
+ } else {
323
+ execGit(cwd, ['add', file]);
324
+ }
241
325
  }
242
326
 
243
- // Commit
327
+ // Commit (--no-verify skips pre-commit hooks, used by parallel executor agents)
244
328
  const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message];
329
+ if (noVerify) commitArgs.push('--no-verify');
245
330
  const commitResult = execGit(cwd, commitArgs);
246
331
  if (commitResult.exitCode !== 0) {
247
332
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
@@ -261,6 +346,74 @@ function cmdCommit(cwd, message, files, raw, amend) {
261
346
  output(result, raw, hash || 'committed');
262
347
  }
263
348
 
349
+ function cmdCommitToSubrepo(cwd, message, files, raw) {
350
+ if (!message) {
351
+ error('commit message required');
352
+ }
353
+
354
+ const config = loadConfig(cwd);
355
+ const subRepos = config.sub_repos;
356
+
357
+ if (!subRepos || subRepos.length === 0) {
358
+ error('no sub_repos configured in .planning/config.json');
359
+ }
360
+
361
+ if (!files || files.length === 0) {
362
+ error('--files required for commit-to-subrepo');
363
+ }
364
+
365
+ // Group files by sub-repo prefix
366
+ const grouped = {};
367
+ const unmatched = [];
368
+ for (const file of files) {
369
+ const match = subRepos.find(repo => file.startsWith(repo + '/'));
370
+ if (match) {
371
+ if (!grouped[match]) grouped[match] = [];
372
+ grouped[match].push(file);
373
+ } else {
374
+ unmatched.push(file);
375
+ }
376
+ }
377
+
378
+ if (unmatched.length > 0) {
379
+ process.stderr.write(`Warning: ${unmatched.length} file(s) did not match any sub-repo prefix: ${unmatched.join(', ')}\n`);
380
+ }
381
+
382
+ const repos = {};
383
+ for (const [repo, repoFiles] of Object.entries(grouped)) {
384
+ const repoCwd = path.join(cwd, repo);
385
+
386
+ // Stage files (strip sub-repo prefix for paths relative to that repo)
387
+ for (const file of repoFiles) {
388
+ const relativePath = file.slice(repo.length + 1);
389
+ execGit(repoCwd, ['add', relativePath]);
390
+ }
391
+
392
+ // Commit
393
+ const commitResult = execGit(repoCwd, ['commit', '-m', message]);
394
+ if (commitResult.exitCode !== 0) {
395
+ if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
396
+ repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
397
+ continue;
398
+ }
399
+ repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'error', error: commitResult.stderr };
400
+ continue;
401
+ }
402
+
403
+ // Get hash
404
+ const hashResult = execGit(repoCwd, ['rev-parse', '--short', 'HEAD']);
405
+ const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
406
+ repos[repo] = { committed: true, hash, files: repoFiles };
407
+ }
408
+
409
+ const result = {
410
+ committed: Object.values(repos).some(r => r.committed),
411
+ repos,
412
+ unmatched: unmatched.length > 0 ? unmatched : undefined,
413
+ };
414
+ output(result, raw, Object.entries(repos).map(([r, v]) => `${r}:${v.hash || 'skip'}`).join(' '));
415
+ }
416
+
264
417
  function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
265
418
  if (!summaryPath) {
266
419
  error('summary-path required for summary-extract');
@@ -294,7 +447,7 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
294
447
  // Build full result
295
448
  const fullResult = {
296
449
  path: summaryPath,
297
- one_liner: fm['one-liner'] || null,
450
+ one_liner: fm['one-liner'] || extractOneLinerFromBody(content) || null,
298
451
  key_files: fm['key-files'] || [],
299
452
  tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [],
300
453
  patterns: fm['patterns-established'] || [],
@@ -380,8 +533,8 @@ async function cmdWebsearch(query, options, raw) {
380
533
  }
381
534
 
382
535
  function cmdProgressRender(cwd, format, raw) {
383
- const phasesDir = path.join(cwd, '.planning', 'phases');
384
- const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
536
+ const phasesDir = planningPaths(cwd).phases;
537
+ const roadmapPath = planningPaths(cwd).roadmap;
385
538
  const milestone = getMilestoneInfo(cwd);
386
539
 
387
540
  const phases = [];
@@ -403,15 +556,11 @@ function cmdProgressRender(cwd, format, raw) {
403
556
  totalPlans += plans;
404
557
  totalSummaries += summaries;
405
558
 
406
- let status;
407
- if (plans === 0) status = 'Pending';
408
- else if (summaries >= plans) status = 'Complete';
409
- else if (summaries > 0) status = 'In Progress';
410
- else status = 'Planned';
559
+ const status = determinePhaseStatus(plans, summaries, path.join(phasesDir, dir), 'Pending');
411
560
 
412
561
  phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
413
562
  }
414
- } catch {}
563
+ } catch { /* intentionally empty */ }
415
564
 
416
565
  const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
417
566
 
@@ -447,13 +596,137 @@ function cmdProgressRender(cwd, format, raw) {
447
596
  }
448
597
  }
449
598
 
599
+ /**
600
+ * Match pending todos against a phase's goal/name/requirements.
601
+ * Returns todos with relevance scores based on keyword, area, and file overlap.
602
+ * Used by discuss-phase to surface relevant todos before scope-setting.
603
+ */
604
+ function cmdTodoMatchPhase(cwd, phase, raw) {
605
+ if (!phase) { error('phase required for todo match-phase'); }
606
+
607
+ const pendingDir = path.join(planningDir(cwd), 'todos', 'pending');
608
+ const todos = [];
609
+
610
+ // Load pending todos
611
+ try {
612
+ const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
613
+ for (const file of files) {
614
+ try {
615
+ const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
616
+ const titleMatch = content.match(/^title:\s*(.+)$/m);
617
+ const areaMatch = content.match(/^area:\s*(.+)$/m);
618
+ const filesMatch = content.match(/^files:\s*(.+)$/m);
619
+ const body = content.replace(/^(title|area|files|created|priority):.*$/gm, '').trim();
620
+
621
+ todos.push({
622
+ file,
623
+ title: titleMatch ? titleMatch[1].trim() : 'Untitled',
624
+ area: areaMatch ? areaMatch[1].trim() : 'general',
625
+ files: filesMatch ? filesMatch[1].trim().split(/[,\s]+/).filter(Boolean) : [],
626
+ body: body.slice(0, 200), // first 200 chars for context
627
+ });
628
+ } catch {}
629
+ }
630
+ } catch {}
631
+
632
+ if (todos.length === 0) {
633
+ output({ phase, matches: [], todo_count: 0 }, raw);
634
+ return;
635
+ }
636
+
637
+ // Load phase goal/name from ROADMAP
638
+ const phaseInfo = getRoadmapPhaseInternal(cwd, phase);
639
+ const phaseName = phaseInfo ? (phaseInfo.phase_name || '') : '';
640
+ const phaseGoal = phaseInfo ? (phaseInfo.goal || '') : '';
641
+ const phaseSection = phaseInfo ? (phaseInfo.section || '') : '';
642
+
643
+ // Build keyword set from phase name + goal + section text
644
+ const phaseText = `${phaseName} ${phaseGoal} ${phaseSection}`.toLowerCase();
645
+ const stopWords = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'will', 'are', 'was', 'has', 'have', 'been', 'not', 'but', 'all', 'can', 'into', 'each', 'when', 'any', 'use', 'new']);
646
+ const phaseKeywords = new Set(
647
+ phaseText.split(/[\s\-_/.,;:()\[\]{}|]+/)
648
+ .map(w => w.replace(/[^a-z0-9]/g, ''))
649
+ .filter(w => w.length > 2 && !stopWords.has(w))
650
+ );
651
+
652
+ // Find phase directory to get expected file paths
653
+ const phaseInfoDisk = findPhaseInternal(cwd, phase);
654
+ const phasePlans = [];
655
+ if (phaseInfoDisk && phaseInfoDisk.found) {
656
+ try {
657
+ const phaseDir = path.join(cwd, phaseInfoDisk.directory);
658
+ const planFiles = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
659
+ for (const pf of planFiles) {
660
+ try {
661
+ const planContent = fs.readFileSync(path.join(phaseDir, pf), 'utf-8');
662
+ const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
663
+ if (fmFiles) {
664
+ phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean));
665
+ }
666
+ } catch {}
667
+ }
668
+ } catch {}
669
+ }
670
+
671
+ // Score each todo for relevance
672
+ const matches = [];
673
+ for (const todo of todos) {
674
+ let score = 0;
675
+ const reasons = [];
676
+
677
+ // Keyword match: todo title/body terms in phase text
678
+ const todoWords = `${todo.title} ${todo.body}`.toLowerCase()
679
+ .split(/[\s\-_/.,;:()\[\]{}|]+/)
680
+ .map(w => w.replace(/[^a-z0-9]/g, ''))
681
+ .filter(w => w.length > 2 && !stopWords.has(w));
682
+
683
+ const matchedKeywords = todoWords.filter(w => phaseKeywords.has(w));
684
+ if (matchedKeywords.length > 0) {
685
+ score += Math.min(matchedKeywords.length * 0.2, 0.6);
686
+ reasons.push(`keywords: ${[...new Set(matchedKeywords)].slice(0, 5).join(', ')}`);
687
+ }
688
+
689
+ // Area match: todo area appears in phase text
690
+ if (todo.area !== 'general' && phaseText.includes(todo.area.toLowerCase())) {
691
+ score += 0.3;
692
+ reasons.push(`area: ${todo.area}`);
693
+ }
694
+
695
+ // File match: todo files overlap with phase plan files
696
+ if (todo.files.length > 0 && phasePlans.length > 0) {
697
+ const fileOverlap = todo.files.filter(f =>
698
+ phasePlans.some(pf => pf.includes(f) || f.includes(pf))
699
+ );
700
+ if (fileOverlap.length > 0) {
701
+ score += 0.4;
702
+ reasons.push(`files: ${fileOverlap.slice(0, 3).join(', ')}`);
703
+ }
704
+ }
705
+
706
+ if (score > 0) {
707
+ matches.push({
708
+ file: todo.file,
709
+ title: todo.title,
710
+ area: todo.area,
711
+ score: Math.round(score * 100) / 100,
712
+ reasons,
713
+ });
714
+ }
715
+ }
716
+
717
+ // Sort by score descending
718
+ matches.sort((a, b) => b.score - a.score);
719
+
720
+ output({ phase, matches, todo_count: todos.length }, raw);
721
+ }
722
+
450
723
  function cmdTodoComplete(cwd, filename, raw) {
451
724
  if (!filename) {
452
725
  error('filename required for todo complete');
453
726
  }
454
727
 
455
- const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
456
- const completedDir = path.join(cwd, '.planning', 'todos', 'completed');
728
+ const pendingDir = path.join(planningDir(cwd), 'todos', 'pending');
729
+ const completedDir = path.join(planningDir(cwd), 'todos', 'completed');
457
730
  const sourcePath = path.join(pendingDir, filename);
458
731
 
459
732
  if (!fs.existsSync(sourcePath)) {
@@ -511,11 +784,11 @@ function cmdScaffold(cwd, type, options, raw) {
511
784
  }
512
785
  const slug = generateSlugInternal(name);
513
786
  const dirName = `${padded}-${slug}`;
514
- const phasesParent = path.join(cwd, '.planning', 'phases');
787
+ const phasesParent = planningPaths(cwd).phases;
515
788
  fs.mkdirSync(phasesParent, { recursive: true });
516
789
  const dirPath = path.join(phasesParent, dirName);
517
790
  fs.mkdirSync(dirPath, { recursive: true });
518
- output({ created: true, directory: `.planning/phases/${dirName}`, path: dirPath }, raw, dirPath);
791
+ output({ created: true, directory: toPosixPath(path.relative(cwd, dirPath)), path: dirPath }, raw, dirPath);
519
792
  return;
520
793
  }
521
794
  default:
@@ -532,6 +805,194 @@ function cmdScaffold(cwd, type, options, raw) {
532
805
  output({ created: true, path: relPath }, raw, relPath);
533
806
  }
534
807
 
808
+ function cmdStats(cwd, format, raw) {
809
+ const phasesDir = planningPaths(cwd).phases;
810
+ const roadmapPath = planningPaths(cwd).roadmap;
811
+ const reqPath = planningPaths(cwd).requirements;
812
+ const statePath = planningPaths(cwd).state;
813
+ const milestone = getMilestoneInfo(cwd);
814
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
815
+
816
+ // Phase & plan stats (reuse progress pattern)
817
+ const phasesByNumber = new Map();
818
+ let totalPlans = 0;
819
+ let totalSummaries = 0;
820
+
821
+ try {
822
+ const roadmapContent = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
823
+ const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
824
+ let match;
825
+ while ((match = headingPattern.exec(roadmapContent)) !== null) {
826
+ phasesByNumber.set(match[1], {
827
+ number: match[1],
828
+ name: match[2].replace(/\(INSERTED\)/i, '').trim(),
829
+ plans: 0,
830
+ summaries: 0,
831
+ status: 'Not Started',
832
+ });
833
+ }
834
+ } catch { /* intentionally empty */ }
835
+
836
+ try {
837
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
838
+ const dirs = entries
839
+ .filter(e => e.isDirectory())
840
+ .map(e => e.name)
841
+ .filter(isDirInMilestone)
842
+ .sort((a, b) => comparePhaseNum(a, b));
843
+
844
+ for (const dir of dirs) {
845
+ const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
846
+ const phaseNum = dm ? dm[1] : dir;
847
+ const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
848
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
849
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
850
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
851
+
852
+ totalPlans += plans;
853
+ totalSummaries += summaries;
854
+
855
+ const status = determinePhaseStatus(plans, summaries, path.join(phasesDir, dir), 'Not Started');
856
+
857
+ const existing = phasesByNumber.get(phaseNum);
858
+ phasesByNumber.set(phaseNum, {
859
+ number: phaseNum,
860
+ name: existing?.name || phaseName,
861
+ plans: (existing?.plans || 0) + plans,
862
+ summaries: (existing?.summaries || 0) + summaries,
863
+ status,
864
+ });
865
+ }
866
+ } catch { /* intentionally empty */ }
867
+
868
+ const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
869
+ const completedPhases = phases.filter(p => p.status === 'Complete').length;
870
+ const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
871
+ const percent = phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0;
872
+
873
+ // Requirements stats
874
+ let requirementsTotal = 0;
875
+ let requirementsComplete = 0;
876
+ try {
877
+ if (fs.existsSync(reqPath)) {
878
+ const reqContent = fs.readFileSync(reqPath, 'utf-8');
879
+ const checked = reqContent.match(/^- \[x\] \*\*/gm);
880
+ const unchecked = reqContent.match(/^- \[ \] \*\*/gm);
881
+ requirementsComplete = checked ? checked.length : 0;
882
+ requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0);
883
+ }
884
+ } catch { /* intentionally empty */ }
885
+
886
+ // Last activity from STATE.md
887
+ let lastActivity = null;
888
+ try {
889
+ if (fs.existsSync(statePath)) {
890
+ const stateContent = fs.readFileSync(statePath, 'utf-8');
891
+ const activityMatch = stateContent.match(/^last_activity:\s*(.+)$/im)
892
+ || stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i)
893
+ || stateContent.match(/^Last Activity:\s*(.+)$/im)
894
+ || stateContent.match(/^Last activity:\s*(.+)$/im);
895
+ if (activityMatch) lastActivity = activityMatch[1].trim();
896
+ }
897
+ } catch { /* intentionally empty */ }
898
+
899
+ // Git stats
900
+ let gitCommits = 0;
901
+ let gitFirstCommitDate = null;
902
+ const commitCount = execGit(cwd, ['rev-list', '--count', 'HEAD']);
903
+ if (commitCount.exitCode === 0) {
904
+ gitCommits = parseInt(commitCount.stdout, 10) || 0;
905
+ }
906
+ const rootHash = execGit(cwd, ['rev-list', '--max-parents=0', 'HEAD']);
907
+ if (rootHash.exitCode === 0 && rootHash.stdout) {
908
+ const firstCommit = rootHash.stdout.split('\n')[0].trim();
909
+ const firstDate = execGit(cwd, ['show', '-s', '--format=%as', firstCommit]);
910
+ if (firstDate.exitCode === 0) {
911
+ gitFirstCommitDate = firstDate.stdout || null;
912
+ }
913
+ }
914
+
915
+ const result = {
916
+ milestone_version: milestone.version,
917
+ milestone_name: milestone.name,
918
+ phases,
919
+ phases_completed: completedPhases,
920
+ phases_total: phases.length,
921
+ total_plans: totalPlans,
922
+ total_summaries: totalSummaries,
923
+ percent,
924
+ plan_percent: planPercent,
925
+ requirements_total: requirementsTotal,
926
+ requirements_complete: requirementsComplete,
927
+ git_commits: gitCommits,
928
+ git_first_commit_date: gitFirstCommitDate,
929
+ last_activity: lastActivity,
930
+ };
931
+
932
+ if (format === 'table') {
933
+ const barWidth = 10;
934
+ const filled = Math.round((percent / 100) * barWidth);
935
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
936
+ let out = `# ${milestone.version} ${milestone.name} \u2014 Statistics\n\n`;
937
+ out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
938
+ if (totalPlans > 0) {
939
+ out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
940
+ }
941
+ out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
942
+ if (requirementsTotal > 0) {
943
+ out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
944
+ }
945
+ out += '\n';
946
+ out += `| Phase | Name | Plans | Completed | Status |\n`;
947
+ out += `|-------|------|-------|-----------|--------|\n`;
948
+ for (const p of phases) {
949
+ out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
950
+ }
951
+ if (gitCommits > 0) {
952
+ out += `\n**Git:** ${gitCommits} commits`;
953
+ if (gitFirstCommitDate) out += ` (since ${gitFirstCommitDate})`;
954
+ out += '\n';
955
+ }
956
+ if (lastActivity) out += `**Last activity:** ${lastActivity}\n`;
957
+ output({ rendered: out }, raw, out);
958
+ } else {
959
+ output(result, raw);
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Check whether a commit should be allowed based on commit_docs config.
965
+ * When commit_docs is false, rejects commits that stage .planning/ files.
966
+ * Intended for use as a pre-commit hook guard.
967
+ */
968
+ function cmdCheckCommit(cwd, raw) {
969
+ const config = loadConfig(cwd);
970
+
971
+ // If commit_docs is true (or not set), allow all commits
972
+ if (config.commit_docs !== false) {
973
+ output({ allowed: true, reason: 'commit_docs_enabled' }, raw, 'allowed');
974
+ return;
975
+ }
976
+
977
+ // commit_docs is false — check if any .planning/ files are staged
978
+ try {
979
+ const staged = execSync('git diff --cached --name-only', { cwd, encoding: 'utf-8' }).trim();
980
+ const planningFiles = staged.split('\n').filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\'));
981
+
982
+ if (planningFiles.length > 0) {
983
+ error(
984
+ `commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
985
+ planningFiles.map(f => ` ${f}`).join('\n') +
986
+ `\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`
987
+ );
988
+ }
989
+ } catch {
990
+ // git diff --cached failed (no staged files or not a git repo) — allow
991
+ }
992
+
993
+ output({ allowed: true, reason: 'no_planning_files_staged' }, raw, 'allowed');
994
+ }
995
+
535
996
  module.exports = {
536
997
  cmdGenerateSlug,
537
998
  cmdCurrentTimestamp,
@@ -540,9 +1001,13 @@ module.exports = {
540
1001
  cmdHistoryDigest,
541
1002
  cmdResolveModel,
542
1003
  cmdCommit,
1004
+ cmdCommitToSubrepo,
543
1005
  cmdSummaryExtract,
544
1006
  cmdWebsearch,
545
1007
  cmdProgressRender,
546
1008
  cmdTodoComplete,
1009
+ cmdTodoMatchPhase,
547
1010
  cmdScaffold,
1011
+ cmdStats,
1012
+ cmdCheckCommit,
548
1013
  };