@polymorphism-tech/morph-spec 7.4.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/README.md +2 -2
- package/bin/morph-spec.js +18 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +9 -4
- package/framework/MORPH.md +4 -4
- 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/hooks/claude-code/core/enforce-phase-writes.js +115 -90
- package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
- package/framework/hooks/claude-code/core/state-sync.js +16 -48
- package/framework/hooks/shared/state-reader.js +74 -8
- 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/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 +15 -15
- package/framework/skills/core/morph-brainstorming/SKILL.md +5 -5
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +5 -4
- package/framework/skills/core/morph-plan/SKILL.md +6 -6
- package/framework/skills/core/morph-proposal/SKILL.md +8 -7
- package/framework/skills/core/morph-review/SKILL.md +3 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- 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/update.js +415 -368
- package/src/commands/project/worktree.js +212 -210
- package/src/commands/state/advance.js +82 -0
- package/src/commands/state/archive.js +111 -0
- package/src/commands/validation/validate-feature.js +229 -105
- package/src/core/state/state-manager.js +238 -66
- package/src/dashboard/scanner/state-scanner.js +35 -8
- package/src/lib/generators/context-generator.js +12 -8
- package/src/utils/file-copier.js +7 -0
|
@@ -5,11 +5,21 @@
|
|
|
5
5
|
* Used both by CLI commands and internal automation.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync, rmdirSync, statSync } from 'fs';
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync, rmdirSync, statSync, readdirSync } from 'fs';
|
|
9
9
|
import { join, dirname } from 'path';
|
|
10
10
|
import { getAllOutputPaths, getOutputPath } from '../paths/output-schema.js';
|
|
11
11
|
|
|
12
12
|
const STATE_FILE_NAME = '.morph/state.json';
|
|
13
|
+
const FEATURES_DIR_NAME = '.morph/features';
|
|
14
|
+
const FEATURE_FILE_NAME = 'feature.json';
|
|
15
|
+
const CONFIG_FILE_NAME = '.morph/config/config.json';
|
|
16
|
+
|
|
17
|
+
// The on-disk state version. NOTE: this tracks the *feature-object* schema, which
|
|
18
|
+
// is unchanged by the per-feature storage split — features are now persisted to
|
|
19
|
+
// `.morph/features/{name}/feature.json` (authoritative, committed) with the root
|
|
20
|
+
// `.morph/state.json` acting as a thin, derivable, gitignored index. Migration to
|
|
21
|
+
// the split layout is detected by `feature.json` presence, not by this number.
|
|
22
|
+
const STATE_VERSION = '5.0.0';
|
|
13
23
|
|
|
14
24
|
// ============================================================================
|
|
15
25
|
// Core Functions
|
|
@@ -21,6 +31,116 @@ const STATE_FILE_NAME = '.morph/state.json';
|
|
|
21
31
|
export function getStatePath() {
|
|
22
32
|
return join(process.cwd(), STATE_FILE_NAME);
|
|
23
33
|
}
|
|
34
|
+
|
|
35
|
+
/** Absolute path to the per-feature folder root (.morph/features). */
|
|
36
|
+
function getFeaturesDir() {
|
|
37
|
+
return join(process.cwd(), FEATURES_DIR_NAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Absolute path to a feature's authoritative feature.json. */
|
|
41
|
+
function getFeatureJsonPath(featureName) {
|
|
42
|
+
return join(getFeaturesDir(), featureName, FEATURE_FILE_NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** List feature names that have a folder on disk under .morph/features. */
|
|
46
|
+
function listFeatureNamesOnDisk() {
|
|
47
|
+
const dir = getFeaturesDir();
|
|
48
|
+
if (!existsSync(dir)) return [];
|
|
49
|
+
try {
|
|
50
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
51
|
+
.filter((e) => e.isDirectory())
|
|
52
|
+
.map((e) => e.name);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read a feature's feature.json, or null if missing/corrupt. */
|
|
59
|
+
function readFeatureJson(featureName) {
|
|
60
|
+
const path = getFeatureJsonPath(featureName);
|
|
61
|
+
if (!existsSync(path)) return null;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read project identity from the committed .morph/config/config.json, if present. */
|
|
70
|
+
function readProjectFromConfig() {
|
|
71
|
+
const path = join(process.cwd(), CONFIG_FILE_NAME);
|
|
72
|
+
if (!existsSync(path)) return null;
|
|
73
|
+
try {
|
|
74
|
+
const cfg = JSON.parse(readFileSync(path, 'utf8'));
|
|
75
|
+
const p = cfg.project || cfg;
|
|
76
|
+
if (!p) return null;
|
|
77
|
+
const project = {};
|
|
78
|
+
if (p.name) project.name = p.name;
|
|
79
|
+
if (p.type) project.type = p.type;
|
|
80
|
+
return Object.keys(project).length ? project : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Atomic JSON write: per-process temp file then rename, with an EPERM retry loop
|
|
88
|
+
* for the brief Windows lock race. Creates the parent directory if needed.
|
|
89
|
+
*/
|
|
90
|
+
function atomicWriteJson(targetPath, data) {
|
|
91
|
+
const dir = dirname(targetPath);
|
|
92
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const tmpPath = `${targetPath}.tmp.${process.pid}`;
|
|
95
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8');
|
|
96
|
+
|
|
97
|
+
const MAX_RETRIES = 5;
|
|
98
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
renameSync(tmpPath, targetPath);
|
|
101
|
+
return;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.code === 'EPERM' && attempt < MAX_RETRIES) {
|
|
104
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1));
|
|
105
|
+
} else {
|
|
106
|
+
try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write a feature's authoritative feature.json (atomic).
|
|
115
|
+
*
|
|
116
|
+
* Skips the write entirely when the on-disk content is already byte-identical to
|
|
117
|
+
* what we'd write. `saveState` hydrates and re-persists EVERY feature, so a CLI
|
|
118
|
+
* mutation on feature A would otherwise rewrite sibling B's file from A's loaded
|
|
119
|
+
* copy. Skipping unchanged siblings means A never touches B's authoritative file
|
|
120
|
+
* in the common case — shrinking the cross-feature clobber window to nothing
|
|
121
|
+
* unless B genuinely changed under A. A formatting mismatch only costs a
|
|
122
|
+
* redundant (idempotent) write, never correctness.
|
|
123
|
+
*/
|
|
124
|
+
function writeFeatureJson(featureName, feature) {
|
|
125
|
+
const path = getFeatureJsonPath(featureName);
|
|
126
|
+
try {
|
|
127
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === JSON.stringify(feature, null, 2)) {
|
|
128
|
+
return; // unchanged — don't rewrite (and don't clobber a concurrent sibling write)
|
|
129
|
+
}
|
|
130
|
+
} catch { /* fall through to a normal write */ }
|
|
131
|
+
atomicWriteJson(path, feature);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A fresh, empty state scaffold (used when no root index exists yet). */
|
|
135
|
+
function scaffoldState() {
|
|
136
|
+
return {
|
|
137
|
+
version: STATE_VERSION,
|
|
138
|
+
project: readProjectFromConfig() || { name: '{PROJECT_NAME}', type: 'dotnet-nextjs' },
|
|
139
|
+
features: {},
|
|
140
|
+
threads: {},
|
|
141
|
+
metadata: { totalFeatures: 0, completedFeatures: 0, totalTimeSpent: 0 },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
24
144
|
/**
|
|
25
145
|
* Derive current phase from filesystem.
|
|
26
146
|
*
|
|
@@ -38,6 +158,17 @@ export function getStatePath() {
|
|
|
38
158
|
* @param {string} featurePath - Absolute path to .morph/features/{feature}/
|
|
39
159
|
* @returns {string} Phase name: 'review' | 'implement' | 'plan' | 'uiux' | 'proposal'
|
|
40
160
|
*/
|
|
161
|
+
const PHASE_INDEX = { proposal: 0, uiux: 1, plan: 2, implement: 3, review: 4 };
|
|
162
|
+
|
|
163
|
+
/** Phase implied purely by approved gates (mirrors hook-side gateImpliedPhase). */
|
|
164
|
+
function gateImpliedPhaseFromGates(gates) {
|
|
165
|
+
const ok = (g) => gates?.[g]?.approved === true;
|
|
166
|
+
if (ok('review')) return 'review';
|
|
167
|
+
if (ok('plan')) return 'implement';
|
|
168
|
+
if (ok('uiux')) return 'plan';
|
|
169
|
+
return 'proposal';
|
|
170
|
+
}
|
|
171
|
+
|
|
41
172
|
export function derivePhase(featurePath) {
|
|
42
173
|
const phaseMap = [
|
|
43
174
|
['4-review', 'review', 'review-report.md'],
|
|
@@ -46,10 +177,21 @@ export function derivePhase(featurePath) {
|
|
|
46
177
|
['1-design', 'uiux', 'design-system.md'],
|
|
47
178
|
['0-proposal', 'proposal', 'proposal.md'],
|
|
48
179
|
];
|
|
180
|
+
let artifactPhase = 'proposal';
|
|
49
181
|
for (const [folder, phase, canonical] of phaseMap) {
|
|
50
|
-
if (existsSync(join(featurePath, folder, canonical)))
|
|
182
|
+
if (existsSync(join(featurePath, folder, canonical))) { artifactPhase = phase; break; }
|
|
51
183
|
}
|
|
52
|
-
|
|
184
|
+
|
|
185
|
+
// Gate-aware (same rule as the hook-side derivePhaseForFeature): an approved
|
|
186
|
+
// gate advances the phase before the next phase's canonical artifact exists,
|
|
187
|
+
// so the agent can actually write that first artifact.
|
|
188
|
+
let gates = null;
|
|
189
|
+
try {
|
|
190
|
+
const fjPath = join(featurePath, 'feature.json');
|
|
191
|
+
if (existsSync(fjPath)) gates = JSON.parse(readFileSync(fjPath, 'utf8'))?.approvalGates;
|
|
192
|
+
} catch { gates = null; }
|
|
193
|
+
const implied = gateImpliedPhaseFromGates(gates);
|
|
194
|
+
return PHASE_INDEX[implied] > PHASE_INDEX[artifactPhase] ? implied : artifactPhase;
|
|
53
195
|
}
|
|
54
196
|
|
|
55
197
|
/**
|
|
@@ -84,54 +226,88 @@ export function stateExists() {
|
|
|
84
226
|
*/
|
|
85
227
|
export function loadState(throwOnError = true) {
|
|
86
228
|
const statePath = getStatePath();
|
|
229
|
+
const rootExists = existsSync(statePath);
|
|
230
|
+
const featureNames = listFeatureNamesOnDisk();
|
|
231
|
+
const featuresExist = featureNames.some((name) => existsSync(getFeatureJsonPath(name)));
|
|
87
232
|
|
|
88
|
-
|
|
233
|
+
// "No state" only when neither the root index nor any per-feature file exists.
|
|
234
|
+
if (!rootExists && !featuresExist) {
|
|
89
235
|
if (throwOnError) {
|
|
90
236
|
throw new Error(`State file not found: ${statePath}\nRun 'morph-spec init' to initialize MORPH in this project.`);
|
|
91
237
|
}
|
|
92
238
|
return null;
|
|
93
239
|
}
|
|
94
240
|
|
|
241
|
+
let state;
|
|
95
242
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (state.version === '3.0.0') {
|
|
101
|
-
state.version = '4.0.0';
|
|
102
|
-
for (const [name, feature] of Object.entries(state.features || {})) {
|
|
103
|
-
if (!feature.outputs) continue;
|
|
104
|
-
const outputPathMap = getAllOutputPaths(name);
|
|
105
|
-
for (const [key, { path: newPath }] of Object.entries(outputPathMap)) {
|
|
106
|
-
if (feature.outputs[key]) {
|
|
107
|
-
feature.outputs[key].path = newPath;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
|
243
|
+
state = rootExists ? JSON.parse(readFileSync(statePath, 'utf8')) : scaffoldState();
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (throwOnError) {
|
|
246
|
+
throw new Error(`Failed to parse state.json: ${err.message}`);
|
|
112
247
|
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
state.features = state.features || {};
|
|
252
|
+
state.metadata = state.metadata || {};
|
|
253
|
+
if (!state.project || !state.project.name || state.project.name === '{PROJECT_NAME}') {
|
|
254
|
+
const fromConfig = readProjectFromConfig();
|
|
255
|
+
if (fromConfig) state.project = { ...(state.project || {}), ...fromConfig };
|
|
256
|
+
}
|
|
113
257
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
258
|
+
// A missing root index means we must (re)generate it from the per-feature
|
|
259
|
+
// files we're about to hydrate — this is the self-heal path after a wipe.
|
|
260
|
+
let mutated = !rootExists;
|
|
261
|
+
|
|
262
|
+
// Migrate v3.0.0 → v4.0.0 (new directory layout) — operates on root features.
|
|
263
|
+
if (state.version === '3.0.0') {
|
|
264
|
+
state.version = '4.0.0';
|
|
265
|
+
for (const [name, feature] of Object.entries(state.features)) {
|
|
266
|
+
if (!feature.outputs) continue;
|
|
267
|
+
const outputPathMap = getAllOutputPaths(name);
|
|
268
|
+
for (const [key, { path: newPath }] of Object.entries(outputPathMap)) {
|
|
269
|
+
if (feature.outputs[key]) feature.outputs[key].path = newPath;
|
|
124
270
|
}
|
|
125
|
-
writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
|
126
271
|
}
|
|
272
|
+
mutated = true;
|
|
273
|
+
}
|
|
127
274
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
275
|
+
// Migrate v4.x → v5.0.0: drop derived fields (now filesystem-derived) and
|
|
276
|
+
// workflow selection (v5 has fixed phases).
|
|
277
|
+
if (state.version && state.version.startsWith('4.')) {
|
|
278
|
+
state.version = '5.0.0';
|
|
279
|
+
for (const feature of Object.values(state.features)) {
|
|
280
|
+
delete feature.outputs;
|
|
281
|
+
delete feature.phase;
|
|
282
|
+
delete feature.workflow;
|
|
283
|
+
delete feature.workflowDetection;
|
|
284
|
+
}
|
|
285
|
+
mutated = true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Split-on-load: any feature still living only in the root index (no
|
|
289
|
+
// feature.json yet) is written out to its authoritative per-feature file.
|
|
290
|
+
// This migrates pre-split monolithic state and is idempotent thereafter.
|
|
291
|
+
for (const [name, feature] of Object.entries(state.features)) {
|
|
292
|
+
if (!existsSync(getFeatureJsonPath(name))) {
|
|
293
|
+
writeFeatureJson(name, feature);
|
|
294
|
+
mutated = true;
|
|
132
295
|
}
|
|
133
|
-
return null;
|
|
134
296
|
}
|
|
297
|
+
|
|
298
|
+
// Hydrate from per-feature files (authoritative). Also picks up features that
|
|
299
|
+
// exist on disk but are absent from a stale/deleted root index — so a wiped
|
|
300
|
+
// state.json self-heals from the committed feature.json files.
|
|
301
|
+
for (const name of featureNames) {
|
|
302
|
+
const fj = readFeatureJson(name);
|
|
303
|
+
if (fj) state.features[name] = fj;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (mutated) {
|
|
307
|
+
saveState(state); // re-persists feature.json files + a freshly thinned root index
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return state;
|
|
135
311
|
}
|
|
136
312
|
|
|
137
313
|
/**
|
|
@@ -141,38 +317,34 @@ export function loadState(throwOnError = true) {
|
|
|
141
317
|
export function saveState(state) {
|
|
142
318
|
state.metadata = state.metadata || {};
|
|
143
319
|
state.metadata.lastUpdated = new Date().toISOString();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
320
|
+
state.version = state.version || STATE_VERSION;
|
|
321
|
+
|
|
322
|
+
const features = state.features || {};
|
|
323
|
+
|
|
324
|
+
// Aggregate metadata is derived from the authoritative feature set so the
|
|
325
|
+
// thin root index never drifts from the per-feature files.
|
|
326
|
+
state.metadata.totalFeatures = Object.keys(features).length;
|
|
327
|
+
state.metadata.completedFeatures = Object.values(features)
|
|
328
|
+
.filter((f) => f && f.status === 'completed').length;
|
|
329
|
+
|
|
330
|
+
// 1) Persist each feature to its authoritative, committed feature.json. This
|
|
331
|
+
// is what travels with the feature's own commit — no cross-feature sweep.
|
|
332
|
+
const thinFeatures = {};
|
|
333
|
+
for (const [name, feature] of Object.entries(features)) {
|
|
334
|
+
writeFeatureJson(name, feature);
|
|
335
|
+
thinFeatures[name] = { status: feature.status, updatedAt: feature.updatedAt };
|
|
151
336
|
}
|
|
152
337
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
try {
|
|
164
|
-
renameSync(tmpPath, statePath);
|
|
165
|
-
return;
|
|
166
|
-
} catch (err) {
|
|
167
|
-
if (err.code === 'EPERM' && attempt < MAX_RETRIES) {
|
|
168
|
-
// Synchronous sleep: CPU-friendly, works on Node.js main thread (v9.4+)
|
|
169
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1));
|
|
170
|
-
} else {
|
|
171
|
-
try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
|
|
172
|
-
throw err;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
338
|
+
// 2) Persist the thin, derivable, gitignored root index (status + updatedAt
|
|
339
|
+
// per feature is all that callers like getActiveFeature need from the root;
|
|
340
|
+
// full feature data is hydrated from feature.json on load).
|
|
341
|
+
atomicWriteJson(getStatePath(), {
|
|
342
|
+
version: state.version,
|
|
343
|
+
project: state.project,
|
|
344
|
+
threads: state.threads || {},
|
|
345
|
+
metadata: state.metadata,
|
|
346
|
+
features: thinFeatures,
|
|
347
|
+
});
|
|
176
348
|
}
|
|
177
349
|
|
|
178
350
|
// ============================================================================
|
|
@@ -1,8 +1,35 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
|
|
4
|
-
export function scanState(projectDir) {
|
|
5
|
-
const statePath = join(projectDir, '.morph', 'state.json');
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function scanState(projectDir) {
|
|
5
|
+
const statePath = join(projectDir, '.morph', 'state.json');
|
|
6
|
+
let state = null;
|
|
7
|
+
if (existsSync(statePath)) {
|
|
8
|
+
try { state = JSON.parse(readFileSync(statePath, 'utf8')); } catch { state = null; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Hydrate full feature objects from the authoritative per-feature files. The
|
|
12
|
+
// root state.json is now a thin, derivable index (status + updatedAt only), so
|
|
13
|
+
// the dashboard must read .morph/features/{name}/feature.json for full data.
|
|
14
|
+
const featuresDir = join(projectDir, '.morph', 'features');
|
|
15
|
+
if (existsSync(featuresDir)) {
|
|
16
|
+
let names = [];
|
|
17
|
+
try {
|
|
18
|
+
names = readdirSync(featuresDir, { withFileTypes: true })
|
|
19
|
+
.filter((e) => e.isDirectory())
|
|
20
|
+
.map((e) => e.name);
|
|
21
|
+
} catch { names = []; }
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
const fjPath = join(featuresDir, name, 'feature.json');
|
|
24
|
+
if (!existsSync(fjPath)) continue;
|
|
25
|
+
try {
|
|
26
|
+
const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
|
|
27
|
+
state = state || { features: {} };
|
|
28
|
+
state.features = state.features || {};
|
|
29
|
+
state.features[name] = fj;
|
|
30
|
+
} catch { /* skip a corrupt feature.json */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
@@ -309,19 +309,23 @@ export async function generateFeatureContext(projectPath, featureName) {
|
|
|
309
309
|
loadAgents(projectPath)
|
|
310
310
|
]);
|
|
311
311
|
|
|
312
|
-
// Find feature
|
|
312
|
+
// Find the feature. The authoritative per-feature state lives in
|
|
313
|
+
// .morph/features/{name}/feature.json (the root state.json is now a thin
|
|
314
|
+
// index); read it directly, falling back to the index for back-compat.
|
|
313
315
|
let feature;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
feature =
|
|
316
|
+
try {
|
|
317
|
+
const fjPath = path.join(projectPath, '.morph/features', featureName, 'feature.json');
|
|
318
|
+
feature = { name: featureName, ...JSON.parse(await fs.readFile(fjPath, 'utf8')) };
|
|
319
|
+
} catch {
|
|
320
|
+
if (Array.isArray(state?.features)) {
|
|
321
|
+
feature = state.features.find(f => f.name === featureName);
|
|
322
|
+
} else if (state?.features && typeof state.features === 'object' && state.features[featureName]) {
|
|
323
|
+
feature = { name: featureName, ...state.features[featureName] };
|
|
320
324
|
}
|
|
321
325
|
}
|
|
322
326
|
|
|
323
327
|
if (!feature) {
|
|
324
|
-
throw new Error(`Feature "${featureName}" not found in state.json`);
|
|
328
|
+
throw new Error(`Feature "${featureName}" not found in .morph/features/ or state.json`);
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
// Load feature outputs
|
package/src/utils/file-copier.js
CHANGED
|
@@ -138,6 +138,13 @@ export async function updateGitignore(projectPath) {
|
|
|
138
138
|
const morphRules = [
|
|
139
139
|
'',
|
|
140
140
|
'# MORPH-SPEC',
|
|
141
|
+
// Derivable, per-machine state index — rebuilt from the committed
|
|
142
|
+
// .morph/features/{feature}/feature.json files. Never commit it: doing so
|
|
143
|
+
// sweeps every feature's accumulated diffs into one commit. The feature
|
|
144
|
+
// state that matters travels inside each feature folder (which IS committed).
|
|
145
|
+
'.morph/state.json',
|
|
146
|
+
'.morph/state.json.tmp.*',
|
|
147
|
+
'.morph/state.json.lock/',
|
|
141
148
|
'.morph/framework/',
|
|
142
149
|
'.morph/memory/',
|
|
143
150
|
'.morph/plans/',
|