@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.
Files changed (61) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +22 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +10 -5
  8. package/framework/MORPH.md +31 -14
  9. package/framework/agents.json +132 -132
  10. package/framework/commands/create-umbraco-lp.md +35 -0
  11. package/framework/commands/migrate-lp-to-umbraco.md +33 -0
  12. package/framework/commands/morph-archive.md +17 -27
  13. package/framework/evals/rubrics/README.md +9 -8
  14. package/framework/hooks/README.md +2 -0
  15. package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
  16. package/framework/hooks/claude-code/core/gate-guard.js +160 -0
  17. package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
  18. package/framework/hooks/claude-code/core/state-sync.js +16 -36
  19. package/framework/hooks/claude-code/core/validate-completion.js +105 -0
  20. package/framework/hooks/shared/state-reader.js +302 -221
  21. package/framework/hooks/shared/state-writer.js +124 -0
  22. package/framework/rules/morph-task-tracking.md +69 -49
  23. package/framework/rules/morph-workflow.md +192 -191
  24. package/framework/skills/README.md +1 -1
  25. package/framework/skills/core/create-umbraco-lp/SKILL.md +252 -0
  26. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +452 -0
  27. package/framework/skills/core/morph-apply/SKILL.md +69 -27
  28. package/framework/skills/core/morph-brainstorming/SKILL.md +6 -2
  29. package/framework/skills/core/morph-eval/SKILL.md +4 -5
  30. package/framework/skills/core/morph-implement/SKILL.md +51 -22
  31. package/framework/skills/core/morph-plan/SKILL.md +10 -4
  32. package/framework/skills/core/morph-proposal/SKILL.md +8 -7
  33. package/framework/skills/core/morph-review/SKILL.md +15 -8
  34. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  35. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  36. package/framework/standards/ai-agents/setup.md +258 -244
  37. package/framework/templates/REGISTRY.json +1 -3
  38. package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
  39. package/framework/templates/context/CONTEXT.md +181 -180
  40. package/framework/templates/docs/onboarding.md +122 -118
  41. package/framework/templates/docs/proposal.md +10 -0
  42. package/framework/templates/docs/spec.md +228 -228
  43. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
  44. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
  45. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
  46. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
  47. package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
  48. package/package.json +1 -1
  49. package/src/commands/project/init.js +517 -515
  50. package/src/commands/project/update.js +415 -368
  51. package/src/commands/project/worktree.js +212 -210
  52. package/src/commands/state/advance.js +82 -0
  53. package/src/commands/state/approve.js +251 -213
  54. package/src/commands/state/archive.js +111 -0
  55. package/src/commands/validation/validate-feature.js +244 -79
  56. package/src/core/state/state-manager.js +256 -74
  57. package/src/dashboard/scanner/state-scanner.js +35 -8
  58. package/src/lib/generators/context-generator.js +12 -8
  59. package/src/lib/risk-detector.js +2 -2
  60. package/src/utils/file-copier.js +7 -0
  61. 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
