@polymorphism-tech/morph-spec 9.0.0 → 9.2.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 (345) hide show
  1. package/CLAUDE.md +8 -9
  2. package/README.md +38 -36
  3. package/bin/morph-spec.js +11 -63
  4. package/bin/validate.js +1 -39
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +2 -1
  9. package/framework/MORPH.md +3 -3
  10. package/framework/agents.json +19 -11
  11. package/framework/commands/morph-apply.md +1 -1
  12. package/framework/commands/morph-status.md +1 -1
  13. package/framework/docs/CLI.md +22 -31
  14. package/framework/evals/review-checklist.md +12 -2
  15. package/framework/hooks/README.md +8 -44
  16. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
  17. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
  18. package/framework/hooks/claude-code/core/chain.js +4 -65
  19. package/framework/hooks/claude-code/core/comment-guard.js +2 -16
  20. package/framework/hooks/claude-code/core/enforce-phase-writes.js +6 -50
  21. package/framework/hooks/claude-code/core/gate-guard.js +7 -67
  22. package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
  23. package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
  24. package/framework/hooks/claude-code/core/heavy-release.js +2 -13
  25. package/framework/hooks/claude-code/core/loop-detect.js +2 -31
  26. package/framework/hooks/claude-code/core/pre-bash-denylist.js +0 -4
  27. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -67
  28. package/framework/hooks/claude-code/core/state-sync.js +12 -68
  29. package/framework/hooks/claude-code/core/telemetry-log.js +12 -93
  30. package/framework/hooks/claude-code/core/validate-completion.js +11 -57
  31. package/framework/hooks/claude-code/core/warn-bash-source-write.js +1 -9
  32. package/framework/hooks/claude-code/core/worktree-context.js +10 -110
  33. package/framework/hooks/claude-code/core/worktree-create.js +6 -70
  34. package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
  35. package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
  36. package/framework/hooks/claude-code/statusline.sh +11 -11
  37. package/framework/hooks/shared/activity-logger.js +9 -89
  38. package/framework/hooks/shared/agent-reap.js +2 -17
  39. package/framework/hooks/shared/build-check.js +6 -75
  40. package/framework/hooks/shared/claim-registry.js +9 -119
  41. package/framework/hooks/shared/command-tokens.js +6 -51
  42. package/framework/hooks/shared/comment-rules.js +5 -20
  43. package/framework/hooks/shared/comment-scanner.js +6 -25
  44. package/framework/hooks/shared/comment-stripper.js +11 -85
  45. package/framework/hooks/shared/e2e-check.js +0 -0
  46. package/framework/hooks/shared/errata.js +2 -15
  47. package/framework/hooks/shared/feature-locator.js +11 -95
  48. package/framework/hooks/shared/frontend-locations.js +1 -3
  49. package/framework/hooks/shared/gate-question.js +1 -7
  50. package/framework/hooks/shared/git-exclude.js +3 -20
  51. package/framework/hooks/shared/git-paths.js +8 -104
  52. package/framework/hooks/shared/graph-freshness.js +22 -166
  53. package/framework/hooks/shared/graph-refresh.js +21 -149
  54. package/framework/hooks/shared/heavy-command.js +4 -12
  55. package/framework/hooks/shared/hook-response.js +8 -83
  56. package/framework/hooks/shared/morph-cli.js +8 -52
  57. package/framework/hooks/shared/payload-utils.js +10 -132
  58. package/framework/hooks/shared/phase-utils.js +1 -42
  59. package/framework/hooks/shared/redact-secrets.js +4 -15
  60. package/framework/hooks/shared/reparse-scan.js +6 -66
  61. package/framework/hooks/shared/slice-progress.js +4 -23
  62. package/framework/hooks/shared/source-files.js +4 -17
  63. package/framework/hooks/shared/state-reader.js +13 -183
  64. package/framework/hooks/shared/state-writer.js +8 -52
  65. package/framework/hooks/shared/stdin-reader.js +1 -11
  66. package/framework/hooks/shared/telemetry-logger.js +7 -67
  67. package/framework/hooks/shared/transcript-usage.js +7 -29
  68. package/framework/hooks/shared/worktree-root.js +7 -29
  69. package/framework/rules/csharp-standards.md +1 -1
  70. package/framework/rules/frontend-standards.md +1 -0
  71. package/framework/rules/hangfire-standards.md +1 -1
  72. package/framework/rules/neon-standards.md +1 -1
  73. package/framework/rules/nextjs-standards.md +1 -1
  74. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  75. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  76. package/framework/skills/core/morph-apply/SKILL.md +4 -4
  77. package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
  78. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  79. package/framework/skills/core/morph-media/SKILL.md +1 -1
  80. package/framework/skills/core/morph-plan/SKILL.md +9 -7
  81. package/framework/skills/core/morph-review/SKILL.md +9 -6
  82. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  83. package/framework/skills/core/morph-uiux/SKILL.md +29 -12
  84. package/framework/standards/STANDARDS.json +36 -14
  85. package/framework/standards/STANDARDS.meta.json +16 -2
  86. package/framework/standards/ai-agents/agent-archetypes.md +2 -2
  87. package/framework/standards/ai-agents/agent-session.md +1 -1
  88. package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
  89. package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
  90. package/framework/standards/ai-agents/guardrails.md +1 -1
  91. package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
  92. package/framework/standards/ai-agents/observability-patterns.md +1 -1
  93. package/framework/standards/ai-agents/production.md +1 -1
  94. package/framework/standards/ai-agents/prompt-sources.md +2 -2
  95. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  96. package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
  97. package/framework/standards/ai-agents/setup.md +2 -2
  98. package/framework/standards/ai-agents/sweet-spot.md +2 -2
  99. package/framework/standards/ai-agents/tool-adapters.md +0 -2
  100. package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
  101. package/framework/standards/ai-agents/workflows.md +1 -1
  102. package/framework/standards/architecture/code-comments.md +1 -1
  103. package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
  104. package/framework/standards/backend/api/validation.md +1 -1
  105. package/framework/standards/backend/dotnet/testing.md +10 -4
  106. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
  107. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
  108. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
  109. package/framework/standards/data/nosql/cache/redis.md +1 -1
  110. package/framework/standards/data/nosql/cosmos-db.md +3 -3
  111. package/framework/standards/frontend/admin-app/forms.md +1 -1
  112. package/framework/standards/frontend/design-system/art-direction.md +88 -0
  113. package/framework/standards/frontend/design-system/premium-finish.md +77 -2
  114. package/framework/standards/frontend/nextjs/testing.md +10 -6
  115. package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
  116. package/framework/templates/.idea/morph-templates.xml +92 -0
  117. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  118. package/framework/templates/CLAUDE.md.core.template +3 -3
  119. package/framework/templates/IDE-SNIPPETS.md +262 -262
  120. package/framework/templates/REGISTRY.json +9 -9
  121. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  122. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  123. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  124. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  125. package/framework/templates/docs/plan.md +3 -1
  126. package/framework/templates/docs/ui-mockups.md +4 -0
  127. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  128. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  129. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  130. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  131. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  132. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  133. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  134. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  135. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  136. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  137. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  138. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  139. package/framework/templates/state.template.json +17 -17
  140. package/framework/templates/ui/design-system.css +226 -226
  141. package/package.json +1 -1
  142. package/src/commands/context/context-refresh.js +0 -16
  143. package/src/commands/dashboard/dashboard.js +2 -8
  144. package/src/commands/e2e/e2e.js +17 -104
  145. package/src/commands/graph/affected.js +0 -14
  146. package/src/commands/graph/build.js +11 -50
  147. package/src/commands/graph/explain.js +0 -16
  148. package/src/commands/graph/status.js +1 -12
  149. package/src/commands/mcp/mcp-setup.js +4 -62
  150. package/src/commands/observability/cost.js +5 -57
  151. package/src/commands/observability/telemetry.js +1 -14
  152. package/src/commands/project/branch.js +4 -45
  153. package/src/commands/project/claim.js +4 -48
  154. package/src/commands/project/doctor.js +48 -302
  155. package/src/commands/project/env.js +1 -27
  156. package/src/commands/project/fleet.js +3 -32
  157. package/src/commands/project/init.js +4 -68
  158. package/src/commands/project/install-plugin-cmd.js +0 -1
  159. package/src/commands/project/standards.js +1 -62
  160. package/src/commands/project/status.js +0 -53
  161. package/src/commands/project/update.js +31 -239
  162. package/src/commands/project/worktree.js +108 -882
  163. package/src/commands/scaffold/scaffold.js +0 -25
  164. package/src/commands/state/advance.js +14 -85
  165. package/src/commands/state/approve.js +15 -97
  166. package/src/commands/state/archive.js +12 -64
  167. package/src/commands/state/auto-approval.js +2 -5
  168. package/src/commands/state/create.js +22 -109
  169. package/src/commands/state/delete.js +11 -63
  170. package/src/commands/state/finish.js +98 -475
  171. package/src/commands/state/gate-check.js +22 -132
  172. package/src/commands/state/gate-decision.js +3 -39
  173. package/src/commands/state/handoff.js +3 -30
  174. package/src/commands/state/list.js +14 -84
  175. package/src/commands/state/report-check.js +8 -58
  176. package/src/commands/state/retype.js +4 -26
  177. package/src/commands/validation/index.js +0 -3
  178. package/src/commands/validation/mutate.js +11 -11
  179. package/src/commands/validation/plan-check.js +4 -2
  180. package/src/commands/validation/tests-audit.js +7 -0
  181. package/src/commands/validation/validate-feature.js +10 -97
  182. package/src/commands/validation/verify.js +30 -113
  183. package/src/core/paths/output-schema.js +2 -101
  184. package/src/core/state/feature-identity.js +1 -25
  185. package/src/core/state/gate-order.js +0 -27
  186. package/src/core/state/lifecycle-audit.js +8 -100
  187. package/src/core/state/state-manager.js +22 -304
  188. package/src/core/templates/template-registry.js +2 -97
  189. package/src/core/templates/template-renderer.js +13 -93
  190. package/src/core/templates/template-seed.js +0 -1
  191. package/src/core/work-types.js +1 -36
  192. package/src/dashboard/app/App.jsx +1 -8
  193. package/src/dashboard/app/components/ActivityFeed.jsx +0 -5
  194. package/src/dashboard/app/components/CommandPalette.jsx +0 -7
  195. package/src/dashboard/app/components/DetailPanel.jsx +1 -5
  196. package/src/dashboard/app/components/EntityLink.jsx +0 -12
  197. package/src/dashboard/app/components/ErrorBoundary.jsx +1 -8
  198. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +0 -11
  199. package/src/dashboard/app/components/Markdown.jsx +0 -9
  200. package/src/dashboard/app/components/PhaseRail.jsx +0 -11
  201. package/src/dashboard/app/components/SearchFilter.jsx +0 -4
  202. package/src/dashboard/app/components/Sidebar.jsx +0 -5
  203. package/src/dashboard/app/hooks/useMorphQuery.js +1 -11
  204. package/src/dashboard/app/hooks/usePref.js +1 -5
  205. package/src/dashboard/app/lib/format.js +0 -2
  206. package/src/dashboard/app/lib/phases.js +1 -5
  207. package/src/dashboard/app/lib/project.js +0 -12
  208. package/src/dashboard/app/main.jsx +13 -13
  209. package/src/dashboard/app/pages/AgentMap.jsx +1 -8
  210. package/src/dashboard/app/pages/FeatureDetail.jsx +1 -5
  211. package/src/dashboard/app/pages/Features.jsx +1 -8
  212. package/src/dashboard/app/pages/Guide.jsx +0 -1
  213. package/src/dashboard/app/pages/HookInspector.jsx +2 -23
  214. package/src/dashboard/app/pages/PhasePipeline.jsx +0 -3
  215. package/src/dashboard/app/pages/Portfolio.jsx +0 -1
  216. package/src/dashboard/app/pages/RulesMap.jsx +1 -16
  217. package/src/dashboard/app/pages/SkillsLibrary.jsx +1 -16
  218. package/src/dashboard/app/pages/Standards.jsx +2 -17
  219. package/src/dashboard/scanner/activity-scanner.js +10 -69
  220. package/src/dashboard/scanner/agents-scanner.js +1 -23
  221. package/src/dashboard/scanner/docs-scanner.js +0 -6
  222. package/src/dashboard/scanner/feature-status.js +2 -5
  223. package/src/dashboard/scanner/features-scanner.js +10 -66
  224. package/src/dashboard/scanner/hooks-scanner.js +5 -28
  225. package/src/dashboard/scanner/index.js +0 -4
  226. package/src/dashboard/scanner/portfolio.js +5 -29
  227. package/src/dashboard/scanner/project-api.js +8 -54
  228. package/src/dashboard/scanner/rules-scanner.js +1 -2
  229. package/src/dashboard/scanner/skills-scanner.js +2 -12
  230. package/src/dashboard/scanner/standards-scanner.js +2 -6
  231. package/src/dashboard/vite-plugin-morph-data.js +5 -19
  232. package/src/dashboard/vite.config.js +24 -24
  233. package/src/lib/ai/ai-checks.js +7 -52
  234. package/src/lib/ai/ai-project-scan.js +14 -108
  235. package/src/lib/ai/code-audit.js +7 -63
  236. package/src/lib/ai/pin-drift.js +8 -51
  237. package/src/lib/ai/registry-audit.js +11 -90
  238. package/src/lib/ci/ci-verify.js +4 -19
  239. package/src/lib/ci/github-ci.js +9 -37
  240. package/src/lib/ci/workflow-scaffold.js +14 -10
  241. package/src/lib/detectors/claude-config-detector.js +13 -74
  242. package/src/lib/detectors/index.js +4 -50
  243. package/src/lib/e2e/applicability.js +37 -0
  244. package/src/lib/e2e/compose-env.js +5 -66
  245. package/src/lib/e2e/stack-teardown.js +6 -40
  246. package/src/lib/generators/architecture-scanner.js +18 -156
  247. package/src/lib/generators/handoff-generator.js +9 -53
  248. package/src/lib/generators/scaffold-generator.js +5 -101
  249. package/src/lib/generators/settings-generator.js +4 -37
  250. package/src/lib/git/branch-classifier.js +11 -84
  251. package/src/lib/git/default-branch.js +14 -169
  252. package/src/lib/git/evidence-staleness.js +1 -0
  253. package/src/lib/git/gh.js +2 -17
  254. package/src/lib/git/pending-prs.js +6 -37
  255. package/src/lib/git/pr-files.js +3 -28
  256. package/src/lib/git/pr-index.js +3 -34
  257. package/src/lib/git/tree-fingerprint.js +3 -15
  258. package/src/lib/git/tree-state.js +6 -33
  259. package/src/lib/graph/god-nodes.js +2 -22
  260. package/src/lib/graph/graph-health.js +3 -24
  261. package/src/lib/graph/graph-loader.js +5 -56
  262. package/src/lib/graph/graphify-runtime.js +7 -37
  263. package/src/lib/graph/graphifyignore.js +8 -74
  264. package/src/lib/graph/sanitize.js +16 -136
  265. package/src/lib/heavy/cleanup.js +1 -1
  266. package/src/lib/installers/effects.js +8 -93
  267. package/src/lib/installers/mcp-installer.js +9 -169
  268. package/src/lib/installers/memory-installer.js +4 -27
  269. package/src/lib/installers/templates-reconcile.js +5 -60
  270. package/src/lib/mutation/adapters.js +6 -17
  271. package/src/lib/mutation/dotnet-introspection.js +20 -106
  272. package/src/lib/mutation/runner.js +23 -75
  273. package/src/lib/mutation/subset.js +1 -4
  274. package/src/lib/mutation/trx.js +0 -1
  275. package/src/lib/plan/artifact-vocabulary.js +5 -25
  276. package/src/lib/plan/checks.js +34 -33
  277. package/src/lib/plan/plan-parser.js +7 -8
  278. package/src/lib/projects/registry.js +7 -44
  279. package/src/lib/review-reports.js +11 -26
  280. package/src/lib/risk-detector.js +41 -173
  281. package/src/lib/specs/adr-index.js +6 -34
  282. package/src/lib/specs/frontmatter.js +2 -15
  283. package/src/lib/specs/gfm-table.js +1 -7
  284. package/src/lib/specs/revocations.js +1 -14
  285. package/src/lib/specs/screen-checklist.js +47 -124
  286. package/src/lib/stack/dotnet-build-ignores.js +2 -6
  287. package/src/lib/stack/stack-profile.js +1 -28
  288. package/src/lib/stack/stack-scan.js +1 -8
  289. package/src/lib/stack-filter.js +4 -27
  290. package/src/lib/standards/registry.js +1 -7
  291. package/src/lib/standards/sections.js +2 -13
  292. package/src/lib/standards/verified-against.js +19 -191
  293. package/src/lib/tasks/eval-node.js +21 -169
  294. package/src/lib/tasks/linux-runner.js +18 -142
  295. package/src/lib/tasks/test-nodes.js +43 -240
  296. package/src/lib/tasks/test-runner.js +43 -341
  297. package/src/lib/telemetry/metrics.js +7 -36
  298. package/src/lib/telemetry/telemetry-reader.js +13 -125
  299. package/src/lib/telemetry/verify-event.js +2 -30
  300. package/src/lib/tests-audit/audit.js +96 -0
  301. package/src/lib/validators/code-budget/code-budget-validator.js +4 -44
  302. package/src/lib/validators/code-budget/index.js +0 -7
  303. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -24
  304. package/src/lib/validators/contracts/index.js +0 -6
  305. package/src/lib/validators/nextjs/index.js +0 -5
  306. package/src/lib/validators/nextjs/next-component-validator.js +4 -81
  307. package/src/lib/validators/packages/index.js +0 -6
  308. package/src/lib/validators/packages/package-validator.js +6 -56
  309. package/src/lib/validators/reuse/index.js +0 -8
  310. package/src/lib/validators/reuse/reuse-validator.js +11 -101
  311. package/src/lib/validators/reuse/symbol-scanner.js +6 -62
  312. package/src/lib/validators/traceability-check.js +5 -63
  313. package/src/lib/validators/validation-runner.js +21 -158
  314. package/src/lib/validators/verify-runner.js +85 -376
  315. package/src/lib/work-type-detector.js +5 -67
  316. package/src/lib/worktree/docker-audit.js +17 -136
  317. package/src/lib/worktree/env-allocator.js +10 -98
  318. package/src/lib/worktree/feature-truth.js +12 -115
  319. package/src/lib/worktree/finish-backup.js +32 -242
  320. package/src/lib/worktree/fleet-scanner.js +13 -95
  321. package/src/lib/worktree/ignored-triage.js +15 -91
  322. package/src/lib/worktree/vscode-settings.js +2 -3
  323. package/src/lib/worktree/worktree-audit.js +21 -213
  324. package/src/lib/worktree/worktree-include.js +3 -5
  325. package/src/scripts/global-install.js +3 -9
  326. package/src/scripts/install-plugin.js +1 -13
  327. package/src/scripts/setup-infra.js +17 -147
  328. package/src/utils/agents-installer.js +29 -276
  329. package/src/utils/banner.js +4 -25
  330. package/src/utils/claude-md-injector.js +1 -37
  331. package/src/utils/claude-settings-manager.js +1 -52
  332. package/src/utils/file-copier.js +11 -95
  333. package/src/utils/hooks-installer.js +21 -298
  334. package/src/utils/skills-installer.js +4 -101
  335. package/src/utils/version-checker.js +4 -83
  336. package/framework/commands/morph-proposal.md +0 -9
  337. package/framework/hooks/git/commit-msg/conventional-commits.sh +0 -33
  338. package/framework/hooks/git/pre-commit/agents.sh +0 -25
  339. package/framework/hooks/git/pre-commit/orchestrator.sh +0 -64
  340. package/framework/hooks/git/pre-push/run-tests.sh +0 -44
  341. package/src/commands/utils/index.js +0 -3
  342. package/src/lib/detectors/design-system-detector.js +0 -188
  343. package/src/lib/generators/design-system-generator.js +0 -508
  344. package/src/lib/specs/old-format.js +0 -68
  345. package/src/utils/agent-id-mapper.js +0 -11
