@polymorphism-tech/morph-spec 8.42.0 → 8.43.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 (87) hide show
  1. package/CLAUDE.md +9 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +48 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +5 -4
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +1 -1
  10. package/framework/docs/CLI.md +10 -3
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  13. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  14. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  15. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  16. package/framework/hooks/shared/agent-reap.js +89 -0
  17. package/framework/hooks/shared/e2e-check.js +3 -1
  18. package/framework/hooks/shared/graph-refresh.js +1 -9
  19. package/framework/hooks/shared/heavy-command.js +128 -0
  20. package/framework/hooks/shared/heavy-config.js +65 -0
  21. package/framework/hooks/shared/heavy-holders.js +85 -0
  22. package/framework/hooks/shared/heavy-slots.js +207 -0
  23. package/framework/hooks/shared/process-alive.js +8 -0
  24. package/framework/hooks/shared/worktree-root.js +93 -0
  25. package/framework/schemas/config.schema.json +80 -0
  26. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  27. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  28. package/framework/skills/core/morph-apply/SKILL.md +8 -3
  29. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  30. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  31. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  32. package/framework/skills/core/morph-implement/SKILL.md +9 -4
  33. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  34. package/framework/skills/core/morph-media/SKILL.md +1 -1
  35. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  36. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  37. package/framework/skills/core/morph-review/SKILL.md +45 -16
  38. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  39. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  40. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  41. package/framework/standards/PROVENANCE.json +10 -10
  42. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  43. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  44. package/package.json +1 -1
  45. package/src/commands/e2e/e2e.js +27 -8
  46. package/src/commands/mcp/mcp-setup.js +10 -9
  47. package/src/commands/project/doctor-procs.js +162 -0
  48. package/src/commands/project/doctor.js +42 -4
  49. package/src/commands/project/heavy.js +61 -0
  50. package/src/commands/project/init.js +1 -1
  51. package/src/commands/project/update.js +4 -0
  52. package/src/commands/project/worktree-reap.js +34 -0
  53. package/src/commands/project/worktree.js +78 -27
  54. package/src/commands/validation/mutate.js +200 -0
  55. package/src/commands/validation/verify.js +26 -1
  56. package/src/dashboard/scanner/features-scanner.js +4 -4
  57. package/src/dashboard/scanner/project-api.js +4 -4
  58. package/src/lib/detectors/claude-config-detector.js +30 -1
  59. package/src/lib/heavy/cleanup.js +70 -0
  60. package/src/lib/heavy/gate.js +96 -0
  61. package/src/lib/installers/mcp-installer.js +15 -2
  62. package/src/lib/mutation/adapters.js +90 -0
  63. package/src/lib/mutation/commands.js +60 -0
  64. package/src/lib/mutation/manifest.js +138 -0
  65. package/src/lib/mutation/plan.js +36 -0
  66. package/src/lib/mutation/report.js +66 -0
  67. package/src/lib/mutation/runner.js +351 -0
  68. package/src/lib/mutation/schemata.js +60 -0
  69. package/src/lib/mutation/snapshot.js +69 -0
  70. package/src/lib/mutation/subset.js +75 -0
  71. package/src/lib/mutation/trx.js +51 -0
  72. package/src/lib/procs/classify.js +91 -0
  73. package/src/lib/procs/list.js +124 -0
  74. package/src/lib/procs/mcp-config.js +103 -0
  75. package/src/lib/procs/prune.js +25 -0
  76. package/src/lib/procs/wslconfig.js +70 -0
  77. package/src/lib/projects/registry.js +6 -2
  78. package/src/lib/tasks/linux-runner.js +30 -3
  79. package/src/lib/tasks/test-runner.js +1 -1
  80. package/src/lib/validators/verify-runner.js +26 -8
  81. package/src/lib/worktree/agent-reaper.js +247 -0
  82. package/src/lib/worktree/dirs.js +3 -32
  83. package/src/lib/worktree/vscode-settings.js +113 -0
  84. package/src/lib/worktree/worktree-audit.js +49 -5
  85. package/src/lib/worktree/worktree-include.js +46 -0
  86. package/src/scripts/setup-infra.js +4 -0
  87. package/src/utils/hooks-installer.js +11 -4
