@polymorphism-tech/morph-spec 8.27.0 → 8.28.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 (38) hide show
  1. package/CLAUDE.md +1 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +9 -4
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +3 -2
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +8 -8
  8. package/framework/docs/CLI.md +6 -5
  9. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  10. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  11. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  12. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  13. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  14. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  15. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  16. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  17. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  18. package/framework/skills/core/morph-review/SKILL.md +1 -1
  19. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  20. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  21. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  22. package/package.json +1 -1
  23. package/src/commands/project/doctor.js +9 -4
  24. package/src/commands/project/worktree.js +45 -3
  25. package/src/commands/state/advance.js +12 -0
  26. package/src/commands/state/approve.js +20 -0
  27. package/src/commands/state/create.js +4 -1
  28. package/src/commands/state/delete.js +63 -4
  29. package/src/commands/state/finish.js +20 -2
  30. package/src/commands/state/gate-check.js +13 -0
  31. package/src/commands/state/gate-decision.js +9 -0
  32. package/src/commands/state/handoff.js +48 -6
  33. package/src/commands/state/list.js +124 -10
  34. package/src/commands/state/score.js +9 -0
  35. package/src/lib/generators/handoff-generator.js +11 -4
  36. package/src/lib/worktree/feature-truth.js +248 -0
  37. package/src/lib/worktree/fleet-scanner.js +1 -65
  38. package/src/lib/worktree/worktree-audit.js +103 -17
