@polymorphism-tech/morph-spec 8.42.0 → 8.44.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 (101) hide show
  1. package/CLAUDE.md +15 -5
  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 +22 -10
  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/claude-code/core/worktree-env-inject.js +1 -4
  17. package/framework/hooks/shared/agent-reap.js +89 -0
  18. package/framework/hooks/shared/e2e-check.js +3 -1
  19. package/framework/hooks/shared/graph-refresh.js +1 -9
  20. package/framework/hooks/shared/heavy-command.js +128 -0
  21. package/framework/hooks/shared/heavy-config.js +65 -0
  22. package/framework/hooks/shared/heavy-holders.js +85 -0
  23. package/framework/hooks/shared/heavy-slots.js +207 -0
  24. package/framework/hooks/shared/process-alive.js +8 -0
  25. package/framework/hooks/shared/worktree-root.js +93 -0
  26. package/framework/rules/ai-agents-standards.md +1 -0
  27. package/framework/schemas/config.schema.json +80 -0
  28. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  29. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  30. package/framework/skills/core/morph-apply/SKILL.md +8 -3
  31. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  32. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  33. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  34. package/framework/skills/core/morph-implement/SKILL.md +9 -4
  35. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  36. package/framework/skills/core/morph-media/SKILL.md +1 -1
  37. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  38. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  39. package/framework/skills/core/morph-review/SKILL.md +45 -16
  40. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  41. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  42. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  43. package/framework/standards/PROVENANCE.json +10 -10
  44. package/framework/standards/STANDARDS.json +45 -1
  45. package/framework/standards/ai-agents/tool-adapters.md +226 -0
  46. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  47. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  48. package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
  49. package/package.json +1 -1
  50. package/src/commands/e2e/e2e.js +27 -8
  51. package/src/commands/mcp/mcp-setup.js +10 -9
  52. package/src/commands/project/doctor-procs.js +162 -0
  53. package/src/commands/project/doctor.js +42 -4
  54. package/src/commands/project/heavy.js +61 -0
  55. package/src/commands/project/init.js +1 -1
  56. package/src/commands/project/update.js +4 -0
  57. package/src/commands/project/worktree-reap.js +34 -0
  58. package/src/commands/project/worktree.js +112 -43
  59. package/src/commands/state/approve.js +2 -1
  60. package/src/commands/state/finish.js +4 -0
  61. package/src/commands/state/gate-check.js +2 -1
  62. package/src/commands/validation/mutate.js +200 -0
  63. package/src/commands/validation/verify.js +39 -22
  64. package/src/dashboard/scanner/features-scanner.js +4 -4
  65. package/src/dashboard/scanner/project-api.js +4 -4
  66. package/src/lib/detectors/claude-config-detector.js +30 -1
  67. package/src/lib/e2e/compose-env.js +5 -2
  68. package/src/lib/git/evidence-staleness.js +39 -0
  69. package/src/lib/heavy/cleanup.js +70 -0
  70. package/src/lib/heavy/gate.js +96 -0
  71. package/src/lib/installers/mcp-installer.js +15 -2
  72. package/src/lib/mutation/adapters.js +90 -0
  73. package/src/lib/mutation/commands.js +60 -0
  74. package/src/lib/mutation/manifest.js +138 -0
  75. package/src/lib/mutation/plan.js +36 -0
  76. package/src/lib/mutation/report.js +66 -0
  77. package/src/lib/mutation/runner.js +351 -0
  78. package/src/lib/mutation/schemata.js +60 -0
  79. package/src/lib/mutation/snapshot.js +69 -0
  80. package/src/lib/mutation/subset.js +75 -0
  81. package/src/lib/mutation/trx.js +51 -0
  82. package/src/lib/procs/classify.js +91 -0
  83. package/src/lib/procs/list.js +124 -0
  84. package/src/lib/procs/mcp-config.js +103 -0
  85. package/src/lib/procs/prune.js +25 -0
  86. package/src/lib/procs/wslconfig.js +70 -0
  87. package/src/lib/projects/registry.js +6 -2
  88. package/src/lib/risk-detector.js +3 -3
  89. package/src/lib/tasks/eval-node.js +4 -34
  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/contracts/contract-compliance-validator.js +4 -42
  93. package/src/lib/validators/verify-runner.js +26 -8
  94. package/src/lib/worktree/agent-reaper.js +247 -0
  95. package/src/lib/worktree/dirs.js +3 -32
  96. package/src/lib/worktree/env-allocator.js +35 -5
  97. package/src/lib/worktree/vscode-settings.js +113 -0
  98. package/src/lib/worktree/worktree-audit.js +49 -5
  99. package/src/lib/worktree/worktree-include.js +46 -0
  100. package/src/scripts/setup-infra.js +4 -0
  101. package/src/utils/hooks-installer.js +11 -4
