@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.
- package/CLAUDE.md +6 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +20 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +2 -2
- package/framework/docs/CLI.md +10 -5
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +3 -2
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +2 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/branch.js +170 -0
- package/src/commands/project/doctor.js +137 -9
- package/src/commands/project/fleet.js +10 -3
- package/src/commands/project/worktree.js +73 -50
- package/src/commands/state/finish.js +41 -26
- package/src/commands/state/list.js +79 -6
- package/src/core/state/lifecycle-audit.js +127 -0
- package/src/lib/git/branch-classifier.js +177 -0
- package/src/lib/git/gh.js +30 -0
- package/src/lib/git/pending-prs.js +89 -0
- package/src/lib/git/pr-index.js +70 -0
- package/src/lib/tasks/test-runner.js +200 -38
- package/src/lib/validators/verify-runner.js +30 -4
- package/src/lib/worktree/fleet-scanner.js +51 -21
- package/src/lib/worktree/ignored-triage.js +197 -0
- 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
|
|
19
|
-
* possible: `git worktree prune` drops ADMIN RECORDS for directories
|
|
20
|
-
* already gone
|
|
21
|
-
*
|
|
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())
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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
|
}
|