@@ -0,0 +1,85 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { freemem, homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { pidAlive } from './process-alive.js';
5
+
6
+ export const GB = 1024 ** 3;
7
+ export const WRITE_GRACE_MS = 5000;
8
+ export const EXCLUSIVE_MAX_AGE_MS = 2 * 60 * 1000;
9
+ export const SLOT_FILE = /^slot-\d+\.lock$/;
10
+
11
+ export function heavyDir(env = process.env, { platform = process.platform, home = homedir() } = {}) {
12
+ if (env.MORPH_HEAVY_DIR) return env.MORPH_HEAVY_DIR;
13
+ if (platform === 'win32') return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'morph-spec', 'heavy');
14
+ return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'morph-spec', 'heavy');
15
+ }
16
+
17
+ export const mtimeOf = (path) => {
18
+ try { return statSync(path).mtimeMs; } catch { return null; }
19
+ };
20
+
21
+ const leaseTtlMs = (meta) => (Number.isFinite(meta.ttlMs) && meta.ttlMs > 0 ? meta.ttlMs : Infinity);
22
+
23
+ export function readLockFile(path, { now, isAlive, maxHoldMs }) {
24
+ const t = now();
25
+ let meta;
26
+ try {
27
+ meta = JSON.parse(readFileSync(path, 'utf8'));
28
+ } catch {
29
+ const mtime = mtimeOf(path);
30
+ if (mtime === null) return null;
31
+ const writing = t - mtime < WRITE_GRACE_MS;
32
+ return { path, pid: null, since: null, label: '(gravando)', live: writing, unreadable: true };
33
+ }
34
+ const age = t - Date.parse(meta.since);
35
+ const inWindow = age >= 0 && age < maxHoldMs;
36
+ // A per-call lease belongs to a hook process that is long gone: only its TTL says when it lapses.
37
+ const live = meta.leaseId ? inWindow && age < leaseTtlMs(meta) : inWindow && Boolean(isAlive(meta.pid));
38
+ return { ...meta, path, live };
39
+ }
40
+
41
+ export function scanHolders(dir, ctx) {
42
+ let names = [];
43
+ try { names = readdirSync(dir).filter((n) => SLOT_FILE.test(n)); } catch { return { live: [], stale: [] }; }
44
+ const live = [];
45
+ const stale = [];
46
+ for (const name of names) {
47
+ const holder = readLockFile(join(dir, name), ctx);
48
+ if (!holder) continue;
49
+ (holder.live ? live : stale).push(holder);
50
+ }
51
+ return { live, stale };
52
+ }
53
+
54
+ export function readHolders(dir, ctx) {
55
+ return scanHolders(dir, ctx).live;
56
+ }
57
+
58
+ export function readExclusive(dir, ctx) {
59
+ const holder = readLockFile(join(dir, 'exclusive.lock'), { ...ctx, maxHoldMs: EXCLUSIVE_MAX_AGE_MS });
60
+ return holder?.live ? holder : null;
61
+ }
62
+
63
+ const ageText = (ms) => {
64
+ const min = Math.floor(ms / 60000);
65
+ return min < 1 ? `${Math.max(0, Math.floor(ms / 1000))}s` : `${min}m`;
66
+ };
67
+
68
+ export function describeHolder(h, now = Date.now()) {
69
+ const what = [h.label, [h.feature, h.node].filter(Boolean).join('/')].filter(Boolean).join(' ');
70
+ const age = h.since ? `, há ${ageText(now - Date.parse(h.since))}` : '';
71
+ const who = h.leaseId ? 'chamada Bash' : `pid ${h.pid ?? '?'}`;
72
+ return `${what || 'processo'}, ${who}${age}`;
73
+ }
74
+
75
+ export function heavyStatus({
76
+ dir, maxHoldMs, now = Date.now, isAlive = pidAlive, freeMem = freemem,
77
+ }) {
78
+ const ctx = { now, isAlive, maxHoldMs };
79
+ return {
80
+ dir,
81
+ holders: readHolders(dir, ctx),
82
+ exclusive: readExclusive(dir, ctx),
83
+ freeGb: freeMem() / GB,
84
+ };
85
+ }
@@ -0,0 +1,207 @@
1
+ import {
2
+ closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, writeSync,
3
+ } from 'node:fs';
4
+ import { freemem } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { pidAlive } from './process-alive.js';
7
+ import {
8
+ GB, SLOT_FILE, describeHolder, mtimeOf, readExclusive, scanHolders,
9
+ } from './heavy-holders.js';
10
+
11
+ export { heavyDir, heavyStatus, describeHolder } from './heavy-holders.js';
12
+
13
+ const MUTEX_STALE_MS = 10_000;
14
+ const POLL_MS = 500;
15
+ const REPORT_EVERY_MS = 30_000;
16
+
17
+ export function sleepSync(ms) {
18
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(1, ms));
19
+ }
20
+
21
+ function withClaimMutex(dir, fn, { now }) {
22
+ const path = join(dir, 'claim.lock');
23
+ for (let attempt = 0; attempt < 50; attempt++) {
24
+ try {
25
+ mkdirSync(path);
26
+ try {
27
+ return { value: fn() };
28
+ } finally {
29
+ try { rmdirSync(path); } catch { /* já removido */ }
30
+ }
31
+ } catch (err) {
32
+ if (err?.code !== 'EEXIST') return null;
33
+ const mtime = mtimeOf(path);
34
+ if (mtime !== null && now() - mtime > MUTEX_STALE_MS) {
35
+ try { rmdirSync(path); } catch { /* outro já recolheu */ }
36
+ } else {
37
+ sleepSync(20);
38
+ }
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function liveHoldersPruningStale(dir, ctx) {
45
+ const { live, stale } = scanHolders(dir, ctx);
46
+ for (const holder of stale) {
47
+ try { unlinkSync(holder.path); } catch { /* já solto */ }
48
+ }
49
+ return live;
50
+ }
51
+
52
+ function blockedText(blocked, slots, now) {
53
+ const holders = blocked.holders.map((h) => describeHolder(h, now)).join('; ');
54
+ if (blocked.reason === 'exclusive') return `aguardando limpeza do servidor de build (${describeHolder(blocked.exclusive, now)})`;
55
+ if (blocked.reason === 'memory') {
56
+ const cause = blocked.holders.length
57
+ ? `slots em uso: ${holders}`
58
+ : 'nenhum slot em uso: a pressão vem de fora do semáforo (vmmem do WSL, IDE, outros processos)';
59
+ return `aguardando RAM livre: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
60
+ }
61
+ return `aguardando slot pesado (${blocked.holders.length}/${slots}): ${holders}`;
62
+ }
63
+
64
+ function tryClaim(dir, opts, ctx) {
65
+ const {
66
+ slots, label, feature, node, cwd, minFreeMemGb, freeMem, lease: leaseSpec,
67
+ } = opts;
68
+ const guarded = withClaimMutex(dir, () => {
69
+ const holders = liveHoldersPruningStale(dir, ctx);
70
+ const exclusive = readExclusive(dir, ctx);
71
+ if (exclusive) return { blocked: { reason: 'exclusive', holders, exclusive } };
72
+ if (holders.length >= slots) return { blocked: { reason: 'slots', holders } };
73
+ const freeGb = freeMem() / GB;
74
+ if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb } };
75
+
76
+ for (let i = 0; i < slots + holders.length + 1; i++) {
77
+ const path = join(dir, `slot-${i}.lock`);
78
+ try {
79
+ const fd = openSync(path, 'wx');
80
+ const meta = {
81
+ pid: leaseSpec ? null : process.pid,
82
+ since: new Date(ctx.now()).toISOString(),
83
+ label,
84
+ feature,
85
+ node,
86
+ cwd,
87
+ ...(leaseSpec ? { leaseId: leaseSpec.id, ttlMs: leaseSpec.ttlMs } : {}),
88
+ };
89
+ writeSync(fd, JSON.stringify(meta));
90
+ closeSync(fd);
91
+ return { lease: { path, pid: meta.pid, slot: i, meta } };
92
+ } catch (err) {
93
+ if (err?.code !== 'EEXIST') return { blocked: { reason: 'slots', holders } };
94
+ }
95
+ }
96
+ return { blocked: { reason: 'slots', holders } };
97
+ }, ctx);
98
+ return guarded ? guarded.value : { blocked: { reason: 'contended', holders: [] } };
99
+ }
100
+
101
+ export function acquireHeavy(opts) {
102
+ const {
103
+ dir, slots, waitTimeoutMs, maxHoldMs,
104
+ now = Date.now, sleep = sleepSync, isAlive = pidAlive, freeMem = freemem,
105
+ log = (m) => process.stderr.write(`${m}\n`), env = process.env,
106
+ } = opts;
107
+ const ctx = { now, isAlive, maxHoldMs };
108
+ const claimOpts = { ...opts, freeMem, minFreeMemGb: opts.minFreeMemGb ?? 0 };
109
+
110
+ try { mkdirSync(dir, { recursive: true }); } catch { return { status: 'acquired', lease: null, waitedMs: 0, unavailable: true }; }
111
+
112
+ const leaseOwner = Number(env.MORPH_HEAVY_LEASE);
113
+ if (Number.isInteger(leaseOwner) && leaseOwner > 0 && scanHolders(dir, ctx).live.some((h) => h.pid === leaseOwner)) {
114
+ return { status: 'acquired', lease: null, waitedMs: 0, reentrant: true };
115
+ }
116
+
117
+ const start = now();
118
+ let lastReport = -Infinity;
119
+ for (;;) {
120
+ const attempt = tryClaim(dir, claimOpts, ctx);
121
+ if (attempt.lease) return { status: 'acquired', lease: attempt.lease, waitedMs: now() - start };
122
+
123
+ const waited = now() - start;
124
+ const { blocked } = attempt;
125
+ if (waited >= waitTimeoutMs) {
126
+ return {
127
+ status: 'busy', reason: blocked.reason, holders: blocked.holders, freeGb: blocked.freeGb ?? null, waitedMs: waited,
128
+ };
129
+ }
130
+ if (blocked.reason !== 'contended' && now() - lastReport >= REPORT_EVERY_MS) {
131
+ log(blockedText(blocked, slots, now()));
132
+ lastReport = now();
133
+ }
134
+ sleep(Math.min(POLL_MS, Math.max(1, waitTimeoutMs - waited)));
135
+ }
136
+ }
137
+
138
+ export function updateLease(lease, patch) {
139
+ if (!lease) return;
140
+ try {
141
+ const current = JSON.parse(readFileSync(lease.path, 'utf8'));
142
+ if (current.pid !== lease.pid) return;
143
+ writeFileSync(lease.path, JSON.stringify({ ...current, ...patch }));
144
+ } catch { /* o lease foi recolhido; nada a atualizar */ }
145
+ }
146
+
147
+ export function releaseHeavy(lease) {
148
+ if (!lease) return;
149
+ try {
150
+ if (JSON.parse(readFileSync(lease.path, 'utf8')).pid === lease.pid) unlinkSync(lease.path);
151
+ } catch { /* já solto */ }
152
+ }
153
+
154
+ export function releaseLeaseById(dir, id) {
155
+ let names = [];
156
+ try { names = readdirSync(dir).filter((n) => SLOT_FILE.test(n)); } catch { return false; }
157
+ for (const name of names) {
158
+ const path = join(dir, name);
159
+ try {
160
+ if (JSON.parse(readFileSync(path, 'utf8')).leaseId !== id) continue;
161
+ unlinkSync(path);
162
+ return true;
163
+ } catch { /* já solto, ou ainda sendo gravado */ }
164
+ }
165
+ return false;
166
+ }
167
+
168
+ export function describeBusy(result, config) {
169
+ const waited = Math.round(result.waitedMs / 1000);
170
+ if (result.reason === 'memory') {
171
+ return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${config.minFreeMemGb} GB mínimos, heavy.minFreeMemGb)`;
172
+ }
173
+ if (result.reason === 'exclusive') return `limpeza do servidor de build ainda em curso após ${waited}s`;
174
+ return `${result.holders.length}/${config.slots} slots pesados ocupados por ${waited}s (heavy.waitTimeoutSec)`;
175
+ }
176
+
177
+ export function runExclusive(opts, fn) {
178
+ const {
179
+ dir, label, maxHoldMs, now = Date.now, isAlive = pidAlive,
180
+ } = opts;
181
+ const ctx = { now, isAlive, maxHoldMs };
182
+ try { mkdirSync(dir, { recursive: true }); } catch { return { ran: false, reason: 'unavailable', holders: [] }; }
183
+
184
+ const own = Number((opts.env ?? process.env).MORPH_HEAVY_LEASE);
185
+ const claimed = withClaimMutex(dir, () => {
186
+ const holders = liveHoldersPruningStale(dir, ctx).filter((h) => h.pid !== own);
187
+ if (holders.length) return { ran: false, reason: 'holders', holders };
188
+ if (readExclusive(dir, ctx)) return { ran: false, reason: 'exclusive', holders: [] };
189
+ try {
190
+ const fd = openSync(join(dir, 'exclusive.lock'), 'wx');
191
+ writeSync(fd, JSON.stringify({ pid: process.pid, since: new Date(now()).toISOString(), label }));
192
+ closeSync(fd);
193
+ } catch {
194
+ return { ran: false, reason: 'exclusive', holders: [] };
195
+ }
196
+ return { ran: true };
197
+ }, ctx);
198
+
199
+ if (!claimed) return { ran: false, reason: 'contended', holders: [] };
200
+ if (!claimed.value.ran) return claimed.value;
201
+
202
+ try {
203
+ return { ran: true, result: fn() };
204
+ } finally {
205
+ try { unlinkSync(join(dir, 'exclusive.lock')); } catch { /* já solto */ }
206
+ }
207
+ }
@@ -0,0 +1,8 @@
1
+ export function pidAlive(pid) {
2
+ try {
3
+ process.kill(pid, 0);
4
+ return true;
5
+ } catch (err) {
6
+ return err?.code === 'EPERM';
7
+ }
8
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * worktree-root.js — onde as worktrees do morph-spec moram, num lugar só.
3
+ *
4
+ * Padrão: `<raiz>/worktrees/<nome>` (mais o legado `.worktrees/`). Com `worktree.root` na
5
+ * config (ou `MORPH_WORKTREE_ROOT`), as novas nascem em `<root>/<chave-do-repo>/<nome>`, FORA da
6
+ * árvore aberta no editor. A chave é `basename-<hash da raiz>`: só o basename colidiria entre dois
7
+ * repositórios de mesmo nome que dividem o mesmo root.
8
+ *
9
+ * Um root DENTRO da raiz é recusado: toda lista que se orienta pelo nome do container
10
+ * (.gitignore, graphifyignore, descoberta de testes, tar do --linux, globs do editor) deixaria de
11
+ * enxergá-lo, em silêncio. Recusado, vale o padrão e o motivo volta em `warning`.
12
+ *
13
+ * O predicado "está no container" é a UNIÃO do layout configurado com o padrão e o legado: uma
14
+ * worktree que já existe continua reconhecida depois que a chave muda. Mora em `hooks/shared`
15
+ * porque hook não importa `src/`; o `src/` importa daqui.
16
+ *
17
+ * @module worktree-root
18
+ */
19
+
20
+ import { createHash } from 'crypto';
21
+ import { readFileSync } from 'fs';
22
+ import { homedir } from 'os';
23
+ import { basename, isAbsolute, join, relative, resolve } from 'path';
24
+
25
+ // No leading dot (C1): Jest escapes it inside an absolute `rootDir` glob, so `.worktrees/` made a whole
26
+ // frontend suite report 0 tests without an error. `.worktrees/` is the pre-C1 name, still read everywhere.
27
+ export const WORKTREES_DIR = 'worktrees';
28
+ export const LEGACY_WORKTREES_DIR = '.worktrees';
29
+ export const WORKTREE_DIR_NAMES = [WORKTREES_DIR, LEGACY_WORKTREES_DIR];
30
+
31
+ const fwd = (p) => String(p).replace(/\\/g, '/');
32
+ const foldCase = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
33
+
34
+ /** `basename-<8 hex do hash da raiz>` — estável entre `R:` e `r:` no Windows. */
35
+ export function repoKey(primaryRoot) {
36
+ const root = fwd(resolve(primaryRoot));
37
+ const hash = createHash('sha1').update(foldCase(root)).digest('hex').slice(0, 8);
38
+ return `${basename(root)}-${hash}`;
39
+ }
40
+
41
+ function configuredRoot(primaryRoot, env) {
42
+ if (typeof env.MORPH_WORKTREE_ROOT === 'string' && env.MORPH_WORKTREE_ROOT.trim()) return env.MORPH_WORKTREE_ROOT.trim();
43
+ try {
44
+ const raw = readFileSync(join(primaryRoot, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, '');
45
+ const value = JSON.parse(raw)?.worktree?.root;
46
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function isInside(root, path) {
53
+ const rel = relative(root, path);
54
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
55
+ }
56
+
57
+ /**
58
+ * @param {string} primaryRoot
59
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
60
+ * @returns {{ dir: string|null, base?: string, warning?: string }} `dir` = `<root>/<repoKey>`
61
+ */
62
+ export function externalContainer(primaryRoot, { env = process.env } = {}) {
63
+ const configured = configuredRoot(primaryRoot, env);
64
+ if (!configured) return { dir: null };
65
+ const expanded = configured === '~' || /^~[\\/]/.test(configured) ? join(homedir(), configured.slice(1)) : configured;
66
+ const base = resolve(primaryRoot, expanded);
67
+ if (isInside(resolve(primaryRoot), base)) {
68
+ return { dir: null, warning: `worktree.root (${configured}) fica dentro da raiz do projeto; ignorado, valem worktrees/ — as listas de exclusão se orientam por esse nome` };
69
+ }
70
+ return { dir: fwd(join(base, repoKey(primaryRoot))), base: fwd(base) };
71
+ }
72
+
73
+ /** Where a NEW worktree lands. */
74
+ export function newWorktreesDir(primaryRoot, opts) {
75
+ return externalContainer(primaryRoot, opts).dir ?? `${fwd(resolve(primaryRoot))}/${WORKTREES_DIR}`;
76
+ }
77
+
78
+ /** Every directory that counts as "the container", external first. Forward slashes. */
79
+ export function containerDirs(primaryRoot, opts) {
80
+ const root = fwd(resolve(primaryRoot));
81
+ const external = externalContainer(primaryRoot, opts).dir;
82
+ return [...(external ? [external] : []), ...WORKTREE_DIR_NAMES.map((name) => `${root}/${name}`)];
83
+ }
84
+
85
+ /** Is `path` under any of `dirs` (a `containerDirs` result)? Ask `containerDirs` once per audit, not once per path. */
86
+ export function isUnder(dirs, path) {
87
+ const target = foldCase(fwd(resolve(path)));
88
+ return dirs.some((dir) => target.startsWith(`${foldCase(dir)}/`));
89
+ }
90
+
91
+ export function isInContainer(primaryRoot, path, opts) {
92
+ return isUnder(containerDirs(primaryRoot, opts), path);
93
+ }
@@ -149,6 +149,86 @@
149
149
  ]
150
150
  }
151
151
  }
152
+ },
153
+ "heavy": {
154
+ "type": "object",
155
+ "additionalProperties": false,
156
+ "description": "Slots per USER (shared by every session on the machine, kept in %LOCALAPPDATA%/morph-spec/heavy on Windows and ~/.local/state/morph-spec/heavy elsewhere) for the heavy nodes: build, tests and e2e of `verify`, `e2e up`, and `morph-spec heavy run`. A value it cannot read falls back to the default with a warning - this block never blocks work by being wrong. `MORPH_HEAVY=off` switches the whole semaphore off (the test suite does), `MORPH_HEAVY_SLOTS` overrides `slots`, `MORPH_HEAVY_DIR` moves the directory.",
157
+ "properties": {
158
+ "enabled": {
159
+ "type": "boolean",
160
+ "default": true,
161
+ "description": "false turns the semaphore off: no slot is taken, no RAM is checked, and `dotnet build-server shutdown` is never run (without the semaphore nothing proves another session is not compiling)."
162
+ },
163
+ "slots": {
164
+ "type": "integer",
165
+ "minimum": 1,
166
+ "default": 2,
167
+ "description": "How many heavy nodes run at once on this machine."
168
+ },
169
+ "minFreeMemGb": {
170
+ "type": "number",
171
+ "minimum": 0,
172
+ "default": 4,
173
+ "description": "Free RAM (os.freemem) below which a heavy node WAITS instead of starting and dying 'killed' for memory. It waits with or without a slot holder - the pressure often comes from outside (WSL vmmem, the IDE). 0 disables the check."
174
+ },
175
+ "waitTimeoutSec": {
176
+ "type": "number",
177
+ "minimum": 0,
178
+ "default": 600,
179
+ "description": "How long to wait for a slot or for RAM. On timeout `verify` exits 3 (busy) and persists NOTHING - a queue is neither a green nor a red gate. 0 means do not wait."
180
+ },
181
+ "maxHoldMinutes": {
182
+ "type": "number",
183
+ "exclusiveMinimum": 0,
184
+ "default": 90,
185
+ "description": "A slot held longer than this is treated as stale even if its pid is alive (pid reuse)."
186
+ },
187
+ "shutdownBuildServer": {
188
+ "type": "boolean",
189
+ "default": true,
190
+ "description": "Run `dotnet build-server shutdown` at the end of a feature-scope `verify` and of `e2e down`, only when no slot is held and no dotnet build/test is alive. Never at the end of a task-scope verify: every task would pay a cold compiler."
191
+ },
192
+ "disableNodeReuse": {
193
+ "type": "boolean",
194
+ "default": false,
195
+ "description": "Set MSBUILDDISABLENODEREUSE=1 while a heavy node runs, so MSBuild worker nodes do not stay resident."
196
+ },
197
+ "gateBash": {
198
+ "type": "boolean",
199
+ "default": false,
200
+ "description": "Opt-in. A PreToolUse hook takes a slot before a raw heavy Bash command (dotnet build|test|publish|pack, npm|pnpm|yarn test|build, vitest, jest, docker compose up|build, ...) runs and the matching Post hook gives it back. Waits up to heavy.waitTimeoutSec (capped below the hook timeout), then refuses the call with a busy message instead of running it. Foreground Bash only: a run_in_background call is not gated, and a call denied or interrupted after the hook keeps its slot until its timeout lapses, and that timeout starts before the permission prompt."
201
+ }
202
+ }
203
+ },
204
+ "worktree": {
205
+ "type": "object",
206
+ "additionalProperties": true,
207
+ "description": "Worktree behaviour of this project. Other keys are left open; the readers are src/lib/worktree/vscode-settings.js and src/lib/worktree/agent-reaper.js.",
208
+ "properties": {
209
+ "root": {
210
+ "type": "string",
211
+ "minLength": 1,
212
+ "description": "Where NEW worktrees are created, outside the project tree: they land in <root>/<repo>-<hash>/<name> (the hash keeps two repos with the same folder name apart). Absolute, `~/...`, or relative to the primary root. A path that resolves INSIDE the project root is ignored with a warning, because every exclusion list (.gitignore, graph ignore, test discovery, the --linux tar, editor globs) is keyed on worktrees/. Existing worktrees under worktrees/ keep being recognised. `MORPH_WORKTREE_ROOT` overrides it for one run."
213
+ },
214
+ "editorSettings": {
215
+ "type": "boolean",
216
+ "default": true,
217
+ "description": "false stops `init`, `update`, `worktree setup|provision` and `doctor --worktrees --prune` from adding the worktree/node_modules (and, on .NET, bin/obj) excludes to .vscode/settings.json. `MORPH_EDITOR_SETTINGS=0` does the same for one run."
218
+ },
219
+ "reap": {
220
+ "type": "object",
221
+ "additionalProperties": false,
222
+ "properties": {
223
+ "graceMinutes": {
224
+ "type": "number",
225
+ "minimum": 0,
226
+ "default": 30,
227
+ "description": "How long a sub-agent's events.jsonl must stay quiet after its AgentUsage before `worktree reap` removes the tree: a stopped agent can still be resumed by SendMessage. An invalid value falls back to 30."
228
+ }
229
+ }
230
+ }
231
+ }
152
232
  }
153
233
  }
154
234
  }
@@ -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-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invokable: true
7
- cliVersion: "8.42.0"
7
+ cliVersion: "8.43.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-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
6
6
  user-invokable: true
7
- cliVersion: "8.42.0"
7
+ cliVersion: "8.43.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
4
4
  version: core
5
5
  when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
6
6
  user-invokable: true
7
- cliVersion: "8.42.0"
7
+ cliVersion: "8.43.0"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -107,6 +107,8 @@ while (true):
107
107
  // de uma task não paga a suíte inteira). `--filter {ids}` só para FORÇAR a
108
108
  // escolha de nós; `--full` desliga o recorte.
109
109
  run: morph-spec verify {feature} {task.id} --json
110
+ // exit 3 / status == 'busy': fila de slot pesado cheia, NÃO vermelho — nada foi verificado nem gravado.
111
+ // Repita a mesma chamada; não conta como tentativa e não dispara o loop de correção.
110
112
  if verify.status == 'fail':
111
113
  // vermelho: feedback determinístico direto ao loop, SEM gastar o juiz LLM.
112
114
  feedback = verify.feedback // usado como input do próximo attempt
@@ -238,7 +240,10 @@ dispatch Agent (subagent_type: "morph-{id}") with prompt:
238
240
  por decisão sua; ao concluir, o relatório final substitui o parcial, sem o
239
241
  `PARCIAL`. Sem limite declarado, bastam os commits por marco.
240
242
  6. PROCESSOS: NUNCA `taskkill /IM node.exe` (derruba os MCPs de todas as
241
- sessões). Encerre dev server pelo PID que você mesmo subiu.
243
+ sessões). Encerre dev server pelo PID que você mesmo subiu. Build e
244
+ suíte pesados (`dotnet build`, `dotnet test`, `docker compose up`) rodam
245
+ por `morph-spec heavy run -- <comando>`: exit 3 é fila cheia, não erro do
246
+ código — espere e repita.
242
247
  7. RETORNO: o relatório no arquivo canônico é o retorno. `SendMessage` não é
