@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.
Files changed (49) 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/commands/create-umbraco-lp.md +35 -0
  9. package/framework/commands/migrate-lp-to-umbraco.md +33 -0
  10. package/framework/commands/morph-archive.md +17 -27
  11. package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
  12. package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
  13. package/framework/hooks/claude-code/core/state-sync.js +16 -48
  14. package/framework/hooks/shared/state-reader.js +74 -8
  15. package/framework/hooks/shared/state-writer.js +124 -0
  16. package/framework/rules/morph-task-tracking.md +69 -49
  17. package/framework/rules/morph-workflow.md +192 -191
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +252 -0
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +452 -0
  20. package/framework/skills/core/morph-apply/SKILL.md +15 -15
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +5 -5
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-implement/SKILL.md +5 -4
  24. package/framework/skills/core/morph-plan/SKILL.md +6 -6
  25. package/framework/skills/core/morph-proposal/SKILL.md +8 -7
  26. package/framework/skills/core/morph-review/SKILL.md +3 -3
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/templates/REGISTRY.json +1 -3
  30. package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
  31. package/framework/templates/context/CONTEXT.md +181 -180
  32. package/framework/templates/docs/onboarding.md +122 -118
  33. package/framework/templates/docs/proposal.md +10 -0
  34. package/framework/templates/docs/spec.md +228 -228
  35. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
  36. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
  37. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
  38. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
  39. package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
  40. package/package.json +1 -1
  41. package/src/commands/project/update.js +415 -368
  42. package/src/commands/project/worktree.js +212 -210
  43. package/src/commands/state/advance.js +82 -0
  44. package/src/commands/state/archive.js +111 -0
  45. package/src/commands/validation/validate-feature.js +229 -105
  46. package/src/core/state/state-manager.js +238 -66
  47. package/src/dashboard/scanner/state-scanner.js +35 -8
  48. package/src/lib/generators/context-generator.js +12 -8
  49. package/src/utils/file-copier.js +7 -0
@@ -11,11 +11,9 @@
11
11
  * - AskUserQuestion answered with option starting "aprovado" → advance gate
12
12
  */
13
13
 
14
- import { readFileSync, existsSync, renameSync, writeFileSync } from 'fs';
15
- import { join } from 'path';
14
+ import { readFileSync } from 'fs';
16
15
  import { loadState, stateExists } from '../../shared/state-reader.js';
17
-
18
- const STATE_FILE = '.morph/state.json';
16
+ import { persistFeature, setGateApproved, createDefaultFeature } from '../../shared/state-writer.js';
19
17
 
20
18
  const PHASE_FOLDERS = {
21
19
  '0-proposal': 'proposal',
@@ -64,31 +62,12 @@ function extractFilename(filePath) {
64
62
  return normalized.split('/').pop();
65
63
  }
66
64
 
67
- /**
68
- * Atomic write — temp file then rename. Mirrors state-manager.js semantics.
69
- */
70
- function writeStateAtomic(cwd, state) {
71
- const path = join(cwd, STATE_FILE);
72
- const tmpPath = `${path}.tmp.${process.pid}`;
73
- writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
74
- renameSync(tmpPath, path);
75
- }
76
-
77
65
  function registerFeatureIfMissing(state, featureName) {
78
66
  if (!state.features) state.features = {};
79
67
  if (state.features[featureName]) return false;
80
68
 
81
- const now = new Date().toISOString();
82
- state.features[featureName] = {
83
- status: 'in_progress',
84
- createdAt: now,
85
- updatedAt: now,
86
- approvalGates: { proposal: false, plan: false, review: false },
87
- outputs: {},
88
- };
89
-
90
- if (!state.metadata) state.metadata = {};
91
- state.metadata.totalFeatures = Object.keys(state.features).length;
69
+ // Canonical object-form gates (incl. optional uiux) — no boolean drift.
70
+ state.features[featureName] = createDefaultFeature();
92
71
  return true;
93
72
  }
94
73
 
@@ -151,7 +130,9 @@ function handleWriteEdit(input, cwd) {
151
130
  }
152
131
 
153
132
  if (changed) {
154
- writeStateAtomic(cwd, state);
133
+ // Persist the touched feature to its authoritative feature.json and refresh
134
+ // the thin root index. The feature's state thus travels in its own folder.
135
+ persistFeature(featureName, state.features[featureName], cwd);
155
136
  }
156
137
  return true;
157
138
  }