@@ -0,0 +1,91 @@
1
+ export const MIN_ORPHAN_AGE_MS = 15 * 60 * 1000;
2
+
3
+ const PLAIN_CANDIDATES = new Set(['node', 'dotnet', 'testhost', 'esbuild']);
4
+ const CHROME_NAMES = new Set(['chrome', 'chromium', 'msedge', 'chrome-headless-shell']);
5
+ const BUILD_SERVER_NAMES = new Set(['vbcscompiler', 'msbuild']);
6
+ const BUILD_SERVER_CMD = /vbcscompiler|\/nodemode:\d|msbuild(?:taskhost)?\.dll|razorserver/i;
7
+ const AUTOMATION_CHROME = /--headless|--remote-debugging-pipe|playwright|puppeteer/i;
8
+ const CLAUDE_CLI = /@anthropic-ai[\\/]claude-code/i;
9
+
10
+ export function normalizeCmd(cmd) {
11
+ return String(cmd ?? '').toLowerCase().replace(/\\/g, '/');
12
+ }
13
+
14
+ export function categorize(proc) {
15
+ const cmd = proc.cmd ?? '';
16
+ if (BUILD_SERVER_NAMES.has(proc.name)) return 'build-server';
17
+ if (proc.name === 'dotnet' && BUILD_SERVER_CMD.test(cmd)) return 'build-server';
18
+ if (CHROME_NAMES.has(proc.name)) {
19
+ return AUTOMATION_CHROME.test(cmd) && !/--type=/.test(cmd) ? 'chrome-headless' : null;
20
+ }
21
+ return PLAIN_CANDIDATES.has(proc.name) ? proc.name : null;
22
+ }
23
+
24
+ export function isClaudeSession(proc) {
25
+ return proc.name === 'claude' || CLAUDE_CLI.test(proc.cmd ?? '');
26
+ }
27
+
28
+ function protectionReason(proc, { mcpTokens, holderPids }) {
29
+ if (holderPids.has(proc.pid)) return 'segura slot do semáforo pesado';
30
+ const cmd = normalizeCmd(proc.cmd);
31
+ if (/graph-refresh\.js/.test(cmd) && /--run\b/.test(cmd)) return 'worker de refresh do grafo';
32
+ const token = mcpTokens.find((t) => cmd.includes(t));
33
+ if (token) return `linha de comando de MCP configurado (${token})`;
34
+ return null;
35
+ }
36
+
37
+ export function classifyProcs(procs, { now = Date.now(), mcpTokens = [], holderPids = [], minAgeMs = MIN_ORPHAN_AGE_MS } = {}) {
38
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
39
+ const tokens = mcpTokens.map((t) => String(t).toLowerCase());
40
+ const holders = new Set(holderPids);
41
+
42
+ const liveParent = (p) => {
43
+ const parent = byPid.get(p.ppid);
44
+ if (!parent) return null;
45
+ if (parent.startedMs !== null && p.startedMs !== null && parent.startedMs > p.startedMs) return null;
46
+ return parent;
47
+ };
48
+
49
+ const rows = [];
50
+ for (const proc of procs) {
51
+ const category = categorize(proc);
52
+ if (!category) continue;
53
+ const orphan = proc.ppid > 4 && liveParent(proc) === null;
54
+ const ageMs = proc.startedMs === null ? null : Math.max(0, now - proc.startedMs);
55
+ const row = {
56
+ pid: proc.pid,
57
+ ppid: proc.ppid,
58
+ name: proc.name,
59
+ category,
60
+ cmd: proc.cmd,
61
+ rssMb: Math.round(proc.rss / (1024 * 1024)),
62
+ startedMs: proc.startedMs,
63
+ ageMs,
64
+ orphan,
65
+ verdict: 'alive-parent',
66
+ reason: null,
67
+ };
68
+ if (orphan) {
69
+ const protectedBy = category === 'build-server' ? null : protectionReason(proc, { mcpTokens: tokens, holderPids: holders });
70
+ if (category === 'build-server') {
71
+ row.verdict = 'build-server';
72
+ row.reason = 'nó de build reutilizável pode servir outra sessão; encerre com `dotnet build-server shutdown` quando ocioso';
73
+ } else if (protectedBy) {
74
+ row.verdict = 'protected';
75
+ row.reason = protectedBy;
76
+ } else if (ageMs === null) {
77
+ row.verdict = 'protected';
78
+ row.reason = 'idade desconhecida';
79
+ } else if (ageMs < minAgeMs) {
80
+ row.verdict = 'protected';
81
+ row.reason = `mais novo que ${Math.round(minAgeMs / 60000)} min`;
82
+ } else {
83
+ row.verdict = 'orphan';
84
+ }
85
+ }
86
+ rows.push(row);
87
+ }
88
+
89
+ const sessions = procs.filter(isClaudeSession).length;
90
+ return { rows, sessions };
91
+ }
@@ -0,0 +1,124 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ const PS_PROJECTION = "[pscustomobject]@{ pid = $_.ProcessId; ppid = $_.ParentProcessId; name = $_.Name; cmd = $_.CommandLine; rss = [int64]$_.WorkingSetSize; started = if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('o') } else { $null } }";
5
+ const PS_PREFIX = '[Console]::OutputEncoding = [Text.Encoding]::UTF8; ';
6
+
7
+ export function defaultProcExec(file, args, { timeout = 30000 } = {}) {
8
+ try {
9
+ const output = execFileSync(file, args, {
10
+ encoding: 'utf8',
11
+ timeout,
12
+ windowsHide: true,
13
+ maxBuffer: 64 * 1024 * 1024,
14
+ stdio: ['ignore', 'pipe', 'pipe'],
15
+ });
16
+ return { code: 0, output };
17
+ } catch (err) {
18
+ return {
19
+ code: err.status ?? 1,
20
+ output: `${err.stdout ?? ''}${err.stderr ?? ''}`,
21
+ missing: err.code === 'ENOENT',
22
+ };
23
+ }
24
+ }
25
+
26
+ export function normalizeProcName(name) {
27
+ return String(name ?? '').toLowerCase().replace(/\.exe$/, '');
28
+ }
29
+
30
+ function toProc(p) {
31
+ const startedMs = typeof p.startedMs === 'number' ? p.startedMs : p.started ? Date.parse(p.started) : null;
32
+ return {
33
+ pid: Number(p.pid),
34
+ ppid: Number(p.ppid ?? 0),
35
+ name: normalizeProcName(p.name),
36
+ cmd: typeof p.cmd === 'string' && p.cmd.length ? p.cmd : null,
37
+ rss: Number(p.rss ?? 0),
38
+ startedMs: Number.isFinite(startedMs) ? startedMs : null,
39
+ };
40
+ }
41
+
42
+ export function parseWindowsProcs(text) {
43
+ const trimmed = String(text ?? '').trim();
44
+ if (!trimmed) return [];
45
+ const raw = JSON.parse(trimmed);
46
+ return (Array.isArray(raw) ? raw : [raw]).filter((p) => p && Number.isInteger(p.pid)).map(toProc);
47
+ }
48
+
49
+ export function parseEtime(etime) {
50
+ const m = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(String(etime).trim());
51
+ if (!m) return null;
52
+ const [, d = 0, h = 0, min, s] = m;
53
+ return ((Number(d) * 24 + Number(h)) * 60 + Number(min)) * 60 + Number(s);
54
+ }
55
+
56
+ export function parsePosixProcs(text, now = Date.now()) {
57
+ const procs = [];
58
+ for (const line of String(text ?? '').split(/\r?\n/)) {
59
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\S+)\s*(.*)$/.exec(line);
60
+ if (!m) continue;
61
+ const seconds = parseEtime(m[3]);
62
+ procs.push(toProc({
63
+ pid: Number(m[1]),
64
+ ppid: Number(m[2]),
65
+ name: m[5].split('/').pop(),
66
+ cmd: m[6] || m[5],
67
+ rss: Number(m[4]) * 1024,
68
+ startedMs: seconds === null ? null : now - seconds * 1000,
69
+ }));
70
+ }
71
+ return procs;
72
+ }
73
+
74
+ function fromFixture(path) {
75
+ const raw = JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
76
+ return (Array.isArray(raw) ? raw : []).map(toProc);
77
+ }
78
+
79
+ function windowsScript(filter) {
80
+ const query = filter ? `Get-CimInstance Win32_Process -Filter "${filter}"` : 'Get-CimInstance Win32_Process';
81
+ return `${PS_PREFIX}${query} | ForEach-Object { ${PS_PROJECTION} } | ConvertTo-Json -Compress`;
82
+ }
83
+
84
+ const POSIX_COLUMNS = 'pid=,ppid=,etime=,rss=,comm=,args=';
85
+
86
+ function runList({ pid = null, exec, platform, now }) {
87
+ if (platform === 'win32') {
88
+ const filter = pid === null ? '' : `ProcessId=${Number(pid)}`;
89
+ const res = exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScript(filter)]);
90
+ if (res.code !== 0) return { procs: null, reason: `PowerShell falhou (exit ${res.code})` };
91
+ try {
92
+ return { procs: parseWindowsProcs(res.output), reason: null };
93
+ } catch {
94
+ return { procs: null, reason: 'saída do PowerShell ilegível' };
95
+ }
96
+ }
97
+ if (platform === 'linux' || platform === 'darwin') {
98
+ const args = pid === null ? ['-eo', POSIX_COLUMNS] : ['-p', String(Number(pid)), '-o', POSIX_COLUMNS];
99
+ const res = exec('ps', args);
100
+ if (res.code !== 0 && pid === null) return { procs: null, reason: `ps falhou (exit ${res.code})` };
101
+ return { procs: parsePosixProcs(res.output, now), reason: null };
102
+ }
103
+ return { procs: null, reason: `plataforma ${platform} sem listador de processos` };
104
+ }
105
+
106
+ export function listProcesses({ exec = defaultProcExec, platform = process.platform, now = Date.now(), env = process.env } = {}) {
107
+ if (env.MORPH_PROCS_FIXTURE) {
108
+ try {
109
+ return { procs: fromFixture(env.MORPH_PROCS_FIXTURE), reason: null };
110
+ } catch {
111
+ return { procs: null, reason: 'MORPH_PROCS_FIXTURE ilegível' };
112
+ }
113
+ }
114
+ return runList({ exec, platform, now });
115
+ }
116
+
117
+ export function readProcess(pid, { exec = defaultProcExec, platform = process.platform, now = Date.now(), env = process.env } = {}) {
118
+ if (env.MORPH_PROCS_FIXTURE) {
119
+ const { procs } = listProcesses({ env });
120
+ return procs?.find((p) => p.pid === pid) ?? null;
121
+ }
122
+ const { procs } = runList({ pid, exec, platform, now });
123
+ return procs?.find((p) => p.pid === pid) ?? null;
124
+ }
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { jsoncToJson } from '../../../framework/hooks/shared/comment-stripper.js';
5
+ import { readEnabledPlugins } from '../detectors/claude-config-detector.js';
6
+
7
+ const GENERIC_TOKENS = new Set([
8
+ 'cmd', 'cmd.exe', 'npx', 'npx.cmd', 'npm', 'node', 'node.exe', 'python', 'python3', 'uv', 'uvx',
9
+ 'dotnet', 'docker', 'run', 'exec', 'bash', 'sh', 'powershell', 'pwsh',
10
+ ]);
11
+
12
+ const toolNamePart = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, '_');
13
+
14
+ function readJsonc(file, unreadable) {
15
+ if (!existsSync(file)) return null;
16
+ try {
17
+ return JSON.parse(jsoncToJson(readFileSync(file, 'utf8').replace(/^/, '')));
18
+ } catch {
19
+ unreadable.push(file);
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function sameDir(a, b) {
25
+ const norm = (p) => resolve(p).replace(/\\/g, '/').replace(/\/$/, '');
26
+ const [x, y] = [norm(a), norm(b)];
27
+ return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
28
+ }
29
+
30
+ function toServers(map, scope, extra = {}) {
31
+ return Object.entries(map && typeof map === 'object' ? map : {}).map(([name, cfg]) => ({
32
+ name,
33
+ scope,
34
+ command: cfg?.command ?? null,
35
+ args: Array.isArray(cfg?.args) ? cfg.args.map(String) : [],
36
+ url: cfg?.url ?? null,
37
+ ...extra,
38
+ }));
39
+ }
40
+
41
+ function pluginMcpServers(installPath, plugin, unreadable) {
42
+ const doc = readJsonc(join(installPath, '.mcp.json'), unreadable);
43
+ if (!doc || typeof doc !== 'object') return [];
44
+ const map = doc.mcpServers && typeof doc.mcpServers === 'object' ? doc.mcpServers : doc;
45
+ return toServers(map, 'plugin', { plugin });
46
+ }
47
+
48
+ function pickInstall(entries, projectDir) {
49
+ const list = Array.isArray(entries) ? entries.filter((e) => e?.installPath) : [];
50
+ return list.find((e) => e.projectPath && sameDir(e.projectPath, projectDir)) ?? list.find((e) => e.scope === 'user') ?? list[0] ?? null;
51
+ }
52
+
53
+ export function readMcpConfig({ projectDir, home = homedir() }) {
54
+ const unreadable = [];
55
+ const servers = [];
56
+
57
+ const project = readJsonc(join(projectDir, '.mcp.json'), unreadable);
58
+ servers.push(...toServers(project?.mcpServers, 'project'));
59
+
60
+ const userDoc = readJsonc(join(home, '.claude.json'), unreadable);
61
+ servers.push(...toServers(userDoc?.mcpServers, 'user'));
62
+ const projectKey = Object.keys(userDoc?.projects ?? {}).find((k) => sameDir(k, projectDir));
63
+ if (projectKey) servers.push(...toServers(userDoc.projects[projectKey]?.mcpServers, 'local'));
64
+
65
+ const enabledPlugins = readEnabledPlugins(projectDir, home);
66
+ const installed = readJsonc(join(home, '.claude', 'plugins', 'installed_plugins.json'), unreadable)?.plugins ?? {};
67
+ const pluginServers = [];
68
+ for (const id of enabledPlugins) {
69
+ const install = pickInstall(installed[id], projectDir);
70
+ if (install) pluginServers.push(...pluginMcpServers(install.installPath, id.split('@')[0], unreadable));
71
+ }
72
+
73
+ return { servers, pluginServers, enabledPlugins, unreadable };
74
+ }
75
+
76
+ export function findDuplicateMcps({ servers, pluginServers }) {
77
+ const duplicates = [];
78
+ for (const ps of pluginServers) {
79
+ const declared = servers.filter((s) => s.name === ps.name);
80
+ if (!declared.length) continue;
81
+ duplicates.push({
82
+ server: ps.name,
83
+ plugin: ps.plugin,
84
+ declaredIn: [...new Set(declared.map((s) => s.scope))],
85
+ toolNames: [`mcp__${ps.name}`, `mcp__plugin_${toolNamePart(ps.plugin)}_${toolNamePart(ps.name)}`],
86
+ });
87
+ }
88
+ return duplicates;
89
+ }
90
+
91
+ export function mcpCommandTokens({ servers, pluginServers }) {
92
+ const tokens = new Set();
93
+ for (const s of [...servers, ...pluginServers]) {
94
+ if (s.url && !s.command) continue;
95
+ for (const raw of [s.command, ...s.args]) {
96
+ if (typeof raw !== 'string') continue;
97
+ const token = raw.toLowerCase().replace(/\\/g, '/');
98
+ if (token.length < 4 || /^[-/]/.test(token) || token.includes('${') || GENERIC_TOKENS.has(token)) continue;
99
+ tokens.add(token);
100
+ }
101
+ }
102
+ return [...tokens];
103
+ }
@@ -0,0 +1,25 @@
1
+ import { readProcess } from './list.js';
2
+
3
+ export function pruneOrphans(rows, { read = readProcess, kill = (pid) => process.kill(pid) } = {}) {
4
+ const results = [];
5
+ for (const row of rows) {
6
+ if (row.verdict !== 'orphan') continue;
7
+ const fresh = read(row.pid);
8
+ const base = { pid: row.pid, name: row.name };
9
+ if (!fresh) {
10
+ results.push({ ...base, outcome: 'skipped', reason: 'já encerrado' });
11
+ continue;
12
+ }
13
+ if (fresh.startedMs !== row.startedMs || fresh.cmd !== row.cmd || fresh.name !== row.name) {
14
+ results.push({ ...base, outcome: 'skipped', reason: 'o PID foi reaproveitado por outro processo' });
15
+ continue;
16
+ }
17
+ try {
18
+ kill(row.pid);
19
+ results.push({ ...base, outcome: 'killed' });
20
+ } catch (err) {
21
+ results.push({ ...base, outcome: 'failed', reason: err?.code ?? err?.message ?? 'erro desconhecido' });
22
+ }
23
+ }
24
+ return results;
25
+ }
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir, totalmem } from 'node:os';
4
+
5
+ const GB = 1024 ** 3;
6
+
7
+ export function parseWslConfig(text) {
8
+ const sections = {};
9
+ let current = null;
10
+ for (const raw of String(text).replace(/^/, '').split(/\r?\n/)) {
11
+ const line = raw.trim();
12
+ if (!line || line.startsWith('#') || line.startsWith(';')) continue;
13
+ const header = /^\[([^\]]+)\]$/.exec(line);
14
+ if (header) {
15
+ current = header[1].trim().toLowerCase();
16
+ sections[current] ??= {};
17
+ continue;
18
+ }
19
+ const eq = line.indexOf('=');
20
+ if (eq < 0 || current === null) continue;
21
+ sections[current][line.slice(0, eq).trim().toLowerCase()] = line.slice(eq + 1).trim();
22
+ }
23
+ return sections;
24
+ }
25
+
26
+ export function suggestedMemoryGb(totalMemBytes) {
27
+ return Math.max(4, Math.floor((totalMemBytes / GB) * 0.4));
28
+ }
29
+
30
+ function suggestion({ memory, reclaim, totalMemBytes }) {
31
+ const lines = [];
32
+ if (!memory) lines.push('[wsl2]', `memory=${suggestedMemoryGb(totalMemBytes)}GB`);
33
+ if (!reclaim) lines.push('[experimental]', 'autoMemoryReclaim=gradual');
34
+ return lines.length ? lines.join('\n') : null;
35
+ }
36
+
37
+ export function inspectWslConfig({
38
+ home = homedir(),
39
+ platform = process.platform,
40
+ totalMemBytes = totalmem(),
41
+ exists = existsSync,
42
+ read = (p) => readFileSync(p, 'utf8'),
43
+ } = {}) {
44
+ if (platform !== 'win32') return { status: 'skip', reason: 'WSL só existe no Windows' };
45
+ const path = join(home, '.wslconfig');
46
+ if (!exists(path)) {
47
+ return {
48
+ status: 'missing',
49
+ path,
50
+ memory: null,
51
+ autoMemoryReclaim: null,
52
+ suggestion: suggestion({ memory: null, reclaim: null, totalMemBytes }),
53
+ };
54
+ }
55
+ let sections;
56
+ try {
57
+ sections = parseWslConfig(read(path));
58
+ } catch {
59
+ return { status: 'skip', reason: `${path} ilegível` };
60
+ }
61
+ const memory = sections.wsl2?.memory ?? null;
62
+ const autoMemoryReclaim = sections.experimental?.automemoryreclaim ?? null;
63
+ return {
64
+ status: 'present',
65
+ path,
66
+ memory,
67
+ autoMemoryReclaim,
68
+ suggestion: suggestion({ memory, reclaim: autoMemoryReclaim, totalMemBytes }),
69
+ };
70
+ }
@@ -42,8 +42,12 @@ export function isMorphProject(dir) {
42
42
 
43
43
  /** A worktree checkout is part of its repo's project, never a project of its own. */
44
44
  function insideWorktreeContainer(dir) {
45
- const parent = basename(dirname(dir));
46
- return WORKTREE_DIR_NAMES.includes(parent);
45
+ if (WORKTREE_DIR_NAMES.includes(basename(dirname(dir)))) return true;
46
+ try {
47
+ return /[\\/]\.git[\\/]worktrees[\\/][^\\/]+\s*$/.test(readFileSync(join(dir, '.git'), 'utf8'));
48
+ } catch {
49
+ return false;
50
+ }
47
51
  }
48
52
 
49
53
  function projectName(dir) {
@@ -15,8 +15,8 @@
15
15
  * - e2eStatus + e2eReason + gate — nó e2e do último verify. Um `skip`
16
16
  * cuja razão NÃO seja dispensa legítima pausa SÓ no ship (gate 'review').
17
17
  * Sem escopo (stamp sem o campo) cai do mesmo lado seguro que `unknown`
18
- * - verificationStale + gate: bool/str — stamp com sha != HEAD pausa SÓ no
19
- * ship (gate 'review') — código mudou depois do último verify
18
+ * - verificationStale + gate: bool/str — stamp de um commit cujo código já mudou
19
+ * (fora de .morph/) pausa SÓ no ship (gate 'review')
20
20
  * - evalScoreSource + gate: str — no ship, score que NÃO veio do avaliador
21
21
  * independente pausa (auto-avaliação não decide o Gate 3)
22
22
  * - mutationProof + gate: object — { section, min, productionCode } lido do
@@ -274,7 +274,7 @@ export function shouldPauseGate(config, signals) {
274
274
  if (signals.gate === 'review' && signals.verificationStale === true) {
275
275
  return {
276
276
  pause: true,
277
- reason: 'verification stamp desatualizado (sha difere do HEAD) — rode `morph-spec verify` de novo antes do ship',
277
+ reason: 'verification stamp desatualizado (o código mudou depois do commit do stamp) — rode `morph-spec verify` de novo antes do ship',
278
278
  };
279
279
  }
280
280
 
@@ -36,7 +36,7 @@ import { join, resolve, sep } from 'path';
36
36
  import { tmpdir } from 'os';
37
37
 
38
38
  import { isInside } from './test-runner.js';
39
- import { readAiPin } from '../standards/verified-against.js';
39
+ import { resolveAiPin } from '../ai/pin-drift.js';
40
40
 
41
41
  /** The runner kind. One spelling, imported by every reader. */
42
42
  export const EVAL_RUNNER = 'evals';
@@ -72,34 +72,6 @@ export function cacheRelevantPackages(packages) {
72
72
  return out;
73
73
  }
74
74
 
75
- /**
76
- * Read the pin from a project, wherever the framework lives.
77
- *
78
- * Two roots, ONE reader: this repository carries `framework/ai-pin.json` at the
79
- * top; an installed project carries it under `.morph/framework/`. `readAiPin`
80
- * appends `framework/ai-pin.json` to whatever root it is given, so both cases
81
- * are the same function with a different argument — never a second parser.
82
- *
83
- * Returns null when neither exists: a project with no pin simply has no stamp
84
- * ruler, and refusing to run there would make the eval node unusable outside
85
- * this framework's own stack.
86
- *
87
- * @param {string} projectPath
88
- * @returns {{pinnedAt: string, packages: object}|null}
89
- */
90
- export function readProjectPin(projectPath) {
91
- for (const root of [projectPath, join(projectPath, '.morph')]) {
92
- try {
93
- return readAiPin(root);
94
- } catch {
95
- // Next candidate. The throw is readAiPin's contract for "no pin here",
96
- // and it is the reason this loop exists instead of an existsSync probe
97
- // that would duplicate the path knowledge.
98
- }
99
- }
100
- return null;
101
- }
102
-
103
75
  /**
104
76
  * Resolve every path an eval node names, against the project root.
105
77
  *
@@ -256,11 +228,9 @@ export function evalPreflight(projectPath, node, options = {}) {
256
228
  return { status: 'fail', reason: `eval-cache-empty: ${cacheRoot} não contém nenhuma entrada de cache` };
257
229
  }
258
230
 
259
- const pin = readProjectPin(projectPath);
231
+ const pin = resolveAiPin(projectPath);
260
232
  if (!pin) {
261
- // No pin, no ruler — and a project without one is a legitimate case, not a
262
- // failure: the stamp check simply does not apply. Saying so is the point;
263
- // silently treating it as "stamp ok" would be the same thing said badly.
233
+ // No pin in the project or in the CLI package: nothing to stamp against.
264
234
  return { status: null, reason: null };
265
235
  }
266
236
 
@@ -315,7 +285,7 @@ export function evalNodeEnv(node, projectPath, options = {}) {
315
285
  MORPH_EVAL_ENVFILE: envFile,
316
286
  };
317
287
 
318
- const pin = readProjectPin(projectPath);
288
+ const pin = resolveAiPin(projectPath);
319
289
  if (pin) {
320
290
  // Compact, and only the packages that decide the cache format. The fixture
321
291
  // stamps from THIS, so it never has to read (or parse) the pin itself.
@@ -38,6 +38,7 @@
38
38
  * @module linux-runner
39
39
  */
40
40
 
41
+ import { createHash } from 'crypto';
41
42
  import { resolve } from 'path';
42
43
 
43
44
  import { defaultExec, isDockerAvailable } from '../../../framework/hooks/shared/e2e-check.js';
@@ -174,9 +175,10 @@ export function buildContainerScript({ nodes }) {
174
175
  * @param {{ projectPath: string, image: string, script: string }} params
175
176
  * @returns {string[]}
176
177
  */
177
- export function buildLinuxArgv({ projectPath, image, script }) {
178
+ export function buildLinuxArgv({ projectPath, image, script, runId = null }) {
178
179
  return [
179
180
  'run', '--rm',
181
+ ...(runId ? ['--label', `${LABEL_RUN}=${runId}`, '--label', `${LABEL_ROOT}=${rootHash(projectPath)}`] : []),
180
182
  // Read-only, and mounted at /src: the SOURCE of the copy, never the tree
181
183
  // the tests run from. See the module docs for why that distinction is the
182
184
  // entire point of the flag.
@@ -269,8 +271,14 @@ export function runLinuxSuite({ projectPath, nodes, image, timeout, exec = defau
269
271
  }
270
272
 
271
273
  const script = buildContainerScript({ nodes: runnable });
272
- const argv = buildLinuxArgv({ projectPath, image: resolvedImage, script });
273
- const run = exec('docker', argv, { cwd: projectPath, timeout });
274
+ const runId = `${process.pid}-${Date.now().toString(36)}`;
275
+ const argv = buildLinuxArgv({ projectPath, image: resolvedImage, script, runId });
276
+ let run;
277
+ try {
278
+ run = exec('docker', argv, { cwd: projectPath, timeout });
279
+ } finally {
280
+ reapLabeledContainers({ runId, exec, cwd: projectPath });
281
+ }
274
282
 
275
283
  const results = splitByMarkers(run.output, runnable).map((r) =>
276
284
  r.exitCode === null && !r.reason
@@ -291,6 +299,25 @@ export function runLinuxSuite({ projectPath, nodes, image, timeout, exec = defau
291
299
  return { results: [...enriched, ...refused], environment, argv, script, containerCode: run.code };
292
300
  }
293
301
 
302
+ export const LABEL_RUN = 'morph-spec.verify';
303
+ export const LABEL_ROOT = 'morph-spec.root';
304
+
305
+ export function rootHash(projectPath) {
306
+ const root = resolve(projectPath).replace(/\\/g, '/');
307
+ const key = process.platform === 'win32' ? root.toLowerCase() : root;
308
+ return createHash('sha1').update(key).digest('hex').slice(0, 12);
309
+ }
310
+
311
+ // A timeout kills the docker client, not the container: `--rm` never fires for it.
312
+ export function reapLabeledContainers({ runId, exec, cwd }) {
313
+ try {
314
+ const listed = exec('docker', ['ps', '-aq', '--filter', `label=${LABEL_RUN}=${runId}`], { cwd, timeout: 15_000 });
315
+ if (listed.code !== 0) return;
316
+ const ids = String(listed.output ?? '').split(/\s+/).filter((id) => /^[0-9a-f]{6,64}$/i.test(id));
317
+ if (ids.length) exec('docker', ['rm', '-f', ...ids], { cwd, timeout: 30_000 });
318
+ } catch { /* limpeza best-effort: nunca muda o veredito da suíte */ }
319
+ }
320
+
294
321
  /** Last few lines of the container output — enough to name a pull failure. */
295
322
  function tail(output, lines = 5) {
296
323
  return String(output || '').split('\n').slice(-lines).join('\n').trim();
@@ -349,7 +349,7 @@ export function resolveTestBuildTargets(node) {
349
349
  * application's solution and may therefore never have been restored at all.
350
350
  * F29's project was exactly that project.
351
351
  */
352
- function dotnetBuildCommandFor(target, projectPath) {
352
+ export function dotnetBuildCommandFor(target, projectPath) {
353
353
  const base = getBuildSteps('dotnet', { cwd: projectPath || '.' })[0]?.command;
354
354
  if (!base) return null;
355
355
  const [verb, subcommand, ...flags] = base.split(/\s+/);
@@ -1,56 +1,21 @@
1
- /**
2
- * Contract Compliance Validator
3
- *
4
- * Parses contracts.cs from feature outputs and verifies that the project code
5
- * actually implements all defined interfaces, methods, DTOs, and enums.
6
- *
7
- * MORPH-SPEC 3.0 - Phase A: Active Validation Pipeline
8
- */
9
-
10
1
  import { readFileSync, existsSync } from 'fs';
11
2
  import { join } from 'path';
12
3
  import { glob } from 'glob';
13
4
  import chalk from 'chalk';
14
5
  import { getAbsoluteOutputPath, resolveContractsOutputType } from '../../../core/paths/output-schema.js';
15
6
 
16
- /**
17
- * Validate that contracts.cs interfaces are implemented in project code
18
- *
19
- * @param {string} projectPath - Project root path
20
- * @param {string} featureName - Feature name
21
- * @param {Object} options - Options
22
- * @returns {Object} { status, errors, warnings, issues, coverage }
23
- */
24
7
  export async function validateContracts(projectPath, featureName, options = {}) {
25
- // `'contracts'` is not an output type — `getAbsoluteOutputPath` threw
26
- // "Unknown output type" on every call. The runner already resolves this the
27
- // right way before deciding to schedule this validator
28
- // (`resolveContractsOutputType()`); using the same resolver here is what
29
- // keeps the two from disagreeing again.
30
8
  const contractsPath = getAbsoluteOutputPath(projectPath, featureName, resolveContractsOutputType());
31
9
 
32
- if (!existsSync(contractsPath)) {
33
- return {
34
- status: 'ok',
35
- errors: 0,
36
- warnings: 0,
37
- issues: [],
38
- coverage: null,
39
- message: 'No contracts.cs found — skipping'
40
- };
41
- }
10
+ const skipped = (message) => ({ status: 'ok', errors: 0, warnings: 0, issues: [], coverage: null, message });
11
+
12
+ if (!existsSync(contractsPath)) return skipped('No spec.md found — skipping');
42
13
 
43
14
  const contractsContent = readFileSync(contractsPath, 'utf-8');
44
15
  const contracts = parseContracts(contractsContent);
45
16
 
46
17
  if (contracts.interfaces.length === 0 && contracts.records.length === 0 && contracts.enums.length === 0) {
47
- return {
48
- status: 'ok',
49
- errors: 0,
50
- warnings: 1,
51
- issues: [{ level: 'warning', message: 'contracts.cs exists but no interfaces/records/enums found' }],
52
- coverage: null
53
- };
18
+ return skipped('spec.md declares no interfaces/records/enums — skipping');
54
19
  }
55
20
 
56
21
  // Find all .cs files in the project (excluding .morph outputs, bin, obj)
@@ -168,9 +133,6 @@ export async function validateContracts(projectPath, featureName, options = {})
168
133
  };
169
134
  }
170
135
 
171
- /**
172
- * Parse contracts.cs content and extract interfaces, records, and enums
173
- */
174
136
  function parseContracts(content) {
175
137
  const interfaces = [];
176
138
  const records = [];