243
248
  substituto do relatório e não deve ser chamado.
244
249
  8. MUTAÇÃO: nunca altere, reverta, mova ou apague código de produção para ver
@@ -296,7 +301,7 @@ Quando o `morph-spec dag {feature}` reporta `isolation.recommended: "worktree"`
296
301
  1. **Dispatch:** mesmo `subagent_type: "morph-{id}"` e standards de §3b, mais `isolation: "worktree"`. O worktree vem do hook `WorktreeCreate` do morph: nasce em `worktrees/<n>` na branch `morph-task/<n>`, com junctions (`node_modules`, `.claude`, `.morph/framework`) e `.morph/worktree.env` já prontos — se faltar infra mesmo assim, o agente roda `morph-spec worktree link --all` (fail-open); se ainda impossível, reporta "verificação: não executada" (o verify pós-merge cobre). Dev server/compose dentro do worktree já sai nas portas do bloco (o hook `worktree-env-inject` prefixa o comando; fora do Claude, `eval "$(morph-spec env)"`). O worktree nasce da branch `morph/<feature>` quando a sessão está nela (o trabalho já integrado da feature vem junto, sem `merge` manual) e com os `.env*` da raiz copiados — o prompt de dispatch não manda o agente preparar nada disso. Sessão fora de `morph/<feature>` (orquestrador na raiz) faz o worktree nascer da default: aí, e só aí, o prompt abre com `git merge --ff-only morph/<feature>`. O agente commita na branch do worktree como último ato e reporta a branch. Ele não roda `morph-spec verify` (cláusula 3): a prova é o verify do orquestrador depois do merge.