@@ -166,34 +147,21 @@ function handleAskUserQuestion(input, cwd) {
166
147
  const featureName = findActiveFeature(state);
167
148
  if (!featureName) return false;
168
149
 
169
- let changed = false;
170
150
  for (const [question, answer] of Object.entries(answers)) {
171
151
  if (!isApprovalAnswer(answer)) continue;
172
152
  const gate = gateFromQuestion(question);
173
153
  if (!gate) continue;
174
154
 
175
- state.features[featureName].approvalGates ??= {};
176
- const existing = state.features[featureName].approvalGates[gate];
155
+ const existing = state.features[featureName].approvalGates?.[gate];
177
156
  const alreadyApproved = existing === true || existing?.approved === true;
178
- if (!alreadyApproved) {
179
- // An "aprovado" answer to AskUserQuestion is a human approving in the
180
- // loop — record it as such. Write the canonical gate shape
181
- // ({ approved, timestamp, approvedBy }) used by setApprovalGate so the
182
- // audit trail is consistent and a manual approval is distinguishable
183
- // from a policy auto-approval (approvedBy: "auto").
184
- const now = new Date().toISOString();
185
- state.features[featureName].approvalGates[gate] = {
186
- approved: true,
187
- timestamp: now,
188
- approvedBy: 'manual'
189
- };
190
- state.features[featureName].updatedAt = now;
191
- changed = true;
192
- }
193
- }
194
-
195
- if (changed) {
196
- writeStateAtomic(cwd, state);
157
+ if (alreadyApproved) continue;
158
+
159
+ // An "aprovado" answer to AskUserQuestion is a human approving in the loop.
160
+ // Route through the shared canonical gate writer so the hook and the CLI
161
+ // (`morph-spec approve`) produce one consistent shape
162
+ // ({ approved, timestamp, approvedBy }) in the authoritative feature.json —
163
+ // and a manual approval stays distinguishable from a policy auto-approval.
164
+ setGateApproved(featureName, gate, { approvedBy: 'manual' }, cwd);
197
165
  }
198
166
  return true;
199
167
  }
@@ -7,22 +7,62 @@
7
7
  * - Uses synchronous reads for <100ms performance
8
8
  */
9
9
 
10
- import { readFileSync, existsSync } from 'fs';
10
+ import { readFileSync, existsSync, readdirSync } from 'fs';
11
11
  import { join } from 'path';
12
12
 
13
13
  const STATE_FILE = '.morph/state.json';
14
+ const FEATURES_DIR = '.morph/features';
14
15
 
15
16
  /**
16
- * Load state.json from the project root.
17
- * Fail-open: returns null if file missing or corrupt.
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).
18
28
  * @param {string} [projectPath] - Project root (defaults to cwd)
19
29
  * @returns {Object|null} State object or null
20
30
  */
