@polymorphism-tech/morph-spec 7.9.4 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +31 -2
  3. package/bin/morph-spec.js +50 -2
  4. package/bin/validate.js +379 -379
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +38 -1
  7. package/docs/QUICKSTART.md +60 -1
  8. package/framework/CLAUDE.md +13 -2
  9. package/framework/MORPH.md +38 -2
  10. package/framework/agents.json +22 -3
  11. package/framework/commands/commit.md +166 -166
  12. package/framework/commands/morph-hotfix.md +39 -0
  13. package/framework/commands/morph-infra.md +215 -215
  14. package/framework/commands/morph-preflight.md +205 -205
  15. package/framework/commands/morph-troubleshoot.md +124 -124
  16. package/framework/hooks/README.md +25 -10
  17. package/framework/hooks/claude-code/core/gate-guard.js +4 -2
  18. package/framework/hooks/claude-code/core/loop-detect.js +2 -1
  19. package/framework/hooks/claude-code/core/post-edit-typecheck.js +14 -5
  20. package/framework/hooks/claude-code/core/tasks-json-guard.js +4 -2
  21. package/framework/hooks/claude-code/core/validate-completion.js +1 -1
  22. package/framework/hooks/claude-code/core/worktree-context.js +160 -0
  23. package/framework/hooks/claude-code/statusline.py +679 -679
  24. package/framework/hooks/claude-code/statusline.sh +11 -11
  25. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  26. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  27. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  28. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  29. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  30. package/framework/hooks/shared/activity-logger.js +160 -160
  31. package/framework/hooks/shared/build-check.js +2 -2
  32. package/framework/hooks/shared/hook-response.js +17 -5
  33. package/framework/hooks/shared/payload-utils.js +42 -0
  34. package/framework/hooks/shared/state-reader.js +22 -2
  35. package/framework/hooks/shared/stdin-reader.js +26 -26
  36. package/framework/rubrics/backend.json +41 -41
  37. package/framework/rules/ai-agents-standards.md +45 -45
  38. package/framework/rules/dotnet-templates.md +86 -86
  39. package/framework/rules/frontend-standards.md +13 -13
  40. package/framework/rules/infrastructure-standards.md +27 -27
  41. package/framework/rules/neon-standards.md +25 -25
  42. package/framework/rules/nextjs-standards.md +20 -20
  43. package/framework/rules/nextjs-templates.md +40 -40
  44. package/framework/schemas/feature.schema.json +95 -1
  45. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  46. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  47. package/framework/skills/core/morph-apply/SKILL.md +39 -8
  48. package/framework/skills/core/morph-brainstorming/SKILL.md +9 -1
  49. package/framework/skills/core/morph-eval/SKILL.md +6 -1
  50. package/framework/skills/core/morph-hotfix/SKILL.md +136 -0
  51. package/framework/skills/core/morph-implement/SKILL.md +8 -4
  52. package/framework/skills/core/morph-plan/SKILL.md +5 -1
  53. package/framework/skills/core/morph-proposal/SKILL.md +24 -1
  54. package/framework/skills/core/morph-review/SKILL.md +3 -2
  55. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  56. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  57. package/framework/standards/ai-agents/agent-session.md +160 -160
  58. package/framework/standards/ai-agents/batching.md +193 -193
  59. package/framework/standards/ai-agents/context-providers.md +188 -188
  60. package/framework/standards/ai-agents/mcp-server.md +250 -250
  61. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  62. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  63. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  64. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  65. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  66. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  67. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  68. package/framework/standards/ai-agents/production.md +275 -275
  69. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  70. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  71. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  72. package/framework/standards/ai-agents/structured-output.md +249 -249
  73. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  74. package/framework/templates/.idea/morph-templates.xml +92 -0
  75. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  76. package/framework/templates/IDE-SNIPPETS.md +262 -262
  77. package/framework/templates/REGISTRY.json +0 -62
  78. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  79. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  80. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  81. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  82. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  83. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  84. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  85. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  86. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  87. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  88. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  89. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  90. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  91. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  92. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  93. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  94. package/framework/templates/code/typescript/contracts.ts +167 -167
  95. package/framework/templates/docs/clarifications.md +253 -253
  96. package/framework/templates/docs/schema-analysis.md +119 -119
  97. package/framework/templates/docs/ui-components.md +52 -52
  98. package/framework/templates/docs/ui-design-system.md +76 -76
  99. package/framework/templates/docs/ui-flows.md +167 -167
  100. package/framework/templates/docs/ui-mockups.md +98 -98
  101. package/framework/templates/feature/decisions.md +122 -122
  102. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  103. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  104. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  105. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  106. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  107. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  108. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  109. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  110. package/framework/templates/infrastructure/docker/docker-compose.template.yml +50 -50
  111. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  112. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  113. package/framework/templates/infrastructure/github/README.md +56 -56
  114. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  115. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  116. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  117. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  118. package/framework/templates/integrations/asaas-client.cs +387 -387
  119. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  120. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  121. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  122. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  123. package/framework/templates/project/validate.js +120 -120
  124. package/framework/templates/state.template.json +17 -17
  125. package/framework/templates/ui/design-system.css +226 -226
  126. package/package.json +1 -1
  127. package/src/commands/dashboard/dashboard.js +53 -53
  128. package/src/commands/index.js +13 -13
  129. package/src/commands/mcp/mcp-setup.js +519 -519
  130. package/src/commands/project/checkpoint.js +209 -209
  131. package/src/commands/project/doctor.js +4 -2
  132. package/src/commands/project/init.js +46 -2
  133. package/src/commands/project/update.js +5 -3
  134. package/src/commands/project/worktree.js +1005 -46
  135. package/src/commands/state/advance.js +17 -2
  136. package/src/commands/state/approve.js +18 -1
  137. package/src/commands/state/create.js +158 -14
  138. package/src/commands/state/retype.js +126 -0
  139. package/src/commands/state/score.js +4 -0
  140. package/src/commands/tasks/dag.js +32 -0
  141. package/src/commands/validation/index.js +1 -0
  142. package/src/commands/validation/validate-feature.js +33 -5
  143. package/src/commands/validation/verify.js +190 -0
  144. package/src/core/state/phase-state-machine.js +197 -197
  145. package/src/core/state/state-manager.js +35 -10
  146. package/src/core/templates/template-renderer.js +477 -477
  147. package/src/core/work-types.js +79 -0
  148. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  149. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  150. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  151. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  152. package/src/dashboard/app/main.jsx +13 -13
  153. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  154. package/src/dashboard/app/pages/HookInspector.jsx +418 -418
  155. package/src/dashboard/app/pages/LiveState.jsx +11 -1
  156. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  157. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  158. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  159. package/src/dashboard/app/pages/Standards.jsx +332 -332
  160. package/src/dashboard/app/styles/index.css +47 -47
  161. package/src/dashboard/index.html +12 -12
  162. package/src/dashboard/package.json +24 -24
  163. package/src/dashboard/scanner/hooks-scanner.js +41 -41
  164. package/src/dashboard/scanner/phases-scanner.js +15 -15
  165. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  166. package/src/dashboard/scanner/rules-scanner.js +37 -37
  167. package/src/dashboard/scanner/skills-scanner.js +43 -43
  168. package/src/dashboard/scanner/squads-scanner.js +13 -13
  169. package/src/dashboard/scanner/standards-scanner.js +26 -26
  170. package/src/dashboard/scanner/state-scanner.js +42 -0
  171. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  172. package/src/dashboard/vite.config.js +24 -24
  173. package/src/lib/agents/metrics-aggregator.js +56 -56
  174. package/src/lib/agents/metrics-calculator.js +125 -125
  175. package/src/lib/agents/output-collector.js +73 -73
  176. package/src/lib/detectors/claude-config-detector.js +152 -152
  177. package/src/lib/detectors/design-system-detector.js +231 -231
  178. package/src/lib/detectors/index.js +217 -217
  179. package/src/lib/generators/architecture-scanner.js +639 -639
  180. package/src/lib/generators/design-system-generator.js +508 -508
  181. package/src/lib/generators/index.js +13 -13
  182. package/src/lib/generators/settings-generator.js +209 -209
  183. package/src/lib/hooks/hook-executor.js +167 -167
  184. package/src/lib/installers/mcp-installer.js +719 -719
  185. package/src/lib/installers/memory-installer.js +1 -1
  186. package/src/lib/risk-detector.js +16 -0
  187. package/src/lib/tasks/dispatch-planner.js +122 -4
  188. package/src/lib/tasks/mermaid-renderer.js +132 -0
  189. package/src/lib/tasks/task-classifier.js +30 -30
  190. package/src/lib/tasks/task-parser.js +209 -209
  191. package/src/lib/tasks/test-runner.js +27 -6
  192. package/src/lib/validators/architecture/architecture-validator.js +259 -259
  193. package/src/lib/validators/checkpoint-persistence.js +39 -39
  194. package/src/lib/validators/contracts/contract-compliance-validator.js +274 -274
  195. package/src/lib/validators/css/css-validator.js +340 -340
  196. package/src/lib/validators/packages/package-validator.js +347 -347
  197. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  198. package/src/lib/validators/shared/issue-counter.js +14 -14
  199. package/src/lib/validators/validation-runner.js +349 -349
  200. package/src/lib/validators/verify-runner.js +322 -0
  201. package/src/lib/work-type-detector.js +143 -0
  202. package/src/scripts/setup-infra.js +32 -5
  203. package/src/utils/agent-id-mapper.js +11 -11
  204. package/src/utils/agents-installer.js +10 -6
  205. package/src/utils/claude-settings-manager.js +300 -300
  206. package/src/utils/file-copier.js +43 -0
  207. package/src/utils/hooks-installer.js +41 -11
  208. package/src/utils/skills-installer.js +174 -174
  209. package/framework/templates/meta-prompts/parallel-workers/parallel-coordinator.md +0 -113
  210. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +0 -80
  211. package/src/lib/tasks/wave-resolver.js +0 -353
