@luanpdd/kit-mcp 1.33.0 → 1.35.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 (379) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +168 -168
  3. package/gates/agent-no-recursive-dispatch.md +84 -84
  4. package/kit/COMANDOS.md +138 -138
  5. package/kit/COMPATIBILITY.md +70 -70
  6. package/kit/README.md +76 -76
  7. package/kit/agents/advisor-researcher.md +109 -109
  8. package/kit/agents/ai-mutation-tester.md +289 -289
  9. package/kit/agents/assumptions-analyzer.md +110 -110
  10. package/kit/agents/audit-log-implementer.md +314 -314
  11. package/kit/agents/auditor-consistencia-isolamento.md +414 -414
  12. package/kit/agents/b2b-saas-architect.md +157 -157
  13. package/kit/agents/burn-rate-forecaster.md +153 -153
  14. package/kit/agents/cascading-failures-auditor.md +299 -299
  15. package/kit/agents/codebase-mapper.md +769 -769
  16. package/kit/agents/crm-pipeline-implementer.md +257 -257
  17. package/kit/agents/debugger.md +814 -814
  18. package/kit/agents/designer-ui.md +216 -216
  19. package/kit/agents/detector-tenant-quente.md +338 -338
  20. package/kit/agents/evolution-go-integrator.md +201 -201
  21. package/kit/agents/example-reviewer.md +22 -22
  22. package/kit/agents/executor.md +565 -565
  23. package/kit/agents/golden-signals-instrumenter.md +232 -232
  24. package/kit/agents/incident-investigator.md +238 -238
  25. package/kit/agents/integration-checker.md +203 -203
  26. package/kit/agents/invite-flow-implementer.md +190 -190
  27. package/kit/agents/legacy-characterizer.md +369 -369
  28. package/kit/agents/lgpd-compliance-auditor.md +296 -296
  29. package/kit/agents/load-shedding-instrumenter.md +290 -290
  30. package/kit/agents/multi-tenant-isolation-auditor.md +254 -254
  31. package/kit/agents/multi-tenant-rls-writer.md +341 -341
  32. package/kit/agents/nyquist-auditor.md +181 -181
  33. package/kit/agents/observability-coverage-auditor.md +316 -316
  34. package/kit/agents/observability-instrumenter.md +191 -191
  35. package/kit/agents/omm-auditor.md +291 -291
  36. package/kit/agents/org-onboarding-implementer.md +224 -224
  37. package/kit/agents/payload-capture-instrumenter.md +274 -274
  38. package/kit/agents/phase-researcher.md +697 -697
  39. package/kit/agents/plan-checker.md +275 -275
  40. package/kit/agents/planner.md +923 -923
  41. package/kit/agents/postmortem-writer.md +273 -273
  42. package/kit/agents/project-researcher.md +653 -653
  43. package/kit/agents/prr-conductor.md +287 -287
  44. package/kit/agents/refactor-safety-auditor.md +405 -405
  45. package/kit/agents/release-pipeline-auditor.md +364 -364
  46. package/kit/agents/research-synthesizer.md +246 -246
  47. package/kit/agents/roadmapper.md +678 -678
  48. package/kit/agents/schema-checker.md +160 -160
  49. package/kit/agents/seam-finder.md +360 -360
  50. package/kit/agents/shotgun-surgery-detector.md +350 -350
  51. package/kit/agents/slo-engineer.md +217 -217
  52. package/kit/agents/storytelling-analyst.md +300 -300
  53. package/kit/agents/supabase-architect.md +249 -249
  54. package/kit/agents/supabase-auth-bootstrapper.md +400 -400
  55. package/kit/agents/supabase-auth-hook-writer.md +418 -418
  56. package/kit/agents/supabase-branching-architect.md +563 -563
  57. package/kit/agents/supabase-cicd-pipeline-implementer.md +778 -778
  58. package/kit/agents/supabase-column-privileges-writer.md +400 -400
  59. package/kit/agents/supabase-edge-fn-tester.md +288 -288
  60. package/kit/agents/supabase-edge-fn-writer.md +341 -341
  61. package/kit/agents/supabase-mfa-implementer.md +439 -439
  62. package/kit/agents/supabase-migration-writer.md +386 -386
  63. package/kit/agents/supabase-oauth-server-implementer.md +507 -507
  64. package/kit/agents/supabase-rbac-implementer.md +393 -393
  65. package/kit/agents/supabase-realtime-implementer.md +364 -364
  66. package/kit/agents/supabase-rls-hardener.md +522 -522
  67. package/kit/agents/supabase-rls-writer.md +324 -324
  68. package/kit/agents/supabase-roles-implementer.md +356 -356
  69. package/kit/agents/supabase-social-auth-implementer.md +451 -451
  70. package/kit/agents/supabase-sso-saml-architect.md +549 -549
  71. package/kit/agents/supabase-storage-implementer.md +407 -407
  72. package/kit/agents/super-admin-implementer.md +282 -282
  73. package/kit/agents/toil-auditor.md +268 -268
  74. package/kit/agents/ui-auditor.md +438 -438
  75. package/kit/agents/ui-checker.md +305 -305
  76. package/kit/agents/ui-researcher.md +356 -356
  77. package/kit/agents/user-profiler.md +176 -176
  78. package/kit/agents/validador-evolucao-schema.md +336 -336
  79. package/kit/agents/verifier.md +729 -729
  80. package/kit/agents/workflow-generator.md +167 -0
  81. package/kit/commands/adicionar-backlog.md +75 -75
  82. package/kit/commands/adicionar-fase.md +42 -42
  83. package/kit/commands/adicionar-tarefa.md +45 -45
  84. package/kit/commands/adicionar-testes.md +41 -41
  85. package/kit/commands/ajuda.md +21 -21
  86. package/kit/commands/atualizar.md +37 -37
  87. package/kit/commands/auditar-cascading.md +111 -111
  88. package/kit/commands/auditar-marco.md +179 -179
  89. package/kit/commands/auditar-observabilidade-cobertura-workflow.md +121 -0
  90. package/kit/commands/auditar-observabilidade-cobertura.md +183 -183
  91. package/kit/commands/auditar-refactor.md +219 -219
  92. package/kit/commands/auditar-release.md +109 -109
  93. package/kit/commands/auditar-uat.md +23 -23
  94. package/kit/commands/autonomo.md +40 -40
  95. package/kit/commands/branch-pr.md +24 -24
  96. package/kit/commands/burn-rate-status.md +408 -408
  97. package/kit/commands/capturar-payloads.md +193 -193
  98. package/kit/commands/caracterizar.md +212 -212
  99. package/kit/commands/concluir-marco.md +247 -247
  100. package/kit/commands/configuracoes.md +36 -36
  101. package/kit/commands/criar-workflow.md +158 -0
  102. package/kit/commands/dados-distribuidos.md +188 -188
  103. package/kit/commands/definir-perfil.md +10 -10
  104. package/kit/commands/depurar.md +190 -190
  105. package/kit/commands/detectar-duplicacao.md +197 -197
  106. package/kit/commands/discutir-fase.md +131 -131
  107. package/kit/commands/encontrar-seams.md +136 -136
  108. package/kit/commands/entrar-discord.md +17 -17
  109. package/kit/commands/estatisticas.md +18 -18
  110. package/kit/commands/example-greeting.md +33 -33
  111. package/kit/commands/executar-fase.md +58 -58
  112. package/kit/commands/expresso.md +56 -56
  113. package/kit/commands/fase-ui.md +34 -34
  114. package/kit/commands/fazer.md +57 -57
  115. package/kit/commands/fio.md +125 -125
  116. package/kit/commands/fluxos-trabalho.md +64 -64
  117. package/kit/commands/forense.md +176 -176
  118. package/kit/commands/gerenciador.md +38 -38
  119. package/kit/commands/inserir-fase.md +31 -31
  120. package/kit/commands/legacy.md +263 -263
  121. package/kit/commands/limpeza.md +17 -17
  122. package/kit/commands/listar-hipoteses-fase.md +45 -45
  123. package/kit/commands/listar-workspaces.md +18 -18
  124. package/kit/commands/load-shedding.md +117 -117
  125. package/kit/commands/mapear-codebase.md +70 -70
  126. package/kit/commands/multi-tenant.md +163 -163
  127. package/kit/commands/nota.md +33 -33
  128. package/kit/commands/novo-marco.md +43 -43
  129. package/kit/commands/novo-projeto.md +41 -41
  130. package/kit/commands/novo-workspace.md +43 -43
  131. package/kit/commands/pausar-trabalho.md +37 -37
  132. package/kit/commands/perfil-usuario.md +45 -45
  133. package/kit/commands/pesquisar-fase.md +195 -195
  134. package/kit/commands/planejar-fase.md +67 -67
  135. package/kit/commands/planejar-lacunas.md +33 -33
  136. package/kit/commands/plantar-ideia.md +25 -25
  137. package/kit/commands/progresso.md +24 -24
  138. package/kit/commands/proximo.md +30 -30
  139. package/kit/commands/publicar.md +490 -490
  140. package/kit/commands/rapido.md +35 -35
  141. package/kit/commands/reaplicar-patches.md +124 -124
  142. package/kit/commands/refactor-seguro.md +321 -321
  143. package/kit/commands/relatorio-sessao.md +19 -19
  144. package/kit/commands/remover-fase.md +31 -31
  145. package/kit/commands/remover-workspace.md +26 -26
  146. package/kit/commands/resumo-marco.md +50 -50
  147. package/kit/commands/retomar-trabalho.md +40 -40
  148. package/kit/commands/revisar-backlog.md +60 -60
  149. package/kit/commands/revisar-ui.md +32 -32
  150. package/kit/commands/revisar.md +37 -37
  151. package/kit/commands/saude.md +21 -21
  152. package/kit/commands/setup-notion.md +93 -93
  153. package/kit/commands/storytelling.md +179 -179
  154. package/kit/commands/supabase.md +238 -238
  155. package/kit/commands/sync-main.md +68 -68
  156. package/kit/commands/validar-fase.md +35 -35
  157. package/kit/commands/verificar-tarefas.md +44 -44
  158. package/kit/commands/verificar-trabalho.md +64 -64
  159. package/kit/file-manifest.json +424 -419
  160. package/kit/framework/bin/lib/commands.cjs +959 -959
  161. package/kit/framework/bin/lib/config.cjs +442 -442
  162. package/kit/framework/bin/lib/core.cjs +1230 -1230
  163. package/kit/framework/bin/lib/frontmatter.cjs +336 -336
  164. package/kit/framework/bin/lib/init.cjs +1442 -1442
  165. package/kit/framework/bin/lib/milestone.cjs +252 -252
  166. package/kit/framework/bin/lib/model-profiles.cjs +68 -68
  167. package/kit/framework/bin/lib/phase.cjs +888 -888
  168. package/kit/framework/bin/lib/profile-output.cjs +952 -952
  169. package/kit/framework/bin/lib/profile-pipeline.cjs +539 -539
  170. package/kit/framework/bin/lib/roadmap.cjs +329 -329
  171. package/kit/framework/bin/lib/security.cjs +382 -382
  172. package/kit/framework/bin/lib/state.cjs +1031 -1031
  173. package/kit/framework/bin/lib/template.cjs +222 -222
  174. package/kit/framework/bin/lib/uat.cjs +282 -282
  175. package/kit/framework/bin/lib/verify.cjs +888 -888
  176. package/kit/framework/bin/lib/workstream.cjs +491 -491
  177. package/kit/framework/bin/tools.cjs +918 -918
  178. package/kit/framework/commands/workstreams.md +63 -63
  179. package/kit/framework/references/checkpoints.md +778 -778
  180. package/kit/framework/references/continuation-format.md +249 -249
  181. package/kit/framework/references/decimal-phase-calculation.md +64 -64
  182. package/kit/framework/references/git-integration.md +295 -295
  183. package/kit/framework/references/git-planning-commit.md +38 -38
  184. package/kit/framework/references/model-profile-resolution.md +36 -36
  185. package/kit/framework/references/model-profiles.md +139 -139
  186. package/kit/framework/references/phase-argument-parsing.md +61 -61
  187. package/kit/framework/references/planning-config.md +202 -202
  188. package/kit/framework/references/questioning.md +162 -162
  189. package/kit/framework/references/tdd.md +263 -263
  190. package/kit/framework/references/ui-brand.md +160 -160
  191. package/kit/framework/references/user-profiling.md +657 -657
  192. package/kit/framework/references/verification-patterns.md +612 -612
  193. package/kit/framework/references/workstream-flag.md +58 -58
  194. package/kit/framework/templates/DEBUG.md +164 -164
  195. package/kit/framework/templates/UAT.md +265 -265
  196. package/kit/framework/templates/UI-SPEC.md +100 -100
  197. package/kit/framework/templates/VALIDATION.md +76 -76
  198. package/kit/framework/templates/claude-md.md +122 -122
  199. package/kit/framework/templates/codebase/architecture.md +185 -185
  200. package/kit/framework/templates/codebase/concerns.md +205 -205
  201. package/kit/framework/templates/codebase/conventions.md +204 -204
  202. package/kit/framework/templates/codebase/integrations.md +192 -192
  203. package/kit/framework/templates/codebase/stack.md +158 -158
  204. package/kit/framework/templates/codebase/structure.md +199 -199
  205. package/kit/framework/templates/codebase/testing.md +301 -301
  206. package/kit/framework/templates/config.json +44 -44
  207. package/kit/framework/templates/context.md +352 -352
  208. package/kit/framework/templates/continue-here.md +78 -78
  209. package/kit/framework/templates/copilot-instructions.md +7 -7
  210. package/kit/framework/templates/debug-subagent-prompt.md +91 -91
  211. package/kit/framework/templates/dev-preferences.md +20 -20
  212. package/kit/framework/templates/discovery.md +146 -146
  213. package/kit/framework/templates/discussion-log.md +63 -63
  214. package/kit/framework/templates/milestone-archive.md +123 -123
  215. package/kit/framework/templates/milestone.md +115 -115
  216. package/kit/framework/templates/phase-prompt.md +610 -610
  217. package/kit/framework/templates/planner-subagent-prompt.md +117 -117
  218. package/kit/framework/templates/project.md +186 -186
  219. package/kit/framework/templates/requirements.md +231 -231
  220. package/kit/framework/templates/research-project/ARCHITECTURE.md +204 -204
  221. package/kit/framework/templates/research-project/FEATURES.md +147 -147
  222. package/kit/framework/templates/research-project/PITFALLS.md +200 -200
  223. package/kit/framework/templates/research-project/STACK.md +120 -120
  224. package/kit/framework/templates/research-project/SUMMARY.md +170 -170
  225. package/kit/framework/templates/research.md +419 -419
  226. package/kit/framework/templates/retrospective.md +54 -54
  227. package/kit/framework/templates/roadmap.md +202 -202
  228. package/kit/framework/templates/state.md +176 -176
  229. package/kit/framework/templates/summary-complex.md +59 -59
  230. package/kit/framework/templates/summary-minimal.md +41 -41
  231. package/kit/framework/templates/summary-standard.md +48 -48
  232. package/kit/framework/templates/summary.md +209 -209
  233. package/kit/framework/templates/user-profile.md +146 -146
  234. package/kit/framework/templates/user-setup.md +256 -256
  235. package/kit/framework/templates/verification-report.md +258 -258
  236. package/kit/framework/workflows/add-phase.md +112 -112
  237. package/kit/framework/workflows/add-tests.md +351 -351
  238. package/kit/framework/workflows/add-todo.md +158 -158
  239. package/kit/framework/workflows/audit-milestone.md +340 -340
  240. package/kit/framework/workflows/audit-uat.md +109 -109
  241. package/kit/framework/workflows/autonomous.md +891 -891
  242. package/kit/framework/workflows/check-todos.md +177 -177
  243. package/kit/framework/workflows/cleanup.md +152 -152
  244. package/kit/framework/workflows/complete-milestone.md +696 -696
  245. package/kit/framework/workflows/diagnose-issues.md +231 -231
  246. package/kit/framework/workflows/discovery-phase.md +289 -289
  247. package/kit/framework/workflows/discuss-phase-assumptions.md +653 -653
  248. package/kit/framework/workflows/discuss-phase.md +784 -784
  249. package/kit/framework/workflows/do.md +104 -104
  250. package/kit/framework/workflows/execute-phase.md +838 -838
  251. package/kit/framework/workflows/execute-plan.md +510 -510
  252. package/kit/framework/workflows/fast.md +102 -102
  253. package/kit/framework/workflows/forensics.md +265 -265
  254. package/kit/framework/workflows/health.md +181 -181
  255. package/kit/framework/workflows/help.md +619 -619
  256. package/kit/framework/workflows/insert-phase.md +130 -130
  257. package/kit/framework/workflows/list-phase-assumptions.md +178 -178
  258. package/kit/framework/workflows/list-workspaces.md +56 -56
  259. package/kit/framework/workflows/manager.md +362 -362
  260. package/kit/framework/workflows/map-codebase.md +377 -377
  261. package/kit/framework/workflows/milestone-summary.md +223 -223
  262. package/kit/framework/workflows/new-milestone.md +486 -486
  263. package/kit/framework/workflows/new-project.md +1159 -1159
  264. package/kit/framework/workflows/new-workspace.md +237 -237
  265. package/kit/framework/workflows/next.md +97 -97
  266. package/kit/framework/workflows/node-repair.md +92 -92
  267. package/kit/framework/workflows/note.md +156 -156
  268. package/kit/framework/workflows/pause-work.md +176 -176
  269. package/kit/framework/workflows/plan-milestone-gaps.md +273 -273
  270. package/kit/framework/workflows/plan-phase.md +765 -765
  271. package/kit/framework/workflows/plant-seed.md +169 -169
  272. package/kit/framework/workflows/pr-branch.md +129 -129
  273. package/kit/framework/workflows/profile-user.md +450 -450
  274. package/kit/framework/workflows/progress.md +507 -507
  275. package/kit/framework/workflows/quick.md +757 -757
  276. package/kit/framework/workflows/remove-phase.md +155 -155
  277. package/kit/framework/workflows/remove-workspace.md +90 -90
  278. package/kit/framework/workflows/research-phase.md +82 -82
  279. package/kit/framework/workflows/resume-project.md +326 -326
  280. package/kit/framework/workflows/review.md +228 -228
  281. package/kit/framework/workflows/session-report.md +146 -146
  282. package/kit/framework/workflows/settings.md +283 -283
  283. package/kit/framework/workflows/ship.md +228 -228
  284. package/kit/framework/workflows/stats.md +60 -60
  285. package/kit/framework/workflows/transition.md +671 -671
  286. package/kit/framework/workflows/ui-phase.md +302 -302
  287. package/kit/framework/workflows/ui-review.md +165 -165
  288. package/kit/framework/workflows/update.md +323 -323
  289. package/kit/framework/workflows/validate-phase.md +174 -174
  290. package/kit/framework/workflows/verify-phase.md +252 -252
  291. package/kit/framework/workflows/verify-work.md +637 -637
  292. package/kit/hooks/check-update.js +118 -118
  293. package/kit/hooks/context-monitor.js +163 -163
  294. package/kit/hooks/kit-attribution-reminder.cjs +92 -92
  295. package/kit/hooks/kit-router.cjs +137 -137
  296. package/kit/hooks/prompt-guard.js +103 -103
  297. package/kit/hooks/statusline.js +125 -125
  298. package/kit/hooks/workflow-guard.js +101 -101
  299. package/kit/settings.json +45 -45
  300. package/kit/skills/ai-prompt-characterization/SKILL.md +335 -335
  301. package/kit/skills/armadilhas-sistemas-distribuidos/SKILL.md +447 -447
  302. package/kit/skills/audit-log-multi-tenant/SKILL.md +340 -340
  303. package/kit/skills/b2b-saas-architecture/SKILL.md +300 -300
  304. package/kit/skills/consistencia-leitura-replica/SKILL.md +385 -385
  305. package/kit/skills/crm-lead-pipeline-patterns/SKILL.md +343 -343
  306. package/kit/skills/dynamic-workflow-authoring/SKILL.md +223 -0
  307. package/kit/skills/escolha-modelo-consistencia/SKILL.md +494 -494
  308. package/kit/skills/evolucao-schema-compativel/SKILL.md +448 -448
  309. package/kit/skills/evolution-go-whatsapp-integration/SKILL.md +322 -322
  310. package/kit/skills/example-skill/SKILL.md +42 -42
  311. package/kit/skills/legacy-api-only-applications/SKILL.md +358 -358
  312. package/kit/skills/legacy-characterization-tests/SKILL.md +330 -330
  313. package/kit/skills/legacy-effect-analysis/SKILL.md +331 -331
  314. package/kit/skills/legacy-extract-class/SKILL.md +203 -203
  315. package/kit/skills/legacy-programming-by-difference/SKILL.md +252 -252
  316. package/kit/skills/legacy-seams-and-test-harness/SKILL.md +460 -460
  317. package/kit/skills/legacy-shotgun-surgery/SKILL.md +286 -286
  318. package/kit/skills/legacy-sprout-wrap-techniques/SKILL.md +434 -434
  319. package/kit/skills/legacy-storytelling-naked-crc/SKILL.md +270 -270
  320. package/kit/skills/lgpd-multi-tenant-compliance/SKILL.md +340 -340
  321. package/kit/skills/member-invite-flow/SKILL.md +305 -305
  322. package/kit/skills/member-management-react-shadcn/SKILL.md +328 -328
  323. package/kit/skills/multi-tenant-performance-scaling/SKILL.md +316 -316
  324. package/kit/skills/multi-tenant-rls-hierarchy/SKILL.md +342 -342
  325. package/kit/skills/org-onboarding-flow/SKILL.md +257 -257
  326. package/kit/skills/org-switcher-react-pattern/SKILL.md +349 -349
  327. package/kit/skills/permission-gate-react-pattern/SKILL.md +271 -271
  328. package/kit/skills/postgres-isolamento-concorrencia/SKILL.md +552 -552
  329. package/kit/skills/pre-refactor-characterization/SKILL.md +421 -421
  330. package/kit/skills/rbac-permissions-matrix-supabase/SKILL.md +338 -338
  331. package/kit/skills/streams-eventos-cdc/SKILL.md +711 -711
  332. package/kit/skills/supabase-auth-hardening/SKILL.md +674 -674
  333. package/kit/skills/supabase-auth-hooks/SKILL.md +875 -875
  334. package/kit/skills/supabase-auth-methods/SKILL.md +486 -486
  335. package/kit/skills/supabase-auth-sessions/SKILL.md +579 -579
  336. package/kit/skills/supabase-auth-ssr/SKILL.md +306 -306
  337. package/kit/skills/supabase-branching-workflow/SKILL.md +544 -544
  338. package/kit/skills/supabase-ci-cd-github-actions/SKILL.md +880 -880
  339. package/kit/skills/supabase-column-level-security/SKILL.md +426 -426
  340. package/kit/skills/supabase-config-toml-remotes/SKILL.md +807 -807
  341. package/kit/skills/supabase-custom-claims-rbac/SKILL.md +472 -472
  342. package/kit/skills/supabase-edge-functions/SKILL.md +330 -330
  343. package/kit/skills/supabase-edge-functions-auth/SKILL.md +309 -309
  344. package/kit/skills/supabase-edge-functions-limits/SKILL.md +302 -302
  345. package/kit/skills/supabase-edge-functions-mcp-server/SKILL.md +279 -279
  346. package/kit/skills/supabase-edge-functions-testing/SKILL.md +277 -277
  347. package/kit/skills/supabase-edge-runtime-builtins/SKILL.md +357 -357
  348. package/kit/skills/supabase-enterprise-sso-saml/SKILL.md +545 -545
  349. package/kit/skills/supabase-jwt-signing-keys/SKILL.md +399 -399
  350. package/kit/skills/supabase-mfa/SKILL.md +488 -488
  351. package/kit/skills/supabase-migration-repair/SKILL.md +823 -823
  352. package/kit/skills/supabase-migrations/SKILL.md +297 -297
  353. package/kit/skills/supabase-oauth-server/SKILL.md +537 -537
  354. package/kit/skills/supabase-pgtap-testing/SKILL.md +1053 -1053
  355. package/kit/skills/supabase-postgres-roles/SKILL.md +392 -392
  356. package/kit/skills/supabase-realtime/SKILL.md +460 -460
  357. package/kit/skills/supabase-rls-defense-in-depth/SKILL.md +418 -418
  358. package/kit/skills/supabase-rls-policies/SKILL.md +635 -635
  359. package/kit/skills/supabase-social-oauth/SKILL.md +480 -480
  360. package/kit/skills/supabase-third-party-auth/SKILL.md +450 -450
  361. package/kit/skills/super-admin-platform-pattern/SKILL.md +326 -326
  362. package/kit/skills/tenant-quente-mitigacao/SKILL.md +605 -605
  363. package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -261
  364. package/kit/skills/ui-contexto-produto/SKILL.md +248 -248
  365. package/kit/skills/ui-cor-estrategia/SKILL.md +213 -213
  366. package/kit/skills/ui-critica-auditoria/SKILL.md +260 -260
  367. package/kit/skills/ui-motion-funcional/SKILL.md +264 -264
  368. package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -259
  369. package/kit/skills/ui-tipografia/SKILL.md +211 -211
  370. package/kit/skills/whatsapp-conversation-state-machine/SKILL.md +287 -287
  371. package/kit/workflows/auditar-observabilidade-cobertura.workflow.js +250 -0
  372. package/package.json +65 -63
  373. package/src/core/kit.js +333 -216
  374. package/src/core/reflect.js +247 -247
  375. package/src/core/registry.js +123 -112
  376. package/src/core/reverse-sync.js +448 -372
  377. package/src/core/sync.js +477 -437
  378. package/src/core/watch.js +121 -121
  379. package/src/mcp-server/index.js +794 -794
