@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,60 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { applyRegions } from './manifest.js';
3
+
4
+ export const SWITCH_ENV = 'MORPH_MUTANT';
5
+
6
+ export function switchClassName(csprojRel) {
7
+ return `MorphMutationSwitch_${createHash('md5').update(csprojRel).digest('hex').slice(0, 6)}`;
8
+ }
9
+
10
+ export function switchSource(className) {
11
+ return [
12
+ '// <auto-generated/>',
13
+ '#nullable enable',
14
+ '#pragma warning disable',
15
+ `internal static class ${className}`,
16
+ '{',
17
+ ` private static readonly string? Active = System.Environment.GetEnvironmentVariable("${SWITCH_ENV}");`,
18
+ ' internal static bool On(string id) => Active == id;',
19
+ '}',
20
+ '',
21
+ ].join('\n');
22
+ }
23
+
24
+ const wrap = (className, region) =>
25
+ `(${className}.On("${region.id}") ? (${region.replaceText}) : (${region.findText}))`;
26
+
27
+ /**
28
+ * A C# file with every region behind its switch; the original text stays as the else branch.
29
+ * Each patched span comes back so a compiler error can be pinned on its mutant.
30
+ */
31
+ export function patchFile(text, regions, className) {
32
+ return applyRegions(text, regions, (region) => wrap(className, region));
33
+ }
34
+
35
+ const MSBUILD_ERROR = /^(?<file>.+?)\((?<line>\d+),(?<col>\d+)\): error (?<code>[A-Z]+\d+): (?<msg>.*?)(?: \[.*\])?\s*$/gm;
36
+
37
+ export function parseCompilerErrors(output) {
38
+ return [...String(output).matchAll(MSBUILD_ERROR)].map((m) => ({
39
+ file: m.groups.file.trim(), line: Number(m.groups.line), code: m.groups.code, message: m.groups.msg.trim(),
40
+ }));
41
+ }
42
+
43
+ /**
44
+ * An error inside a patched span names its mutant; one elsewhere in a patched file
45
+ * (an `out` variable the switch left unassigned) names every mutant of that file.
46
+ */
47
+ export function blame(errors, patched) {
48
+ const blamed = new Set();
49
+ const unmapped = [];
50
+ for (const err of errors) {
51
+ const file = patched.find((p) => samePath(p.abs, err.file));
52
+ if (!file) { unmapped.push(err); continue; }
53
+ const inSpan = [...file.spans].find(([, s]) => err.line >= s.from && err.line <= s.to);
54
+ if (inSpan) blamed.add(inSpan[0]);
55
+ else for (const id of file.spans.keys()) blamed.add(id);
56
+ }
57
+ return { blamed: [...blamed], unmapped };
58
+ }
59
+
60
+ const samePath = (a, b) => a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase();
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+
5
+ export const md5Of = (bytes) => createHash('md5').update(bytes).digest('hex');
6
+ export const md5OfFile = (abs) => md5Of(readFileSync(abs));
7
+
8
+ /**
9
+ * Copies of the original bytes, kept outside the working tree, plus a journal
10
+ * that survives the process: a run killed halfway is undone by the next
11
+ * `--restore` from the journal alone.
12
+ */
13
+ export function openSnapshot({ dir, root, files, runId }) {
14
+ mkdirSync(dir, { recursive: true });
15
+ const entries = files.map((file, index) => {
16
+ const abs = join(root, file);
17
+ const bytes = readFileSync(abs);
18
+ const backup = join(dir, `${index}.bak`);
19
+ writeFileSync(backup, bytes);
20
+ return { file, md5: md5Of(bytes), backup, created: false };
21
+ });
22
+ const snapshot = { runId, root, dir, entries, generated: [], state: 'open' };
23
+ writeJournal(snapshot);
24
+ return snapshot;
25
+ }
26
+
27
+ export function trackGenerated(snapshot, file) {
28
+ if (!snapshot.generated.includes(file)) snapshot.generated.push(file);
29
+ writeJournal(snapshot);
30
+ }
31
+
32
+ export function writeJournal(snapshot) {
33
+ writeFileSync(join(snapshot.dir, 'journal.json'), JSON.stringify(snapshot, null, 2));
34
+ }
35
+
36
+ export function readJournal(dir) {
37
+ const path = join(dir, 'journal.json');
38
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
39
+ }
40
+
41
+ /**
42
+ * Writes the original bytes back — a fresh write, so the file is NEWER than
43
+ * any binary built from a mutant. Restoring the old mtime would let the build
44
+ * consider the mutant binary up to date and never recompile the source.
45
+ */
46
+ export function restoreSnapshot(snapshot, { rewrite = true } = {}) {
47
+ const report = [];
48
+ for (const entry of snapshot.entries) {
49
+ const abs = join(snapshot.root, entry.file);
50
+ const before = existsSync(abs) ? md5OfFile(abs) : null;
51
+ if (rewrite || before !== entry.md5) {
52
+ mkdirSync(dirname(abs), { recursive: true });
53
+ writeFileSync(abs, readFileSync(entry.backup));
54
+ }
55
+ const after = md5OfFile(abs);
56
+ report.push({ file: entry.file, md5Original: entry.md5, md5Before: before, md5After: after, ok: after === entry.md5 });
57
+ }
58
+ for (const file of snapshot.generated) {
59
+ const abs = join(snapshot.root, file);
60
+ if (existsSync(abs)) unlinkSync(abs);
61
+ }
62
+ snapshot.state = report.every((r) => r.ok) ? 'restored' : 'restore-failed';
63
+ writeJournal(snapshot);
64
+ return report;
65
+ }
66
+
67
+ export function discardSnapshot(snapshot) {
68
+ rmSync(snapshot.dir, { recursive: true, force: true });
69
+ }
@@ -0,0 +1,75 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { dirname, join, relative, resolve } from 'node:path';
3
+ import { csharpClasses } from '../tasks/dotnet-test-introspection.js';
4
+ import { relatedCommand } from './commands.js';
5
+
6
+ const SKIP_DIRS = new Set(['bin', 'obj', 'node_modules', '.git', 'worktrees', 'TestResults']);
7
+ const SAFE_CLASS = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+ const MIN_TYPE_LENGTH = 4;
9
+
10
+ const posix = (p) => p.replace(/\\/g, '/');
11
+
12
+ function walkCs(dir, out = []) {
13
+ let entries = [];
14
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
15
+ for (const e of entries) {
16
+ if (e.isDirectory()) {
17
+ if (!SKIP_DIRS.has(e.name)) walkCs(join(dir, e.name), out);
18
+ } else if (e.name.endsWith('.cs')) {
19
+ out.push(join(dir, e.name));
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+
25
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
+
27
+ /**
28
+ * The tests to run FIRST, chosen by the tool: files the feature changed, and files naming a
29
+ * type the mutated files declare. A mutant the subset does not kill goes to the whole suite.
30
+ */
31
+ export function dotnetSubset({ root, testProjectDirs, mutatedFiles, changed = [] }) {
32
+ const changedSet = new Set(changed.map(posix));
33
+ const mutatedTypes = new Set();
34
+ for (const file of mutatedFiles) {
35
+ for (const t of csharpClasses(resolve(root, file)).types) if (t.length >= MIN_TYPE_LENGTH) mutatedTypes.add(t);
36
+ }
37
+ const mention = mutatedTypes.size > 0
38
+ ? new RegExp(`\\b(?:${[...mutatedTypes].map(escapeRe).join('|')})\\b`) : null;
39
+
40
+ const byChange = new Set();
41
+ const byMention = new Set();
42
+ const classes = new Set();
43
+ for (const dir of testProjectDirs) {
44
+ for (const abs of walkCs(resolve(root, dir))) {
45
+ const info = csharpClasses(abs);
46
+ if (!info.scannable || !info.hasTestAttribute || info.names.length === 0) continue;
47
+ const rel = posix(relative(root, abs));
48
+ const viaChange = changedSet.has(rel);
49
+ const viaMention = !viaChange && mention !== null && mention.test(readFileSync(abs, 'utf8'));
50
+ if (!viaChange && !viaMention) continue;
51
+ (viaChange ? byChange : byMention).add(rel);
52
+ for (const name of info.names) classes.add(name);
53
+ }
54
+ }
55
+ const unsafe = [...classes].find((c) => !SAFE_CLASS.test(c));
56
+ if (unsafe) return { usable: false, reason: `nome de classe "${unsafe}" não é seguro num filtro` };
57
+ if (classes.size === 0) return { usable: false, reason: 'nenhum arquivo de teste alterado pela feature nem citando os tipos mutados' };
58
+ return {
59
+ usable: true,
60
+ classes: [...classes].sort(),
61
+ filter: [...classes].sort().map((c) => `FullyQualifiedName~${c}`).join('|'),
62
+ files: { changed: [...byChange].sort(), mentions: [...byMention].sort() },
63
+ };
64
+ }
65
+
66
+ export function jsSubset({ runner, command, root, mutatedFiles }) {
67
+ const files = mutatedFiles.filter((f) => existsSync(resolve(root, f)) && statSync(resolve(root, f)).isFile());
68
+ const cmd = relatedCommand(runner, command, files);
69
+ if (cmd === null) return { usable: false, reason: `runner "${runner}" sem seleção por arquivo relacionado neste formato de comando` };
70
+ return { usable: true, command: cmd, files: { related: files } };
71
+ }
72
+
73
+ export function testProjectDirsOf(root, csprojs) {
74
+ return [...new Set(csprojs.map((c) => posix(relative(root, dirname(resolve(root, c))))))];
75
+ }
@@ -0,0 +1,51 @@
1
+ const ENTITIES = { '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&apos;': "'" };
2
+
3
+ export function decodeXml(text) {
4
+ return text
5
+ .replace(/&(?:lt|gt|amp|quot|apos);/g, (m) => ENTITIES[m])
6
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
7
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)));
8
+ }
9
+
10
+ const attr = (attrs, name) => new RegExp(`\\b${name}="([^"]*)"`).exec(attrs)?.[1] ?? null;
11
+ const count = (attrs, name) => Number(attr(attrs, name) ?? 0);
12
+
13
+ /**
14
+ * What a VSTest TRX says about a run: the counters, and for every failed test
15
+ * its name and the FIRST message line — the literal reason a mutation report
16
+ * quotes. Linear over the text: a full suite writes several MB.
17
+ */
18
+ export function parseTrx(xml) {
19
+ const counters = /<Counters\b([^>]*)\/?>/.exec(xml)?.[1] ?? null;
20
+ const summary = /<ResultSummary\b([^>]*)>/.exec(xml)?.[1] ?? '';
21
+ const failedTests = [];
22
+ const tag = /<UnitTestResult\b([^>]*?)(\/?)>/g;
23
+ for (let m = tag.exec(xml); m; m = tag.exec(xml)) {
24
+ if (attr(m[1], 'outcome') !== 'Failed') continue;
25
+ let message = '';
26
+ if (m[2] !== '/') {
27
+ const close = xml.indexOf('</UnitTestResult>', tag.lastIndex);
28
+ const body = xml.slice(tag.lastIndex, close === -1 ? undefined : close);
29
+ const raw = /<Message>([\s\S]*?)<\/Message>/.exec(body)?.[1] ?? '';
30
+ message = decodeXml(raw.replace(/^<!\[CDATA\[|\]\]>$/g, '')).trim();
31
+ }
32
+ failedTests.push({ name: decodeXml(attr(m[1], 'testName') ?? '?'), message });
33
+ }
34
+ return {
35
+ parsed: counters !== null,
36
+ total: counters ? count(counters, 'total') : 0,
37
+ executed: counters ? count(counters, 'executed') : 0,
38
+ passed: counters ? count(counters, 'passed') : 0,
39
+ failed: counters ? count(counters, 'failed') : failedTests.length,
40
+ aborted: counters ? count(counters, 'aborted') + count(counters, 'timeout') : 0,
41
+ runOutcome: attr(summary, 'outcome'),
42
+ failedTests,
43
+ };
44
+ }
45
+
46
+ export const methodOf = (testName) => String(testName).replace(/\s*\(.*$/s, '').trim();
47
+
48
+ export function firstLines(message, max = 2, width = 240) {
49
+ const lines = String(message).split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, max).join(' ⏎ ');
50
+ return lines.length > width ? `${lines.slice(0, width)}…` : lines;
51
+ }
@@ -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
+ }