gsd-remix 1.0.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 (554) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +939 -0
  3. package/README.zh-CN.md +876 -0
  4. package/agents/gsd-advisor-researcher.md +127 -0
  5. package/agents/gsd-ai-researcher.md +133 -0
  6. package/agents/gsd-assumptions-analyzer.md +105 -0
  7. package/agents/gsd-code-fixer.md +517 -0
  8. package/agents/gsd-code-reviewer.md +371 -0
  9. package/agents/gsd-codebase-mapper.md +781 -0
  10. package/agents/gsd-debug-session-manager.md +314 -0
  11. package/agents/gsd-debugger.md +1452 -0
  12. package/agents/gsd-doc-classifier.md +168 -0
  13. package/agents/gsd-doc-synthesizer.md +204 -0
  14. package/agents/gsd-doc-verifier.md +217 -0
  15. package/agents/gsd-doc-writer.md +615 -0
  16. package/agents/gsd-domain-researcher.md +153 -0
  17. package/agents/gsd-eval-auditor.md +191 -0
  18. package/agents/gsd-eval-planner.md +154 -0
  19. package/agents/gsd-executor.md +603 -0
  20. package/agents/gsd-framework-selector.md +160 -0
  21. package/agents/gsd-integration-checker.md +470 -0
  22. package/agents/gsd-intel-updater.md +334 -0
  23. package/agents/gsd-nyquist-auditor.md +203 -0
  24. package/agents/gsd-pattern-mapper.md +335 -0
  25. package/agents/gsd-phase-researcher.md +841 -0
  26. package/agents/gsd-plan-checker.md +978 -0
  27. package/agents/gsd-planner.md +1251 -0
  28. package/agents/gsd-project-researcher.md +677 -0
  29. package/agents/gsd-research-synthesizer.md +247 -0
  30. package/agents/gsd-roadmapper.md +688 -0
  31. package/agents/gsd-security-auditor.md +155 -0
  32. package/agents/gsd-ui-auditor.md +495 -0
  33. package/agents/gsd-ui-checker.md +309 -0
  34. package/agents/gsd-ui-researcher.md +380 -0
  35. package/agents/gsd-user-profiler.md +171 -0
  36. package/agents/gsd-verifier.md +830 -0
  37. package/bin/install.js +7062 -0
  38. package/commands/gsd/add-backlog.md +79 -0
  39. package/commands/gsd/add-phase.md +43 -0
  40. package/commands/gsd/add-tests.md +41 -0
  41. package/commands/gsd/add-todo.md +47 -0
  42. package/commands/gsd/ai-integration-phase.md +36 -0
  43. package/commands/gsd/analyze-dependencies.md +34 -0
  44. package/commands/gsd/audit-fix.md +33 -0
  45. package/commands/gsd/audit-milestone.md +36 -0
  46. package/commands/gsd/audit-uat.md +24 -0
  47. package/commands/gsd/autonomous.md +46 -0
  48. package/commands/gsd/check-todos.md +45 -0
  49. package/commands/gsd/cleanup.md +23 -0
  50. package/commands/gsd/code-review-fix.md +52 -0
  51. package/commands/gsd/code-review.md +55 -0
  52. package/commands/gsd/complete-milestone.md +136 -0
  53. package/commands/gsd/debug.md +263 -0
  54. package/commands/gsd/discuss-phase.md +69 -0
  55. package/commands/gsd/do.md +30 -0
  56. package/commands/gsd/docs-update.md +48 -0
  57. package/commands/gsd/eval-review.md +32 -0
  58. package/commands/gsd/execute-phase.md +63 -0
  59. package/commands/gsd/explore.md +27 -0
  60. package/commands/gsd/extract_learnings.md +22 -0
  61. package/commands/gsd/fast.md +30 -0
  62. package/commands/gsd/forensics.md +56 -0
  63. package/commands/gsd/from-gsd2.md +47 -0
  64. package/commands/gsd/graphify.md +201 -0
  65. package/commands/gsd/health.md +22 -0
  66. package/commands/gsd/help.md +24 -0
  67. package/commands/gsd/import.md +37 -0
  68. package/commands/gsd/inbox.md +38 -0
  69. package/commands/gsd/ingest-docs.md +42 -0
  70. package/commands/gsd/insert-phase.md +32 -0
  71. package/commands/gsd/intel.md +179 -0
  72. package/commands/gsd/join-discord.md +19 -0
  73. package/commands/gsd/list-phase-assumptions.md +46 -0
  74. package/commands/gsd/list-workspaces.md +19 -0
  75. package/commands/gsd/manager.md +40 -0
  76. package/commands/gsd/map-codebase.md +71 -0
  77. package/commands/gsd/milestone-summary.md +51 -0
  78. package/commands/gsd/new-milestone.md +44 -0
  79. package/commands/gsd/new-project.md +46 -0
  80. package/commands/gsd/new-workspace.md +44 -0
  81. package/commands/gsd/next.md +28 -0
  82. package/commands/gsd/note.md +34 -0
  83. package/commands/gsd/pause-work.md +38 -0
  84. package/commands/gsd/plan-milestone-gaps.md +34 -0
  85. package/commands/gsd/plan-phase.md +52 -0
  86. package/commands/gsd/plan-review-convergence.md +52 -0
  87. package/commands/gsd/plant-seed.md +28 -0
  88. package/commands/gsd/pr-branch.md +25 -0
  89. package/commands/gsd/profile-user.md +46 -0
  90. package/commands/gsd/progress.md +25 -0
  91. package/commands/gsd/quick.md +173 -0
  92. package/commands/gsd/reapply-patches.md +331 -0
  93. package/commands/gsd/remove-phase.md +31 -0
  94. package/commands/gsd/remove-workspace.md +26 -0
  95. package/commands/gsd/research-phase.md +195 -0
  96. package/commands/gsd/resume-work.md +40 -0
  97. package/commands/gsd/review-backlog.md +62 -0
  98. package/commands/gsd/review.md +40 -0
  99. package/commands/gsd/scan.md +26 -0
  100. package/commands/gsd/secure-phase.md +35 -0
  101. package/commands/gsd/session-report.md +19 -0
  102. package/commands/gsd/set-profile.md +12 -0
  103. package/commands/gsd/settings.md +36 -0
  104. package/commands/gsd/ship.md +23 -0
  105. package/commands/gsd/sketch-wrap-up.md +31 -0
  106. package/commands/gsd/sketch.md +49 -0
  107. package/commands/gsd/spec-phase.md +62 -0
  108. package/commands/gsd/spike-wrap-up.md +31 -0
  109. package/commands/gsd/spike.md +46 -0
  110. package/commands/gsd/stats.md +18 -0
  111. package/commands/gsd/sync-skills.md +19 -0
  112. package/commands/gsd/thread.md +227 -0
  113. package/commands/gsd/ui-phase.md +34 -0
  114. package/commands/gsd/ui-review.md +32 -0
  115. package/commands/gsd/ultraplan-phase.md +33 -0
  116. package/commands/gsd/undo.md +34 -0
  117. package/commands/gsd/update.md +37 -0
  118. package/commands/gsd/validate-phase.md +35 -0
  119. package/commands/gsd/verify-work.md +38 -0
  120. package/commands/gsd/workstreams.md +69 -0
  121. package/get-shit-done/bin/gsd-tools.cjs +1263 -0
  122. package/get-shit-done/bin/lib/artifacts.cjs +52 -0
  123. package/get-shit-done/bin/lib/audit.cjs +757 -0
  124. package/get-shit-done/bin/lib/commands.cjs +1023 -0
  125. package/get-shit-done/bin/lib/config-schema.cjs +79 -0
  126. package/get-shit-done/bin/lib/config.cjs +463 -0
  127. package/get-shit-done/bin/lib/core.cjs +1794 -0
  128. package/get-shit-done/bin/lib/docs.cjs +267 -0
  129. package/get-shit-done/bin/lib/frontmatter.cjs +379 -0
  130. package/get-shit-done/bin/lib/graphify.cjs +494 -0
  131. package/get-shit-done/bin/lib/gsd2-import.cjs +511 -0
  132. package/get-shit-done/bin/lib/init.cjs +1878 -0
  133. package/get-shit-done/bin/lib/intel.cjs +639 -0
  134. package/get-shit-done/bin/lib/learnings.cjs +378 -0
  135. package/get-shit-done/bin/lib/milestone.cjs +283 -0
  136. package/get-shit-done/bin/lib/model-profiles.cjs +71 -0
  137. package/get-shit-done/bin/lib/phase.cjs +1058 -0
  138. package/get-shit-done/bin/lib/profile-output.cjs +1080 -0
  139. package/get-shit-done/bin/lib/profile-pipeline.cjs +539 -0
  140. package/get-shit-done/bin/lib/roadmap.cjs +523 -0
  141. package/get-shit-done/bin/lib/schema-detect.cjs +238 -0
  142. package/get-shit-done/bin/lib/security.cjs +504 -0
  143. package/get-shit-done/bin/lib/state.cjs +1649 -0
  144. package/get-shit-done/bin/lib/template.cjs +226 -0
  145. package/get-shit-done/bin/lib/uat.cjs +288 -0
  146. package/get-shit-done/bin/lib/verify.cjs +1184 -0
  147. package/get-shit-done/bin/lib/workstream.cjs +495 -0
  148. package/get-shit-done/bin/repair-sdk.cjs +177 -0
  149. package/get-shit-done/contexts/dev.md +21 -0
  150. package/get-shit-done/contexts/research.md +22 -0
  151. package/get-shit-done/contexts/review.md +22 -0
  152. package/get-shit-done/references/agent-contracts.md +79 -0
  153. package/get-shit-done/references/ai-evals.md +156 -0
  154. package/get-shit-done/references/ai-frameworks.md +186 -0
  155. package/get-shit-done/references/artifact-types.md +131 -0
  156. package/get-shit-done/references/autonomous-smart-discuss.md +277 -0
  157. package/get-shit-done/references/checkpoints.md +808 -0
  158. package/get-shit-done/references/common-bug-patterns.md +114 -0
  159. package/get-shit-done/references/context-budget.md +49 -0
  160. package/get-shit-done/references/continuation-format.md +253 -0
  161. package/get-shit-done/references/debugger-philosophy.md +76 -0
  162. package/get-shit-done/references/decimal-phase-calculation.md +64 -0
  163. package/get-shit-done/references/doc-conflict-engine.md +91 -0
  164. package/get-shit-done/references/domain-probes.md +125 -0
  165. package/get-shit-done/references/executor-examples.md +110 -0
  166. package/get-shit-done/references/few-shot-examples/plan-checker.md +73 -0
  167. package/get-shit-done/references/few-shot-examples/verifier.md +109 -0
  168. package/get-shit-done/references/gate-prompts.md +100 -0
  169. package/get-shit-done/references/gates.md +70 -0
  170. package/get-shit-done/references/git-integration.md +295 -0
  171. package/get-shit-done/references/git-planning-commit.md +40 -0
  172. package/get-shit-done/references/ios-scaffold.md +123 -0
  173. package/get-shit-done/references/mandatory-initial-read.md +2 -0
  174. package/get-shit-done/references/model-profile-resolution.md +38 -0
  175. package/get-shit-done/references/model-profiles.md +145 -0
  176. package/get-shit-done/references/phase-argument-parsing.md +61 -0
  177. package/get-shit-done/references/planner-antipatterns.md +89 -0
  178. package/get-shit-done/references/planner-gap-closure.md +62 -0
  179. package/get-shit-done/references/planner-reviews.md +39 -0
  180. package/get-shit-done/references/planner-revision.md +87 -0
  181. package/get-shit-done/references/planner-source-audit.md +73 -0
  182. package/get-shit-done/references/planning-config.md +460 -0
  183. package/get-shit-done/references/project-skills-discovery.md +19 -0
  184. package/get-shit-done/references/questioning.md +162 -0
  185. package/get-shit-done/references/revision-loop.md +97 -0
  186. package/get-shit-done/references/sketch-interactivity.md +41 -0
  187. package/get-shit-done/references/sketch-theme-system.md +94 -0
  188. package/get-shit-done/references/sketch-tooling.md +45 -0
  189. package/get-shit-done/references/sketch-variant-patterns.md +81 -0
  190. package/get-shit-done/references/tdd.md +330 -0
  191. package/get-shit-done/references/thinking-models-debug.md +44 -0
  192. package/get-shit-done/references/thinking-models-execution.md +50 -0
  193. package/get-shit-done/references/thinking-models-planning.md +62 -0
  194. package/get-shit-done/references/thinking-models-research.md +50 -0
  195. package/get-shit-done/references/thinking-models-verification.md +55 -0
  196. package/get-shit-done/references/thinking-partner.md +96 -0
  197. package/get-shit-done/references/ui-brand.md +160 -0
  198. package/get-shit-done/references/universal-anti-patterns.md +63 -0
  199. package/get-shit-done/references/user-profiling.md +681 -0
  200. package/get-shit-done/references/verification-overrides.md +227 -0
  201. package/get-shit-done/references/verification-patterns.md +612 -0
  202. package/get-shit-done/references/workstream-flag.md +111 -0
  203. package/get-shit-done/templates/AI-SPEC.md +246 -0
  204. package/get-shit-done/templates/DEBUG.md +169 -0
  205. package/get-shit-done/templates/README.md +76 -0
  206. package/get-shit-done/templates/SECURITY.md +61 -0
  207. package/get-shit-done/templates/UAT.md +265 -0
  208. package/get-shit-done/templates/UI-SPEC.md +100 -0
  209. package/get-shit-done/templates/VALIDATION.md +76 -0
  210. package/get-shit-done/templates/claude-md.md +145 -0
  211. package/get-shit-done/templates/codebase/architecture.md +255 -0
  212. package/get-shit-done/templates/codebase/concerns.md +310 -0
  213. package/get-shit-done/templates/codebase/conventions.md +307 -0
  214. package/get-shit-done/templates/codebase/integrations.md +280 -0
  215. package/get-shit-done/templates/codebase/stack.md +186 -0
  216. package/get-shit-done/templates/codebase/structure.md +285 -0
  217. package/get-shit-done/templates/codebase/testing.md +480 -0
  218. package/get-shit-done/templates/config.json +56 -0
  219. package/get-shit-done/templates/context.md +352 -0
  220. package/get-shit-done/templates/continue-here.md +78 -0
  221. package/get-shit-done/templates/copilot-instructions.md +7 -0
  222. package/get-shit-done/templates/debug-subagent-prompt.md +91 -0
  223. package/get-shit-done/templates/dev-preferences.md +21 -0
  224. package/get-shit-done/templates/discovery.md +146 -0
  225. package/get-shit-done/templates/discussion-log.md +63 -0
  226. package/get-shit-done/templates/milestone-archive.md +123 -0
  227. package/get-shit-done/templates/milestone.md +115 -0
  228. package/get-shit-done/templates/phase-prompt.md +610 -0
  229. package/get-shit-done/templates/planner-subagent-prompt.md +117 -0
  230. package/get-shit-done/templates/project.md +186 -0
  231. package/get-shit-done/templates/requirements.md +231 -0
  232. package/get-shit-done/templates/research-project/ARCHITECTURE.md +204 -0
  233. package/get-shit-done/templates/research-project/FEATURES.md +147 -0
  234. package/get-shit-done/templates/research-project/PITFALLS.md +200 -0
  235. package/get-shit-done/templates/research-project/STACK.md +120 -0
  236. package/get-shit-done/templates/research-project/SUMMARY.md +170 -0
  237. package/get-shit-done/templates/research.md +592 -0
  238. package/get-shit-done/templates/retrospective.md +54 -0
  239. package/get-shit-done/templates/roadmap.md +202 -0
  240. package/get-shit-done/templates/spec.md +307 -0
  241. package/get-shit-done/templates/state.md +184 -0
  242. package/get-shit-done/templates/summary-complex.md +59 -0
  243. package/get-shit-done/templates/summary-minimal.md +41 -0
  244. package/get-shit-done/templates/summary-standard.md +48 -0
  245. package/get-shit-done/templates/summary.md +248 -0
  246. package/get-shit-done/templates/user-profile.md +146 -0
  247. package/get-shit-done/templates/user-setup.md +311 -0
  248. package/get-shit-done/templates/verification-report.md +322 -0
  249. package/get-shit-done/workflows/add-phase.md +112 -0
  250. package/get-shit-done/workflows/add-tests.md +354 -0
  251. package/get-shit-done/workflows/add-todo.md +160 -0
  252. package/get-shit-done/workflows/ai-integration-phase.md +284 -0
  253. package/get-shit-done/workflows/analyze-dependencies.md +96 -0
  254. package/get-shit-done/workflows/audit-fix.md +175 -0
  255. package/get-shit-done/workflows/audit-milestone.md +340 -0
  256. package/get-shit-done/workflows/audit-uat.md +109 -0
  257. package/get-shit-done/workflows/autonomous.md +789 -0
  258. package/get-shit-done/workflows/check-todos.md +179 -0
  259. package/get-shit-done/workflows/cleanup.md +154 -0
  260. package/get-shit-done/workflows/code-review-fix.md +497 -0
  261. package/get-shit-done/workflows/code-review.md +515 -0
  262. package/get-shit-done/workflows/complete-milestone.md +847 -0
  263. package/get-shit-done/workflows/diagnose-issues.md +238 -0
  264. package/get-shit-done/workflows/discovery-phase.md +291 -0
  265. package/get-shit-done/workflows/discuss-phase-assumptions.md +670 -0
  266. package/get-shit-done/workflows/discuss-phase-power.md +308 -0
  267. package/get-shit-done/workflows/discuss-phase.md +1378 -0
  268. package/get-shit-done/workflows/do.md +110 -0
  269. package/get-shit-done/workflows/docs-update.md +1155 -0
  270. package/get-shit-done/workflows/eval-review.md +155 -0
  271. package/get-shit-done/workflows/execute-phase.md +1677 -0
  272. package/get-shit-done/workflows/execute-plan.md +533 -0
  273. package/get-shit-done/workflows/explore.md +141 -0
  274. package/get-shit-done/workflows/extract_learnings.md +242 -0
  275. package/get-shit-done/workflows/fast.md +105 -0
  276. package/get-shit-done/workflows/forensics.md +265 -0
  277. package/get-shit-done/workflows/graduation.md +195 -0
  278. package/get-shit-done/workflows/health.md +314 -0
  279. package/get-shit-done/workflows/help.md +667 -0
  280. package/get-shit-done/workflows/import.md +246 -0
  281. package/get-shit-done/workflows/inbox.md +387 -0
  282. package/get-shit-done/workflows/ingest-docs.md +328 -0
  283. package/get-shit-done/workflows/insert-phase.md +130 -0
  284. package/get-shit-done/workflows/list-phase-assumptions.md +178 -0
  285. package/get-shit-done/workflows/list-workspaces.md +56 -0
  286. package/get-shit-done/workflows/manager.md +365 -0
  287. package/get-shit-done/workflows/map-codebase.md +393 -0
  288. package/get-shit-done/workflows/milestone-summary.md +223 -0
  289. package/get-shit-done/workflows/new-milestone.md +611 -0
  290. package/get-shit-done/workflows/new-project.md +1391 -0
  291. package/get-shit-done/workflows/new-workspace.md +239 -0
  292. package/get-shit-done/workflows/next.md +220 -0
  293. package/get-shit-done/workflows/node-repair.md +92 -0
  294. package/get-shit-done/workflows/note.md +158 -0
  295. package/get-shit-done/workflows/pause-work.md +243 -0
  296. package/get-shit-done/workflows/plan-milestone-gaps.md +273 -0
  297. package/get-shit-done/workflows/plan-phase.md +1349 -0
  298. package/get-shit-done/workflows/plan-review-convergence.md +254 -0
  299. package/get-shit-done/workflows/plant-seed.md +172 -0
  300. package/get-shit-done/workflows/pr-branch.md +157 -0
  301. package/get-shit-done/workflows/profile-user.md +452 -0
  302. package/get-shit-done/workflows/progress.md +619 -0
  303. package/get-shit-done/workflows/quick.md +970 -0
  304. package/get-shit-done/workflows/remove-phase.md +155 -0
  305. package/get-shit-done/workflows/remove-workspace.md +92 -0
  306. package/get-shit-done/workflows/research-phase.md +89 -0
  307. package/get-shit-done/workflows/resume-project.md +326 -0
  308. package/get-shit-done/workflows/review.md +344 -0
  309. package/get-shit-done/workflows/scan.md +102 -0
  310. package/get-shit-done/workflows/secure-phase.md +166 -0
  311. package/get-shit-done/workflows/session-report.md +146 -0
  312. package/get-shit-done/workflows/settings.md +319 -0
  313. package/get-shit-done/workflows/ship.md +302 -0
  314. package/get-shit-done/workflows/sketch-wrap-up.md +283 -0
  315. package/get-shit-done/workflows/sketch.md +286 -0
  316. package/get-shit-done/workflows/spec-phase.md +262 -0
  317. package/get-shit-done/workflows/spike-wrap-up.md +281 -0
  318. package/get-shit-done/workflows/spike.md +362 -0
  319. package/get-shit-done/workflows/stats.md +60 -0
  320. package/get-shit-done/workflows/sync-skills.md +182 -0
  321. package/get-shit-done/workflows/transition.md +693 -0
  322. package/get-shit-done/workflows/ui-phase.md +323 -0
  323. package/get-shit-done/workflows/ui-review.md +190 -0
  324. package/get-shit-done/workflows/ultraplan-phase.md +189 -0
  325. package/get-shit-done/workflows/undo.md +314 -0
  326. package/get-shit-done/workflows/update.md +587 -0
  327. package/get-shit-done/workflows/validate-phase.md +176 -0
  328. package/get-shit-done/workflows/verify-phase.md +465 -0
  329. package/get-shit-done/workflows/verify-work.md +740 -0
  330. package/hooks/dist/gsd-check-update-worker.js +108 -0
  331. package/hooks/dist/gsd-check-update.js +64 -0
  332. package/hooks/dist/gsd-context-monitor.js +192 -0
  333. package/hooks/dist/gsd-phase-boundary.sh +28 -0
  334. package/hooks/dist/gsd-prompt-guard.js +97 -0
  335. package/hooks/dist/gsd-read-guard.js +82 -0
  336. package/hooks/dist/gsd-read-injection-scanner.js +152 -0
  337. package/hooks/dist/gsd-session-state.sh +34 -0
  338. package/hooks/dist/gsd-statusline.js +293 -0
  339. package/hooks/dist/gsd-validate-commit.sh +48 -0
  340. package/hooks/dist/gsd-workflow-guard.js +94 -0
  341. package/hooks/gsd-check-update-worker.js +108 -0
  342. package/hooks/gsd-check-update.js +64 -0
  343. package/hooks/gsd-context-monitor.js +192 -0
  344. package/hooks/gsd-phase-boundary.sh +28 -0
  345. package/hooks/gsd-prompt-guard.js +97 -0
  346. package/hooks/gsd-read-guard.js +82 -0
  347. package/hooks/gsd-read-injection-scanner.js +152 -0
  348. package/hooks/gsd-session-state.sh +34 -0
  349. package/hooks/gsd-statusline.js +293 -0
  350. package/hooks/gsd-validate-commit.sh +48 -0
  351. package/hooks/gsd-workflow-guard.js +94 -0
  352. package/package.json +59 -0
  353. package/scripts/base64-scan.sh +262 -0
  354. package/scripts/build-hooks.js +95 -0
  355. package/scripts/gen-inventory-manifest.cjs +109 -0
  356. package/scripts/prompt-injection-scan.sh +201 -0
  357. package/scripts/run-tests.cjs +33 -0
  358. package/scripts/secret-scan.sh +227 -0
  359. package/sdk/package-lock.json +1998 -0
  360. package/sdk/package.json +52 -0
  361. package/sdk/prompts/agents/gsd-executor.md +110 -0
  362. package/sdk/prompts/agents/gsd-phase-researcher.md +158 -0
  363. package/sdk/prompts/agents/gsd-plan-checker.md +160 -0
  364. package/sdk/prompts/agents/gsd-planner.md +214 -0
  365. package/sdk/prompts/agents/gsd-project-researcher.md +323 -0
  366. package/sdk/prompts/agents/gsd-research-synthesizer.md +237 -0
  367. package/sdk/prompts/agents/gsd-roadmapper.md +670 -0
  368. package/sdk/prompts/agents/gsd-verifier.md +159 -0
  369. package/sdk/prompts/templates/project.md +186 -0
  370. package/sdk/prompts/templates/requirements.md +231 -0
  371. package/sdk/prompts/templates/research-project/ARCHITECTURE.md +204 -0
  372. package/sdk/prompts/templates/research-project/FEATURES.md +147 -0
  373. package/sdk/prompts/templates/research-project/PITFALLS.md +200 -0
  374. package/sdk/prompts/templates/research-project/STACK.md +120 -0
  375. package/sdk/prompts/templates/research-project/SUMMARY.md +170 -0
  376. package/sdk/prompts/templates/roadmap.md +202 -0
  377. package/sdk/prompts/templates/state.md +175 -0
  378. package/sdk/prompts/workflows/discuss-phase.md +126 -0
  379. package/sdk/prompts/workflows/execute-plan.md +106 -0
  380. package/sdk/prompts/workflows/plan-phase.md +84 -0
  381. package/sdk/prompts/workflows/research-phase.md +45 -0
  382. package/sdk/prompts/workflows/verify-phase.md +142 -0
  383. package/sdk/src/assembled-prompts.test.ts +349 -0
  384. package/sdk/src/cli-transport.test.ts +388 -0
  385. package/sdk/src/cli-transport.ts +130 -0
  386. package/sdk/src/cli.test.ts +383 -0
  387. package/sdk/src/cli.ts +670 -0
  388. package/sdk/src/config.test.ts +168 -0
  389. package/sdk/src/config.ts +177 -0
  390. package/sdk/src/context-engine.test.ts +295 -0
  391. package/sdk/src/context-engine.ts +170 -0
  392. package/sdk/src/context-truncation.test.ts +163 -0
  393. package/sdk/src/context-truncation.ts +233 -0
  394. package/sdk/src/e2e.integration.test.ts +178 -0
  395. package/sdk/src/errors.ts +72 -0
  396. package/sdk/src/event-stream.test.ts +661 -0
  397. package/sdk/src/event-stream.ts +441 -0
  398. package/sdk/src/failure-memory.test.ts +457 -0
  399. package/sdk/src/failure-memory.ts +1324 -0
  400. package/sdk/src/golden/capture.ts +95 -0
  401. package/sdk/src/golden/fixtures/generate-slug.golden.json +1 -0
  402. package/sdk/src/golden/fixtures/profile-sample-sessions/demo-project/sample.jsonl +3 -0
  403. package/sdk/src/golden/fixtures/summary-extract-sample.md +26 -0
  404. package/sdk/src/golden/fixtures/uat-render-checkpoint-sample.md +15 -0
  405. package/sdk/src/golden/golden-integration-covered.ts +30 -0
  406. package/sdk/src/golden/golden-mutation-covered.ts +7 -0
  407. package/sdk/src/golden/golden-policy.test.ts +8 -0
  408. package/sdk/src/golden/golden-policy.ts +112 -0
  409. package/sdk/src/golden/golden.integration.test.ts +373 -0
  410. package/sdk/src/golden/init-golden-normalize.ts +15 -0
  411. package/sdk/src/golden/read-only-golden-rows.ts +77 -0
  412. package/sdk/src/golden/read-only-parity.integration.test.ts +125 -0
  413. package/sdk/src/golden/registry-canonical-commands.ts +31 -0
  414. package/sdk/src/gsd-tools.test.ts +409 -0
  415. package/sdk/src/gsd-tools.ts +595 -0
  416. package/sdk/src/headless-prompts.test.ts +159 -0
  417. package/sdk/src/index.ts +333 -0
  418. package/sdk/src/init-e2e.integration.test.ts +136 -0
  419. package/sdk/src/init-runner.test.ts +783 -0
  420. package/sdk/src/init-runner.ts +735 -0
  421. package/sdk/src/lifecycle-e2e.integration.test.ts +258 -0
  422. package/sdk/src/logger.test.ts +149 -0
  423. package/sdk/src/logger.ts +113 -0
  424. package/sdk/src/milestone-runner.test.ts +421 -0
  425. package/sdk/src/phase-prompt.test.ts +538 -0
  426. package/sdk/src/phase-prompt.ts +264 -0
  427. package/sdk/src/phase-runner-types.test.ts +421 -0
  428. package/sdk/src/phase-runner.integration.test.ts +377 -0
  429. package/sdk/src/phase-runner.test.ts +2333 -0
  430. package/sdk/src/phase-runner.ts +1203 -0
  431. package/sdk/src/plan-parser.test.ts +528 -0
  432. package/sdk/src/plan-parser.ts +427 -0
  433. package/sdk/src/prompt-builder.test.ts +306 -0
  434. package/sdk/src/prompt-builder.ts +193 -0
  435. package/sdk/src/prompt-sanitizer.test.ts +260 -0
  436. package/sdk/src/prompt-sanitizer.ts +71 -0
  437. package/sdk/src/query/QUERY-HANDLERS.md +317 -0
  438. package/sdk/src/query/audit-open.ts +722 -0
  439. package/sdk/src/query/check-auto-mode.test.ts +77 -0
  440. package/sdk/src/query/check-auto-mode.ts +50 -0
  441. package/sdk/src/query/check-completion.test.ts +113 -0
  442. package/sdk/src/query/check-completion.ts +182 -0
  443. package/sdk/src/query/check-gates.test.ts +103 -0
  444. package/sdk/src/query/check-gates.ts +112 -0
  445. package/sdk/src/query/check-ship-ready.test.ts +77 -0
  446. package/sdk/src/query/check-ship-ready.ts +103 -0
  447. package/sdk/src/query/check-verification-status.test.ts +143 -0
  448. package/sdk/src/query/check-verification-status.ts +160 -0
  449. package/sdk/src/query/commit.test.ts +202 -0
  450. package/sdk/src/query/commit.ts +301 -0
  451. package/sdk/src/query/config-gates.test.ts +89 -0
  452. package/sdk/src/query/config-gates.ts +69 -0
  453. package/sdk/src/query/config-mutation.test.ts +365 -0
  454. package/sdk/src/query/config-mutation.ts +497 -0
  455. package/sdk/src/query/config-query.test.ts +161 -0
  456. package/sdk/src/query/config-query.ts +190 -0
  457. package/sdk/src/query/context-history.test.ts +165 -0
  458. package/sdk/src/query/context-history.ts +467 -0
  459. package/sdk/src/query/decomposed-handlers.test.ts +365 -0
  460. package/sdk/src/query/detect-custom-files.ts +97 -0
  461. package/sdk/src/query/detect-phase-type.test.ts +105 -0
  462. package/sdk/src/query/detect-phase-type.ts +141 -0
  463. package/sdk/src/query/docs-init.ts +257 -0
  464. package/sdk/src/query/failure-capture.ts +58 -0
  465. package/sdk/src/query/frontmatter-array.test.ts +14 -0
  466. package/sdk/src/query/frontmatter-mutation.test.ts +259 -0
  467. package/sdk/src/query/frontmatter-mutation.ts +343 -0
  468. package/sdk/src/query/frontmatter.test.ts +281 -0
  469. package/sdk/src/query/frontmatter.ts +397 -0
  470. package/sdk/src/query/helpers.test.ts +426 -0
  471. package/sdk/src/query/helpers.ts +482 -0
  472. package/sdk/src/query/index.ts +586 -0
  473. package/sdk/src/query/init-complex.test.ts +232 -0
  474. package/sdk/src/query/init-complex.ts +578 -0
  475. package/sdk/src/query/init.test.ts +522 -0
  476. package/sdk/src/query/init.ts +1046 -0
  477. package/sdk/src/query/intel.test.ts +90 -0
  478. package/sdk/src/query/intel.ts +404 -0
  479. package/sdk/src/query/normalize-query-command.test.ts +50 -0
  480. package/sdk/src/query/normalize-query-command.ts +56 -0
  481. package/sdk/src/query/phase-lifecycle.test.ts +1126 -0
  482. package/sdk/src/query/phase-lifecycle.ts +1799 -0
  483. package/sdk/src/query/phase-list-queries.test.ts +88 -0
  484. package/sdk/src/query/phase-list-queries.ts +152 -0
  485. package/sdk/src/query/phase-ready.test.ts +65 -0
  486. package/sdk/src/query/phase-ready.ts +158 -0
  487. package/sdk/src/query/phase.test.ts +307 -0
  488. package/sdk/src/query/phase.ts +340 -0
  489. package/sdk/src/query/pipeline.test.ts +169 -0
  490. package/sdk/src/query/pipeline.ts +243 -0
  491. package/sdk/src/query/plan-execution-route.test.ts +166 -0
  492. package/sdk/src/query/plan-execution-route.ts +209 -0
  493. package/sdk/src/query/plan-task-structure.test.ts +65 -0
  494. package/sdk/src/query/plan-task-structure.ts +63 -0
  495. package/sdk/src/query/profile-extract-messages.ts +247 -0
  496. package/sdk/src/query/profile-output.ts +908 -0
  497. package/sdk/src/query/profile-questionnaire-data.ts +181 -0
  498. package/sdk/src/query/profile-sample.ts +184 -0
  499. package/sdk/src/query/profile-scan-sessions.ts +174 -0
  500. package/sdk/src/query/profile.test.ts +74 -0
  501. package/sdk/src/query/profile.ts +337 -0
  502. package/sdk/src/query/progress.test.ts +156 -0
  503. package/sdk/src/query/progress.ts +566 -0
  504. package/sdk/src/query/registry.test.ts +216 -0
  505. package/sdk/src/query/registry.ts +174 -0
  506. package/sdk/src/query/requirements-extract-from-plans.test.ts +58 -0
  507. package/sdk/src/query/requirements-extract-from-plans.ts +86 -0
  508. package/sdk/src/query/roadmap-update-plan-progress.ts +132 -0
  509. package/sdk/src/query/roadmap.test.ts +359 -0
  510. package/sdk/src/query/roadmap.ts +591 -0
  511. package/sdk/src/query/route-next-action.test.ts +61 -0
  512. package/sdk/src/query/route-next-action.ts +345 -0
  513. package/sdk/src/query/runtime-health.ts +7 -0
  514. package/sdk/src/query/schema-detect.ts +189 -0
  515. package/sdk/src/query/skill-manifest.ts +214 -0
  516. package/sdk/src/query/skills.test.ts +80 -0
  517. package/sdk/src/query/skills.ts +62 -0
  518. package/sdk/src/query/state-mutation.test.ts +450 -0
  519. package/sdk/src/query/state-mutation.ts +1444 -0
  520. package/sdk/src/query/state-project-load.ts +109 -0
  521. package/sdk/src/query/state.test.ts +347 -0
  522. package/sdk/src/query/state.ts +397 -0
  523. package/sdk/src/query/summary.test.ts +95 -0
  524. package/sdk/src/query/summary.ts +296 -0
  525. package/sdk/src/query/template.test.ts +180 -0
  526. package/sdk/src/query/template.ts +242 -0
  527. package/sdk/src/query/uat.test.ts +77 -0
  528. package/sdk/src/query/uat.ts +314 -0
  529. package/sdk/src/query/utils.test.ts +82 -0
  530. package/sdk/src/query/utils.ts +92 -0
  531. package/sdk/src/query/validate.test.ts +656 -0
  532. package/sdk/src/query/validate.ts +807 -0
  533. package/sdk/src/query/verify.test.ts +414 -0
  534. package/sdk/src/query/verify.ts +645 -0
  535. package/sdk/src/query/websearch.test.ts +31 -0
  536. package/sdk/src/query/websearch.ts +82 -0
  537. package/sdk/src/query/workspace.test.ts +119 -0
  538. package/sdk/src/query/workspace.ts +131 -0
  539. package/sdk/src/query/workstream.test.ts +51 -0
  540. package/sdk/src/query/workstream.ts +434 -0
  541. package/sdk/src/research-gate.test.ts +190 -0
  542. package/sdk/src/research-gate.ts +94 -0
  543. package/sdk/src/runtime-health.test.ts +176 -0
  544. package/sdk/src/runtime-health.ts +387 -0
  545. package/sdk/src/session-runner.test.ts +98 -0
  546. package/sdk/src/session-runner.ts +299 -0
  547. package/sdk/src/tool-scoping.test.ts +160 -0
  548. package/sdk/src/tool-scoping.ts +61 -0
  549. package/sdk/src/types.ts +917 -0
  550. package/sdk/src/workstream-utils.ts +33 -0
  551. package/sdk/src/ws-flag.test.ts +285 -0
  552. package/sdk/src/ws-transport.test.ts +161 -0
  553. package/sdk/src/ws-transport.ts +93 -0
  554. package/sdk/tsconfig.json +20 -0
