@polymorphism-tech/morph-spec 8.5.0 → 8.7.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/README.md +4 -4
- package/bin/morph-spec.js +23 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +2 -2
- package/docs/QUICKSTART.md +2 -2
- package/framework/CLAUDE.md +12 -7
- package/framework/MORPH.md +49 -6
- package/framework/evals/rubrics/test-coverage.md +4 -1
- package/framework/hooks/claude-code/core/telemetry-log.js +15 -0
- package/framework/hooks/claude-code/core/worktree-context.js +41 -5
- package/framework/hooks/shared/claim-registry.js +175 -0
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/hooks/shared/state-reader.js +68 -25
- package/framework/rules/morph-task-tracking.md +14 -8
- package/framework/rules/morph-workflow.md +36 -3
- package/framework/schemas/feature.schema.json +2 -1
- package/framework/schemas/tasks.schema.json +6 -1
- 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 +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +4 -3
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +83 -13
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +7 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/claim.js +91 -0
- package/src/commands/project/doctor.js +65 -0
- package/src/commands/project/fleet.js +37 -2
- package/src/commands/project/worktree.js +70 -7
- package/src/commands/state/advance.js +60 -1
- package/src/commands/state/approve.js +125 -2
- package/src/commands/state/archive.js +44 -5
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/finish.js +103 -7
- package/src/commands/state/list.js +9 -1
- package/src/commands/state/report-check.js +202 -0
- package/src/commands/tasks/dag.js +25 -2
- package/src/commands/validation/validate-feature.js +9 -2
- package/src/core/paths/output-schema.js +66 -6
- package/src/core/state/lifecycle-audit.js +57 -0
- package/src/dashboard/scanner/state-scanner.js +11 -3
- package/src/lib/agents/output-collector.js +5 -0
- package/src/lib/generators/architecture-scanner.js +1 -1
- package/src/lib/generators/handoff-generator.js +4 -2
- package/src/lib/specs/usecase-check.js +173 -0
- package/src/lib/specs/usecase-promoter.js +120 -0
- package/src/lib/tasks/tasks-json-parser.js +16 -0
- package/src/lib/worktree/fleet-scanner.js +224 -15
- package/src/lib/worktree/worktree-audit.js +181 -0
- package/src/utils/file-copier.js +5 -1
|
@@ -17,18 +17,118 @@ import { execSync, execFileSync } from 'child_process';
|
|
|
17
17
|
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
18
18
|
import { join, resolve } from 'path';
|
|
19
19
|
import { isGitRepo, resolvePrimaryRoot, parseWorktreeList } from '../../commands/project/worktree.js';
|
|
20
|
+
import { hasOriginRemote } from '../git/default-branch.js';
|
|
20
21
|
import { registryPath, resolveFleetConfig } from './env-allocator.js';
|
|
21
22
|
import {
|
|
22
23
|
getFeature,
|
|
23
24
|
derivePhaseForFeature,
|
|
25
|
+
derivePhaseFrom,
|
|
24
26
|
getPendingGates,
|
|
27
|
+
pendingGatesFrom,
|
|
25
28
|
} from '../../../framework/hooks/shared/state-reader.js';
|
|
29
|
+
import { listClaims } from '../../../framework/hooks/shared/claim-registry.js';
|
|
30
|
+
|
|
31
|
+
function countFrom(raw) {
|
|
32
|
+
const data = JSON.parse(raw);
|
|
33
|
+
if (!Array.isArray(data.tasks)) return null;
|
|
34
|
+
return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
/**
|
|
103
|
+
* Task progress for one feature.
|
|
104
|
+
*
|
|
105
|
+
* DIVERGENCE from the bug report, which blamed "a cached state.json/feature.json
|
|
106
|
+
* snapshot": this never read either — it always read `2-plan/tasks.json`
|
|
107
|
+
* directly. The real mechanism is which COPY of that file is on disk. Since
|
|
108
|
+
* v8.4 every feature is born on `morph/{f}` and its tasks.json is COMMITTED
|
|
109
|
+
* there; the primary root's working tree only ever holds the copy of the
|
|
110
|
+
* CHECKED-OUT branch. An operator sitting on the default therefore read the
|
|
111
|
+
* pre-work copy — the reported `2/2` for a feature whose branch already had
|
|
112
|
+
* T1..T3 all done.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} featureDir - the feature dir inside `path`
|
|
115
|
+
* @param {{ root: string, feature: string, featureBranch: string|null }} ctx
|
|
116
|
+
*/
|
|
117
|
+
function taskCounts(featureDir, { root, feature, featureBranch } = {}) {
|
|
118
|
+
if (featureBranch && root && feature) {
|
|
119
|
+
try {
|
|
120
|
+
const raw = execFileSync(
|
|
121
|
+
'git',
|
|
122
|
+
['show', `${featureBranch}:.morph/features/${feature}/2-plan/tasks.json`],
|
|
123
|
+
{ cwd: root, stdio: 'pipe' },
|
|
124
|
+
).toString();
|
|
125
|
+
const counted = countFrom(raw);
|
|
126
|
+
if (counted) return counted;
|
|
127
|
+
} catch { /* no such branch/blob — fall through to the working tree */ }
|
|
128
|
+
}
|
|
26
129
|
|
|
27
|
-
function taskCounts(featureDir) {
|
|
28
130
|
try {
|
|
29
|
-
|
|
30
|
-
if (!Array.isArray(data.tasks)) return null;
|
|
31
|
-
return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
|
|
131
|
+
return countFrom(readFileSync(join(featureDir, '2-plan', 'tasks.json'), 'utf8'));
|
|
32
132
|
} catch {
|
|
33
133
|
return null;
|
|
34
134
|
}
|
|
@@ -63,17 +163,108 @@ function dirtyCount(worktreePath) {
|
|
|
63
163
|
}
|
|
64
164
|
}
|
|
65
165
|
|
|
66
|
-
|
|
166
|
+
/**
|
|
167
|
+
* One `git ls-remote --heads origin` for the WHOLE scan → Map<branch, sha>.
|
|
168
|
+
*
|
|
169
|
+
* The fleet used to read only local state, so a feature whose branch (and PR)
|
|
170
|
+
* already lived on origin still showed up as "pending, nothing pushed" — that
|
|
171
|
+
* reading is what made a second session redo finished work. Resolved once per
|
|
172
|
+
* scan, never per entry.
|
|
173
|
+
*
|
|
174
|
+
* Returns `null` when there is no origin, or the probe fails/times out: the
|
|
175
|
+
* entries then carry `remote: null` ("unknown"), never a false `pushed: false`.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} root
|
|
178
|
+
* @returns {Map<string,string>|null}
|
|
179
|
+
*/
|
|
180
|
+
function remoteHeads(root) {
|
|
181
|
+
if (!hasOriginRemote(root)) return null;
|
|
182
|
+
try {
|
|
183
|
+
const out = execFileSync('git', ['ls-remote', '--heads', 'origin'], {
|
|
184
|
+
cwd: root, stdio: 'pipe', timeout: 5000,
|
|
185
|
+
}).toString();
|
|
186
|
+
const map = new Map();
|
|
187
|
+
for (const line of out.split('\n')) {
|
|
188
|
+
const m = line.match(/^([0-9a-f]{7,64})\s+refs\/heads\/(.+)$/);
|
|
189
|
+
if (m) map.set(m[2].trim(), m[1]);
|
|
190
|
+
}
|
|
191
|
+
return map;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One `gh pr list` for the whole scan → Map<headRefName, {number, state}>.
|
|
199
|
+
*
|
|
200
|
+
* Gated on the origin URL actually pointing at github.com — `gh` against any
|
|
201
|
+
* other host is a guaranteed error, and paying its start-up cost to learn that
|
|
202
|
+
* on every `fleet` call is pure latency. Fail-open: no `gh`, not authenticated,
|
|
203
|
+
* or a timeout all yield `null`, which just leaves the PR half of the column
|
|
204
|
+
* empty while `pushed` still works.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} root
|
|
207
|
+
* @returns {Map<string,{number:number,state:string}>|null}
|
|
208
|
+
*/
|
|
209
|
+
function remotePrs(root) {
|
|
210
|
+
try {
|
|
211
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
|
|
212
|
+
if (!/github\.com/i.test(url)) return null;
|
|
213
|
+
const out = execFileSync('gh', ['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number,state,headRefName'], {
|
|
214
|
+
cwd: root, stdio: 'pipe', timeout: 8000,
|
|
215
|
+
}).toString();
|
|
216
|
+
const map = new Map();
|
|
217
|
+
for (const pr of JSON.parse(out)) {
|
|
218
|
+
// First wins: `gh` lists newest-first, so an OPEN PR shadows an older
|
|
219
|
+
// closed one on the same branch.
|
|
220
|
+
if (pr?.headRefName && !map.has(pr.headRefName)) map.set(pr.headRefName, { number: pr.number, state: pr.state });
|
|
221
|
+
}
|
|
222
|
+
return map;
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The remote fact for one feature. Worktree entries know their real branch
|
|
230
|
+
* (which may carry a `--fresh` date suffix); root-resident features fall back
|
|
231
|
+
* to the canonical `morph/{feature}` every feature is born on.
|
|
232
|
+
*/
|
|
233
|
+
function buildRemote(feature, branch, heads, prs) {
|
|
234
|
+
if (!heads) return null;
|
|
235
|
+
const remoteBranch = branch || `morph/${feature}`;
|
|
236
|
+
const sha = heads.get(remoteBranch) || null;
|
|
237
|
+
return { branch: remoteBranch, pushed: sha !== null, sha, pr: prs?.get(remoteBranch) || null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildEntry(feature, { location, path, branch, root, allocations, blockSize, heads, prs, claims }) {
|
|
67
241
|
let workType = null;
|
|
68
242
|
let status = 'unknown';
|
|
69
243
|
let phase = 'proposal';
|
|
70
244
|
let pendingGates = [];
|
|
245
|
+
|
|
246
|
+
// Resolved once and shared with taskCounts — see branchToReadFrom.
|
|
247
|
+
const featureBranch = branchToReadFrom(root, feature, location, branch);
|
|
248
|
+
|
|
71
249
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
250
|
+
// B3b: phase and gates suffered the same branch-staleness B3 fixed for
|
|
251
|
+
// tasks. They read `.morph/features/{f}/` through the working tree, so a
|
|
252
|
+
// root parked on the default reported the PRE-WORK phase for a feature
|
|
253
|
+
// whose branch was already at implement — and re-asked for a gate the
|
|
254
|
+
// feature branch had already approved.
|
|
255
|
+
const view = featureBranch ? branchView(root, feature, featureBranch) : null;
|
|
256
|
+
if (view) {
|
|
257
|
+
workType = view.fj?.workType || null;
|
|
258
|
+
status = view.fj?.status || 'unknown';
|
|
259
|
+
phase = derivePhaseFrom(view.fj, (rel) => view.files.has(rel));
|
|
260
|
+
pendingGates = pendingGatesFrom(view.fj);
|
|
261
|
+
} else {
|
|
262
|
+
const fj = getFeature(feature, path);
|
|
263
|
+
workType = fj?.workType || null;
|
|
264
|
+
status = fj?.status || 'unknown';
|
|
265
|
+
phase = derivePhaseForFeature(feature, path);
|
|
266
|
+
pendingGates = getPendingGates(feature, path);
|
|
267
|
+
}
|
|
77
268
|
} catch { /* field-level fail-open */ }
|
|
78
269
|
|
|
79
270
|
const featureDir = join(path, '.morph', 'features', feature);
|
|
@@ -87,13 +278,19 @@ function buildEntry(feature, { location, path, branch, root, allocations, blockS
|
|
|
87
278
|
location,
|
|
88
279
|
path,
|
|
89
280
|
branch,
|
|
90
|
-
tasks: taskCounts(featureDir),
|
|
281
|
+
tasks: taskCounts(featureDir, { root, feature, featureBranch }),
|
|
91
282
|
env: alloc && Number.isInteger(alloc.base)
|
|
92
283
|
? { base: alloc.base, range: [alloc.base, alloc.base + blockSize - 1] }
|
|
93
284
|
: null,
|
|
94
285
|
handoff: handoffInfo(featureDir),
|
|
95
286
|
lastCommit: location === 'worktree' && branch ? lastCommitRelative(root, branch) : null,
|
|
96
287
|
dirty: location === 'worktree' ? dirtyCount(path) : null,
|
|
288
|
+
remote: buildRemote(feature, branch, heads, prs),
|
|
289
|
+
// Advisory ownership (B1): which session is on this feature right now.
|
|
290
|
+
// `live` is what matters for the operator — a stale claim is reported as
|
|
291
|
+
// stale rather than hidden, so "nobody is here" and "somebody died here"
|
|
292
|
+
// stay distinguishable.
|
|
293
|
+
claim: claims?.[feature] || null,
|
|
97
294
|
};
|
|
98
295
|
}
|
|
99
296
|
|
|
@@ -110,15 +307,24 @@ export function scanFleet(cwd = process.cwd()) {
|
|
|
110
307
|
const root = resolvePrimaryRoot(cwd);
|
|
111
308
|
|
|
112
309
|
let allocations = {};
|
|
310
|
+
const claims = {};
|
|
113
311
|
try {
|
|
114
312
|
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
115
313
|
if (common) {
|
|
116
|
-
const
|
|
117
|
-
|
|
314
|
+
const gitCommonDir = resolve(root, common);
|
|
315
|
+
try {
|
|
316
|
+
const reg = JSON.parse(readFileSync(registryPath(gitCommonDir), 'utf8'));
|
|
317
|
+
if (reg && typeof reg.allocations === 'object' && reg.allocations !== null) allocations = reg.allocations;
|
|
318
|
+
} catch { /* no env registry yet — env stays null per entry */ }
|
|
319
|
+
for (const c of listClaims(gitCommonDir)) claims[c.feature] = c;
|
|
118
320
|
}
|
|
119
|
-
} catch { /* no
|
|
321
|
+
} catch { /* no common dir — env and claim both stay null per entry */ }
|
|
120
322
|
const { blockSize } = resolveFleetConfig(root);
|
|
121
323
|
|
|
324
|
+
// Remote awareness, resolved ONCE for the whole scan (see remoteHeads/remotePrs).
|
|
325
|
+
const heads = remoteHeads(root);
|
|
326
|
+
const prs = heads ? remotePrs(root) : null;
|
|
327
|
+
|
|
122
328
|
const entries = [];
|
|
123
329
|
const inWorktree = new Set();
|
|
124
330
|
|
|
@@ -133,6 +339,9 @@ export function scanFleet(cwd = process.cwd()) {
|
|
|
133
339
|
root,
|
|
134
340
|
allocations,
|
|
135
341
|
blockSize,
|
|
342
|
+
heads,
|
|
343
|
+
prs,
|
|
344
|
+
claims,
|
|
136
345
|
}));
|
|
137
346
|
}
|
|
138
347
|
|
|
@@ -146,7 +355,7 @@ export function scanFleet(cwd = process.cwd()) {
|
|
|
146
355
|
.filter((name) => !inWorktree.has(name) && existsSync(join(featuresDir, name, 'feature.json')));
|
|
147
356
|
for (const name of names) {
|
|
148
357
|
entries.push(buildEntry(name, {
|
|
149
|
-
location: 'root', path: root, branch: null, root, allocations, blockSize,
|
|
358
|
+
location: 'root', path: root, branch: null, root, allocations, blockSize, heads, prs, claims,
|
|
150
359
|
}));
|
|
151
360
|
}
|
|
152
361
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-audit.js — reconcile the three views of "which worktrees exist".
|
|
3
|
+
*
|
|
4
|
+
* Orphan worktrees accumulate silently. In the observed session,
|
|
5
|
+
* `.worktrees/verify-tables-ui` and `.worktrees/tmp-cleanup` were registered in
|
|
6
|
+
* `git worktree list` yet ABSENT from `morph-spec fleet` — fleet only reports
|
|
7
|
+
* worktrees whose branch matches `refs/heads/morph/*`, so anything else is
|
|
8
|
+
* invisible by construction and therefore never cleaned up (C2).
|
|
9
|
+
*
|
|
10
|
+
* Nothing compared the three sources that must agree:
|
|
11
|
+
*
|
|
12
|
+
* 1. git's registry — `git worktree list --porcelain`
|
|
13
|
+
* 2. the disk — directories under `worktrees/` (and legacy `.worktrees/`)
|
|
14
|
+
* 3. morph's features — `.morph/features/{f}/feature.json`
|
|
15
|
+
*
|
|
16
|
+
* Read-only and fail-open: anything unresolvable yields no issue rather than a
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'child_process';
|
|
25
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
26
|
+
import { join, resolve } from 'path';
|
|
27
|
+
import {
|
|
28
|
+
isGitRepo,
|
|
29
|
+
resolvePrimaryRoot,
|
|
30
|
+
parseWorktreeList,
|
|
31
|
+
WORKTREE_DIR_NAMES,
|
|
32
|
+
} from '../../commands/project/worktree.js';
|
|
33
|
+
|
|
34
|
+
const norm = (p) => resolve(p).replace(/\\/g, '/');
|
|
35
|
+
|
|
36
|
+
/** Feature names morph knows about in the primary root. */
|
|
37
|
+
function knownFeatures(root) {
|
|
38
|
+
const names = new Set();
|
|
39
|
+
try {
|
|
40
|
+
const dir = join(root, '.morph', 'features');
|
|
41
|
+
if (!existsSync(dir)) return names;
|
|
42
|
+
for (const name of readdirSync(dir)) {
|
|
43
|
+
if (existsSync(join(dir, name, 'feature.json'))) names.add(name);
|
|
44
|
+
}
|
|
45
|
+
} catch { /* fail-open */ }
|
|
46
|
+
return names;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Is the feature's authoritative feature.json COMMITTED on its own branch?
|
|
51
|
+
*
|
|
52
|
+
* Not "does the branch exist": `git worktree add -b morph/{f}` creates the
|
|
53
|
+
* branch as a side effect, so branch-existence is true for every registered
|
|
54
|
+
* worktree and proves nothing. The blob is the real evidence — it is where the
|
|
55
|
+
* per-feature state model says the truth lives.
|
|
56
|
+
*/
|
|
57
|
+
function featureCommittedOnBranch(root, feature) {
|
|
58
|
+
try {
|
|
59
|
+
execFileSync('git', ['cat-file', '-e', `morph/${feature}:.morph/features/${feature}/feature.json`], {
|
|
60
|
+
cwd: root, stdio: 'pipe',
|
|
61
|
+
});
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Directories physically present under either worktree root. */
|
|
69
|
+
function dirsOnDisk(root) {
|
|
70
|
+
const found = [];
|
|
71
|
+
for (const dirName of WORKTREE_DIR_NAMES) {
|
|
72
|
+
const base = join(root, dirName);
|
|
73
|
+
if (!existsSync(base)) continue;
|
|
74
|
+
try {
|
|
75
|
+
for (const name of readdirSync(base)) {
|
|
76
|
+
const full = join(base, name);
|
|
77
|
+
try {
|
|
78
|
+
if (statSync(full).isDirectory()) found.push({ name, path: norm(full), container: dirName });
|
|
79
|
+
} catch { /* unreadable entry — skip */ }
|
|
80
|
+
}
|
|
81
|
+
} catch { /* fail-open */ }
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reconcile git's registry, the disk and morph's features.
|
|
88
|
+
*
|
|
89
|
+
* 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
|
|
95
|
+
*
|
|
96
|
+
* @param {string} [cwd]
|
|
97
|
+
* @returns {Array<{ type: string, detail: string, path?: string, fix: string }>}
|
|
98
|
+
*/
|
|
99
|
+
export function auditWorktrees(cwd = process.cwd()) {
|
|
100
|
+
if (!isGitRepo(cwd)) return [];
|
|
101
|
+
const root = resolvePrimaryRoot(cwd);
|
|
102
|
+
const rootNorm = norm(root);
|
|
103
|
+
const issues = [];
|
|
104
|
+
|
|
105
|
+
const registered = parseWorktreeList(root);
|
|
106
|
+
const registeredPaths = new Set();
|
|
107
|
+
const features = knownFeatures(root);
|
|
108
|
+
|
|
109
|
+
for (const entry of registered) {
|
|
110
|
+
const path = norm(entry.path);
|
|
111
|
+
registeredPaths.add(path);
|
|
112
|
+
if (path === rootNorm) continue; // the primary root is not a worktree orphan
|
|
113
|
+
|
|
114
|
+
if (!existsSync(path)) {
|
|
115
|
+
issues.push({
|
|
116
|
+
type: 'registered-missing',
|
|
117
|
+
path,
|
|
118
|
+
detail: `git registra um worktree em ${path}, mas o diretório não existe`,
|
|
119
|
+
fix: `git worktree prune (ou \`morph-spec doctor --worktrees --prune\`)`,
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const feature = entry.branch?.match(/^refs\/heads\/morph\/(.+)$/)?.[1] || null;
|
|
125
|
+
if (!feature) {
|
|
126
|
+
// Only worth reporting when it sits in morph's own worktree container —
|
|
127
|
+
// a worktree the user keeps elsewhere is none of morph's business.
|
|
128
|
+
const inContainer = WORKTREE_DIR_NAMES.some((d) => path.startsWith(`${rootNorm}/${d}/`));
|
|
129
|
+
if (inContainer) {
|
|
130
|
+
issues.push({
|
|
131
|
+
type: 'non-morph-branch',
|
|
132
|
+
path,
|
|
133
|
+
detail: `${path} está em ${entry.branch || 'HEAD destacado'}, não numa branch morph/* — o \`fleet\` não o enxerga`,
|
|
134
|
+
fix: `git worktree remove "${path}" (se já não serve)`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!features.has(feature) && !featureCommittedOnBranch(root, feature)) {
|
|
141
|
+
issues.push({
|
|
142
|
+
type: 'no-feature',
|
|
143
|
+
path,
|
|
144
|
+
detail: `worktree de "${feature}" sem feature conhecida (nem em .morph/features/, nem na branch)`,
|
|
145
|
+
fix: `git worktree remove "${path}"`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const d of dirsOnDisk(root)) {
|
|
151
|
+
if (registeredPaths.has(d.path)) continue;
|
|
152
|
+
issues.push({
|
|
153
|
+
type: 'unregistered-dir',
|
|
154
|
+
path: d.path,
|
|
155
|
+
detail: `${d.container}/${d.name} existe no disco mas o git não o registra — resíduo de um finish anterior`,
|
|
156
|
+
fix: `apague o diretório à mão depois de conferir que nada importante ficou nele`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return issues;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Drop git's admin records for worktrees whose directory is already gone.
|
|
165
|
+
*
|
|
166
|
+
* The ONLY prune offered, on purpose: it cannot destroy anything, because
|
|
167
|
+
* everything it touches is already absent from disk. Removing a directory that
|
|
168
|
+
* still exists is exactly the operation that needs a human looking at it.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} [cwd]
|
|
171
|
+
* @returns {{ pruned: boolean, error?: string }}
|
|
172
|
+
*/
|
|
173
|
+
export function pruneWorktrees(cwd = process.cwd()) {
|
|
174
|
+
if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo' };
|
|
175
|
+
try {
|
|
176
|
+
execFileSync('git', ['worktree', 'prune'], { cwd: resolvePrimaryRoot(cwd), stdio: 'pipe' });
|
|
177
|
+
return { pruned: true };
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return { pruned: false, error: (err.message || 'unknown').slice(0, 200) };
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/utils/file-copier.js
CHANGED
|
@@ -165,7 +165,11 @@ export async function updateGitignore(projectPath) {
|
|
|
165
165
|
'.claude/agents/',
|
|
166
166
|
'.claude/CLAUDE.md',
|
|
167
167
|
'.claude/settings.local.json',
|
|
168
|
-
'
|
|
168
|
+
'/worktrees/',
|
|
169
|
+
// Legacy name (pre-C1). Kept so a project that already has `.worktrees/`
|
|
170
|
+
// on disk does not suddenly see it as untracked dirt — which would also
|
|
171
|
+
// make `finish`'s clean-root check refuse.
|
|
172
|
+
'/.worktrees/',
|
|
169
173
|
'.playwright-mcp/',
|
|
170
174
|
'docs/superpowers/',
|
|
171
175
|
''
|