297
302
  2. **Integração (orquestrador, a cada relatório que chega):** faça o merge de cada branch reportada, uma por vez, na ordem de chegada — `git merge --no-ff <branch>`. Conflito → `git merge --abort`, marque a task de volta a `pending` com nota "merge conflict — reexecutar sequencial", siga para a próxima branch. Os paths em conflito são exatamente os de `isolation.collisions` do dag.
298
303
  3. **Pós-merge:** `verify {feature} {taskId}` no merge de cada task (dispatch rolling, §3b), depois `morph-eval` + `morph-spec score`; `morph-spec verify {feature} --json` de escopo feature UMA vez, quando nada mais está em voo (o merge é o novo ponto de integração — verificação compartilhada obrigatória).
299
- 4. **Cleanup:** o hook `WorktreeRemove` remove o worktree **limpo** ao fim do subagent (branch sobrevive) — mas não conte com ele: rode `morph-spec worktree remove --target <path>` por worktree que sobrou (NUNCA `git worktree remove` cru — as junctions do passo 1 seriam atravessadas e o `node_modules`/`.claude` da raiz apagados) + `git branch -d morph-task/<n>` das branches integradas + `git worktree prune` (fail-open). Worktree sujo (subagent não commitou) fica, vira `unintegrated-work` no `doctor --worktrees`, e a task volta a `pending`. Branches não integradas ficam e são registradas no recap com o motivo.
304
+ 4. **Cleanup:** o hook `WorktreeRemove` remove o worktree **limpo** ao fim do subagent (branch sobrevive) — mas não conte com ele: rode `morph-spec worktree remove --target <path>` por worktree que sobrou (NUNCA `git worktree remove` cru — as junctions do passo 1 seriam atravessadas e o `node_modules`/`.claude` da raiz apagados) + `git branch -d morph-task/<n>` das branches integradas + `git worktree prune` (fail-open). Depois de cada retorno de agent com `isolation`, e ao fim do batch, rode `morph-spec worktree reap --json`: remove só o worktree de `agent-<hex>` que parou limpo e sem commit exclusivo, e lista o resto com a razão (`not-a-registered-worktree` no `remove --target` seguinte = o reaper já o levou). Worktree sujo (subagent não commitou) fica, vira `agent-worktree-leftover` no `doctor --worktrees`, e a task volta a `pending`. Branches não integradas ficam e são registradas no recap com o motivo.
300
305
  5. **Agente morto no meio:** se commitou, a branch sobrevive (retomável); senão, a task volta a `pending` na lane sequencial. Registre a falha de protocolo no recap.