@@ -1,21 +1,43 @@
1
1
  /**
2
- * MORPH-SPEC Worktree Setup Command
2
+ * MORPH-SPEC Worktree Command Suite
3
3
  *
4
- * Creates a git worktree at .worktrees/{feature}/ on branch morph/{feature}.
5
- * Used by SessionStart hook to isolate feature work from the main branch.
4
+ * Isolates feature work into per-feature git worktrees at .worktrees/{feature}/
5
+ * on branch morph/{feature}, so each feature can run its own Claude Code
6
+ * instance (v8 multi-instance, parallel-feature-development flow).
6
7
  *
7
- * Usage:
8
- * morph-spec worktree setup <feature>
9
- * morph-spec worktree setup <feature> --fresh
8
+ * `.morph/features/{f}/feature.json` is the authoritative, committed state — it
9
+ * travels with the worktree's branch. `.morph/state.json` is a thin, gitignored,
10
+ * derivable index; `worktree setup` MATERIALIZES a local one via loadState()'s
11
+ * self-heal rather than linking to the main copy (a hard link breaks silently on
12
+ * the first atomic write, since tmp+rename creates a new inode).
10
13
  *
11
- * Exit codes:
14
+ * Subcommands:
15
+ * morph-spec worktree setup <feature> [--fresh] — create + materialize state
16
+ * morph-spec worktree link [--target <path>] [--all] — (re)link shared infra
17
+ * morph-spec worktree finish <feature> [--keep] [--force] — merge-back + cleanup
18
+ * morph-spec worktree list [--json] — list active feature worktrees
19
+ *
20
+ * Exit codes (setup):
12
21
  * 0 — success (created) or fail-open (not a git repo, git error)
13
- * 2 — worktree already exists (signals resume scenario to hook caller)
22
+ * 1 — refused: feature name is not a plain kebab slug
23
+ * 2 — worktree already exists at this exact path (signals resume to caller)
24
+ * 3 — a live worktree for this feature already exists at a DIFFERENT path
25
+ * (anti-dupe guard — refuses to fork a second live instance)
26
+ *
27
+ * Exit codes (finish):
28
+ * 0 — merged successfully (or fail-open in a non-git dir is not applicable here)
29
+ * 1 — refused: invalid feature name, no worktree found, the worktree is on a
30
+ * different branch, Gate 3 not approved, worktree/root dirty, or merge
31
+ * conflict. Every refusal is fail-CLOSED — no merge lands and no worktree
32
+ * is removed. The shed runs before the root's clean check, so a refusal
33
+ * from that point on has moved this feature's root-side scope to the
34
+ * backup it reports; see worktreeFinishCommand.
14
35
  */
15
36
 
16
- import { execSync } from 'child_process';
17
- import { existsSync, mkdirSync, symlinkSync, linkSync, copyFileSync } from 'fs';
18
- import { join, resolve, dirname } from 'path';
37
+ import { execSync, execFileSync } from 'child_process';
38
+ import { existsSync, mkdirSync, symlinkSync, readFileSync, lstatSync, unlinkSync, rmdirSync, readdirSync, cpSync } from 'fs';
39
+ import { join, resolve, dirname, relative } from 'path';
40
+ import chalk from 'chalk';
19
41
 
20
42
  // ── Pure helpers (exported for tests) ──────────────────────────────────────
21
43
 
@@ -24,6 +46,24 @@ import { join, resolve, dirname } from 'path';
24
46
  * @param {string} cwd
25
47
  * @returns {boolean}
26
48
  */
