@polymorphism-tech/morph-spec 8.41.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 (102) hide show
  1. package/CLAUDE.md +14 -4
  2. package/README.md +3 -3
  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 +6 -5
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +7 -7
  10. package/framework/docs/CLI.md +10 -3
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/README.md +1 -0
  13. package/framework/hooks/claude-code/core/chain.js +2 -0
  14. package/framework/hooks/claude-code/core/comment-guard.js +107 -0
  15. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  16. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  17. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  18. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  19. package/framework/hooks/shared/agent-reap.js +89 -0
  20. package/framework/hooks/shared/comment-rules.js +171 -0
  21. package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
  22. package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
  23. package/framework/hooks/shared/e2e-check.js +3 -1
  24. package/framework/hooks/shared/graph-refresh.js +1 -9
  25. package/framework/hooks/shared/heavy-command.js +128 -0
  26. package/framework/hooks/shared/heavy-config.js +65 -0
  27. package/framework/hooks/shared/heavy-holders.js +85 -0
  28. package/framework/hooks/shared/heavy-slots.js +207 -0
  29. package/framework/hooks/shared/process-alive.js +8 -0
  30. package/framework/hooks/shared/source-files.js +34 -0
  31. package/framework/hooks/shared/worktree-root.js +93 -0
  32. package/framework/schemas/config.schema.json +80 -0
  33. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  34. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  35. package/framework/skills/core/morph-apply/SKILL.md +11 -4
  36. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  37. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  38. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  39. package/framework/skills/core/morph-implement/SKILL.md +12 -5
  40. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  41. package/framework/skills/core/morph-media/SKILL.md +1 -1
  42. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  44. package/framework/skills/core/morph-review/SKILL.md +45 -16
  45. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  46. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  47. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  48. package/framework/standards/PROVENANCE.json +10 -10
  49. package/framework/standards/STANDARDS.json +1 -1
  50. package/framework/standards/architecture/code-comments.md +9 -2
  51. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  52. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  53. package/package.json +1 -1
  54. package/src/commands/e2e/e2e.js +27 -8
  55. package/src/commands/mcp/mcp-setup.js +10 -9
  56. package/src/commands/project/doctor-procs.js +162 -0
  57. package/src/commands/project/doctor.js +42 -4
  58. package/src/commands/project/heavy.js +61 -0
  59. package/src/commands/project/init.js +1 -1
  60. package/src/commands/project/update.js +4 -0
  61. package/src/commands/project/worktree-reap.js +34 -0
  62. package/src/commands/project/worktree.js +78 -27
  63. package/src/commands/validation/mutate.js +200 -0
  64. package/src/commands/validation/verify.js +26 -1
  65. package/src/dashboard/scanner/features-scanner.js +4 -4
  66. package/src/dashboard/scanner/project-api.js +4 -4
  67. package/src/lib/ai/code-audit.js +1 -1
  68. package/src/lib/ai/registry-audit.js +1 -1
  69. package/src/lib/detectors/claude-config-detector.js +30 -1
  70. package/src/lib/heavy/cleanup.js +70 -0
  71. package/src/lib/heavy/gate.js +96 -0
  72. package/src/lib/installers/mcp-installer.js +15 -2
  73. package/src/lib/mutation/adapters.js +90 -0
  74. package/src/lib/mutation/commands.js +60 -0
  75. package/src/lib/mutation/manifest.js +138 -0
  76. package/src/lib/mutation/plan.js +36 -0
  77. package/src/lib/mutation/report.js +66 -0
  78. package/src/lib/mutation/runner.js +351 -0
  79. package/src/lib/mutation/schemata.js +60 -0
  80. package/src/lib/mutation/snapshot.js +69 -0
  81. package/src/lib/mutation/subset.js +75 -0
  82. package/src/lib/mutation/trx.js +51 -0
  83. package/src/lib/procs/classify.js +91 -0
  84. package/src/lib/procs/list.js +124 -0
  85. package/src/lib/procs/mcp-config.js +103 -0
  86. package/src/lib/procs/prune.js +25 -0
  87. package/src/lib/procs/wslconfig.js +70 -0
  88. package/src/lib/projects/registry.js +6 -2
  89. package/src/lib/tasks/dotnet-test-introspection.js +1 -1
  90. package/src/lib/tasks/linux-runner.js +30 -3
  91. package/src/lib/tasks/test-runner.js +1 -1
  92. package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
  93. package/src/lib/validators/code-budget/index.js +1 -1
  94. package/src/lib/validators/reuse/symbol-scanner.js +1 -28
  95. package/src/lib/validators/verify-runner.js +26 -8
  96. package/src/lib/worktree/agent-reaper.js +247 -0
  97. package/src/lib/worktree/dirs.js +3 -32
  98. package/src/lib/worktree/vscode-settings.js +113 -0
  99. package/src/lib/worktree/worktree-audit.js +49 -5
  100. package/src/lib/worktree/worktree-include.js +46 -0
  101. package/src/scripts/setup-infra.js +4 -0
  102. package/src/utils/hooks-installer.js +14 -6