- * Load state.json from the project root.
17
- * Fail-open: returns null if file missing or corrupt.
18
- * @param {string} [projectPath] - Project root (defaults to cwd)
19
- * @returns {Object|null} State object or null
20
- */
21
- export function loadState(projectPath) {
22
- try {
23
- const statePath = join(projectPath || process.cwd(), STATE_FILE);
24
- if (!existsSync(statePath)) return null;
25
- return JSON.parse(readFileSync(statePath, 'utf-8'));
26
- } catch {
27
- return null;
28
- }
29
- }
30
-
31
- /**
32
- * Check if state.json exists.
33
- * @param {string} [projectPath]
34
- * @returns {boolean}
35
- */
36
- export function stateExists(projectPath) {
37
- return existsSync(join(projectPath || process.cwd(), STATE_FILE));
38
- }
39
-
40
- /**
41
- * Get the active feature (most recently updated in_progress feature).
42
- * @param {string} [projectPath]
43
- * @returns {{ name: string, feature: Object }|null}
44
- */
45
- export function getActiveFeature(projectPath) {
46
- const state = loadState(projectPath);
47
- if (!state?.features) return null;
48
-
49
- let active = null;
50
- let latestUpdate = '';
51
-
52
- for (const [name, feature] of Object.entries(state.features)) {
53
- if (feature.status === 'in_progress' || feature.status === 'draft') {
54
- const updated = feature.updatedAt || feature.createdAt || '';
55
- if (updated >= latestUpdate) {
56
- latestUpdate = updated;
57
- active = { name, feature };
58
- }
59
- }
60
- }
61
-
62
- return active;
63
- }
64
-
65
- /**
66
- * Get the most recently updated feature (any status).
67
- * Useful as a fallback when getActiveFeature returns null (all features done).
68
- * @param {string} [projectPath]
69
- * @returns {{ name: string, feature: Object }|null}
70
- */
71
- export function getMostRecentFeature(projectPath) {
72
- const state = loadState(projectPath);
73
- if (!state?.features) return null;
74
-
75
- let result = null;
76
- let latestUpdate = '';
77
-
78
- for (const [name, feature] of Object.entries(state.features)) {
79
- const updated = feature.updatedAt || feature.createdAt || '';
80
- if (updated >= latestUpdate) {
81
- latestUpdate = updated;
82
- result = { name, feature };
83
- }
84
- }
85
-
86
- return result;
87
- }
88
-
89
- /**
90
- * Get a specific feature by name.
91
- * @param {string} featureName
92
- * @param {string} [projectPath]
93
- * @returns {Object|null}
94
- */
95
- export function getFeature(featureName, projectPath) {
96
- const state = loadState(projectPath);
97
- return state?.features?.[featureName] || null;
98
- }
99
-
100
- /**
101
- * Derive phase from filesystem (phase deleted from state).
102
- * Checks for phase folders in descending order, returns highest present.
103
- * @param {string} featureName
104
- * @param {string} [projectPath]
105
- * @returns {string} Phase name
106
- */
107
- export function derivePhaseForFeature(featureName, projectPath) {
108
- const basePath = join(projectPath || process.cwd(), '.morph/features', featureName);
109
- const phaseMap = [
110
- ['4-review', 'review'],
111
- ['3-implement', 'implement'],
112
- ['2-plan', 'plan'],
113
- ['1-design', 'uiux'],
114
- ['0-proposal', 'proposal'],
115
- ];
116
- for (const [folder, phase] of phaseMap) {
117
- if (existsSync(join(basePath, folder))) return phase;
118
- }
119
- return 'proposal';
120
- }
121
-
122
- /**
123
- * Get the current phase of a feature.
124
- * Falls back to filesystem derivation when phase is not in state.
125
- * @param {string} featureName
126
- * @param {string} [projectPath]
127
- * @returns {string|null}
128
- */
129
- export function getFeaturePhase(featureName, projectPath) {
130
- const feature = getFeature(featureName, projectPath);
131
- if (!feature) return null;
132
- return feature.phase || derivePhaseForFeature(featureName, projectPath);
133
- }
134
-
135
- /**
136
- * Get approval status for a gate.
137
- * @param {string} featureName
138
- * @param {string} gate - 'proposal'|'uiux'|'plan'|'review'
139
- * @param {string} [projectPath]
140
- * @returns {boolean}
141
- */
142
- export function isGateApproved(featureName, gate, projectPath) {
143
- const feature = getFeature(featureName, projectPath);
144
- // Support both field names: 'approvals' (v5 state) and 'approvalGates' (legacy)
145
- const gates = feature?.approvals || feature?.approvalGates;
146
- return gates?.[gate]?.approved === true;
147
- }
148
-
149
- /**
150
- * Get all pending approval gates for a feature.
151
- * @param {string} featureName
152
- * @param {string} [projectPath]
153
- * @returns {string[]} List of gate names that are not yet approved
154
- */
155
- export function getPendingGates(featureName, projectPath) {
156
- const feature = getFeature(featureName, projectPath);
157
- // Support both field names: 'approvals' (v5 state) and 'approvalGates' (legacy)
158
- const gates = feature?.approvals || feature?.approvalGates;
159
- if (!gates) return [];
160
-
161
- return Object.entries(gates)
162
- .filter(([, gate]) => !gate.approved)
163
- .map(([name]) => name);
164
- }
165
-
166
- /**
167
- * Get feature outputs status.
168
- * @param {string} featureName
169
- * @param {string} [projectPath]
170
- * @returns {Object|null} outputs map or null
171
- */
172
- export function getOutputs(featureName, projectPath) {
173
- const feature = getFeature(featureName, projectPath);
174
- return feature?.outputs || null;
175
- }
176
-
177
- /**
178
- * Get uncompleted required outputs for current phase.
179
- * @param {string} featureName
180
- * @param {string} [projectPath]
181
- * @returns {Array<{type: string, path: string}>}
182
- */
183
- /**
184
- * Get task statistics directly from taskList (preferred over feature.tasks counters).
185
- * Excludes expanded tasks from counts, matching calculateProgress() behavior.
186
- *
187
- * @param {Object} feature - Feature state object
188
- * @returns {{ total: number, completed: number, inProgress: number, pending: number }}
189
- */
190
- export function getTaskStats(feature) {
191
- const list = feature?.taskList || [];
192
- if (list.length > 0) {
193
- const active = list.filter(t => t.status !== 'expanded');
194
- return {
195
- total: active.length,
196
- completed: active.filter(t => t.status === 'completed').length,
197
- inProgress: active.filter(t => t.status === 'in_progress').length,
198
- pending: active.filter(t => t.status === 'pending').length,
199
- };
200
- }
201
- // Fallback: read from legacy counter object
202
- const tasks = feature?.tasks;
203
- if (tasks && typeof tasks === 'object' && !Array.isArray(tasks)) {
204
- return {
205
- total: tasks.total || 0,
206
- completed: tasks.completed || 0,
207
- inProgress: tasks.inProgress || 0,
208
- pending: tasks.pending || 0,
209
- };
210
- }
211
- return { total: 0, completed: 0, inProgress: 0, pending: 0 };
212
- }
213
-
214
- export function getMissingOutputs(featureName, projectPath) {
215
- const feature = getFeature(featureName, projectPath);
216
- if (!feature?.outputs) return [];
217
-
218
- return Object.entries(feature.outputs)
219
- .filter(([, output]) => !output.created)
220
- .map(([type, output]) => ({ type, path: output.path }));
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
+ }