@polymorphism-tech/morph-spec 7.3.0 → 7.5.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 +1 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +22 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +10 -5
- package/framework/MORPH.md +31 -14
- package/framework/agents.json +132 -132
- package/framework/commands/create-umbraco-lp.md +35 -0
- package/framework/commands/migrate-lp-to-umbraco.md +33 -0
- package/framework/commands/morph-archive.md +17 -27
- package/framework/evals/rubrics/README.md +9 -8
- package/framework/hooks/README.md +2 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
- package/framework/hooks/claude-code/core/gate-guard.js +160 -0
- package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
- package/framework/hooks/claude-code/core/state-sync.js +16 -36
- package/framework/hooks/claude-code/core/validate-completion.js +105 -0
- package/framework/hooks/shared/state-reader.js +302 -221
- package/framework/hooks/shared/state-writer.js +124 -0
- package/framework/rules/morph-task-tracking.md +69 -49
- package/framework/rules/morph-workflow.md +192 -191
- package/framework/skills/README.md +1 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +252 -0
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +452 -0
- package/framework/skills/core/morph-apply/SKILL.md +69 -27
- package/framework/skills/core/morph-brainstorming/SKILL.md +6 -2
- package/framework/skills/core/morph-eval/SKILL.md +4 -5
- package/framework/skills/core/morph-implement/SKILL.md +51 -22
- package/framework/skills/core/morph-plan/SKILL.md +10 -4
- package/framework/skills/core/morph-proposal/SKILL.md +8 -7
- package/framework/skills/core/morph-review/SKILL.md +15 -8
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/ai-agents/setup.md +258 -244
- package/framework/templates/REGISTRY.json +1 -3
- package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
- package/framework/templates/context/CONTEXT.md +181 -180
- package/framework/templates/docs/onboarding.md +122 -118
- package/framework/templates/docs/proposal.md +10 -0
- package/framework/templates/docs/spec.md +228 -228
- package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
- package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
- package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
- package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
- package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
- package/package.json +1 -1
- package/src/commands/project/init.js +517 -515
- package/src/commands/project/update.js +415 -368
- package/src/commands/project/worktree.js +212 -210
- package/src/commands/state/advance.js +82 -0
- package/src/commands/state/approve.js +251 -213
- package/src/commands/state/archive.js +111 -0
- package/src/commands/validation/validate-feature.js +244 -79
- package/src/core/state/state-manager.js +256 -74
- package/src/dashboard/scanner/state-scanner.js +35 -8
- package/src/lib/generators/context-generator.js +12 -8
- package/src/lib/risk-detector.js +2 -2
- package/src/utils/file-copier.js +7 -0
- package/src/utils/hooks-installer.js +527 -516
|
@@ -1,221 +1,302 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared read-only state accessor for Claude Code hooks.
|
|
3
|
-
*
|
|
4
|
-
* Wraps state.json reads with fail-open semantics:
|
|
5
|
-
* - Returns null instead of throwing on missing/corrupt state
|
|
6
|
-
* - Never writes to state.json (read-only)
|
|
7
|
-
* - Uses synchronous reads for <100ms performance
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { readFileSync, existsSync } from 'fs';
|
|
11
|
-
import { join } from 'path';
|
|
12
|
-
|
|
13
|
-
const STATE_FILE = '.morph/state.json';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared read-only state accessor for Claude Code hooks.
|
|
3
|
+
*
|
|
4
|
+
* Wraps state.json reads with fail-open semantics:
|
|
5
|
+
* - Returns null instead of throwing on missing/corrupt state
|
|
6
|
+
* - Never writes to state.json (read-only)
|
|
7
|
+
* - Uses synchronous reads for <100ms performance
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
|
|
13
|
+
const STATE_FILE = '.morph/state.json';
|
|
14
|
+
const FEATURES_DIR = '.morph/features';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load state from the project root, hydrating each feature from its
|
|
18
|
+
* authoritative per-feature `feature.json`.
|
|
19
|
+
*
|
|
20
|
+
* The root `.morph/state.json` is a thin, gitignored index (status + updatedAt
|
|
21
|
+
* per feature); the heavy feature data lives in `.morph/features/{name}/feature.json`
|
|
22
|
+
* (committed). This reader merges them: it reads the thin index for project /
|
|
23
|
+
* metadata and overlays each feature.json so callers see the full feature object.
|
|
24
|
+
* A single corrupt feature.json is skipped, not fatal.
|
|
25
|
+
*
|
|
26
|
+
* Fail-open: returns null on any unexpected error or when there is no state at
|
|
27
|
+
* all (no index and no feature files).
|
|
28
|
+
* @param {string} [projectPath] - Project root (defaults to cwd)
|
|
29
|
+
* @returns {Object|null} State object or null
|
|
30
|
+
*/
|
|
31
|
+
export function loadState(projectPath) {
|
|
32
|
+
try {
|
|
33
|
+
const base = projectPath || process.cwd();
|
|
34
|
+
const statePath = join(base, STATE_FILE);
|
|
35
|
+
|
|
36
|
+
let state = null;
|
|
37
|
+
if (existsSync(statePath)) {
|
|
38
|
+
state = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const featuresDir = join(base, FEATURES_DIR);
|
|
42
|
+
if (existsSync(featuresDir)) {
|
|
43
|
+
let names = [];
|
|
44
|
+
try {
|
|
45
|
+
names = readdirSync(featuresDir, { withFileTypes: true })
|
|
46
|
+
.filter((e) => e.isDirectory())
|
|
47
|
+
.map((e) => e.name);
|
|
48
|
+
} catch {
|
|
49
|
+
names = [];
|
|
50
|
+
}
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const fjPath = join(featuresDir, name, 'feature.json');
|
|
53
|
+
if (!existsSync(fjPath)) continue;
|
|
54
|
+
try {
|
|
55
|
+
const fj = JSON.parse(readFileSync(fjPath, 'utf-8'));
|
|
56
|
+
state = state || { features: {} };
|
|
57
|
+
state.features = state.features || {};
|
|
58
|
+
state.features[name] = fj; // feature.json is authoritative
|
|
59
|
+
} catch {
|
|
60
|
+
// skip a single corrupt feature.json — don't blind every hook
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return state;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if state.json exists.
|
|
73
|
+
* @param {string} [projectPath]
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function stateExists(projectPath) {
|
|
77
|
+
return existsSync(join(projectPath || process.cwd(), STATE_FILE));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get the active feature (most recently updated in_progress feature).
|
|
82
|
+
* @param {string} [projectPath]
|
|
83
|
+
* @returns {{ name: string, feature: Object }|null}
|
|
84
|
+
*/
|
|
85
|
+
export function getActiveFeature(projectPath) {
|
|
86
|
+
const state = loadState(projectPath);
|
|
87
|
+
if (!state?.features) return null;
|
|
88
|
+
|
|
89
|
+
let active = null;
|
|
90
|
+
let latestUpdate = '';
|
|
91
|
+
|
|
92
|
+
for (const [name, feature] of Object.entries(state.features)) {
|
|
93
|
+
if (feature.status === 'in_progress' || feature.status === 'draft') {
|
|
94
|
+
const updated = feature.updatedAt || feature.createdAt || '';
|
|
95
|
+
if (updated >= latestUpdate) {
|
|
96
|
+
latestUpdate = updated;
|
|
97
|
+
active = { name, feature };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return active;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get the most recently updated feature (any status).
|
|
107
|
+
* Useful as a fallback when getActiveFeature returns null (all features done).
|
|
108
|
+
* @param {string} [projectPath]
|
|
109
|
+
* @returns {{ name: string, feature: Object }|null}
|
|
110
|
+
*/
|
|
111
|
+
export function getMostRecentFeature(projectPath) {
|
|
112
|
+
const state = loadState(projectPath);
|
|
113
|
+
if (!state?.features) return null;
|
|
114
|
+
|
|
115
|
+
let result = null;
|
|
116
|
+
let latestUpdate = '';
|
|
117
|
+
|
|
118
|
+
for (const [name, feature] of Object.entries(state.features)) {
|
|
119
|
+
const updated = feature.updatedAt || feature.createdAt || '';
|
|
120
|
+
if (updated >= latestUpdate) {
|
|
121
|
+
latestUpdate = updated;
|
|
122
|
+
result = { name, feature };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get a specific feature by name.
|
|
131
|
+
* @param {string} featureName
|
|
132
|
+
* @param {string} [projectPath]
|
|
133
|
+
* @returns {Object|null}
|
|
134
|
+
*/
|
|
135
|
+
export function getFeature(featureName, projectPath) {
|
|
136
|
+
const state = loadState(projectPath);
|
|
137
|
+
return state?.features?.[featureName] || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Derive phase from filesystem (phase deleted from state).
|
|
142
|
+
*
|
|
143
|
+
* Walks the phase map in descending order and returns the first phase whose
|
|
144
|
+
* CANONICAL ARTIFACT exists. An empty folder is not enough — this avoids the
|
|
145
|
+
* chicken-and-egg problem where `mkdir 2-plan/` (or any pre-created sibling)
|
|
146
|
+
* advances the phase before plan content is written, then
|
|
147
|
+
* `enforce-phase-writes` blocks writes back to `0-proposal/`.
|
|
148
|
+
*
|
|
149
|
+
* Canonical artifacts (one per phase) match MORPH.md §3:
|
|
150
|
+
* - 4-review → review-report.md
|
|
151
|
+
* - 3-implement → recap.md
|
|
152
|
+
* - 2-plan → spec.md (the first plan-phase output produced)
|
|
153
|
+
* - 1-design → design-system.md
|
|
154
|
+
* - 0-proposal → proposal.md
|
|
155
|
+
*
|
|
156
|
+
* If no canonical file is found, returns `'proposal'` (the starting phase).
|
|
157
|
+
*
|
|
158
|
+
* @param {string} featureName
|
|
159
|
+
* @param {string} [projectPath]
|
|
160
|
+
* @returns {string} Phase name
|
|
161
|
+
*/
|
|
162
|
+
export const PHASE_INDEX = { proposal: 0, uiux: 1, plan: 2, implement: 3, review: 4 };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The phase implied purely by approved gates, independent of which artifacts
|
|
166
|
+
* exist on disk. An approved gate authorizes the NEXT phase, so:
|
|
167
|
+
* review approved → review (terminal)
|
|
168
|
+
* plan approved → implement
|
|
169
|
+
* uiux approved → plan
|
|
170
|
+
* otherwise → proposal (proposal-approval's fork between uiux/plan is
|
|
171
|
+
* resolved by enforce-phase-writes, not here)
|
|
172
|
+
* @returns {string} phase name
|
|
173
|
+
*/
|
|
174
|
+
export function gateImpliedPhase(featureName, projectPath) {
|
|
175
|
+
if (isGateApproved(featureName, 'review', projectPath)) return 'review';
|
|
176
|
+
if (isGateApproved(featureName, 'plan', projectPath)) return 'implement';
|
|
177
|
+
if (isGateApproved(featureName, 'uiux', projectPath)) return 'plan';
|
|
178
|
+
return 'proposal';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function derivePhaseForFeature(featureName, projectPath) {
|
|
182
|
+
const basePath = join(projectPath || process.cwd(), '.morph/features', featureName);
|
|
183
|
+
const phaseMap = [
|
|
184
|
+
['4-review', 'review', 'review-report.md'],
|
|
185
|
+
['3-implement', 'implement', 'recap.md'],
|
|
186
|
+
['2-plan', 'plan', 'spec.md'],
|
|
187
|
+
['1-design', 'uiux', 'design-system.md'],
|
|
188
|
+
['0-proposal', 'proposal', 'proposal.md'],
|
|
189
|
+
];
|
|
190
|
+
let artifactPhase = 'proposal';
|
|
191
|
+
for (const [folder, phase, canonical] of phaseMap) {
|
|
192
|
+
if (existsSync(join(basePath, folder, canonical))) { artifactPhase = phase; break; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Gate-aware: an approved gate advances the phase even before the next
|
|
196
|
+
// phase's first canonical artifact exists. This breaks the chicken-and-egg
|
|
197
|
+
// where that first artifact could never be written because the phase never
|
|
198
|
+
// advanced to permit it. Take whichever of (artifact, gate-implied) is later.
|
|
199
|
+
const implied = gateImpliedPhase(featureName, projectPath);
|
|
200
|
+
return PHASE_INDEX[implied] > PHASE_INDEX[artifactPhase] ? implied : artifactPhase;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the current phase of a feature.
|
|
205
|
+
* Falls back to filesystem derivation when phase is not in state.
|
|
206
|
+
* @param {string} featureName
|
|
207
|
+
* @param {string} [projectPath]
|
|
208
|
+
* @returns {string|null}
|
|
209
|
+
*/
|
|
210
|
+
export function getFeaturePhase(featureName, projectPath) {
|
|
211
|
+
const feature = getFeature(featureName, projectPath);
|
|
212
|
+
if (!feature) return null;
|
|
213
|
+
return feature.phase || derivePhaseForFeature(featureName, projectPath);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get approval status for a gate.
|
|
218
|
+
* @param {string} featureName
|
|
219
|
+
* @param {string} gate - 'proposal'|'uiux'|'plan'|'review'
|
|
220
|
+
* @param {string} [projectPath]
|
|
221
|
+
* @returns {boolean}
|
|
222
|
+
*/
|
|
223
|
+
export function isGateApproved(featureName, gate, projectPath) {
|
|
224
|
+
const feature = getFeature(featureName, projectPath);
|
|
225
|
+
// Support both field names: 'approvals' (v5 state) and 'approvalGates' (legacy)
|
|
226
|
+
const gates = feature?.approvals || feature?.approvalGates;
|
|
227
|
+
return gates?.[gate]?.approved === true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get all pending approval gates for a feature.
|
|
232
|
+
* @param {string} featureName
|
|
233
|
+
* @param {string} [projectPath]
|
|
234
|
+
* @returns {string[]} List of gate names that are not yet approved
|
|
235
|
+
*/
|
|
236
|
+
export function getPendingGates(featureName, projectPath) {
|
|
237
|
+
const feature = getFeature(featureName, projectPath);
|
|
238
|
+
// Support both field names: 'approvals' (v5 state) and 'approvalGates' (legacy)
|
|
239
|
+
const gates = feature?.approvals || feature?.approvalGates;
|
|
240
|
+
if (!gates) return [];
|
|
241
|
+
|
|
242
|
+
return Object.entries(gates)
|
|
243
|
+
.filter(([, gate]) => !gate.approved)
|
|
244
|
+
.map(([name]) => name);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get feature outputs status.
|
|
249
|
+
* @param {string} featureName
|
|
250
|
+
* @param {string} [projectPath]
|
|
251
|
+
* @returns {Object|null} outputs map or null
|
|
252
|
+
*/
|
|
253
|
+
export function getOutputs(featureName, projectPath) {
|
|
254
|
+
const feature = getFeature(featureName, projectPath);
|
|
255
|
+
return feature?.outputs || null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get uncompleted required outputs for current phase.
|
|
260
|
+
* @param {string} featureName
|
|
261
|
+
* @param {string} [projectPath]
|
|
262
|
+
* @returns {Array<{type: string, path: string}>}
|
|
263
|
+
*/
|
|
264
|
+
/**
|
|
265
|
+
* Get task statistics directly from taskList (preferred over feature.tasks counters).
|
|
266
|
+
* Excludes expanded tasks from counts, matching calculateProgress() behavior.
|
|
267
|
+
*
|
|
268
|
+
* @param {Object} feature - Feature state object
|
|
269
|
+
* @returns {{ total: number, completed: number, inProgress: number, pending: number }}
|
|
270
|
+
*/
|
|
271
|
+
export function getTaskStats(feature) {
|
|
272
|
+
const list = feature?.taskList || [];
|
|
273
|
+
if (list.length > 0) {
|
|
274
|
+
const active = list.filter(t => t.status !== 'expanded');
|
|
275
|
+
return {
|
|
276
|
+
total: active.length,
|
|
277
|
+
completed: active.filter(t => t.status === 'completed').length,
|
|
278
|
+
inProgress: active.filter(t => t.status === 'in_progress').length,
|
|
279
|
+
pending: active.filter(t => t.status === 'pending').length,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// Fallback: read from legacy counter object
|
|
283
|
+
const tasks = feature?.tasks;
|
|
284
|
+
if (tasks && typeof tasks === 'object' && !Array.isArray(tasks)) {
|
|
285
|
+
return {
|
|
286
|
+
total: tasks.total || 0,
|
|
287
|
+
completed: tasks.completed || 0,
|
|
288
|
+
inProgress: tasks.inProgress || 0,
|
|
289
|
+
pending: tasks.pending || 0,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { total: 0, completed: 0, inProgress: 0, pending: 0 };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function getMissingOutputs(featureName, projectPath) {
|
|
296
|
+
const feature = getFeature(featureName, projectPath);
|
|
297
|
+
if (!feature?.outputs) return [];
|
|
298
|
+
|
|
299
|
+
return Object.entries(feature.outputs)
|
|
300
|
+
.filter(([, output]) => !output.created)
|
|
301
|
+
.map(([type, output]) => ({ type, path: output.path }));
|
|
302
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared per-feature state writer for Claude Code hooks.
|
|
3
|
+
*
|
|
4
|
+
* Hooks run inside the user's project and cannot import the CLI's
|
|
5
|
+
* src/core/state-manager.js. This module is the hook-side mirror of the core
|
|
6
|
+
* writers: it persists a feature to its authoritative `feature.json` (committed)
|
|
7
|
+
* and refreshes the thin, gitignored root index (`.morph/state.json`).
|
|
8
|
+
*
|
|
9
|
+
* Fail-open by contract: every function swallows errors and returns a boolean —
|
|
10
|
+
* a hook must never break the agent over a state write.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
14
|
+
import { join, dirname } from 'path';
|
|
15
|
+
|
|
16
|
+
const STATE_FILE = '.morph/state.json';
|
|
17
|
+
const STATE_VERSION = '5.0.0';
|
|
18
|
+
|
|
19
|
+
/** Absolute path to a feature's authoritative feature.json. */
|
|
20
|
+
export function featureJsonPath(featureName, projectPath) {
|
|
21
|
+
return join(projectPath || process.cwd(), '.morph', 'features', featureName, 'feature.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read a feature's feature.json, or null if missing/corrupt. */
|
|
25
|
+
export function readFeatureFile(featureName, projectPath) {
|
|
26
|
+
const p = featureJsonPath(featureName, projectPath);
|
|
27
|
+
if (!existsSync(p)) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Atomic JSON write: per-pid temp then rename, with an EPERM retry for Windows. */
|
|
36
|
+
function atomicWriteJson(targetPath, data) {
|
|
37
|
+
const dir = dirname(targetPath);
|
|
38
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
39
|
+
const tmp = `${targetPath}.tmp.${process.pid}`;
|
|
40
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
41
|
+
for (let attempt = 0; attempt <= 5; attempt++) {
|
|
42
|
+
try {
|
|
43
|
+
renameSync(tmp, targetPath);
|
|
44
|
+
return;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 'EPERM' && attempt < 5) {
|
|
47
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1));
|
|
48
|
+
} else {
|
|
49
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Persist a feature to its authoritative feature.json and refresh the thin root
|
|
58
|
+
* index entry (status + updatedAt) + aggregate metadata. Returns true on
|
|
59
|
+
* success, false fail-open.
|
|
60
|
+
*/
|
|
61
|
+
export function persistFeature(featureName, feature, projectPath) {
|
|
62
|
+
try {
|
|
63
|
+
atomicWriteJson(featureJsonPath(featureName, projectPath), feature);
|
|
64
|
+
|
|
65
|
+
const statePath = join(projectPath || process.cwd(), STATE_FILE);
|
|
66
|
+
let root = {};
|
|
67
|
+
if (existsSync(statePath)) {
|
|
68
|
+
try { root = JSON.parse(readFileSync(statePath, 'utf-8')); } catch { root = {}; }
|
|
69
|
+
}
|
|
70
|
+
root.version = root.version || STATE_VERSION;
|
|
71
|
+
root.features = root.features || {};
|
|
72
|
+
root.features[featureName] = { status: feature.status, updatedAt: feature.updatedAt };
|
|
73
|
+
root.metadata = root.metadata || {};
|
|
74
|
+
root.metadata.totalFeatures = Object.keys(root.features).length;
|
|
75
|
+
atomicWriteJson(statePath, root);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Canonical gate-approval writer (hook-side mirror of CLI setApprovalGate).
|
|
84
|
+
* Stamps approvalGates[gate] = { approved, timestamp, approvedBy } — the single
|
|
85
|
+
* canonical shape — onto the feature and persists it. Returns true on success.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} featureName
|
|
88
|
+
* @param {string} gate - 'proposal'|'uiux'|'plan'|'review'
|
|
89
|
+
* @param {{ approvedBy?: string, timestamp?: string }} [meta]
|
|
90
|
+
* @param {string} [projectPath]
|
|
91
|
+
*/
|
|
92
|
+
export function setGateApproved(featureName, gate, meta = {}, projectPath) {
|
|
93
|
+
try {
|
|
94
|
+
const feature = readFeatureFile(featureName, projectPath) || createDefaultFeature();
|
|
95
|
+
const now = new Date().toISOString();
|
|
96
|
+
feature.approvalGates = feature.approvalGates || {};
|
|
97
|
+
feature.approvalGates[gate] = {
|
|
98
|
+
approved: true,
|
|
99
|
+
timestamp: meta.timestamp || now,
|
|
100
|
+
approvedBy: meta.approvedBy || 'manual',
|
|
101
|
+
};
|
|
102
|
+
feature.updatedAt = now;
|
|
103
|
+
return persistFeature(featureName, feature, projectPath);
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Default feature shape (canonical object-form gates, incl. optional uiux). */
|
|
110
|
+
export function createDefaultFeature() {
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
return {
|
|
113
|
+
status: 'in_progress',
|
|
114
|
+
createdAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
approvalGates: {
|
|
117
|
+
proposal: { approved: false, timestamp: null, approvedBy: null },
|
|
118
|
+
uiux: { approved: false, timestamp: null, approvedBy: null },
|
|
119
|
+
plan: { approved: false, timestamp: null, approvedBy: null },
|
|
120
|
+
review: { approved: false, timestamp: null, approvedBy: null },
|
|
121
|
+
},
|
|
122
|
+
outputs: {},
|
|
123
|
+
};
|
|
124
|
+
}
|