@@ -0,0 +1,248 @@
1
+ /**
2
+ * feature-truth.js — where a feature's authoritative `feature.json` actually
3
+ * lives, and how to read it from a place that isn't the feature's own tree.
4
+ *
5
+ * `create --worktree` writes a COMPLETE feature.json into the primary root
6
+ * (status, approvalGates, tasks, gateDecisions) and only then copies it into
7
+ * the worktree. From that instant the branch-committed copy (`morph/{f}`) is
8
+ * the truth and the root's copy never changes again — the root sees the
9
+ * feature exactly as it was at birth, forever, unless someone reads through.
10
+ *
11
+ * `branchToReadFrom`/`branchView` are moved verbatim from fleet-scanner.js —
12
+ * they already solved this (comments B3/B3b there), fleet just never shared
13
+ * them. `resolveFeatureTree` is the missing piece for CONSUMERS OTHER than
14
+ * fleet: "which tree/branch actually owns this feature's state", used by
15
+ * `list`, `handoff`, and the mutator guard (assertOwnerTree).
16
+ *
17
+ * Fail-open throughout: no repo, no branch, no blob, unparseable JSON never
18
+ * throws — callers get null/false and fall back to reading the disk.
19
+ */
20
+
21
+ import { execFileSync } from 'child_process';
22
+ import { existsSync, readFileSync } from 'fs';
23
+ import { join } from 'path';
24
+ import { isGitRepo, resolvePrimaryRoot, findWorktreeForFeature, buildBranchName } from '../../commands/project/worktree.js';
25
+
26
+ /**
27
+ * Which branch holds this feature's truth, when the working tree at `path` does
28
+ * NOT — or `null` when the working tree IS the feature's own checkout.
29
+ *
30
+ * The ONE place this condition lives. `tasks` (B3) and `phase`/`pendingGates`
31
+ * (B3b) are the same defect read through different files, so they must agree on
32
+ * when to look at the branch; two drifting copies of this test would resurrect
33
+ * the bug on one field while the other stayed fixed.
34
+ *
35
+ * A worktree entry is the feature's checkout by construction. A root entry is
36
+ * only the feature's checkout when the root happens to be ON `morph/{f}`.
37
+ *
38
+ * @returns {string|null} branch name to read from, or null for "read the disk"
39
+ */
40
+ export function branchToReadFrom(root, feature, location, branch) {
41
+ if (location === 'worktree' || !root || !feature) return null;
42
+ const featureBranch = branch || `morph/${feature}`;
43
+ try {
44
+ const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' })
45
+ .toString().trim();
46
+ return current === featureBranch ? null : featureBranch;
47
+ } catch {
48
+ return null; // no HEAD to compare against — trust the disk
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The feature's committed state on its own branch: the feature.json blob plus
54
+ * the set of files under its feature dir.
55
+ *
56
+ * `git ls-tree -z` is deliberate: without it git QUOTES paths containing
57
+ * non-ASCII bytes (`"a\303\247/…"`), which would silently fail the prefix strip
58
+ * and leave the artifact set empty — a predicate that is always false looks
59
+ * exactly like "this feature has no artifacts".
60
+ *
61
+ * Fail-open in layers: no blob → null (caller reads the disk); no tree → the
62
+ * gates alone still beat a stale working tree.
63
+ *
64
+ * @returns {{ fj: Object, files: Set<string> }|null}
65
+ */
66
+ export function branchView(root, feature, featureBranch) {
67
+ const prefix = `.morph/features/${feature}/`;
68
+ let fj;
69
+ try {
70
+ fj = JSON.parse(execFileSync('git', ['show', `${featureBranch}:${prefix}feature.json`], {
71
+ cwd: root, stdio: 'pipe',
72
+ }).toString());
73
+ } catch {
74
+ return null;
75
+ }
76
+
77
+ const files = new Set();
78
+ try {
79
+ const out = execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', featureBranch, '--', prefix], {
80
+ cwd: root, stdio: 'pipe',
81
+ }).toString();
82
+ for (const raw of out.split('\0')) {
83
+ const p = raw.trim();
84
+ if (p.startsWith(prefix)) files.add(p.slice(prefix.length));
85
+ }
86
+ } catch { /* no tree — gates alone still beat the stale working tree */ }
87
+
88
+ return { fj, files };
89
+ }
90
+
91
+ /** All local `morph/*` branches, as bare feature names — one spawn, a pre-filter. */
92
+ export function morphBranches(root) {
93
+ try {
94
+ const out = execFileSync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads/morph/'], {
95
+ cwd: root, stdio: 'pipe',
96
+ }).toString();
97
+ const names = new Set();
98
+ for (const line of out.split('\n')) {
99
+ const b = line.trim();
100
+ if (b) names.add(b.slice('morph/'.length));
101
+ }
102
+ return names;
103
+ } catch {
104
+ return new Set();
105
+ }
106
+ }
107
+
108
+ /** The tree's current branch, or null (detached HEAD / no repo / no commits yet). */
109
+ export function currentBranch(cwd) {
110
+ try {
111
+ const out = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
112
+ return out === 'HEAD' ? null : out || null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** Read a feature's local feature.json from `dir`, or null if missing/corrupt. */
119
+ function readLocalFeatureJson(dir, feature) {
120
+ const p = join(dir, '.morph', 'features', feature, 'feature.json');
121
+ if (!existsSync(p)) return null;
122
+ try {
123
+ return JSON.parse(readFileSync(p, 'utf8'));
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Resolve which TREE (or, failing that, which BRANCH) actually owns a
131
+ * feature's state — "follow the pointer" for any consumer, not just fleet.
132
+ *
133
+ * Six-step search, each step justified by what it's immune to:
134
+ *
135
+ * 1. cwd holds the feature AND cwd is that feature's registered worktree
136
+ * (git's own record, via findWorktreeForFeature) → running inside the
137
+ * owner never counts as a jump.
138
+ * 2. `findWorktreeForFeature` finds a live worktree elsewhere → the tree the
139
+ * GIT REGISTRY says is the owner, not the feature.json pointer — immune
140
+ * to a dangling/stale `feature.worktree` field.
141
+ * 3. the local feature.json's own `worktree.path` field, if it still exists
142
+ * on disk → covers a worktree living outside the conventional path or a
143
+ * registry `git worktree list` can't see for some reason.
144
+ * 4. cwd holds the feature with no worktree registered anywhere → the common
145
+ * case (no `--worktree`, or the worktree was already torn down).
146
+ * 5. no local copy, but `morph/{feature}` has the file committed → the truth
147
+ * exists, just not materialized into any tree right now.
148
+ * 6. nothing anywhere.
149
+ *
150
+ * @param {string} feature
151
+ * @param {string} [cwd]
152
+ * @returns {{
153
+ * source: 'cwd'|'registry'|'pointer'|'branch-only'|'none',
154
+ * tree: string|null,
155
+ * branch: string|null,
156
+ * worktree: {path:string, branch:string}|null,
157
+ * pointerDangling: boolean,
158
+ * }}
159
+ */
160
+ export function resolveFeatureTree(feature, cwd = process.cwd()) {
161
+ const localFj = readLocalFeatureJson(cwd, feature);
162
+ const pointerPath = localFj?.worktree?.path || null;
163
+ const pointerDangling = Boolean(pointerPath) && !existsSync(pointerPath);
164
+
165
+ const inGit = isGitRepo(cwd);
166
+ const root = inGit ? resolvePrimaryRoot(cwd) : null;
167
+
168
+ // Step 1 + 2: git's own worktree registry is the source of truth for "is
169
+ // there a live worktree for this feature, and where".
170
+ if (inGit && root) {
171
+ const registered = findWorktreeForFeature(feature, root);
172
+ if (registered && existsSync(registered.path)) {
173
+ const branch = registered.branch || buildBranchName(feature);
174
+ if (registered.path.replace(/\\/g, '/') === cwd.replace(/\\/g, '/')) {
175
+ return { source: 'cwd', tree: cwd, branch, worktree: { path: registered.path, branch }, pointerDangling };
176
+ }
177
+ return { source: 'registry', tree: registered.path, branch, worktree: { path: registered.path, branch }, pointerDangling };
178
+ }
179
+ }
180
+
181
+ // Step 3: the local pointer names a worktree the registry didn't surface
182
+ // (unconventional location, or registry unreadable) but that still exists.
183
+ if (pointerPath && !pointerDangling) {
184
+ const branch = localFj?.worktree?.branch || buildBranchName(feature);
185
+ return { source: 'pointer', tree: pointerPath, branch, worktree: { path: pointerPath, branch }, pointerDangling };
186
+ }
187
+
188
+ // Step 4: this tree holds the feature and nothing else claims to own it.
189
+ if (localFj) {
190
+ return { source: 'cwd', tree: cwd, branch: null, worktree: null, pointerDangling };
191
+ }
192
+
193
+ // Step 5: no local copy anywhere in this tree, but the branch has one.
194
+ if (inGit && root) {
195
+ const branch = buildBranchName(feature);
196
+ const view = branchView(root, feature, branch);
197
+ if (view) {
198
+ return { source: 'branch-only', tree: null, branch, worktree: null, pointerDangling: false };
199
+ }
200
+ }
201
+
202
+ // Step 6.
203
+ return { source: 'none', tree: null, branch: null, worktree: null, pointerDangling: false };
204
+ }
205
+
206
+ /**
207
+ * Should a mutator (approve/advance/score/gate-decision/gate-check) refuse to
208
+ * write this feature's `feature.json` from `cwd`?
209
+ *
210
+ * Blocked ONLY when a live worktree owns the feature and it is NOT cwd —
211
+ * `resolveFeatureTree` sources `'registry'`/`'pointer'`. `'cwd'` (cwd IS the
212
+ * owner, or no worktree exists at all) always passes. This is what stops
213
+ * `morph-spec approve <f> plan` run from the root, with the worktree still
214
+ * alive, from silently stamping the gate onto the root's birth-instant
215
+ * skeleton — branch-drift CREATED by a command, not merely left behind by one.
216
+ *
217
+ * @param {string} feature
218
+ * @param {string} [cwd]
219
+ * @returns {{ blocked: boolean, tree: string|null, branch: string|null }}
220
+ */
221
+ export function assertOwnerTree(feature, cwd = process.cwd()) {
222
+ const owner = resolveFeatureTree(feature, cwd);
223
+ const blocked = owner.source === 'registry' || owner.source === 'pointer';
224
+ return { blocked, tree: owner.tree, branch: owner.branch };
225
+ }
226
+
227
+ /**
228
+ * The refusal text every mutator prints on an owner-tree block — one place so
229
+ * `approve`/`advance`/`score`/`gate-decision`/`gate-check` can never drift
230
+ * into five different wordings for the same defect. `commandLine` is the
231
+ * exact command the caller should re-run inside the owner tree.
232
+ *
233
+ * @param {string} feature
234
+ * @param {string} tree
235
+ * @param {string|null} branch
236
+ * @param {string} commandLine
237
+ * @returns {Array<{tone: 'red'|'gray'|'cyan'|'yellow', text: string}>}
238
+ */
239
+ export function ownerTreeRefusalLines(feature, tree, branch, commandLine) {
240
+ return [
241
+ { tone: 'red', text: `\n❌ A feature "${feature}" não pode ser mutada A PARTIR DESTA ÁRVORE.` },
242
+ { tone: 'gray', text: ` O worktree dono está vivo em: ${tree}${branch ? ` (branch ${branch})` : ''}` },
243
+ { tone: 'gray', text: ' Rode o comando LÁ — a mutação é gravada no feature.json daquela árvore:' },
244
+ { tone: 'cyan', text: ` cd "${tree}" && ${commandLine}` },
245
+ { tone: 'yellow', text: '\n NÃO use --force aqui: ele carimbaria a mutação na cópia da raiz, duplicando' },
246
+ { tone: 'yellow', text: ' uma escrita que a outra árvore já tem e desalinhando os dois feature.json.\n' },
247
+ ];
248
+ }
@@ -19,6 +19,7 @@ import { join, resolve } from 'path';
19
19
  import { isGitRepo, resolvePrimaryRoot, parseWorktreeList } from '../../commands/project/worktree.js';
20
20
  import { hasOriginRemote } from '../git/default-branch.js';
21
21
  import { registryPath, resolveFleetConfig } from './env-allocator.js';
22
+ import { branchToReadFrom, branchView } from './feature-truth.js';
22
23
  import {
23
24
  getFeature,
24
25
  derivePhaseForFeature,
@@ -34,71 +35,6 @@ function countFrom(raw) {
34
35
  return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
35
36
  }
36
37
 
37
- /**
38
- * Which branch holds this feature's truth, when the working tree at `path` does
39
- * NOT — or `null` when the working tree IS the feature's own checkout.
40
- *
41
- * The ONE place this condition lives. `tasks` (B3) and `phase`/`pendingGates`
42
- * (B3b) are the same defect read through different files, so they must agree on
43
- * when to look at the branch; two drifting copies of this test would resurrect
44
- * the bug on one field while the other stayed fixed.
45
- *
46
- * A worktree entry is the feature's checkout by construction. A root entry is
47
- * only the feature's checkout when the root happens to be ON `morph/{f}`.
48
- *
49
- * @returns {string|null} branch name to read from, or null for "read the disk"
50
- */
51
- function branchToReadFrom(root, feature, location, branch) {
52
- if (location === 'worktree' || !root || !feature) return null;
53
- const featureBranch = branch || `morph/${feature}`;
54
- try {
55
- const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' })
56
- .toString().trim();
57
- return current === featureBranch ? null : featureBranch;
58
- } catch {
59
- return null; // no HEAD to compare against — trust the disk
60
- }
61
- }
62
-
63
- /**
64
- * The feature's committed state on its own branch: the feature.json blob plus
65
- * the set of files under its feature dir.
66
- *
67
- * `git ls-tree -z` is deliberate: without it git QUOTES paths containing
68
- * non-ASCII bytes (`"a\303\247/…"`), which would silently fail the prefix strip
69
- * and leave the artifact set empty — a predicate that is always false looks
70
- * exactly like "this feature has no artifacts".
71
- *
72
- * Fail-open in layers: no blob → null (caller reads the disk); no tree → the
73
- * gates alone still beat a stale working tree.
74
- *
75
- * @returns {{ fj: Object, files: Set<string> }|null}
76
- */
77
- function branchView(root, feature, featureBranch) {
78
- const prefix = `.morph/features/${feature}/`;
79
- let fj;
80
- try {
81
- fj = JSON.parse(execFileSync('git', ['show', `${featureBranch}:${prefix}feature.json`], {
82
- cwd: root, stdio: 'pipe',
83
- }).toString());
84
- } catch {
85
- return null;
86
- }
87
-
88
- const files = new Set();
89
- try {
90
- const out = execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', featureBranch, '--', prefix], {
91
- cwd: root, stdio: 'pipe',
92
- }).toString();
93
- for (const raw of out.split('\0')) {
94
- const p = raw.trim();
95
- if (p.startsWith(prefix)) files.add(p.slice(prefix.length));
96
- }
97
- } catch { /* no tree — gates alone still beat the stale working tree */ }
98
-
99
- return { fj, files };
100
- }
101
-
102
38
  /**
103
39
  * Task progress for one feature.
104
40
  *
@@ -22,13 +22,14 @@
22
22
  */
23
23
 
24
24
  import { execFileSync } from 'child_process';
25
- import { existsSync, readdirSync, statSync } from 'fs';
25
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
26
26
  import { join, resolve } from 'path';
27
27
  import {
28
28
  isGitRepo,
29
29
  resolvePrimaryRoot,
30
30
  parseWorktreeList,
31
31
  WORKTREE_DIR_NAMES,
32
+ clearDanglingWorktreeRegistration,
32
33
  } from '../../commands/project/worktree.js';
33
34
 
34
35
  const norm = (p) => resolve(p).replace(/\\/g, '/');
@@ -65,6 +66,36 @@ function featureCommittedOnBranch(root, feature) {
65
66
  }
66
67
  }
67
68
 
69
+ /**
70
+ * Every feature's `worktree` registration in the primary root — the OTHER
71
+ * direction from `knownFeatures`/`dirsOnDisk`: not "does git/disk know about
72
+ * this worktree", but "does the ROOT's feature.json still point somewhere
73
+ * real". `create --worktree` writes this pointer once at birth
74
+ * (`{path, branch, createdAt}`) and nothing updates it afterwards except
75
+ * `finish`'s own teardown — so a directory removed any other way (by hand,
76
+ * `git worktree prune` run directly, a failed teardown that still cleared the
77
+ * field, an unrelated script) leaves it dangling with nothing to notice.
78
+ *
79
+ * @param {string} root
80
+ * @returns {Array<{ feature: string, path: string, branch: string|null }>}
81
+ */
82
+ function featureRegistrations(root) {
83
+ const out = [];
84
+ try {
85
+ const dir = join(root, '.morph', 'features');
86
+ if (!existsSync(dir)) return out;
87
+ for (const feature of readdirSync(dir)) {
88
+ const fjPath = join(dir, feature, 'feature.json');
89
+ if (!existsSync(fjPath)) continue;
90
+ try {
91
+ const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
92
+ if (fj?.worktree?.path) out.push({ feature, path: fj.worktree.path, branch: fj.worktree.branch || null });
93
+ } catch { /* corrupt feature.json — nothing to register */ }
94
+ }
95
+ } catch { /* fail-open */ }
96
+ return out;
97
+ }
98
+
68
99
  /** Directories physically present under either worktree root. */
69
100
  function dirsOnDisk(root) {
70
101
  const found = [];
@@ -87,14 +118,25 @@ function dirsOnDisk(root) {
87
118
  * Reconcile git's registry, the disk and morph's features.
88
119
  *
89
120
  * Issue types:
90
- * • registered-missing — git has a worktree whose directory is gone
91
- * • non-morph-branch — a registered worktree under worktrees/ on a branch
92
- * that is not `morph/*`, so `fleet` cannot see it
93
- * • unregistered-dir — a directory under worktrees/ git never registered
94
- * • no-feature — a `morph/{f}` worktree for a feature morph does not know
121
+ * • registered-missing — git has a worktree whose directory is gone
122
+ * • non-morph-branch — a registered worktree under worktrees/ on a
123
+ * branch that is not `morph/*`, so `fleet`
124
+ * cannot see it
125
+ * • unregistered-dir — a directory under worktrees/ git never
126
+ * registered
127
+ * • no-feature — a `morph/{f}` worktree for a feature morph
128
+ * does not know
129
+ * • dangling-registration — a feature's ROOT feature.json still points
130
+ * at a `worktree.path` that no longer exists
131
+ * (the symmetric OTHER direction from
132
+ * `no-feature`: the worktree is gone, but the
133
+ * root's pointer to it survived)
134
+ * • unregistered-registration — a feature's `worktree.path` names a
135
+ * directory that exists but git's own
136
+ * registry does not know as a worktree
95
137
  *
96
138
  * @param {string} [cwd]
97
- * @returns {Array<{ type: string, detail: string, path?: string, fix: string }>}
139
+ * @returns {Array<{ type: string, detail: string, path?: string, feature?: string, fix: string }>}
98
140
  */
99
141
  export function auditWorktrees(cwd = process.cwd()) {
100
142
  if (!isGitRepo(cwd)) return [];
@@ -161,25 +203,69 @@ export function auditWorktrees(cwd = process.cwd()) {
161
203
  });
162
204
  }
163
205
 
206
+ // The other direction: does the ROOT's own bookkeeping still point at a
207
+ // live worktree? `git worktree list`/disk only see what's PHYSICALLY
208
+ // there; a registration surviving past its worktree's removal is invisible
209
+ // to every check above.
210
+ for (const reg of featureRegistrations(root)) {
211
+ const path = norm(reg.path);
212
+ if (!existsSync(reg.path)) {
213
+ issues.push({
214
+ type: 'dangling-registration',
215
+ feature: reg.feature,
216
+ path,
217
+ detail: `"${reg.feature}" registra worktree.path=${path} na raiz, mas o diretório não existe`,
218
+ fix: `morph-spec doctor --worktrees --prune`,
219
+ });
220
+ } else if (!registeredPaths.has(path)) {
221
+ issues.push({
222
+ type: 'unregistered-registration',
223
+ feature: reg.feature,
224
+ path,
225
+ detail: `"${reg.feature}" registra worktree.path=${path}, mas o git não o conhece como worktree — a próxima \`worktree setup\` recusará com exit 3`,
226
+ fix: `inspecione o diretório; se não serve mais, apague-o e rode morph-spec doctor --worktrees --prune`,
227
+ });
228
+ }
229
+ }
230
+
164
231
  return issues;
165
232
  }
166
233
 
167
234
  /**
168
- * Drop git's admin records for worktrees whose directory is already gone.
235
+ * Drop git's admin records for worktrees whose directory is already gone, AND
236
+ * clear any feature's `worktree` registration that names one of those same
237
+ * gone directories.
169
238
  *
170
- * The ONLY prune offered, on purpose: it cannot destroy anything, because
171
- * everything it touches is already absent from disk. Removing a directory that
172
- * still exists is exactly the operation that needs a human looking at it.
239
+ * Both halves are the ONLY prune offered, on purpose: neither can destroy
240
+ * anything, because everything they touch is already absent from disk.
241
+ * Removing a directory that still exists is exactly the operation that needs
242
+ * a human looking at it.
243
+ *
244
+ * `clearDanglingWorktreeRegistration` re-checks the dangling predicate itself
245
+ * (path unset, or still exists → no-op) rather than trusting a pre-computed
246
+ * list, so this stays correct even called against a stale audit snapshot.
173
247
  *
174
248
  * @param {string} [cwd]
175
- * @returns {{ pruned: boolean, error?: string }}
249
+ * @returns {Promise<{ pruned: boolean, error?: string, clearedRegistrations: Array<{feature: string, cleared: boolean}> }>}
176
250
  */
177
- export function pruneWorktrees(cwd = process.cwd()) {
178
- if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo' };
251
+ export async function pruneWorktrees(cwd = process.cwd()) {
252
+ if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo', clearedRegistrations: [] };
253
+ const root = resolvePrimaryRoot(cwd);
254
+
255
+ let pruned = false;
256
+ let error;
179
257
  try {
180
- execFileSync('git', ['worktree', 'prune'], { cwd: resolvePrimaryRoot(cwd), stdio: 'pipe' });
181
- return { pruned: true };
258
+ execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
259
+ pruned = true;
182
260
  } catch (err) {
183
- return { pruned: false, error: (err.message || 'unknown').slice(0, 200) };
261
+ error = (err.message || 'unknown').slice(0, 200);
184
262
  }
263
+
264
+ const clearedRegistrations = [];
265
+ for (const reg of featureRegistrations(root)) {
266
+ const { cleared } = await clearDanglingWorktreeRegistration(root, reg.feature);
267
+ if (cleared) clearedRegistrations.push({ feature: reg.feature, cleared: true });
268
+ }
269
+
270
+ return { pruned, ...(error ? { error } : {}), clearedRegistrations };
185
271
  }