@@ -1,1230 +1,1230 @@
1
- /**
2
- * Core — Shared utilities, constants, and internal helpers
3
- */
4
-
5
- const fs = require('fs');
6
- const path = require('path');
7
- const { execSync, execFileSync, spawnSync } = require('child_process');
8
- const { MODEL_PROFILES } = require('./model-profiles.cjs');
9
-
10
- // ─── Path helpers ────────────────────────────────────────────────────────────
11
-
12
- /** Normalize a relative path to always use forward slashes (cross-platform). */
13
- function toPosixPath(p) {
14
- return p.split(path.sep).join('/');
15
- }
16
-
17
- /**
18
- * Scan immediate child directories for separate git repos.
19
- * Returns a sorted array of directory names that have their own `.git`.
20
- * Excludes hidden directories and node_modules.
21
- */
22
- function detectSubRepos(cwd) {
23
- const results = [];
24
- try {
25
- const entries = fs.readdirSync(cwd, { withFileTypes: true });
26
- for (const entry of entries) {
27
- if (!entry.isDirectory()) continue;
28
- if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
29
- const gitPath = path.join(cwd, entry.name, '.git');
30
- try {
31
- if (fs.existsSync(gitPath)) {
32
- results.push(entry.name);
33
- }
34
- } catch {}
35
- }
36
- } catch {}
37
- return results.sort();
38
- }
39
-
40
- /**
41
- * Walk up from `startDir` to find the project root that owns `.planning/`.
42
- *
43
- * In multi-repo workspaces, Claude may open inside a sub-repo (e.g. `backend/`)
44
- * instead of the project root. This function prevents `.planning/` from being
45
- * created inside the sub-repo by locating the nearest ancestor that already has
46
- * a `.planning/` directory.
47
- *
48
- * Detection strategy (checked in order for each ancestor):
49
- * 1. Parent has `.planning/config.json` with `sub_repos` listing this directory
50
- * 2. Parent has `.planning/config.json` with `multiRepo: true` (legacy format)
51
- * 3. Parent has `.planning/` and current dir has its own `.git` (heuristic)
52
- *
53
- * Returns `startDir` unchanged when no ancestor `.planning/` is found (first-run
54
- * or single-repo projects).
55
- */
56
- function findProjectRoot(startDir) {
57
- const resolved = path.resolve(startDir);
58
- const root = path.parse(resolved).root;
59
- const homedir = require('os').homedir();
60
-
61
- // If startDir already contains .planning/, it IS the project root.
62
- // Do not walk up to a parent workspace that also has .planning/ (#1362).
63
- const ownPlanning = path.join(resolved, '.planning');
64
- if (fs.existsSync(ownPlanning) && fs.statSync(ownPlanning).isDirectory()) {
65
- return startDir;
66
- }
67
-
68
- // Check if startDir or any of its ancestors (up to AND including the
69
- // candidate project root) contains a .git directory. This handles both
70
- // `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside),
71
- // as well as the common case where .git lives at the same level as .planning/.
72
- function isInsideGitRepo(candidateParent) {
73
- let d = resolved;
74
- while (d !== root) {
75
- if (fs.existsSync(path.join(d, '.git'))) return true;
76
- if (d === candidateParent) break;
77
- d = path.dirname(d);
78
- }
79
- return false;
80
- }
81
-
82
- let dir = resolved;
83
- while (dir !== root) {
84
- const parent = path.dirname(dir);
85
- if (parent === dir) break; // filesystem root
86
- if (parent === homedir) break; // never go above home
87
-
88
- const parentPlanning = path.join(parent, '.planning');
89
- if (fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory()) {
90
- const configPath = path.join(parentPlanning, 'config.json');
91
- try {
92
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
93
- const subRepos = config.sub_repos || config.planning?.sub_repos || [];
94
-
95
- // Check explicit sub_repos list
96
- if (Array.isArray(subRepos) && subRepos.length > 0) {
97
- const relPath = path.relative(parent, resolved);
98
- const topSegment = relPath.split(path.sep)[0];
99
- if (subRepos.includes(topSegment)) {
100
- return parent;
101
- }
102
- }
103
-
104
- // Check legacy multiRepo flag
105
- if (config.multiRepo === true && isInsideGitRepo(parent)) {
106
- return parent;
107
- }
108
- } catch {
109
- // config.json missing or malformed — fall back to .git heuristic
110
- }
111
-
112
- // Heuristic: parent has .planning/ and we're inside a git repo
113
- if (isInsideGitRepo(parent)) {
114
- return parent;
115
- }
116
- }
117
- dir = parent;
118
- }
119
- return startDir;
120
- }
121
-
122
- // ─── Output helpers ───────────────────────────────────────────────────────────
123
-
124
- /**
125
- * Remove stale framework-* temp files/dirs older than maxAgeMs (default: 5 minutes).
126
- * Runs opportunistically before each new temp file write to prevent unbounded accumulation.
127
- * @param {string} prefix - filename prefix to match (e.g., 'framework-')
128
- * @param {object} opts
129
- * @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min)
130
- * @param {boolean} opts.dirsOnly - if true, only remove directories (default: false)
131
- */
132
- function reapStaleTempFiles(prefix = 'framework-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
133
- try {
134
- const tmpDir = require('os').tmpdir();
135
- const now = Date.now();
136
- const entries = fs.readdirSync(tmpDir);
137
- for (const entry of entries) {
138
- if (!entry.startsWith(prefix)) continue;
139
- const fullPath = path.join(tmpDir, entry);
140
- try {
141
- const stat = fs.statSync(fullPath);
142
- if (now - stat.mtimeMs > maxAgeMs) {
143
- if (stat.isDirectory()) {
144
- fs.rmSync(fullPath, { recursive: true, force: true });
145
- } else if (!dirsOnly) {
146
- fs.unlinkSync(fullPath);
147
- }
148
- }
149
- } catch {
150
- // File may have been removed between readdir and stat — ignore
151
- }
152
- }
153
- } catch {
154
- // Non-critical — don't let cleanup failures break output
155
- }
156
- }
157
-
158
- function output(result, raw, rawValue) {
159
- let data;
160
- if (raw && rawValue !== undefined) {
161
- data = String(rawValue);
162
- } else {
163
- const json = JSON.stringify(result, null, 2);
164
- // Large payloads exceed Claude Code's Bash tool buffer (~50KB).
165
- // Write to tmpfile and output the path prefixed with @file: so callers can detect it.
166
- if (json.length > 50000) {
167
- reapStaleTempFiles();
168
- const tmpPath = path.join(require('os').tmpdir(), `framework-${Date.now()}.json`);
169
- fs.writeFileSync(tmpPath, json, 'utf-8');
170
- data = '@file:' + tmpPath;
171
- } else {
172
- data = json;
173
- }
174
- }
175
- // process.stdout.write() is async when stdout is a pipe — process.exit()
176
- // can tear down the process before the reader consumes the buffer.
177
- // fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
178
- // skipping process.exit() lets the event loop drain naturally.
179
- fs.writeSync(1, data);
180
- }
181
-
182
- function error(message) {
183
- fs.writeSync(2, 'Error: ' + message + '\n');
184
- process.exit(1);
185
- }
186
-
187
- // ─── File & Config utilities ──────────────────────────────────────────────────
188
-
189
- function safeReadFile(filePath) {
190
- try {
191
- return fs.readFileSync(filePath, 'utf-8');
192
- } catch {
193
- return null;
194
- }
195
- }
196
-
197
- function loadConfig(cwd) {
198
- const configPath = path.join(cwd, '.planning', 'config.json');
199
- const defaults = {
200
- model_profile: 'balanced',
201
- commit_docs: true,
202
- search_gitignored: false,
203
- branching_strategy: 'none',
204
- phase_branch_template: 'framework/phase-{phase}-{slug}',
205
- milestone_branch_template: 'framework/{milestone}-{slug}',
206
- quick_branch_template: null,
207
- research: true,
208
- plan_checker: true,
209
- verifier: true,
210
- nyquist_validation: true,
211
- parallelization: true,
212
- brave_search: false,
213
- firecrawl: false,
214
- exa_search: false,
215
- text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus
216
- sub_repos: [],
217
- resolve_model_ids: false, // false: return alias as-is | true: map to full Claude model ID | "omit": return '' (runtime uses its default)
218
- context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
219
- phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
220
- };
221
-
222
- try {
223
- const raw = fs.readFileSync(configPath, 'utf-8');
224
- const parsed = JSON.parse(raw);
225
-
226
- // Migrate deprecated "depth" key to "granularity" with value mapping
227
- if ('depth' in parsed && !('granularity' in parsed)) {
228
- const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
229
- parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth;
230
- delete parsed.depth;
231
- try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch { /* intentionally empty */ }
232
- }
233
-
234
- // Auto-detect and sync sub_repos: scan for child directories with .git
235
- let configDirty = false;
236
-
237
- // Migrate legacy "multiRepo: true" boolean → sub_repos array
238
- if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) {
239
- const detected = detectSubRepos(cwd);
240
- if (detected.length > 0) {
241
- parsed.sub_repos = detected;
242
- if (!parsed.planning) parsed.planning = {};
243
- parsed.planning.commit_docs = false;
244
- delete parsed.multiRepo;
245
- configDirty = true;
246
- }
247
- }
248
-
249
- // Keep sub_repos in sync with actual filesystem
250
- const currentSubRepos = parsed.sub_repos || parsed.planning?.sub_repos || [];
251
- if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
252
- const detected = detectSubRepos(cwd);
253
- if (detected.length > 0) {
254
- const sorted = [...currentSubRepos].sort();
255
- if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
256
- parsed.sub_repos = detected;
257
- configDirty = true;
258
- }
259
- }
260
- }
261
-
262
- // Persist sub_repos changes (migration or sync)
263
- if (configDirty) {
264
- try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {}
265
- }
266
-
267
- const get = (key, nested) => {
268
- if (parsed[key] !== undefined) return parsed[key];
269
- if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
270
- return parsed[nested.section][nested.field];
271
- }
272
- return undefined;
273
- };
274
-
275
- const parallelization = (() => {
276
- const val = get('parallelization');
277
- if (typeof val === 'boolean') return val;
278
- if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled;
279
- return defaults.parallelization;
280
- })();
281
-
282
- return {
283
- model_profile: get('model_profile') ?? defaults.model_profile,
284
- commit_docs: (() => {
285
- const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
286
- // If explicitly set in config, respect the user's choice
287
- if (explicit !== undefined) return explicit;
288
- // Auto-detection: when no explicit value and .planning/ is gitignored,
289
- // default to false instead of true
290
- if (isGitIgnored(cwd, '.planning/')) return false;
291
- return defaults.commit_docs;
292
- })(),
293
- search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
294
- branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
295
- phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
296
- milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
297
- quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
298
- research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
299
- plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
300
- verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
301
- nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
302
- parallelization,
303
- brave_search: get('brave_search') ?? defaults.brave_search,
304
- firecrawl: get('firecrawl') ?? defaults.firecrawl,
305
- exa_search: get('exa_search') ?? defaults.exa_search,
306
- text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
307
- sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos,
308
- resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
309
- context_window: get('context_window') ?? defaults.context_window,
310
- phase_naming: get('phase_naming') ?? defaults.phase_naming,
311
- model_overrides: parsed.model_overrides || null,
312
- agent_skills: parsed.agent_skills || {},
313
- };
314
- } catch {
315
- return defaults;
316
- }
317
- }
318
-
319
- // ─── Git utilities ────────────────────────────────────────────────────────────
320
-
321
- function isGitIgnored(cwd, targetPath) {
322
- try {
323
- // --no-index checks .gitignore rules regardless of whether the file is tracked.
324
- // Without it, git check-ignore returns "not ignored" for tracked files even when
325
- // .gitignore explicitly lists them — a common source of confusion when .planning/
326
- // was committed before being added to .gitignore.
327
- // Use execFileSync (array args) to prevent shell interpretation of special characters
328
- // in file paths — avoids command injection via crafted path names.
329
- execFileSync('git', ['check-ignore', '-q', '--no-index', '--', targetPath], {
330
- cwd,
331
- stdio: 'pipe',
332
- });
333
- return true;
334
- } catch {
335
- return false;
336
- }
337
- }
338
-
339
- // ─── Markdown normalization ─────────────────────────────────────────────────
340
-
341
- /**
342
- * Normalize markdown to fix common markdownlint violations.
343
- * Applied at write points so framework-generated .planning/ files are IDE-friendly.
344
- *
345
- * Rules enforced:
346
- * MD022 — Blank lines around headings
347
- * MD031 — Blank lines around fenced code blocks
348
- * MD032 — Blank lines around lists
349
- * MD012 — No multiple consecutive blank lines (collapsed to 2 max)
350
- * MD047 — Files end with a single newline
351
- */
352
- function normalizeMd(content) {
353
- if (!content || typeof content !== 'string') return content;
354
-
355
- // Normalize line endings to LF for consistent processing
356
- let text = content.replace(/\r\n/g, '\n');
357
-
358
- const lines = text.split('\n');
359
- const result = [];
360
-
361
- for (let i = 0; i < lines.length; i++) {
362
- const line = lines[i];
363
- const prev = i > 0 ? lines[i - 1] : '';
364
- const prevTrimmed = prev.trimEnd();
365
- const trimmed = line.trimEnd();
366
-
367
- // MD022: Blank line before headings (skip first line and frontmatter delimiters)
368
- if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
369
- result.push('');
370
- }
371
-
372
- // MD031: Blank line before fenced code blocks
373
- if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) {
374
- result.push('');
375
- }
376
-
377
- // MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
378
- if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
379
- prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
380
- prevTrimmed !== '---') {
381
- result.push('');
382
- }
383
-
384
- result.push(line);
385
-
386
- // MD022: Blank line after headings
387
- if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) {
388
- const next = lines[i + 1];
389
- if (next !== undefined && next.trimEnd() !== '') {
390
- result.push('');
391
- }
392
- }
393
-
394
- // MD031: Blank line after closing fenced code blocks
395
- if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) {
396
- const next = lines[i + 1];
397
- if (next !== undefined && next.trimEnd() !== '') {
398
- result.push('');
399
- }
400
- }
401
-
402
- // MD032: Blank line after last list item in a block
403
- if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
404
- const next = lines[i + 1];
405
- if (next !== undefined && next.trimEnd() !== '' &&
406
- !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
407
- !/^\s/.test(next)) {
408
- // Only add blank line if next line is not a continuation/indented line
409
- result.push('');
410
- }
411
- }
412
- }
413
-
414
- text = result.join('\n');
415
-
416
- // MD012: Collapse 3+ consecutive blank lines to 2
417
- text = text.replace(/\n{3,}/g, '\n\n');
418
-
419
- // MD047: Ensure file ends with exactly one newline
420
- text = text.replace(/\n*$/, '\n');
421
-
422
- return text;
423
- }
424
-
425
- /** Check if line index i is inside an already-open fenced code block */
426
- function isInsideFencedBlock(lines, i) {
427
- let fenceCount = 0;
428
- for (let j = 0; j < i; j++) {
429
- if (/^```/.test(lines[j].trimEnd())) fenceCount++;
430
- }
431
- return fenceCount % 2 === 1;
432
- }
433
-
434
- /** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */
435
- function isClosingFence(lines, i) {
436
- let fenceCount = 0;
437
- for (let j = 0; j <= i; j++) {
438
- if (/^```/.test(lines[j].trimEnd())) fenceCount++;
439
- }
440
- return fenceCount % 2 === 0;
441
- }
442
-
443
- function execGit(cwd, args) {
444
- const result = spawnSync('git', args, {
445
- cwd,
446
- stdio: 'pipe',
447
- encoding: 'utf-8',
448
- });
449
- return {
450
- exitCode: result.status ?? 1,
451
- stdout: (result.stdout ?? '').toString().trim(),
452
- stderr: (result.stderr ?? '').toString().trim(),
453
- };
454
- }
455
-
456
- // ─── Common path helpers ──────────────────────────────────────────────────────
457
-
458
- /**
459
- * Resolve the main worktree root when running inside a git worktree.
460
- * In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
461
- * Returns the main worktree path, or cwd if not in a worktree.
462
- */
463
- function resolveWorktreeRoot(cwd) {
464
- // If the current directory already has its own .planning/, respect it.
465
- // This handles linked worktrees with independent planning state (e.g., Conductor workspaces).
466
- if (fs.existsSync(path.join(cwd, '.planning'))) {
467
- return cwd;
468
- }
469
-
470
- // Check if we're in a linked worktree
471
- const gitDir = execGit(cwd, ['rev-parse', '--git-dir']);
472
- const commonDir = execGit(cwd, ['rev-parse', '--git-common-dir']);
473
-
474
- if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return cwd;
475
-
476
- // In a linked worktree, .git is a file pointing to .git/worktrees/<name>
477
- // and git-common-dir points to the main repo's .git directory
478
- const gitDirResolved = path.resolve(cwd, gitDir.stdout);
479
- const commonDirResolved = path.resolve(cwd, commonDir.stdout);
480
-
481
- if (gitDirResolved !== commonDirResolved) {
482
- // We're in a linked worktree — resolve main worktree root
483
- // The common dir is the main repo's .git, so its parent is the main worktree root
484
- return path.dirname(commonDirResolved);
485
- }
486
-
487
- return cwd;
488
- }
489
-
490
- /**
491
- * Acquire a file-based lock for .planning/ writes.
492
- * Prevents concurrent worktrees from corrupting shared planning files.
493
- * Lock is auto-released after the callback completes.
494
- */
495
- function withPlanningLock(cwd, fn) {
496
- const lockPath = path.join(planningDir(cwd), '.lock');
497
- const lockTimeout = 10000; // 10 seconds
498
- const retryDelay = 100;
499
- const start = Date.now();
500
-
501
- // Ensure .planning/ exists
502
- try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ }
503
-
504
- while (Date.now() - start < lockTimeout) {
505
- try {
506
- // Atomic create — fails if file exists
507
- fs.writeFileSync(lockPath, JSON.stringify({
508
- pid: process.pid,
509
- cwd,
510
- acquired: new Date().toISOString(),
511
- }), { flag: 'wx' });
512
-
513
- // Lock acquired — run the function
514
- try {
515
- return fn();
516
- } finally {
517
- try { fs.unlinkSync(lockPath); } catch { /* already released */ }
518
- }
519
- } catch (err) {
520
- if (err.code === 'EEXIST') {
521
- // Lock exists — check if stale (>30s old)
522
- try {
523
- const stat = fs.statSync(lockPath);
524
- if (Date.now() - stat.mtimeMs > 30000) {
525
- fs.unlinkSync(lockPath);
526
- continue; // retry
527
- }
528
- } catch { continue; }
529
-
530
- // Wait and retry
531
- spawnSync('sleep', ['0.1'], { stdio: 'ignore' });
532
- continue;
533
- }
534
- throw err;
535
- }
536
- }
537
- // Timeout — force acquire (stale lock recovery)
538
- try { fs.unlinkSync(lockPath); } catch { /* ok */ }
539
- return fn();
540
- }
541
-
542
- /**
543
- * Get the .planning directory path, workstream-aware.
544
- * When a workstream is active (via explicit ws arg or WORKSTREAM env var),
545
- * returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`.
546
- *
547
- * @param {string} cwd - project root
548
- * @param {string} [ws] - explicit workstream name; if omitted, checks WORKSTREAM env var
549
- */
550
- function planningDir(cwd, ws) {
551
- if (ws === undefined) ws = process.env.WORKSTREAM || null;
552
- if (!ws) return path.join(cwd, '.planning');
553
- return path.join(cwd, '.planning', 'workstreams', ws);
554
- }
555
-
556
- /** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */
557
- function planningRoot(cwd) {
558
- return path.join(cwd, '.planning');
559
- }
560
-
561
- /**
562
- * Get common .planning file paths, workstream-aware.
563
- * Scoped paths (state, roadmap, phases, requirements) resolve to the active workstream.
564
- * Shared paths (project, config) always resolve to the root .planning/.
565
- */
566
- function planningPaths(cwd, ws) {
567
- const base = planningDir(cwd, ws);
568
- const root = path.join(cwd, '.planning');
569
- return {
570
- planning: base,
571
- state: path.join(base, 'STATE.md'),
572
- roadmap: path.join(base, 'ROADMAP.md'),
573
- project: path.join(root, 'PROJECT.md'),
574
- config: path.join(root, 'config.json'),
575
- phases: path.join(base, 'phases'),
576
- requirements: path.join(base, 'REQUIREMENTS.md'),
577
- };
578
- }
579
-
580
- // ─── Active Workstream Detection ─────────────────────────────────────────────
581
-
582
- /**
583
- * Get the active workstream name from .planning/active-workstream file.
584
- * Returns null if no active workstream or file doesn't exist.
585
- */
586
- function getActiveWorkstream(cwd) {
587
- const filePath = path.join(planningRoot(cwd), 'active-workstream');
588
- try {
589
- const name = fs.readFileSync(filePath, 'utf-8').trim();
590
- if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) return null;
591
- const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
592
- if (!fs.existsSync(wsDir)) return null;
593
- return name;
594
- } catch {
595
- return null;
596
- }
597
- }
598
-
599
- /**
600
- * Set the active workstream. Pass null to clear.
601
- */
602
- function setActiveWorkstream(cwd, name) {
603
- const filePath = path.join(planningRoot(cwd), 'active-workstream');
604
- if (!name) {
605
- try { fs.unlinkSync(filePath); } catch {}
606
- return;
607
- }
608
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
609
- throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
610
- }
611
- fs.writeFileSync(filePath, name + '\n', 'utf-8');
612
- }
613
-
614
- // ─── Phase utilities ──────────────────────────────────────────────────────────
615
-
616
- function escapeRegex(value) {
617
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
618
- }
619
-
620
- function normalizePhaseName(phase) {
621
- const str = String(phase);
622
- // Standard numeric phases: 1, 01, 12A, 12.1
623
- const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
624
- if (match) {
625
- const padded = match[1].padStart(2, '0');
626
- const letter = match[2] ? match[2].toUpperCase() : '';
627
- const decimal = match[3] || '';
628
- return padded + letter + decimal;
629
- }
630
- // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
631
- return str;
632
- }
633
-
634
- function comparePhaseNum(a, b) {
635
- const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
636
- const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
637
- // If either is non-numeric (custom ID), fall back to string comparison
638
- if (!pa || !pb) return String(a).localeCompare(String(b));
639
- const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
640
- if (intDiff !== 0) return intDiff;
641
- // No letter sorts before letter: 12 < 12A < 12B
642
- const la = (pa[2] || '').toUpperCase();
643
- const lb = (pb[2] || '').toUpperCase();
644
- if (la !== lb) {
645
- if (!la) return -1;
646
- if (!lb) return 1;
647
- return la < lb ? -1 : 1;
648
- }
649
- // Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2
650
- const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
651
- const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
652
- const maxLen = Math.max(aDecParts.length, bDecParts.length);
653
- if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
654
- if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
655
- for (let i = 0; i < maxLen; i++) {
656
- const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
657
- const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
658
- if (av !== bv) return av - bv;
659
- }
660
- return 0;
661
- }
662
-
663
- function searchPhaseInDir(baseDir, relBase, normalized) {
664
- try {
665
- const dirs = readSubdirectories(baseDir, true);
666
- // Match: starts with normalized (numeric) OR contains normalized as prefix segment (custom ID)
667
- const match = dirs.find(d => {
668
- if (d.startsWith(normalized)) return true;
669
- // For custom IDs like PROJ-42, match case-insensitively
670
- if (d.toUpperCase().startsWith(normalized.toUpperCase())) return true;
671
- return false;
672
- });
673
- if (!match) return null;
674
-
675
- // Extract phase number and name — supports both numeric (01-name) and custom (PROJ-42-name)
676
- const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
677
- || match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
678
- || [null, match, null];
679
- const phaseNumber = dirMatch ? dirMatch[1] : normalized;
680
- const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
681
- const phaseDir = path.join(baseDir, match);
682
- const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
683
- const plans = unsortedPlans.sort();
684
- const summaries = unsortedSummaries.sort();
685
-
686
- const completedPlanIds = new Set(
687
- summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
688
- );
689
- const incompletePlans = plans.filter(p => {
690
- const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
691
- return !completedPlanIds.has(planId);
692
- });
693
-
694
- return {
695
- found: true,
696
- directory: toPosixPath(path.join(relBase, match)),
697
- phase_number: phaseNumber,
698
- phase_name: phaseName,
699
- phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
700
- plans,
701
- summaries,
702
- incomplete_plans: incompletePlans,
703
- has_research: hasResearch,
704
- has_context: hasContext,
705
- has_verification: hasVerification,
706
- has_reviews: hasReviews,
707
- };
708
- } catch {
709
- return null;
710
- }
711
- }
712
-
713
- function findPhaseInternal(cwd, phase) {
714
- if (!phase) return null;
715
-
716
- const phasesDir = path.join(planningDir(cwd), 'phases');
717
- const normalized = normalizePhaseName(phase);
718
-
719
- // Search current phases first
720
- const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
721
- const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
722
- if (current) return current;
723
-
724
- // Search archived milestone phases (newest first)
725
- const milestonesDir = path.join(cwd, '.planning', 'milestones');
726
- if (!fs.existsSync(milestonesDir)) return null;
727
-
728
- try {
729
- const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
730
- const archiveDirs = milestoneEntries
731
- .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
732
- .map(e => e.name)
733
- .sort()
734
- .reverse();
735
-
736
- for (const archiveName of archiveDirs) {
737
- const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
738
- const archivePath = path.join(milestonesDir, archiveName);
739
- const relBase = '.planning/milestones/' + archiveName;
740
- const result = searchPhaseInDir(archivePath, relBase, normalized);
741
- if (result) {
742
- result.archived = version;
743
- return result;
744
- }
745
- }
746
- } catch { /* intentionally empty */ }
747
-
748
- return null;
749
- }
750
-
751
- function getArchivedPhaseDirs(cwd) {
752
- const milestonesDir = path.join(cwd, '.planning', 'milestones');
753
- const results = [];
754
-
755
- if (!fs.existsSync(milestonesDir)) return results;
756
-
757
- try {
758
- const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
759
- // Find v*-phases directories, sort newest first
760
- const phaseDirs = milestoneEntries
761
- .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
762
- .map(e => e.name)
763
- .sort()
764
- .reverse();
765
-
766
- for (const archiveName of phaseDirs) {
767
- const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
768
- const archivePath = path.join(milestonesDir, archiveName);
769
- const dirs = readSubdirectories(archivePath, true);
770
-
771
- for (const dir of dirs) {
772
- results.push({
773
- name: dir,
774
- milestone: version,
775
- basePath: path.join('.planning', 'milestones', archiveName),
776
- fullPath: path.join(archivePath, dir),
777
- });
778
- }
779
- }
780
- } catch { /* intentionally empty */ }
781
-
782
- return results;
783
- }
784
-
785
- // ─── Roadmap milestone scoping ───────────────────────────────────────────────
786
-
787
- /**
788
- * Strip shipped milestone content wrapped in <details> blocks.
789
- * Used to isolate current milestone phases when searching ROADMAP.md
790
- * for phase headings or checkboxes — prevents matching archived milestone
791
- * phases that share the same numbers as current milestone phases.
792
- */
793
- function stripShippedMilestones(content) {
794
- return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
795
- }
796
-
797
- /**
798
- * Extract the current milestone section from ROADMAP.md by positive lookup.
799
- *
800
- * Instead of stripping <details> blocks (negative heuristic that breaks if
801
- * agents wrap the current milestone in <details>), this finds the section
802
- * matching the current milestone version and returns only that content.
803
- *
804
- * Falls back to stripShippedMilestones() if:
805
- * - cwd is not provided
806
- * - STATE.md doesn't exist or has no milestone field
807
- * - Version can't be found in ROADMAP.md
808
- *
809
- * @param {string} content - Full ROADMAP.md content
810
- * @param {string} [cwd] - Working directory for reading STATE.md
811
- * @returns {string} Content scoped to current milestone
812
- */
813
- function extractCurrentMilestone(content, cwd) {
814
- if (!cwd) return stripShippedMilestones(content);
815
-
816
- // 1. Get current milestone version from STATE.md frontmatter
817
- let version = null;
818
- try {
819
- const statePath = path.join(planningDir(cwd), 'STATE.md');
820
- if (fs.existsSync(statePath)) {
821
- const stateRaw = fs.readFileSync(statePath, 'utf-8');
822
- const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
823
- if (milestoneMatch) {
824
- version = milestoneMatch[1].trim();
825
- }
826
- }
827
- } catch {}
828
-
829
- // 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself
830
- if (!version) {
831
- // Check for 🚧 in-progress marker
832
- const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
833
- if (inProgressMatch) {
834
- version = 'v' + inProgressMatch[1];
835
- }
836
- }
837
-
838
- if (!version) return stripShippedMilestones(content);
839
-
840
- // 3. Find the section matching this version
841
- // Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc.
842
- const escapedVersion = escapeRegex(version);
843
- const sectionPattern = new RegExp(
844
- `(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`,
845
- 'mi'
846
- );
847
- const sectionMatch = content.match(sectionPattern);
848
-
849
- if (!sectionMatch) return stripShippedMilestones(content);
850
-
851
- const sectionStart = sectionMatch.index;
852
-
853
- // Find the end: next milestone heading at same or higher level, or EOF
854
- // Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc.
855
- const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
856
- const restContent = content.slice(sectionStart + sectionMatch[0].length);
857
- const nextMilestonePattern = new RegExp(
858
- `^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
859
- 'mi'
860
- );
861
- const nextMatch = restContent.match(nextMilestonePattern);
862
-
863
- let sectionEnd;
864
- if (nextMatch) {
865
- sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index;
866
- } else {
867
- sectionEnd = content.length;
868
- }
869
-
870
- // Return everything before the current milestone section (non-milestone content
871
- // like title, overview) plus the current milestone section
872
- const beforeMilestones = content.slice(0, sectionStart);
873
- const currentSection = content.slice(sectionStart, sectionEnd);
874
-
875
- // Also include any content before the first milestone heading (title, overview, etc.)
876
- // but strip any <details> blocks in it (these are definitely shipped)
877
- const preamble = beforeMilestones.replace(/<details>[\s\S]*?<\/details>/gi, '');
878
-
879
- return preamble + currentSection;
880
- }
881
-
882
- /**
883
- * Replace a pattern only in the current milestone section of ROADMAP.md
884
- * (everything after the last </details> close tag). Used for write operations
885
- * that must not accidentally modify archived milestone checkboxes/tables.
886
- */
887
- function replaceInCurrentMilestone(content, pattern, replacement) {
888
- const lastDetailsClose = content.lastIndexOf('</details>');
889
- if (lastDetailsClose === -1) {
890
- return content.replace(pattern, replacement);
891
- }
892
- const offset = lastDetailsClose + '</details>'.length;
893
- const before = content.slice(0, offset);
894
- const after = content.slice(offset);
895
- return before + after.replace(pattern, replacement);
896
- }
897
-
898
- // ─── Roadmap & model utilities ────────────────────────────────────────────────
899
-
900
- function getRoadmapPhaseInternal(cwd, phaseNum) {
901
- if (!phaseNum) return null;
902
- const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
903
- if (!fs.existsSync(roadmapPath)) return null;
904
-
905
- try {
906
- const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
907
- const escapedPhase = escapeRegex(phaseNum.toString());
908
- // Match both numeric (Phase 1:) and custom (Phase PROJ-42:) headers
909
- const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
910
- const headerMatch = content.match(phasePattern);
911
- if (!headerMatch) return null;
912
-
913
- const phaseName = headerMatch[1].trim();
914
- const headerIndex = headerMatch.index;
915
- const restOfContent = content.slice(headerIndex);
916
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i);
917
- const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
918
- const section = content.slice(headerIndex, sectionEnd).trim();
919
-
920
- const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
921
- const goal = goalMatch ? goalMatch[1].trim() : null;
922
-
923
- return {
924
- found: true,
925
- phase_number: phaseNum.toString(),
926
- phase_name: phaseName,
927
- goal,
928
- section,
929
- };
930
- } catch {
931
- return null;
932
- }
933
- }
934
-
935
- // ─── Agent installation validation (#1371) ───────────────────────────────────
936
-
937
- /**
938
- * Resolve the agents directory from the framework install location.
939
- * tools.cjs lives at <configDir>/framework/bin/tools.cjs,
940
- * so agents/ is at <configDir>/agents/.
941
- *
942
- * @returns {string} Absolute path to the agents directory
943
- */
944
- function getAgentsDir() {
945
- // __dirname is framework/bin/lib/ → go up 3 levels to configDir
946
- return path.join(__dirname, '..', '..', '..', 'agents');
947
- }
948
-
949
- /**
950
- * Check which framework agents are installed on disk.
951
- * Returns an object with installation status and details.
952
- *
953
- * @returns {{ agents_installed: boolean, missing_agents: string[], installed_agents: string[], agents_dir: string }}
954
- */
955
- function checkAgentsInstalled() {
956
- const agentsDir = getAgentsDir();
957
- const expectedAgents = Object.keys(MODEL_PROFILES);
958
- const installed = [];
959
- const missing = [];
960
-
961
- if (!fs.existsSync(agentsDir)) {
962
- return {
963
- agents_installed: false,
964
- missing_agents: expectedAgents,
965
- installed_agents: [],
966
- agents_dir: agentsDir,
967
- };
968
- }
969
-
970
- for (const agent of expectedAgents) {
971
- const agentFile = path.join(agentsDir, `${agent}.md`);
972
- if (fs.existsSync(agentFile)) {
973
- installed.push(agent);
974
- } else {
975
- missing.push(agent);
976
- }
977
- }
978
-
979
- return {
980
- agents_installed: installed.length > 0 && missing.length === 0,
981
- missing_agents: missing,
982
- installed_agents: installed,
983
- agents_dir: agentsDir,
984
- };
985
- }
986
-
987
- // ─── Model alias resolution ───────────────────────────────────────────────────
988
-
989
- /**
990
- * Map short model aliases to full model IDs.
991
- * Updated each release to match current model versions.
992
- * Users can override with model_overrides in config.json for custom/latest models.
993
- */
994
- const MODEL_ALIAS_MAP = {
995
- 'opus': 'claude-opus-4-0',
996
- 'sonnet': 'claude-sonnet-4-5',
997
- 'haiku': 'claude-haiku-3-5',
998
- };
999
-
1000
- function resolveModelInternal(cwd, agentType) {
1001
- const config = loadConfig(cwd);
1002
-
1003
- // Check per-agent override first — always respected regardless of resolve_model_ids.
1004
- // Users who set fully-qualified model IDs (e.g., "openai/gpt-5.4") get exactly that.
1005
- const override = config.model_overrides?.[agentType];
1006
- if (override) {
1007
- return override;
1008
- }
1009
-
1010
- // resolve_model_ids: "omit" — return empty string so the runtime uses its configured
1011
- // default model. For non-Claude runtimes (OpenCode, Codex, etc.) that don't recognize
1012
- // Claude aliases (opus/sonnet/haiku/inherit). Set automatically during install. See #1156.
1013
- if (config.resolve_model_ids === 'omit') {
1014
- return '';
1015
- }
1016
-
1017
- // Fall back to profile lookup
1018
- const profile = String(config.model_profile || 'balanced').toLowerCase();
1019
- const agentModels = MODEL_PROFILES[agentType];
1020
- if (!agentModels) return 'sonnet';
1021
- if (profile === 'inherit') return 'inherit';
1022
- const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet';
1023
-
1024
- // resolve_model_ids: true — map alias to full Claude model ID
1025
- // Prevents 404s when the Task tool passes aliases directly to the API
1026
- if (config.resolve_model_ids) {
1027
- return MODEL_ALIAS_MAP[alias] || alias;
1028
- }
1029
-
1030
- return alias;
1031
- }
1032
-
1033
- // ─── Summary body helpers ─────────────────────────────────────────────────
1034
-
1035
- /**
1036
- * Extract a one-liner from the summary body when it's not in frontmatter.
1037
- * The summary template defines one-liner as a bold markdown line after the heading:
1038
- * # Phase X: Name Summary
1039
- * **[substantive one-liner text]**
1040
- */
1041
- function extractOneLinerFromBody(content) {
1042
- if (!content) return null;
1043
- // Strip frontmatter first
1044
- const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
1045
- // Find the first **...** line after a # heading
1046
- const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
1047
- return match ? match[1].trim() : null;
1048
- }
1049
-
1050
- // ─── Misc utilities ───────────────────────────────────────────────────────────
1051
-
1052
- function pathExistsInternal(cwd, targetPath) {
1053
- const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
1054
- try {
1055
- fs.statSync(fullPath);
1056
- return true;
1057
- } catch {
1058
- return false;
1059
- }
1060
- }
1061
-
1062
- function generateSlugInternal(text) {
1063
- if (!text) return null;
1064
- return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1065
- }
1066
-
1067
- function getMilestoneInfo(cwd) {
1068
- try {
1069
- const roadmap = fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8');
1070
-
1071
- // First: check for list-format roadmaps using 🚧 (in-progress) marker
1072
- // e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)"
1073
- // e.g. "- 🚧 **v1.2.1 Tech Debt** — Phases 1-8 (in progress)"
1074
- const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
1075
- if (inProgressMatch) {
1076
- return {
1077
- version: 'v' + inProgressMatch[1],
1078
- name: inProgressMatch[2].trim(),
1079
- };
1080
- }
1081
-
1082
- // Second: heading-format roadmaps — strip shipped milestones in <details> blocks
1083
- const cleaned = stripShippedMilestones(roadmap);
1084
- // Extract version and name from the same ## heading for consistency
1085
- // Supports 2+ segment versions: v1.2, v1.2.1, v2.0.1, etc.
1086
- const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
1087
- if (headingMatch) {
1088
- return {
1089
- version: 'v' + headingMatch[1],
1090
- name: headingMatch[2].trim(),
1091
- };
1092
- }
1093
- // Fallback: try bare version match (greedy — capture longest version string)
1094
- const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
1095
- return {
1096
- version: versionMatch ? versionMatch[0] : 'v1.0',
1097
- name: 'milestone',
1098
- };
1099
- } catch {
1100
- return { version: 'v1.0', name: 'milestone' };
1101
- }
1102
- }
1103
-
1104
- /**
1105
- * Returns a filter function that checks whether a phase directory belongs
1106
- * to the current milestone based on ROADMAP.md phase headings.
1107
- * If no ROADMAP exists or no phases are listed, returns a pass-all filter.
1108
- */
1109
- function getMilestonePhaseFilter(cwd) {
1110
- const milestonePhaseNums = new Set();
1111
- try {
1112
- const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
1113
- // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
1114
- const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1115
- let m;
1116
- while ((m = phasePattern.exec(roadmap)) !== null) {
1117
- milestonePhaseNums.add(m[1]);
1118
- }
1119
- } catch { /* intentionally empty */ }
1120
-
1121
- if (milestonePhaseNums.size === 0) {
1122
- const passAll = () => true;
1123
- passAll.phaseCount = 0;
1124
- return passAll;
1125
- }
1126
-
1127
- const normalized = new Set(
1128
- [...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase())
1129
- );
1130
-
1131
- function isDirInMilestone(dirName) {
1132
- // Try numeric match first
1133
- const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1134
- if (m && normalized.has(m[1].toLowerCase())) return true;
1135
- // Try custom ID match (e.g. PROJ-42-description → PROJ-42)
1136
- const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
1137
- if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
1138
- return false;
1139
- }
1140
- isDirInMilestone.phaseCount = milestonePhaseNums.size;
1141
- return isDirInMilestone;
1142
- }
1143
-
1144
- // ─── Phase file helpers ──────────────────────────────────────────────────────
1145
-
1146
- /** Filter a file list to just PLAN.md / *-PLAN.md entries. */
1147
- function filterPlanFiles(files) {
1148
- return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
1149
- }
1150
-
1151
- /** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
1152
- function filterSummaryFiles(files) {
1153
- return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1154
- }
1155
-
1156
- /**
1157
- * Read a phase directory and return counts/flags for common file types.
1158
- * Returns an object with plans[], summaries[], and boolean flags for
1159
- * research/context/verification files.
1160
- */
1161
- function getPhaseFileStats(phaseDir) {
1162
- const files = fs.readdirSync(phaseDir);
1163
- return {
1164
- plans: filterPlanFiles(files),
1165
- summaries: filterSummaryFiles(files),
1166
- hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
1167
- hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'),
1168
- hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
1169
- hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
1170
- };
1171
- }
1172
-
1173
- /**
1174
- * Read immediate child directories from a path.
1175
- * Returns [] if the path doesn't exist or can't be read.
1176
- * Pass sort=true to apply comparePhaseNum ordering.
1177
- */
1178
- function readSubdirectories(dirPath, sort = false) {
1179
- try {
1180
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1181
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1182
- return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
1183
- } catch {
1184
- return [];
1185
- }
1186
- }
1187
-
1188
- module.exports = {
1189
- output,
1190
- error,
1191
- safeReadFile,
1192
- loadConfig,
1193
- isGitIgnored,
1194
- execGit,
1195
- normalizeMd,
1196
- escapeRegex,
1197
- normalizePhaseName,
1198
- comparePhaseNum,
1199
- searchPhaseInDir,
1200
- findPhaseInternal,
1201
- getArchivedPhaseDirs,
1202
- getRoadmapPhaseInternal,
1203
- resolveModelInternal,
1204
- pathExistsInternal,
1205
- generateSlugInternal,
1206
- getMilestoneInfo,
1207
- getMilestonePhaseFilter,
1208
- stripShippedMilestones,
1209
- extractCurrentMilestone,
1210
- replaceInCurrentMilestone,
1211
- toPosixPath,
1212
- extractOneLinerFromBody,
1213
- resolveWorktreeRoot,
1214
- withPlanningLock,
1215
- findProjectRoot,
1216
- detectSubRepos,
1217
- reapStaleTempFiles,
1218
- MODEL_ALIAS_MAP,
1219
- planningDir,
1220
- planningRoot,
1221
- planningPaths,
1222
- getActiveWorkstream,
1223
- setActiveWorkstream,
1224
- filterPlanFiles,
1225
- filterSummaryFiles,
1226
- getPhaseFileStats,
1227
- readSubdirectories,
1228
- getAgentsDir,
1229
- checkAgentsInstalled,
1230
- };
1
+ /**
2
+ * Core — Shared utilities, constants, and internal helpers
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { execSync, execFileSync, spawnSync } = require('child_process');
8
+ const { MODEL_PROFILES } = require('./model-profiles.cjs');
9
+
10
+ // ─── Path helpers ────────────────────────────────────────────────────────────
11
+
12
+ /** Normalize a relative path to always use forward slashes (cross-platform). */
13
+ function toPosixPath(p) {
14
+ return p.split(path.sep).join('/');
15
+ }
16
+
17
+ /**
18
+ * Scan immediate child directories for separate git repos.
19
+ * Returns a sorted array of directory names that have their own `.git`.
20
+ * Excludes hidden directories and node_modules.
21
+ */
22
+ function detectSubRepos(cwd) {
23
+ const results = [];
24
+ try {
25
+ const entries = fs.readdirSync(cwd, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ if (!entry.isDirectory()) continue;
28
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
29
+ const gitPath = path.join(cwd, entry.name, '.git');
30
+ try {
31
+ if (fs.existsSync(gitPath)) {
32
+ results.push(entry.name);
33
+ }
34
+ } catch {}
35
+ }
36
+ } catch {}
37
+ return results.sort();
38
+ }
39
+
40
+ /**
41
+ * Walk up from `startDir` to find the project root that owns `.planning/`.
42
+ *
43
+ * In multi-repo workspaces, Claude may open inside a sub-repo (e.g. `backend/`)
44
+ * instead of the project root. This function prevents `.planning/` from being
45
+ * created inside the sub-repo by locating the nearest ancestor that already has
46
+ * a `.planning/` directory.
47
+ *
48
+ * Detection strategy (checked in order for each ancestor):
49
+ * 1. Parent has `.planning/config.json` with `sub_repos` listing this directory
50
+ * 2. Parent has `.planning/config.json` with `multiRepo: true` (legacy format)
51
+ * 3. Parent has `.planning/` and current dir has its own `.git` (heuristic)
52
+ *
53
+ * Returns `startDir` unchanged when no ancestor `.planning/` is found (first-run
54
+ * or single-repo projects).
55
+ */
56
+ function findProjectRoot(startDir) {
57
+ const resolved = path.resolve(startDir);
58
+ const root = path.parse(resolved).root;
59
+ const homedir = require('os').homedir();
60
+
61
+ // If startDir already contains .planning/, it IS the project root.
62
+ // Do not walk up to a parent workspace that also has .planning/ (#1362).
63
+ const ownPlanning = path.join(resolved, '.planning');
64
+ if (fs.existsSync(ownPlanning) && fs.statSync(ownPlanning).isDirectory()) {
65
+ return startDir;
66
+ }
67
+
68
+ // Check if startDir or any of its ancestors (up to AND including the
69
+ // candidate project root) contains a .git directory. This handles both
70
+ // `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside),
71
+ // as well as the common case where .git lives at the same level as .planning/.
72
+ function isInsideGitRepo(candidateParent) {
73
+ let d = resolved;
74
+ while (d !== root) {
75
+ if (fs.existsSync(path.join(d, '.git'))) return true;
76
+ if (d === candidateParent) break;
77
+ d = path.dirname(d);
78
+ }
79
+ return false;
80
+ }
81
+
82
+ let dir = resolved;
83
+ while (dir !== root) {
84
+ const parent = path.dirname(dir);
85
+ if (parent === dir) break; // filesystem root
86
+ if (parent === homedir) break; // never go above home
87
+
88
+ const parentPlanning = path.join(parent, '.planning');
89
+ if (fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory()) {
90
+ const configPath = path.join(parentPlanning, 'config.json');
91
+ try {
92
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
93
+ const subRepos = config.sub_repos || config.planning?.sub_repos || [];
94
+
95
+ // Check explicit sub_repos list
96
+ if (Array.isArray(subRepos) && subRepos.length > 0) {
97
+ const relPath = path.relative(parent, resolved);
98
+ const topSegment = relPath.split(path.sep)[0];
99
+ if (subRepos.includes(topSegment)) {
100
+ return parent;
101
+ }
102
+ }
103
+
104
+ // Check legacy multiRepo flag
105
+ if (config.multiRepo === true && isInsideGitRepo(parent)) {
106
+ return parent;
107
+ }
108
+ } catch {
109
+ // config.json missing or malformed — fall back to .git heuristic
110
+ }
111
+
112
+ // Heuristic: parent has .planning/ and we're inside a git repo
113
+ if (isInsideGitRepo(parent)) {
114
+ return parent;
115
+ }
116
+ }
117
+ dir = parent;
118
+ }
119
+ return startDir;
120
+ }
121
+
122
+ // ─── Output helpers ───────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Remove stale framework-* temp files/dirs older than maxAgeMs (default: 5 minutes).
126
+ * Runs opportunistically before each new temp file write to prevent unbounded accumulation.
127
+ * @param {string} prefix - filename prefix to match (e.g., 'framework-')
128
+ * @param {object} opts
129
+ * @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min)
130
+ * @param {boolean} opts.dirsOnly - if true, only remove directories (default: false)
131
+ */
132
+ function reapStaleTempFiles(prefix = 'framework-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
133
+ try {
134
+ const tmpDir = require('os').tmpdir();
135
+ const now = Date.now();
136
+ const entries = fs.readdirSync(tmpDir);
137
+ for (const entry of entries) {
138
+ if (!entry.startsWith(prefix)) continue;
139
+ const fullPath = path.join(tmpDir, entry);
140
+ try {
141
+ const stat = fs.statSync(fullPath);
142
+ if (now - stat.mtimeMs > maxAgeMs) {
143
+ if (stat.isDirectory()) {
144
+ fs.rmSync(fullPath, { recursive: true, force: true });
145
+ } else if (!dirsOnly) {
146
+ fs.unlinkSync(fullPath);
147
+ }
148
+ }
149
+ } catch {
150
+ // File may have been removed between readdir and stat — ignore
151
+ }
152
+ }
153
+ } catch {
154
+ // Non-critical — don't let cleanup failures break output
155
+ }
156
+ }
157
+
158
+ function output(result, raw, rawValue) {
159
+ let data;
160
+ if (raw && rawValue !== undefined) {
161
+ data = String(rawValue);
162
+ } else {
163
+ const json = JSON.stringify(result, null, 2);
164
+ // Large payloads exceed Claude Code's Bash tool buffer (~50KB).
165
+ // Write to tmpfile and output the path prefixed with @file: so callers can detect it.
166
+ if (json.length > 50000) {
167
+ reapStaleTempFiles();
168
+ const tmpPath = path.join(require('os').tmpdir(), `framework-${Date.now()}.json`);
169
+ fs.writeFileSync(tmpPath, json, 'utf-8');
170
+ data = '@file:' + tmpPath;
171
+ } else {
172
+ data = json;
173
+ }
174
+ }
175
+ // process.stdout.write() is async when stdout is a pipe — process.exit()
176
+ // can tear down the process before the reader consumes the buffer.
177
+ // fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
178
+ // skipping process.exit() lets the event loop drain naturally.
179
+ fs.writeSync(1, data);
180
+ }
181
+
182
+ function error(message) {
183
+ fs.writeSync(2, 'Error: ' + message + '\n');
184
+ process.exit(1);
185
+ }
186
+
187
+ // ─── File & Config utilities ──────────────────────────────────────────────────
188
+
189
+ function safeReadFile(filePath) {
190
+ try {
191
+ return fs.readFileSync(filePath, 'utf-8');
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ function loadConfig(cwd) {
198
+ const configPath = path.join(cwd, '.planning', 'config.json');
199
+ const defaults = {
200
+ model_profile: 'balanced',
201
+ commit_docs: true,
202
+ search_gitignored: false,
203
+ branching_strategy: 'none',
204
+ phase_branch_template: 'framework/phase-{phase}-{slug}',
205
+ milestone_branch_template: 'framework/{milestone}-{slug}',
206
+ quick_branch_template: null,
207
+ research: true,
208
+ plan_checker: true,
209
+ verifier: true,
210
+ nyquist_validation: true,
211
+ parallelization: true,
212
+ brave_search: false,
213
+ firecrawl: false,
214
+ exa_search: false,
215
+ text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus
216
+ sub_repos: [],
217
+ resolve_model_ids: false, // false: return alias as-is | true: map to full Claude model ID | "omit": return '' (runtime uses its default)
218
+ context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
219
+ phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
220
+ };
221
+
222
+ try {
223
+ const raw = fs.readFileSync(configPath, 'utf-8');
224
+ const parsed = JSON.parse(raw);
225
+
226
+ // Migrate deprecated "depth" key to "granularity" with value mapping
227
+ if ('depth' in parsed && !('granularity' in parsed)) {
228
+ const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
229
+ parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth;
230
+ delete parsed.depth;
231
+ try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch { /* intentionally empty */ }
232
+ }
233
+
234
+ // Auto-detect and sync sub_repos: scan for child directories with .git
235
+ let configDirty = false;
236
+
237
+ // Migrate legacy "multiRepo: true" boolean → sub_repos array
238
+ if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) {
239
+ const detected = detectSubRepos(cwd);
240
+ if (detected.length > 0) {
241
+ parsed.sub_repos = detected;
242
+ if (!parsed.planning) parsed.planning = {};
243
+ parsed.planning.commit_docs = false;
244
+ delete parsed.multiRepo;
245
+ configDirty = true;
246
+ }
247
+ }
248
+
249
+ // Keep sub_repos in sync with actual filesystem
250
+ const currentSubRepos = parsed.sub_repos || parsed.planning?.sub_repos || [];
251
+ if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
252
+ const detected = detectSubRepos(cwd);
253
+ if (detected.length > 0) {
254
+ const sorted = [...currentSubRepos].sort();
255
+ if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
256
+ parsed.sub_repos = detected;
257
+ configDirty = true;
258
+ }
259
+ }
260
+ }
261
+
262
+ // Persist sub_repos changes (migration or sync)
263
+ if (configDirty) {
264
+ try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {}
265
+ }
266
+
267
+ const get = (key, nested) => {
268
+ if (parsed[key] !== undefined) return parsed[key];
269
+ if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
270
+ return parsed[nested.section][nested.field];
271
+ }
272
+ return undefined;
273
+ };
274
+
275
+ const parallelization = (() => {
276
+ const val = get('parallelization');
277
+ if (typeof val === 'boolean') return val;
278
+ if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled;
279
+ return defaults.parallelization;
280
+ })();
281
+
282
+ return {
283
+ model_profile: get('model_profile') ?? defaults.model_profile,
284
+ commit_docs: (() => {
285
+ const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
286
+ // If explicitly set in config, respect the user's choice
287
+ if (explicit !== undefined) return explicit;
288
+ // Auto-detection: when no explicit value and .planning/ is gitignored,
289
+ // default to false instead of true
290
+ if (isGitIgnored(cwd, '.planning/')) return false;
291
+ return defaults.commit_docs;
292
+ })(),
293
+ search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
294
+ branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
295
+ phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
296
+ milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
297
+ quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
298
+ research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
299
+ plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
300
+ verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
301
+ nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
302
+ parallelization,
303
+ brave_search: get('brave_search') ?? defaults.brave_search,
304
+ firecrawl: get('firecrawl') ?? defaults.firecrawl,
305
+ exa_search: get('exa_search') ?? defaults.exa_search,
306
+ text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
307
+ sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos,
308
+ resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
309
+ context_window: get('context_window') ?? defaults.context_window,
310
+ phase_naming: get('phase_naming') ?? defaults.phase_naming,
311
+ model_overrides: parsed.model_overrides || null,
312
+ agent_skills: parsed.agent_skills || {},
313
+ };
314
+ } catch {
315
+ return defaults;
316
+ }
317
+ }
318
+
319
+ // ─── Git utilities ────────────────────────────────────────────────────────────
320
+
321
+ function isGitIgnored(cwd, targetPath) {
322
+ try {
323
+ // --no-index checks .gitignore rules regardless of whether the file is tracked.
324
+ // Without it, git check-ignore returns "not ignored" for tracked files even when
325
+ // .gitignore explicitly lists them — a common source of confusion when .planning/
326
+ // was committed before being added to .gitignore.
327
+ // Use execFileSync (array args) to prevent shell interpretation of special characters
328
+ // in file paths — avoids command injection via crafted path names.
329
+ execFileSync('git', ['check-ignore', '-q', '--no-index', '--', targetPath], {
330
+ cwd,
331
+ stdio: 'pipe',
332
+ });
333
+ return true;
334
+ } catch {
335
+ return false;
336
+ }
337
+ }
338
+
339
+ // ─── Markdown normalization ─────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Normalize markdown to fix common markdownlint violations.
343
+ * Applied at write points so framework-generated .planning/ files are IDE-friendly.
344
+ *
345
+ * Rules enforced:
346
+ * MD022 — Blank lines around headings
347
+ * MD031 — Blank lines around fenced code blocks
348
+ * MD032 — Blank lines around lists
349
+ * MD012 — No multiple consecutive blank lines (collapsed to 2 max)
350
+ * MD047 — Files end with a single newline
351
+ */
352
+ function normalizeMd(content) {
353
+ if (!content || typeof content !== 'string') return content;
354
+
355
+ // Normalize line endings to LF for consistent processing
356
+ let text = content.replace(/\r\n/g, '\n');
357
+
358
+ const lines = text.split('\n');
359
+ const result = [];
360
+
361
+ for (let i = 0; i < lines.length; i++) {
362
+ const line = lines[i];
363
+ const prev = i > 0 ? lines[i - 1] : '';
364
+ const prevTrimmed = prev.trimEnd();
365
+ const trimmed = line.trimEnd();
366
+
367
+ // MD022: Blank line before headings (skip first line and frontmatter delimiters)
368
+ if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
369
+ result.push('');
370
+ }
371
+
372
+ // MD031: Blank line before fenced code blocks
373
+ if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) {
374
+ result.push('');
375
+ }
376
+
377
+ // MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
378
+ if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
379
+ prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
380
+ prevTrimmed !== '---') {
381
+ result.push('');
382
+ }
383
+
384
+ result.push(line);
385
+
386
+ // MD022: Blank line after headings
387
+ if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) {
388
+ const next = lines[i + 1];
389
+ if (next !== undefined && next.trimEnd() !== '') {
390
+ result.push('');
391
+ }
392
+ }
393
+
394
+ // MD031: Blank line after closing fenced code blocks
395
+ if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) {
396
+ const next = lines[i + 1];
397
+ if (next !== undefined && next.trimEnd() !== '') {
398
+ result.push('');
399
+ }
400
+ }
401
+
402
+ // MD032: Blank line after last list item in a block
403
+ if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
404
+ const next = lines[i + 1];
405
+ if (next !== undefined && next.trimEnd() !== '' &&
406
+ !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
407
+ !/^\s/.test(next)) {
408
+ // Only add blank line if next line is not a continuation/indented line
409
+ result.push('');
410
+ }
411
+ }
412
+ }
413
+
414
+ text = result.join('\n');
415
+
416
+ // MD012: Collapse 3+ consecutive blank lines to 2
417
+ text = text.replace(/\n{3,}/g, '\n\n');
418
+
419
+ // MD047: Ensure file ends with exactly one newline
420
+ text = text.replace(/\n*$/, '\n');
421
+
422
+ return text;
423
+ }
424
+
425
+ /** Check if line index i is inside an already-open fenced code block */
426
+ function isInsideFencedBlock(lines, i) {
427
+ let fenceCount = 0;
428
+ for (let j = 0; j < i; j++) {
429
+ if (/^```/.test(lines[j].trimEnd())) fenceCount++;
430
+ }
431
+ return fenceCount % 2 === 1;
432
+ }
433
+
434
+ /** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */
435
+ function isClosingFence(lines, i) {
436
+ let fenceCount = 0;
437
+ for (let j = 0; j <= i; j++) {
438
+ if (/^```/.test(lines[j].trimEnd())) fenceCount++;
439
+ }
440
+ return fenceCount % 2 === 0;
441
+ }
442
+
443
+ function execGit(cwd, args) {
444
+ const result = spawnSync('git', args, {
445
+ cwd,
446
+ stdio: 'pipe',
447
+ encoding: 'utf-8',
448
+ });
449
+ return {
450
+ exitCode: result.status ?? 1,
451
+ stdout: (result.stdout ?? '').toString().trim(),
452
+ stderr: (result.stderr ?? '').toString().trim(),
453
+ };
454
+ }
455
+
456
+ // ─── Common path helpers ──────────────────────────────────────────────────────
457
+
458
+ /**
459
+ * Resolve the main worktree root when running inside a git worktree.
460
+ * In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
461
+ * Returns the main worktree path, or cwd if not in a worktree.
462
+ */
463
+ function resolveWorktreeRoot(cwd) {
464
+ // If the current directory already has its own .planning/, respect it.
465
+ // This handles linked worktrees with independent planning state (e.g., Conductor workspaces).
466
+ if (fs.existsSync(path.join(cwd, '.planning'))) {
467
+ return cwd;
468
+ }
469
+
470
+ // Check if we're in a linked worktree
471
+ const gitDir = execGit(cwd, ['rev-parse', '--git-dir']);
472
+ const commonDir = execGit(cwd, ['rev-parse', '--git-common-dir']);
473
+
474
+ if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return cwd;
475
+
476
+ // In a linked worktree, .git is a file pointing to .git/worktrees/<name>
477
+ // and git-common-dir points to the main repo's .git directory
478
+ const gitDirResolved = path.resolve(cwd, gitDir.stdout);
479
+ const commonDirResolved = path.resolve(cwd, commonDir.stdout);
480
+
481
+ if (gitDirResolved !== commonDirResolved) {
482
+ // We're in a linked worktree — resolve main worktree root
483
+ // The common dir is the main repo's .git, so its parent is the main worktree root
484
+ return path.dirname(commonDirResolved);
485
+ }
486
+
487
+ return cwd;
488
+ }
489
+
490
+ /**
491
+ * Acquire a file-based lock for .planning/ writes.
492
+ * Prevents concurrent worktrees from corrupting shared planning files.
493
+ * Lock is auto-released after the callback completes.
494
+ */
495
+ function withPlanningLock(cwd, fn) {
496
+ const lockPath = path.join(planningDir(cwd), '.lock');
497
+ const lockTimeout = 10000; // 10 seconds
498
+ const retryDelay = 100;
499
+ const start = Date.now();
500
+
501
+ // Ensure .planning/ exists
502
+ try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ }
503
+
504
+ while (Date.now() - start < lockTimeout) {
505
+ try {
506
+ // Atomic create — fails if file exists
507
+ fs.writeFileSync(lockPath, JSON.stringify({
508
+ pid: process.pid,
509
+ cwd,
510
+ acquired: new Date().toISOString(),
511
+ }), { flag: 'wx' });
512
+
513
+ // Lock acquired — run the function
514
+ try {
515
+ return fn();
516
+ } finally {
517
+ try { fs.unlinkSync(lockPath); } catch { /* already released */ }
518
+ }
519
+ } catch (err) {
520
+ if (err.code === 'EEXIST') {
521
+ // Lock exists — check if stale (>30s old)
522
+ try {
523
+ const stat = fs.statSync(lockPath);
524
+ if (Date.now() - stat.mtimeMs > 30000) {
525
+ fs.unlinkSync(lockPath);
526
+ continue; // retry
527
+ }
528
+ } catch { continue; }
529
+
530
+ // Wait and retry
531
+ spawnSync('sleep', ['0.1'], { stdio: 'ignore' });
532
+ continue;
533
+ }
534
+ throw err;
535
+ }
536
+ }
537
+ // Timeout — force acquire (stale lock recovery)
538
+ try { fs.unlinkSync(lockPath); } catch { /* ok */ }
539
+ return fn();
540
+ }
541
+
542
+ /**
543
+ * Get the .planning directory path, workstream-aware.
544
+ * When a workstream is active (via explicit ws arg or WORKSTREAM env var),
545
+ * returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`.
546
+ *
547
+ * @param {string} cwd - project root
548
+ * @param {string} [ws] - explicit workstream name; if omitted, checks WORKSTREAM env var
549
+ */
550
+ function planningDir(cwd, ws) {
551
+ if (ws === undefined) ws = process.env.WORKSTREAM || null;
552
+ if (!ws) return path.join(cwd, '.planning');
553
+ return path.join(cwd, '.planning', 'workstreams', ws);
554
+ }
555
+
556
+ /** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */
557
+ function planningRoot(cwd) {
558
+ return path.join(cwd, '.planning');
559
+ }
560
+
561
+ /**
562
+ * Get common .planning file paths, workstream-aware.
563
+ * Scoped paths (state, roadmap, phases, requirements) resolve to the active workstream.
564
+ * Shared paths (project, config) always resolve to the root .planning/.
565
+ */
566
+ function planningPaths(cwd, ws) {
567
+ const base = planningDir(cwd, ws);
568
+ const root = path.join(cwd, '.planning');
569
+ return {
570
+ planning: base,
571
+ state: path.join(base, 'STATE.md'),
572
+ roadmap: path.join(base, 'ROADMAP.md'),
573
+ project: path.join(root, 'PROJECT.md'),
574
+ config: path.join(root, 'config.json'),
575
+ phases: path.join(base, 'phases'),
576
+ requirements: path.join(base, 'REQUIREMENTS.md'),
577
+ };
578
+ }
579
+
580
+ // ─── Active Workstream Detection ─────────────────────────────────────────────
581
+
582
+ /**
583
+ * Get the active workstream name from .planning/active-workstream file.
584
+ * Returns null if no active workstream or file doesn't exist.
585
+ */
586
+ function getActiveWorkstream(cwd) {
587
+ const filePath = path.join(planningRoot(cwd), 'active-workstream');
588
+ try {
589
+ const name = fs.readFileSync(filePath, 'utf-8').trim();
590
+ if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) return null;
591
+ const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
592
+ if (!fs.existsSync(wsDir)) return null;
593
+ return name;
594
+ } catch {
595
+ return null;
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Set the active workstream. Pass null to clear.
601
+ */
602
+ function setActiveWorkstream(cwd, name) {
603
+ const filePath = path.join(planningRoot(cwd), 'active-workstream');
604
+ if (!name) {
605
+ try { fs.unlinkSync(filePath); } catch {}
606
+ return;
607
+ }
608
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
609
+ throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
610
+ }
611
+ fs.writeFileSync(filePath, name + '\n', 'utf-8');
612
+ }
613
+
614
+ // ─── Phase utilities ──────────────────────────────────────────────────────────
615
+
616
+ function escapeRegex(value) {
617
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
618
+ }
619
+
620
+ function normalizePhaseName(phase) {
621
+ const str = String(phase);
622
+ // Standard numeric phases: 1, 01, 12A, 12.1
623
+ const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
624
+ if (match) {
625
+ const padded = match[1].padStart(2, '0');
626
+ const letter = match[2] ? match[2].toUpperCase() : '';
627
+ const decimal = match[3] || '';
628
+ return padded + letter + decimal;
629
+ }
630
+ // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
631
+ return str;
632
+ }
633
+
634
+ function comparePhaseNum(a, b) {
635
+ const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
636
+ const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
637
+ // If either is non-numeric (custom ID), fall back to string comparison
638
+ if (!pa || !pb) return String(a).localeCompare(String(b));
639
+ const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
640
+ if (intDiff !== 0) return intDiff;
641
+ // No letter sorts before letter: 12 < 12A < 12B
642
+ const la = (pa[2] || '').toUpperCase();
643
+ const lb = (pb[2] || '').toUpperCase();
644
+ if (la !== lb) {
645
+ if (!la) return -1;
646
+ if (!lb) return 1;
647
+ return la < lb ? -1 : 1;
648
+ }
649
+ // Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2
650
+ const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
651
+ const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
652
+ const maxLen = Math.max(aDecParts.length, bDecParts.length);
653
+ if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
654
+ if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
655
+ for (let i = 0; i < maxLen; i++) {
656
+ const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
657
+ const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
658
+ if (av !== bv) return av - bv;
659
+ }
660
+ return 0;
661
+ }
662
+
663
+ function searchPhaseInDir(baseDir, relBase, normalized) {
664
+ try {
665
+ const dirs = readSubdirectories(baseDir, true);
666
+ // Match: starts with normalized (numeric) OR contains normalized as prefix segment (custom ID)
667
+ const match = dirs.find(d => {
668
+ if (d.startsWith(normalized)) return true;
669
+ // For custom IDs like PROJ-42, match case-insensitively
670
+ if (d.toUpperCase().startsWith(normalized.toUpperCase())) return true;
671
+ return false;
672
+ });
673
+ if (!match) return null;
674
+
675
+ // Extract phase number and name — supports both numeric (01-name) and custom (PROJ-42-name)
676
+ const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
677
+ || match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
678
+ || [null, match, null];
679
+ const phaseNumber = dirMatch ? dirMatch[1] : normalized;
680
+ const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
681
+ const phaseDir = path.join(baseDir, match);
682
+ const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
683
+ const plans = unsortedPlans.sort();
684
+ const summaries = unsortedSummaries.sort();
685
+
686
+ const completedPlanIds = new Set(
687
+ summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
688
+ );
689
+ const incompletePlans = plans.filter(p => {
690
+ const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
691
+ return !completedPlanIds.has(planId);
692
+ });
693
+
694
+ return {
695
+ found: true,
696
+ directory: toPosixPath(path.join(relBase, match)),
697
+ phase_number: phaseNumber,
698
+ phase_name: phaseName,
699
+ phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
700
+ plans,
701
+ summaries,
702
+ incomplete_plans: incompletePlans,
703
+ has_research: hasResearch,
704
+ has_context: hasContext,
705
+ has_verification: hasVerification,
706
+ has_reviews: hasReviews,
707
+ };
708
+ } catch {
709
+ return null;
710
+ }
711
+ }
712
+
713
+ function findPhaseInternal(cwd, phase) {
714
+ if (!phase) return null;
715
+
716
+ const phasesDir = path.join(planningDir(cwd), 'phases');
717
+ const normalized = normalizePhaseName(phase);
718
+
719
+ // Search current phases first
720
+ const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
721
+ const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
722
+ if (current) return current;
723
+
724
+ // Search archived milestone phases (newest first)
725
+ const milestonesDir = path.join(cwd, '.planning', 'milestones');
726
+ if (!fs.existsSync(milestonesDir)) return null;
727
+
728
+ try {
729
+ const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
730
+ const archiveDirs = milestoneEntries
731
+ .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
732
+ .map(e => e.name)
733
+ .sort()
734
+ .reverse();
735
+
736
+ for (const archiveName of archiveDirs) {
737
+ const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
738
+ const archivePath = path.join(milestonesDir, archiveName);
739
+ const relBase = '.planning/milestones/' + archiveName;
740
+ const result = searchPhaseInDir(archivePath, relBase, normalized);
741
+ if (result) {
742
+ result.archived = version;
743
+ return result;
744
+ }
745
+ }
746
+ } catch { /* intentionally empty */ }
747
+
748
+ return null;
749
+ }
750
+
751
+ function getArchivedPhaseDirs(cwd) {
752
+ const milestonesDir = path.join(cwd, '.planning', 'milestones');
753
+ const results = [];
754
+
755
+ if (!fs.existsSync(milestonesDir)) return results;
756
+
757
+ try {
758
+ const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
759
+ // Find v*-phases directories, sort newest first
760
+ const phaseDirs = milestoneEntries
761
+ .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
762
+ .map(e => e.name)
763
+ .sort()
764
+ .reverse();
765
+
766
+ for (const archiveName of phaseDirs) {
767
+ const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
768
+ const archivePath = path.join(milestonesDir, archiveName);
769
+ const dirs = readSubdirectories(archivePath, true);
770
+
771
+ for (const dir of dirs) {
772
+ results.push({
773
+ name: dir,
774
+ milestone: version,
775
+ basePath: path.join('.planning', 'milestones', archiveName),
776
+ fullPath: path.join(archivePath, dir),
777
+ });
778
+ }
779
+ }
780
+ } catch { /* intentionally empty */ }
781
+
782
+ return results;
783
+ }
784
+
785
+ // ─── Roadmap milestone scoping ───────────────────────────────────────────────
786
+
787
+ /**
788
+ * Strip shipped milestone content wrapped in <details> blocks.
789
+ * Used to isolate current milestone phases when searching ROADMAP.md
790
+ * for phase headings or checkboxes — prevents matching archived milestone
791
+ * phases that share the same numbers as current milestone phases.
792
+ */
793
+ function stripShippedMilestones(content) {
794
+ return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
795
+ }
796
+
797
+ /**
798
+ * Extract the current milestone section from ROADMAP.md by positive lookup.
799
+ *
800
+ * Instead of stripping <details> blocks (negative heuristic that breaks if
801
+ * agents wrap the current milestone in <details>), this finds the section
802
+ * matching the current milestone version and returns only that content.
803
+ *
804
+ * Falls back to stripShippedMilestones() if:
805
+ * - cwd is not provided
806
+ * - STATE.md doesn't exist or has no milestone field
807
+ * - Version can't be found in ROADMAP.md
808
+ *
809
+ * @param {string} content - Full ROADMAP.md content
810
+ * @param {string} [cwd] - Working directory for reading STATE.md
811
+ * @returns {string} Content scoped to current milestone
812
+ */
813
+ function extractCurrentMilestone(content, cwd) {
814
+ if (!cwd) return stripShippedMilestones(content);
815
+
816
+ // 1. Get current milestone version from STATE.md frontmatter
817
+ let version = null;
818
+ try {
819
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
820
+ if (fs.existsSync(statePath)) {
821
+ const stateRaw = fs.readFileSync(statePath, 'utf-8');
822
+ const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
823
+ if (milestoneMatch) {
824
+ version = milestoneMatch[1].trim();
825
+ }
826
+ }
827
+ } catch {}
828
+
829
+ // 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself
830
+ if (!version) {
831
+ // Check for 🚧 in-progress marker
832
+ const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
833
+ if (inProgressMatch) {
834
+ version = 'v' + inProgressMatch[1];
835
+ }
836
+ }
837
+
838
+ if (!version) return stripShippedMilestones(content);
839
+
840
+ // 3. Find the section matching this version
841
+ // Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc.
842
+ const escapedVersion = escapeRegex(version);
843
+ const sectionPattern = new RegExp(
844
+ `(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`,
845
+ 'mi'
846
+ );
847
+ const sectionMatch = content.match(sectionPattern);
848
+
849
+ if (!sectionMatch) return stripShippedMilestones(content);
850
+
851
+ const sectionStart = sectionMatch.index;
852
+
853
+ // Find the end: next milestone heading at same or higher level, or EOF
854
+ // Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc.
855
+ const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
856
+ const restContent = content.slice(sectionStart + sectionMatch[0].length);
857
+ const nextMilestonePattern = new RegExp(
858
+ `^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
859
+ 'mi'
860
+ );
861
+ const nextMatch = restContent.match(nextMilestonePattern);
862
+
863
+ let sectionEnd;
864
+ if (nextMatch) {
865
+ sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index;
866
+ } else {
867
+ sectionEnd = content.length;
868
+ }
869
+
870
+ // Return everything before the current milestone section (non-milestone content
871
+ // like title, overview) plus the current milestone section
872
+ const beforeMilestones = content.slice(0, sectionStart);
873
+ const currentSection = content.slice(sectionStart, sectionEnd);
874
+
875
+ // Also include any content before the first milestone heading (title, overview, etc.)
876
+ // but strip any <details> blocks in it (these are definitely shipped)
877
+ const preamble = beforeMilestones.replace(/<details>[\s\S]*?<\/details>/gi, '');
878
+
879
+ return preamble + currentSection;
880
+ }
881
+
882
+ /**
883
+ * Replace a pattern only in the current milestone section of ROADMAP.md
884
+ * (everything after the last </details> close tag). Used for write operations
885
+ * that must not accidentally modify archived milestone checkboxes/tables.
886
+ */
887
+ function replaceInCurrentMilestone(content, pattern, replacement) {
888
+ const lastDetailsClose = content.lastIndexOf('</details>');
889
+ if (lastDetailsClose === -1) {
890
+ return content.replace(pattern, replacement);
891
+ }
892
+ const offset = lastDetailsClose + '</details>'.length;
893
+ const before = content.slice(0, offset);
894
+ const after = content.slice(offset);
895
+ return before + after.replace(pattern, replacement);
896
+ }
897
+
898
+ // ─── Roadmap & model utilities ────────────────────────────────────────────────
899
+
900
+ function getRoadmapPhaseInternal(cwd, phaseNum) {
901
+ if (!phaseNum) return null;
902
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
903
+ if (!fs.existsSync(roadmapPath)) return null;
904
+
905
+ try {
906
+ const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
907
+ const escapedPhase = escapeRegex(phaseNum.toString());
908
+ // Match both numeric (Phase 1:) and custom (Phase PROJ-42:) headers
909
+ const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
910
+ const headerMatch = content.match(phasePattern);
911
+ if (!headerMatch) return null;
912
+
913
+ const phaseName = headerMatch[1].trim();
914
+ const headerIndex = headerMatch.index;
915
+ const restOfContent = content.slice(headerIndex);
916
+ const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i);
917
+ const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
918
+ const section = content.slice(headerIndex, sectionEnd).trim();
919
+
920
+ const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
921
+ const goal = goalMatch ? goalMatch[1].trim() : null;
922
+
923
+ return {
924
+ found: true,
925
+ phase_number: phaseNum.toString(),
926
+ phase_name: phaseName,
927
+ goal,
928
+ section,
929
+ };
930
+ } catch {
931
+ return null;
932
+ }
933
+ }
934
+
935
+ // ─── Agent installation validation (#1371) ───────────────────────────────────
936
+
937
+ /**
938
+ * Resolve the agents directory from the framework install location.
939
+ * tools.cjs lives at <configDir>/framework/bin/tools.cjs,
940
+ * so agents/ is at <configDir>/agents/.
941
+ *
942
+ * @returns {string} Absolute path to the agents directory
943
+ */
944
+ function getAgentsDir() {
945
+ // __dirname is framework/bin/lib/ → go up 3 levels to configDir
946
+ return path.join(__dirname, '..', '..', '..', 'agents');
947
+ }
948
+
949
+ /**
950
+ * Check which framework agents are installed on disk.
951
+ * Returns an object with installation status and details.
952
+ *
953
+ * @returns {{ agents_installed: boolean, missing_agents: string[], installed_agents: string[], agents_dir: string }}
954
+ */
955
+ function checkAgentsInstalled() {
956
+ const agentsDir = getAgentsDir();
957
+ const expectedAgents = Object.keys(MODEL_PROFILES);
958
+ const installed = [];
959
+ const missing = [];
960
+
961
+ if (!fs.existsSync(agentsDir)) {
962
+ return {
963
+ agents_installed: false,
964
+ missing_agents: expectedAgents,
965
+ installed_agents: [],
966
+ agents_dir: agentsDir,
967
+ };
968
+ }
969
+
970
+ for (const agent of expectedAgents) {
971
+ const agentFile = path.join(agentsDir, `${agent}.md`);
972
+ if (fs.existsSync(agentFile)) {
973
+ installed.push(agent);
974
+ } else {
975
+ missing.push(agent);
976
+ }
977
+ }
978
+
979
+ return {
980
+ agents_installed: installed.length > 0 && missing.length === 0,
981
+ missing_agents: missing,
982
+ installed_agents: installed,
983
+ agents_dir: agentsDir,
984
+ };
985
+ }
986
+
987
+ // ─── Model alias resolution ───────────────────────────────────────────────────
988
+
989
+ /**
990
+ * Map short model aliases to full model IDs.
991
+ * Updated each release to match current model versions.
992
+ * Users can override with model_overrides in config.json for custom/latest models.
993
+ */
994
+ const MODEL_ALIAS_MAP = {
995
+ 'opus': 'claude-opus-4-0',
996
+ 'sonnet': 'claude-sonnet-4-5',
997
+ 'haiku': 'claude-haiku-3-5',
998
+ };
999
+
1000
+ function resolveModelInternal(cwd, agentType) {
1001
+ const config = loadConfig(cwd);
1002
+
1003
+ // Check per-agent override first — always respected regardless of resolve_model_ids.
1004
+ // Users who set fully-qualified model IDs (e.g., "openai/gpt-5.4") get exactly that.
1005
+ const override = config.model_overrides?.[agentType];
1006
+ if (override) {
1007
+ return override;
1008
+ }
1009
+
1010
+ // resolve_model_ids: "omit" — return empty string so the runtime uses its configured
1011
+ // default model. For non-Claude runtimes (OpenCode, Codex, etc.) that don't recognize
1012
+ // Claude aliases (opus/sonnet/haiku/inherit). Set automatically during install. See #1156.
1013
+ if (config.resolve_model_ids === 'omit') {
1014
+ return '';
1015
+ }
1016
+
1017
+ // Fall back to profile lookup
1018
+ const profile = String(config.model_profile || 'balanced').toLowerCase();
1019
+ const agentModels = MODEL_PROFILES[agentType];
1020
+ if (!agentModels) return 'sonnet';
1021
+ if (profile === 'inherit') return 'inherit';
1022
+ const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet';
1023
+
1024
+ // resolve_model_ids: true — map alias to full Claude model ID
1025
+ // Prevents 404s when the Task tool passes aliases directly to the API
1026
+ if (config.resolve_model_ids) {
1027
+ return MODEL_ALIAS_MAP[alias] || alias;
1028
+ }
1029
+
1030
+ return alias;
1031
+ }
1032
+
1033
+ // ─── Summary body helpers ─────────────────────────────────────────────────
1034
+
1035
+ /**
1036
+ * Extract a one-liner from the summary body when it's not in frontmatter.
1037
+ * The summary template defines one-liner as a bold markdown line after the heading:
1038
+ * # Phase X: Name Summary
1039
+ * **[substantive one-liner text]**
1040
+ */
1041
+ function extractOneLinerFromBody(content) {
1042
+ if (!content) return null;
1043
+ // Strip frontmatter first
1044
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
1045
+ // Find the first **...** line after a # heading
1046
+ const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
1047
+ return match ? match[1].trim() : null;
1048
+ }
1049
+
1050
+ // ─── Misc utilities ───────────────────────────────────────────────────────────
1051
+
1052
+ function pathExistsInternal(cwd, targetPath) {
1053
+ const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
1054
+ try {
1055
+ fs.statSync(fullPath);
1056
+ return true;
1057
+ } catch {
1058
+ return false;
1059
+ }
1060
+ }
1061
+
1062
+ function generateSlugInternal(text) {
1063
+ if (!text) return null;
1064
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1065
+ }
1066
+
1067
+ function getMilestoneInfo(cwd) {
1068
+ try {
1069
+ const roadmap = fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8');
1070
+
1071
+ // First: check for list-format roadmaps using 🚧 (in-progress) marker
1072
+ // e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)"
1073
+ // e.g. "- 🚧 **v1.2.1 Tech Debt** — Phases 1-8 (in progress)"
1074
+ const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
1075
+ if (inProgressMatch) {
1076
+ return {
1077
+ version: 'v' + inProgressMatch[1],
1078
+ name: inProgressMatch[2].trim(),
1079
+ };
1080
+ }
1081
+
1082
+ // Second: heading-format roadmaps — strip shipped milestones in <details> blocks
1083
+ const cleaned = stripShippedMilestones(roadmap);
1084
+ // Extract version and name from the same ## heading for consistency
1085
+ // Supports 2+ segment versions: v1.2, v1.2.1, v2.0.1, etc.
1086
+ const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
1087
+ if (headingMatch) {
1088
+ return {
1089
+ version: 'v' + headingMatch[1],
1090
+ name: headingMatch[2].trim(),
1091
+ };
1092
+ }
1093
+ // Fallback: try bare version match (greedy — capture longest version string)
1094
+ const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
1095
+ return {
1096
+ version: versionMatch ? versionMatch[0] : 'v1.0',
1097
+ name: 'milestone',
1098
+ };
1099
+ } catch {
1100
+ return { version: 'v1.0', name: 'milestone' };
1101
+ }
1102
+ }
1103
+
1104
+ /**
1105
+ * Returns a filter function that checks whether a phase directory belongs
1106
+ * to the current milestone based on ROADMAP.md phase headings.
1107
+ * If no ROADMAP exists or no phases are listed, returns a pass-all filter.
1108
+ */
1109
+ function getMilestonePhaseFilter(cwd) {
1110
+ const milestonePhaseNums = new Set();
1111
+ try {
1112
+ const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
1113
+ // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
1114
+ const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1115
+ let m;
1116
+ while ((m = phasePattern.exec(roadmap)) !== null) {
1117
+ milestonePhaseNums.add(m[1]);
1118
+ }
1119
+ } catch { /* intentionally empty */ }
1120
+
1121
+ if (milestonePhaseNums.size === 0) {
1122
+ const passAll = () => true;
1123
+ passAll.phaseCount = 0;
1124
+ return passAll;
1125
+ }
1126
+
1127
+ const normalized = new Set(
1128
+ [...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase())
1129
+ );
1130
+
1131
+ function isDirInMilestone(dirName) {
1132
+ // Try numeric match first
1133
+ const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1134
+ if (m && normalized.has(m[1].toLowerCase())) return true;
1135
+ // Try custom ID match (e.g. PROJ-42-description → PROJ-42)
1136
+ const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
1137
+ if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
1138
+ return false;
1139
+ }
1140
+ isDirInMilestone.phaseCount = milestonePhaseNums.size;
1141
+ return isDirInMilestone;
1142
+ }
1143
+
1144
+ // ─── Phase file helpers ──────────────────────────────────────────────────────
1145
+
1146
+ /** Filter a file list to just PLAN.md / *-PLAN.md entries. */
1147
+ function filterPlanFiles(files) {
1148
+ return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
1149
+ }
1150
+
1151
+ /** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
1152
+ function filterSummaryFiles(files) {
1153
+ return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1154
+ }
1155
+
1156
+ /**
1157
+ * Read a phase directory and return counts/flags for common file types.
1158
+ * Returns an object with plans[], summaries[], and boolean flags for
1159
+ * research/context/verification files.
1160
+ */
1161
+ function getPhaseFileStats(phaseDir) {
1162
+ const files = fs.readdirSync(phaseDir);
1163
+ return {
1164
+ plans: filterPlanFiles(files),
1165
+ summaries: filterSummaryFiles(files),
1166
+ hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
1167
+ hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'),
1168
+ hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
1169
+ hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
1170
+ };
1171
+ }
1172
+
1173
+ /**
1174
+ * Read immediate child directories from a path.
1175
+ * Returns [] if the path doesn't exist or can't be read.
1176
+ * Pass sort=true to apply comparePhaseNum ordering.
1177
+ */
1178
+ function readSubdirectories(dirPath, sort = false) {
1179
+ try {
1180
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1181
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1182
+ return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
1183
+ } catch {
1184
+ return [];
1185
+ }
1186
+ }
1187
+
1188
+ module.exports = {
1189
+ output,
1190
+ error,
1191
+ safeReadFile,
1192
+ loadConfig,
1193
+ isGitIgnored,
1194
+ execGit,
1195
+ normalizeMd,
1196
+ escapeRegex,
1197
+ normalizePhaseName,
1198
+ comparePhaseNum,
1199
+ searchPhaseInDir,
1200
+ findPhaseInternal,
1201
+ getArchivedPhaseDirs,
1202
+ getRoadmapPhaseInternal,
1203
+ resolveModelInternal,
1204
+ pathExistsInternal,
1205
+ generateSlugInternal,
1206
+ getMilestoneInfo,
1207
+ getMilestonePhaseFilter,
1208
+ stripShippedMilestones,
1209
+ extractCurrentMilestone,
1210
+ replaceInCurrentMilestone,
1211
+ toPosixPath,
1212
+ extractOneLinerFromBody,
1213
+ resolveWorktreeRoot,
1214
+ withPlanningLock,
1215
+ findProjectRoot,
1216
+ detectSubRepos,
1217
+ reapStaleTempFiles,
1218
+ MODEL_ALIAS_MAP,
1219
+ planningDir,
1220
+ planningRoot,
1221
+ planningPaths,
1222
+ getActiveWorkstream,
1223
+ setActiveWorkstream,
1224
+ filterPlanFiles,
1225
+ filterSummaryFiles,
1226
+ getPhaseFileStats,
1227
+ readSubdirectories,
1228
+ getAgentsDir,
1229
+ checkAgentsInstalled,
1230
+ };