@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.
- package/README.md +19 -18
- package/docs/CHEATSHEET.md +3 -3
- package/docs/QUICKSTART.md +4 -4
- package/framework/CLAUDE.md +13 -0
- package/framework/MORPH.md +57 -36
- package/framework/commands/morph-apply.md +4 -21
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-proposal.md +2 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +1 -1
- package/framework/hooks/claude-code/core/protect-spec-files.js +1 -1
- package/framework/hooks/claude-code/core/state-sync.js +212 -0
- package/framework/hooks/shared/phase-utils.js +5 -4
- package/framework/hooks/shared/state-reader.js +1 -1
- package/framework/phases.json +1 -1
- package/framework/rules/morph-workflow.md +15 -34
- package/framework/schemas/tasks.schema.json +35 -0
- package/framework/skills/core/morph-apply/SKILL.md +154 -0
- package/framework/skills/core/morph-brainstorming/SKILL.md +53 -30
- package/framework/skills/core/morph-implement/SKILL.md +25 -14
- package/framework/skills/core/morph-plan/SKILL.md +91 -23
- package/framework/skills/core/morph-proposal/SKILL.md +137 -0
- package/framework/skills/core/morph-review/SKILL.md +43 -7
- package/framework/skills/core/morph-stack-scan/SKILL.md +40 -1
- package/framework/standards/STANDARDS.json +4 -5
- package/framework/templates/docs/onboarding.md +4 -4
- package/framework/templates/tasks.template.json +17 -0
- package/package.json +1 -1
- package/src/commands/project/init.js +16 -0
- package/src/commands/validation/validate-feature.js +1 -1
- package/src/core/paths/output-schema.js +6 -6
- package/src/core/state/state-manager.js +1 -1
- package/src/lib/detectors/design-system-detector.js +1 -1
- package/src/lib/installers/memory-installer.js +138 -0
- package/src/lib/phase-chain/phase-validator.js +4 -4
- package/src/lib/risk-detector.js +80 -0
- package/src/lib/tasks/tasks-json-parser.js +185 -0
- package/src/scripts/setup-infra.js +25 -0
- 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
|
-
*
|
|
26
|
-
*
|
|
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
|
|
29
|
-
//
|
|
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
|
|
217
|
+
// Install native permissions (idempotent — preserve user-added entries)
|
|
183
218
|
settings.permissions = settings.permissions || {};
|
|
184
219
|
settings.permissions.deny = settings.permissions.deny || [];
|
|
185
|
-
|
|
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 => !
|
|
229
|
+
p => !MORPH_DENY.includes(p) && !STALE_DENY.includes(p)
|
|
188
230
|
);
|
|
189
|
-
for (const perm of
|
|
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
|
|