@@ -24,113 +24,48 @@ import { runArchive } from './archive.js';
24
24
  import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
25
25
  import { newBackupStamp, enforceBackupRetention, inspectFinishBackups } from '../../lib/worktree/finish-backup.js';
26
26
 
27
- /** workType → conventional-commit type. `hotfix` IS a fix; anything else is a chore. */
28
27
  const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
29
28
 
30
29
  /** Git's soft limit for a subject line. */
31
30
  const PR_TITLE_MAX = 72;
32
31
 
33
- /**
34
- * The PR title for `finish --pr`.
35
- *
36
- * Was always `morph: {slug}` — so every PR in a repo whose hand-made ones follow
37
- * conventional commits (`fix(ui): coluna CRM nos dossiês…`) arrived shapeless
38
- * and said nothing about what changed (D4).
39
- *
40
- * Now: `{type}({slug}): {subject}` — the type from the feature's `workType`, the
41
- * slug kept as the scope (it is the traceability the old title at least had),
42
- * and the subject from the feature's own identity: `name` first (a human
43
- * deliberately titled it), then `description`, first line only. With neither,
44
- * degrade to `{type}: {slug}` rather than emit an empty subject.
45
- *
46
- * @param {string} feature
47
- * @param {object|null} fj - the feature.json
48
- * @returns {string}
49
- */
32
+ // `{type}({slug}): {subject}`, the subject from `name` (unless it is the default slug), then
33
+ // `description`, first line only; with neither, `{type}: {slug}`.
50
34
  export function buildPrTitle(feature, fj) {
51
35
  const type = PR_TYPE_BY_WORK_TYPE[fj?.workType] || 'chore';
52
36
 
53
37
  const named = typeof fj?.name === 'string' ? fj.name.trim() : '';
54
- // The default `name` IS the slug (create stamps it), which as a subject would
55
- // only repeat the scope — treat it as absent.
38
+ // The default `name` IS the slug, which as a subject would only repeat the scope.
56
39
  const described = typeof fj?.description === 'string' ? fj.description.trim() : '';
57
40
  const raw = (named && named !== feature ? named : described).split('\n')[0].trim();
58
41
  if (!raw) return `${type}: ${feature}`;
59
42
 
60
43
  const prefix = `${type}(${feature}): `;
61
44
  const room = PR_TITLE_MAX - prefix.length;
62
- // A scope so long there is no room left for a subject: drop the scope rather
63
- // than ship a title that is all prefix.
45
+ // No room left for a subject: drop the scope rather than ship a title that is all prefix.
64
46
  if (room < 12) return `${type}: ${raw}`.slice(0, PR_TITLE_MAX);
65
47
  return prefix + (raw.length <= room ? raw : `${raw.slice(0, room - 1)}…`);
66
48
  }