49
+ /**
50
+ * Is `name` a plain kebab-case feature name?
51
+ *
52
+ * Mirrors the validation `morph-spec create` applies, and every worktree
53
+ * subcommand enforces it for the same reason plus a sharper one: a feature name
54
+ * flows into `.worktrees/{name}`, the branch `morph/{name}`, and from there into
55
+ * git invocations. Characters like `& | ; > ^` are legal in a git refname but
56
+ * are cmd.exe metacharacters — on Windows an unvalidated name turns any
57
+ * string-interpolated git call into command injection, and makes a `finish`
58
+ * merge a DIFFERENT branch than the one it validated and reports.
59
+ *
60
+ * @param {string} name
61
+ * @returns {boolean}
62
+ */
63
+ export function isValidFeatureName(name) {
64
+ return typeof name === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(name);
65
+ }
66
+
27
67
  export function isGitRepo(cwd = process.cwd()) {
28
68
  try {
29
69
  execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' });
@@ -50,6 +90,17 @@ export function branchExists(branch, cwd = process.cwd()) {
50
90
 
51
91
  /**
52
92
  * Check if a worktree path is already registered with git.
93
+ *
94
+ * BUGFIX: the old `normalizedPath.startsWith('/')` absolute-path check only
95
+ * recognized POSIX-style absolute paths. On Windows, `buildWorktreePath`
96
+ * already returns an absolute `C:/...` path, which does NOT start with `/` —
97
+ * so this used to re-prepend `cwd` onto an already-absolute path, producing
98
+ * a bogus double-rooted string that never matched `git worktree list`'s
99
+ * output. That silently broke the Gate 2 "already exists → resume" check on
100
+ * Windows (it always fell through to a failing `git worktree add`, caught by
101
+ * the outer fail-open handler). `path.resolve(cwd, worktreePath)` handles
102
+ * both relative and platform-absolute paths correctly.
103
+ *
53
104
  * @param {string} worktreePath Relative or absolute path
54
105
  * @param {string} cwd
55
106
  * @returns {boolean}
@@ -57,8 +108,7 @@ export function branchExists(branch, cwd = process.cwd()) {
57
108
  export function worktreeExists(worktreePath, cwd = process.cwd()) {
58
109
  try {
59
110
  const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
60
- const normalizedPath = worktreePath.replace(/\\/g, '/');
61
- const absPath = normalizedPath.startsWith('/') ? normalizedPath : `${cwd.replace(/\\/g, '/')}/${normalizedPath}`;
111
+ const absPath = resolve(cwd, worktreePath).replace(/\\/g, '/');
62
112
  return output.replace(/\\/g, '/').includes(absPath);
63
113
  } catch {
64
114
  return false;
@@ -96,26 +146,38 @@ function getDateSuffix() {
96
146
  // ── Infrastructure linking ───────────────────────────────────────────────────
97
147
 
98
148
  /**
99
- * Create symlinks/junctions in the worktree for shared infrastructure files
100
- * that are gitignored (state.json, .claude/, .morph/framework/, node_modules/).
149
+ * Create junctions in the worktree for shared infrastructure directories
150
+ * (node_modules/, .claude/, .morph/framework/).
151
+ *
152
+ * `.morph/state.json` is deliberately NEVER linked here — a hard link breaks
153
+ * silently the moment either side writes atomically (tmp file + rename creates
154
+ * a fresh inode, severing the link), so worktree and main state can silently
155
+ * diverge. The worktree's local state.json is MATERIALIZED instead, see
156
+ * `materializeState()`. `memory/` and `logs/` are also never linked — each
157
+ * worktree instance owns its own debounce/session snapshots.
101
158
  *
102
159
  * Uses junctions for directories (no admin required on Windows).
103
- * File symlinks fall back to copy if symlink fails.
104
160
  * Fail-open: any error is silently ignored.
105
161
  *
106
162
  * @param {string} worktreePath - Absolute path to the worktree
107
163
  * @param {string} mainCwd - Absolute path to the main project root
164
+ * @param {{ dirs?: string[] }} [opts] - When `dirs` is provided, restricts
165
+ * which junctions are (re)created to exactly that list. Default: node_modules,
166
+ * .claude, and .morph/framework (the last only if it exists in the main root).
108
167
  */
109
- export function setupInfraLinks(worktreePath, mainCwd) {
168
+ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
110
169
  const mainAbs = resolve(mainCwd);
111
170
  const wtAbs = resolve(worktreePath);
112
171
 
113
- // Directory junctions — shared across worktrees
114
- const dirLinks = ['node_modules', '.claude'];
115
-
116
- // .morph/framework/ — client projects (in framework repo, framework/ is tracked)
117
- if (existsSync(join(mainAbs, '.morph', 'framework'))) {
118
- dirLinks.push(join('.morph', 'framework'));
172
+ let dirLinks;
173
+ if (Array.isArray(opts.dirs) && opts.dirs.length) {
174
+ dirLinks = opts.dirs;
175
+ } else {
176
+ dirLinks = ['node_modules', '.claude'];
177
+ // .morph/framework/ — client projects (in framework repo, framework/ is tracked)
178
+ if (existsSync(join(mainAbs, '.morph', 'framework'))) {
179
+ dirLinks.push(join('.morph', 'framework'));
180
+ }
119
181
  }
120
182
 
121
183
  for (const rel of dirLinks) {
@@ -128,38 +190,102 @@ export function setupInfraLinks(worktreePath, mainCwd) {
128
190
  symlinkSync(target, linkPath, 'junction');
129
191
  } catch { /* fail-open */ }
130
192
  }
193
+ }
131
194
 
132
- // state.json — shared state via hard link (works on Windows without admin,
133
- // same NTFS volume). Falls back to symlink, then copy.
134
- const stateTarget = join(mainAbs, '.morph', 'state.json');
135
- const stateLink = join(wtAbs, '.morph', 'state.json');
136
- if (existsSync(stateTarget) && !existsSync(stateLink)) {
137
- const morphDir = join(wtAbs, '.morph');
138
- if (!existsSync(morphDir)) {
139
- try { mkdirSync(morphDir, { recursive: true }); } catch { /* */ }
195
+ /**
196
+ * Resolve the absolute path (forward slashes) of the primary/main git worktree,
197
+ * from anywhere inside the repo (including from within a secondary worktree).
198
+ *
199
+ * Fail-open: if `cwd` is not a git repo (or the git call errors), returns
200
+ * `cwd` itself, resolved and normalized.
201
+ *
202
+ * @param {string} cwd
203
+ * @returns {string} Absolute, forward-slash path to the primary worktree
204
+ */
205
+ export function resolvePrimaryRoot(cwd = process.cwd()) {
206
+ try {
207
+ const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
208
+ const line = output.split('\n').find((l) => l.startsWith('worktree '));
209
+ if (line) {
210
+ return line.slice('worktree '.length).trim().replace(/\\/g, '/');
140
211
  }
141
- try {
142
- linkSync(stateTarget, stateLink); // hard link — no admin, shared data
143
- } catch {
144
- try { copyFileSync(stateTarget, stateLink); } catch { /* fail-open */ }
212
+ } catch { /* fall through to fail-open */ }
213
+ return resolve(cwd).replace(/\\/g, '/');
214
+ }
215
+
216
+ /**
217
+ * Materialize a LOCAL, real `.morph/state.json` inside a worktree, reconstructed
218
+ * from the committed per-feature `feature.json` files under `.morph/features/`
219
+ * — not linked to the main copy (see `setupInfraLinks` doc for why hard links
220
+ * are unsafe here).
221
+ *
222
+ * Mechanism: temporarily chdir into the worktree and call `loadState(false)`,
223
+ * whose self-heal path (state-manager.js) scaffolds + hydrates + writes a fresh
224
+ * root index when `.morph/state.json` is missing but per-feature files exist.
225
+ * `process.chdir` is process-global, so the original cwd is ALWAYS restored via
226
+ * finally, even on error. Fail-open: never throws.
227
+ *
228
+ * @param {string} worktreePath - Absolute path to the worktree
229
+ * @returns {Promise<{ materialized: boolean, reason?: string }>}
230
+ */
231
+ export async function materializeState(worktreePath) {
232
+ const featuresDir = join(worktreePath, '.morph', 'features');
233
+ if (!existsSync(featuresDir)) {
234
+ return { materialized: false, reason: 'no-features' };
235
+ }
236
+
237
+ const prevCwd = process.cwd();
238
+ try {
239
+ process.chdir(resolve(worktreePath));
240
+ const { loadState } = await import('../../core/state/state-manager.js');
241
+ loadState(false); // self-heal: writes a fresh local .morph/state.json
242
+ // loadState(false) only writes state.json when it actually found something
243
+ // to hydrate (a non-empty .morph/features/). A present-but-empty features
244
+ // dir (e.g. right after `archive` moved the last feature out) makes
245
+ // loadState return null and write nothing — don't report success in that
246
+ // case, or callers believe a local state.json exists when it doesn't.
247
+ if (existsSync(join(worktreePath, '.morph', 'state.json'))) {
248
+ return { materialized: true };
145
249
  }
250
+ return { materialized: false, reason: 'no-features-to-hydrate' };
251
+ } catch (err) {
252
+ return { materialized: false, reason: (err && err.message) || 'unknown' };
253
+ } finally {
254
+ process.chdir(prevCwd);
146
255
  }
147
256
  }
148
257
 
149
258
  // ── Command handler ─────────────────────────────────────────────────────────
150
259
 
151
260
  /**
152
- * Main command handler for `morph-spec worktree setup <feature>`
261
+ * Non-exiting core of `worktree setup` — does all the real work (git checks,
262
+ * guards, branch creation, `git worktree add`, infra linking, state
263
+ * materialization, metadata persistence) and returns a result object instead
264
+ * of calling `process.exit`/`console.log`, so callers (e.g. a future
265
+ * `create --worktree` that needs to chain further steps) can reuse it in
266
+ * process. `worktreeSetupCommand` below is a thin CLI wrapper around this.
267
+ *
153
268
  * @param {string} feature
154
- * @param {{ fresh?: boolean, cwd?: string }} options
269
+ * @param {{ fresh?: boolean, cwd?: string }} [options]
270
+ * @returns {Promise<{ ok: boolean, exitCode: 0|1|2|3, created?: boolean, alreadyExists?: boolean, path?: string, branch?: string, feature: string, materialized?: boolean, nextStep?: string, error?: string, existingPath?: string }>}
271
+ * exitCode 1 = refused, `error: 'invalid-feature-name'`. This function is the
272
+ * non-exiting core precisely so callers can reuse it in-process — a caller
273
+ * trusting a `0|2|3` union would mishandle that refusal.
155
274
  */
156
- export async function worktreeSetupCommand(feature, options = {}) {
275
+ export async function runWorktreeSetup(feature, options = {}) {
157
276
  const cwd = options.cwd || process.cwd();
158
277
 
278
+ // Gate 0: the name must be a plain kebab slug before it reaches a path, a
279
+ // refname, or any git call. See isValidFeatureName — `create` already refuses
280
+ // these names, so accepting them here would only ever create a worktree no
281
+ // other morph-spec command can address.
282
+ if (!isValidFeatureName(feature)) {
283
+ return { ok: false, exitCode: 1, created: false, error: 'invalid-feature-name', feature };
284
+ }
285
+
159
286
  // Gate 1: Must be a git repo
160
287
  if (!isGitRepo(cwd)) {
161
- console.log(JSON.stringify({ created: false, error: 'not-a-git-repo', feature }));
162
- process.exit(0); // Fail-open
288
+ return { ok: true, exitCode: 0, created: false, error: 'not-a-git-repo', feature };
163
289
  }
164
290
 
165
291
  const branch = buildBranchName(feature, options);
@@ -167,10 +293,29 @@ export async function worktreeSetupCommand(feature, options = {}) {
167
293
 
168
294
  // Gate 2: If worktree already registered and not --fresh, signal resume
169
295
  if (!options.fresh && worktreeExists(worktreePath, cwd)) {
170
- console.log(JSON.stringify({ created: false, alreadyExists: true, path: worktreePath, branch, feature }));
171
- process.exit(2); // Convention: 2 = already exists
296
+ return { ok: false, exitCode: 2, created: false, alreadyExists: true, path: worktreePath, branch, feature };
172
297
  }
173
298
 
299
+ // Guard 3: anti-dupe — refuse to fork a second LIVE worktree for the same
300
+ // feature. If feature.json already records a worktree path that still exists
301
+ // on disk and differs from the one this call would create, stop (exit 3).
302
+ // (A recorded path that's gone from disk — e.g. removed via `git worktree
303
+ // remove` — does not block: existsSync() is false, so this falls through.)
304
+ try {
305
+ const featureJsonPath = join(cwd, '.morph', 'features', feature, 'feature.json');
306
+ if (existsSync(featureJsonPath)) {
307
+ const fj = JSON.parse(readFileSync(featureJsonPath, 'utf8'));
308
+ const existingPath = fj?.worktree?.path;
309
+ if (existingPath) {
310
+ const normalizedExisting = existingPath.replace(/\\/g, '/');
311
+ const normalizedNew = worktreePath.replace(/\\/g, '/');
312
+ if (normalizedExisting !== normalizedNew && existsSync(existingPath)) {
313
+ return { ok: false, exitCode: 3, created: false, error: 'worktree-exists-elsewhere', existingPath, feature };
314
+ }
315
+ }
316
+ }
317
+ } catch { /* fail-open — malformed feature.json must never block setup */ }
318
+
174
319
  try {
175
320
  // Create branch if needed (based off current HEAD)
176
321
  if (!branchExists(branch, cwd)) {
@@ -186,9 +331,13 @@ export async function worktreeSetupCommand(feature, options = {}) {
186
331
  // Add worktree
187
332
  execSync(`git worktree add "${worktreePath}" ${branch}`, { cwd, stdio: 'pipe' });
188
333
 
189
- // Set up infrastructure symlinks so the worktree has state.json, .claude/, etc.
334
+ // Set up infrastructure junctions (node_modules, .claude, .morph/framework).
190
335
  setupInfraLinks(worktreePath, cwd);
191
336
 
337
+ // Materialize a local, real state.json in the worktree (self-heal from the
338
+ // committed feature.json files) so hooks work from the very first session.
339
+ const materialized = await materializeState(worktreePath);
340
+
192
341
  // Persist in state (non-blocking — feature may not be in state yet).
193
342
  // NOTE: imports from core/ (the lib/ path was a latent bug that silently
194
343
  // no-op'd this whole block); loadState/saveState operate on the cwd's state.
@@ -203,10 +352,820 @@ export async function worktreeSetupCommand(feature, options = {}) {
203
352
  // Non-blocking
204
353
  }
205
354
 
206
- console.log(JSON.stringify({ created: true, path: worktreePath, branch, feature }));
207
- process.exit(0);
355
+ return {
356
+ ok: true,
357
+ exitCode: 0,
358
+ created: true,
359
+ path: worktreePath,
360
+ branch,
361
+ feature,
362
+ materialized: materialized.materialized,
363
+ nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
364
+ };
208
365
  } catch (err) {
209
- console.log(JSON.stringify({ created: false, error: (err.message || 'unknown').slice(0, 200), feature }));
366
+ return { ok: true, exitCode: 0, created: false, error: (err.message || 'unknown').slice(0, 200), feature };
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Main command handler for `morph-spec worktree setup <feature>` — thin
372
+ * wrapper around `runWorktreeSetup` that prints its JSON result and exits
373
+ * with its exit code. Preserves the exact JSON shape and exit codes (0/2/3)
374
+ * this command has always used.
375
+ * @param {string} feature
376
+ * @param {{ fresh?: boolean, cwd?: string }} options
377
+ */
378
+ export async function worktreeSetupCommand(feature, options = {}) {
379
+ const { ok, exitCode, ...result } = await runWorktreeSetup(feature, options);
380
+ console.log(JSON.stringify(result));
381
+ process.exit(exitCode);
382
+ }
383
+
384
+ /**
385
+ * Command handler for `morph-spec worktree link [--target <path>] [--all]`.
386
+ *
387
+ * (Re)links shared infrastructure (node_modules by default; also .claude and
388
+ * .morph/framework with --all) from the primary root into a worktree. Useful
389
+ * to repair/refresh junctions without recreating the whole worktree.
390
+ * Idempotent — already-present junctions are reported as skipped, not re-created.
391
+ *
392
+ * @param {{ target?: string, all?: boolean, cwd?: string }} [options]
393
+ */
394
+ export function worktreeLinkCommand(options = {}) {
395
+ const cwdArg = options.cwd || process.cwd();
396
+ const root = resolvePrimaryRoot(cwdArg);
397
+ const target = resolve(options.target || options.cwd || process.cwd()).replace(/\\/g, '/');
398
+
399
+ // No-op: linking the primary root into itself makes no sense.
400
+ if (target === root) {
401
+ console.log(JSON.stringify({ linked: [], skipped: ['is-primary-root'], root, target }));
402
+ process.exit(0);
403
+ }
404
+
405
+ if (!isGitRepo(cwdArg)) {
406
+ console.log(JSON.stringify({ error: 'not-a-git-repo' }));
210
407
  process.exit(0); // Fail-open
211
408
  }
409
+
410
+ const dirs = options.all
411
+ ? ['node_modules', '.claude', join('.morph', 'framework')]
412
+ : ['node_modules'];
413
+
414
+ const alreadyPresent = {};
415
+ for (const rel of dirs) {
416
+ alreadyPresent[rel] = existsSync(join(target, rel));
417
+ }
418
+
419
+ setupInfraLinks(target, root, { dirs });
420
+
421
+ const linked = [];
422
+ const skipped = [];
423
+ for (const rel of dirs) {
424
+ if (alreadyPresent[rel]) {
425
+ skipped.push(rel); // already there before this call
426
+ } else if (existsSync(join(target, rel))) {
427
+ linked.push(rel); // newly created by setupInfraLinks
428
+ } else {
429
+ skipped.push(rel); // nothing to link (source missing in root, or link failed)
430
+ }
431
+ }
432
+
433
+ console.log(JSON.stringify({ linked, skipped, root, target }));
434
+ process.exit(0);
435
+ }
436
+
437
+ // ── finish / list shared helpers ────────────────────────────────────────────
438
+
439
+ /**
440
+ * Parse `git worktree list --porcelain` into structured entries.
441
+ * Fail-open: returns `[]` on any git error (e.g. not a git repo).
442
+ * @param {string} cwd
443
+ * @returns {Array<{ path: string, branch: string|null, head: string|null }>}
444
+ */
445
+ function parseWorktreeList(cwd) {
446
+ let output;
447
+ try {
448
+ output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
449
+ } catch {
450
+ return [];
451
+ }
452
+
453
+ const entries = [];
454
+ let current = null;
455
+ for (const line of output.split('\n')) {
456
+ if (line.startsWith('worktree ')) {
457
+ if (current) entries.push(current);
458
+ current = { path: line.slice('worktree '.length).trim().replace(/\\/g, '/'), branch: null, head: null };
459
+ } else if (line.startsWith('branch ') && current) {
460
+ current.branch = line.slice('branch '.length).trim();
461
+ } else if (line.startsWith('HEAD ') && current) {
462
+ current.head = line.slice('HEAD '.length).trim();
463
+ }
464
+ }
465
+ if (current) entries.push(current);
466
+ return entries;
467
+ }
468
+
469
+ /**
470
+ * Locate the registered worktree for a feature's `morph/{feature}` branch.
471
+ * Primary strategy: match the `branch refs/heads/morph/{feature}` entry from
472
+ * `git worktree list --porcelain`. Fallback: the conventional
473
+ * `buildWorktreePath` location, but only if it is itself a registered
474
+ * worktree entry (so a stray directory never gets mistaken for a live one).
475
+ *
476
+ * Returns the branch the worktree is ACTUALLY on, never the computed one. Via
477
+ * the path fallback the two can diverge (the user checked out something else in
478
+ * there), and reporting the computed name would make `finish` merge
479
+ * `morph/{feature}` while removing a worktree holding unrelated work — merging a
480
+ * branch it never validated, which is the whole failure this command guards
481
+ * against. The caller compares the two and refuses on mismatch.
482
+ *
483
+ * @param {string} feature
484
+ * @param {string} root - Primary root path
485
+ * @returns {{ path: string, branch: string, expectedBranch: string }|null}
486
+ */
487
+ function findWorktreeForFeature(feature, root) {
488
+ const expectedBranch = buildBranchName(feature);
489
+ const refName = `refs/heads/${expectedBranch}`;
490
+ const entries = parseWorktreeList(root);
491
+
492
+ let match = entries.find((e) => e.branch === refName);
493
+ if (!match) {
494
+ const guess = buildWorktreePath(feature, root).replace(/\\/g, '/');
495
+ match = entries.find((e) => e.path === guess);
496
+ }
497
+ if (!match) return null;
498
+
499
+ // A detached HEAD has no branch at all — report it as such rather than
500
+ // inventing the computed name for it.
501
+ const actual = match.branch ? match.branch.replace(/^refs\/heads\//, '') : null;
502
+ return { path: match.path, branch: actual, expectedBranch };
503
+ }
504
+
505
+ /** The infra paths `setupInfraLinks` junctions into a worktree. */
506
+ const INFRA_LINK_RELS = ['node_modules', '.claude', join('.morph', 'framework')];
507
+
508
+ /**
509
+ * Drop the infra junctions a worktree carries (`node_modules`, `.claude`,
510
+ * `.morph/framework`) WITHOUT following them.
511
+ *
512
+ * CRITICAL — this is what keeps `worktree finish` from destroying the project.
513
+ * `git worktree remove` refuses a worktree still holding these ("contains
514
+ * modified or untracked files"), and the `--force` fallback FOLLOWS the
515
+ * junctions and deletes the PRIMARY ROOT's real `.claude/` and
516
+ * `.morph/framework/` — wiping the morph-spec installation (MORPH.md, agents,
517
+ * skills, settings) while still reporting `finished: true`. On a short path the
518
+ * wipe is total and silent; on a long one MAX_PATH aborts it midway, leaving
519
+ * partial damage plus an orphan directory. Removing the reparse points first
520
+ * lets the plain (non-force) removal succeed and leaves `--force` nothing to
521
+ * traverse.
522
+ *
523
+ * Only links are touched: `unlinkSync`/`rmdirSync` act on the LINK, never on the
524
+ * target's contents, and `rmdirSync` fails on a real non-empty directory — the
525
+ * safety that stops this from ever deleting genuine files.
526
+ *
527
+ * @param {string} worktreePath
528
+ * @returns {string[]} relative paths actually unlinked
529
+ */
530
+ export function unlinkWorktreeInfraLinks(worktreePath) {
531
+ const removed = [];
532
+ for (const rel of INFRA_LINK_RELS) {
533
+ const p = join(worktreePath, rel);
534
+ let st;
535
+ try {
536
+ st = lstatSync(p);
537
+ } catch {
538
+ continue; // absent — nothing to drop
539
+ }
540
+ if (!st.isSymbolicLink() && !st.isDirectory()) continue;
541
+ try {
542
+ // symlink → unlink; Windows junction → rmdir. Both act on the link itself.
543
+ try {
544
+ unlinkSync(p);
545
+ } catch {
546
+ rmdirSync(p);
547
+ }
548
+ removed.push(rel);
549
+ } catch { /* a real, non-empty directory — leave it alone */ }
550
+ }
551
+ return removed;
552
+ }
553
+
554
+ /**
555
+ * Every reparse point anywhere under `worktreePath`, as relative paths.
556
+ *
557
+ * CRITICAL — this is the gate that keeps `worktree finish` from destroying the
558
+ * project, and it must be exhaustive. BOTH `git worktree remove` and its
559
+ * `--force` fallback FOLLOW reparse points out of the worktree and delete the
560
+ * PRIMARY ROOT's real files. The plain removal is not the safe one: it refuses
561
+ * only when it sees modified/untracked *tracked-scope* content, so a GITIGNORED
562
+ * junction (`node_modules`, `.claude`) leaves `git status` empty, the removal
563
+ * proceeds, exits 0 — and the target is gone.
564
+ *
565
+ * So the check cannot live in the `catch` of the plain removal (unreachable on
566
+ * exactly the destructive path), and it cannot be a fixed list of the links
567
+ * `setup` creates: a nested link inside a real `node_modules` (npm link, npm
568
+ * workspaces) is invisible to any top-level inventory. We scan the whole tree
569
+ * and NEVER recurse INTO a reparse point — descending one would walk the target
570
+ * we are trying to protect (and could not terminate on a cycle).
571
+ *
572
+ * An empty result is NOT the same as a safe one: a directory we could not read
573
+ * or an entry we could not lstat might BE (or contain) a reparse point. Those
574
+ * answer `unprovable: true`, and the caller must treat that exactly like a found
575
+ * link — decline. "I saw nothing" and "I proved there is nothing" are different
576
+ * claims, and only the second one may authorize a removal.
577
+ *
578
+ * @param {string} dir
579
+ * @param {string} [base] - root of the scan, for relative reporting
580
+ * @param {{points: string[], unprovable: string[]}} [acc]
581
+ * @returns {{points: string[], unprovable: string[]}} both empty = provably clean
582
+ */
583
+ function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }) {
584
+ let entries;
585
+ try {
586
+ entries = readdirSync(dir, { withFileTypes: true });
587
+ } catch {
588
+ // Unreadable (ACL, lock, EMFILE): we cannot prove what is in here.
589
+ acc.unprovable.push((relative(base, dir) || '.').replace(/\\/g, '/'));
590
+ return acc;
591
+ }
592
+ for (const entry of entries) {
593
+ const p = join(dir, entry.name);
594
+ let st;
595
+ try {
596
+ st = lstatSync(p);
597
+ } catch {
598
+ acc.unprovable.push(relative(base, p).replace(/\\/g, '/'));
599
+ continue;
600
+ }
601
+ if (st.isSymbolicLink()) {
602
+ // Forward slashes: every other path this command reports is normalized,
603
+ // and these end up in JSON the caller may compare or display.
604
+ acc.points.push(relative(base, p).replace(/\\/g, '/'));
605
+ continue; // do NOT descend — that is the target, not the worktree
606
+ }
607
+ if (st.isDirectory() && entry.name !== '.git') findReparsePoints(p, base, acc);
608
+ }
609
+ return acc;
610
+ }
611
+
612
+ /**
613
+ * Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
614
+ * once backed up (see backupWorktreeIgnored). `.morph/state.json` is provably
615
+ * derivable (loadState self-heals it from the committed feature.json files) and
616
+ * the rest is morph's own diagnostic output, which `setupInfraLinks` documents
617
+ * as worktree-local. They are small, so backing them up costs nothing and buys
618
+ * the guarantee that this list does not have to be RIGHT.
619
+ *
620
+ * `node_modules` is deliberately NOT here: it is regenerable from the lockfile,
621
+ * far too large to copy, and `unlinkWorktreeInfraLinks` already dropped it when
622
+ * it is the junction `setup` creates. A real one left behind blocks removal —
623
+ * safe, and the user can clear it themselves.
624
+ */
625
+ const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
626
+
627
+ /**
628
+ * Split the worktree's GITIGNORED leftovers into what this command may remove
629
+ * (morph's own, after a backup) and what it must refuse to destroy.
630
+ *
631
+ * Why this exists: `git worktree remove` deletes gitignored files outright, and
632
+ * the clean-check that gates it uses `git status --porcelain`, which omits them
633
+ * BY DESIGN. So a user's `.env` or a local override is invisible to that gate
634
+ * and dies silently while the command reports success. Git treats gitignored as
635
+ * disposable; morph must not, because "not in git" is exactly the property that
636
+ * makes a file unrecoverable.
637
+ *
638
+ * Fail-CLOSED: if the status call fails we cannot enumerate what is at stake, so
639
+ * everything is reported as blocking.
640
+ *
641
+ * @param {string} worktreePath
642
+ * @param {{ discardIgnored?: boolean }} options
643
+ * @returns {{ shieldable: string[], blocking: string[] }}
644
+ */
645
+ function classifyIgnoredLeftovers(worktreePath, options = {}) {
646
+ let out;
647
+ try {
648
+ // `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
649
+ // paths with spaces or non-ASCII (a pt-BR codebase has both) stay intact.
650
+ out = execFileSync('git', ['status', '--porcelain', '--ignored', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
651
+ } catch {
652
+ return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'] };
653
+ }
654
+
655
+ const shieldable = [];
656
+ const blocking = [];
657
+ for (const record of out.split('\0')) {
658
+ if (!record || record.length < 4) continue;
659
+ if (record.slice(0, 2) !== '!!') continue; // only ignored entries; the rest is the clean-check's job
660
+ const p = record.slice(3).replace(/\\/g, '/');
661
+ if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) shieldable.push(p);
662
+ else blocking.push(p);
663
+ }
664
+ // Explicit consent clears the block — but the list is NOT thrown away. These
665
+ // are the files that exist nowhere else, and they are the ones about to die;
666
+ // reporting them is the least this can do. (Backing them up instead is not an
667
+ // option: this bucket is where `node_modules`/`dist`/`.next` land, so a copy
668
+ // is unbounded. The user declared them disposable — they still get told.)
669
+ if (options.discardIgnored) return { shieldable, blocking: [], discarded: blocking };
670
+ return { shieldable, blocking, discarded: [] };
671
+ }
672
+
673
+ /**
674
+ * Back up the worktree's morph-owned gitignored leftovers before the removal
675
+ * deletes them. Same destination and rationale as backupFeatureScope.
676
+ *
677
+ * @param {string} worktreePath
678
+ * @param {string} root
679
+ * @param {string} feature
680
+ * @param {string[]} rels - worktree-relative paths
681
+ * @param {string} stamp
682
+ * @returns {string|null} backup path, or null if ANY copy failed (caller must
683
+ * then decline the removal — a partial backup authorizes nothing)
684
+ */
685
+ function backupWorktreeIgnored(worktreePath, root, feature, rels, stamp) {
686
+ const dest = join(root, '.git', 'morph-spec', 'finish-backup', `${feature}-${stamp}-worktree-ignored`);
687
+ try {
688
+ for (const rel of rels) {
689
+ const src = join(worktreePath, rel);
690
+ if (!existsSync(src)) continue; // a directory entry already covered by its children
691
+ const target = join(dest, rel);
692
+ mkdirSync(dirname(target), { recursive: true });
693
+ cpSync(src, target, { recursive: true });
694
+ }
695
+ return existsSync(dest) ? dest : null;
696
+ } catch {
697
+ return null;
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Copy the feature's root-side scope to a backup OUTSIDE the working tree, so
703
+ * the shed below can never be the reason something is lost.
704
+ *
705
+ * Lives under `.git/` on purpose: it is not a working-tree path, so it can never
706
+ * dirty `git status` (which the very next validation reads) and is never merged,
707
+ * committed, or cleaned by `git clean`.
708
+ *
709
+ * @param {string} root
710
+ * @param {string} feature
711
+ * @param {string} stamp - caller-supplied timestamp (keeps this pure/testable)
712
+ * @returns {string|null} absolute backup path, or null if the copy failed
713
+ */
714
+ function backupFeatureScope(root, feature, stamp) {
715
+ const src = join(root, '.morph', 'features', feature);
716
+ if (!existsSync(src)) return null;
717
+ const dest = join(root, '.git', 'morph-spec', 'finish-backup', `${feature}-${stamp}`);
718
+ try {
719
+ mkdirSync(dirname(dest), { recursive: true });
720
+ cpSync(src, dest, { recursive: true });
721
+ return dest;
722
+ } catch {
723
+ return null; // caller MUST treat this as "do not shed"
724
+ }
725
+ }
726
+
727
+ // ── finish ───────────────────────────────────────────────────────────────
728
+
729
+ /**
730
+ * Command handler for `morph-spec worktree finish <feature> [--keep] [--force]`.
731
+ *
732
+ * Deterministic merge-back: merges the feature's `morph/{feature}` branch into
733
+ * the primary root and (unless `--keep`) removes the worktree + branch. This
734
+ * is the closing half of the worktree lifecycle that `setup` opens.
735
+ *
736
+ * All three preconditions below are fail-CLOSED — no merge is attempted and
737
+ * nothing is removed. They are NOT no-ops, though: the shed runs before the
738
+ * last one, so a refusal there (or on a merge conflict) has already moved this
739
+ * feature's root-side scope to the backup. Recoverable, and every such refusal
740
+ * reports the path — but "nothing was touched" would be false. Preconditions:
741
+ * 1. Gate 3 (review) approved on the worktree's own feature.json, unless `--force`.
742
+ * 2. The worktree itself has no uncommitted changes (work must be committed
743
+ * to travel with the merge).
744
+ * 3. The primary root has no uncommitted changes (so a merge commit lands cleanly).
745
+ *
746
+ * NOTE: precondition 3 runs AFTER the shed of this feature's root-side scope, so
747
+ * a refusal there is not a no-op — but it is RECOVERABLE: the shed copies that
748
+ * scope to `.git/morph-spec/finish-backup/` first and every refusal after it
749
+ * reports the path.
750
+ *
751
+ * That guarantee covers what the SHED discards (the root-side scope) and, at
752
+ * cleanup, morph's own gitignored files in the worktree. It does NOT cover
753
+ * `--discard-ignored`, which destroys the worktree's non-morph gitignored files
754
+ * outright — that is the flag's stated purpose, the JSON names each file it
755
+ * destroyed, and no default path reaches it.
756
+ *
757
+ * A merge conflict aborts the merge (`git merge --abort`) and refuses —
758
+ * nothing is left half-merged.
759
+ *
760
+ * Success is reported as two INDEPENDENT signals: `finished` is the merge (the
761
+ * actual value), `worktreeRemoved` is the best-effort cleanup. Cleanup declines
762
+ * itself whenever it cannot prove it is safe, so `finished: true` +
763
+ * `worktreeRemoved: false` is a normal, honest outcome — not a failure.
764
+ *
765
+ * @param {string} feature
766
+ * @param {{ keep?: boolean, force?: boolean, discardIgnored?: boolean, cwd?: string }} [options]
767
+ * `force` waives the Gate 3 precondition ONLY — never the branch check.
768
+ * `discardIgnored` is consent to destroy the worktree's non-morph gitignored
769
+ * files (see the NOTE above); it does not waive the reparse-point gate.
770
+ */
771
+ export async function worktreeFinishCommand(feature, options = {}) {
772
+ const cwd = options.cwd || process.cwd();
773
+ const root = resolvePrimaryRoot(cwd);
774
+
775
+ // Refuse anything that is not a plain kebab feature name. `& | ; > ^` are all
776
+ // legal in a git refname but are cmd.exe metacharacters, and every git call
777
+ // below interpolates the name (or the branch built from it). Validating here —
778
+ // not just around the shed — is what keeps a crafted name from steering the
779
+ // `merge`/`branch -d` at the bottom onto a DIFFERENT branch than the one this
780
+ // command validated and reported.
781
+ if (!isValidFeatureName(feature)) {
782
+ console.log(JSON.stringify({ finished: false, error: 'invalid-feature-name', feature }));
783
+ process.exit(1);
784
+ }
785
+
786
+ const found = findWorktreeForFeature(feature, root);
787
+ if (!found) {
788
+ console.log(JSON.stringify({ finished: false, error: 'no-worktree', feature }));
789
+ process.exit(1);
790
+ }
791
+ const { path: worktreePath, branch, expectedBranch } = found;
792
+
793
+ // Validation 0: the worktree must actually be ON the branch we are about to
794
+ // merge. The path fallback in findWorktreeForFeature matches by location, so
795
+ // a worktree the user re-pointed at another branch (or detached) still
796
+ // matches — and merging the computed `morph/{feature}` then would land a
797
+ // branch we never validated while destroying a worktree holding unrelated
798
+ // work. `--force` does NOT waive this: it waives the review gate, not the
799
+ // question of WHICH branch this command is acting on.
800
+ if (branch !== expectedBranch) {
801
+ console.log(JSON.stringify({
802
+ finished: false,
803
+ error: 'worktree-branch-mismatch',
804
+ feature,
805
+ expectedBranch,
806
+ actualBranch: branch, // null = detached HEAD
807
+ path: worktreePath,
808
+ hint: `o worktree em ${worktreePath} está em ${branch || 'HEAD destacado'}, não em ${expectedBranch} — faça checkout da branch da feature ou finalize essa branch manualmente`,
809
+ }));
810
+ process.exit(1);
811
+ }
812
+
813
+ // Validation 1: Gate 3 (review) approved, unless --force.
814
+ if (!options.force) {
815
+ let reviewApproved = false;
816
+ try {
817
+ const fjPath = join(worktreePath, '.morph', 'features', feature, 'feature.json');
818
+ if (existsSync(fjPath)) {
819
+ const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
820
+ const gates = fj?.approvalGates || fj?.approvals || {};
821
+ reviewApproved = gates?.review?.approved === true;
822
+ }
823
+ } catch { /* reviewApproved stays false — refuse below */ }
824
+
825
+ if (!reviewApproved) {
826
+ console.log(JSON.stringify({ finished: false, error: 'review-not-approved', feature }));
827
+ process.exit(1);
828
+ }
829
+ }
830
+
831
+ // Validation 2: worktree must be clean (work must be committed to travel with the merge).
832
+ let worktreeStatus;
833
+ try {
834
+ worktreeStatus = execSync('git status --porcelain', { cwd: worktreePath, stdio: 'pipe' }).toString();
835
+ } catch (err) {
836
+ console.log(JSON.stringify({ finished: false, error: 'worktree-status-failed', detail: (err.message || '').slice(0, 200), feature }));
837
+ process.exit(1);
838
+ }
839
+ if (worktreeStatus.trim() !== '') {
840
+ console.log(JSON.stringify({
841
+ finished: false,
842
+ error: 'worktree-dirty',
843
+ hint: 'commit seu trabalho na branch antes do finish',
844
+ feature,
845
+ }));
846
+ process.exit(1);
847
+ }
848
+
849
+ // Shed the feature's OWN transient root-side bookkeeping BEFORE the clean
850
+ // check. `.morph/features/{feature}/` in the root holds artifacts the merge is
851
+ // about to replace wholesale: the factory flow (`create --worktree`) scaffolds
852
+ // it untracked, and `setup` stamps the worktree registration onto a committed
853
+ // feature.json (a ` M`). Shedding them lets the merge own this feature's state
854
+ // while the whole-root clean-check keeps guarding UNRELATED work.
855
+ //
856
+ // SAFETY — back up FIRST, then shed. An earlier design classified each dirty
857
+ // path to decide what was safe to discard; every version of that classifier
858
+ // was wrong in some real flow (an untracked file the branch has at a different
859
+ // CONTENT; a `??` feature.json that a `M`-gated check never inspected), and a
860
+ // wrong classifier here destroys unversioned work while reporting success.
861
+ // Copying the whole scope out of the working tree first makes the shed safe
862
+ // WITHOUT having to be right about what is in it: if the classification the
863
+ // merge implies turns out wrong, the bytes are still on disk.
864
+ const featPathspec = `.morph/features/${feature}`;
865
+ let shedBackup = null;
866
+
867
+ let dirtyInScope = '';
868
+ try {
869
+ dirtyInScope = execFileSync('git', ['status', '--porcelain', '-uall', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
870
+ } catch { /* nothing in scope */ }
871
+
872
+ if (dirtyInScope.trim() !== '') {
873
+ shedBackup = backupFeatureScope(root, feature, new Date().toISOString().replace(/[:.]/g, '-'));
874
+ // No backup, no shed. Falling through leaves the root dirty, so the
875
+ // clean-check below refuses — the exact fail-closed behaviour that existed
876
+ // before any shed did.
877
+ if (shedBackup) {
878
+ // Discard the registration stamped onto a committed feature.json by `setup`.
879
+ try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing tracked to restore */ }
880
+ // Remove the untracked scaffold left in the root by `create --worktree`.
881
+ // Scoped pathspec + no `-x` keep this away from gitignored `.morph/logs/`.
882
+ try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing untracked to clean */ }
883
+ }
884
+ }
885
+
886
+ // Every refusal from here on must tell the user where the bytes went.
887
+ // Distinct key from `hint`: callers below carry their own hint and this must
888
+ // never overwrite it.
889
+ const backupNote = shedBackup
890
+ ? {
891
+ shedBackup,
892
+ shedBackupNote: `o conteúdo root-side de ${featPathspec} foi copiado para ${shedBackup} antes de ser descartado — nada foi perdido`,
893
+ }
894
+ : {};
895
+
896
+ // Validation 3: primary root must be clean.
897
+ let rootStatus;
898
+ try {
899
+ rootStatus = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
900
+ } catch (err) {
901
+ console.log(JSON.stringify({ finished: false, error: 'primary-root-status-failed', detail: (err.message || '').slice(0, 200), feature, ...backupNote }));
902
+ process.exit(1);
903
+ }
904
+ if (rootStatus.trim() !== '') {
905
+ console.log(JSON.stringify({
906
+ finished: false,
907
+ error: 'primary-root-dirty',
908
+ feature,
909
+ ...backupNote,
910
+ }));
911
+ process.exit(1);
912
+ }
913
+
914
+ // Merge — fail-closed on conflict. Args go through execFile: the branch name
915
+ // reaches git as one argv entry, never as a cmd.exe string to re-split.
916
+ try {
917
+ execFileSync('git', ['merge', '--no-ff', branch, '-m', `morph-spec worktree finish: merge ${branch}`], { cwd: root, stdio: 'pipe' });
918
+ } catch (err) {
919
+ try {
920
+ execFileSync('git', ['merge', '--abort'], { cwd: root, stdio: 'pipe' });
921
+ } catch { /* best-effort abort — merge may not have entered a mergeable state */ }
922
+ const output = (((err.stdout && err.stdout.toString()) || '') + ((err.stderr && err.stderr.toString()) || '')).slice(0, 2000) || (err.message || '').slice(0, 500);
923
+ console.log(JSON.stringify({
924
+ finished: false,
925
+ error: 'merge-conflict',
926
+ output,
927
+ hint: 'resolva manualmente ou re-execute a feature sequencialmente',
928
+ feature,
929
+ ...backupNote,
930
+ }));
931
+ process.exit(1);
932
+ }
933
+
934
+ if (options.keep) {
935
+ console.log(JSON.stringify({ finished: true, kept: true, feature, merged: branch, path: worktreePath, branch, ...backupNote }));
936
+ process.exit(0);
937
+ }
938
+
939
+ // ── Cleanup: PROVE it is safe, or decline it ──────────────────────────────
940
+ //
941
+ // The merge (the actual value of this command) has landed. Removing the
942
+ // worktree is convenience, and it is the single most dangerous thing here.
943
+ // Two independent ways it destroys data, both of which end in a removal that
944
+ // exits 0 while doing it:
945
+ //
946
+ // 1. `git worktree remove` — plain AND `--force` — follows reparse points
947
+ // out of the worktree and deletes what they point at in the primary root.
948
+ // 2. It deletes GITIGNORED files outright. Git considers them disposable;
949
+ // a user's `.env` is not. They are invisible to the clean-check above
950
+ // (`git status --porcelain` omits them by design), so that gate cannot
951
+ // speak for them at all.
952
+ //
953
+ // Both gates below therefore PROVE-or-DECLINE. Declining costs a stale
954
+ // directory the user can delete in one command; being wrong costs data that
955
+ // exists nowhere else.
956
+ unlinkWorktreeInfraLinks(worktreePath);
957
+
958
+ const scan = findReparsePoints(worktreePath);
959
+ const ignored = classifyIgnoredLeftovers(worktreePath, options);
960
+ const blockers = [...scan.points, ...scan.unprovable];
961
+
962
+ // Back up ONLY once the removal is otherwise cleared. Two reasons, and the
963
+ // ordering buys both for free:
964
+ // - Nothing is about to be destroyed on a declined path, so copying there
965
+ // is pure waste — and worse, it would duplicate the user's content into
966
+ // `.git/` for a removal that never happens.
967
+ // - `blockers.length === 0` is a PROOF that no reparse point exists anywhere
968
+ // under the worktree. cpSync follows junctions on Windows, so running it
969
+ // before that proof could copy an out-of-repo tree of unbounded size in
970
+ // under an allowlisted `.morph/` path.
971
+ let ignoredBackup = null;
972
+ const clearedToRemove = blockers.length === 0 && ignored.blocking.length === 0;
973
+ if (clearedToRemove && ignored.shieldable.length) {
974
+ ignoredBackup = backupWorktreeIgnored(worktreePath, root, feature, ignored.shieldable, new Date().toISOString().replace(/[:.]/g, '-'));
975
+ }
976
+
977
+ const canRemove = clearedToRemove
978
+ && (!ignored.shieldable.length || ignoredBackup !== null);
979
+
980
+ let worktreeRemoved = false;
981
+ if (canRemove) {
982
+ try {
983
+ execFileSync('git', ['worktree', 'remove', worktreePath], { cwd: root, stdio: 'pipe' });
984
+ worktreeRemoved = true;
985
+ } catch {
986
+ // Nothing is left to traverse (proven above), so --force can only act
987
+ // inside the worktree.
988
+ try {
989
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: root, stdio: 'pipe' });
990
+ worktreeRemoved = true;
991
+ } catch { /* fail-open — cleanup is best-effort once the merge already landed */ }
992
+ }
993
+ }
994
+
995
+ // The branch is still checked out by a worktree we did not remove — deleting
996
+ // it would fail anyway, and `--worktree prune` has nothing to reclaim.
997
+ if (worktreeRemoved) {
998
+ try {
999
+ execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' });
1000
+ } catch { /* fail-open */ }
1001
+
1002
+ try {
1003
+ execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
1004
+ } catch { /* fail-open */ }
1005
+ }
1006
+
1007
+ // The feature is now merged into the primary root — clear the `worktree`
1008
+ // bookkeeping field from its (root-side) feature.json. Fail-open: this is
1009
+ // metadata cleanup, never a reason to report the finish itself as failed.
1010
+ //
1011
+ // Written DIRECTLY, not via loadState/saveState: saveState re-persists EVERY
1012
+ // feature's feature.json, so clearing one field on THIS feature rewrites the
1013
+ // siblings too. Any sibling whose committed file differs only in formatting (a
1014
+ // trailing newline — what an editor or prettier leaves) gets rewritten into a
1015
+ // ` M`, dirtying the root outside this command's own pathspec, where the
1016
+ // post-merge commit below cannot see it. That stray modification then blocks
1017
+ // the NEXT feature's finish — the exact failure this command exists to avoid,
1018
+ // and it bites hardest in the parallel multi-worktree flow it was built for.
1019
+ // The thin root index carries only {status, updatedAt}, never `worktree`, so
1020
+ // there is nothing else to persist here.
1021
+ // atomicWriteJson (not a plain writeFileSync): this file is the feature's
1022
+ // AUTHORITATIVE, committed state. A torn write from a crash — or an EPERM
1023
+ // from a Windows AV/indexer holding the handle — would corrupt or fail on the
1024
+ // one copy that matters. tmp+rename with the EPERM retry is what the rest of
1025
+ // the state layer uses; bypassing the sweep must not also bypass that.
1026
+ try {
1027
+ const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
1028
+ if (existsSync(rootFjPath)) {
1029
+ const fj = JSON.parse(readFileSync(rootFjPath, 'utf8'));
1030
+ if (fj.worktree) {
1031
+ delete fj.worktree;
1032
+ const { atomicWriteJson } = await import('../../core/state/state-manager.js');
1033
+ atomicWriteJson(rootFjPath, fj);
1034
+ }
1035
+ }
1036
+ } catch { /* fail-open */ }
1037
+
1038
+ // Clearing that field can itself dirty the root: when `setup`'s registration
1039
+ // was COMMITTED, it lives in HEAD, so the shed's `git checkout` cannot undo it
1040
+ // and it survives the merge — then the rewrite above leaves a ` M`. Left
1041
+ // behind, that stray modification blocks the NEXT feature's finish (the shed
1042
+ // is scoped to its own feature), which is the very bug this command was fixed
1043
+ // to stop hitting. Committing it is in contract: `finish` already writes a
1044
+ // merge commit. Scoped to this feature's path — never sweeps up other work.
1045
+ try {
1046
+ const dirtyAfter = execFileSync('git', ['status', '--porcelain', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
1047
+ if (dirtyAfter.trim() !== '') {
1048
+ execFileSync('git', ['add', '--', featPathspec], { cwd: root, stdio: 'pipe' });
1049
+ execFileSync('git', ['commit', '-m', `morph-spec worktree finish: clear ${feature} worktree registration`, '--', featPathspec], { cwd: root, stdio: 'pipe' });
1050
+ }
1051
+ } catch { /* fail-open — a dirty root is a nuisance, not a reason to fail a landed merge */ }
1052
+
1053
+ const declineReason = ignored.blocking.length
1054
+ ? `merge OK. O worktree NÃO foi removido: ele contém arquivo(s) gitignored que existem só ali e o \`git worktree remove\` apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`git worktree remove --force "${worktreePath}"\` + \`git branch -d ${branch}\`, ou re-execute o finish com \`--discard-ignored\` para descartá-los deliberadamente`
1055
+ : blockers.length
1056
+ ? `merge OK. O worktree NÃO foi removido: ele ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que o \`git worktree remove\` seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`git worktree remove "${worktreePath}"\` + \`git branch -d ${branch}\``
1057
+ : ignored.shieldable.length && ignoredBackup === null
1058
+ ? `merge OK. O worktree NÃO foi removido: o backup de ${featPathspec} (gitignored do worktree) falhou, e sem ele a remoção seria destrutiva. Rode \`git worktree remove --force "${worktreePath}"\` manualmente se não precisar desse conteúdo`
1059
+ : `merge OK, mas o \`git worktree remove\` falhou. Rode \`git worktree remove "${worktreePath}"\` + \`git branch -d ${branch}\` manualmente`;
1060
+
1061
+ console.log(JSON.stringify({
1062
+ finished: true,
1063
+ feature,
1064
+ merged: branch,
1065
+ worktreeRemoved,
1066
+ ...(shedBackup ? { shedBackup } : {}),
1067
+ ...(ignoredBackup ? { worktreeIgnoredBackup: ignoredBackup } : {}),
1068
+ // Only when the removal actually ran did these die — a decline for any other
1069
+ // reason leaves them on disk. Naming them keeps `--discard-ignored` honest:
1070
+ // `worktreeIgnoredBackup` alone would imply everything was preserved.
1071
+ ...(worktreeRemoved && ignored.discarded.length
1072
+ ? {
1073
+ discardedIgnored: ignored.discarded,
1074
+ discardedNote: `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`,
1075
+ }
1076
+ : {}),
1077
+ ...(worktreeRemoved
1078
+ ? {}
1079
+ : {
1080
+ path: worktreePath,
1081
+ branch,
1082
+ ...(blockers.length ? { reparsePoints: blockers } : {}),
1083
+ ...(ignored.blocking.length ? { ignoredFiles: ignored.blocking } : {}),
1084
+ cleanupHint: declineReason,
1085
+ // The infra junctions were dropped before this decline (that step runs
1086
+ // unconditionally, since it is what makes a removal survivable). A kept
1087
+ // worktree therefore has no node_modules/.claude until they are relinked
1088
+ // — say so, instead of leaving the user to discover a broken worktree.
1089
+ relinkHint: `as junctions de infra (node_modules, .claude, .morph/framework) foram soltas antes desta recusa — se for continuar trabalhando nesse worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``,
1090
+ }),
1091
+ nextStep: `morph-spec archive ${feature}`,
1092
+ }));
1093
+ process.exit(0);
1094
+ }
1095
+
1096
+ // ── list ─────────────────────────────────────────────────────────────────
1097
+
1098
+ /**
1099
+ * Command handler for `morph-spec worktree list [--json]`.
1100
+ *
1101
+ * Lists active `morph/{feature}` worktrees with their derived phase, status,
1102
+ * and any pending approval gates — visibility across multi-instance sessions.
1103
+ * Fail-open: outside a git repo (or on any git error), reports an empty list.
1104
+ *
1105
+ * @param {{ json?: boolean, cwd?: string }} [options]
1106
+ */
1107
+ export async function worktreeListCommand(options = {}) {
1108
+ const cwd = options.cwd || process.cwd();
1109
+
1110
+ if (!isGitRepo(cwd)) {
1111
+ printWorktreeList([], options);
1112
+ process.exit(0);
1113
+ }
1114
+
1115
+ const root = resolvePrimaryRoot(cwd);
1116
+ const entries = parseWorktreeList(root);
1117
+
1118
+ const worktrees = [];
1119
+ if (entries.length) {
1120
+ const { derivePhaseForFeature, getPendingGates, getFeature } =
1121
+ await import('../../../framework/hooks/shared/state-reader.js');
1122
+
1123
+ for (const entry of entries) {
1124
+ const m = entry.branch && entry.branch.match(/^refs\/heads\/morph\/(.+)$/);
1125
+ if (!m) continue; // skip the primary root entry and any non-morph worktree
1126
+ const feature = m[1];
1127
+
1128
+ let phase = 'proposal';
1129
+ let status = 'unknown';
1130
+ let pendingGates = [];
1131
+ try {
1132
+ const featureObj = getFeature(feature, entry.path);
1133
+ status = featureObj?.status || 'unknown';
1134
+ phase = derivePhaseForFeature(feature, entry.path);
1135
+ pendingGates = getPendingGates(feature, entry.path);
1136
+ } catch { /* leave defaults — a corrupt feature.json must never crash list */ }
1137
+
1138
+ worktrees.push({
1139
+ feature,
1140
+ branch: entry.branch.replace(/^refs\/heads\//, ''),
1141
+ phase,
1142
+ status,
1143
+ pendingGates,
1144
+ path: entry.path,
1145
+ });
1146
+ }
1147
+ }
1148
+
1149
+ printWorktreeList(worktrees, options);
1150
+ process.exit(0);
1151
+ }
1152
+
1153
+ /** Render `worktree list` output — JSON or a human table (chalk). */
1154
+ function printWorktreeList(worktrees, options) {
1155
+ if (options.json) {
1156
+ console.log(JSON.stringify({ worktrees }));
1157
+ return;
1158
+ }
1159
+
1160
+ if (worktrees.length === 0) {
1161
+ console.log(chalk.gray('No active feature worktrees.'));
1162
+ return;
1163
+ }
1164
+
1165
+ const col = (s, w) => String(s).padEnd(w);
1166
+ console.log(chalk.white.bold(col('FEATURE', 22) + col('BRANCH', 26) + col('PHASE', 12) + col('STATUS', 14) + col('PENDING GATES', 24) + 'PATH'));
1167
+ for (const w of worktrees) {
1168
+ const gates = w.pendingGates.length ? w.pendingGates.join(',') : '-';
1169
+ console.log(col(w.feature, 22) + col(w.branch, 26) + col(w.phase, 12) + col(w.status, 14) + col(gates, 24) + w.path);
1170
+ }
212
1171
  }