@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
|
@@ -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,25 +31,167 @@ 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
|
-
* Derive current phase from filesystem
|
|
26
|
-
*
|
|
145
|
+
* Derive current phase from filesystem.
|
|
146
|
+
*
|
|
147
|
+
* Walks the phase map in descending order and returns the first phase whose
|
|
148
|
+
* CANONICAL ARTIFACT exists. An empty folder is not enough — `mkdir 2-plan/`
|
|
149
|
+
* does not count as "in plan phase" until `spec.md` (the first canonical
|
|
150
|
+
* plan-phase output) lands. If no canonical artifact is found anywhere,
|
|
151
|
+
* returns `'proposal'` (the starting phase).
|
|
152
|
+
*
|
|
153
|
+
* This avoids the chicken-and-egg problem where the agent pre-creates all
|
|
154
|
+
* phase folders, the derived phase jumps to the highest one, and
|
|
155
|
+
* `enforce-phase-writes` then blocks the agent from writing back to the
|
|
156
|
+
* prior phase folder.
|
|
27
157
|
*
|
|
28
158
|
* @param {string} featurePath - Absolute path to .morph/features/{feature}/
|
|
29
159
|
* @returns {string} Phase name: 'review' | 'implement' | 'plan' | 'uiux' | 'proposal'
|
|
30
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
|
+
|
|
31
172
|
export function derivePhase(featurePath) {
|
|
32
173
|
const phaseMap = [
|
|
33
|
-
['4-review',
|
|
34
|
-
['3-implement', 'implement'],
|
|
35
|
-
['2-plan',
|
|
36
|
-
['1-design',
|
|
37
|
-
['0-proposal',
|
|
174
|
+
['4-review', 'review', 'review-report.md'],
|
|
175
|
+
['3-implement', 'implement', 'recap.md'],
|
|
176
|
+
['2-plan', 'plan', 'spec.md'],
|
|
177
|
+
['1-design', 'uiux', 'design-system.md'],
|
|
178
|
+
['0-proposal', 'proposal', 'proposal.md'],
|
|
38
179
|
];
|
|
39
|
-
|
|
40
|
-
|
|
180
|
+
let artifactPhase = 'proposal';
|
|
181
|
+
for (const [folder, phase, canonical] of phaseMap) {
|
|
182
|
+
if (existsSync(join(featurePath, folder, canonical))) { artifactPhase = phase; break; }
|
|
41
183
|
}
|
|
42
|
-
|
|
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;
|
|
43
195
|
}
|
|
44
196
|
|
|
45
197
|
/**
|
|
@@ -74,54 +226,88 @@ export function stateExists() {
|
|
|
74
226
|
*/
|
|
75
227
|
export function loadState(throwOnError = true) {
|
|
76
228
|
const statePath = getStatePath();
|
|
229
|
+
const rootExists = existsSync(statePath);
|
|
230
|
+
const featureNames = listFeatureNamesOnDisk();
|
|
231
|
+
const featuresExist = featureNames.some((name) => existsSync(getFeatureJsonPath(name)));
|
|
77
232
|
|
|
78
|
-
|
|
233
|
+
// "No state" only when neither the root index nor any per-feature file exists.
|
|
234
|
+
if (!rootExists && !featuresExist) {
|
|
79
235
|
if (throwOnError) {
|
|
80
236
|
throw new Error(`State file not found: ${statePath}\nRun 'morph-spec init' to initialize MORPH in this project.`);
|
|
81
237
|
}
|
|
82
238
|
return null;
|
|
83
239
|
}
|
|
84
240
|
|
|
241
|
+
let state;
|
|
85
242
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (state.version === '3.0.0') {
|
|
91
|
-
state.version = '4.0.0';
|
|
92
|
-
for (const [name, feature] of Object.entries(state.features || {})) {
|
|
93
|
-
if (!feature.outputs) continue;
|
|
94
|
-
const outputPathMap = getAllOutputPaths(name);
|
|
95
|
-
for (const [key, { path: newPath }] of Object.entries(outputPathMap)) {
|
|
96
|
-
if (feature.outputs[key]) {
|
|
97
|
-
feature.outputs[key].path = newPath;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
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}`);
|
|
102
247
|
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
103
250
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
|
257
|
+
|
|
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;
|
|
114
270
|
}
|
|
115
|
-
writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
|
116
271
|
}
|
|
272
|
+
mutated = true;
|
|
273
|
+
}
|
|
117
274
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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;
|
|
122
295
|
}
|
|
123
|
-
return null;
|
|
124
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;
|
|
125
311
|
}
|
|
126
312
|
|
|
127
313
|
/**
|
|
@@ -131,38 +317,34 @@ export function loadState(throwOnError = true) {
|
|
|
131
317
|
export function saveState(state) {
|
|
132
318
|
state.metadata = state.metadata || {};
|
|
133
319
|
state.metadata.lastUpdated = new Date().toISOString();
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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 };
|
|
141
336
|
}
|
|
142
337
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
renameSync(tmpPath, statePath);
|
|
155
|
-
return;
|
|
156
|
-
} catch (err) {
|
|
157
|
-
if (err.code === 'EPERM' && attempt < MAX_RETRIES) {
|
|
158
|
-
// Synchronous sleep: CPU-friendly, works on Node.js main thread (v9.4+)
|
|
159
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1));
|
|
160
|
-
} else {
|
|
161
|
-
try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
|
|
162
|
-
throw err;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
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
|
+
});
|
|
166
348
|
}
|
|
167
349
|
|
|
168
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/lib/risk-detector.js
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* Signals checked (in order):
|
|
9
9
|
* - designSystemDivergence: boolean — conflicting design sources detected
|
|
10
10
|
* - standardsViolations: string[] — violations of mandate.md proibições
|
|
11
|
-
* - evalScore: number — morph-eval score (pause if <
|
|
11
|
+
* - evalScore: number — morph-eval score (pause if < 9)
|
|
12
12
|
* - taskCount: number — total tasks (pause if > 20)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const SCOPE_TASK_THRESHOLD = 20;
|
|
16
|
-
const MIN_EVAL_SCORE =
|
|
16
|
+
const MIN_EVAL_SCORE = 9;
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* @param {object} config - .morph/config/config.json contents (uses `trust`)
|
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/',
|