67
49
 
68
50
  const MEMORY_NOTE = 'o fechamento desta feature já está registrado no repositório (commit de archive, `plan.md` com as decisões, índice de ADRs, `git log`) — memória de sessão só para o que NENHUM deles guarda: decisão do usuário, pendência a observar em produção, custo medido.';
69
51
 
70
- /**
71
- * `morph-spec finish <feature> --pr|--merge` — the single, symmetric close of a
72
- * feature's lifecycle, for BOTH the worktree flow and the common flow.
73
- *
74
- * Order (see the plan's backbone): validate → ARCHIVE inside the `morph/{f}`
75
- * branch (committed, so the archived state travels in one integration) →
76
- * integrate:
77
- * --merge : merge `--no-ff` into the default branch, LOCAL (no push).
78
- * --pr : push `morph/{f}` + `gh pr create` (does NOT merge locally).
79
- * → teardown the worktree (PROVE-or-DECLINE) when there is one.
80
- *
81
- * Every refusal is fail-CLOSED, exit 1, and — since the `--pr` remote
82
- * preconditions run BEFORE the archive — a refused `--pr` leaves no side effect.
83
- * `finished` is the integration (the actual value); `worktreeRemoved` is the
84
- * best-effort teardown — two independent signals, as before.
85
- *
86
- * RESUMABLE. `merge-conflict`, `checkout-default-failed`, `push-failed` and
87
- * `pr-create-failed` all fail AFTER the archive, and legitimately so: the
88
- * archive commit is what travels with the branch. Re-running then found no
89
- * `features/{f}` and refused from scratch, so every mid-way failure demanded a
90
- * hand repair. Now a second run that finds the feature ARCHIVED on its own
91
- * branch, not yet merged into the default, picks up at the integration and says
92
- * `resumed: true` in its output — see Validation 0c.
93
- *
94
- * @param {string} feature
95
- * `--dry-run` desvia para {@link finishPreflight} ANTES de qualquer validação
96
- * que saia com exit 1 e ANTES de qualquer mutação: o pré-voo existe para
97
- * EXPLICAR, e um pré-voo que recusa pelos mesmos motivos do comando real não
98
- * explica nada.
99
- *
100
- * @param {{ pr?: boolean, merge?: boolean, keep?: boolean, keepVolumes?: boolean,
101
- * push?: boolean, force?: boolean, discardIgnored?: boolean, dryRun?: boolean,
102
- * cwd?: string }} [options]
103
- */
52
+ // Every refusal is fail-CLOSED and happens BEFORE the archive, the first mutation. Resumable: a
53
+ // later run that finds the feature archived on its branch but not integrated picks up there.
54
+ // `--dry-run` diverts to finishPreflight before any exit-1 validation: a preflight explains.
104
55
  export async function finishCommand(feature, options = {}) {
105
56
  const cwd = options.cwd || process.cwd();
106
57
  const root = resolvePrimaryRoot(cwd);
107
58
  const rootNorm = resolve(root).replace(/\\/g, '/');
108
59
 
109
- // A closed feature must not keep warning other sessions that someone is
110
- // working on it (B1). Released on EVERY success path — hooking it into emit
111
- // is the single point that covers merge/PR/--keep alike. Fail-open: a claim
112
- // that outlives its feature only expires on its own TTL.
113
- //
114
- // The e2e stack is NOT reclaimed here: by the time a success is emitted the
115
- // worktree that owns the compose file may already be gone. Each integration
116
- // path calls `reclaimStack` immediately BEFORE its worktree teardown (or
117
- // before its final emit in the common flow) — see stack-teardown.js.
118
- //
119
- // Set by Validation 0c when this run picks up an already-archived feature.
120
- // Stamped onto EVERY payload emitted from that point on, success or refusal:
121
- // a resume merges a branch the user did not archive in this run, and that has
122
- // to be visible. Someone who mistypes a name and lands on an archived feature
123
- // would otherwise get a silent merge they never asked for.
60
+ // The claim is released on every success through emit (fail-open: a stale claim expires on its
61
+ // TTL). The e2e stack is reclaimed by each path BEFORE its worktree teardown, never here.
62
+ // `resumed` goes on every payload, so a merge of a feature archived by an earlier run is visible.
124
63
  let resumed = false;
125
64
 
126
- // ONE stamp for every backup this run writes: the shed's and the teardown's
127
- // folders then count as a single backup under the retention.
65
+ // ONE stamp for every backup this run writes, so they count as a single backup in the retention.
128
66
  const backupStamp = newBackupStamp();
129
67
 
130
- // The backup retention rides the same single point, AFTER this run's own
131
- // backups exist (the shed and the teardown both precede every success emit).
132
- // This run's stamp is protected: the prune never takes the backup it just
133
- // made room for, even when backups stamped by a clock running ahead outrank it.
68
+ // Retention runs after this run's own backups exist, and never prunes this run's stamp.
134
69
  const emit = (obj) => {
135
70
  if (resumed) obj = { ...obj, resumed: true };
136
71
  if (obj?.finished === true) {
@@ -146,15 +81,12 @@ export async function finishCommand(feature, options = {}) {
146
81
  console.log(JSON.stringify(obj));
147
82
  };
148
83
 
149
- // Gate 0: valid kebab name — anti command-injection (the name flows into every
150
- // git/gh call below) and the same gate the worktree path always applied.
84
+ // Anti command-injection: the name flows into every git/gh call below.
151
85
  if (!isValidFeatureName(feature)) {
152
86
  emit({ finished: false, error: 'invalid-feature-name', feature });
153
87
  process.exit(1);
154
88
  }
155
89
 
156
- // Mode: exactly one of --pr / --merge (they are alternative integrations — the
157
- // finish merges OR opens a PR, never both, never neither).
158
90
  const wantPr = !!options.pr;
159
91
  const wantMerge = !!options.merge;
160
92
  if (wantPr === wantMerge) {
@@ -163,15 +95,8 @@ export async function finishCommand(feature, options = {}) {
163
95
  }
164
96
  const mode = wantPr ? 'pr' : 'merge';
165
97
 
166
- // `--push` publica a DEFAULT depois de um merge local; o fluxo `--pr` não tem
167
- // merge local nenhum (a default só recebe a feature quando o PR mergear), e
168
- // já publica `morph/{f}` por conta própria. A combinação é um pedido que o
169
- // comando não consegue honrar — e aceitar em silêncio o que não se pode
170
- // honrar é exatamente o defeito que esta série de fixes existe para tirar do
171
- // CLI (o `score` que gravava um zero fabricado, o `approve` que saía 0 sem
172
- // aprovar nada). Recusa ALTA, e AQUI: junto do gate de modo, antes do
173
- // archive — que é a primeira mutação do finish. Uma recusa depois dele
174
- // deixaria a feature meio fechada, pior que o no-op que ela substitui.
98
+ // `--push` publica a default depois de um merge local, e o `--pr` não faz merge local: recusa
99
+ // AQUI, antes do archive (a primeira mutação), em vez de aceitar em silêncio o que não honra.
175
100
  if (wantPr && options.push) {
176
101
  emit({
177
102
  finished: false,
@@ -184,23 +109,16 @@ export async function finishCommand(feature, options = {}) {
184
109
 
185
110
  const expectedBranch = buildBranchName(feature);
186
111
 
187
- // ── Resolve the flow ──────────────────────────────────────────────────────
188
- // findWorktreeForFeature matches ANY worktree on morph/{feature} — including
189
- // the PRIMARY ROOT itself when the common flow checked the branch out there.
190
- // A match whose path IS the root is the common flow (no separate worktree to
191
- // tear down); a match elsewhere is the worktree flow.
112
+ // findWorktreeForFeature also matches the PRIMARY ROOT when the common flow checked the branch
113
+ // out there: a match at the root is the common flow.
192
114
  const found = findWorktreeForFeature(feature, root);
193
115
  const isWorktreeFlow = !!found && found.path.replace(/\\/g, '/') !== rootNorm;
194
116
 
195
117
  let worktreePath = null;
196
- let featureCwd = root; // where the feature's own tree (and Gate 3) lives
197
-
198
- // ── PRÉ-VOO ───────────────────────────────────────────────────────────────
199
- // AQUI, e não mais abaixo: as validações seguintes saem com exit 1 (branch
200
- // divergente, cwd dentro do worktree, feature ausente), e o pré-voo existe
201
- // justamente para ser rodado ANTES de o estado estar bom. Ele reporta o que
202
- // encontrar — inclusive o cwd dentro do worktree, como AVISO — e sai 0 sem
203
- // ter tocado em nada.
118
+ let featureCwd = root; // where the feature's own tree (and its review) lives
119
+
120
+ // Pré-voo AQUI, antes das validações que saem com exit 1: ele existe para rodar antes de o estado
121
+ // estar bom, reporta o que encontrar e sai 0 sem tocar em nada.
204
122
  if (options.dryRun) {
205
123
  emit(finishPreflight({
206
124
  feature,
@@ -209,17 +127,14 @@ export async function finishCommand(feature, options = {}) {
209
127
  cwd,
210
128
  mode,
211
129
  branch: expectedBranch,
212
- // `options` INTEIRO, e não um subconjunto escolhido a dedo: `--discard-ignored`
213
- // INVERTE a classificação (o exclusivo deixa de ser copiado e passa a ser
214
- // destruído), então um pré-voo que não recebe as flags do comando prevê
215
- // outro comando. Ver B1 na errata E4.
130
+ // `options` INTEIRO: `--discard-ignored` inverte a classificação, e um pré-voo sem as flags do
131
+ // comando prevê outro comando.
216
132
  options,
217
133
  }));
218
134
  return;
219
135
  }
220
136
 
221
137
  if (isWorktreeFlow) {
222
- // Validation 0: the worktree must actually be ON the branch we will merge.
223
138
  if (found.branch !== found.expectedBranch) {
224
139
  emit({
225
140
  finished: false,
@@ -235,14 +150,8 @@ export async function finishCommand(feature, options = {}) {
235
150
  worktreePath = found.path;
236
151
  featureCwd = worktreePath;
237
152
 
238
- // Validation 0b: the session's cwd must not be INSIDE the worktree we are
239
- // about to tear down (A4). Windows holds a lock on the process's working
240
- // directory, so `git worktree remove` fails — and by then the archive is
241
- // committed, the branch merged, and the infra junctions already dropped.
242
- // The result is a worse state than never having tried: an orphan directory,
243
- // a de-registered worktree, and a `relinkHint` telling the user to repair
244
- // by hand. Refuse HERE, before the first mutation — the cost is one
245
- // `cd`, and it is the exact failure observed in the ProspectPRO session.
153
+ // Windows locks the process cwd, so `git worktree remove` would fail AFTER the archive and the
154
+ // merge, leaving an orphan directory. Refuse here, before the first mutation.
246
155
  if (cwdIsInsideWorktree(cwd, worktreePath)) {
247
156
  emit({
248
157
  finished: false,
@@ -255,15 +164,9 @@ export async function finishCommand(feature, options = {}) {
255
164
  process.exit(1);
256
165
  }
257
166
  } else {
258
- // COMMON flow: the feature branch must exist and be the ROOT's current
259
- // branch (so the archive commits on it and we can checkout the default to
260
- // merge). A feature with NO morph/{f} branch was born before this flow —
261
- // route it explicitly to archive-without-integrate, never a generic refusal.
167
+ // A feature with no morph/{f} branch predates this flow: route it to archive-without-integrate.
262
168
  if (!branchExists(expectedBranch, root)) {
263
- // No branch AND no feature on disk is not a legacy feature — it is a
264
- // feature that is not here at all (wrong name, wrong tree, or already
265
- // archived). Routing that to `archive legacy` would tell the user to
266
- // archive something that does not exist.
169
+ // No branch AND no folder is not a legacy feature: it is not here at all.
267
170
  if (!existsSync(join(root, '.morph', 'features', feature))) {
268
171
  emit({ finished: false, error: 'feature-not-found', feature, ...featureElsewhereHint(root, root, feature) });
269
172
  process.exit(1);
@@ -291,19 +194,8 @@ export async function finishCommand(feature, options = {}) {
291
194
  featureCwd = root;
292
195
  }
293
196
 
294
- // Validation 0c: the feature must still be in `features/` — or, if it is not,
295
- // this run has to decide which of three states it is in before doing anything:
296
- // gone entirely, already ARCHIVED and awaiting integration (resume), or
297
- // already integrated (nothing to do).
298
- //
299
- // Originally all three arrived as `review-not-approved`, because a missing
300
- // feature.json parses to null and the optional chaining below fell straight
301
- // through — being told "Gate 3 not approved" about an approved gate is what
302
- // sent the user looking in the wrong place after an interrupted finish.
303
- //
304
- // Keyed on the DIRECTORY, not on the parse: readFeatureJson also returns null
305
- // for malformed JSON, and a legacy feature with a folder but no (or broken)
306
- // feature.json is a real thing that `--force` is expected to push through.
197
+ // Not in `features/`: gone, archived awaiting integration (resume), or already integrated. Keyed
198
+ // on the DIRECTORY, not the parse: a legacy folder with a broken feature.json is --force's case.
307
199
  if (!existsSync(join(featureCwd, '.morph', 'features', feature))) {
308
200
  const archivePath = join(featureCwd, '.morph', 'archive', feature);
309
201
  if (!existsSync(archivePath)) {
@@ -311,19 +203,8 @@ export async function finishCommand(feature, options = {}) {
311
203
  process.exit(1);
312
204
  }
313
205
 
314
- // ── RESUME ──────────────────────────────────────────────────────────────
315
- // Archived HERE, on the branch this finish would merge — every path that
316
- // reaches this line has already proved we are standing on `morph/{f}`
317
- // (worktree flow: found.branch === expectedBranch; common flow: branchExists
318
- // + currentBranch === expectedBranch). So the archive step is done and only
319
- // the integration is missing. Which is exactly what a failed merge/push
320
- // leaves behind, and what used to require repairing by hand.
321
- //
322
- // The one thing still to establish is whether the integration ALSO
323
- // happened. A branch already contained in the default is a finish that
324
- // COMPLETED (`--keep`, or a `--pr` whose PR was merged) — re-merging it is
325
- // the hazard this check exists to stop, and it is why resume is keyed on
326
- // the branch's relationship to the default rather than on the archive alone.
206
+ // Every path here already stands on `morph/{f}`, so only the integration is missing. A branch
207
+ // already contained in the default COMPLETED: re-merging it is the hazard this check stops.
327
208
  const target = detectDefaultBranch(root);
328
209
  if (!target) {
329
210
  emit({ finished: false, error: 'no-default-branch', hint: 'não foi possível resolver a branch default (origin/HEAD, main ou master)', feature });
@@ -331,9 +212,7 @@ export async function finishCommand(feature, options = {}) {
331
212
  }
332
213
  const merged = branchMergedInto(expectedBranch, target, root);
333
214
  if (merged === null) {
334
- // PROVE-or-DECLINE, same discipline as the worktree teardown: we could not
335
- // establish the branch's state, so we do not act on a guess. Refusing is
336
- // recoverable; a duplicate merge is not.
215
+ // PROVE-or-DECLINE: refusing is recoverable, a duplicate merge is not.
337
216
  emit({
338
217
  finished: false,
339
218
  error: 'resume-undecidable',
@@ -358,17 +237,8 @@ export async function finishCommand(feature, options = {}) {
358
237
  process.exit(1);
359
238
  }
360
239
 
361
- // "Already integrated" is MODE-SPECIFIC, and the ancestor test above only
362
- // answers it for `--merge`. A `--pr` that completed leaves its branch
363
- // deliberately UNMERGED (that is what the open PR is for), so the ancestor
364
- // test says "not merged" and would wave the resume through — straight into
365
- // a second `gh pr create` on a head that already has one. The common flow
366
- // makes that trivially reachable: integratePr never leaves `morph/{f}`, so
367
- // simply re-running the command lands here.
368
- //
369
- // `gh pr create` refuses a duplicate, so the damage was never two PRs — it
370
- // was `pr-create-failed` standing in for "this already finished", which is
371
- // the same collapsed diagnosis the `feature-not-found` split removed.
240
+ // "Already integrated" is per mode: a completed `--pr` leaves its branch unmerged on purpose, so
241
+ // the ancestor test alone would wave a second `gh pr create` through.
372
242
  if (mode === 'pr') {
373
243
  const openPr = openPrFor(expectedBranch, root);
374
244
  if (openPr === null) {
@@ -400,17 +270,8 @@ export async function finishCommand(feature, options = {}) {
400
270
  resumed = true;
401
271
  }
402
272
 
403
- // Validation 1: Gate 3 (review) approved on the feature's own feature.json —
404
- // which on a resume lives in `archive/{f}`, where runArchive moved it.
405
- //
406
- // NOT re-checked on a resume, deliberately. The archive IS the gate's receipt:
407
- // runArchive refuses `review-not-approved` itself, so a feature that reached
408
- // `archive/` either passed Gate 3 or was explicitly forced past it. Re-reading
409
- // it here would only add a way to fail — an archive whose feature.json is
410
- // absent or unparseable (legacy shapes, a forced close) parses to null and
411
- // falls straight through to `review-not-approved`, which is the exact
412
- // collapsed diagnosis the `feature-not-found` fix removed. Resume integrates
413
- // what was already archived; it does not re-litigate why.
273
+ // Not re-checked on a resume: the archive IS the gate's receipt (runArchive refuses without
274
+ // the review gate), and an archive with no readable feature.json would only fail here wrongly.
414
275
  const fj = readFeatureJson(featureCwd, feature, resumed ? 'archive' : 'features');
415
276
  if (!resumed && !options.force && !(fj?.approvalGates?.review?.approved === true || fj?.approvals?.review?.approved === true)) {
416
277
  emit({ finished: false, error: 'review-not-approved', feature });
@@ -421,8 +282,7 @@ export async function finishCommand(feature, options = {}) {
421
282
  ? String(fj.description).trim()
422
283
  : `Feature ${feature} — integrada via morph-spec finish --pr.`;
423
284
 
424
- // Validation 2: the tree that will carry the archive commit must be clean
425
- // (work committed), so the scoped archive commit contains only the move.
285
+ // Clean, so the scoped archive commit contains only the move.
426
286
  const cleanCwd = isWorktreeFlow ? worktreePath : root;
427
287
  const dirty = gitStatusPorcelain(cleanCwd);
428
288
  if (dirty === null) {
@@ -434,8 +294,7 @@ export async function finishCommand(feature, options = {}) {
434
294
  process.exit(1);
435
295
  }
436
296
 
437
- // Default branch — needed as the merge target and the PR base. Never assume
438
- // "master"; refuse clearly when it cannot be resolved.
297
+ // Never assume "master".
439
298
  const defaultBranch = detectDefaultBranch(root);
440
299
  if (!defaultBranch) {
441
300
  emit({ finished: false, error: 'no-default-branch', hint: 'não foi possível resolver a branch default (origin/HEAD, main ou master)', feature });
@@ -446,20 +305,8 @@ export async function finishCommand(feature, options = {}) {
446
305
  process.exit(1);
447
306
  }
448
307
 
449
- // ── MERGE precondition, BEFORE the archive ────────────────────────────────
450
- // `--merge` lands its merge commit in the PRIMARY ROOT, so the root has to be
451
- // clean. That check used to live inside integrateMerge — i.e. AFTER
452
- // runArchive had already moved the feature to .morph/archive/, updated the
453
- // ADR index and COMMITTED. A root dirtied by unrelated work therefore got a
454
- // refusal on a half-finished feature, and the retry then failed for a
455
- // different reason entirely (the feature was no longer in features/).
456
- //
457
- // `exemptOwnScope` because shedRootFeatureScope has not run yet: this
458
- // feature's own root-side scaffold is still on disk and is not a reason to
459
- // refuse — the shed backs it up and discards it moments later.
460
- //
461
- // `--pr` is exempt by design: integratePr pushes from the root but never
462
- // checks out or merges there, so root dirt cannot corrupt it.
308
+ // BEFORE the archive: a dirty root refused after it left a half-finished feature. The own scope
309
+ // is exempt (the shed discards it next), and `--pr` never checks out or merges in the root.
463
310
  if (mode === 'merge' && isWorktreeFlow) {
464
311
  const rootPre = primaryRootUnexplainedDirt(root, feature, { exemptOwnScope: true });
465
312
  if (!rootPre.ok) {
@@ -477,8 +324,7 @@ export async function finishCommand(feature, options = {}) {
477
324
  }
478
325
  }
479
326
 
480
- // ── PR preconditions run BEFORE the archive, so a refused --pr has NO side
481
- // effect (nothing archived, nothing committed). ─────────────────────────
327
+ // BEFORE the archive, so a refused --pr has no side effect.
482
328
  if (mode === 'pr') {
483
329
  if (!hasOriginRemote(root)) {
484
330
  emit({ finished: false, error: 'no-origin-remote', hint: 'não há remoto `origin` para abrir PR — configure um ou use --merge', feature });
@@ -490,9 +336,7 @@ export async function finishCommand(feature, options = {}) {
490
336
  }
491
337
  }
492
338
 
493
- // ── ARCHIVE (inside morph/{f}) ────────────────────────────────────────────
494
- // Every refusal is behind us: the leftover of a killed `graph refresh` goes now,
495
- // before the archive — never before a precondition that could still refuse.
339
+ // Every refusal is behind us: the leftover of a killed `graph refresh` goes now, never before.
496
340
  dropLeftoverIgnore(cleanCwd);
497
341
  const arch = runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
498
342
  if (!arch.ok) {
@@ -506,66 +350,26 @@ export async function finishCommand(feature, options = {}) {
506
350
  process.exit(1);
507
351
  }
508
352
 
509
- // ── INTEGRATE ─────────────────────────────────────────────────────────────
510
353
  if (mode === 'merge') {
511
354
  return integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch, backupStamp });
512
355
  }
513
356
  return integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch, backupStamp });
514
357
  }
515
358
 
516
- /**
517
- * O aviso que acompanha CADA reparse point listado pelo pré-voo.
518
- *
519
- * Exportado porque é a frase que carrega o fato: uma junction/symlink dentro do
520
- * worktree aponta para conteúdo que vive na RAIZ, e `rm -rf` / `git worktree
521
- * remove --force` atravessam o link. Já aconteceu: um `worktree remove --force`
522
- * atravessou a junction e apagou o `node_modules` da raiz.
523
- */
359
+ // A junction/symlink in the worktree points at content in the ROOT, and `rm -rf` or
360
+ // `git worktree remove --force` traverse it.
524
361
  export const REPARSE_WARNING =
525
362
  'estes são junctions/symlinks: apagá-los fora do morph-spec apaga o conteúdo REAL na raiz';
526
363
 
527
- /**
528
- * `morph-spec finish <feature> --pr|--merge --dry-run` — o PRÉ-VOO.
529
- *
530
- * NÃO MUTA NADA. Nenhum commit, nenhum archive, nenhum worktree removido,
531
- * nenhum `gh` que crie coisa alguma, nenhum `git fetch`. Ele responde, ANTES,
532
- * as três perguntas que hoje só se respondem depois do estrago:
533
- *
534
- * 1. O que este `finish` vai COPIAR para o backup e o que vai DESCARTAR?
535
- * (`classifyIgnoredLeftovers` — a MESMA função que o teardown consulta;
536
- * um segundo enumerador aqui faria o pré-voo prometer o que o comando não
537
- * cumpre, e um dry-run que mente é pior que nenhum.)
538
- * 2. Que reparse points existem, e o que significa apagá-los por fora?
539
- * (`findReparsePoints`, exaustivo e fail-closed.)
540
- * 3. A base andou? — a pergunta INVERSA da de #89. Lá se mede "quantos
541
- * commits EU tenho que o upstream não tem"; aqui, "quantos commits
542
- * `origin/{default}` tem que a MINHA base não tem", que é o que decide se
543
- * este merge vai ser trivial ou uma surpresa. Mesma função
544
- * (`commitsAhead`), argumentos invertidos.
545
- *
546
- * `cwd` dentro do worktree é AVISO, não recusa: o caminho real continua
547
- * recusando antes da primeira mutação (Validation 0b), e recusar aqui negaria
548
- * ao usuário justamente a explicação que ele veio buscar — do lugar mais
549
- * provável de estar quando a pede.
550
- *
551
- * SEM `git fetch` implícito: um fetch dentro de um comando que promete não
552
- * mexer em nada é efeito colateral de rede. O pré-voo AVISA que o dado pode
553
- * estar velho e sugere o fetch.
554
- *
555
- * @param {{ feature: string, root: string, worktreePath: string|null,
556
- * cwd: string, mode: 'pr'|'merge', branch: string,
557
- * options?: { discardIgnored?: boolean, keep?: boolean } }} args
558
- * @returns {object} o payload emitido — nunca lança
559
- */
364
+ // NÃO MUTA NADA: nenhum commit, archive, remoção, `gh` que crie algo nem `git fetch` (efeito de
365
+ // rede). Usa as MESMAS funções do comando real, senão o pré-voo promete o que o comando não
366
+ // cumpre. `cwd` dentro do worktree é AVISO, não recusa. Nunca lança.
560
367
  export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch, options = {} }) {
561
368
  const warnings = [];
562
369
 
563
- // ── A base andou? (vale COM e SEM worktree — UC-16 A1) ────────────────────
564
370
  const defaultBranch = detectDefaultBranch(root);
565
371
  const baseRef = defaultBranch ? resolveBaseRef(defaultBranch, root) : null;
566
- // `commitsAhead(base, branch)` conta `base..branch`. Passando a branch da
567
- // feature como BASE e o baseRef como BRANCH, contamos o que a default tem e a
568
- // feature não — exatamente a pergunta inversa.
372
+ // `commitsAhead` conta `base..branch`: invertidos, contam o que a default tem e a feature não.
569
373
  const commits = baseRef && branchExists(branch, root)
570
374
  ? commitsAhead(branch, baseRef, root)
571
375
  : null;
@@ -577,9 +381,7 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
577
381
  mode,
578
382
  branch,
579
383
  baseAdvanced: { ref: baseRef, commits },
580
- // `null` é DESCONHECIDO e nunca é renderizado como 0 (UC-16 P6): "não deu
581
- // para perguntar" e "a default não andou" são fatos opostos, e confundi-los
582
- // é como um `--dry-run` passa confiança que não tem.
384
+ // `null` é DESCONHECIDO, nunca 0: "não deu para perguntar" e "não andou" são fatos opostos.
583
385
  baseAdvancedNote: commits === null
584
386
  ? (baseRef === null
585
387
  ? 'sem contraparte remota nem branch default resolvível — a contagem é DESCONHECIDA, não zero'
@@ -601,10 +403,8 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
601
403
 
602
404
  if (defaultBranch) out.defaultBranch = defaultBranch;
603
405
 
604
- // A retenção dos backups também é mutação do finish real — a AUTOMÁTICA, que
605
- // não toca backup sem manifesto (esses só o `doctor --worktrees --prune`
606
- // poda). A contagem é a de AGORA: o backup que este finish gravar ainda pode
607
- // empurrar mais um para fora.
406
+ // A retenção automática também é mutação do finish real; a contagem é a de AGORA, antes do
407
+ // backup que este finish gravar.
608
408
  try {
609
409
  const retention = inspectFinishBackups(root);
610
410
  if (retention.policy.skipped) {
@@ -625,7 +425,6 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
625
425
  }
626
426
  } catch { /* o pré-voo nunca lança */ }
627
427
 
628
- // ── Seções de worktree — omitidas quando não há worktree (UC-16 A1) ───────
629
428
  if (worktreePath) {
630
429
  out.worktreePath = worktreePath.replace(/\\/g, '/');
631
430
 
@@ -647,24 +446,17 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
647
446
  );
648
447
  }
649
448
 
650
- // AS MESMAS FLAGS do comando real. `--discard-ignored` INVERTE a
651
- // classificação — o exclusivo deixa de ser copiado e entra no lote
652
- // destruído sem backup — então prever com `options` vazio é prever OUTRO
653
- // comando. Medido antes do conserto: o pré-voo prometia salvar caminhos que
654
- // o `--discard-ignored` destruía. Normalizado com `=== true` para que
655
- // `undefined` e `false` sejam a mesma coisa, como no `worktree remove`.
449
+ // As MESMAS flags do comando real: `--discard-ignored` inverte a classificação, então prever com
450
+ // `options` vazio é prever outro comando.
656
451
  const discardIgnored = options.discardIgnored === true;
657
452
  const ignored = classifyIgnoredLeftovers(worktreePath, root, { discardIgnored });
658
453
  out.discardIgnored = discardIgnored;
659
454
  out.ignored = {
660
- // COPIADO para .git/morph-spec/finish-backup antes da remoção: existe só
661
- // aqui, ou difere do que está na raiz. Com `--discard-ignored` esta lista
662
- // sai VAZIA por construção — a flag significa "pule o backup".
455
+ // Copiado para o backup; vazio por construção sob `--discard-ignored`.
663
456
  shieldable: ignored.shieldable,
664
457
  // Idêntico byte a byte ao da raiz (SHA-256): apagar não perde nada.
665
458
  replicable: ignored.replicable,
666
- // Artefato regenerável + os replicáveis (+ o exclusivo, sob a flag):
667
- // descartado sem backup.
459
+ // Descartado sem backup.
668
460
  discardable: ignored.discarded,
669
461
  };
670
462
  if (ignored.blocking.length) {
@@ -673,12 +465,8 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
673
465
  }
674
466
 
675
467
  if (discardIgnored) {
676
- // SEGUNDA leitura, deliberada e só aqui: sob a flag, `shieldable` sai
677
- // vazia e a informação mais valiosa do pré-voo — QUAIS destes existem só
678
- // aqui — desapareceria justamente no modo destrutivo. Perguntar à MESMA
679
- // função, sem a flag, é a única forma de nomeá-los sem recriar a regra
680
- // (uma segunda cópia da triagem é o defeito que o export de
681
- // `classifyIgnoredLeftovers` existe para evitar).
468
+ // Sob a flag `shieldable` sai vazia: perguntar à MESMA função sem a flag nomeia o que existe só
469
+ // aqui, sem recriar a regra.
682
470
  const semFlag = classifyIgnoredLeftovers(worktreePath, root);
683
471
  out.ignored.discardableExclusive = semFlag.shieldable;
684
472
  if (semFlag.shieldable.length) {
@@ -705,23 +493,17 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
705
493
  return out;
706
494
  }
707
495
 
708
- // ── merge integration (local, no push) ───────────────────────────────────────
709
-
710
496
  async function integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch, backupStamp }) {
711
497
  const mergeMsg = `morph-spec finish: merge ${expectedBranch}`;
712
498
  const adrNote = adrIndexNote(arch);
713
499
 
714
500
  if (isWorktreeFlow) {
715
- // The root stays on the default; shed this feature's own root-side scaffold,
716
- // then guard the whole root before landing the merge commit.
501
+ // Shed this feature's root-side scaffold, then guard the whole root before the merge commit.
717
502
  const { shedBackup } = shedRootFeatureScope(root, feature, { stamp: backupStamp });
718
503
  const backupNote = shedBackup ? { shedBackup } : {};
719
504
 
720
- // Final gate before the merge commit lands. The pre-archive check above
721
- // already refused an unrelated-dirt root WITHOUT side effects, so reaching
722
- // a refusal HERE means the shed itself dirtied the root — a bug signal, not
723
- // user error. Kept because this is the last moment before we mutate the
724
- // default branch, and it is nearly free.
505
+ // The pre-archive check already refused unrelated dirt, so a refusal here means the shed
506
+ // dirtied the root: a bug signal. Last moment before mutating the default branch.
725
507
  const rootClean = primaryRootUnexplainedDirt(root, feature);
726
508
  if (!rootClean.ok) {
727
509
  emit({ finished: false, error: 'primary-root-status-failed', feature, ...backupNote });
@@ -753,14 +535,10 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
753
535
  process.exit(0);
754
536
  }
755
537
 
756
- // The stack comes down BEFORE the worktree: the compose file, the bind
757
- // mount and the port block all belong to that tree, and once it is gone
758
- // there is nothing left to address the containers by.
538
+ // The stack comes down BEFORE the worktree, which holds its compose file, bind mount and ports.
759
539
  const e2e = reclaimStack(feature, { root, worktreePath, options });
760
- // The session that ran this finish may be ANCHORED in the worktree (its
761
- // hooks resolve `.morph/framework` through it): tearing it down now would
762
- // kill every hook of that session. The integration is done; only the
763
- // teardown waits (see sessionAnchoredInWorktree).
540
+ // A session ANCHORED in the worktree resolves its hooks through it: tearing it down now would
541
+ // kill every hook of that session, so only the teardown waits.
764
542
  const deferred = sessionAnchoredInWorktree(worktreePath);
765
543
  const teardown = deferred
766
544
  ? null
@@ -770,8 +548,6 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
770
548
  process.exit(0);
771
549
  }
772
550
 
773
- // COMMON flow: we are on morph/{f} in the root, archive committed. Move to the
774
- // default and merge the feature branch in.
775
551
  try {
776
552
  execFileSync('git', ['checkout', defaultBranch], { cwd: root, stdio: 'pipe' });
777
553
  } catch (err) {
@@ -786,8 +562,6 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
786
562
  finished: false,
787
563
  error: 'merge-conflict',
788
564
  output: gitErr(err),
789
- // The abort left the working dir on the default WITHOUT the feature — say
790
- // where the intact work is so the user can recover it.
791
565
  hint: `conflito ao mergear ${expectedBranch} em ${defaultBranch}. Seu trabalho está intacto em ${expectedBranch} — rode \`git checkout ${expectedBranch}\` para voltar a ele`,
792
566
  feature,
793
567
  });
@@ -796,29 +570,16 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
796
570
  tryGit(['branch', '-d', expectedBranch], root);
797
571
  const publish = publicationState({ root, defaultBranch, push: options.push });
798
572
  const e2e = reclaimStack(feature, { root, worktreePath: null, options });
799
- // The root index (gitignored) was already updated by the archive (same cwd).
800
573
  emit({ ...mergeOutput({ feature, branch: expectedBranch, defaultBranch, publish }), ...adrNote, e2e });
801
574
  process.exit(0);
802
575
  }
803
576
 
804
- /**
805
- * O número do PR a partir da url que o `gh pr create` imprime.
806
- *
807
- * Existe porque o número é o que um humano usa para falar do PR ("mergeia o
808
- * #51"), e a url é a única coisa que o `gh` devolve. Um formato inesperado
809
- * devolve `null` em vez de um palpite: o payload prefere admitir que não sabe
810
- * o número a afirmar um errado.
811
- *
812
- * @param {string|null} url
813
- * @returns {number|null}
814
- */
577
+ // `null` for an unexpected format: better to admit not knowing the number than state a wrong one.
815
578
  export function prNumberFromUrl(url) {
816
579
  const m = typeof url === 'string' ? url.match(/\/pull\/(\d+)(?:[/?#]|$)/) : null;
817
580
  return m ? Number(m[1]) : null;
818
581
  }
819
582
 
820
- // ── PR integration (push + gh, no local merge) ───────────────────────────────
821
-
822
583
  async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch, backupStamp }) {
823
584
  try {
824
585
  execFileSync('git', ['push', '-u', 'origin', expectedBranch], { cwd: root, stdio: 'pipe' });
@@ -836,37 +597,27 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
836
597
  process.exit(1);
837
598
  }
838
599
 
839
- // Worktree flow: remove the worktree but KEEP the branch (the open PR needs it).
840
- // Common flow: nothing to tear down; stay on morph/{f} (the default has no
841
- // feature yet — honest until the PR merges).
600
+ // The worktree flow keeps the branch (the open PR needs it); the common flow stays on morph/{f},
601
+ // since the default has no feature until the PR merges.
842
602
  let teardown = null;
843
603
  let shedBackup = null;
844
- // Reclaimed from the owning tree, before that tree goes away; skipped on
845
- // --keep, where the worktree (and its stack) stays alive on purpose.
604
+ // Skipped on --keep, where the worktree and its stack stay alive on purpose.
846
605
  const e2e = isWorktreeFlow
847
606
  ? (options.keep ? null : reclaimStack(feature, { root, worktreePath, options }))
848
607
  : reclaimStack(feature, { root, worktreePath: null, options });
849
608
  let teardownDeferred = false;
850
609
  if (isWorktreeFlow && !options.keep) {
851
- // Shed the transient root-side scaffold BEFORE teardown — exactly as --merge
852
- // does. The real `create --worktree` leaves the root's .morph/features/{f}
853
- // UNTRACKED; without this, teardown's clearRootWorktreeRegistration would
854
- // `git add` + commit that scaffold onto the default branch. Then when the PR
855
- // later merges (carrying features/{f} → archive/{f}), the default would hold
856
- // the feature in BOTH features/ and archive/ at once — active and archived.
857
- // The branch/PR owns the authoritative copy; the root scaffold is transient.
610
+ // Shed BEFORE teardown: otherwise clearRootWorktreeRegistration commits the untracked root
611
+ // scaffold to the default, and the merged PR leaves the feature in both features/ and archive/.
858
612
  shedBackup = shedRootFeatureScope(root, feature, { stamp: backupStamp }).shedBackup;
859
- // Same deferral as --merge: never tear down the worktree the running
860
- // session is anchored in (see sessionAnchoredInWorktree).
613
+ // Same deferral as --merge (sessionAnchoredInWorktree).
861
614
  if (sessionAnchoredInWorktree(worktreePath)) {
862
615
  teardownDeferred = true;
863
616
  } else {
864
617
  teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false, stamp: backupStamp });
865
618
  }
866
- // The shed removed the root's features/{f} scaffold from disk, but runArchive
867
- // dropped the feature only from the WORKTREE's index (its cwd). Drop it from
868
- // the ROOT index too — exactly as --merge does — or `doctor`'s lifecycle audit
869
- // flags a `stale-index` phantom (index lists {f}, no folder on disk).
619
+ // runArchive dropped the feature only from the WORKTREE index: drop it from the root's too, or
620
+ // `doctor` flags a `stale-index` phantom.
870
621
  dropFromRootIndex(root, feature);
871
622
  }
872
623
 
@@ -875,10 +626,7 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
875
626
 
876
627
  const out = {
877
628
  finished: true,
878
- // O comando terminou; a INTEGRAÇÃO não aconteceu. Eram a mesma coisa no
879
- // payload antigo, e é dessa colisão que sai o "shipped" para uma feature
880
- // cujo PR seguia aberto: quem lê `finished: true` sem mais nada conclui
881
- // que acabou. A default só recebe archive/{f} quando o PR mergear.
629
+ // O comando terminou, a INTEGRAÇÃO não: a default só recebe archive/{f} quando o PR mergear.
882
630
  integrated: false,
883
631
  awaiting: 'merge',
884
632
  mode: 'pr',
@@ -894,8 +642,7 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
894
642
  ? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
895
643
  : {
896
644
  worktreeRemoved: false,
897
- // isWorktreeFlow && options.keep is the only way `teardown` stays null
898
- // with a worktree still alive — the common flow has none to report.
645
+ // Only --keep leaves `teardown` null with a worktree still alive.
899
646
  ...(isWorktreeFlow && options.keep
900
647
  ? {
901
648
  worktree: { path: worktreePath, branch: expectedBranch },
@@ -914,12 +661,6 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
914
661
  process.exit(0);
915
662
  }
916
663
 
917
- // ── output helpers ────────────────────────────────────────────────────────
918
-
919
- /**
920
- * What the archive did to the ADR index (`adrIndex`) and the revocations it
921
- * could not apply (`revocationProblems`); both absent when there is nothing to report.
922
- */
923
664
  function adrIndexNote(arch) {
924
665
  return {
925
666
  ...(arch?.adrIndex ? { adrIndex: arch.adrIndex } : {}),
@@ -927,34 +668,13 @@ function adrIndexNote(arch) {
927
668
  };
928
669
  }
929
670
 
930
- /**
931
- * O único remoto com que o `finish` conversa — o mesmo que `hasOriginRemote` e
932
- * o `resolveBaseRef` do branch-classifier assumem. Um nome só, aqui, para que o
933
- * comando que a saída IMPRIME e o push que o `--push` EXECUTA jamais possam
934
- * nomear remotos diferentes.
935
- */
671
+ // Um nome só, para que o comando que a saída IMPRIME e o push que o `--push` EXECUTA jamais
672
+ // nomeiem remotos diferentes.
936
673
  const PUSH_REMOTE = 'origin';
937
674
 
938
- /**
939
- * O que sobrou por publicar depois que o merge local aterrissou — e, com
940
- * `--push`, o resultado da publicação.
941
- *
942
- * A contagem REUSA `resolveBaseRef` + `commitsAhead` (branch-classifier), em vez
943
- * de uma terceira cópia de `rev-list --count`. Detalhe que decide o P4:
944
- * `resolveBaseRef` degrada para a branch LOCAL quando não existe ref de
945
- * rastreamento remoto — comparar a branch consigo mesma devolveria um `0` de
946
- * aparência honesta para um repo que nunca deu push. Por isso a comparação só
947
- * vale quando o ref resolvido é diferente do nome da branch; caso contrário
948
- * `ahead` fica `null` ("incomparável"), nunca zero.
949
- *
950
- * Fail-open por desenho: o merge JÁ aconteceu quando esta função roda, então
951
- * nenhum resultado daqui — nem um push que falhou — pode transformar um sucesso
952
- * em exit 1. A falha vira `pushError` no payload.
953
- *
954
- * @param {{ root: string, defaultBranch: string, push?: boolean }} ctx
955
- * @returns {{ remote: string, branch: string, ahead: number|null, pushed: boolean,
956
- * published?: number|null, pushError?: string }}
957
- */
675
+ // `resolveBaseRef` degrada para a branch LOCAL sem ref remoto: comparada consigo mesma daria um
676
+ // `0` falso, então `ahead` fica `null`. Fail-open: o merge JÁ aconteceu, e um push que falha
677
+ // vira `pushError`, nunca exit 1.
958
678
  function publicationState({ root, defaultBranch, push }) {
959
679
  const baseRef = resolveBaseRef(defaultBranch, root);
960
680
  const tracked = !!baseRef && baseRef !== defaultBranch;
@@ -978,19 +698,8 @@ function publicationState({ root, defaultBranch, push }) {
978
698
  return { ...state, published: ahead, ahead: remaining, pushed: true };
979
699
  }
980
700
 
981
- /**
982
- * A frase do próximo passo — PRODUTOR ÚNICO.
983
- *
984
- * O comando se chama `finish`, e no modo `--merge` ele NÃO publica nada por
985
- * conta própria: o merge é local, o remoto continua sem a feature. Quem lê
986
- * "finished: true" conclui o contrário — foi pegadinha repetida em sessões
987
- * diferentes. Três saídas de `--merge` montavam esse aviso por conta própria, e
988
- * uma delas (o `--keep`) simplesmente esquecia dele; um produtor único é o que
989
- * impede a divergência de voltar.
990
- *
991
- * @param {ReturnType<typeof publicationState>} publish
992
- * @returns {string}
993
- */
701
+ // PRODUTOR ÚNICO: no `--merge` o merge é local e o remoto continua sem a feature; cada saída
702
+ // que montava o aviso por conta própria podia esquecê-lo.
994
703
  function mergeNextStep(publish) {
995
704
  const { remote, branch, ahead, pushed, published, pushError } = publish;
996
705
  const pushCmd = `\`git push ${remote} ${branch}\``;
@@ -1005,25 +714,15 @@ function mergeNextStep(publish) {
1005
714
  if (pushError) {
1006
715
  return `integração LOCAL — o push pedido por --push FALHOU e nada foi publicado. Rode ${pushCmd} manualmente.`;
1007
716
  }
1008
- // Sem contraparte remota não se inventa contagem (P4): fica a frase estática.
717
+ // Sem contraparte remota não se inventa contagem: fica a frase estática.
1009
718
  if (ahead === null) {
1010
719
  return 'integração LOCAL — nada foi publicado. Rode `git push` para levar ao remoto.';
1011
720
  }
1012
721
  return `integração LOCAL — nada foi publicado: ${branch} está ${ahead} commit(s) à frente de ${remote}/${branch}. Rode ${pushCmd} para levar ao remoto (ou re-execute o finish com --push).`;
1013
722
  }
1014
723
 
1015
- /**
1016
- * CONSTRUTOR ÚNICO da saída de sucesso do `--merge`. As três emissões (worktree
1017
- * normal, worktree `--keep` e fluxo comum) passam por aqui — era a terceira
1018
- * cópia, montada à mão no `--keep`, que saía sem `nextStep` nenhum.
1019
- *
1020
- * `teardown === null` cobre os dois casos sem desmonte: o `--keep` (worktree
1021
- * vivo de propósito) e o fluxo comum (não há worktree algum).
1022
- *
1023
- * @param {{ feature: string, branch: string, defaultBranch: string,
1024
- * publish: ReturnType<typeof publicationState>, worktreePath?: string|null,
1025
- * teardown?: object|null, shedBackup?: string|null, kept?: boolean }} args
1026
- */
724
+ // CONSTRUTOR ÚNICO das três saídas de sucesso do `--merge`. `teardown === null` cobre o `--keep`
725
+ // e o fluxo comum.
1027
726
  function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = null, teardown = null, shedBackup = null, kept = false, deferred = false }) {
1028
727
  return {
1029
728
  finished: true,
@@ -1040,9 +739,7 @@ function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = n
1040
739
  ...(shedBackup ? { shedBackup } : {}),
1041
740
  ...(kept
1042
741
  ? {
1043
- // Worktree preserved on purpose: the root's `worktree` registration stays
1044
- // VALID (it still names a live directory), so nothing to clean up here —
1045
- // only report where it lives and how to close it later.
742
+ // The root registration stays VALID (it names a live directory): nothing to clean up.
1046
743
  worktree: { path: worktreePath, branch },
1047
744
  hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
1048
745
  }
@@ -1051,12 +748,7 @@ function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = n
1051
748
  };
1052
749
  }
1053
750
 
1054
- /**
1055
- * Hint for a teardown deferred because the session is anchored in the worktree
1056
- * (both modes). Names the safe command, never `git worktree remove`. `--merge`
1057
- * would have deleted the (now integrated) branch, so its hint carries
1058
- * `--delete-branch`; `--pr` keeps the branch — the PR needs it.
1059
- */
751
+ // Names the safe command, never `git worktree remove`. `--pr` keeps the branch: the PR needs it.
1060
752
  function deferredTeardownHint(feature, worktreePath, { deleteBranch = false } = {}) {
1061
753
  return `worktree mantido em ${worktreePath}: a sessão do Claude Code que rodou o finish está ancorada nele, `
1062
754
  + 'e desmontá-lo agora quebraria todos os hooks dela (eles resolvem `.morph/framework` por ele) — '
@@ -1064,19 +756,14 @@ function deferredTeardownHint(feature, worktreePath, { deleteBranch = false } =
1064
756
  + 'de uma sessão aberta na raiz do repo.';
1065
757
  }
1066
758
 
1067
- /** Shared teardown reporting for both modes (kept identical to the old finish). */
1068
759
  function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
1069
760
  const out = {};
1070
761
  if (teardown.ignoredBackup) out.worktreeIgnoredBackup = teardown.ignoredBackup;
1071
762
  if (teardown.worktreeRemoved && teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1072
- // WHAT was copied out, and what needed no copy because the primary root holds
1073
- // the identical bytes. Reported on both paths: after a removal these two lists
1074
- // are the whole answer to "did I just lose something?", and the user should
1075
- // not have to open the backup directory to find out.
763
+ // After a removal these two lists answer "did I just lose something?".
1076
764
  if (teardown.shieldable?.length) out.worktreeIgnoredBackedUp = teardown.shieldable;
1077
765
  if (teardown.replicable?.length) out.replicatedInRoot = teardown.replicable;
1078
- // The infra reparse points dropped BEFORE git touched the directory — the
1079
- // proof the primary root's real `.claude/` was unlinked, never followed.
766
+ // Proof the primary root's real `.claude/` was unlinked, never followed.
1080
767
  if (teardown.unlinked?.length) out.unlinked = teardown.unlinked;
1081
768
  if (!teardown.worktreeRemoved) {
1082
769
  out.path = worktreePath;
@@ -1084,16 +771,8 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
1084
771
  if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
1085
772
  if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
1086
773
  out.cleanupHint = teardown.declineReason;
1087
- // The unlink runs before the removal by design (it is what keeps the removal
1088
- // from following the junctions out into the primary root), so every
1089
- // non-removal passes through a moment with the infra loose. What happens
1090
- // next depends on WHY, and the two must not be reported alike:
1091
- // REFUSED — we proved removal unsafe, the user keeps working here, so the
1092
- // teardown re-links (A4).
1093
- // FAILED — removal was the intent and a lock got in the way. The infra
1094
- // stays down ON PURPOSE, so the retry (or anything pasted from an old
1095
- // transcript) has nothing to traverse. Say so explicitly, or the user
1096
- // reads a bare "failed" and reaches for the destructive command.
774
+ // REFUSED re-links (the user keeps working here); FAILED leaves the infra down ON PURPOSE so a
775
+ // retry has nothing to traverse. Say which, or a bare "failed" invites the destructive command.
1097
776
  out.relinked = teardown.relinked === true;
1098
777
  if (!teardown.refused) {
1099
778
  if (teardown.infraLeftUnlinked?.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
@@ -1107,8 +786,6 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
1107
786
  return out;
1108
787
  }
1109
788
 
1110
- // ── small git helpers ────────────────────────────────────────────────────────
1111
-
1112
789
  function currentBranch(cwd) {
1113
790
  try {
1114
791
  return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
@@ -1143,27 +820,8 @@ function gitErr(err) {
1143
820
  return (((err.stdout && err.stdout.toString()) || '') + ((err.stderr && err.stderr.toString()) || '')).slice(0, 2000) || (err.message || '').slice(0, 500);
1144
821
  }
1145
822
 
1146
- /**
1147
- * Where a feature that is NOT in `features/` actually went, as extra fields on
1148
- * the `feature-not-found` payload.
1149
- *
1150
- * Exists because "not found" is the least useful thing we can say about a
1151
- * feature the user just worked on. The two real cases:
1152
- *
1153
- * - ARCHIVED: an earlier finish archived it and then failed to integrate.
1154
- * What is missing is the merge, not the whole finish — say so, because the
1155
- * user's instinct is to re-run `finish` and get refused again.
1156
- * - ON ITS BRANCH: the feature was created on `morph/{f}` and the command is
1157
- * running from the default branch, where `.morph/features/{f}` does not
1158
- * exist yet. Naming the branch turns a dead end into one checkout.
1159
- *
1160
- * Fail-open in every direction: a hint that cannot be computed is simply absent.
1161
- *
1162
- * @param {string} featureCwd - the tree we looked in
1163
- * @param {string} root - the primary root
1164
- * @param {string} feature
1165
- * @returns {{ archived?: string, hint?: string, onBranch?: string }}
1166
- */
823
+ // Where a feature not in `features/` went: ARCHIVED (only the merge is missing) or on its
824
+ // `morph/{f}` branch. Fail-open: a hint that cannot be computed is absent.
1167
825
  function featureElsewhereHint(featureCwd, root, feature) {
1168
826
  try {
1169
827
  const archived = join(featureCwd, '.morph', 'archive', feature);
@@ -1189,12 +847,6 @@ function featureElsewhereHint(featureCwd, root, feature) {
1189
847
  return { hint: `'${feature}' não existe em .morph/features/ nesta árvore. Confira o nome com \`morph-spec list --all\`.` };
1190
848
  }
1191
849
 
1192
- /**
1193
- * @param {string} cwd
1194
- * @param {string} feature
1195
- * @param {'features'|'archive'} [bucket] - `archive` on a resume, where the
1196
- * feature.json has already been moved (and stamped `completed`).
1197
- */
1198
850
  function readFeatureJson(cwd, feature, bucket = 'features') {
1199
851
  try {
1200
852
  return JSON.parse(readFileSync(join(cwd, '.morph', bucket, feature, 'feature.json'), 'utf8'));
@@ -1204,19 +856,8 @@ function readFeatureJson(cwd, feature, bucket = 'features') {
1204
856
  }
1205
857
 
1206
858
  /**
1207
- * Is `branch` already contained in `target` — i.e. did an earlier finish get its
1208
- * merge in?
1209
- *
1210
- * Three-valued ON PURPOSE. `git merge-base --is-ancestor` answers by exit code,
1211
- * and the two failure modes mean opposite things: **1** is a real "no, not
1212
- * merged", while **128** is "one of these refs does not resolve" and proves
1213
- * nothing at all. Collapsing them to a boolean would read an unresolvable ref as
1214
- * "not merged" and let a resume re-merge a completed feature.
1215
- *
1216
- * @param {string} branch
1217
- * @param {string} target
1218
- * @param {string} cwd
1219
- * @returns {boolean|null} null when the comparison could not be made
859
+ * Three-valued ON PURPOSE: `merge-base --is-ancestor` exit 1 is "not merged", 128 is "a ref does
860
+ * not resolve". `null` when the comparison could not be made.
1220
861
  */
1221
862
  export function branchMergedInto(branch, target, cwd) {
1222
863
  try {
@@ -1227,20 +868,8 @@ export function branchMergedInto(branch, target, cwd) {
1227
868
  }
1228
869
  }
1229
870
 
1230
- /**
1231
- * Does `branch` already have an OPEN pull request? The `--pr` half of
1232
- * "already integrated" — a completed `--pr` leaves the branch unmerged on
1233
- * purpose, so `branchMergedInto` cannot see it.
1234
- *
1235
- * Three-valued for the same reason: an unanswerable query (no `gh`, no auth, no
1236
- * network) is not evidence of "no PR", and treating it as such would resume
1237
- * into a duplicate `gh pr create`.
1238
- *
1239
- * @param {string} branch
1240
- * @param {string} cwd
1241
- * @returns {string|boolean|null} the PR url, `true` (open PR, url unavailable),
1242
- * `false` (none open), or `null` when the question could not be asked
1243
- */
871
+ // Three-valued: an unanswerable query (no `gh`, auth or network) is not evidence of "no PR".
872
+ // Returns the PR url, `true` (open, url unknown), `false` (none) or `null`.
1244
873
  function openPrFor(branch, cwd) {
1245
874
  const { bin, prefix } = resolveGh();
1246
875
  try {
@@ -1255,13 +884,7 @@ function openPrFor(branch, cwd) {
1255
884
  }
1256
885
  }
1257
886
 
1258
- /**
1259
- * Reclaim the feature's verification stack (containers, ports, named volumes,
1260
- * freshness stamp) from the tree that owns it. `--keep-volumes` keeps the
1261
- * named volumes (e.g. a seeded database) for the next feature. Best-effort by
1262
- * contract — a stack that refuses to come down must never fail a finish that
1263
- * already merged, so this never throws.
1264
- */
887
+ // Never throws: a stack that refuses to come down must not fail a finish that already merged.
1265
888
  function reclaimStack(feature, { root, worktreePath, options }) {
1266
889
  try {
1267
890
  return tearDownFeatureStack(feature, { root, worktreePath, volumes: !options.keepVolumes });
@@ -1270,7 +893,7 @@ function reclaimStack(feature, { root, worktreePath, options }) {
1270
893
  }
1271
894
  }
1272
895
 
1273
- /** Drop a feature from the thin, gitignored root index (never via saveState). */
896
+ // Never via saveState, which would re-materialize the feature folder.
1274
897
  function dropFromRootIndex(root, feature) {
1275
898
  const rootPath = join(root, '.morph', 'state.json');
1276
899
  if (!existsSync(rootPath)) return;