301
306
 
302
307
  > Anti-padrões: não misture unidades isoladas e não-isoladas no mesmo batch; o subagent nunca faz merge (integração é exclusiva do orquestrador).
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
4
4
  version: core
5
5
  when-to-invoke: ao receber um novo request de feature, antes de morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.42.0"
7
+ cliVersion: "8.43.0"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
4
4
  version: core
5
5
  when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
6
6
  user-invokable: false
7
- cliVersion: "8.42.0"
7
+ cliVersion: "8.43.0"
8
8
  ---
9
9
 
10
10
  # morph-eval
@@ -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-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
6
6
  user-invokable: true
7
- cliVersion: "8.42.0"
7
+ cliVersion: "8.43.0"
8
8
  ---
9
9
 
10
10
  # morph-hotfix
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
4
4
  version: core
5
5
  when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
6
6
  user-invokable: false
7
- cliVersion: "8.42.0"
7
+ cliVersion: "8.43.0"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -132,7 +132,10 @@ Ao iniciar cada task de `tasks.json` — antes de escrever qualquer código. Nor
132
132
  por decisão sua; ao concluir, o relatório final substitui o parcial, sem o
133
133
  `PARCIAL`. Sem limite declarado, bastam os commits por marco.
134
134
  6. PROCESSOS: NUNCA `taskkill /IM node.exe` (derruba os MCPs de todas as
135
- sessões). Encerre dev server pelo PID que você mesmo subiu.
135
+ sessões). Encerre dev server pelo PID que você mesmo subiu. Build e
136
+ suíte pesados (`dotnet build`, `dotnet test`, `docker compose up`) rodam
137
+ por `morph-spec heavy run -- <comando>`: exit 3 é fila cheia, não erro do
138
+ código — espere e repita.
136
139
  7. RETORNO: o relatório no arquivo canônico é o retorno. `SendMessage` não é
