@polymorphism-tech/morph-spec 8.31.0 → 8.33.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 (39) hide show
  1. package/CLAUDE.md +6 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +20 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +2 -1
  8. package/framework/MORPH.md +2 -2
  9. package/framework/docs/CLI.md +10 -5
  10. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  11. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  12. package/framework/skills/core/morph-apply/SKILL.md +3 -2
  13. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  14. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  15. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  16. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  17. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  18. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  19. package/framework/skills/core/morph-review/SKILL.md +2 -2
  20. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  22. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  23. package/package.json +1 -1
  24. package/src/commands/project/branch.js +170 -0
  25. package/src/commands/project/doctor.js +137 -9
  26. package/src/commands/project/fleet.js +10 -3
  27. package/src/commands/project/worktree.js +73 -50
  28. package/src/commands/state/finish.js +41 -26
  29. package/src/commands/state/list.js +79 -6
  30. package/src/core/state/lifecycle-audit.js +127 -0
  31. package/src/lib/git/branch-classifier.js +177 -0
  32. package/src/lib/git/gh.js +30 -0
  33. package/src/lib/git/pending-prs.js +89 -0
  34. package/src/lib/git/pr-index.js +70 -0
  35. package/src/lib/tasks/test-runner.js +200 -38
  36. package/src/lib/validators/verify-runner.js +30 -4
  37. package/src/lib/worktree/fleet-scanner.js +51 -21
  38. package/src/lib/worktree/ignored-triage.js +197 -0
  39. package/src/lib/worktree/worktree-audit.js +200 -35
@@ -15,14 +15,16 @@
15
15
  *
16
16
  * Read-only and fail-open: anything unresolvable yields no issue rather than a
17
17
  * throw. A diagnostic that invents problems is worse than one that misses some.
18
- * The one mutation lives in `pruneWorktrees`, and it is deliberately the safest
19
- * possible: `git worktree prune` drops ADMIN RECORDS for directories that are
20
- * already gone. Deleting a directory that still exists stays a human decision —
21
- * the same PROVE-or-DECLINE discipline `teardownWorktree` follows.
18
+ * The mutations all live in `pruneWorktrees`, and each is deliberately the
19
+ * safest possible: `git worktree prune` drops ADMIN RECORDS for directories
20
+ * that are already gone, and the empty-residue sweep removes only directories
21
+ * with zero entries inside — nothing that can hold work. Deleting a directory
22
+ * that still HOLDS something stays a human decision, the same PROVE-or-DECLINE
23
+ * discipline `teardownWorktree` follows.
22
24
  */
23
25
 
24
26
  import { execFileSync } from 'child_process';
25
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
27
+ import { existsSync, readdirSync, readFileSync, rmdirSync, statSync } from 'fs';
26
28
  import { join, resolve } from 'path';
27
29
  import {
28
30
  isGitRepo,
@@ -31,6 +33,9 @@ import {
31
33
  WORKTREE_DIR_NAMES,
32
34
  clearDanglingWorktreeRegistration,
33
35
  } from '../../commands/project/worktree.js';
36
+ import { detectDefaultBranch } from '../git/default-branch.js';
37
+ import { buildPrIndex } from '../git/pr-index.js';
38
+ import { classifyBranch, commitsAhead, resolveBaseRef } from '../git/branch-classifier.js';
34
39
 
35
40
  const norm = (p) => resolve(p).replace(/\\/g, '/');
36
41
 
@@ -66,6 +71,54 @@ function featureCommittedOnBranch(root, feature) {
66
71
  }
67
72
  }
68
73
 
