@polymorphism-tech/morph-spec 7.4.0 → 7.6.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.
Files changed (66) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +18 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +9 -4
  7. package/framework/MORPH.md +4 -4
  8. package/framework/agents.json +1 -1
  9. package/framework/commands/create-umbraco-lp.md +35 -0
  10. package/framework/commands/migrate-lp-to-umbraco.md +33 -0
  11. package/framework/commands/morph-archive.md +17 -27
  12. package/framework/commands/morph-preflight.md +10 -5
  13. package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
  14. package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
  15. package/framework/hooks/claude-code/core/state-sync.js +16 -48
  16. package/framework/hooks/shared/state-reader.js +74 -8
  17. package/framework/hooks/shared/state-writer.js +124 -0
  18. package/framework/rules/infrastructure-standards.md +4 -4
  19. package/framework/rules/morph-task-tracking.md +69 -49
  20. package/framework/rules/morph-workflow.md +192 -191
  21. package/framework/skills/core/create-umbraco-lp/SKILL.md +272 -0
  22. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +531 -0
  23. package/framework/skills/core/morph-apply/SKILL.md +15 -15
  24. package/framework/skills/core/morph-brainstorming/SKILL.md +5 -5
  25. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  26. package/framework/skills/core/morph-implement/SKILL.md +5 -4
  27. package/framework/skills/core/morph-plan/SKILL.md +6 -6
  28. package/framework/skills/core/morph-proposal/SKILL.md +8 -7
  29. package/framework/skills/core/morph-review/SKILL.md +3 -3
  30. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  31. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  32. package/framework/standards/STANDARDS.json +80 -8
  33. package/framework/standards/ai-agents/durable-workflows-hitl.md +191 -0
  34. package/framework/standards/ai-agents/rag-custom-pgvector.md +246 -243
  35. package/framework/standards/ai-agents/setup.md +3 -0
  36. package/framework/standards/ai-agents/vector-data-extensions.md +170 -0
  37. package/framework/standards/ai-agents/workflows.md +190 -187
  38. package/framework/standards/infrastructure/docker/{easypanel-deploy.md → coolify-deploy.md} +49 -20
  39. package/framework/templates/README.md +7 -7
  40. package/framework/templates/REGISTRY.json +15 -17
  41. package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
  42. package/framework/templates/context/CONTEXT.md +181 -180
  43. package/framework/templates/docs/onboarding.md +122 -118
  44. package/framework/templates/docs/proposal.md +12 -2
  45. package/framework/templates/docs/spec.md +228 -228
  46. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +1 -1
  47. package/framework/templates/infrastructure/docker/coolify.template.json +49 -0
  48. package/framework/templates/infrastructure/github/README.md +6 -6
  49. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +33 -0
  50. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
  51. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
  52. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
  53. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
  54. package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
  55. package/package.json +1 -1
  56. package/src/commands/project/update.js +415 -368
  57. package/src/commands/project/worktree.js +212 -210
  58. package/src/commands/state/advance.js +82 -0
  59. package/src/commands/state/archive.js +111 -0
  60. package/src/commands/validation/validate-feature.js +229 -105
  61. package/src/core/state/state-manager.js +238 -66
  62. package/src/dashboard/scanner/state-scanner.js +35 -8
  63. package/src/lib/generators/context-generator.js +12 -8
  64. package/src/utils/file-copier.js +7 -0
  65. package/framework/templates/infrastructure/docker/easypanel.template.json +0 -46
  66. package/framework/templates/infrastructure/github/workflows/deploy-easypanel.yml.hbs +0 -31
@@ -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))) return phase;
182
+ if (existsSync(join(featurePath, folder, canonical))) { artifactPhase = phase; break; }
51
183
  }
