@polymorphism-tech/morph-spec 9.6.0 → 9.7.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 +12 -5
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +3 -3
- package/framework/docs/CLI.md +35 -7
- package/framework/evals/review-checklist.md +2 -2
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/README.md +4 -0
- package/framework/hooks/claude-code/core/test-budget.js +89 -0
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/heavy-command.js +60 -1
- package/framework/hooks/shared/host-services.js +433 -0
- package/framework/schemas/config.schema.json +53 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +2 -2
- package/framework/skills/core/morph-review/SKILL.md +3 -2
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +2 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +14 -1
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +47 -24
- package/src/commands/observability/cost.js +12 -0
- package/src/commands/project/worktree.js +7 -3
- package/src/commands/state/advance.js +3 -0
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/delete.js +1 -1
- package/src/commands/state/finish.js +3 -1
- package/src/commands/validation/plan-check.js +1 -0
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/e2e/stack-teardown.js +19 -4
- package/src/lib/review-reports.js +21 -10
- package/src/lib/tasks/test-runner.js +3 -0
- package/src/lib/telemetry/metrics.js +47 -3
- package/src/lib/validators/packages/package-validator.js +2 -2
- package/src/utils/agents-installer.js +4 -1
- package/src/utils/file-copier.js +26 -4
- package/src/utils/hooks-installer.js +4 -4
- package/framework/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// Host processes that the e2e stack runs next to compose (api, portal), one PID file per project.
|
|
2
|
+
// Nothing here imports e2e-check.js: it imports this module, and the timing constants mirror its own.
|
|
3
|
+
|
|
4
|
+
import { execFileSync, spawn, spawnSync } from 'child_process';
|
|
5
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
6
|
+
import { join, resolve } from 'path';
|
|
7
|
+
|
|
8
|
+
import { tokenize } from './command-tokens.js';
|
|
9
|
+
import { pidAlive } from './process-alive.js';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_HOST_TIMEOUT = 180_000;
|
|
12
|
+
|
|
13
|
+
const HEALTH_POLL_MS = 2_000;
|
|
14
|
+
|
|
15
|
+
const OUTPUT_TAIL_LINES = 20;
|
|
16
|
+
|
|
17
|
+
const NO_HEALTH_GRACE_MS = 2_000;
|
|
18
|
+
|
|
19
|
+
const KILL_WAIT_MS = 5_000;
|
|
20
|
+
|
|
21
|
+
const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
22
|
+
|
|
23
|
+
export function hostStatePath(gitCommonDir, projectName) {
|
|
24
|
+
return join(gitCommonDir, 'morph-spec', `e2e-host-${projectName}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hostLogPath(gitCommonDir, projectName, name) {
|
|
28
|
+
return join(gitCommonDir, 'morph-spec', `e2e-host-${projectName}-${name}.log`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Process creation time as the OS reports it, or null when the PID is gone. */
|
|
32
|
+
export function readProcessCreatedAt(pid, platform = process.platform) {
|
|
33
|
+
try {
|
|
34
|
+
const out = platform === 'win32'
|
|
35
|
+
? execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
|
|
36
|
+
`(Get-Process -Id ${Number(pid)}).StartTime.ToFileTimeUtc()`],
|
|
37
|
+
{ encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
38
|
+
: execFileSync('ps', ['-o', 'lstart=', '-p', String(Number(pid))],
|
|
39
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
40
|
+
return out.trim() || null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolves `${VAR}` in `env` and `health`: stack env first, then `process.env`. */
|
|
47
|
+
export function interpolateEnv(service, stackEnv = {}) {
|
|
48
|
+
const missing = new Set();
|
|
49
|
+
const sub = (text) => String(text).replace(VAR_RE, (_, name) => {
|
|
50
|
+
const value = stackEnv?.[name] ?? process.env[name];
|
|
51
|
+
if (value === undefined || value === null || value === '') {
|
|
52
|
+
missing.add(name);
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
return String(value);
|
|
56
|
+
});
|
|
57
|
+
const env = {};
|
|
58
|
+
for (const [key, value] of Object.entries(service.env ?? {})) env[key] = sub(value);
|
|
59
|
+
const health = service.health ? sub(service.health) : null;
|
|
60
|
+
return { env, health, missing: [...missing] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SERVICE_KEYS = new Set(['name', 'cwd', 'build', 'start', 'env', 'health', 'timeoutMs']);
|
|
64
|
+
|
|
65
|
+
function validateServices(services) {
|
|
66
|
+
if (!Array.isArray(services)) return 'e2e.hostServices must be an array';
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const [i, svc] of services.entries()) {
|
|
69
|
+
const label = typeof svc?.name === 'string' && svc.name ? `"${svc.name}"` : `#${i}`;
|
|
70
|
+
if (!svc || typeof svc !== 'object' || Array.isArray(svc)) return `e2e.hostServices[${i}] must be an object`;
|
|
71
|
+
if (typeof svc.name !== 'string' || !/^[A-Za-z0-9._-]+$/.test(svc.name)) {
|
|
72
|
+
return `e2e.hostServices[${i}].name must be a non-empty string of [A-Za-z0-9._-]`;
|
|
73
|
+
}
|
|
74
|
+
if (seen.has(svc.name)) return `e2e.hostServices has two services named "${svc.name}"`;
|
|
75
|
+
seen.add(svc.name);
|
|
76
|
+
const unknown = Object.keys(svc).find((key) => !SERVICE_KEYS.has(key));
|
|
77
|
+
if (unknown) return `hostService ${label}: unknown field "${unknown}"`;
|
|
78
|
+
if (typeof svc.start !== 'string' || !svc.start.trim()) return `hostService ${label}: "start" must be a non-empty string`;
|
|
79
|
+
for (const field of ['cwd', 'build', 'health']) {
|
|
80
|
+
if (svc[field] !== undefined && typeof svc[field] !== 'string') return `hostService ${label}: "${field}" must be a string`;
|
|
81
|
+
}
|
|
82
|
+
if (svc.env !== undefined) {
|
|
83
|
+
if (!svc.env || typeof svc.env !== 'object' || Array.isArray(svc.env)) return `hostService ${label}: "env" must be an object`;
|
|
84
|
+
for (const [key, value] of Object.entries(svc.env)) {
|
|
85
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
86
|
+
return `hostService ${label}: env.${key} must be a string or number`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (svc.timeoutMs !== undefined && !(Number.isFinite(svc.timeoutMs) && svc.timeoutMs > 0)) {
|
|
91
|
+
return `hostService ${label}: "timeoutMs" must be a positive number`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readState(gitCommonDir, projectName) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(readFileSync(hostStatePath(gitCommonDir, projectName), 'utf8').replace(/^/, ''));
|
|
100
|
+
return Array.isArray(parsed?.services) ? parsed.services : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeState(gitCommonDir, projectName, records) {
|
|
107
|
+
const target = hostStatePath(gitCommonDir, projectName);
|
|
108
|
+
if (records.length === 0) {
|
|
109
|
+
try { unlinkSync(target); } catch { /* already gone */ }
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
mkdirSync(join(gitCommonDir, 'morph-spec'), { recursive: true });
|
|
113
|
+
const tmp = `${target}.tmp.${process.pid}`;
|
|
114
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 1, project: projectName, services: records }, null, 2)}\n`, 'utf8');
|
|
115
|
+
renameSync(tmp, target);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sleep(ms) {
|
|
119
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const PROBE_SCRIPT =
|
|
123
|
+
'fetch(process.argv[1],{signal:AbortSignal.timeout(Number(process.argv[2]))})' +
|
|
124
|
+
'.then(r=>process.exit(r.status<500?0:1),()=>process.exit(1))';
|
|
125
|
+
|
|
126
|
+
function probeHealth(url, timeoutMs = 1_500) {
|
|
127
|
+
const r = spawnSync(process.execPath, ['-e', PROBE_SCRIPT, url, String(timeoutMs)], {
|
|
128
|
+
timeout: timeoutMs + 1_000,
|
|
129
|
+
windowsHide: true,
|
|
130
|
+
stdio: 'ignore',
|
|
131
|
+
});
|
|
132
|
+
return r.status === 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Only the recorded identity may be killed: a recycled PID belongs to someone else.
|
|
136
|
+
function killRecord(record, readCreatedAt) {
|
|
137
|
+
if (!running(record.pid)) {
|
|
138
|
+
return { killed: false, warning: `hostService "${record.name}": PID ${record.pid} was no longer running; record dropped` };
|
|
139
|
+
}
|
|
140
|
+
const current = readCreatedAt(record.pid);
|
|
141
|
+
if (!record.createdAt || current !== record.createdAt) {
|
|
142
|
+
return {
|
|
143
|
+
killed: false,
|
|
144
|
+
warning: `hostService "${record.name}": PID ${record.pid} is alive but its creation time differs ` +
|
|
145
|
+
`from the recorded one (recycled PID); not killed, record dropped`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (process.platform === 'win32') killWindowsTree(record.pid);
|
|
149
|
+
else killPosixGroup(record.pid);
|
|
150
|
+
return { killed: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function waitUntil(predicate) {
|
|
154
|
+
const deadline = Date.now() + KILL_WAIT_MS;
|
|
155
|
+
while (!predicate() && Date.now() < deadline) sleep(100);
|
|
156
|
+
return predicate();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The process that spawned the shell blocks in a sync loop and never reaps it: a zombie is not alive.
|
|
160
|
+
function running(pid) {
|
|
161
|
+
if (!pidAlive(pid)) return false;
|
|
162
|
+
if (process.platform === 'win32') return true;
|
|
163
|
+
const r = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' });
|
|
164
|
+
return r.status !== 0 || !String(r.stdout).trim().startsWith('Z');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function groupAlive(pgid) {
|
|
168
|
+
const r = spawnSync('ps', ['-A', '-o', 'pgid=,stat='], { encoding: 'utf8' });
|
|
169
|
+
if (r.status !== 0) {
|
|
170
|
+
try {
|
|
171
|
+
process.kill(-pgid, 0);
|
|
172
|
+
return true;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return err?.code === 'EPERM';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return String(r.stdout).split('\n').some((line) => {
|
|
178
|
+
const [group, stat = ''] = line.trim().split(/\s+/);
|
|
179
|
+
return Number(group) === pgid && !stat.startsWith('Z');
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The port belongs to the grandchild, not the shell: wait on the whole group.
|
|
184
|
+
function killPosixGroup(pgid) {
|
|
185
|
+
try { process.kill(-pgid, 'SIGTERM'); } catch { /* group already gone */ }
|
|
186
|
+
if (!waitUntil(() => !groupAlive(pgid))) {
|
|
187
|
+
try { process.kill(-pgid, 'SIGKILL'); } catch { /* group already gone */ }
|
|
188
|
+
waitUntil(() => !groupAlive(pgid));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const WIN_SNAPSHOT =
|
|
193
|
+
'Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ p = $_.ProcessId; pp = $_.ParentProcessId; ' +
|
|
194
|
+
't = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 } } } | ConvertTo-Json -Compress';
|
|
195
|
+
|
|
196
|
+
function windowsSnapshot() {
|
|
197
|
+
try {
|
|
198
|
+
const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', WIN_SNAPSHOT],
|
|
199
|
+
{ encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 });
|
|
200
|
+
const parsed = JSON.parse(out.trim() || '[]');
|
|
201
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
202
|
+
} catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Windows keeps a dead parent's PID in ParentProcessId, so a child only counts when born after its parent.
|
|
208
|
+
function windowsDescendants(rows, roots) {
|
|
209
|
+
const byPid = new Map(rows.map((r) => [r.p, r]));
|
|
210
|
+
const found = [];
|
|
211
|
+
const queue = [...roots];
|
|
212
|
+
while (queue.length > 0) {
|
|
213
|
+
const parent = byPid.get(queue.shift());
|
|
214
|
+
if (!parent) continue;
|
|
215
|
+
for (const row of rows) {
|
|
216
|
+
if (row.pp === parent.p && row.p !== parent.p && row.t >= parent.t && !found.includes(row.p) && !roots.includes(row.p)) {
|
|
217
|
+
found.push(row.p);
|
|
218
|
+
queue.push(row.p);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Every PID goes through process.kill: killing via a console tool is banned in hooks (doctor-procs canary).
|
|
226
|
+
// The rescan catches a child spawned between the snapshot and its parent's death.
|
|
227
|
+
function killWindowsTree(rootPid) {
|
|
228
|
+
const first = windowsSnapshot();
|
|
229
|
+
const killed = [rootPid, ...windowsDescendants(first, [rootPid])];
|
|
230
|
+
for (const pid of killed) {
|
|
231
|
+
try { process.kill(pid); } catch { /* already gone */ }
|
|
232
|
+
}
|
|
233
|
+
const killedRows = first.filter((r) => killed.includes(r.p));
|
|
234
|
+
const rescan = windowsSnapshot().filter((r) => !killed.includes(r.p));
|
|
235
|
+
const stragglers = windowsDescendants([...killedRows, ...rescan], killed);
|
|
236
|
+
for (const pid of stragglers) {
|
|
237
|
+
try { process.kill(pid); } catch { /* already gone */ }
|
|
238
|
+
}
|
|
239
|
+
waitUntil(() => [...killed, ...stragglers].every((pid) => !pidAlive(pid)));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function tailLines(text, n = OUTPUT_TAIL_LINES) {
|
|
243
|
+
return String(text || '').trimEnd().split(/\r?\n/).slice(-n).join('\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function readLogTail(path) {
|
|
247
|
+
try { return tailLines(readFileSync(path, 'utf8')); } catch { return ''; }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @returns {{ killed: string[], skipped: string[], warnings: string[] }} */
|
|
251
|
+
export function stopHostServices(gitCommonDir, projectName, { readCreatedAt = readProcessCreatedAt } = {}) {
|
|
252
|
+
const result = { killed: [], skipped: [], warnings: [] };
|
|
253
|
+
if (!gitCommonDir) return result;
|
|
254
|
+
const records = readState(gitCommonDir, projectName);
|
|
255
|
+
if (!records) return result;
|
|
256
|
+
for (const record of records) {
|
|
257
|
+
const outcome = killRecord(record, readCreatedAt);
|
|
258
|
+
if (outcome.killed) result.killed.push(record.name);
|
|
259
|
+
else {
|
|
260
|
+
result.skipped.push(record.name);
|
|
261
|
+
result.warnings.push(outcome.warning);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
writeState(gitCommonDir, projectName, []);
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Liveness by PID only: the creation-time read is too slow for a poll. */
|
|
269
|
+
export function hostServicesStatus(gitCommonDir, projectName) {
|
|
270
|
+
const records = readState(gitCommonDir, projectName) ?? [];
|
|
271
|
+
return records.map((r) => {
|
|
272
|
+
const alive = pidAlive(r.pid);
|
|
273
|
+
return { name: r.name, pid: r.pid, alive, health: r.health ? alive && probeHealth(r.health) : null };
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A detached cmd.exe has no console, so the console apps it starts get a fresh one and drop the
|
|
278
|
+
// log handles; a detached node holding the shell keeps them inherited down the tree.
|
|
279
|
+
const WIN_LAUNCHER =
|
|
280
|
+
"const c=require('child_process').spawn(process.argv[1],{shell:true,stdio:'inherit',windowsHide:true});" +
|
|
281
|
+
'c.on(\'exit\',(k)=>process.exit(k??1))';
|
|
282
|
+
|
|
283
|
+
function launch(svc, ctx, deadline, exec) {
|
|
284
|
+
const fail = (reason, output = '') => ({ ok: false, reason: `hostService "${svc.name}": ${reason}`, output });
|
|
285
|
+
const remaining = () => deadline - Date.now();
|
|
286
|
+
|
|
287
|
+
if (svc.build) {
|
|
288
|
+
const [file, ...args] = tokenize(svc.build);
|
|
289
|
+
const built = exec(file, args, { cwd: svc.dir, env: svc.childEnv, timeout: remaining() });
|
|
290
|
+
// A build killed at its timeout comes back as a plain non-zero exit: the deadline decides first.
|
|
291
|
+
if (remaining() <= 0) return fail(`timed out after ${svc.timeoutMs} ms during build`, tailLines(built.output));
|
|
292
|
+
if (built.code !== 0) return fail(`build failed (${svc.build}, exit ${built.code})`, tailLines(built.output));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const log = hostLogPath(ctx.gitCommonDir, ctx.projectName, svc.name);
|
|
296
|
+
mkdirSync(join(ctx.gitCommonDir, 'morph-spec'), { recursive: true });
|
|
297
|
+
const fd = openSync(log, 'w');
|
|
298
|
+
let child;
|
|
299
|
+
try {
|
|
300
|
+
// The CLI ends with process.exit: the child must own its group and hold none of our pipes.
|
|
301
|
+
const win = process.platform === 'win32';
|
|
302
|
+
child = spawn(win ? process.execPath : svc.start, win ? ['-e', WIN_LAUNCHER, svc.start] : [], {
|
|
303
|
+
cwd: svc.dir,
|
|
304
|
+
env: { ...process.env, ...svc.childEnv },
|
|
305
|
+
shell: !win,
|
|
306
|
+
detached: true,
|
|
307
|
+
windowsHide: true,
|
|
308
|
+
stdio: ['ignore', fd, fd],
|
|
309
|
+
});
|
|
310
|
+
child.on('error', () => {});
|
|
311
|
+
child.unref();
|
|
312
|
+
} finally {
|
|
313
|
+
closeSync(fd);
|
|
314
|
+
}
|
|
315
|
+
if (!child?.pid) return fail(`could not start (${svc.start})`, readLogTail(log));
|
|
316
|
+
|
|
317
|
+
const record = {
|
|
318
|
+
name: svc.name,
|
|
319
|
+
pid: child.pid,
|
|
320
|
+
createdAt: ctx.readCreatedAt(child.pid),
|
|
321
|
+
stamp: ctx.stamp ?? null,
|
|
322
|
+
log,
|
|
323
|
+
health: svc.health,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
if (!svc.health) {
|
|
327
|
+
sleep(Math.min(NO_HEALTH_GRACE_MS, Math.max(remaining(), 0)));
|
|
328
|
+
if (!running(child.pid)) return { ...fail('exited right after start', readLogTail(log)), record };
|
|
329
|
+
return { ok: true, record };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
while (true) {
|
|
333
|
+
if (probeHealth(svc.health)) return { ok: true, record };
|
|
334
|
+
if (!running(child.pid)) return { ...fail('exited before its health check went green', readLogTail(log)), record };
|
|
335
|
+
if (remaining() <= 0) {
|
|
336
|
+
return { ...fail(`health ${svc.health} not green within ${svc.timeoutMs} ms`, readLogTail(log)), record };
|
|
337
|
+
}
|
|
338
|
+
sleep(Math.min(HEALTH_POLL_MS, remaining()));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isReusable(record, svc, ctx) {
|
|
343
|
+
if (!record || !running(record.pid)) return false;
|
|
344
|
+
if (ctx.readCreatedAt(record.pid) !== record.createdAt) return false;
|
|
345
|
+
return svc.health ? probeHealth(svc.health) : true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** @returns {{ status: 'pass'|'fail'|'skip', reused: boolean, services: object[], warnings: string[], reason?: string, output?: string }} */
|
|
349
|
+
export function startHostServices(cwd, options = {}, exec) {
|
|
350
|
+
const {
|
|
351
|
+
services,
|
|
352
|
+
env: stackEnv = {},
|
|
353
|
+
gitCommonDir,
|
|
354
|
+
projectName,
|
|
355
|
+
stamp = null,
|
|
356
|
+
forceFresh = false,
|
|
357
|
+
readCreatedAt = readProcessCreatedAt,
|
|
358
|
+
} = options;
|
|
359
|
+
|
|
360
|
+
if (services === undefined || (Array.isArray(services) && services.length === 0)) {
|
|
361
|
+
return { status: 'skip', reused: false, services: [], warnings: [], reason: 'no e2e.hostServices configured' };
|
|
362
|
+
}
|
|
363
|
+
const invalid = validateServices(services);
|
|
364
|
+
if (invalid) return { status: 'fail', reused: false, services: [], warnings: [], reason: invalid };
|
|
365
|
+
|
|
366
|
+
const resolved = [];
|
|
367
|
+
for (const svc of services) {
|
|
368
|
+
const { env, health, missing } = interpolateEnv(svc, stackEnv);
|
|
369
|
+
if (missing.length > 0) {
|
|
370
|
+
return {
|
|
371
|
+
status: 'fail',
|
|
372
|
+
reused: false,
|
|
373
|
+
services: [],
|
|
374
|
+
warnings: [],
|
|
375
|
+
reason: `hostService "${svc.name}": no value for ${missing.map((v) => `\${${v}}`).join(', ')} ` +
|
|
376
|
+
'(looked in the e2e stack env and the process env)',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const dir = resolve(cwd, svc.cwd ?? '.');
|
|
380
|
+
if (!existsSync(dir)) {
|
|
381
|
+
return { status: 'fail', reused: false, services: [], warnings: [], reason: `hostService "${svc.name}": cwd ${dir} does not exist` };
|
|
382
|
+
}
|
|
383
|
+
resolved.push({
|
|
384
|
+
name: svc.name,
|
|
385
|
+
dir,
|
|
386
|
+
build: svc.build,
|
|
387
|
+
start: svc.start,
|
|
388
|
+
health,
|
|
389
|
+
timeoutMs: svc.timeoutMs ?? DEFAULT_HOST_TIMEOUT,
|
|
390
|
+
childEnv: { ...stackEnv, ...env },
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const ctx = { gitCommonDir, projectName, stamp, readCreatedAt };
|
|
395
|
+
const previous = readState(gitCommonDir, projectName) ?? [];
|
|
396
|
+
const sameSet = previous.length === resolved.length && resolved.every((s) => previous.some((r) => r.name === s.name));
|
|
397
|
+
const divergent = forceFresh || !sameSet || previous.some((r) => r.stamp !== stamp);
|
|
398
|
+
const warnings = divergent && previous.length > 0
|
|
399
|
+
? stopHostServices(gitCommonDir, projectName, { readCreatedAt }).warnings
|
|
400
|
+
: [];
|
|
401
|
+
const prior = divergent ? [] : previous;
|
|
402
|
+
|
|
403
|
+
const records = [];
|
|
404
|
+
const startedNow = [];
|
|
405
|
+
const out = [];
|
|
406
|
+
for (const [i, svc] of resolved.entries()) {
|
|
407
|
+
const old = prior.find((r) => r.name === svc.name);
|
|
408
|
+
if (isReusable(old, svc, ctx)) {
|
|
409
|
+
records.push(old);
|
|
410
|
+
out.push({ name: svc.name, pid: old.pid, reused: true, log: old.log });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (old) {
|
|
414
|
+
const killed = killRecord(old, readCreatedAt);
|
|
415
|
+
if (killed.warning) warnings.push(killed.warning);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const result = launch(svc, ctx, Date.now() + svc.timeoutMs, exec);
|
|
419
|
+
if (!result.ok) {
|
|
420
|
+
for (const r of [...startedNow, result.record].filter(Boolean)) killRecord(r, readCreatedAt);
|
|
421
|
+
const unreached = resolved.slice(i + 1).map((s) => prior.find((r) => r.name === s.name)).filter(Boolean);
|
|
422
|
+
const kept = [...records.filter((r) => !startedNow.includes(r)), ...unreached];
|
|
423
|
+
writeState(gitCommonDir, projectName, kept);
|
|
424
|
+
return { status: 'fail', reused: false, services: out, warnings, reason: result.reason, output: result.output };
|
|
425
|
+
}
|
|
426
|
+
records.push(result.record);
|
|
427
|
+
startedNow.push(result.record);
|
|
428
|
+
writeState(gitCommonDir, projectName, records);
|
|
429
|
+
out.push({ name: svc.name, pid: result.record.pid, reused: false, log: result.record.log });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return { status: 'pass', reused: startedNow.length === 0, services: out, warnings };
|
|
433
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"title": "MORPH-SPEC .morph/config/config.json",
|
|
4
|
-
"description": "Describes project.tests[] — the plural declaration of the project's test suites
|
|
4
|
+
"description": "Describes project.tests[] — the plural declaration of the project's test suites —, e2e.hostServices and finishBackup.retention. Everything else in config.json is deliberately left open (additionalProperties: true) so that configs already in the field never break on a key this schema has not heard of. The authorities at runtime are validateTestNodes() in src/lib/tasks/test-nodes.js and resolveBackupRetention() in src/lib/worktree/finish-backup.js: a schema nothing calls is decoration.",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"additionalProperties": true,
|
|
7
7
|
"properties": {
|
|
@@ -119,6 +119,58 @@
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
},
|
|
122
|
+
"e2e": {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"additionalProperties": true,
|
|
125
|
+
"description": "The verification stack of `e2e up` and of the verify e2e node. Only hostServices is described here; the other keys (auth, command, portEnvNames, ...) stay open.",
|
|
126
|
+
"properties": {
|
|
127
|
+
"hostServices": {
|
|
128
|
+
"type": "array",
|
|
129
|
+
"description": "Apps that run as host processes next to the compose stack, when building them inside the Docker VM is expensive: compose keeps only the dependencies. Order on `e2e up` and on the verify e2e node: compose up --wait, then each service in array order (build, start detached, health), then the auth seed. They stay up until `e2e down`, `finish`, `delete` or `worktree remove`. Reused while the code stamp (HEAD + status + diff) matches and health is green; a divergent stamp or --fresh/--fresh-e2e kills, rebuilds and restarts them. A failure (build exit != 0, timeout, health never green) fails `e2e up` (exit 1) and the e2e node with the service name and its log tail, kills what this run started and skips the seed. The authority at runtime is validateServices() in framework/hooks/shared/host-services.js.",
|
|
130
|
+
"items": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"additionalProperties": false,
|
|
133
|
+
"required": ["name", "start"],
|
|
134
|
+
"properties": {
|
|
135
|
+
"name": {
|
|
136
|
+
"type": "string",
|
|
137
|
+
"pattern": "^[A-Za-z0-9._-]+$",
|
|
138
|
+
"description": "Unique in the array. Names the log file (<git-common-dir>/morph-spec/e2e-host-<project>-<name>.log) and every message about the service."
|
|
139
|
+
},
|
|
140
|
+
"cwd": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"default": ".",
|
|
143
|
+
"description": "Working directory of build and start, relative to the project root. One that does not exist refuses."
|
|
144
|
+
},
|
|
145
|
+
"build": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": "Runs before start; a non-zero exit fails the bring-up."
|
|
148
|
+
},
|
|
149
|
+
"start": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"minLength": 1,
|
|
152
|
+
"description": "The process, started detached; its output goes to the service log."
|
|
153
|
+
},
|
|
154
|
+
"env": {
|
|
155
|
+
"type": "object",
|
|
156
|
+
"additionalProperties": { "type": ["string", "number"] },
|
|
157
|
+
"description": "Added on top of process.env and the stack env (port block + e2e.auth.envFile). ${VAR} is resolved from the stack env, then process.env; a VAR with no value (or an empty one) refuses before any build, naming the variable and the service."
|
|
158
|
+
},
|
|
159
|
+
"health": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"description": "URL polled until it answers with an HTTP status below 500. ${VAR} interpolates like env. Without it, a process still alive 2 s after start counts as ready."
|
|
162
|
+
},
|
|
163
|
+
"timeoutMs": {
|
|
164
|
+
"type": "number",
|
|
165
|
+
"exclusiveMinimum": 0,
|
|
166
|
+
"default": 180000,
|
|
167
|
+
"description": "Ceiling for build + start + health together."
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
122
174
|
"finishBackup": {
|
|
123
175
|
"type": "object",
|
|
124
176
|
"additionalProperties": true,
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando for iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- umbraco
|
|
10
10
|
---
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando o usuário pedir "use /migrate-lp-to-umbraco {projeto}" ou descrever a necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- umbraco
|
|
10
10
|
---
|
|
@@ -4,7 +4,7 @@ description: Executa as fatias do plano de uma feature morph-spec — dispatch p
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use com o gate do plano aprovado, em "use /morph-apply {feature}" ou "implementar feature"
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando chegar "/morph-hotfix {feature}", quando a morph-plan rotear o pedido como workType hotfix, ou quando o usuário disser "produção caiu / hotfix / urgente" a respeito de um incidente
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -4,7 +4,7 @@ description: Cuida da RAM e da CPU da máquina enquanto o morph-spec trabalha
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando a máquina estiver lenta ou sem RAM, um comando pesado esperou ou foi recusado pela fila, sobrou worktree de sub-agent, ou vários sub-agents pesados vão rodar em paralelo
|
|
6
6
|
user-invocable: false
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-machine
|
|
@@ -4,7 +4,7 @@ description: Conduz a AUTORIA de mídia gerada por IA — escolher o modelo pelo
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando a tarefa for PRODUZIR imagem ou vídeo por IA (catálogo, marketing, asset de LP) e alguém precisar decidir modelo, prompt e aprovação
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
stacks:
|
|
9
9
|
- dotnet
|
|
10
10
|
- nextjs
|
|
@@ -4,7 +4,7 @@ description: Leva uma feature do pedido ao plano aprovado — roteia o tipo de t
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando uma feature, bug ou chore começar ("/morph-plan {feature}", "vamos planejar X"), ou ao retomar uma feature cujo gate plan ainda não foi aprovado
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -105,7 +105,7 @@ O `create` já escreveu o esqueleto em `2-plan/plan.md` (leia e preencha; `chore
|
|
|
105
105
|
- Cenário que se observa por um artefato de outra fatia (rota, endpoint, componente que ela cria) faz da fatia que o cobre um `depende:` daquela — senão as duas caem na mesma onda e o teste do cenário não tem por onde provar. O `plan-check` não vê de quem é a rota: a regra é sua.
|
|
106
106
|
|
|
107
107
|
**Contrato de implementação**: o que quem implementa precisa e o código não diz.
|
|
108
|
-
- **Verificação de runtime:** o stack sobe por `morph-spec e2e up {feature}` (compose do projeto; sem compose, isso é uma fatia); os fluxos que o verificador navega, nos verbos dos cenários; base URL `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``, compose com `"${PORT:-3000}:3000"` e sem `container_name` (standard `infrastructure/docker/local-compose-isolation`). App com login: `e2e.auth` em `config.json` (`mode: seed|storageState|bypass|none`, `seedCommand`, `storageStatePath`, `envFile`), credenciais num `.env.e2e` gitignored — **`mode` é obrigatório sempre que houver `seedCommand`**, sem ele o seed fica inerte. Biblioteca pura: registre a dispensa e o porquê.
|
|
108
|
+
- **Verificação de runtime:** o stack sobe por `morph-spec e2e up {feature}` (compose do projeto; sem compose, isso é uma fatia); os fluxos que o verificador navega, nos verbos dos cenários; base URL `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``, compose com `"${PORT:-3000}:3000"` e sem `container_name` (standard `infrastructure/docker/local-compose-isolation`). App cujo build é caro dentro do Docker sobe como processo do host por `e2e.hostServices` (compose só com as dependências; campos em `morph-spec e2e --help`). App com login: `e2e.auth` em `config.json` (`mode: seed|storageState|bypass|none`, `seedCommand`, `storageStatePath`, `envFile`), credenciais num `.env.e2e` gitignored — **`mode` é obrigatório sempre que houver `seedCommand`**, sem ele o seed fica inerte. Biblioteca pura: registre a dispensa e o porquê.
|
|
109
109
|
- **Baseline é um COMANDO, nunca um número.** O plano congela e é lido por dias: um número cravado está errado na primeira fatia, e quem confia nele "conserta" o que não quebrou. Registre como medir (`npm test`, `dotnet test`); o `expectedCount` sai de `morph-spec verify {feature} --record-baseline`, nunca digitado. Sem `project.stack`/`project.tests` no `config.json`, a `morph-apply` os declara a partir destes comandos antes do primeiro verify.
|
|
110
110
|
- **Pré-requisito é um PREDICADO, nunca um SHA literal:** "`git merge-base --is-ancestor origin/main HEAD` sai 0" — o SHA muda no primeiro squash.
|
|
111
111
|
|
|
@@ -4,7 +4,7 @@ description: Conduz o Gate 3 — despacha verificador ∥ revisor (∥ seguranç
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando a fase review começar, com todas as fatias com relatório final e o verify da feature verde
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -51,7 +51,8 @@ símbolo público do diff, `morph-spec graph affected <símbolo> --prod --json`;
|
|
|
51
51
|
(`no-graph`/`not-found`/`ambiguous`), siga sem o blast radius — o revisor cai para `git grep` — e
|
|
52
52
|
registre a ausência no recado a ele. Cada agente nasce com contexto fresco e **nunca recebe relatório anterior** (ancoragem).
|
|
53
53
|
`chore` tem review leve: só o revisor (∥ segurança, quando exigida) — sem verificador, sem cobertura de
|
|
54
|
-
`Cn`, sem mutação. Despache todos na mesma resposta
|
|
54
|
+
`Cn`, sem mutação. Despache todos na mesma resposta, **sem nome no dispatch**: com nome, o `agent_type` que o hook
|
|
55
|
+
`test-budget` lê vira o nome, e o guard de review não reconhece o papel.
|
|
55
56
|
```
|
|
56
57
|
dispatch Agent (subagent_type: "morph-verifier", model: {reviewModels.verifier.model}) with prompt:
|
|
57
58
|
feature, base, sha, JSON do verify (nós, e2e, URL) se já terminou, 1-design/mockups.md e
|
|
@@ -4,7 +4,7 @@ description: Localiza um standard específico via busca server-side (morph-spec
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX de uma feature com plano aprovado
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando o ramo de design for tomado (morph-spec advance {feature} --design, depois do gate do plano) e antes de /morph-apply
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "9.
|
|
7
|
+
cliVersion: "9.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-uiux
|
|
@@ -1629,9 +1629,10 @@
|
|
|
1629
1629
|
"portNames": "#project-named-port-variables",
|
|
1630
1630
|
"rules": "#core-rules",
|
|
1631
1631
|
"enforcement": "#what-the-harness-enforces",
|
|
1632
|
+
"hostServices": "#services-on-the-host",
|
|
1632
1633
|
"skeleton": "#minimal-compose-skeleton"
|
|
1633
1634
|
},
|
|
1634
|
-
"digest": "Every worktree runs its OWN compose stack (project morph-{feature}, own postgres, own volumes). Ports come from the worktree block: PORT +0, API_PORT +1 (ASPNETCORE_URLS on .NET), POSTGRES_PORT +2, REDIS_PORT +3 — publish via ${VAR:-default}:container, never a fixed host port, never container_name (not prefixed by the project → collides). Stable POSTGRES_DB, healthcheck on every service, DATABASE_URL with an in-network default, playwright.config reads PLAYWRIGHT_BASE_URL. A non-isolable compose fails the verify e2e node inside a worktree. finish
|
|
1635
|
+
"digest": "Every worktree runs its OWN compose stack (project morph-{feature}, own postgres, own volumes). Ports come from the worktree block: PORT +0, API_PORT +1 (ASPNETCORE_URLS on .NET), POSTGRES_PORT +2, REDIS_PORT +3 — publish via ${VAR:-default}:container, never a fixed host port, never container_name (not prefixed by the project → collides). Stable POSTGRES_DB, healthcheck on every service, DATABASE_URL with an in-network default, playwright.config reads PLAYWRIGHT_BASE_URL. A non-isolable compose fails the verify e2e node inside a worktree. finish/delete tear down with --rmi local -v (--keep-volumes drops -v on finish); worktree remove of a morph-task/* branch with -v, of morph/{f} without. An app whose build is expensive in the Docker VM runs as a host process via e2e.hostServices (compose keeps only the dependencies; ports from the block). Cloud DB credentials go in e2e.auth.envFile, never in .morph/worktree.env. A compose that reads a port by ANOTHER name (PG_PORT) is not fed by the block: declare it in config.json e2e.portEnvNames ({\"postgres\": \"PG_PORT\"}) and the harness exports it with the same value in worktree.env, the compose env and the hook."
|
|
1635
1636
|
},
|
|
1636
1637
|
{
|
|
1637
1638
|
"id": "infrastructure-neon-pgrag",
|