@polymorphism-tech/morph-spec 6.0.0 → 7.0.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 (38) hide show
  1. package/README.md +19 -18
  2. package/docs/CHEATSHEET.md +3 -3
  3. package/docs/QUICKSTART.md +4 -4
  4. package/framework/CLAUDE.md +13 -0
  5. package/framework/MORPH.md +57 -36
  6. package/framework/commands/morph-apply.md +4 -21
  7. package/framework/commands/morph-archive.md +1 -1
  8. package/framework/commands/morph-proposal.md +2 -0
  9. package/framework/hooks/claude-code/core/enforce-phase-writes.js +1 -1
  10. package/framework/hooks/claude-code/core/protect-spec-files.js +1 -1
  11. package/framework/hooks/claude-code/core/state-sync.js +212 -0
  12. package/framework/hooks/shared/phase-utils.js +5 -4
  13. package/framework/hooks/shared/state-reader.js +1 -1
  14. package/framework/phases.json +1 -1
  15. package/framework/rules/morph-workflow.md +15 -34
  16. package/framework/schemas/tasks.schema.json +35 -0
  17. package/framework/skills/core/morph-apply/SKILL.md +154 -0
  18. package/framework/skills/core/morph-brainstorming/SKILL.md +53 -30
  19. package/framework/skills/core/morph-implement/SKILL.md +25 -14
  20. package/framework/skills/core/morph-plan/SKILL.md +91 -23
  21. package/framework/skills/core/morph-proposal/SKILL.md +137 -0
  22. package/framework/skills/core/morph-review/SKILL.md +43 -7
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +40 -1
  24. package/framework/standards/STANDARDS.json +4 -5
  25. package/framework/templates/docs/onboarding.md +4 -4
  26. package/framework/templates/tasks.template.json +17 -0
  27. package/package.json +1 -1
  28. package/src/commands/project/init.js +16 -0
  29. package/src/commands/validation/validate-feature.js +1 -1
  30. package/src/core/paths/output-schema.js +6 -6
  31. package/src/core/state/state-manager.js +1 -1
  32. package/src/lib/detectors/design-system-detector.js +1 -1
  33. package/src/lib/installers/memory-installer.js +138 -0
  34. package/src/lib/phase-chain/phase-validator.js +4 -4
  35. package/src/lib/risk-detector.js +80 -0
  36. package/src/lib/tasks/tasks-json-parser.js +185 -0
  37. package/src/scripts/setup-infra.js +25 -0
  38. package/src/utils/hooks-installer.js +59 -10