137
140
  substituto do relatório e não deve ser chamado.
138
141
  8. MUTAÇÃO: nunca altere, reverta, mova ou apague código de produção para ver
@@ -153,7 +156,9 @@ Ao iniciar cada task de `tasks.json` — antes de escrever qualquer código. Nor
153
156
  deixa o disco a meio caminho — e um relatório lido sobre uma árvore que ninguém conferiu avalia
154
157
  um estado que ninguém quis. Árvore inesperadamente suja ou `markers` não vazio é falha de
155
158
  protocolo: registre no recap antes de pontuar. Essa saída serve ao passo 7 enquanto a árvore
156
- não mudar; depois de uma correção sua, rode de novo.
159
+ não mudar; depois de uma correção sua, rode de novo. Dispatch com `isolation: "worktree"`:
160
+ depois de ler o relatório, `morph-spec worktree reap --json` remove o worktree do agent que parou
161
+ limpo e sem commit exclusivo; o que fica vem listado com a razão.
157
162
 
158
163
  Após o retorno do dispatch: **leia primeiro** `.morph/features/{feature}/3-implement/reports/[ID].md`
159
164
  — é o valor de retorno autoritativo. Trate a mensagem de texto do sub-agent, se chegou inteira,
@@ -267,7 +272,7 @@ Ao iniciar cada task de `tasks.json` — antes de escrever qualquer código. Nor
267
272
  > tasks.json + recap.md + `taskScores` do feature.json. **NÃO escreva o trace
