@polymorphism-tech/morph-spec 8.6.0 → 8.8.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 +6 -3
- package/framework/MORPH.md +28 -3
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- 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/claude-code/statusline.py +545 -521
- package/framework/hooks/shared/claim-registry.js +175 -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 +34 -1
- package/framework/schemas/feature.schema.json +2 -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 +1 -1
- 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 +1 -1
- 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/create.js +3 -3
- package/src/commands/state/finish.js +88 -3
- package/src/commands/state/list.js +9 -1
- package/src/commands/state/report-check.js +202 -0
- package/src/core/state/lifecycle-audit.js +57 -0
- package/src/dashboard/scanner/state-scanner.js +11 -3
- package/src/lib/generators/architecture-scanner.js +1 -1
- package/src/lib/tasks/tasks-json-parser.js +8 -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
- package/src/utils/hooks-installer.js +7 -1
|
@@ -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
|
''
|
|
@@ -504,10 +504,16 @@ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOve
|
|
|
504
504
|
? `"${resolvePythonForStatusline()}" "${join(globalDir, 'statusline.py').replace(/\\/g, '/')}"`
|
|
505
505
|
: join(globalDir, 'statusline.sh').replace(/\\/g, '/');
|
|
506
506
|
|
|
507
|
+
// refreshInterval: without it the status line only re-renders on events
|
|
508
|
+
// (new assistant message, /compact, permission or vim mode change), so the
|
|
509
|
+
// `↻` countdown to the 5h rate-limit reset — the only widget that moves on
|
|
510
|
+
// its own — sits frozen through a long turn. Everything else is event-driven
|
|
511
|
+
// and would be fine without this.
|
|
507
512
|
settings.statusLine = {
|
|
508
513
|
type: 'command',
|
|
509
514
|
command: statuslineCommand,
|
|
510
|
-
padding: 2
|
|
515
|
+
padding: 2,
|
|
516
|
+
refreshInterval: 10
|
|
511
517
|
};
|
|
512
518
|
|
|
513
519
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|