@@ -0,0 +1,1794 @@
1
+ /**
2
+ * Core — Shared utilities, constants, and internal helpers
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { execSync, execFileSync, spawnSync } = require('child_process');
10
+ const { MODEL_PROFILES } = require('./model-profiles.cjs');
11
+
12
+ const WORKSTREAM_SESSION_ENV_KEYS = [
13
+ 'GSD_SESSION_KEY',
14
+ 'CODEX_THREAD_ID',
15
+ 'CLAUDE_SESSION_ID',
16
+ 'CLAUDE_CODE_SSE_PORT',
17
+ 'OPENCODE_SESSION_ID',
18
+ 'GEMINI_SESSION_ID',
19
+ 'CURSOR_SESSION_ID',
20
+ 'WINDSURF_SESSION_ID',
21
+ 'TERM_SESSION_ID',
22
+ 'WT_SESSION',
23
+ 'TMUX_PANE',
24
+ 'ZELLIJ_SESSION_NAME',
25
+ ];
26
+
27
+ let cachedControllingTtyToken = null;
28
+ let didProbeControllingTtyToken = false;
29
+
30
+ // Track all .planning/.lock files held by this process so they can be removed
31
+ // on exit. process.on('exit') fires even on process.exit(1), unlike try/finally
32
+ // which is skipped when error() calls process.exit(1) inside a locked region (#1916).
33
+ const _heldPlanningLocks = new Set();
34
+ process.on('exit', () => {
35
+ for (const lockPath of _heldPlanningLocks) {
36
+ try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
37
+ }
38
+ });
39
+
40
+ // ─── Path helpers ────────────────────────────────────────────────────────────
41
+
42
+ /** Normalize a relative path to always use forward slashes (cross-platform). */
43
+ function toPosixPath(p) {
44
+ return p.split(path.sep).join('/');
45
+ }
46
+
47
+ /**
48
+ * Scan immediate child directories for separate git repos.
49
+ * Returns a sorted array of directory names that have their own `.git`.
50
+ * Excludes hidden directories and node_modules.
51
+ */
52
+ function detectSubRepos(cwd) {
53
+ const results = [];
54
+ try {
55
+ const entries = fs.readdirSync(cwd, { withFileTypes: true });
56
+ for (const entry of entries) {
57
+ if (!entry.isDirectory()) continue;
58
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
59
+ const gitPath = path.join(cwd, entry.name, '.git');
60
+ try {
61
+ if (fs.existsSync(gitPath)) {
62
+ results.push(entry.name);
63
+ }
64
+ } catch {}
65
+ }
66
+ } catch {}
67
+ return results.sort();
68
+ }
69
+
70
+ /**
71
+ * Walk up from `startDir` to find the project root that owns `.planning/`.
72
+ *
73
+ * In multi-repo workspaces, Claude may open inside a sub-repo (e.g. `backend/`)
74
+ * instead of the project root. This function prevents `.planning/` from being
75
+ * created inside the sub-repo by locating the nearest ancestor that already has
76
+ * a `.planning/` directory.
77
+ *
78
+ * Detection strategy (checked in order for each ancestor):
79
+ * 1. Parent has `.planning/config.json` with `sub_repos` listing this directory
80
+ * 2. Parent has `.planning/config.json` with `multiRepo: true` (legacy format)
81
+ * 3. Parent has `.planning/` and current dir has its own `.git` (heuristic)
82
+ *
83
+ * Returns `startDir` unchanged when no ancestor `.planning/` is found (first-run
84
+ * or single-repo projects).
85
+ */
86
+ function findProjectRoot(startDir) {
87
+ const resolved = path.resolve(startDir);
88
+ const root = path.parse(resolved).root;
89
+ const homedir = require('os').homedir();
90
+
91
+ // If startDir already contains .planning/, it IS the project root.
92
+ // Do not walk up to a parent workspace that also has .planning/ (#1362).
93
+ const ownPlanning = path.join(resolved, '.planning');
94
+ if (fs.existsSync(ownPlanning) && fs.statSync(ownPlanning).isDirectory()) {
95
+ return startDir;
96
+ }
97
+
98
+ // Check if startDir or any of its ancestors (up to AND including the
99
+ // candidate project root) contains a .git directory. This handles both
100
+ // `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside),
101
+ // as well as the common case where .git lives at the same level as .planning/.
102
+ function isInsideGitRepo(candidateParent) {
103
+ let d = resolved;
104
+ while (d !== root) {
105
+ if (fs.existsSync(path.join(d, '.git'))) return true;
106
+ if (d === candidateParent) break;
107
+ d = path.dirname(d);
108
+ }
109
+ return false;
110
+ }
111
+
112
+ let dir = resolved;
113
+ while (dir !== root) {
114
+ const parent = path.dirname(dir);
115
+ if (parent === dir) break; // filesystem root
116
+ if (parent === homedir) break; // never go above home
117
+
118
+ const parentPlanning = path.join(parent, '.planning');
119
+ if (fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory()) {
120
+ const configPath = path.join(parentPlanning, 'config.json');
121
+ try {
122
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
123
+ const subRepos = config.sub_repos || config.planning?.sub_repos || [];
124
+
125
+ // Check explicit sub_repos list
126
+ if (Array.isArray(subRepos) && subRepos.length > 0) {
127
+ const relPath = path.relative(parent, resolved);
128
+ const topSegment = relPath.split(path.sep)[0];
129
+ if (subRepos.includes(topSegment)) {
130
+ return parent;
131
+ }
132
+ }
133
+
134
+ // Check legacy multiRepo flag
135
+ if (config.multiRepo === true && isInsideGitRepo(parent)) {
136
+ return parent;
137
+ }
138
+ } catch {
139
+ // config.json missing or malformed — fall back to .git heuristic
140
+ }
141
+
142
+ // Heuristic: parent has .planning/ and we're inside a git repo
143
+ if (isInsideGitRepo(parent)) {
144
+ return parent;
145
+ }
146
+ }
147
+ dir = parent;
148
+ }
149
+ return startDir;
150
+ }
151
+
152
+ // ─── Output helpers ───────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
156
+ * Runs opportunistically before each new temp file write to prevent unbounded accumulation.
157
+ * @param {string} prefix - filename prefix to match (e.g., 'gsd-')
158
+ * @param {object} opts
159
+ * @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min)
160
+ * @param {boolean} opts.dirsOnly - if true, only remove directories (default: false)
161
+ */
162
+ /**
163
+ * Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
164
+ * Created on first use. Keeps GSD temp files isolated from the system
165
+ * temp directory so reap scans only GSD files (#1975).
166
+ */
167
+ const GSD_TEMP_DIR = path.join(require('os').tmpdir(), 'gsd');
168
+
169
+ function ensureGsdTempDir() {
170
+ fs.mkdirSync(GSD_TEMP_DIR, { recursive: true });
171
+ }
172
+
173
+ function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
174
+ try {
175
+ ensureGsdTempDir();
176
+ const now = Date.now();
177
+ const entries = fs.readdirSync(GSD_TEMP_DIR);
178
+ for (const entry of entries) {
179
+ if (!entry.startsWith(prefix)) continue;
180
+ const fullPath = path.join(GSD_TEMP_DIR, entry);
181
+ try {
182
+ const stat = fs.statSync(fullPath);
183
+ if (now - stat.mtimeMs > maxAgeMs) {
184
+ if (stat.isDirectory()) {
185
+ fs.rmSync(fullPath, { recursive: true, force: true });
186
+ } else if (!dirsOnly) {
187
+ fs.unlinkSync(fullPath);
188
+ }
189
+ }
190
+ } catch {
191
+ // File may have been removed between readdir and stat — ignore
192
+ }
193
+ }
194
+ } catch {
195
+ // Non-critical — don't let cleanup failures break output
196
+ }
197
+ }
198
+
199
+ function output(result, raw, rawValue) {
200
+ let data;
201
+ if (raw && rawValue !== undefined) {
202
+ data = String(rawValue);
203
+ } else {
204
+ const json = JSON.stringify(result, null, 2);
205
+ // Large payloads exceed Claude Code's Bash tool buffer (~50KB).
206
+ // Write to tmpfile and output the path prefixed with @file: so callers can detect it.
207
+ if (json.length > 50000) {
208
+ reapStaleTempFiles();
209
+ ensureGsdTempDir();
210
+ const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
211
+ fs.writeFileSync(tmpPath, json, 'utf-8');
212
+ data = '@file:' + tmpPath;
213
+ } else {
214
+ data = json;
215
+ }
216
+ }
217
+ // process.stdout.write() is async when stdout is a pipe — process.exit()
218
+ // can tear down the process before the reader consumes the buffer.
219
+ // fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
220
+ // skipping process.exit() lets the event loop drain naturally.
221
+ fs.writeSync(1, data);
222
+ }
223
+
224
+ function error(message) {
225
+ fs.writeSync(2, 'Error: ' + message + '\n');
226
+ process.exit(1);
227
+ }
228
+
229
+ // ─── File & Config utilities ──────────────────────────────────────────────────
230
+
231
+ function safeReadFile(filePath) {
232
+ try {
233
+ return fs.readFileSync(filePath, 'utf-8');
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Canonical config defaults. Single source of truth — imported by config.cjs and verify.cjs.
241
+ */
242
+ const CONFIG_DEFAULTS = {
243
+ model_profile: 'balanced',
244
+ commit_docs: true,
245
+ search_gitignored: false,
246
+ branching_strategy: 'none',
247
+ phase_branch_template: 'gsd/phase-{phase}-{slug}',
248
+ milestone_branch_template: 'gsd/{milestone}-{slug}',
249
+ quick_branch_template: null,
250
+ research: true,
251
+ plan_checker: true,
252
+ verifier: true,
253
+ nyquist_validation: true,
254
+ ai_integration_phase: true,
255
+ parallelization: true,
256
+ brave_search: false,
257
+ firecrawl: false,
258
+ exa_search: false,
259
+ text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus
260
+ sub_repos: [],
261
+ resolve_model_ids: false, // false: return alias as-is | true: map to full Claude model ID | "omit": return '' (runtime uses its default)
262
+ context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
263
+ phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
264
+ project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation')
265
+ subagent_timeout: 300000, // 5 min default; increase for large codebases or slower models (ms)
266
+ security_enforcement: true, // workflow.security_enforcement — threat-model-anchored security verification via /gsd-secure-phase
267
+ security_asvs_level: 1, // workflow.security_asvs_level — OWASP ASVS verification level (1=opportunistic, 2=standard, 3=comprehensive)
268
+ security_block_on: 'high', // workflow.security_block_on — minimum severity that blocks phase advancement ('high' | 'medium' | 'low')
269
+ };
270
+
271
+ function loadConfig(cwd) {
272
+ const configPath = path.join(planningDir(cwd), 'config.json');
273
+ const defaults = CONFIG_DEFAULTS;
274
+
275
+ try {
276
+ const raw = fs.readFileSync(configPath, 'utf-8');
277
+ const parsed = JSON.parse(raw);
278
+
279
+ // Migrate deprecated "depth" key to "granularity" with value mapping
280
+ if ('depth' in parsed && !('granularity' in parsed)) {
281
+ const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
282
+ parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth;
283
+ delete parsed.depth;
284
+ try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch { /* intentionally empty */ }
285
+ }
286
+
287
+ // Auto-detect and sync sub_repos: scan for child directories with .git
288
+ let configDirty = false;
289
+
290
+ // Migrate legacy "multiRepo: true" boolean → sub_repos array
291
+ if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) {
292
+ const detected = detectSubRepos(cwd);
293
+ if (detected.length > 0) {
294
+ parsed.sub_repos = detected;
295
+ if (!parsed.planning) parsed.planning = {};
296
+ parsed.planning.commit_docs = false;
297
+ delete parsed.multiRepo;
298
+ configDirty = true;
299
+ }
300
+ }
301
+
302
+ // Keep sub_repos in sync with actual filesystem
303
+ const currentSubRepos = parsed.sub_repos || parsed.planning?.sub_repos || [];
304
+ if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
305
+ const detected = detectSubRepos(cwd);
306
+ if (detected.length > 0) {
307
+ const sorted = [...currentSubRepos].sort();
308
+ if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
309
+ parsed.sub_repos = detected;
310
+ configDirty = true;
311
+ }
312
+ }
313
+ }
314
+
315
+ // Persist sub_repos changes (migration or sync)
316
+ if (configDirty) {
317
+ try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {}
318
+ }
319
+
320
+ // Warn about unrecognized top-level keys so users don't silently lose config.
321
+ // Derived from config-set's VALID_CONFIG_KEYS (canonical source) plus internal-only
322
+ // keys that loadConfig handles but config-set doesn't expose. This avoids maintaining
323
+ // a hardcoded duplicate that drifts when new config keys are added.
324
+ const { VALID_CONFIG_KEYS } = require('./config.cjs');
325
+ const KNOWN_TOP_LEVEL = new Set([
326
+ // Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow')
327
+ ...[...VALID_CONFIG_KEYS].map(k => k.split('.')[0]),
328
+ // Section containers that hold nested sub-keys
329
+ 'git', 'workflow', 'planning', 'hooks', 'features',
330
+ // Internal keys loadConfig reads but config-set doesn't expose
331
+ 'model_overrides', 'agent_skills', 'context_window', 'resolve_model_ids', 'claude_md_path',
332
+ // Deprecated keys (still accepted for migration, not in config-set)
333
+ 'depth', 'multiRepo',
334
+ ]);
335
+ const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
336
+ if (unknownKeys.length > 0) {
337
+ process.stderr.write(
338
+ `gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`
339
+ );
340
+ }
341
+
342
+ const get = (key, nested) => {
343
+ if (parsed[key] !== undefined) return parsed[key];
344
+ if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
345
+ return parsed[nested.section][nested.field];
346
+ }
347
+ return undefined;
348
+ };
349
+
350
+ const parallelization = (() => {
351
+ const val = get('parallelization');
352
+ if (typeof val === 'boolean') return val;
353
+ if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled;
354
+ return defaults.parallelization;
355
+ })();
356
+
357
+ return {
358
+ model_profile: get('model_profile') ?? defaults.model_profile,
359
+ commit_docs: (() => {
360
+ const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
361
+ // If explicitly set in config, respect the user's choice
362
+ if (explicit !== undefined) return explicit;
363
+ // Auto-detection: when no explicit value and .planning/ is gitignored,
364
+ // default to false instead of true
365
+ if (isGitIgnored(cwd, '.planning/')) return false;
366
+ return defaults.commit_docs;
367
+ })(),
368
+ search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
369
+ branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
370
+ phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
371
+ milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
372
+ quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
373
+ research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
374
+ plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
375
+ verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
376
+ nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
377
+ parallelization,
378
+ brave_search: get('brave_search') ?? defaults.brave_search,
379
+ firecrawl: get('firecrawl') ?? defaults.firecrawl,
380
+ exa_search: get('exa_search') ?? defaults.exa_search,
381
+ tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false,
382
+ text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
383
+ auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
384
+ _auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false,
385
+ mode: get('mode') ?? 'interactive',
386
+ sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos,
387
+ resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
388
+ context_window: get('context_window') ?? defaults.context_window,
389
+ phase_naming: get('phase_naming') ?? defaults.phase_naming,
390
+ project_code: get('project_code') ?? defaults.project_code,
391
+ subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
392
+ model_overrides: parsed.model_overrides || null,
393
+ agent_skills: parsed.agent_skills || {},
394
+ manager: parsed.manager || {},
395
+ response_language: get('response_language') || null,
396
+ claude_md_path: get('claude_md_path') || null,
397
+ claude_md_assembly: parsed.claude_md_assembly || null,
398
+ };
399
+ } catch {
400
+ // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
401
+ // If .planning/ exists, the project is initialized — just missing config.json
402
+ if (fs.existsSync(planningDir(cwd))) {
403
+ return defaults;
404
+ }
405
+ try {
406
+ const home = process.env.GSD_HOME || os.homedir();
407
+ const globalDefaultsPath = path.join(home, '.gsd', 'defaults.json');
408
+ const raw = fs.readFileSync(globalDefaultsPath, 'utf-8');
409
+ const globalDefaults = JSON.parse(raw);
410
+ return {
411
+ ...defaults,
412
+ model_profile: globalDefaults.model_profile ?? defaults.model_profile,
413
+ commit_docs: globalDefaults.commit_docs ?? defaults.commit_docs,
414
+ research: globalDefaults.research ?? defaults.research,
415
+ plan_checker: globalDefaults.plan_checker ?? defaults.plan_checker,
416
+ verifier: globalDefaults.verifier ?? defaults.verifier,
417
+ nyquist_validation: globalDefaults.nyquist_validation ?? defaults.nyquist_validation,
418
+ parallelization: globalDefaults.parallelization ?? defaults.parallelization,
419
+ text_mode: globalDefaults.text_mode ?? defaults.text_mode,
420
+ resolve_model_ids: globalDefaults.resolve_model_ids ?? defaults.resolve_model_ids,
421
+ context_window: globalDefaults.context_window ?? defaults.context_window,
422
+ subagent_timeout: globalDefaults.subagent_timeout ?? defaults.subagent_timeout,
423
+ model_overrides: globalDefaults.model_overrides || null,
424
+ agent_skills: globalDefaults.agent_skills || {},
425
+ response_language: globalDefaults.response_language || null,
426
+ };
427
+ } catch {
428
+ return defaults;
429
+ }
430
+ }
431
+ }
432
+
433
+ // ─── Git utilities ────────────────────────────────────────────────────────────
434
+
435
+ const _gitIgnoredCache = new Map();
436
+
437
+ function isGitIgnored(cwd, targetPath) {
438
+ const key = cwd + '::' + targetPath;
439
+ if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key);
440
+ try {
441
+ // --no-index checks .gitignore rules regardless of whether the file is tracked.
442
+ // Without it, git check-ignore returns "not ignored" for tracked files even when
443
+ // .gitignore explicitly lists them — a common source of confusion when .planning/
444
+ // was committed before being added to .gitignore.
445
+ // Use execFileSync (array args) to prevent shell interpretation of special characters
446
+ // in file paths — avoids command injection via crafted path names.
447
+ execFileSync('git', ['check-ignore', '-q', '--no-index', '--', targetPath], {
448
+ cwd,
449
+ stdio: 'pipe',
450
+ });
451
+ _gitIgnoredCache.set(key, true);
452
+ return true;
453
+ } catch {
454
+ _gitIgnoredCache.set(key, false);
455
+ return false;
456
+ }
457
+ }
458
+
459
+ // ─── Markdown normalization ─────────────────────────────────────────────────
460
+
461
+ /**
462
+ * Normalize markdown to fix common markdownlint violations.
463
+ * Applied at write points so GSD-generated .planning/ files are IDE-friendly.
464
+ *
465
+ * Rules enforced:
466
+ * MD022 — Blank lines around headings
467
+ * MD031 — Blank lines around fenced code blocks
468
+ * MD032 — Blank lines around lists
469
+ * MD012 — No multiple consecutive blank lines (collapsed to 2 max)
470
+ * MD047 — Files end with a single newline
471
+ */
472
+ function normalizeMd(content) {
473
+ if (!content || typeof content !== 'string') return content;
474
+
475
+ // Normalize line endings to LF for consistent processing
476
+ let text = content.replace(/\r\n/g, '\n');
477
+
478
+ const lines = text.split('\n');
479
+ const result = [];
480
+
481
+ // Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
482
+ const fenceRegex = /^```/;
483
+ const insideFence = new Array(lines.length);
484
+ let fenceOpen = false;
485
+ for (let i = 0; i < lines.length; i++) {
486
+ if (fenceRegex.test(lines[i].trimEnd())) {
487
+ if (fenceOpen) {
488
+ // This is a closing fence — mark as NOT inside (it's the boundary)
489
+ insideFence[i] = false;
490
+ fenceOpen = false;
491
+ } else {
492
+ // This is an opening fence
493
+ insideFence[i] = false;
494
+ fenceOpen = true;
495
+ }
496
+ } else {
497
+ insideFence[i] = fenceOpen;
498
+ }
499
+ }
500
+
501
+ for (let i = 0; i < lines.length; i++) {
502
+ const line = lines[i];
503
+ const prev = i > 0 ? lines[i - 1] : '';
504
+ const prevTrimmed = prev.trimEnd();
505
+ const trimmed = line.trimEnd();
506
+ const isFenceLine = fenceRegex.test(trimmed);
507
+
508
+ // MD022: Blank line before headings (skip first line and frontmatter delimiters)
509
+ if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
510
+ result.push('');
511
+ }
512
+
513
+ // MD031: Blank line before fenced code blocks (opening fences only)
514
+ if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
515
+ // Only add blank before opening fences (not closing ones)
516
+ if (i === 0 || !insideFence[i - 1]) {
517
+ result.push('');
518
+ }
519
+ }
520
+
521
+ // MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
522
+ if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
523
+ prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
524
+ prevTrimmed !== '---') {
525
+ result.push('');
526
+ }
527
+
528
+ result.push(line);
529
+
530
+ // MD022: Blank line after headings
531
+ if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) {
532
+ const next = lines[i + 1];
533
+ if (next !== undefined && next.trimEnd() !== '') {
534
+ result.push('');
535
+ }
536
+ }
537
+
538
+ // MD031: Blank line after closing fenced code blocks
539
+ if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
540
+ const next = lines[i + 1];
541
+ if (next !== undefined && next.trimEnd() !== '') {
542
+ result.push('');
543
+ }
544
+ }
545
+
546
+ // MD032: Blank line after last list item in a block
547
+ if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
548
+ const next = lines[i + 1];
549
+ if (next !== undefined && next.trimEnd() !== '' &&
550
+ !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
551
+ !/^\s/.test(next)) {
552
+ // Only add blank line if next line is not a continuation/indented line
553
+ result.push('');
554
+ }
555
+ }
556
+ }
557
+
558
+ text = result.join('\n');
559
+
560
+ // MD012: Collapse 3+ consecutive blank lines to 2
561
+ text = text.replace(/\n{3,}/g, '\n\n');
562
+
563
+ // MD047: Ensure file ends with exactly one newline
564
+ text = text.replace(/\n*$/, '\n');
565
+
566
+ return text;
567
+ }
568
+
569
+ function execGit(cwd, args) {
570
+ const result = spawnSync('git', args, {
571
+ cwd,
572
+ stdio: 'pipe',
573
+ encoding: 'utf-8',
574
+ });
575
+ return {
576
+ exitCode: result.status ?? 1,
577
+ stdout: (result.stdout ?? '').toString().trim(),
578
+ stderr: (result.stderr ?? '').toString().trim(),
579
+ };
580
+ }
581
+
582
+ // ─── Common path helpers ──────────────────────────────────────────────────────
583
+
584
+ /**
585
+ * Resolve the main worktree root when running inside a git worktree.
586
+ * In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
587
+ * Returns the main worktree path, or cwd if not in a worktree.
588
+ */
589
+ function resolveWorktreeRoot(cwd) {
590
+ // If the current directory already has its own .planning/, respect it.
591
+ // This handles linked worktrees with independent planning state (e.g., Conductor workspaces).
592
+ if (fs.existsSync(path.join(cwd, '.planning'))) {
593
+ return cwd;
594
+ }
595
+
596
+ // Check if we're in a linked worktree
597
+ const gitDir = execGit(cwd, ['rev-parse', '--git-dir']);
598
+ const commonDir = execGit(cwd, ['rev-parse', '--git-common-dir']);
599
+
600
+ if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return cwd;
601
+
602
+ // In a linked worktree, .git is a file pointing to .git/worktrees/<name>
603
+ // and git-common-dir points to the main repo's .git directory
604
+ const gitDirResolved = path.resolve(cwd, gitDir.stdout);
605
+ const commonDirResolved = path.resolve(cwd, commonDir.stdout);
606
+
607
+ if (gitDirResolved !== commonDirResolved) {
608
+ // We're in a linked worktree — resolve main worktree root
609
+ // The common dir is the main repo's .git, so its parent is the main worktree root
610
+ return path.dirname(commonDirResolved);
611
+ }
612
+
613
+ return cwd;
614
+ }
615
+
616
+ /**
617
+ * Parse `git worktree list --porcelain` output into an array of
618
+ * { path, branch } objects. Entries with a detached HEAD (no branch line)
619
+ * are skipped because we cannot safely reason about their merge status.
620
+ *
621
+ * @param {string} porcelain - raw output from git worktree list --porcelain
622
+ * @returns {{ path: string, branch: string }[]}
623
+ */
624
+ function parseWorktreePorcelain(porcelain) {
625
+ const entries = [];
626
+ let current = null;
627
+ for (const line of porcelain.split('\n')) {
628
+ if (line.startsWith('worktree ')) {
629
+ current = { path: line.slice('worktree '.length).trim(), branch: null };
630
+ } else if (line.startsWith('branch refs/heads/') && current) {
631
+ current.branch = line.slice('branch refs/heads/'.length).trim();
632
+ } else if (line === '' && current) {
633
+ if (current.branch) entries.push(current);
634
+ current = null;
635
+ }
636
+ }
637
+ // flush last entry if file doesn't end with blank line
638
+ if (current && current.branch) entries.push(current);
639
+ return entries;
640
+ }
641
+
642
+ /**
643
+ * Remove linked git worktrees whose branch has already been merged into the
644
+ * current HEAD of the main worktree. Also runs `git worktree prune` to clear
645
+ * any stale references left by manually-deleted worktree directories.
646
+ *
647
+ * Safe guards:
648
+ * - Never removes the main worktree (first entry in --porcelain output).
649
+ * - Never removes the worktree at process.cwd().
650
+ * - Never removes a worktree whose branch has unmerged commits.
651
+ * - Skips detached-HEAD worktrees (no branch name).
652
+ *
653
+ * @param {string} repoRoot - absolute path to the main (or any) worktree of
654
+ * the repository; used as `cwd` for git commands.
655
+ * @returns {string[]} list of worktree paths that were removed
656
+ */
657
+ function pruneOrphanedWorktrees(repoRoot) {
658
+ const pruned = [];
659
+ const cwd = process.cwd();
660
+
661
+ try {
662
+ // 1. Get all worktrees in porcelain format
663
+ const listResult = execGit(repoRoot, ['worktree', 'list', '--porcelain']);
664
+ if (listResult.exitCode !== 0) return pruned;
665
+
666
+ const worktrees = parseWorktreePorcelain(listResult.stdout);
667
+ if (worktrees.length === 0) {
668
+ execGit(repoRoot, ['worktree', 'prune']);
669
+ return pruned;
670
+ }
671
+
672
+ // 2. First entry is the main worktree — never touch it
673
+ const mainWorktreePath = worktrees[0].path;
674
+
675
+ // 3. Check each non-main worktree
676
+ for (let i = 1; i < worktrees.length; i++) {
677
+ const { path: wtPath, branch } = worktrees[i];
678
+
679
+ // Never remove the worktree for the current process directory
680
+ if (wtPath === cwd || cwd.startsWith(wtPath + path.sep)) continue;
681
+
682
+ // Check if the branch is fully merged into HEAD (main)
683
+ // git merge-base --is-ancestor <branch> HEAD exits 0 when merged
684
+ const ancestorCheck = execGit(repoRoot, [
685
+ 'merge-base', '--is-ancestor', branch, 'HEAD',
686
+ ]);
687
+
688
+ if (ancestorCheck.exitCode !== 0) {
689
+ // Not yet merged — leave it alone
690
+ continue;
691
+ }
692
+
693
+ // Remove the worktree and delete the branch
694
+ const removeResult = execGit(repoRoot, ['worktree', 'remove', '--force', wtPath]);
695
+ if (removeResult.exitCode === 0) {
696
+ execGit(repoRoot, ['branch', '-D', branch]);
697
+ pruned.push(wtPath);
698
+ }
699
+ }
700
+ } catch { /* never crash the caller */ }
701
+
702
+ // 4. Always run prune to clear stale references (e.g. manually-deleted dirs)
703
+ execGit(repoRoot, ['worktree', 'prune']);
704
+
705
+ return pruned;
706
+ }
707
+
708
+ /**
709
+ * Acquire a file-based lock for .planning/ writes.
710
+ * Prevents concurrent worktrees from corrupting shared planning files.
711
+ * Lock is auto-released after the callback completes.
712
+ */
713
+ function withPlanningLock(cwd, fn) {
714
+ const lockPath = path.join(planningDir(cwd), '.lock');
715
+ const lockTimeout = 10000; // 10 seconds
716
+ const retryDelay = 100;
717
+ const start = Date.now();
718
+
719
+ // Ensure .planning/ exists
720
+ try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ }
721
+
722
+ while (Date.now() - start < lockTimeout) {
723
+ try {
724
+ // Atomic create — fails if file exists
725
+ fs.writeFileSync(lockPath, JSON.stringify({
726
+ pid: process.pid,
727
+ cwd,
728
+ acquired: new Date().toISOString(),
729
+ }), { flag: 'wx' });
730
+
731
+ // Register for exit-time cleanup so process.exit(1) inside a locked region
732
+ // cannot leave a stale lock file (#1916).
733
+ _heldPlanningLocks.add(lockPath);
734
+
735
+ // Lock acquired — run the function
736
+ try {
737
+ return fn();
738
+ } finally {
739
+ _heldPlanningLocks.delete(lockPath);
740
+ try { fs.unlinkSync(lockPath); } catch { /* already released */ }
741
+ }
742
+ } catch (err) {
743
+ if (err.code === 'EEXIST') {
744
+ // Lock exists — check if stale (>30s old)
745
+ try {
746
+ const stat = fs.statSync(lockPath);
747
+ if (Date.now() - stat.mtimeMs > 30000) {
748
+ fs.unlinkSync(lockPath);
749
+ continue; // retry
750
+ }
751
+ } catch { continue; }
752
+
753
+ // Wait and retry (cross-platform, no shell dependency)
754
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
755
+ continue;
756
+ }
757
+ throw err;
758
+ }
759
+ }
760
+ // Timeout — force acquire (stale lock recovery)
761
+ try { fs.unlinkSync(lockPath); } catch { /* ok */ }
762
+ return fn();
763
+ }
764
+
765
+ /**
766
+ * Get the .planning directory path, project- and workstream-aware.
767
+ *
768
+ * Resolution order:
769
+ * 1. If GSD_PROJECT is set (env var or explicit `project` arg), routes to
770
+ * `.planning/{project}/` — supports multi-project workspaces where several
771
+ * independent projects share a single `.planning/` root directory (e.g.,
772
+ * an Obsidian vault or monorepo knowledge base used as a command center).
773
+ * 2. If GSD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`.
774
+ * 3. Otherwise returns `.planning/`.
775
+ *
776
+ * GSD_PROJECT and GSD_WORKSTREAM can be combined:
777
+ * `.planning/{project}/workstreams/{ws}/`
778
+ *
779
+ * @param {string} cwd - project root
780
+ * @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var
781
+ * @param {string} [project] - explicit project name; if omitted, checks GSD_PROJECT env var
782
+ */
783
+ function planningDir(cwd, ws, project) {
784
+ if (project === undefined) project = process.env.GSD_PROJECT || null;
785
+ if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
786
+
787
+ // Reject path separators and traversal components in project/workstream names
788
+ const BAD_SEGMENT = /[/\\]|\.\./;
789
+ if (project && BAD_SEGMENT.test(project)) {
790
+ throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`);
791
+ }
792
+ if (ws && BAD_SEGMENT.test(ws)) {
793
+ throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`);
794
+ }
795
+
796
+ let base = path.join(cwd, '.planning');
797
+ if (project) base = path.join(base, project);
798
+ if (ws) base = path.join(base, 'workstreams', ws);
799
+ return base;
800
+ }
801
+
802
+ /** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */
803
+ function planningRoot(cwd) {
804
+ return path.join(cwd, '.planning');
805
+ }
806
+
807
+ /**
808
+ * Get common .planning file paths, project-and-workstream-aware.
809
+ *
810
+ * All paths route through planningDir(cwd, ws), which honors the GSD_PROJECT
811
+ * env var and active workstream. This matches loadConfig() above (line 256),
812
+ * which has always read config.json via planningDir(cwd). Previously project
813
+ * and config were resolved against the unrouted .planning/ root, which broke
814
+ * `gsd-tools config-get` in multi-project layouts (the CRUD writers and the
815
+ * reader pointed at different files).
816
+ */
817
+ function planningPaths(cwd, ws) {
818
+ const base = planningDir(cwd, ws);
819
+ return {
820
+ planning: base,
821
+ state: path.join(base, 'STATE.md'),
822
+ roadmap: path.join(base, 'ROADMAP.md'),
823
+ project: path.join(base, 'PROJECT.md'),
824
+ config: path.join(base, 'config.json'),
825
+ phases: path.join(base, 'phases'),
826
+ requirements: path.join(base, 'REQUIREMENTS.md'),
827
+ };
828
+ }
829
+
830
+ // ─── Active Workstream Detection ─────────────────────────────────────────────
831
+
832
+ function sanitizeWorkstreamSessionToken(value) {
833
+ if (value === null || value === undefined) return null;
834
+ const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
835
+ return token ? token.slice(0, 160) : null;
836
+ }
837
+
838
+ function probeControllingTtyToken() {
839
+ if (didProbeControllingTtyToken) return cachedControllingTtyToken;
840
+ didProbeControllingTtyToken = true;
841
+
842
+ // `tty` reads stdin. When stdin is already non-interactive, spawning it only
843
+ // adds avoidable failures on the routing hot path and cannot reveal a stable token.
844
+ if (!(process.stdin && process.stdin.isTTY)) {
845
+ return cachedControllingTtyToken;
846
+ }
847
+
848
+ try {
849
+ const ttyPath = execFileSync('tty', [], {
850
+ encoding: 'utf-8',
851
+ stdio: ['inherit', 'pipe', 'ignore'],
852
+ }).trim();
853
+ if (ttyPath && ttyPath !== 'not a tty') {
854
+ const token = sanitizeWorkstreamSessionToken(ttyPath.replace(/^\/dev\//, ''));
855
+ if (token) cachedControllingTtyToken = `tty-${token}`;
856
+ }
857
+ } catch {}
858
+
859
+ return cachedControllingTtyToken;
860
+ }
861
+
862
+ function getControllingTtyToken() {
863
+ for (const envKey of ['TTY', 'SSH_TTY']) {
864
+ const token = sanitizeWorkstreamSessionToken(process.env[envKey]);
865
+ if (token) return `tty-${token.replace(/^dev_/, '')}`;
866
+ }
867
+
868
+ return probeControllingTtyToken();
869
+ }
870
+
871
+ /**
872
+ * Resolve a deterministic session key for workstream-local routing.
873
+ *
874
+ * Order:
875
+ * 1. Explicit runtime/session env vars (`GSD_SESSION_KEY`, `CODEX_THREAD_ID`, etc.)
876
+ * 2. Terminal identity exposed via `TTY` or `SSH_TTY`
877
+ * 3. One best-effort `tty` probe when stdin is interactive
878
+ * 4. `null`, which tells callers to use the legacy shared pointer fallback
879
+ */
880
+ function getWorkstreamSessionKey() {
881
+ for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
882
+ const raw = process.env[envKey];
883
+ const token = sanitizeWorkstreamSessionToken(raw);
884
+ if (token) return `${envKey.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${token}`;
885
+ }
886
+
887
+ return getControllingTtyToken();
888
+ }
889
+
890
+ function getSessionScopedWorkstreamFile(cwd) {
891
+ const sessionKey = getWorkstreamSessionKey();
892
+ if (!sessionKey) return null;
893
+
894
+ // Use realpathSync.native so the hash is derived from the canonical filesystem
895
+ // path. On Windows, path.resolve returns whatever case the caller supplied,
896
+ // while realpathSync.native returns the case the OS recorded — they differ on
897
+ // case-insensitive NTFS, producing different hashes and different tmpdir slots.
898
+ // Fall back to path.resolve when the directory does not yet exist.
899
+ let planningAbs;
900
+ try {
901
+ planningAbs = fs.realpathSync.native(planningRoot(cwd));
902
+ } catch {
903
+ planningAbs = path.resolve(planningRoot(cwd));
904
+ }
905
+ const projectId = crypto
906
+ .createHash('sha1')
907
+ .update(planningAbs)
908
+ .digest('hex')
909
+ .slice(0, 16);
910
+
911
+ const dirPath = path.join(os.tmpdir(), 'gsd-workstream-sessions', projectId);
912
+ return {
913
+ sessionKey,
914
+ dirPath,
915
+ filePath: path.join(dirPath, sessionKey),
916
+ };
917
+ }
918
+
919
+ function clearActiveWorkstreamPointer(filePath, cleanupDirPath) {
920
+ try { fs.unlinkSync(filePath); } catch {}
921
+
922
+ // Session-scoped pointers for a repo share one tmp directory. Only remove it
923
+ // when it is empty so clearing or self-healing one session never deletes siblings.
924
+ // Explicitly check remaining entries rather than relying on rmdirSync throwing
925
+ // ENOTEMPTY — that error is not raised reliably on Windows.
926
+ if (cleanupDirPath) {
927
+ try {
928
+ const remaining = fs.readdirSync(cleanupDirPath);
929
+ if (remaining.length === 0) {
930
+ fs.rmdirSync(cleanupDirPath);
931
+ }
932
+ } catch {}
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Pointer files are self-healing: invalid names or deleted-workstream pointers
938
+ * are removed on read so the session falls back to `null` instead of carrying
939
+ * silent stale state forward. Session-scoped callers may also prune an empty
940
+ * per-project tmp directory; shared `.planning/active-workstream` callers do not.
941
+ */
942
+ function readActiveWorkstreamPointer(filePath, cwd, cleanupDirPath = null) {
943
+ try {
944
+ const name = fs.readFileSync(filePath, 'utf-8').trim();
945
+ if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
946
+ clearActiveWorkstreamPointer(filePath, cleanupDirPath);
947
+ return null;
948
+ }
949
+ const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
950
+ if (!fs.existsSync(wsDir)) {
951
+ clearActiveWorkstreamPointer(filePath, cleanupDirPath);
952
+ return null;
953
+ }
954
+ return name;
955
+ } catch {
956
+ return null;
957
+ }
958
+ }
959
+
960
+ /**
961
+ * Get the active workstream name.
962
+ *
963
+ * Resolution priority:
964
+ * 1. Session-scoped pointer (tmpdir) when the runtime exposes a stable session key
965
+ * 2. Legacy shared `.planning/active-workstream` file when no session key is available
966
+ *
967
+ * The shared file is intentionally ignored when a session key exists so multiple
968
+ * concurrent sessions do not overwrite each other's active workstream.
969
+ */
970
+ function getActiveWorkstream(cwd) {
971
+ const sessionScoped = getSessionScopedWorkstreamFile(cwd);
972
+ if (sessionScoped) {
973
+ return readActiveWorkstreamPointer(sessionScoped.filePath, cwd, sessionScoped.dirPath);
974
+ }
975
+
976
+ const sharedFilePath = path.join(planningRoot(cwd), 'active-workstream');
977
+ return readActiveWorkstreamPointer(sharedFilePath, cwd);
978
+ }
979
+
980
+ /**
981
+ * Set the active workstream. Pass null to clear.
982
+ *
983
+ * When a stable session key is available, this updates a tmpdir-backed
984
+ * session-scoped pointer. Otherwise it falls back to the legacy shared
985
+ * `.planning/active-workstream` file for backward compatibility.
986
+ */
987
+ function setActiveWorkstream(cwd, name) {
988
+ const sessionScoped = getSessionScopedWorkstreamFile(cwd);
989
+ const filePath = sessionScoped
990
+ ? sessionScoped.filePath
991
+ : path.join(planningRoot(cwd), 'active-workstream');
992
+
993
+ if (!name) {
994
+ clearActiveWorkstreamPointer(filePath, sessionScoped ? sessionScoped.dirPath : null);
995
+ return;
996
+ }
997
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
998
+ throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
999
+ }
1000
+
1001
+ if (sessionScoped) {
1002
+ fs.mkdirSync(sessionScoped.dirPath, { recursive: true });
1003
+ }
1004
+ fs.writeFileSync(filePath, name + '\n', 'utf-8');
1005
+ }
1006
+
1007
+ // ─── Phase utilities ──────────────────────────────────────────────────────────
1008
+
1009
+ function escapeRegex(value) {
1010
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1011
+ }
1012
+
1013
+ function normalizePhaseName(phase) {
1014
+ const str = String(phase);
1015
+ // Strip optional project_code prefix (e.g., 'CK-01' → '01')
1016
+ const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
1017
+ // Standard numeric phases: 1, 01, 12A, 12.1
1018
+ const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
1019
+ if (match) {
1020
+ const padded = match[1].padStart(2, '0');
1021
+ // Preserve original case of letter suffix (#1962).
1022
+ // Uppercasing causes directory/roadmap mismatches on case-sensitive filesystems
1023
+ // (e.g., "16c" in ROADMAP.md → directory "16C-name" → progress can't match).
1024
+ const letter = match[2] || '';
1025
+ const decimal = match[3] || '';
1026
+ return padded + letter + decimal;
1027
+ }
1028
+ // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
1029
+ return str;
1030
+ }
1031
+
1032
+ function comparePhaseNum(a, b) {
1033
+ // Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name')
1034
+ const sa = String(a).replace(/^[A-Z]{1,6}-/, '');
1035
+ const sb = String(b).replace(/^[A-Z]{1,6}-/, '');
1036
+ const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
1037
+ const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
1038
+ // If either is non-numeric (custom ID), fall back to string comparison
1039
+ if (!pa || !pb) return String(a).localeCompare(String(b));
1040
+ const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
1041
+ if (intDiff !== 0) return intDiff;
1042
+ // No letter sorts before letter: 12 < 12A < 12B
1043
+ const la = (pa[2] || '').toUpperCase();
1044
+ const lb = (pb[2] || '').toUpperCase();
1045
+ if (la !== lb) {
1046
+ if (!la) return -1;
1047
+ if (!lb) return 1;
1048
+ return la < lb ? -1 : 1;
1049
+ }
1050
+ // Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2
1051
+ const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
1052
+ const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
1053
+ const maxLen = Math.max(aDecParts.length, bDecParts.length);
1054
+ if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
1055
+ if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
1056
+ for (let i = 0; i < maxLen; i++) {
1057
+ const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
1058
+ const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
1059
+ if (av !== bv) return av - bv;
1060
+ }
1061
+ return 0;
1062
+ }
1063
+
1064
+ /**
1065
+ * Extract the phase token from a directory name.
1066
+ * Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'.
1067
+ * Returns the token portion (e.g. '01', '1009A', '999.6', 'PROJ-42') or the full name if no separator.
1068
+ */
1069
+ function extractPhaseToken(dirName) {
1070
+ // Try project-code-prefixed numeric: CK-01-name → CK-01, CK-01A.2-name → CK-01A.2
1071
+ const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
1072
+ if (codePrefixed) return codePrefixed[1];
1073
+ // Try plain numeric: 01-name, 1009A-name, 999.6-name
1074
+ const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
1075
+ if (numeric) return numeric[1];
1076
+ // Custom IDs: PROJ-42-name → everything before the last segment that looks like a name
1077
+ const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i);
1078
+ if (custom) return custom[1];
1079
+ return dirName;
1080
+ }
1081
+
1082
+ /**
1083
+ * Check if a directory name's phase token matches the normalized phase exactly.
1084
+ * Case-insensitive comparison for the token portion.
1085
+ */
1086
+ function phaseTokenMatches(dirName, normalized) {
1087
+ const token = extractPhaseToken(dirName);
1088
+ if (token.toUpperCase() === normalized.toUpperCase()) return true;
1089
+ // Strip optional project_code prefix from dir and retry
1090
+ const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
1091
+ if (stripped !== dirName) {
1092
+ const strippedToken = extractPhaseToken(stripped);
1093
+ if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
1094
+ }
1095
+ return false;
1096
+ }
1097
+
1098
+ function searchPhaseInDir(baseDir, relBase, normalized) {
1099
+ try {
1100
+ const dirs = readSubdirectories(baseDir, true);
1101
+ // Match: exact phase token comparison (not prefix matching)
1102
+ const match = dirs.find(d => phaseTokenMatches(d, normalized));
1103
+ if (!match) return null;
1104
+
1105
+ // Extract phase number and name — supports numeric (01-name), project-code-prefixed (CK-01-name), and custom (PROJ-42-name)
1106
+ const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
1107
+ || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
1108
+ || match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
1109
+ || [null, match, null];
1110
+ const phaseNumber = dirMatch ? dirMatch[1] : normalized;
1111
+ const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
1112
+ const phaseDir = path.join(baseDir, match);
1113
+ const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
1114
+ const plans = unsortedPlans.sort();
1115
+ const summaries = unsortedSummaries.sort();
1116
+
1117
+ const completedPlanIds = new Set(
1118
+ summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
1119
+ );
1120
+ const incompletePlans = plans.filter(p => {
1121
+ const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
1122
+ return !completedPlanIds.has(planId);
1123
+ });
1124
+
1125
+ return {
1126
+ found: true,
1127
+ directory: toPosixPath(path.join(relBase, match)),
1128
+ phase_number: phaseNumber,
1129
+ phase_name: phaseName,
1130
+ phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
1131
+ plans,
1132
+ summaries,
1133
+ incomplete_plans: incompletePlans,
1134
+ has_research: hasResearch,
1135
+ has_context: hasContext,
1136
+ has_verification: hasVerification,
1137
+ has_reviews: hasReviews,
1138
+ };
1139
+ } catch {
1140
+ return null;
1141
+ }
1142
+ }
1143
+
1144
+ function findPhaseInternal(cwd, phase) {
1145
+ if (!phase) return null;
1146
+
1147
+ const phasesDir = path.join(planningDir(cwd), 'phases');
1148
+ const normalized = normalizePhaseName(phase);
1149
+
1150
+ // Search current phases first
1151
+ const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
1152
+ const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
1153
+ if (current) return current;
1154
+
1155
+ // Search archived milestone phases (newest first)
1156
+ const milestonesDir = path.join(cwd, '.planning', 'milestones');
1157
+ if (!fs.existsSync(milestonesDir)) return null;
1158
+
1159
+ try {
1160
+ const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
1161
+ const archiveDirs = milestoneEntries
1162
+ .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
1163
+ .map(e => e.name)
1164
+ .sort()
1165
+ .reverse();
1166
+
1167
+ for (const archiveName of archiveDirs) {
1168
+ const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
1169
+ const archivePath = path.join(milestonesDir, archiveName);
1170
+ const relBase = '.planning/milestones/' + archiveName;
1171
+ const result = searchPhaseInDir(archivePath, relBase, normalized);
1172
+ if (result) {
1173
+ result.archived = version;
1174
+ return result;
1175
+ }
1176
+ }
1177
+ } catch { /* intentionally empty */ }
1178
+
1179
+ return null;
1180
+ }
1181
+
1182
+ function getArchivedPhaseDirs(cwd) {
1183
+ const milestonesDir = path.join(cwd, '.planning', 'milestones');
1184
+ const results = [];
1185
+
1186
+ if (!fs.existsSync(milestonesDir)) return results;
1187
+
1188
+ try {
1189
+ const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
1190
+ // Find v*-phases directories, sort newest first
1191
+ const phaseDirs = milestoneEntries
1192
+ .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
1193
+ .map(e => e.name)
1194
+ .sort()
1195
+ .reverse();
1196
+
1197
+ for (const archiveName of phaseDirs) {
1198
+ const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
1199
+ const archivePath = path.join(milestonesDir, archiveName);
1200
+ const dirs = readSubdirectories(archivePath, true);
1201
+
1202
+ for (const dir of dirs) {
1203
+ results.push({
1204
+ name: dir,
1205
+ milestone: version,
1206
+ basePath: path.join('.planning', 'milestones', archiveName),
1207
+ fullPath: path.join(archivePath, dir),
1208
+ });
1209
+ }
1210
+ }
1211
+ } catch { /* intentionally empty */ }
1212
+
1213
+ return results;
1214
+ }
1215
+
1216
+ // ─── Roadmap milestone scoping ───────────────────────────────────────────────
1217
+
1218
+ /**
1219
+ * Strip shipped milestone content wrapped in <details> blocks.
1220
+ * Used to isolate current milestone phases when searching ROADMAP.md
1221
+ * for phase headings or checkboxes — prevents matching archived milestone
1222
+ * phases that share the same numbers as current milestone phases.
1223
+ */
1224
+ function stripShippedMilestones(content) {
1225
+ return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
1226
+ }
1227
+
1228
+ /**
1229
+ * Extract the current milestone section from ROADMAP.md by positive lookup.
1230
+ *
1231
+ * Instead of stripping <details> blocks (negative heuristic that breaks if
1232
+ * agents wrap the current milestone in <details>), this finds the section
1233
+ * matching the current milestone version and returns only that content.
1234
+ *
1235
+ * Falls back to stripShippedMilestones() if:
1236
+ * - cwd is not provided
1237
+ * - STATE.md doesn't exist or has no milestone field
1238
+ * - Version can't be found in ROADMAP.md
1239
+ *
1240
+ * @param {string} content - Full ROADMAP.md content
1241
+ * @param {string} [cwd] - Working directory for reading STATE.md
1242
+ * @returns {string} Content scoped to current milestone
1243
+ */
1244
+ function extractCurrentMilestone(content, cwd) {
1245
+ if (!cwd) return stripShippedMilestones(content);
1246
+
1247
+ // 1. Get current milestone version from STATE.md frontmatter
1248
+ let version = null;
1249
+ try {
1250
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
1251
+ if (fs.existsSync(statePath)) {
1252
+ const stateRaw = fs.readFileSync(statePath, 'utf-8');
1253
+ const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
1254
+ if (milestoneMatch) {
1255
+ version = milestoneMatch[1].trim();
1256
+ }
1257
+ }
1258
+ } catch {}
1259
+
1260
+ // 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself
1261
+ if (!version) {
1262
+ // Check for 🚧 in-progress marker
1263
+ const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
1264
+ if (inProgressMatch) {
1265
+ version = 'v' + inProgressMatch[1];
1266
+ }
1267
+ }
1268
+
1269
+ if (!version) return stripShippedMilestones(content);
1270
+
1271
+ // 3. Find the section matching this version
1272
+ // Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc.
1273
+ const escapedVersion = escapeRegex(version);
1274
+ const sectionPattern = new RegExp(
1275
+ `(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`,
1276
+ 'mi'
1277
+ );
1278
+ const sectionMatch = content.match(sectionPattern);
1279
+
1280
+ if (!sectionMatch) return stripShippedMilestones(content);
1281
+
1282
+ const sectionStart = sectionMatch.index;
1283
+
1284
+ // Find the end: next milestone heading at same or higher level, or EOF
1285
+ // Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc.
1286
+ const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
1287
+ const restContent = content.slice(sectionStart + sectionMatch[0].length);
1288
+ const nextMilestonePattern = new RegExp(
1289
+ `^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
1290
+ 'mi'
1291
+ );
1292
+ const nextMatch = restContent.match(nextMilestonePattern);
1293
+
1294
+ let sectionEnd;
1295
+ if (nextMatch) {
1296
+ sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index;
1297
+ } else {
1298
+ sectionEnd = content.length;
1299
+ }
1300
+
1301
+ // Return everything before the current milestone section (non-milestone content
1302
+ // like title, overview) plus the current milestone section
1303
+ const beforeMilestones = content.slice(0, sectionStart);
1304
+ const currentSection = content.slice(sectionStart, sectionEnd);
1305
+
1306
+ // Also include any content before the first milestone heading (title, overview, etc.)
1307
+ // but strip any <details> blocks in it (these are definitely shipped)
1308
+ const preamble = beforeMilestones.replace(/<details>[\s\S]*?<\/details>/gi, '');
1309
+
1310
+ return preamble + currentSection;
1311
+ }
1312
+
1313
+ /**
1314
+ * Replace a pattern only in the current milestone section of ROADMAP.md
1315
+ * (everything after the last </details> close tag). Used for write operations
1316
+ * that must not accidentally modify archived milestone checkboxes/tables.
1317
+ */
1318
+ function replaceInCurrentMilestone(content, pattern, replacement) {
1319
+ const lastDetailsClose = content.lastIndexOf('</details>');
1320
+ if (lastDetailsClose === -1) {
1321
+ return content.replace(pattern, replacement);
1322
+ }
1323
+ const offset = lastDetailsClose + '</details>'.length;
1324
+ const before = content.slice(0, offset);
1325
+ const after = content.slice(offset);
1326
+ return before + after.replace(pattern, replacement);
1327
+ }
1328
+
1329
+ // ─── Roadmap & model utilities ────────────────────────────────────────────────
1330
+
1331
+ function getRoadmapPhaseInternal(cwd, phaseNum) {
1332
+ if (!phaseNum) return null;
1333
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
1334
+ if (!fs.existsSync(roadmapPath)) return null;
1335
+
1336
+ try {
1337
+ const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
1338
+ // Strip leading zeros from purely numeric phase numbers so "03" matches "Phase 3:"
1339
+ // in canonical ROADMAP headings. Non-numeric IDs (e.g. "PROJ-42") are kept as-is.
1340
+ const normalized = /^\d+$/.test(String(phaseNum))
1341
+ ? String(phaseNum).replace(/^0+(?=\d)/, '')
1342
+ : String(phaseNum);
1343
+ const escapedPhase = escapeRegex(normalized);
1344
+ // Match both numeric and custom (Phase PROJ-42:) headers.
1345
+ // For purely numeric phases allow optional leading zeros so both "Phase 1:" and
1346
+ // "Phase 01:" are matched regardless of whether the ROADMAP uses padded numbers.
1347
+ const isNumeric = /^\d+$/.test(String(phaseNum));
1348
+ const phasePattern = isNumeric
1349
+ ? new RegExp(`#{2,4}\\s*Phase\\s+0*${escapedPhase}:\\s*([^\\n]+)`, 'i')
1350
+ : new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
1351
+ const headerMatch = content.match(phasePattern);
1352
+ if (!headerMatch) return null;
1353
+
1354
+ const phaseName = headerMatch[1].trim();
1355
+ const headerIndex = headerMatch.index;
1356
+ const restOfContent = content.slice(headerIndex);
1357
+ const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i);
1358
+ const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
1359
+ const section = content.slice(headerIndex, sectionEnd).trim();
1360
+
1361
+ const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
1362
+ const goal = goalMatch ? goalMatch[1].trim() : null;
1363
+
1364
+ return {
1365
+ found: true,
1366
+ phase_number: phaseNum.toString(),
1367
+ phase_name: phaseName,
1368
+ goal,
1369
+ section,
1370
+ };
1371
+ } catch {
1372
+ return null;
1373
+ }
1374
+ }
1375
+
1376
+ // ─── Agent installation validation (#1371) ───────────────────────────────────
1377
+
1378
+ /**
1379
+ * Resolve the agents directory from the GSD install location.
1380
+ * gsd-tools.cjs lives at <configDir>/get-shit-done/bin/gsd-tools.cjs,
1381
+ * so agents/ is at <configDir>/agents/.
1382
+ *
1383
+ * GSD_AGENTS_DIR env var overrides the default path. Used in tests and for
1384
+ * installs where the agents directory is not co-located with gsd-tools.cjs.
1385
+ *
1386
+ * @returns {string} Absolute path to the agents directory
1387
+ */
1388
+ function getAgentsDir() {
1389
+ if (process.env.GSD_AGENTS_DIR) {
1390
+ return process.env.GSD_AGENTS_DIR;
1391
+ }
1392
+ // __dirname is get-shit-done/bin/lib/ → go up 3 levels to configDir
1393
+ return path.join(__dirname, '..', '..', '..', 'agents');
1394
+ }
1395
+
1396
+ /**
1397
+ * Check which GSD agents are installed on disk.
1398
+ * Returns an object with installation status and details.
1399
+ *
1400
+ * Recognises both standard format (gsd-planner.md) and Copilot format
1401
+ * (gsd-planner.agent.md). Copilot renames agent files during install (#1512).
1402
+ *
1403
+ * @returns {{ agents_installed: boolean, missing_agents: string[], installed_agents: string[], agents_dir: string }}
1404
+ */
1405
+ function checkAgentsInstalled() {
1406
+ const agentsDir = getAgentsDir();
1407
+ const expectedAgents = Object.keys(MODEL_PROFILES);
1408
+ const installed = [];
1409
+ const missing = [];
1410
+
1411
+ if (!fs.existsSync(agentsDir)) {
1412
+ return {
1413
+ agents_installed: false,
1414
+ missing_agents: expectedAgents,
1415
+ installed_agents: [],
1416
+ agents_dir: agentsDir,
1417
+ };
1418
+ }
1419
+
1420
+ for (const agent of expectedAgents) {
1421
+ // Check both .md (standard) and .agent.md (Copilot) file formats.
1422
+ const agentFile = path.join(agentsDir, `${agent}.md`);
1423
+ const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
1424
+ if (fs.existsSync(agentFile) || fs.existsSync(agentFileCopilot)) {
1425
+ installed.push(agent);
1426
+ } else {
1427
+ missing.push(agent);
1428
+ }
1429
+ }
1430
+
1431
+ return {
1432
+ agents_installed: installed.length > 0 && missing.length === 0,
1433
+ missing_agents: missing,
1434
+ installed_agents: installed,
1435
+ agents_dir: agentsDir,
1436
+ };
1437
+ }
1438
+
1439
+ // ─── Model alias resolution ───────────────────────────────────────────────────
1440
+
1441
+ /**
1442
+ * Map short model aliases to full model IDs.
1443
+ * Updated each release to match current model versions.
1444
+ * Users can override with model_overrides in config.json for custom/latest models.
1445
+ */
1446
+ const MODEL_ALIAS_MAP = {
1447
+ 'opus': 'claude-opus-4-6',
1448
+ 'sonnet': 'claude-sonnet-4-6',
1449
+ 'haiku': 'claude-haiku-4-5',
1450
+ };
1451
+
1452
+ function resolveModelInternal(cwd, agentType) {
1453
+ const config = loadConfig(cwd);
1454
+
1455
+ // Check per-agent override first — always respected regardless of resolve_model_ids.
1456
+ // Users who set fully-qualified model IDs (e.g., "openai/gpt-5.4") get exactly that.
1457
+ const override = config.model_overrides?.[agentType];
1458
+ if (override) {
1459
+ return override;
1460
+ }
1461
+
1462
+ // resolve_model_ids: "omit" — return empty string so the runtime uses its configured
1463
+ // default model. For non-Claude runtimes (OpenCode, Codex, etc.) that don't recognize
1464
+ // Claude aliases (opus/sonnet/haiku/inherit). Set automatically during install. See #1156.
1465
+ if (config.resolve_model_ids === 'omit') {
1466
+ return '';
1467
+ }
1468
+
1469
+ // Fall back to profile lookup
1470
+ const profile = String(config.model_profile || 'balanced').toLowerCase();
1471
+ const agentModels = MODEL_PROFILES[agentType];
1472
+ if (!agentModels) return 'sonnet';
1473
+ if (profile === 'inherit') return 'inherit';
1474
+ const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet';
1475
+
1476
+ // resolve_model_ids: true — map alias to full Claude model ID
1477
+ // Prevents 404s when the Task tool passes aliases directly to the API
1478
+ if (config.resolve_model_ids) {
1479
+ return MODEL_ALIAS_MAP[alias] || alias;
1480
+ }
1481
+
1482
+ return alias;
1483
+ }
1484
+
1485
+ // ─── Summary body helpers ─────────────────────────────────────────────────
1486
+
1487
+ /**
1488
+ * Extract a one-liner from the summary body when it's not in frontmatter.
1489
+ * The summary template defines one-liner as a bold markdown line after the heading:
1490
+ * # Phase X: Name Summary
1491
+ * **[substantive one-liner text]**
1492
+ */
1493
+ function extractOneLinerFromBody(content) {
1494
+ if (!content) return null;
1495
+ // Strip frontmatter first
1496
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
1497
+ // Find the first **...** line after a # heading
1498
+ const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
1499
+ return match ? match[1].trim() : null;
1500
+ }
1501
+
1502
+ // ─── Misc utilities ───────────────────────────────────────────────────────────
1503
+
1504
+ function pathExistsInternal(cwd, targetPath) {
1505
+ const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
1506
+ try {
1507
+ fs.statSync(fullPath);
1508
+ return true;
1509
+ } catch {
1510
+ return false;
1511
+ }
1512
+ }
1513
+
1514
+ function generateSlugInternal(text) {
1515
+ if (!text) return null;
1516
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
1517
+ }
1518
+
1519
+ function getMilestoneInfo(cwd) {
1520
+ try {
1521
+ const roadmap = fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8');
1522
+
1523
+ // 0. Prefer STATE.md milestone: frontmatter as the authoritative source.
1524
+ // This prevents falling through to a regex that may match an old heading
1525
+ // when the active milestone's 🚧 marker is inside a <summary> tag without
1526
+ // **bold** formatting (bug #2409).
1527
+ let stateVersion = null;
1528
+ if (cwd) {
1529
+ try {
1530
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
1531
+ if (fs.existsSync(statePath)) {
1532
+ const stateRaw = fs.readFileSync(statePath, 'utf-8');
1533
+ const m = stateRaw.match(/^milestone:\s*(.+)/m);
1534
+ if (m) stateVersion = m[1].trim();
1535
+ }
1536
+ } catch { /* intentionally empty */ }
1537
+ }
1538
+
1539
+ if (stateVersion) {
1540
+ // Look up the name for this version in ROADMAP.md
1541
+ const escapedVer = escapeRegex(stateVersion);
1542
+ // Match heading-format: ## Roadmap v2.9: Name or ## v2.9 Name
1543
+ const headingMatch = roadmap.match(
1544
+ new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i')
1545
+ );
1546
+ if (headingMatch) {
1547
+ // If the heading line contains ✅ the milestone is already shipped.
1548
+ // Fall through to normal detection so the NEW active milestone is returned
1549
+ // instead of the stale shipped one still recorded in STATE.md.
1550
+ if (!headingMatch[0].includes('✅')) {
1551
+ return { version: stateVersion, name: headingMatch[1].trim() };
1552
+ }
1553
+ // Shipped milestone — do not early-return; fall through to normal detection below.
1554
+ } else {
1555
+ // Match list-format: 🚧 **v2.9 Name** or 🚧 v2.9 Name
1556
+ const listMatch = roadmap.match(
1557
+ new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
1558
+ );
1559
+ if (listMatch) {
1560
+ return { version: stateVersion, name: listMatch[1].trim() };
1561
+ }
1562
+ // Version found in STATE.md but no name match in ROADMAP — return bare version
1563
+ return { version: stateVersion, name: 'milestone' };
1564
+ }
1565
+ }
1566
+
1567
+ // First: check for list-format roadmaps using 🚧 (in-progress) marker
1568
+ // e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)"
1569
+ // e.g. "- 🚧 **v1.2.1 Tech Debt** — Phases 1-8 (in progress)"
1570
+ const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
1571
+ if (inProgressMatch) {
1572
+ return {
1573
+ version: 'v' + inProgressMatch[1],
1574
+ name: inProgressMatch[2].trim(),
1575
+ };
1576
+ }
1577
+
1578
+ // Second: heading-format roadmaps — strip shipped milestones.
1579
+ // <details> blocks are stripped by stripShippedMilestones; heading-format ✅ markers
1580
+ // are excluded by the negative lookahead below so a stale STATE.md version (or any
1581
+ // shipped ✅ heading) never wins over the first non-shipped milestone heading.
1582
+ const cleaned = stripShippedMilestones(roadmap);
1583
+ // Negative lookahead skips headings that contain ✅ (shipped milestone marker).
1584
+ // Supports 2+ segment versions: v1.2, v1.2.1, v2.0.1, etc.
1585
+ const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
1586
+ if (headingMatch) {
1587
+ return {
1588
+ version: 'v' + headingMatch[1],
1589
+ name: headingMatch[2].trim(),
1590
+ };
1591
+ }
1592
+ // Fallback: try bare version match (greedy — capture longest version string)
1593
+ const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
1594
+ return {
1595
+ version: versionMatch ? versionMatch[0] : 'v1.0',
1596
+ name: 'milestone',
1597
+ };
1598
+ } catch {
1599
+ return { version: 'v1.0', name: 'milestone' };
1600
+ }
1601
+ }
1602
+
1603
+ /**
1604
+ * Returns a filter function that checks whether a phase directory belongs
1605
+ * to the current milestone based on ROADMAP.md phase headings.
1606
+ * If no ROADMAP exists or no phases are listed, returns a pass-all filter.
1607
+ */
1608
+ function getMilestonePhaseFilter(cwd) {
1609
+ const milestonePhaseNums = new Set();
1610
+ try {
1611
+ const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
1612
+ // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
1613
+ const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1614
+ let m;
1615
+ while ((m = phasePattern.exec(roadmap)) !== null) {
1616
+ milestonePhaseNums.add(m[1]);
1617
+ }
1618
+ } catch { /* intentionally empty */ }
1619
+
1620
+ if (milestonePhaseNums.size === 0) {
1621
+ const passAll = () => true;
1622
+ passAll.phaseCount = 0;
1623
+ return passAll;
1624
+ }
1625
+
1626
+ const normalized = new Set(
1627
+ [...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase())
1628
+ );
1629
+
1630
+ function isDirInMilestone(dirName) {
1631
+ // Try numeric match first
1632
+ const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1633
+ if (m && normalized.has(m[1].toLowerCase())) return true;
1634
+ // Try custom ID match (e.g. PROJ-42-description → PROJ-42)
1635
+ const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
1636
+ if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
1637
+ return false;
1638
+ }
1639
+ isDirInMilestone.phaseCount = milestonePhaseNums.size;
1640
+ return isDirInMilestone;
1641
+ }
1642
+
1643
+ // ─── Phase file helpers ──────────────────────────────────────────────────────
1644
+
1645
+ /** Filter a file list to just PLAN.md / *-PLAN.md entries. */
1646
+ function filterPlanFiles(files) {
1647
+ return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
1648
+ }
1649
+
1650
+ /** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
1651
+ function filterSummaryFiles(files) {
1652
+ return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1653
+ }
1654
+
1655
+ /**
1656
+ * Read a phase directory and return counts/flags for common file types.
1657
+ * Returns an object with plans[], summaries[], and boolean flags for
1658
+ * research/context/verification files.
1659
+ */
1660
+ function getPhaseFileStats(phaseDir) {
1661
+ const files = fs.readdirSync(phaseDir);
1662
+ return {
1663
+ plans: filterPlanFiles(files),
1664
+ summaries: filterSummaryFiles(files),
1665
+ hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
1666
+ hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'),
1667
+ hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
1668
+ hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
1669
+ };
1670
+ }
1671
+
1672
+ /**
1673
+ * Read immediate child directories from a path.
1674
+ * Returns [] if the path doesn't exist or can't be read.
1675
+ * Pass sort=true to apply comparePhaseNum ordering.
1676
+ */
1677
+ function readSubdirectories(dirPath, sort = false) {
1678
+ try {
1679
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1680
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1681
+ return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
1682
+ } catch {
1683
+ return [];
1684
+ }
1685
+ }
1686
+
1687
+ // ─── Atomic file writes ───────────────────────────────────────────────────────
1688
+
1689
+ /**
1690
+ * Write a file atomically using write-to-temp-then-rename.
1691
+ *
1692
+ * On POSIX systems, `fs.renameSync` is atomic when the source and destination
1693
+ * are on the same filesystem. This prevents a process killed mid-write from
1694
+ * leaving a truncated file that is unparseable on next read.
1695
+ *
1696
+ * The temp file is placed alongside the target so it is guaranteed to be on
1697
+ * the same filesystem (required for rename atomicity). The PID is embedded in
1698
+ * the temp file name so concurrent writers use distinct paths.
1699
+ *
1700
+ * If `renameSync` fails (e.g. cross-device move), the function falls back to a
1701
+ * direct `writeFileSync` so callers always get a best-effort write.
1702
+ *
1703
+ * @param {string} filePath Absolute path to write.
1704
+ * @param {string|Buffer} content File content.
1705
+ * @param {string} [encoding='utf-8'] Encoding passed to writeFileSync.
1706
+ */
1707
+ function atomicWriteFileSync(filePath, content, encoding = 'utf-8') {
1708
+ const tmpPath = filePath + '.tmp.' + process.pid;
1709
+ try {
1710
+ fs.writeFileSync(tmpPath, content, encoding);
1711
+ fs.renameSync(tmpPath, filePath);
1712
+ } catch (renameErr) {
1713
+ // Clean up the temp file if rename failed, then fall back to direct write.
1714
+ try { fs.unlinkSync(tmpPath); } catch { /* already gone or never created */ }
1715
+ fs.writeFileSync(filePath, content, encoding);
1716
+ }
1717
+ }
1718
+
1719
+ /**
1720
+ * Format a Date as a fuzzy relative time string (e.g. "5 minutes ago").
1721
+ * @param {Date} date
1722
+ * @returns {string}
1723
+ */
1724
+ function timeAgo(date) {
1725
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1726
+ if (seconds < 5) return 'just now';
1727
+ if (seconds < 60) return `${seconds} seconds ago`;
1728
+ const minutes = Math.floor(seconds / 60);
1729
+ if (minutes === 1) return '1 minute ago';
1730
+ if (minutes < 60) return `${minutes} minutes ago`;
1731
+ const hours = Math.floor(minutes / 60);
1732
+ if (hours === 1) return '1 hour ago';
1733
+ if (hours < 24) return `${hours} hours ago`;
1734
+ const days = Math.floor(hours / 24);
1735
+ if (days === 1) return '1 day ago';
1736
+ if (days < 30) return `${days} days ago`;
1737
+ const months = Math.floor(days / 30);
1738
+ if (months === 1) return '1 month ago';
1739
+ if (months < 12) return `${months} months ago`;
1740
+ const years = Math.floor(days / 365);
1741
+ if (years === 1) return '1 year ago';
1742
+ return `${years} years ago`;
1743
+ }
1744
+
1745
+ module.exports = {
1746
+ output,
1747
+ error,
1748
+ safeReadFile,
1749
+ loadConfig,
1750
+ isGitIgnored,
1751
+ execGit,
1752
+ normalizeMd,
1753
+ escapeRegex,
1754
+ normalizePhaseName,
1755
+ comparePhaseNum,
1756
+ searchPhaseInDir,
1757
+ extractPhaseToken,
1758
+ phaseTokenMatches,
1759
+ findPhaseInternal,
1760
+ getArchivedPhaseDirs,
1761
+ getRoadmapPhaseInternal,
1762
+ resolveModelInternal,
1763
+ pathExistsInternal,
1764
+ generateSlugInternal,
1765
+ getMilestoneInfo,
1766
+ getMilestonePhaseFilter,
1767
+ stripShippedMilestones,
1768
+ extractCurrentMilestone,
1769
+ replaceInCurrentMilestone,
1770
+ toPosixPath,
1771
+ extractOneLinerFromBody,
1772
+ resolveWorktreeRoot,
1773
+ withPlanningLock,
1774
+ findProjectRoot,
1775
+ detectSubRepos,
1776
+ reapStaleTempFiles,
1777
+ GSD_TEMP_DIR,
1778
+ MODEL_ALIAS_MAP,
1779
+ CONFIG_DEFAULTS,
1780
+ planningDir,
1781
+ planningRoot,
1782
+ planningPaths,
1783
+ getActiveWorkstream,
1784
+ setActiveWorkstream,
1785
+ filterPlanFiles,
1786
+ filterSummaryFiles,
1787
+ getPhaseFileStats,
1788
+ readSubdirectories,
1789
+ getAgentsDir,
1790
+ checkAgentsInstalled,
1791
+ atomicWriteFileSync,
1792
+ timeAgo,
1793
+ pruneOrphanedWorktrees,
1794
+ };