@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.
- package/README.md +2 -2
- package/bin/morph-spec.js +18 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +9 -4
- package/framework/MORPH.md +4 -4
- package/framework/agents.json +1 -1
- package/framework/commands/create-umbraco-lp.md +35 -0
- package/framework/commands/migrate-lp-to-umbraco.md +33 -0
- package/framework/commands/morph-archive.md +17 -27
- package/framework/commands/morph-preflight.md +10 -5
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
- package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
- package/framework/hooks/claude-code/core/state-sync.js +16 -48
- package/framework/hooks/shared/state-reader.js +74 -8
- package/framework/hooks/shared/state-writer.js +124 -0
- package/framework/rules/infrastructure-standards.md +4 -4
- package/framework/rules/morph-task-tracking.md +69 -49
- package/framework/rules/morph-workflow.md +192 -191
- package/framework/skills/core/create-umbraco-lp/SKILL.md +272 -0
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +531 -0
- package/framework/skills/core/morph-apply/SKILL.md +15 -15
- package/framework/skills/core/morph-brainstorming/SKILL.md +5 -5
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +5 -4
- package/framework/skills/core/morph-plan/SKILL.md +6 -6
- package/framework/skills/core/morph-proposal/SKILL.md +8 -7
- package/framework/skills/core/morph-review/SKILL.md +3 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +80 -8
- package/framework/standards/ai-agents/durable-workflows-hitl.md +191 -0
- package/framework/standards/ai-agents/rag-custom-pgvector.md +246 -243
- package/framework/standards/ai-agents/setup.md +3 -0
- package/framework/standards/ai-agents/vector-data-extensions.md +170 -0
- package/framework/standards/ai-agents/workflows.md +190 -187
- package/framework/standards/infrastructure/docker/{easypanel-deploy.md → coolify-deploy.md} +49 -20
- package/framework/templates/README.md +7 -7
- package/framework/templates/REGISTRY.json +15 -17
- package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
- package/framework/templates/context/CONTEXT.md +181 -180
- package/framework/templates/docs/onboarding.md +122 -118
- package/framework/templates/docs/proposal.md +12 -2
- package/framework/templates/docs/spec.md +228 -228
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +1 -1
- package/framework/templates/infrastructure/docker/coolify.template.json +49 -0
- package/framework/templates/infrastructure/github/README.md +6 -6
- package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +33 -0
- package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
- package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
- package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
- package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
- package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
- package/package.json +1 -1
- package/src/commands/project/update.js +415 -368
- package/src/commands/project/worktree.js +212 -210
- package/src/commands/state/advance.js +82 -0
- package/src/commands/state/archive.js +111 -0
- package/src/commands/validation/validate-feature.js +229 -105
- package/src/core/state/state-manager.js +238 -66
- package/src/dashboard/scanner/state-scanner.js +35 -8
- package/src/lib/generators/context-generator.js +12 -8
- package/src/utils/file-copier.js +7 -0
- package/framework/templates/infrastructure/docker/easypanel.template.json +0 -46
- package/framework/templates/infrastructure/github/workflows/deploy-easypanel.yml.hbs +0 -31
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Semantic exception for files in MUTABLE_SPEC_FIELDS (e.g. tasks.json):
|
|
13
13
|
* the plan contract is frozen, but per-task progress fields (status, outputs,
|
|
14
|
-
* notes) may still change during the implement phase. For those files a
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* notes) may still change during the implement phase. For those files BOTH a
|
|
15
|
+
* full Write and a targeted Edit are allowed — but only when the semantic diff
|
|
16
|
+
* touches solely the mutable fields. The Edit is simulated (old_string →
|
|
17
|
+
* new_string applied to the current file) and diffed exactly like a Write, so a
|
|
18
|
+
* one-field status flip via Edit is permitted while any change to the frozen
|
|
19
|
+
* plan contract (titles, dependencies, doneCriteria, the task set) is blocked.
|
|
20
|
+
* This is the sanctioned progress path — the agent never has to rewrite the
|
|
21
|
+
* whole file nor reach for a Node script to record task status.
|
|
19
22
|
*
|
|
20
23
|
* Fail-open: exits 0 on any error.
|
|
21
24
|
*/
|
|
@@ -129,40 +132,39 @@ try {
|
|
|
129
132
|
// that touches only those fields. Anything else stays blocked.
|
|
130
133
|
if (mutableFields) {
|
|
131
134
|
const toolName = getToolName(payload);
|
|
132
|
-
const newContent = payload?.tool_input?.content;
|
|
133
|
-
|
|
134
|
-
// Edit is never the legitimate progress path — progress is written as a
|
|
135
|
-
// full file via writeTasksJson(). Block Edit; fall through to the message.
|
|
136
|
-
if (toolName !== 'Write' || typeof newContent !== 'string') {
|
|
137
|
-
logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
|
|
138
|
-
block(
|
|
139
|
-
`MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
|
|
140
|
-
`Progress updates (${mutableFields.join(', ')}) must be written as a full-file\n` +
|
|
141
|
-
`Write, not an Edit. The plan contract (titles, dependencies, doneCriteria…)\n` +
|
|
142
|
-
`is frozen. To change the plan itself, ask the user to revoke the\n` +
|
|
143
|
-
`'${requiredGate}' gate in .morph/state.json, then re-present it for approval.`
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
135
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
136
|
+
// Build the proposed document from either a full Write or a targeted Edit,
|
|
137
|
+
// then semantic-diff it against the current file on disk. Any failure to
|
|
138
|
+
// read / parse / apply → fail-open (pass), never a false block.
|
|
139
|
+
const absPath = isAbsolute(filePath) ? filePath : join(process.cwd(), filePath);
|
|
149
140
|
let oldDoc, newDoc;
|
|
150
141
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
142
|
+
const original = readFileSync(absPath, 'utf-8');
|
|
143
|
+
oldDoc = JSON.parse(original);
|
|
144
|
+
|
|
145
|
+
if (toolName === 'Edit') {
|
|
146
|
+
// Simulate the edit: apply old_string → new_string to the current file.
|
|
147
|
+
const oldStr = payload?.tool_input?.old_string;
|
|
148
|
+
const newStr = payload?.tool_input?.new_string;
|
|
149
|
+
if (typeof oldStr !== 'string' || typeof newStr !== 'string') throw new Error('no edit strings');
|
|
150
|
+
const edited = payload?.tool_input?.replace_all
|
|
151
|
+
? original.split(oldStr).join(newStr)
|
|
152
|
+
: original.replace(oldStr, newStr);
|
|
153
|
+
newDoc = JSON.parse(edited);
|
|
154
|
+
} else {
|
|
155
|
+
const newContent = payload?.tool_input?.content;
|
|
156
|
+
if (typeof newContent !== 'string') throw new Error('no content');
|
|
157
|
+
newDoc = JSON.parse(newContent);
|
|
158
|
+
}
|
|
156
159
|
} catch {
|
|
157
|
-
// Cannot compare — fail-open rather than block a possibly-valid write.
|
|
158
160
|
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
159
161
|
pass();
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
const violations = diffPlanContract(oldDoc, newDoc, mutableFields);
|
|
163
165
|
if (violations.length === 0) {
|
|
164
|
-
// Only mutable progress fields changed —
|
|
165
|
-
//
|
|
166
|
+
// Only mutable progress fields changed — a legitimate implement-phase
|
|
167
|
+
// update via Write OR Edit.
|
|
166
168
|
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
167
169
|
pass();
|
|
168
170
|
}
|
|
@@ -170,7 +172,7 @@ try {
|
|
|
170
172
|
logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
|
|
171
173
|
block(
|
|
172
174
|
`MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
|
|
173
|
-
`This
|
|
175
|
+
`This ${toolName} changes the frozen plan contract:\n` +
|
|
174
176
|
violations.map(v => ` • ${v}`).join('\n') + '\n\n' +
|
|
175
177
|
`During the implement phase only these fields may change: ${mutableFields.join(', ')}.\n` +
|
|
176
178
|
`To change the plan itself, ask the user to revoke the '${requiredGate}' gate\n` +
|
|
@@ -11,11 +11,9 @@
|
|
|
11
11
|
* - AskUserQuestion answered with option starting "aprovado" → advance gate
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { readFileSync
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
17
|
-
*
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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)))
|
|
192
|
+
if (existsSync(join(basePath, folder, canonical))) { artifactPhase = phase; break; }
|
|
133
193
|
}
|
|
134
|
-
|
|
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
|
+
}
|
|
@@ -3,14 +3,14 @@ paths:
|
|
|
3
3
|
- "**/Dockerfile*"
|
|
4
4
|
- "**/docker-compose*"
|
|
5
5
|
- "**/.github/**"
|
|
6
|
-
- "**/
|
|
6
|
+
- "**/coolify*"
|
|
7
7
|
stacks:
|
|
8
8
|
- docker
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# Infrastructure Standards
|
|
12
12
|
|
|
13
|
-
@.morph/framework/standards/infrastructure/docker/
|
|
13
|
+
@.morph/framework/standards/infrastructure/docker/coolify-deploy.md
|
|
14
14
|
|
|
15
15
|
## Infrastructure Templates
|
|
16
16
|
|
|
@@ -19,9 +19,9 @@ stacks:
|
|
|
19
19
|
| .NET API Dockerfile | `.morph/framework/templates/infrastructure/docker/dockerfile-api.dockerfile` | Multi-stage .NET |
|
|
20
20
|
| Next.js Dockerfile | `.morph/framework/templates/infrastructure/docker/dockerfile-web.dockerfile` | Multi-stage Next.js |
|
|
21
21
|
| Docker Compose | `.morph/framework/templates/infrastructure/docker/docker-compose.template.yml` | Local dev env |
|
|
22
|
-
|
|
|
22
|
+
| Coolify Config | `.morph/framework/templates/infrastructure/docker/coolify.template.json` | Deployment config |
|
|
23
23
|
| .NET Build CI | `.morph/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs` | CI workflow |
|
|
24
24
|
| Docker Push CI | `.morph/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs` | Build & push |
|
|
25
|
-
| Deploy
|
|
25
|
+
| Deploy Coolify | `.morph/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs` | CD via SSH |
|
|
26
26
|
|
|
27
27
|
Read the template and adapt to your project structure.
|
|
@@ -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
|
-
##
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
```
|