52
- return 'proposal';
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
- if (!existsSync(statePath)) {
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
- const content = readFileSync(statePath, 'utf8');
97
- const state = JSON.parse(content);
98
-
99
- // Migrate v3.0.0 → v4.0.0 (new directory layout)
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
- // Migrate v4.x -> v5.0.0: remove outputs and phase (now derived from
115
- // filesystem) and workflow/workflowDetection (v5 has fixed phases, no
116
- // workflow selection).
117
- if (state.version && state.version.startsWith('4.')) {
118
- state.version = '5.0.0';
119
- for (const feature of Object.values(state.features || {})) {
120
- delete feature.outputs;
121
- delete feature.phase;
122
- delete feature.workflow;
123
- delete feature.workflowDetection;
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
- return state;
129
- } catch (err) {
130
- if (throwOnError) {
131
- throw new Error(`Failed to parse state.json: ${err.message}`);
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
- const statePath = getStatePath();
146
- const stateDir = dirname(statePath);
147
-
148
- // Ensure .morph directory exists
149
- if (!existsSync(stateDir)) {
150
- mkdirSync(stateDir, { recursive: true });
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
- // Atomic write: write to a per-process temp file then rename.
154
- // Per-process name prevents two concurrent CLI invocations (e.g. `task done && task start`)
155
- // from clobbering each other's temp file on Windows.
156
- const tmpPath = `${statePath}.tmp.${process.pid}`;
157
- writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
158
-
159
- // Retry rename on EPERM — Windows holds a brief exclusive lock on the target
160
- // when another process is reading it; a short backoff resolves the contention.
161
- const MAX_RETRIES = 5;
162
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
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
- if (!existsSync(statePath)) return null;
7
- try { return JSON.parse(readFileSync(statePath, 'utf8')); } catch { return null; }
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 in state (handle both array and object format)
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
- if (Array.isArray(state?.features)) {
315
- feature = state.features.find(f => f.name === featureName);
316
- } else if (state?.features && typeof state.features === 'object') {
317
- feature = state.features[featureName];
318
- if (feature) {
319
- feature = { name: featureName, ...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
@@ -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/',
@@ -1,46 +0,0 @@
1
- {
2
- "name": "{{kebabCase FEATURE_NAME}}",
3
- "services": [
4
- {
5
- "type": "app",
6
- "data": {
7
- "projectName": "{{kebabCase FEATURE_NAME}}",
8
- "serviceName": "{{kebabCase FEATURE_NAME}}-app",
9
- "source": {
10
- "type": "github",
11
- "owner": "{{GITHUB_OWNER}}",
12
- "repo": "{{GITHUB_REPO}}",
13
- "ref": "main",
14
- "autoDeploy": true
15
- },
16
- "build": {
17
- "type": "dockerfile",
18
- "dockerfile": "Dockerfile"
19
- },
20
- "deploy": {
21
- "replicas": 1,
22
- "command": null,
23
- "zeroDowntime": true
24
- },
25
- "env": [
26
- {
27
- "key": "NODE_ENV",
28
- "value": "production"
29
- },
30
- {
31
- "key": "DATABASE_URL",
32
- "value": "${DATABASE_URL}"
33
- }
34
- ],
35
- "domains": [
36
- {
37
- "host": "{{kebabCase FEATURE_NAME}}.easypanel.host",
38
- "port": 3000,
39
- "https": true
40
- }
41
- ],
42
- "mounts": []
43
- }
44
- }
45
- ]
46
- }
@@ -1,31 +0,0 @@
1
- name: Deploy to EasyPanel
2
-
3
- on:
4
- workflow_call:
5
- inputs:
6
- environment:
7
- required: true
8
- type: string
9
- image:
10
- required: true
11
- type: string
12
-
13
- jobs:
14
- deploy:
15
- runs-on: ubuntu-latest
16
- environment: ${{{{ inputs.environment }}}}
17
- steps:
18
- - name: Trigger EasyPanel Deploy
19
- run: |
20
- curl -X POST ${{{{ secrets.EASYPANEL_WEBHOOK_URL }}}} \
21
- -H "Authorization: Bearer ${{{{ secrets.EASYPANEL_API_TOKEN }}}}" \
22
- -H "Content-Type: application/json" \
23
- -d '{
24
- "image": "${{{{ inputs.image }}}}",
25
- "environment": "${{{{ inputs.environment }}}}"
26
- }'
27
-
28
- - name: Health Check
29
- run: |
30
- sleep 30 # Wait for deployment
31
- curl --retry 10 --retry-delay 5 --retry-connrefused ${{{{ secrets.APP_URL }}}}/health