21
31
  export function loadState(projectPath) {
22
32
  try {
23
- const statePath = join(projectPath || process.cwd(), STATE_FILE);
24
- if (!existsSync(statePath)) return null;
25
- return JSON.parse(readFileSync(statePath, 'utf-8'));
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;
26
66
  } catch {
27
67
  return null;
28
68
  }
@@ -119,6 +159,25 @@ export function getFeature(featureName, projectPath) {
119
159
  * @param {string} [projectPath]
120
160
  * @returns {string} Phase name
121
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
+
122
181
  export function derivePhaseForFeature(featureName, projectPath) {
123
182
  const basePath = join(projectPath || process.cwd(), '.morph/features', featureName);
124
183
  const phaseMap = [
@@ -128,10 +187,17 @@ export function derivePhaseForFeature(featureName, projectPath) {
128
187
  ['1-design', 'uiux', 'design-system.md'],
129
188
  ['0-proposal', 'proposal', 'proposal.md'],
130
189
  ];
190
+ let artifactPhase = 'proposal';
131
191
  for (const [folder, phase, canonical] of phaseMap) {
132
- if (existsSync(join(basePath, folder, canonical))) return phase;
192
+ if (existsSync(join(basePath, folder, canonical))) { artifactPhase = phase; break; }
133
193
  }
134
- return 'proposal';
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;
135
201
  }
136
202
 
137
203
  /**
@@ -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
+ }
@@ -1,49 +1,69 @@
1
- ---
2
- stacks:
3
- - "*"
4
- ---
5
-
6
- # MORPH-SPEC Task Tracking
7
-
8
- > Session tasks are MANDATORY in every MORPH-SPEC phase. They track real progress and must be
9
- > created BEFORE any phase work begins.
10
-
11
- ## Rules
12
-
13
- 1. **Every phase skill MUST create session tasks** using `TaskCreate` before reading files or generating outputs
14
- 2. **Tasks are dynamic** — analyze the phase workflow and create tasks that reflect YOUR actual planned actions for this session
15
- 3. **Every task MUST be updated**: `in_progress` before starting, `completed` after finishing
16
- 4. **Pipeline orchestrators** (`/morph-proposal`, `/morph-apply`) MUST create master tasks for each phase
17
- 5. **Never skip task creation** — even if resuming mid-phase, create tasks for remaining steps
18
- 6. **Granularity**: minimum 2, typically 3-7 tasks per phase — adjust to complexity
19
- 7. **For iterative work** (clarification rounds, quality loops): update task `subject` with round/score progress
20
-
21
- ## Task Update Pattern
22
-
23
- ```
24
- TaskUpdate(taskId, status="in_progress") // before starting the step
25
- // ... do the work ...
26
- TaskUpdate(taskId, status="completed") // after step finishes
27
- ```
28
-
29
- For iterative tasks:
30
- ```
31
- TaskUpdate(taskId, subject="Round 3 — score 82/100")
32
- ```
33
-
34
- ## Deviation Tracking
35
-
36
- When completing a task differently than originally planned, record the reason:
37
-
38
- ```
39
- morph-spec task done <feature> <taskId> --note "Reason for deviation"
40
- ```
41
-
42
- For after-the-fact annotation (supports multiple types):
43
- ```
44
- morph-spec task annotate <feature> <taskId> "Reason" --type deviation
45
- morph-spec task annotate <feature> <taskId> "Context info" --type context
46
- morph-spec task annotate <feature> <taskId> "Resolved" --type blocker-resolved
47
- ```
48
-
49
- Note types: `deviation` (default), `context`, `blocker-resolved`
1
+ ---
2
+ stacks:
3
+ - "*"
4
+ ---
5
+
6
+ # MORPH-SPEC Task Tracking
7
+
8
+ > Session tasks are MANDATORY in every MORPH-SPEC phase. They track real progress and must be
9
+ > created BEFORE any phase work begins.
10
+
11
+ ## Rules
12
+
13
+ 1. **Every phase skill MUST create session tasks** using `TaskCreate` before reading files or generating outputs
14
+ 2. **Tasks are dynamic** — analyze the phase workflow and create tasks that reflect YOUR actual planned actions for this session
15
+ 3. **Every task MUST be updated**: `in_progress` before starting, `completed` after finishing
16
+ 4. **Pipeline orchestrators** (`/morph-proposal`, `/morph-apply`) MUST create master tasks for each phase
17
+ 5. **Never skip task creation** — even if resuming mid-phase, create tasks for remaining steps
18
+ 6. **Granularity**: minimum 2, typically 3-7 tasks per phase — adjust to complexity
19
+ 7. **For iterative work** (clarification rounds, quality loops): update task `subject` with round/score progress
20
+
21
+ ## Task Update Pattern
22
+
23
+ ```
24
+ TaskUpdate(taskId, status="in_progress") // before starting the step
25
+ // ... do the work ...
26
+ TaskUpdate(taskId, status="completed") // after step finishes
27
+ ```
28
+
29
+ For iterative tasks:
30
+ ```
31
+ TaskUpdate(taskId, subject="Round 3 — score 82/100")
32
+ ```
33
+
34
+ ## Persisting Progress to `tasks.json`
35
+
36
+ Native session tasks (`TaskCreate`/`TaskUpdate`) track your live work. To persist
37
+ task progress for the feature, write `.morph/features/{feature}/2-plan/tasks.json`,
38
+ touching ONLY the mutable fields:
39
+
40
+ - `status` — `pending` → `in_progress` → `completed`
41
+ - `outputs` — the files the task produced
42
+ - `notes` — free-text record (deviations, context, resolved blockers)
43
+
44
+ The plan contract — `title`, `description`, `dependencies`, `effort`, `doneCriteria`,
45
+ `group` — is frozen after Gate 2. Any attempt to change it is blocked by the
46
+ `protect-spec-files` hook.
47
+
48
+ Use the `Write` tool (whole-file rewrite) OR the `Edit` tool — `Edit` is allowed
49
+ as long as it touches only `status`, `outputs`, and `notes`. State is then synced
50
+ automatically by the `state-sync` PostToolUse hook; you do not call a CLI to mark
51
+ output.
52
+
53
+ ## Deviation Tracking
54
+
55
+ When completing a task differently than originally planned, record the reason in the
56
+ task's `notes` field. Use the `notes` array to capture deviations, added context, or
57
+ resolved blockers — for example:
58
+
59
+ ```json
60
+ {
61
+ "id": "T3",
62
+ "status": "completed",
63
+ "outputs": ["Features/User/Update/Handler.cs"],
64
+ "notes": [
65
+ "deviation: split validation into a separate validator for testability",
66
+ "blocker-resolved: EF migration conflict fixed by regenerating the snapshot"
67
+ ]
68
+ }
69
+ ```