@@ -0,0 +1,185 @@
1
+ /**
2
+ * tasks-json-parser.js — read, validate, query, mutate tasks.json
3
+ *
4
+ * Replaces the prior Markdown-based tasks.md parser for new features.
5
+ * Legacy features that still use tasks.md are handled by task-parser.js.
6
+ *
7
+ * Uses a minimal hand-rolled JSON Schema validator (no AJV dep) — sufficient
8
+ * for the controlled schema in framework/schemas/tasks.schema.json.
9
+ */
10
+
11
+ import fs from 'fs-extra';
12
+ import { readFileSync, existsSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const SCHEMA_PATH = join(__dirname, '..', '..', '..', 'framework', 'schemas', 'tasks.schema.json');
18
+
19
+ let cachedSchema = null;
20
+ export function getSchema() {
21
+ if (cachedSchema) return cachedSchema;
22
+ cachedSchema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
23
+ return cachedSchema;
24
+ }
25
+
26
+ const VALID_STATUS = ['pending', 'in_progress', 'done', 'skipped', 'blocked'];
27
+ const VALID_EFFORT = ['S', 'M', 'L', 'XL'];
28
+ const VALID_GROUP = ['backend', 'frontend', 'tests', 'infra', 'docs'];
29
+
30
+ /**
31
+ * Validate a tasks.json structure.
32
+ * @returns {{ valid: boolean, errors: string[] }}
33
+ */
34
+ export function validateTasksJson(data) {
35
+ const errors = [];
36
+ if (!data || typeof data !== 'object') {
37
+ errors.push('tasks.json must be an object');
38
+ return { valid: false, errors };
39
+ }
40
+ if (data.version !== '1.0') errors.push('version must be "1.0"');
41
+ if (typeof data.feature !== 'string' || !data.feature) {
42
+ errors.push('feature must be a non-empty string');
43
+ }
44
+ if (!Array.isArray(data.tasks)) {
45
+ errors.push('tasks must be an array');
46
+ return { valid: false, errors };
47
+ }
48
+
49
+ const ids = new Set();
50
+ for (const [i, task] of data.tasks.entries()) {
51
+ const ctx = `tasks[${i}]`;
52
+ if (!task.id || !/^T\d+$/.test(task.id)) {
53
+ errors.push(`${ctx}.id must match /^T\\d+$/`);
54
+ } else if (ids.has(task.id)) {
55
+ errors.push(`${ctx}.id duplicate: ${task.id}`);
56
+ }
57
+ ids.add(task.id);
58
+
59
+ if (typeof task.title !== 'string' || !task.title) errors.push(`${ctx}.title required`);
60
+ if (typeof task.description !== 'string' || !task.description) errors.push(`${ctx}.description required`);
61
+ if (!Array.isArray(task.dependencies)) {
62
+ errors.push(`${ctx}.dependencies must be array`);
63
+ } else {
64
+ for (const dep of task.dependencies) {
65
+ if (typeof dep !== 'string' || !/^T\d+$/.test(dep)) {
66
+ errors.push(`${ctx}.dependencies[${dep}] must match /^T\\d+$/`);
67
+ }
68
+ }
69
+ }
70
+ if (!VALID_EFFORT.includes(task.effort)) {
71
+ errors.push(`${ctx}.effort must be one of ${VALID_EFFORT.join(', ')}`);
72
+ }
73
+ if (typeof task.doneCriteria !== 'string' || !task.doneCriteria) {
74
+ errors.push(`${ctx}.doneCriteria required`);
75
+ }
76
+ if (!VALID_STATUS.includes(task.status)) {
77
+ errors.push(`${ctx}.status must be one of ${VALID_STATUS.join(', ')}`);
78
+ }
79
+ if (task.group !== undefined && !VALID_GROUP.includes(task.group)) {
80
+ errors.push(`${ctx}.group must be one of ${VALID_GROUP.join(', ')}`);
81
+ }
82
+ }
83
+
84
+ return { valid: errors.length === 0, errors };
85
+ }
86
+
87
+ /**
88
+ * Read and parse tasks.json from disk. Throws on validation errors.
89
+ * @param {string} filePath - Absolute path to tasks.json
90
+ * @returns {Promise<object>}
91
+ */
92
+ export async function parseTasksJson(filePath) {
93
+ const data = await fs.readJson(filePath);
94
+ const { valid, errors } = validateTasksJson(data);
95
+ if (!valid) {
96
+ throw new Error(`Invalid tasks.json:\n ${errors.join('\n ')}`);
97
+ }
98
+ return data;
99
+ }
100
+
101
+ /**
102
+ * Synchronous variant — used by hooks.
103
+ */
104
+ export function parseTasksJsonSync(filePath) {
105
+ if (!existsSync(filePath)) {
106
+ throw new Error(`tasks.json not found: ${filePath}`);
107
+ }
108
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
109
+ const { valid, errors } = validateTasksJson(data);
110
+ if (!valid) {
111
+ throw new Error(`Invalid tasks.json:\n ${errors.join('\n ')}`);
112
+ }
113
+ return data;
114
+ }
115
+
116
+ /**
117
+ * @returns {object[]} tasks with status === 'pending'
118
+ */
119
+ export function getPendingTasks(data) {
120
+ return data.tasks.filter(t => t.status === 'pending');
121
+ }
122
+
123
+ /**
124
+ * Tasks ready to run: pending AND all dependencies are 'done'.
125
+ */
126
+ export function getReadyTasks(data) {
127
+ const doneIds = new Set(data.tasks.filter(t => t.status === 'done').map(t => t.id));
128
+ return data.tasks.filter(t =>
129
+ t.status === 'pending' && t.dependencies.every(dep => doneIds.has(dep))
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Mutate `data` to set task status. Returns `data` for chaining.
135
+ */
136
+ export function updateTaskStatus(data, taskId, newStatus) {
137
+ if (!VALID_STATUS.includes(newStatus)) {
138
+ throw new Error(`Invalid status: ${newStatus}`);
139
+ }
140
+ const task = data.tasks.find(t => t.id === taskId);
141
+ if (!task) throw new Error(`Task not found: ${taskId}`);
142
+ task.status = newStatus;
143
+ return data;
144
+ }
145
+
146
+ /**
147
+ * Write tasks.json to disk. Validates before writing.
148
+ */
149
+ export async function writeTasksJson(filePath, data) {
150
+ const { valid, errors } = validateTasksJson(data);
151
+ if (!valid) {
152
+ throw new Error(`Cannot write invalid tasks.json:\n ${errors.join('\n ')}`);
153
+ }
154
+ await fs.writeJson(filePath, data, { spaces: 2 });
155
+ }
156
+
157
+ /**
158
+ * Group tasks by `group` field, falling back to 'backend' when absent.
159
+ * Returns `{ [group]: taskId[] }` shape matching the legacy parseAndGroupTasks output.
160
+ */
161
+ export function groupTasks(data) {
162
+ const groups = {};
163
+ for (const task of data.tasks) {
164
+ const group = task.group || 'backend';
165
+ if (!groups[group]) groups[group] = [];
166
+ groups[group].push(task.id);
167
+ }
168
+ return groups;
169
+ }
170
+
171
+ /**
172
+ * Convert tasks.json to the legacy taskList shape used in state.json.
173
+ * Lets the rest of the codebase keep using feature.taskList while migration
174
+ * is in progress.
175
+ */
176
+ export function toTaskList(data) {
177
+ return data.tasks.map(t => ({
178
+ id: t.id,
179
+ title: t.title,
180
+ status: t.status === 'done' ? 'completed' : t.status,
181
+ dependencies: [...t.dependencies],
182
+ files: t.outputs || [],
183
+ checkpoint: null,
184
+ }));
185
+ }
@@ -185,6 +185,7 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
185
185
  const config = {
186
186
  framework: 'global',
187
187
  frameworkVersion: getInstalledCLIVersion(),
188
+ trust: 'auto',
188
189
  project: {
189
190
  name: dirName
190
191
  }
@@ -243,6 +244,30 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
243
244
  await copyFile(agentsSrc, agentsDest);
244
245
  }
245
246
 
247
+ // --- 8b. Copy framework/MORPH.md → .morph/framework/MORPH.md ---
248
+ log('Step 8b: Copying MORPH.md (NLH charter)...');
249
+ const morphMdSrc = join(FRAMEWORK_DIR, 'MORPH.md');
250
+ const morphMdDest = join(frameworkDestDir, 'MORPH.md');
251
+ if (await pathExists(morphMdSrc)) {
252
+ await copyFile(morphMdSrc, morphMdDest);
253
+ }
254
+
255
+ // --- 8c. Copy framework/phases.json → .morph/framework/phases.json ---
256
+ log('Step 8c: Copying phases.json...');
257
+ const phasesSrc = join(FRAMEWORK_DIR, 'phases.json');
258
+ const phasesDest = join(frameworkDestDir, 'phases.json');
259
+ if (await pathExists(phasesSrc)) {
260
+ await copyFile(phasesSrc, phasesDest);
261
+ }
262
+
263
+ // --- 8d. Copy framework/schemas → .morph/framework/schemas (JSON schemas) ---
264
+ log('Step 8d: Copying schemas...');
265
+ const schemasSrc = join(FRAMEWORK_DIR, 'schemas');
266
+ const schemasDest = join(frameworkDestDir, 'schemas');
267
+ if (await pathExists(schemasSrc)) {
268
+ await copyDirectory(schemasSrc, schemasDest);
269
+ }
270
+
246
271
  // --- 9. Copy framework/commands → .claude/commands ---
247
272
  log('Step 9: Setting up Claude Code integration...');
248
273
  const claudeDest = join(targetPath, '.claude');
@@ -22,13 +22,13 @@ const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
22
22
 
23
23
  /**
24
24
  * Native permissions.deny entries managed by MORPH-SPEC.
25
- * These replace the protect-readonly-files.js PreToolUse hook,
26
- * using Claude Code's built-in permissions system instead.
25
+ *
26
+ * state.json is NOT in deny because the agent edits it directly as a
27
+ * fallback to the state-sync hook (per v7 hybrid state model). The
28
+ * framework/ tree remains read-only — only the installer touches it.
27
29
  */
28
- const MORPH_PERMISSIONS = [
29
- // Core framework state and readonly files
30
- 'Write(.morph/state.json)',
31
- 'Edit(.morph/state.json)',
30
+ const MORPH_DENY = [
31
+ // Framework files — installer-managed, immutable at runtime
32
32
  'Write(.morph/framework/**)',
33
33
  'Edit(.morph/framework/**)',
34
34
  // .claude/CLAUDE.md (managed copy — source is framework/CLAUDE.md)
@@ -45,6 +45,30 @@ const MORPH_PERMISSIONS = [
45
45
  'Edit(.claude/skills/**)',
46
46
  ];
47
47
 
48
+ /**
49
+ * Native permissions.allow entries managed by MORPH-SPEC.
50
+ *
51
+ * Pre-authorizes operations the agent legitimately performs during the
52
+ * morph-spec flow, so the user does not get permission prompts on every
53
+ * proposal.md / spec.md / tasks.json write.
54
+ */
55
+ const MORPH_ALLOW = [
56
+ // Feature artifacts — the agent's working directory during proposal/plan/implement/review
57
+ 'Write(.morph/features/**)',
58
+ 'Edit(.morph/features/**)',
59
+ // state.json — fallback path when state-sync hook can't cover a case
60
+ 'Edit(.morph/state.json)',
61
+ // Long-lived architectural context — agent may reconcile during brainstorming
62
+ 'Write(.morph/context/**)',
63
+ 'Edit(.morph/context/**)',
64
+ // Folder creation under .morph
65
+ 'Bash(mkdir:.morph/**)',
66
+ 'Bash(ls:.morph/**)',
67
+ ];
68
+
69
+ // Backwards-compat alias used by older tests
70
+ const MORPH_PERMISSIONS = MORPH_DENY;
71
+
48
72
  /**
49
73
  * All MORPH-SPEC hook definitions.
50
74
  * Each entry maps to a Claude Code hook event.
@@ -88,6 +112,17 @@ const MORPH_HOOKS = [
88
112
  }]
89
113
  },
90
114
 
115
+ // === PostToolUse: Write|Edit|AskUserQuestion — state-sync (autonomous state.json updates) ===
116
+ {
117
+ event: 'PostToolUse',
118
+ matcher: 'Write|Edit|AskUserQuestion',
119
+ hooks: [{
120
+ type: 'command',
121
+ command: 'node framework/hooks/claude-code/core/state-sync.js',
122
+ timeout: 3000
123
+ }]
124
+ },
125
+
91
126
  // === PostToolUse: All — loop detection ===
92
127
  {
93
128
  event: 'PostToolUse',
@@ -179,17 +214,31 @@ export async function installClaudeHooks(targetPath) {
179
214
  installed++;
180
215
  }
181
216
 
182
- // Install native permissions.deny entries (idempotent)
217
+ // Install native permissions (idempotent — preserve user-added entries)
183
218
  settings.permissions = settings.permissions || {};
184
219
  settings.permissions.deny = settings.permissions.deny || [];
185
- // Remove previously managed permissions to avoid duplicates on re-install
220
+ settings.permissions.allow = settings.permissions.allow || [];
221
+
222
+ // Stale-entry cleanup: remove old morph-managed deny entries (e.g. state.json
223
+ // was previously in deny — v7 moves it to allow)
224
+ const STALE_DENY = [
225
+ 'Write(.morph/state.json)',
226
+ 'Edit(.morph/state.json)',
227
+ ];
186
228
  settings.permissions.deny = settings.permissions.deny.filter(
187
- p => !MORPH_PERMISSIONS.includes(p)
229
+ p => !MORPH_DENY.includes(p) && !STALE_DENY.includes(p)
188
230
  );
189
- for (const perm of MORPH_PERMISSIONS) {
231
+ for (const perm of MORPH_DENY) {
190
232
  settings.permissions.deny.push(perm);
191
233
  }
192
234
 
235
+ settings.permissions.allow = settings.permissions.allow.filter(
236
+ p => !MORPH_ALLOW.includes(p)
237
+ );
238
+ for (const perm of MORPH_ALLOW) {
239
+ settings.permissions.allow.push(perm);
240
+ }
241
+
193
242
  // JSON Schema for IDE auto-complete and validation
194
243
  settings['$schema'] = 'https://json.schemastore.org/claude-code-settings.json';
195
244