@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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claim-registry.js — advisory feature ownership across parallel sessions.
|
|
3
|
+
*
|
|
4
|
+
* Two Claude Code sessions worked the SAME feature at the same time without
|
|
5
|
+
* seeing each other: one finished it and removed the worktree out from under
|
|
6
|
+
* the other's running sub-agent. A complete recap.md was discarded, a sub-agent
|
|
7
|
+
* wasted, and a decision taken on state that was already obsolete. There was no
|
|
8
|
+
* lock, no claim, no heartbeat — not even a warning.
|
|
9
|
+
*
|
|
10
|
+
* DELIBERATELY ADVISORY. This is not a lock: nothing here ever blocks a
|
|
11
|
+
* command. The framework is fail-open by principle, and a strong lock left
|
|
12
|
+
* behind by a crashed session would strand a feature for no gain. Being
|
|
13
|
+
* VISIBLE is the whole fix.
|
|
14
|
+
*
|
|
15
|
+
* Location: `<git-common-dir>/morph-spec/claims.json`, beside the fleet's
|
|
16
|
+
* env-registry, for the same three reasons — it can never dirty the `git
|
|
17
|
+
* status` the finish gates read, it survives `git clean -fdx`, and the COMMON
|
|
18
|
+
* dir is the one place every linked worktree of the repo shares. That last one
|
|
19
|
+
* is decisive here: in the observed incident one session sat inside the
|
|
20
|
+
* worktree and the other in the primary root, so a claim written to the
|
|
21
|
+
* branch-local `feature.json` would have been invisible to exactly the session
|
|
22
|
+
* that needed to read it.
|
|
23
|
+
*
|
|
24
|
+
* Writes are tmp+rename (torn file impossible); a corrupt registry reads as
|
|
25
|
+
* empty — the cost is a lost warning, never a crash.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
29
|
+
import { join } from 'path';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A claim with no heartbeat for this long is considered dead. The heartbeat
|
|
33
|
+
* fires on tool activity, so this is really "how long a session may sit idle
|
|
34
|
+
* before its claim stops warning others".
|
|
35
|
+
*/
|
|
36
|
+
export const CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/** Minimum gap between heartbeat WRITES. The heartbeat runs on every tool call. */
|
|
39
|
+
export const HEARTBEAT_THROTTLE_MS = 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/** Claim file path for a repo's common git dir. */
|
|
42
|
+
export function claimsPath(gitCommonDir) {
|
|
43
|
+
return join(gitCommonDir, 'morph-spec', 'claims.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readRegistry(gitCommonDir) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(claimsPath(gitCommonDir), 'utf8').replace(/^/, '');
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (parsed && typeof parsed.claims === 'object' && parsed.claims !== null) return parsed;
|
|
51
|
+
} catch { /* fail-open: absent or corrupt registry restarts empty */ }
|
|
52
|
+
return { version: 1, claims: {} };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeRegistry(gitCommonDir, registry) {
|
|
56
|
+
const path = claimsPath(gitCommonDir);
|
|
57
|
+
mkdirSync(join(gitCommonDir, 'morph-spec'), { recursive: true });
|
|
58
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
59
|
+
writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
60
|
+
renameSync(tmp, path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Is this claim still within its TTL? A missing/unparseable heartbeat is dead. */
|
|
64
|
+
export function isLive(claim, now = Date.now()) {
|
|
65
|
+
const beat = Date.parse(claim?.heartbeatAt ?? '');
|
|
66
|
+
return Number.isFinite(beat) && now - beat < CLAIM_TTL_MS;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Claim a feature for this session, or refresh an existing claim of ours.
|
|
71
|
+
*
|
|
72
|
+
* A claim held by ANOTHER session is TAKEN OVER, not refused — advisory by
|
|
73
|
+
* design, and the caller has already been warned via `foreignClaim`. The
|
|
74
|
+
* takeover is recorded in `previousSessionId` so it is never silent.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} feature
|
|
77
|
+
* @param {{ gitCommonDir: string, sessionId: string, pid?: number, path?: string }} opts
|
|
78
|
+
* @returns {object} the stored claim
|
|
79
|
+
*/
|
|
80
|
+
export function acquireClaim(feature, { gitCommonDir, sessionId, pid = process.pid, path = null }) {
|
|
81
|
+
const registry = readRegistry(gitCommonDir);
|
|
82
|
+
const previous = registry.claims[feature];
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
const claim = {
|
|
85
|
+
feature,
|
|
86
|
+
sessionId,
|
|
87
|
+
pid,
|
|
88
|
+
path: path ? String(path).replace(/\\/g, '/') : null,
|
|
89
|
+
claimedAt: previous?.sessionId === sessionId ? (previous.claimedAt || now) : now,
|
|
90
|
+
heartbeatAt: now,
|
|
91
|
+
...(previous && previous.sessionId !== sessionId ? { previousSessionId: previous.sessionId } : {}),
|
|
92
|
+
};
|
|
93
|
+
registry.claims[feature] = claim;
|
|
94
|
+
writeRegistry(gitCommonDir, registry);
|
|
95
|
+
return claim;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refresh our own claim's heartbeat. Throttled — this runs on every tool call,
|
|
100
|
+
* and a write per call would be pure I/O waste.
|
|
101
|
+
*
|
|
102
|
+
* Never touches a claim owned by another session: a heartbeat is proof of life,
|
|
103
|
+
* and forging someone else's would defeat the TTL.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} feature
|
|
106
|
+
* @param {{ gitCommonDir: string, sessionId: string }} opts
|
|
107
|
+
* @returns {boolean} true when a write actually happened
|
|
108
|
+
*/
|
|
109
|
+
export function heartbeatClaim(feature, { gitCommonDir, sessionId }) {
|
|
110
|
+
const registry = readRegistry(gitCommonDir);
|
|
111
|
+
const claim = registry.claims[feature];
|
|
112
|
+
if (!claim || claim.sessionId !== sessionId) return false;
|
|
113
|
+
|
|
114
|
+
const beat = Date.parse(claim.heartbeatAt ?? '');
|
|
115
|
+
if (Number.isFinite(beat) && Date.now() - beat < HEARTBEAT_THROTTLE_MS) return false;
|
|
116
|
+
|
|
117
|
+
claim.heartbeatAt = new Date().toISOString();
|
|
118
|
+
writeRegistry(gitCommonDir, registry);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The LIVE claim on this feature held by a session that is not us — the thing
|
|
124
|
+
* worth warning about. Returns null when the feature is unclaimed, claimed by
|
|
125
|
+
* us, or claimed by a session whose heartbeat went stale.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} feature
|
|
128
|
+
* @param {{ gitCommonDir: string, sessionId?: string }} opts
|
|
129
|
+
* @returns {object|null}
|
|
130
|
+
*/
|
|
131
|
+
export function foreignClaim(feature, { gitCommonDir, sessionId }) {
|
|
132
|
+
const claim = readRegistry(gitCommonDir).claims[feature];
|
|
133
|
+
if (!claim) return null;
|
|
134
|
+
if (sessionId && claim.sessionId === sessionId) return null;
|
|
135
|
+
return isLive(claim) ? claim : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Drop a feature's claim. Called by the manual `morph-spec claim <f> --release`
|
|
140
|
+
* (recovering from a session that died without releasing) and by `finish`.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} feature
|
|
143
|
+
* @param {{ gitCommonDir: string }} opts
|
|
144
|
+
* @returns {boolean} false when there was nothing to release
|
|
145
|
+
*/
|
|
146
|
+
export function releaseClaim(feature, { gitCommonDir }) {
|
|
147
|
+
const registry = readRegistry(gitCommonDir);
|
|
148
|
+
if (!registry.claims[feature]) return false;
|
|
149
|
+
delete registry.claims[feature];
|
|
150
|
+
writeRegistry(gitCommonDir, registry);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every claim on record, live and stale, newest heartbeat first. `live` is
|
|
156
|
+
* computed, never stored — a stored flag would go wrong the moment nobody
|
|
157
|
+
* rewrites the file.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} gitCommonDir
|
|
160
|
+
* @returns {object[]}
|
|
161
|
+
*/
|
|
162
|
+
export function listClaims(gitCommonDir) {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
return Object.values(readRegistry(gitCommonDir).claims)
|
|
165
|
+
.map((c) => ({ ...c, live: isLive(c, now) }))
|
|
166
|
+
.sort((a, b) => String(b.heartbeatAt).localeCompare(String(a.heartbeatAt)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Human-readable age of a claim's last heartbeat ("3 min", "2 h", "—"). */
|
|
170
|
+
export function claimAge(claim, now = Date.now()) {
|
|
171
|
+
const beat = Date.parse(claim?.heartbeatAt ?? '');
|
|
172
|
+
if (!Number.isFinite(beat)) return '—';
|
|
173
|
+
const min = Math.max(0, Math.round((now - beat) / 60000));
|
|
174
|
+
return min < 60 ? `${min} min` : `${Math.round(min / 60)} h`;
|
|
175
|
+
}
|
|
@@ -181,6 +181,63 @@ export function getFeature(featureName, projectPath) {
|
|
|
181
181
|
*/
|
|
182
182
|
export const PHASE_INDEX = { proposal: 0, uiux: 1, plan: 2, implement: 3, review: 4 };
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The canonical artifact of each phase, descending. Exported so a caller that
|
|
186
|
+
* holds the file list from somewhere OTHER than the local filesystem (a git
|
|
187
|
+
* tree, say) can answer the same question without re-encoding the map.
|
|
188
|
+
*/
|
|
189
|
+
export const PHASE_ARTIFACTS = [
|
|
190
|
+
['4-review', 'review', 'review-report.md'],
|
|
191
|
+
['3-implement', 'implement', 'recap.md'],
|
|
192
|
+
['2-plan', 'plan', 'spec.md'],
|
|
193
|
+
['1-design', 'uiux', 'design-system.md'],
|
|
194
|
+
['0-proposal', 'proposal', 'proposal.md'],
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
const gatesOf = (feature) => feature?.approvals || feature?.approvalGates;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Gate-implied phase from an ALREADY-LOADED feature object — no disk access.
|
|
201
|
+
*
|
|
202
|
+
* Split out of `gateImpliedPhase` so callers holding a feature.json that did
|
|
203
|
+
* not come from the working tree (e.g. `git show morph/{f}:…`) can reuse the
|
|
204
|
+
* rule instead of copying it. The path-based wrapper below is unchanged.
|
|
205
|
+
*
|
|
206
|
+
* @param {Object|null} feature
|
|
207
|
+
* @returns {string} phase name
|
|
208
|
+
*/
|
|
209
|
+
export function gateImpliedPhaseFrom(feature) {
|
|
210
|
+
const gates = gatesOf(feature);
|
|
211
|
+
const ok = (g) => gates?.[g]?.approved === true;
|
|
212
|
+
if (ok('review')) return 'review';
|
|
213
|
+
if (ok('plan')) return 'implement';
|
|
214
|
+
if (ok('uiux')) return 'plan';
|
|
215
|
+
return 'proposal';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Phase from an already-loaded feature plus an artifact predicate — no disk
|
|
220
|
+
* access of its own, so the artifact universe can be a git tree.
|
|
221
|
+
*
|
|
222
|
+
* @param {Object|null} feature
|
|
223
|
+
* @param {(relPath: string) => boolean} hasArtifact - receives POSIX-style
|
|
224
|
+
* paths relative to the feature dir, e.g. `2-plan/spec.md`
|
|
225
|
+
* @returns {string} phase name
|
|
226
|
+
*/
|
|
227
|
+
export function derivePhaseFrom(feature, hasArtifact) {
|
|
228
|
+
let artifactPhase = 'proposal';
|
|
229
|
+
for (const [folder, phase, canonical] of PHASE_ARTIFACTS) {
|
|
230
|
+
if (hasArtifact(`${folder}/${canonical}`)) { artifactPhase = phase; break; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Gate-aware: an approved gate advances the phase even before the next
|
|
234
|
+
// phase's first canonical artifact exists. This breaks the chicken-and-egg
|
|
235
|
+
// where that first artifact could never be written because the phase never
|
|
236
|
+
// advanced to permit it. Take whichever of (artifact, gate-implied) is later.
|
|
237
|
+
const implied = gateImpliedPhaseFrom(feature);
|
|
238
|
+
return PHASE_INDEX[implied] > PHASE_INDEX[artifactPhase] ? implied : artifactPhase;
|
|
239
|
+
}
|
|
240
|
+
|
|
184
241
|
/**
|
|
185
242
|
* The phase implied purely by approved gates, independent of which artifacts
|
|
186
243
|
* exist on disk. An approved gate authorizes the NEXT phase, so:
|
|
@@ -192,32 +249,15 @@ export const PHASE_INDEX = { proposal: 0, uiux: 1, plan: 2, implement: 3, review
|
|
|
192
249
|
* @returns {string} phase name
|
|
193
250
|
*/
|
|
194
251
|
export function gateImpliedPhase(featureName, projectPath) {
|
|
195
|
-
|
|
196
|
-
if (isGateApproved(featureName, 'plan', projectPath)) return 'implement';
|
|
197
|
-
if (isGateApproved(featureName, 'uiux', projectPath)) return 'plan';
|
|
198
|
-
return 'proposal';
|
|
252
|
+
return gateImpliedPhaseFrom(getFeature(featureName, projectPath));
|
|
199
253
|
}
|
|
200
254
|
|
|
201
255
|
export function derivePhaseForFeature(featureName, projectPath) {
|
|
202
256
|
const basePath = join(projectPath || process.cwd(), '.morph/features', featureName);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
['1-design', 'uiux', 'design-system.md'],
|
|
208
|
-
['0-proposal', 'proposal', 'proposal.md'],
|
|
209
|
-
];
|
|
210
|
-
let artifactPhase = 'proposal';
|
|
211
|
-
for (const [folder, phase, canonical] of phaseMap) {
|
|
212
|
-
if (existsSync(join(basePath, folder, canonical))) { artifactPhase = phase; break; }
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Gate-aware: an approved gate advances the phase even before the next
|
|
216
|
-
// phase's first canonical artifact exists. This breaks the chicken-and-egg
|
|
217
|
-
// where that first artifact could never be written because the phase never
|
|
218
|
-
// advanced to permit it. Take whichever of (artifact, gate-implied) is later.
|
|
219
|
-
const implied = gateImpliedPhase(featureName, projectPath);
|
|
220
|
-
return PHASE_INDEX[implied] > PHASE_INDEX[artifactPhase] ? implied : artifactPhase;
|
|
257
|
+
return derivePhaseFrom(
|
|
258
|
+
getFeature(featureName, projectPath),
|
|
259
|
+
(rel) => existsSync(join(basePath, rel)),
|
|
260
|
+
);
|
|
221
261
|
}
|
|
222
262
|
|
|
223
263
|
/**
|
|
@@ -253,10 +293,9 @@ export function isGateApproved(featureName, gate, projectPath) {
|
|
|
253
293
|
* @param {string} [projectPath]
|
|
254
294
|
* @returns {string[]} List of gate names that are not yet approved
|
|
255
295
|
*/
|
|
256
|
-
export function
|
|
257
|
-
const feature = getFeature(featureName, projectPath);
|
|
296
|
+
export function pendingGatesFrom(feature) {
|
|
258
297
|
// Support both field names: 'approvals' (v5 state) and 'approvalGates' (legacy)
|
|
259
|
-
const gates = feature
|
|
298
|
+
const gates = gatesOf(feature);
|
|
260
299
|
if (!gates) return [];
|
|
261
300
|
|
|
262
301
|
return Object.entries(gates)
|
|
@@ -264,6 +303,10 @@ export function getPendingGates(featureName, projectPath) {
|
|
|
264
303
|
.map(([name]) => name);
|
|
265
304
|
}
|
|
266
305
|
|
|
306
|
+
export function getPendingGates(featureName, projectPath) {
|
|
307
|
+
return pendingGatesFrom(getFeature(featureName, projectPath));
|
|
308
|
+
}
|
|
309
|
+
|
|
267
310
|
/**
|
|
268
311
|
* Get feature outputs status.
|
|
269
312
|
* @param {string} featureName
|
|
@@ -33,11 +33,18 @@ TaskUpdate(taskId, subject="Round 3 — score 82/100")
|
|
|
33
33
|
|
|
34
34
|
## Persisting Progress to `tasks.json`
|
|
35
35
|
|
|
36
|
+
> **Dois sistemas, dois vocabulários.** Tudo acima desta linha é sobre as *session
|
|
37
|
+
> tasks* nativas do Claude Code (`TaskCreate`/`TaskUpdate`), cujo status terminal é
|
|
38
|
+
> `completed`. Tudo abaixo é sobre o `tasks.json` do morph-spec, governado por
|
|
39
|
+
> `.morph/framework/schemas/tasks.schema.json`, cujo status terminal é `done` —
|
|
40
|
+
> **`completed` não existe nesse enum** e o hook `protect-spec-files` rejeita a
|
|
41
|
+
> gravação. Não misture os dois.
|
|
42
|
+
|
|
36
43
|
Native session tasks (`TaskCreate`/`TaskUpdate`) track your live work. To persist
|
|
37
44
|
task progress for the feature, write `.morph/features/{feature}/2-plan/tasks.json`,
|
|
38
45
|
touching ONLY the mutable fields:
|
|
39
46
|
|
|
40
|
-
- `status` — `pending` → `in_progress` → `
|
|
47
|
+
- `status` — `pending` → `in_progress` → `done` (também: `skipped`, `blocked`)
|
|
41
48
|
- `outputs` — the files the task produced
|
|
42
49
|
- `notes` — free-text record (deviations, context, resolved blockers)
|
|
43
50
|
|
|
@@ -53,17 +60,16 @@ output.
|
|
|
53
60
|
## Deviation Tracking
|
|
54
61
|
|
|
55
62
|
When completing a task differently than originally planned, record the reason in the
|
|
56
|
-
task's `notes` field.
|
|
57
|
-
|
|
63
|
+
task's `notes` field. `notes` is **uma única string** (o schema declara
|
|
64
|
+
`"notes": { "type": "string" }`) — para registrar mais de um desvio, separe-os com
|
|
65
|
+
`;` ou `\n` dentro da mesma string. Um array é **rejeitado** pelo validador, com a
|
|
66
|
+
mensagem `'notes' must be a string`.
|
|
58
67
|
|
|
59
68
|
```json
|
|
60
69
|
{
|
|
61
70
|
"id": "T3",
|
|
62
|
-
"status": "
|
|
71
|
+
"status": "done",
|
|
63
72
|
"outputs": ["Features/User/Update/Handler.cs"],
|
|
64
|
-
"notes":
|
|
65
|
-
"deviation: split validation into a separate validator for testability",
|
|
66
|
-
"blocker-resolved: EF migration conflict fixed by regenerating the snapshot"
|
|
67
|
-
]
|
|
73
|
+
"notes": "deviation: split validation into a separate validator for testability; blocker-resolved: EF migration conflict fixed by regenerating the snapshot"
|
|
68
74
|
}
|
|
69
75
|
```
|
|
@@ -72,6 +72,39 @@ All outputs go in `.morph/features/{feature}/`:
|
|
|
72
72
|
|
|
73
73
|
---
|
|
74
74
|
|
|
75
|
+
## Formato obrigatório do `3-implement/recap.md`
|
|
76
|
+
|
|
77
|
+
> Esta exigência é **cobrada por hook** (`validate-completion`, no Stop). Ela está aqui, na regra
|
|
78
|
+
> sempre-ativa, e não só na skill `morph-implement`: descobrir o formato quando o hook dispara
|
|
79
|
+
> significa reescrever o recap depois de a fase já estar dada por encerrada.
|
|
80
|
+
|
|
81
|
+
**Para CADA task com `status: "done"` em `tasks.json`**, o recap precisa de um bloco próprio:
|
|
82
|
+
|
|
83
|
+
1. um heading `## T{N}` (o hook casa `^## T\d+` — uma linha de tabela `| T1 | … | done |` conta
|
|
84
|
+
como menção, **não** como bloco);
|
|
85
|
+
2. uma linha `**Standard:** {standard-id}` **dentro de 12 linhas** do heading. Sem standard que
|
|
86
|
+
cubra o caso, registre a decisão em `decisions.md` e cite
|
|
87
|
+
`**Standard:** ad-hoc (see decisions.md ADR-{N})`.
|
|
88
|
+
|
|
89
|
+
```markdown
|
|
90
|
+
## T{N} — {title}
|
|
91
|
+
|
|
92
|
+
**Standard:** {standard-id} (`.morph/framework/standards/{path}.md`)
|
|
93
|
+
**Persona:** {mecanismo real usado}
|
|
94
|
+
**Files:**
|
|
95
|
+
- `{path}`
|
|
96
|
+
|
|
97
|
+
{2-3 frases sobre o que foi feito}
|
|
98
|
+
|
|
99
|
+
**Issues encontrados:** {breve descrição ou "nenhum"}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
O `validate-completion` também acusa **drift**: uma task que aparece como concluída no recap mas
|
|
103
|
+
ainda está `pending`/`in_progress` no `tasks.json`. Template completo e regras de exceção em
|
|
104
|
+
`morph-implement` §6.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
75
108
|
## Checkpoints
|
|
76
109
|
|
|
77
110
|
Run a checkpoint every 3 completed tasks:
|
|
@@ -158,7 +191,7 @@ During implementation, these skills MUST be invoked at the specified triggers:
|
|
|
158
191
|
| Trigger | Skill | When |
|
|
159
192
|
|---------|-------|------|
|
|
160
193
|
| Before each task | `superpowers:test-driven-development` | Invoke BEFORE writing any code for a task |
|
|
161
|
-
| Before marking a task complete | `morph-verification-before-completion` | Invoke BEFORE setting a task's `status` to `
|
|
194
|
+
| Before marking a task complete | `morph-verification-before-completion` | Invoke BEFORE setting a task's `status` to `done` in `tasks.json` |
|
|
162
195
|
| On bug/unexpected | `superpowers:systematic-debugging` | Invoke IF a bug or unexpected behavior is encountered |
|
|
163
196
|
| After all tasks | `morph-post-implementation` | Invoke AFTER all tasks completed |
|
|
164
197
|
| Before PR | `morph-checklist` | Invoke BEFORE creating pull request |
|
|
@@ -222,7 +222,8 @@
|
|
|
222
222
|
"properties": {
|
|
223
223
|
"approved": { "type": "boolean" },
|
|
224
224
|
"timestamp": { "type": ["string", "null"], "description": "ISO timestamp of the approval/revocation, null while never evaluated." },
|
|
225
|
-
"approvedBy": { "type": ["string", "null"], "description": "\"auto\" (approve/advance --mode auto), \"manual\" (human in the loop), \"policy:{workType}\" (gate stamped at birth by `morph-spec create` per the work type's skippedGates — see src/core/work-types.js; the gate-guard hook only special-cases \"auto\", so policy-stamped gates stay silent to it), a --approver name, or null while never evaluated." }
|
|
225
|
+
"approvedBy": { "type": ["string", "null"], "description": "\"auto\" (approve/advance --mode auto), \"manual\" (human in the loop), \"policy:{workType}\" (gate stamped at birth by `morph-spec create` per the work type's skippedGates — see src/core/work-types.js; the gate-guard hook only special-cases \"auto\", so policy-stamped gates stay silent to it), a --approver name, or null while never evaluated." },
|
|
226
|
+
"selfAssessed": { "type": "boolean", "description": "Gate `review` SOMENTE. `true` quando o Gate 3 foi aprovado sem `4-review/evaluator-report.md`, via `morph-spec approve <f> review --self-assessed`. O score composto deveria vir do avaliador independente; esta flag registra a degradação em vez de deixá-la silenciosa, e aparece no `morph-spec list`." }
|
|
226
227
|
},
|
|
227
228
|
"additionalProperties": true
|
|
228
229
|
}
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# create-umbraco-lp
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# migrate-lp-to-umbraco
|
|
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber um novo request de feature, antes de morph-plan
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-brainstorming
|
|
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
|
|
6
6
|
user-invokable: false
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-eval
|
|
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -4,7 +4,7 @@ description: Guia a produção dos artefatos de planejamento de uma feature (use
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-proposal
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -74,6 +74,12 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
|
|
|
74
74
|
- **Leia primeiro** `.morph/features/{feature}/4-review/evaluator-report.md` — é o valor de retorno autoritativo, não a mensagem de texto do dispatch (que é cortesia, útil só quando chega inteira). Os scores por dimensão e o composto do review-report vêm **desse arquivo**, não da sua auto-avaliação. Se o arquivo vier ausente, oco, ou sem os scores por dimensão, registre a falha de protocolo na seção de método do review-report e re-despache.
|
|
75
75
|
|
|
76
76
|
5. Produza `review-report.md` em `.morph/features/{feature}/4-review/` com:
|
|
77
|
+
- **`**Reviewed-SHA:** {git rev-parse HEAD}`** — o commit que você revisou, **obrigatório**.
|
|
78
|
+
Um review revisa uma ÁRVORE, e a árvore anda: sem esse carimbo, um relatório escrito antes
|
|
79
|
+
da última task fica no repo afirmando números e cobertura que já não valem. O
|
|
80
|
+
`morph-spec approve {feature} review` compara o carimbo com o HEAD e **recusa** se divergirem
|
|
81
|
+
(aconteceu de verdade: relatório de `b30ac6e` sobrevivendo à T3, que inverteu o default de
|
|
82
|
+
uma regra de negócio, e ainda dizendo `976/976` quando eram 982).
|
|
77
83
|
- Scores por dimensão (arquitetura, contratos, qualidade, cobertura)
|
|
78
84
|
- Score composto final
|
|
79
85
|
- Lista de itens bloqueantes (se houver) — com descrição e critério de resolução
|
|
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-stack-scan
|
|
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.8.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `morph-spec claim [feature] [--release] [--list]` — the manual half of
|
|
3
|
+
* advisory feature ownership (B1).
|
|
4
|
+
*
|
|
5
|
+
* Claims are acquired and heartbeaten AUTOMATICALLY (SessionStart hook +
|
|
6
|
+
* telemetry hook), so the operator never has to remember anything. This command
|
|
7
|
+
* exists for the case automation cannot cover: a session that died without
|
|
8
|
+
* releasing, leaving a claim that keeps warning everyone until its TTL expires.
|
|
9
|
+
*
|
|
10
|
+
* Read-only by default (`--list`); the only mutation is `--release`.
|
|
11
|
+
* Always exits 0 except on a genuine misuse — the claim system is advisory and
|
|
12
|
+
* must never become a source of hard failures.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import { resolve } from 'path';
|
|
18
|
+
import { isGitRepo, resolvePrimaryRoot } from './worktree.js';
|
|
19
|
+
import { listClaims, releaseClaim, claimAge } from '../../../framework/hooks/shared/claim-registry.js';
|
|
20
|
+
|
|
21
|
+
/** Resolve the repo's shared git common dir — where claims live. */
|
|
22
|
+
function gitCommonDirOf(cwd) {
|
|
23
|
+
const root = resolvePrimaryRoot(cwd);
|
|
24
|
+
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
25
|
+
return common ? resolve(root, common) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string|undefined} feature
|
|
30
|
+
* @param {{ release?: boolean, list?: boolean, json?: boolean, cwd?: string }} [options]
|
|
31
|
+
*/
|
|
32
|
+
export function claimCommand(feature, options = {}) {
|
|
33
|
+
const cwd = options.cwd || process.cwd();
|
|
34
|
+
if (!isGitRepo(cwd)) {
|
|
35
|
+
console.log(chalk.gray('Fora de um repositório git — não há registro de claims.'));
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let gitCommonDir = null;
|
|
40
|
+
try {
|
|
41
|
+
gitCommonDir = gitCommonDirOf(cwd);
|
|
42
|
+
} catch { gitCommonDir = null; }
|
|
43
|
+
if (!gitCommonDir) {
|
|
44
|
+
console.log(chalk.gray('Não foi possível resolver o git common dir — nenhum claim a mostrar.'));
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (options.release) {
|
|
49
|
+
if (!feature) {
|
|
50
|
+
console.error(chalk.red('\n❌ --release exige o nome da feature: morph-spec claim <feature> --release\n'));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const released = releaseClaim(feature, { gitCommonDir });
|
|
54
|
+
if (options.json) {
|
|
55
|
+
console.log(JSON.stringify({ feature, released }));
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
console.log(released
|
|
59
|
+
? chalk.green(`\n✅ Claim de "${feature}" liberado.\n`)
|
|
60
|
+
: chalk.yellow(`\n⚠️ Nenhum claim registrado para "${feature}" — nada a liberar.\n`));
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Default and --list are the same read: the whole board, or one feature.
|
|
65
|
+
const all = listClaims(gitCommonDir);
|
|
66
|
+
const rows = feature ? all.filter((c) => c.feature === feature) : all;
|
|
67
|
+
|
|
68
|
+
if (options.json) {
|
|
69
|
+
console.log(JSON.stringify(rows));
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!rows.length) {
|
|
74
|
+
console.log(chalk.gray(feature
|
|
75
|
+
? `Nenhum claim para "${feature}".`
|
|
76
|
+
: 'Nenhuma feature reclamada — nenhuma sessão registrada.'));
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const col = (s, w) => String(s ?? '-').padEnd(w);
|
|
81
|
+
console.log(chalk.white.bold(col('FEATURE', 24) + col('ESTADO', 10) + col('VISTO HÁ', 12) + col('PID', 9) + 'ONDE'));
|
|
82
|
+
for (const c of rows) {
|
|
83
|
+
const state = c.live ? chalk.yellow('viva') : chalk.gray('morta');
|
|
84
|
+
console.log(
|
|
85
|
+
col(c.feature, 24) + state + ' '.repeat(Math.max(1, 10 - (c.live ? 4 : 5)))
|
|
86
|
+
+ col(claimAge(c), 12) + col(c.pid, 9) + (c.path || '-'),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
console.log(chalk.gray('\nClaims são AVISOS, nunca bloqueios. Claim órfão: `morph-spec claim <f> --release`'));
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|