268
273
  > manualmente** — a escrita manual foi removida da cerimônia por task.
269
274
 
270
- 9. Quando invocada por `morph-apply`, retornar o controle à skill orquestradora — ela decide qual task vem em seguida via `getReadyTasks(data)`. Não rode a suíte de testes inteira do projeto por conta própria ao fim da task quando invocada pela `morph-apply` — a verificação determinística (`morph-spec verify {feature} {task}`) é do orquestrador, logo após seu retorno (ver morph-apply §3a). **Única exceção:** a execução de evidência do passo 5 — só o nó `evals`, filtrado, com `--skip-e2e`, nunca persistida (`--no-persist`); ela não é a verificação da task e não substitui a do orquestrador. **A divisão:** no loop por task o recorte é **automático** — o `group` da task escolhe as suítes e os arquivos de teste dos `outputs` (passo 8) estreitam o comando dentro de cada uma (o relógio de uma task não paga a suíte inteira); `--filter {ids}` só força a escolha de suítes (um nó não casado aparece como `skip` nomeando o filtro, e um filtro que não casa nada é erro de uso, exit 2) e `--full` desliga o recorte. O sub-agent despachado roda só o filtro dos próprios testes, uma vez (cláusula 3), e nunca o `verify`. A suíte **inteira** roda no Gate 3 e no CI, sem filtro — `verify {feature}` nunca recorta. A suíte completa de um projeto grande não cabe numa invocação de Bash do harness — rode em background ou por partes, nunca "suba o limite" (não há constante de timeout global no CLI). Detalhe e forma de `project.tests[]`: `.morph/framework/CLI.md`, seção "Suítes de teste". Quando invocada manualmente, verificar se há próxima task ready e iniciá-la imediatamente aplicando esta skill desde o passo 1 (auto-advance). Pause apenas se encontrar ambiguidade bloqueante não coberta pelo mandate.
275
+ 9. Quando invocada por `morph-apply`, retornar o controle à skill orquestradora — ela decide qual task vem em seguida via `getReadyTasks(data)`. Não rode a suíte de testes inteira do projeto por conta própria ao fim da task quando invocada pela `morph-apply` — a verificação determinística (`morph-spec verify {feature} {task}`) é do orquestrador, logo após seu retorno (ver morph-apply §3a). **`verify` com exit 3** (`status: "busy"`) é fila de slot pesado cheia, não vermelho: nada foi verificado e nada foi gravado — repita a mesma chamada em vez de abrir uma tentativa de correção. **Única exceção:** a execução de evidência do passo 5 — só o nó `evals`, filtrado, com `--skip-e2e`, nunca persistida (`--no-persist`); ela não é a verificação da task e não substitui a do orquestrador. **A divisão:** no loop por task o recorte é **automático** — o `group` da task escolhe as suítes e os arquivos de teste dos `outputs` (passo 8) estreitam o comando dentro de cada uma (o relógio de uma task não paga a suíte inteira); `--filter {ids}` só força a escolha de suítes (um nó não casado aparece como `skip` nomeando o filtro, e um filtro que não casa nada é erro de uso, exit 2) e `--full` desliga o recorte. O sub-agent despachado roda só o filtro dos próprios testes, uma vez (cláusula 3), e nunca o `verify`. A suíte **inteira** roda no Gate 3 e no CI, sem filtro — `verify {feature}` nunca recorta. A suíte completa de um projeto grande não cabe numa invocação de Bash do harness — rode em background ou por partes, nunca "suba o limite" (não há constante de timeout global no CLI). Detalhe e forma de `project.tests[]`: `.morph/framework/CLI.md`, seção "Suítes de teste". Quando invocada manualmente, verificar se há próxima task ready e iniciá-la imediatamente aplicando esta skill desde o passo 1 (auto-advance). Pause apenas se encontrar ambiguidade bloqueante não coberta pelo mandate.
271
276
 
272
277
  ## Output esperado
273
278
  - Código implementado conforme o standard consultado (direto ou via sub-agent)