74
+ /**
75
+ * Does this worktree's branch still hold work that is NOT in the default
76
+ * branch, or an OPEN PR?
77
+ *
78
+ * This is the predicate that separates "an orphan directory to clean up" from
79
+ * "the only copy of somebody's work". Two independent pieces of evidence, and
80
+ * ANY of them is enough to protect the worktree:
81
+ *
82
+ * 1. commits the default branch does not have (`git rev-list base..branch`)
83
+ * 2. an OPEN pull request on that branch
84
+ *
85
+ * Fail-CLOSED, and that asymmetry is the whole design: whenever the answer
86
+ * cannot be established — no default branch, no origin, git errored — the
87
+ * worktree counts as UNINTEGRATED. The cost of being wrong that way is a
88
+ * diagnostic that stays quiet; the cost of the opposite is a suggestion to
89
+ * delete work that exists nowhere else.
90
+ *
91
+ * A squash-merged branch legitimately reports commits ahead, so the tree
92
+ * comparison (`branch-classifier.js`) is consulted for the same reason
93
+ * `branch prune` needs it — the two must not disagree about "is this in".
94
+ *
95
+ * @returns {{ unintegrated: boolean, evidence: string, pr: number|null }}
96
+ */
97
+ function unintegratedWork(root, branchRef, prIndex) {
98
+ const branch = (branchRef || '').replace(/^refs\/heads\//, '');
99
+ if (!branch) return { unintegrated: true, evidence: 'não foi possível resolver a branch do worktree', pr: null };
100
+
101
+ const pr = prIndex?.get(branch) ?? null;
102
+ if (pr?.state === 'OPEN') return { unintegrated: true, evidence: `PR #${pr.number} OPEN`, pr: pr.number };
103
+
104
+ const defaultBranch = detectDefaultBranch(root);
105
+ const baseRef = resolveBaseRef(defaultBranch, root);
106
+ if (!baseRef) return { unintegrated: true, evidence: 'sem branch default resolvível para comparar', pr: null };
107
+
108
+ const ahead = commitsAhead(baseRef, branch, root);
109
+ if (ahead === null) return { unintegrated: true, evidence: `não foi possível comparar com ${baseRef}`, pr: null };
110
+ if (ahead === 0) return { unintegrated: false, evidence: `nenhum commit fora de ${baseRef}`, pr: pr?.number ?? null };
111
+
112
+ // Commits ahead, but the work may still be IN: a squash-merge leaves exactly
113
+ // that shape. Reuse the classifier so `doctor` and `branch prune` cannot
114
+ // reach opposite conclusions about the same branch.
115
+ const verdict = classifyBranch({ name: branch, sha: '', current: false, worktreePath: null }, { baseRef, cwd: root, pr });
116
+ if (verdict.kind === 'merged-ancestral' || verdict.kind === 'merged-squash') {
117
+ return { unintegrated: false, evidence: verdict.reason, pr: pr?.number ?? null };
118
+ }
119
+ return { unintegrated: true, evidence: `${ahead} commit(s) fora de ${baseRef}`, pr: pr?.number ?? null };
120
+ }
121
+
69
122
  /**
70
123
  * Every feature's `worktree` registration in the primary root — the OTHER
71
124
  * direction from `knownFeatures`/`dirsOnDisk`: not "does git/disk know about
@@ -96,6 +149,22 @@ function featureRegistrations(root) {
96
149
  return out;
97
150
  }
98
151
 
152
+ /**
153
+ * Is this directory literally empty — zero entries, dotfiles included?
154
+ *
155
+ * The whole point of the distinction: an empty residue holds nothing that can
156
+ * be lost, so it can be removed without a human looking at it. Anything else —
157
+ * including a directory we cannot read — is reported as NON-empty, because the
158
+ * safe answer to "might there be work in here?" is yes.
159
+ */
160
+ function isEmptyDir(path) {
161
+ try {
162
+ return readdirSync(path).length === 0;
163
+ } catch {
164
+ return false; // unreadable → treat as occupied
165
+ }
166
+ }
167
+
99
168
  /** Directories physically present under either worktree root. */
100
169
  function dirsOnDisk(root) {
101
170
  const found = [];
@@ -106,7 +175,9 @@ function dirsOnDisk(root) {
106
175
  for (const name of readdirSync(base)) {
107
176
  const full = join(base, name);
108
177
  try {
109
- if (statSync(full).isDirectory()) found.push({ name, path: norm(full), container: dirName });
178
+ if (statSync(full).isDirectory()) {
179
+ found.push({ name, path: norm(full), container: dirName, empty: isEmptyDir(full) });
180
+ }
110
181
  } catch { /* unreadable entry — skip */ }
111
182
  }
112
183
  } catch { /* fail-open */ }
@@ -125,7 +196,13 @@ function dirsOnDisk(root) {
125
196
  * • unregistered-dir — a directory under worktrees/ git never
126
197
  * registered
127
198
  * • no-feature — a `morph/{f}` worktree for a feature morph
128
- * does not know
199
+ * does not know AND whose branch carries
200
+ * nothing that is not already integrated
201
+ * • unintegrated-work (info) — the same shape, except the branch still holds
202
+ * commits the default does not, or an OPEN PR.
203
+ * Reported so the reader knows why no removal
204
+ * is suggested — this worktree may be the ONLY
205
+ * copy of the work
129
206
  * • dangling-registration — a feature's ROOT feature.json still points
130
207
  * at a `worktree.path` that no longer exists
131
208
  * (the symmetric OTHER direction from
@@ -144,6 +221,11 @@ export function auditWorktrees(cwd = process.cwd()) {
144
221
  const rootNorm = norm(root);
145
222
  const issues = [];
146
223
 
224
+ // ONE `gh pr list` for the whole audit — null means UNKNOWN (no gh, not
225
+ // GitHub, not authenticated), never "no PRs"; `unintegratedWork` treats the
226
+ // two differently on purpose.
227
+ const prIndex = buildPrIndex(root);
228
+
147
229
  const registered = parseWorktreeList(root);
148
230
  const registeredPaths = new Set();
149
231
  const features = knownFeatures(root);
@@ -169,27 +251,65 @@ export function auditWorktrees(cwd = process.cwd()) {
169
251
  // a worktree the user keeps elsewhere is none of morph's business.
170
252
  const inContainer = WORKTREE_DIR_NAMES.some((d) => path.startsWith(`${rootNorm}/${d}/`));
171
253
  if (inContainer) {
172
- issues.push({
173
- type: 'non-morph-branch',
174
- path,
175
- detail: `${path} está em ${entry.branch || 'HEAD destacado'}, não numa branch morph/* — o \`fleet\` não o enxerga`,
176
- // NEVER raw `git worktree remove` here: it follows the infra junctions
177
- // out and empties the primary root's .claude/ and .morph/framework/.
178
- // `worktree remove` drops them first (PROVE-or-DECLINE) and takes
179
- // --target precisely for worktrees like this one, with no feature.
180
- fix: `morph-spec worktree remove --target "${path}" (se já não serve)`,
181
- });
254
+ // Same hazard as `no-feature` below, and it needs the same guard:
255
+ // "invisible to fleet" says nothing about whether the WORK is safe
256
+ // elsewhere. A worktree on a `feat/*` branch with an open PR is the
257
+ // most normal thing in the world, and suggesting its removal is a
258
+ // suggestion to destroy the only copy.
259
+ const work = unintegratedWork(root, entry.branch, prIndex);
260
+ issues.push(work.unintegrated
261
+ ? {
262
+ type: 'unintegrated-work',
263
+ severity: 'info',
264
+ path,
265
+ detail: `${path} está em ${entry.branch || 'HEAD destacado'} (o \`fleet\` não o enxerga) e carrega trabalho NÃO integrado (${work.evidence})`,
266
+ fix: work.pr
267
+ ? `nada a fazer enquanto o PR #${work.pr} estiver aberto`
268
+ : `integre a branch antes de remover o worktree — ele é o único exemplar`,
269
+ }
270
+ : {
271
+ type: 'non-morph-branch',
272
+ path,
273
+ detail: `${path} está em ${entry.branch || 'HEAD destacado'}, não numa branch morph/* — o \`fleet\` não o enxerga (e não há trabalho pendente: ${work.evidence})`,
274
+ // NEVER raw `git worktree remove` here: it follows the infra junctions
275
+ // out and empties the primary root's .claude/ and .morph/framework/.
276
+ // `worktree remove` drops them first (PROVE-or-DECLINE) and takes
277
+ // --target precisely for worktrees like this one, with no feature.
278
+ fix: `morph-spec worktree remove --target "${path}" (se já não serve)`,
279
+ });
182
280
  }
183
281
  continue;
184
282
  }
185
283
 
186
284
  if (!features.has(feature) && !featureCommittedOnBranch(root, feature)) {
187
- issues.push({
188
- type: 'no-feature',
189
- path,
190
- detail: `worktree de "${feature}" sem feature conhecida (nem em .morph/features/, nem na branch)`,
191
- fix: `morph-spec worktree remove --target "${path}"`,
192
- });
285
+ // "sem feature conhecida" is a bookkeeping fact, NOT a verdict on the
286
+ // work: a `finish` that archived the feature leaves exactly this shape,
287
+ // and so does a worktree whose PR is still open. In the observed session
288
+ // this branch recommended `worktree remove --target` for a worktree
289
+ // holding the ONLY copy of an open PR with 12 tasks and a 9.5 at Gate 3.
290
+ // So the diagnosis now depends on whether the WORK is integrated, and the
291
+ // suggestion to destroy appears only when it provably is.
292
+ const work = unintegratedWork(root, entry.branch, prIndex);
293
+ if (work.unintegrated) {
294
+ issues.push({
295
+ type: 'unintegrated-work',
296
+ severity: 'info',
297
+ path,
298
+ feature,
299
+ detail: `worktree com trabalho NÃO integrado (${work.evidence}) — a feature foi arquivada, o trabalho não`,
300
+ fix: work.pr
301
+ ? `nada a fazer enquanto o PR #${work.pr} estiver aberto; depois de mergeado, \`morph-spec worktree remove --target "${path}"\``
302
+ : `integre a branch (\`morph-spec finish\` ou um PR) antes de remover o worktree — ele é o único exemplar`,
303
+ });
304
+ } else {
305
+ issues.push({
306
+ type: 'no-feature',
307
+ path,
308
+ feature,
309
+ detail: `worktree de "${feature}" sem feature conhecida (nem em .morph/features/, nem na branch) e sem trabalho pendente (${work.evidence})`,
310
+ fix: `morph-spec worktree remove --target "${path}"`,
311
+ });
312
+ }
193
313
  }
194
314
  }
195
315
 
@@ -198,8 +318,16 @@ export function auditWorktrees(cwd = process.cwd()) {
198
318
  issues.push({
199
319
  type: 'unregistered-dir',
200
320
  path: d.path,
201
- detail: `${d.container}/${d.name} existe no disco mas o git não o registra — resíduo de um finish anterior`,
202
- fix: `apague o diretório à mão depois de conferir que nada importante ficou nele`,
321
+ empty: d.empty,
322
+ detail: d.empty
323
+ ? `${d.container}/${d.name} existe no disco, está VAZIO e o git não o registra — resíduo de um finish anterior`
324
+ : `${d.container}/${d.name} existe no disco mas o git não o registra — resíduo de um finish anterior`,
325
+ // An empty folder has nothing to lose, so it gets the automatic repair.
326
+ // One with content keeps the manual chore: there may be work in there,
327
+ // and the whole PROVE-or-DECLINE discipline exists for exactly that.
328
+ fix: d.empty
329
+ ? `morph-spec doctor --worktrees --prune`
330
+ : `apague o diretório à mão depois de conferir que nada importante ficou nele`,
203
331
  });
204
332
  }
205
333
 
@@ -232,24 +360,59 @@ export function auditWorktrees(cwd = process.cwd()) {
232
360
  }
233
361
 
234
362
  /**
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.
363
+ * Remove the EMPTY directories under `worktrees/`/`.worktrees/` that git does
364
+ * not register — the residue a finish leaves behind.
365
+ *
366
+ * "Empty" is the whole licence to act: a folder with zero entries cannot be
367
+ * hiding uncommitted work, so asking a human about it is pure ceremony, and
368
+ * the audit that only warns leaves it there forever. A directory with anything
369
+ * in it keeps the manual path.
370
+ *
371
+ * Two guards beyond the emptiness check:
372
+ * • only directories directly inside morph's own worktree containers, never
373
+ * the containers themselves and never a path git registers;
374
+ * • `rmdirSync`, not `rm -rf` — it REFUSES a non-empty directory, so a file
375
+ * that appears between the check and the call is a failure, not a loss.
376
+ *
377
+ * @param {string} root
378
+ * @returns {string[]} paths actually removed
379
+ */
380
+ function removeEmptyUnregisteredDirs(root) {
381
+ const registeredPaths = new Set(parseWorktreeList(root).map((e) => norm(e.path)));
382
+ const removed = [];
383
+ for (const d of dirsOnDisk(root)) {
384
+ if (!d.empty || registeredPaths.has(d.path)) continue;
385
+ try {
386
+ rmdirSync(d.path);
387
+ removed.push(d.path);
388
+ } catch { /* raced, locked or no longer empty — leave it to the next audit */ }
389
+ }
390
+ return removed;
391
+ }
392
+
393
+ /**
394
+ * Drop git's admin records for worktrees whose directory is already gone,
395
+ * remove the EMPTY unregistered residue directories, AND clear any feature's
396
+ * `worktree` registration that names a directory that is now gone.
397
+ *
398
+ * These three are the ONLY prune offered, on purpose: none can destroy
399
+ * anything, because everything they touch is either already absent from disk
400
+ * or provably empty. Removing a directory that still HOLDS something is
401
+ * exactly the operation that needs a human looking at it.
238
402
  *
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.
403
+ * Order is load-bearing: the empty directories go first, so a root pointer
404
+ * naming one of them becomes dangling in time for the registration sweep below
405
+ * to clear it — one command, not two runs.
243
406
  *
244
407
  * `clearDanglingWorktreeRegistration` re-checks the dangling predicate itself
245
408
  * (path unset, or still exists → no-op) rather than trusting a pre-computed
246
409
  * list, so this stays correct even called against a stale audit snapshot.
247
410
  *
248
411
  * @param {string} [cwd]
249
- * @returns {Promise<{ pruned: boolean, error?: string, clearedRegistrations: Array<{feature: string, cleared: boolean}> }>}
412
+ * @returns {Promise<{ pruned: boolean, error?: string, removedEmptyDirs: string[], clearedRegistrations: Array<{feature: string, cleared: boolean}> }>}
250
413
  */
251
414
  export async function pruneWorktrees(cwd = process.cwd()) {
252
- if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo', clearedRegistrations: [] };
415
+ if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo', removedEmptyDirs: [], clearedRegistrations: [] };
253
416
  const root = resolvePrimaryRoot(cwd);
254
417
 
255
418
  let pruned = false;
@@ -261,11 +424,13 @@ export async function pruneWorktrees(cwd = process.cwd()) {
261
424
  error = (err.message || 'unknown').slice(0, 200);
262
425
  }
263
426
 
427
+ const removedEmptyDirs = removeEmptyUnregisteredDirs(root);
428
+
264
429
  const clearedRegistrations = [];
265
430
  for (const reg of featureRegistrations(root)) {
266
431
  const { cleared } = await clearDanglingWorktreeRegistration(root, reg.feature);
267
432
  if (cleared) clearedRegistrations.push({ feature: reg.feature, cleared: true });
268
433
  }
269
434
 
270
- return { pruned, ...(error ? { error } : {}), clearedRegistrations };
435
+ return { pruned, ...(error ? { error } : {}), removedEmptyDirs, clearedRegistrations };
271
436
  }