@@ -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,34 @@
1
+ /**
2
+ * Which files count as the project's hand-written source. One reading for the
3
+ * `reuse` and `code-budget` validators and the `comment-guard` hook, so the
4
+ * three never disagree about the same file.
5
+ *
6
+ * @module source-files
7
+ */
8
+
9
+ const SCANNABLE = /\.(cs|ts|tsx|js|jsx|mjs|cjs)$/i;
10
+
11
+ // `.morph/` is real: an archived feature's `contracts.cs` entered ProspectPRO's
12
+ // graph as a second `RoulettePriority`.
13
+ const NOT_SOURCE = /(^|\/)(?:\.morph|node_modules|dist|build|out|\.next|bin|obj|coverage)\//;
14
+
15
+ const DECLARATION_FILE = /\.d\.ts$/i;
16
+
17
+ const GENERATED = /(?:\.Designer|\.g|\.g\.i)\.cs$|ModelSnapshot\.cs$/i;
18
+
19
+ /**
20
+ * @param {string} sourceFile
21
+ * @returns {boolean} project code in a scanned extension, tests included
22
+ */
23
+ export function isProjectSource(sourceFile) {
24
+ const f = String(sourceFile || '').replace(/\\/g, '/');
25
+ return SCANNABLE.test(f) && !DECLARATION_FILE.test(f) && !NOT_SOURCE.test(f);
26
+ }
27
+
28
+ /**
29
+ * @param {string} sourceFile
30
+ * @returns {boolean} generated C# nobody writes by hand
31
+ */
32
+ export function isGenerated(sourceFile) {
33
+ return GENERATED.test(String(sourceFile || ''));
34
+ }
@@ -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.41.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.41.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.41.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
@@ -193,7 +195,9 @@ dispatch Agent (subagent_type: "morph-{id}") with prompt:
193
195
  Comentário: o padrão é nenhum. Nome, tipo e teste explicam o código; sem
194
196
  `///`/JSDoc em membro de aplicação, cabeçalho, banner de seção ou história
195
197
  da task. Só o porquê que o código não mostra, em uma linha
196
- (`architecture-code-comments`).
198
+ (`architecture-code-comments`). O elo com a pós-condição vai no nome do
199
+ teste; a densidade do arquivo vizinho não é modelo. O hook `comment-guard`
200
+ bloqueia a edição que adiciona história, banner ou bloco longo.
197
201
  Não modifique spec.md, mandate.md.
198
202
 
199
203
  Contrato do relatório final:
@@ -236,7 +240,10 @@ dispatch Agent (subagent_type: "morph-{id}") with prompt:
236
240
  por decisão sua; ao concluir, o relatório final substitui o parcial, sem o
237
241
  `PARCIAL`. Sem limite declarado, bastam os commits por marco.
238
242
  6. PROCESSOS: NUNCA `taskkill /IM node.exe` (derruba os MCPs de todas as
239
- 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.
240
247
  7. RETORNO: o relatório no arquivo canônico é o retorno. `SendMessage` não é
241
248
  substituto do relatório e não deve ser chamado.
242
249
  8. MUTAÇÃO: nunca altere, reverta, mova ou apague código de produção para ver
@@ -294,7 +301,7 @@ Quando o `morph-spec dag {feature}` reporta `isolation.recommended: "worktree"`
294
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.
295
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.
296
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).
297
- 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.
298
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.
299
306
 
300
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.41.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.41.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.41.0"
7
+ cliVersion: "8.43.0"
8
8
  ---
9
9
 
10
10
  # morph-hotfix