@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
@@ -42,9 +42,13 @@ import {
42
42
  import { isSecondaryWorktree } from '../../../framework/hooks/shared/git-paths.js';
43
43
  import { buildComposeEnv } from '../../lib/e2e/compose-env.js';
44
44
  import { findWorktreeForFeature, resolvePrimaryRoot } from '../project/worktree.js';
45
+ import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
46
+ import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
47
+ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
45
48
 
46
49
  /** Exit code for a usage error, matching `verify`'s convention. */
47
50
  const EXIT_USAGE = 2;
51
+ const EXIT_BUSY = 3;
48
52
 
49
53
  // ============================================================================
50
54
  // Shared context
@@ -158,13 +162,27 @@ export async function e2eUpCommand(featureName, options = {}) {
158
162
  process.exit(EXIT_USAGE);
159
163
  }
160
164
 
161
- const result = bringUpStack(ctx.cwd, {
162
- composeFile: ctx.composeFile,
163
- projectName: ctx.projectName,
164
- gitCommonDir: ctx.gitCommonDir,
165
- env: ctx.env,
166
- forceFresh: Boolean(options.fresh),
167
- });
165
+ const gate = createHeavyGate({ projectPath: ctx.cwd, label: 'e2e up', feature: featureName });
166
+ let result;
167
+ try {
168
+ gate.enter('e2e');
169
+ result = bringUpStack(ctx.cwd, {
170
+ composeFile: ctx.composeFile,
171
+ projectName: ctx.projectName,
172
+ gitCommonDir: ctx.gitCommonDir,
173
+ env: ctx.env,
174
+ forceFresh: Boolean(options.fresh),
175
+ });
176
+ } catch (err) {
177
+ if (!(err instanceof HeavyBusyError)) throw err;
178
+ if (options.json) {
179
+ console.log(JSON.stringify({ status: 'busy', feature: featureName, busy: { reason: err.reason, waitedMs: err.waitedMs } }, null, 2));
180
+ }
181
+ console.error(chalk.yellow(`\n⏳ e2e up não rodou: ${err.message}. Tente de novo quando liberar (\`morph-spec heavy status\`).\n`));
182
+ process.exit(EXIT_BUSY);
183
+ } finally {
184
+ gate.release();
185
+ }
168
186
 
169
187
  const auth =
170
188
  result.status === 'pass'
@@ -313,10 +331,11 @@ export async function e2eDownCommand(featureName, options = {}) {
313
331
  volumes: Boolean(options.volumes),
314
332
  });
315
333
  const stampDeleted = deleteStamp(ctx.gitCommonDir, ctx.projectName);
334
+ const cleanup = afterHeavyRun({ projectPath: ctx.cwd, config: readHeavyConfig(ctx.cwd), scope: 'e2e-down' });
316
335
 
317
336
  if (options.json) {
318
337
  console.log(JSON.stringify(
319
- { feature: featureName, project: ctx.projectName, tree: ctx.tree, status: result.status, stampDeleted },
338
+ { feature: featureName, project: ctx.projectName, tree: ctx.tree, status: result.status, stampDeleted, cleanup },
320
339
  null, 2,
321
340
  ));
322
341
  } else if (result.status === 'pass') {
@@ -100,13 +100,14 @@ export async function mcpSetupCommand(name, options) {
100
100
  const integrations = await detectIntegrations(targetPath);
101
101
  const claudeConfig = await detectClaudeConfig(targetPath);
102
102
  const existingMcps = claudeConfig.mcpServers || [];
103
+ const enabledPlugins = claudeConfig.enabledPlugins || [];
103
104
 
104
105
  if (options.list) {
105
- return showStatusList(targetPath, stack, existingMcps, integrations);
106
+ return showStatusList(targetPath, stack, existingMcps, integrations, enabledPlugins);
106
107
  }
107
108
 
108
109
  if (options.auto) {
109
- return autoInstall(targetPath, stack, existingMcps, integrations);
110
+ return autoInstall(targetPath, stack, existingMcps, integrations, enabledPlugins);
110
111
  }
111
112
 
112
113
  if (name) {
@@ -114,7 +115,7 @@ export async function mcpSetupCommand(name, options) {
114
115
  }
115
116
 
116
117
  // Interactive wizard
117
- return interactiveWizard(targetPath, stack, existingMcps, integrations);
118
+ return interactiveWizard(targetPath, stack, existingMcps, integrations, enabledPlugins);
118
119
  } catch (err) {
119
120
  console.error(chalk.red(`Error: ${err.message}`));
120
121
  process.exit(1);
@@ -124,8 +125,8 @@ export async function mcpSetupCommand(name, options) {
124
125
  /**
125
126
  * Show MCP status list (--list)
126
127
  */
127
- async function showStatusList(targetPath, stack, existingMcps, integrations) {
128
- const orchestration = orchestrateMcpSetup(targetPath, stack, existingMcps, { integrations });
128
+ async function showStatusList(targetPath, stack, existingMcps, integrations, enabledPlugins) {
129
+ const orchestration = orchestrateMcpSetup(targetPath, stack, existingMcps, { integrations, enabledPlugins });
129
130
  const statusRows = formatMcpStatusTable(orchestration, {});
130
131
 
131
132
  console.log(chalk.cyan('\n MCP Server Status\n'));
@@ -158,8 +159,8 @@ async function showStatusList(targetPath, stack, existingMcps, integrations) {
158
159
  /**
159
160
  * Auto-install credential-free MCPs (--auto)
160
161
  */
161
- async function autoInstall(targetPath, stack, existingMcps, integrations) {
162
- const orchestration = orchestrateMcpSetup(targetPath, stack, existingMcps, { autoOnly: true, integrations });
162
+ async function autoInstall(targetPath, stack, existingMcps, integrations, enabledPlugins) {
163
+ const orchestration = orchestrateMcpSetup(targetPath, stack, existingMcps, { autoOnly: true, integrations, enabledPlugins });
163
164
  const autoNames = Object.keys(orchestration.autoInstallable);
164
165
 
165
166
  if (autoNames.length === 0) {
@@ -419,8 +420,8 @@ async function setupSpecificMcp(targetPath, name, stack, existingMcps) {
419
420
  /**
420
421
  * Interactive wizard showing all MCPs
421
422
  */
422
- async function interactiveWizard(targetPath, stack, existingMcps, integrations) {
423
- const orchestration = orchestrateMcpSetup(targetPath, stack, existingMcps, { integrations });
423
+ async function interactiveWizard(targetPath, stack, existingMcps, integrations, enabledPlugins) {
424
+ const orchestration = orchestrateMcpSetup(targetPath, stack, existingMcps, { integrations, enabledPlugins });
424
425
 
425
426
  console.log(chalk.cyan('\n MCP Setup Wizard'));
426
427
  console.log(chalk.gray(` Stack: ${stack}`));
@@ -0,0 +1,162 @@
1
+ import chalk from 'chalk';
2
+ import { logger } from '../../utils/logger.js';
3
+ import { listProcesses } from '../../lib/procs/list.js';
4
+ import { classifyProcs } from '../../lib/procs/classify.js';
5
+ import { readMcpConfig, findDuplicateMcps, mcpCommandTokens } from '../../lib/procs/mcp-config.js';
6
+ import { inspectWslConfig } from '../../lib/procs/wslconfig.js';
7
+ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
8
+ import { heavyDir, heavyStatus } from '../../../framework/hooks/shared/heavy-holders.js';
9
+
10
+ function readHolderPids(targetPath) {
11
+ try {
12
+ const config = readHeavyConfig(targetPath);
13
+ const { holders } = heavyStatus({ dir: heavyDir(), maxHoldMs: config.maxHoldMinutes * 60_000 });
14
+ return holders.map((h) => h.pid).filter(Number.isInteger);
15
+ } catch {
16
+ return [];
17
+ }
18
+ }
19
+
20
+ export function formatAge(ms) {
21
+ if (ms === null) return '?';
22
+ const min = Math.floor(ms / 60000);
23
+ if (min < 60) return `${min}min`;
24
+ const h = Math.floor(min / 60);
25
+ if (h < 48) return `${h}h${String(min % 60).padStart(2, '0')}`;
26
+ return `${Math.floor(h / 24)}d`;
27
+ }
28
+
29
+ const VERDICT_TEXT = {
30
+ orphan: 'órfão — `--prune` encerra por PID',
31
+ protected: 'protegido',
32
+ 'build-server': 'nó de build',
33
+ };
34
+
35
+ export function collectProcsReport(targetPath, deps = {}) {
36
+ const d = { listProcesses, readMcpConfig, inspectWslConfig, now: Date.now(), ...deps };
37
+ d.holderPids ??= readHolderPids(targetPath);
38
+ const listing = d.listProcesses();
39
+ const mcp = d.readMcpConfig({ projectDir: targetPath });
40
+
41
+ let procs;
42
+ let vmmemMb = 0;
43
+ if (listing.procs === null) {
44
+ procs = { status: 'skip', reason: listing.reason };
45
+ } else {
46
+ const { rows, sessions } = classifyProcs(listing.procs, {
47
+ now: d.now,
48
+ mcpTokens: mcpCommandTokens(mcp),
49
+ holderPids: d.holderPids,
50
+ });
51
+ procs = { status: 'ok', sessions, total: rows.length, rows };
52
+ const vmmem = listing.procs.filter((p) => p.name.startsWith('vmmem')).reduce((sum, p) => sum + p.rss, 0);
53
+ vmmemMb = Math.round(vmmem / (1024 * 1024));
54
+ }
55
+
56
+ return {
57
+ procs,
58
+ mcp: {
59
+ duplicates: findDuplicateMcps(mcp),
60
+ unreadable: mcp.unreadable,
61
+ servers: mcp.servers.length,
62
+ pluginServers: mcp.pluginServers.length,
63
+ },
64
+ wsl: { ...d.inspectWslConfig(), vmmemMb },
65
+ };
66
+ }
67
+
68
+ function renderProcs(procs) {
69
+ if (procs.status === 'skip') {
70
+ console.log(chalk.gray(` ○ Processos — não verificado: ${procs.reason}`));
71
+ return;
72
+ }
73
+ const orphans = procs.rows.filter((r) => r.orphan);
74
+ console.log(` ${chalk.bold('Processos')} — ${procs.total} candidato(s) (node, dotnet, testhost, esbuild, chrome headless), `
75
+ + `${procs.total - orphans.length} com pai vivo, ${orphans.length} sem pai; ${procs.sessions} sessão(ões) do Claude viva(s)`);
76
+ if (!orphans.length) {
77
+ console.log(chalk.green(' ✓ nenhum processo órfão'));
78
+ return;
79
+ }
80
+ for (const r of orphans) {
81
+ const label = VERDICT_TEXT[r.verdict] ?? r.verdict;
82
+ const mark = r.verdict === 'orphan' ? chalk.yellow(' ⚠') : chalk.gray(' ○');
83
+ console.log(`${mark} pid ${r.pid} ${r.name} ${r.rssMb} MB, ${formatAge(r.ageMs)} — ${label}${r.reason ? `: ${r.reason}` : ''}`);
84
+ if (r.cmd) console.log(chalk.gray(` ${r.cmd.length > 110 ? `${r.cmd.slice(0, 107)}...` : r.cmd}`));
85
+ }
86
+ }
87
+
88
+ function renderMcp(mcp) {
89
+ console.log(` ${chalk.bold('MCP duplicado')} — ${mcp.servers} servidor(es) de configuração, ${mcp.pluginServers} de plugin`);
90
+ if (!mcp.duplicates.length) console.log(chalk.green(' ✓ nenhum MCP declarado no projeto e também entregue por plugin'));
91
+ for (const dup of mcp.duplicates) {
92
+ console.log(chalk.yellow(` ⚠ ${dup.server}: declarado em ${dup.declaredIn.join(', ')} e entregue pelo plugin ${dup.plugin} — sobem 2 processos`));
93
+ console.log(chalk.gray(` tools: ${dup.toolNames.join(' e ')}`));
94
+ console.log(chalk.gray(` conserto (só sugestão): remova "${dup.server}" do .mcp.json, ou desligue o plugin ${dup.plugin}`));
95
+ }
96
+ for (const file of mcp.unreadable) console.log(chalk.gray(` ○ não verificado: ${file} (ilegível)`));
97
+ }
98
+
99
+ function renderWsl(wsl) {
100
+ if (wsl.status === 'skip') {
101
+ console.log(chalk.gray(` ○ WSL — não verificado: ${wsl.reason}`));
102
+ return;
103
+ }
104
+ const held = wsl.vmmemMb > 0 ? ` (vmmem segura ${wsl.vmmemMb} MB agora)` : '';
105
+ if (wsl.status === 'missing') console.log(chalk.yellow(` ⚠ WSL — ${wsl.path} ausente${held}`));
106
+ else if (wsl.suggestion) console.log(chalk.yellow(` ⚠ WSL — ${wsl.path} sem limite de memória ou sem devolução gradual${held}`));
107
+ else console.log(chalk.green(` ✓ WSL — ${wsl.path} com memory=${wsl.memory} e autoMemoryReclaim=${wsl.autoMemoryReclaim}`));
108
+ if (wsl.suggestion) {
109
+ console.log(chalk.gray(' conserto (só sugestão; o morph-spec não escreve fora do repo), acrescente a esse arquivo:'));
110
+ for (const line of wsl.suggestion.split('\n')) console.log(chalk.gray(` ${line}`));
111
+ console.log(chalk.gray(' depois rode `wsl --shutdown` para o WSL reler o arquivo'));
112
+ }
113
+ }
114
+
115
+ function renderPrune(results) {
116
+ console.log(` ${chalk.bold('Poda')}`);
117
+ if (!results.length) {
118
+ console.log(chalk.green(' ✓ nada a podar'));
119
+ return;
120
+ }
121
+ for (const r of results) {
122
+ const line = `pid ${r.pid} ${r.name}${r.reason ? ` — ${r.reason}` : ''}`;
123
+ if (r.outcome === 'killed') console.log(chalk.green(` ✓ encerrado: ${line}`));
124
+ else if (r.outcome === 'skipped') console.log(chalk.gray(` ○ pulado: ${line}`));
125
+ else console.log(chalk.red(` ✗ falhou: ${line}`));
126
+ }
127
+ }
128
+
129
+ export async function doctorProcsCommand(targetPath, options = {}) {
130
+ const report = collectProcsReport(targetPath, options.deps);
131
+
132
+ if (options.prune) {
133
+ const { pruneOrphans } = await import('../../lib/procs/prune.js');
134
+ const { afterHeavyRun } = await import('../../lib/heavy/cleanup.js');
135
+ report.prune = report.procs.status === 'ok' ? pruneOrphans(report.procs.rows, options.pruneDeps) : [];
136
+ report.buildServer = afterHeavyRun({
137
+ projectPath: targetPath, config: readHeavyConfig(targetPath), scope: 'prune', deps: options.cleanupDeps,
138
+ });
139
+ }
140
+
141
+ if (options.json) {
142
+ console.log(JSON.stringify(report, null, 2));
143
+ } else {
144
+ logger.header(options.prune ? 'Processos e memória (com --prune)' : 'Processos e memória (somente leitura)');
145
+ renderProcs(report.procs);
146
+ logger.blank();
147
+ renderMcp(report.mcp);
148
+ logger.blank();
149
+ renderWsl(report.wsl);
150
+ if (report.prune) {
151
+ logger.blank();
152
+ renderPrune(report.prune);
153
+ const bs = report.buildServer;
154
+ const mark = bs.status === 'ran' ? chalk.green('✓') : bs.status === 'failed' ? chalk.red('✗') : chalk.gray('○');
155
+ console.log(` ${mark} nós de build (dotnet build-server shutdown): ${bs.status}${bs.reason ? ` — ${bs.reason}` : ''}`);
156
+ }
157
+ logger.blank();
158
+ }
159
+
160
+ process.exitCode = report.prune?.some((r) => r.outcome === 'failed') ? 1 : 0;
161
+ return report;
162
+ }
@@ -725,7 +725,24 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
725
725
  }
726
726
  }
727
727
 
728
+ const { reapAgentWorktrees } = await import('../../lib/worktree/agent-reaper.js');
729
+ const reap = await reapAgentWorktrees(targetPath);
730
+ if (reap.ok) {
731
+ const gone = reap.reaped.map((r) => r.path ?? r.branch);
732
+ prune.agentReap = { reaped: gone, branchesDeleted: reap.branchesDeleted, preserved: reap.preserved, ...(reap.busy ? { busy: true } : {}) };
733
+ if (!options.json && gone.length) logger.success(`sobras de sub-agent removidas: ${gone.join(', ')}`);
734
+ if (!options.json && reap.busy) logger.warn('reaper de sub-agent já em execução em outro processo; nada foi feito nesta rodada');
735
+ }
736
+
728
737
  const { resolvePrimaryRoot } = await import('./worktree.js');
738
+ const { applyEditorSettings } = await import('../../lib/worktree/vscode-settings.js');
739
+ const editor = applyEditorSettings(resolvePrimaryRoot(targetPath));
740
+ if (editor.status !== 'off') {
741
+ prune.editorSettings = editor;
742
+ if (!options.json && editor.status === 'write') logger.success(`.vscode/settings.json: ${editor.added.length} exclusão(ões) de worktree adicionada(s)`);
743
+ if (!options.json && editor.status === 'suggest') logger.warn(`.vscode/settings.json não foi alterado (${editor.reason}); cole isto nele:\n${editor.snippet}`);
744
+ }
745
+
729
746
  const { applyBackupRetention, summarizeRetention } = await import('../../lib/worktree/finish-backup.js');
730
747
  // The explicit prune is the one that reaches the backups written before the
731
748
  // retention existed (no manifest): the automatic one never touches them.
@@ -788,6 +805,9 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
788
805
  if (problems.some(prunable) && !options.prune) {
789
806
  logger.dim('\n Rode `morph-spec doctor --worktrees --prune` para limpar os registros órfãos e os diretórios vazios.');
790
807
  }
808
+ if (issues.some((i) => i.type === 'agent-worktree-leftover' && i.reapable) && !options.prune) {
809
+ logger.dim('\n Rode `morph-spec worktree reap` (ou `doctor --worktrees --prune`) para remover as sobras de sub-agent que já pararam.');
810
+ }
791
811
  if (!problems.length) {
792
812
  logger.success('Nenhuma inconsistência — os itens acima são informativos.');
793
813
  return true;
@@ -932,25 +952,38 @@ export async function doctorAiCommand(targetPath, options = {}) {
932
952
  }
933
953
 
934
954
  export async function doctorCommand(options = {}) {
935
- // `--json` and `--prune` only mean something under `--worktrees`. Falling
955
+ // `--json` and `--prune` only mean something under `--worktrees` or `--procs`. Falling
936
956
  // through to the human health check would hand a script a banner and a table
937
957
  // where it asked for JSON — a caller has no way to notice a flag that was
938
958
  // dropped in silence, so refuse instead of pretending.
939
959
  const scoped = ['json', 'prune'].filter((f) => options[f]);
940
- if (scoped.length && !options.worktrees) {
960
+ if (scoped.length && !options.worktrees && !options.procs) {
941
961
  const flags = scoped.map((f) => `--${f}`).join(' e ');
942
- logger.error(`${flags} só se aplica${scoped.length > 1 ? 'm' : ''} a \`morph-spec doctor --worktrees\`.`);
962
+ logger.error(`${flags} só se aplica${scoped.length > 1 ? 'm' : ''} a \`morph-spec doctor --worktrees\` ou \`morph-spec doctor --procs\`.`);
943
963
  logger.dim(` Rode: morph-spec doctor --worktrees ${scoped.map((f) => `--${f}`).join(' ')}`);
944
964
  process.exitCode = 1;
945
965
  return false;
946
966
  }
947
967
 
968
+ // `--prune` é uma flag só e poda coisas diferentes em cada modo; juntar os dois
969
+ // modos faria as duas podas destrutivas rodarem sob um único pedido.
970
+ if (options.procs) {
971
+ const conflicts = ['full', 'mcp', 'worktrees', 'reset'].filter((f) => options[f]);
972
+ if (conflicts.length) {
973
+ const flags = conflicts.map((f) => `--${f}`).join(' e ');
974
+ logger.error(`--procs não se combina com ${flags} — cada superfície tem o seu relatório, o seu código de saída e a sua poda.`);
975
+ logger.dim(' Rode: morph-spec doctor --procs');
976
+ process.exitCode = 1;
977
+ return false;
978
+ }
979
+ }
980
+
948
981
  // `--ai` é superfície própria (D1). Combiná-la com outra superfície pediria
949
982
  // dois relatórios com dois códigos de saída no mesmo processo — o segundo
950
983
  // apagaria o primeiro em silêncio. Mesmo molde da guarda acima: recusa em
951
984
  // vez de fingir.
952
985
  if (options.ai) {
953
- const conflicts = ['full', 'mcp', 'worktrees'].filter((f) => options[f]);
986
+ const conflicts = ['full', 'mcp', 'worktrees', 'procs'].filter((f) => options[f]);
954
987
  if (conflicts.length) {
955
988
  const flags = conflicts.map((f) => `--${f}`).join(' e ');
956
989
  logger.error(`--ai não se combina com ${flags} — cada superfície tem o seu relatório e o seu código de saída.`);
@@ -1011,6 +1044,11 @@ export async function doctorCommand(options = {}) {
1011
1044
  return clean;
1012
1045
  }
1013
1046
 
1047
+ if (options.procs) {
1048
+ const { doctorProcsCommand } = await import('./doctor-procs.js');
1049
+ return doctorProcsCommand(process.cwd(), options);
1050
+ }
1051
+
1014
1052
  const targetPath = process.cwd();
1015
1053
 
1016
1054
  logger.header('MORPH-SPEC Health Check');
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import chalk from 'chalk';
3
+ import { logger } from '../../utils/logger.js';
4
+ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
5
+ import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
6
+ import { describeHolder, heavyDir, heavyStatus } from '../../../framework/hooks/shared/heavy-slots.js';
7
+
8
+ const EXIT_BUSY = 3;
9
+ const NEEDS_CMD_SHIM = /^(npm|npx|pnpm|yarn)$/;
10
+
11
+ export function heavyStatusCommand(options = {}) {
12
+ const config = readHeavyConfig(process.cwd());
13
+ const status = heavyStatus({ dir: heavyDir(), maxHoldMs: config.maxHoldMinutes * 60_000 });
14
+ const report = {
15
+ enabled: config.enabled,
16
+ slots: config.slots,
17
+ minFreeMemGb: config.minFreeMemGb,
18
+ dir: status.dir,
19
+ freeGb: Number(status.freeGb.toFixed(2)),
20
+ holders: status.holders.map(({ pid, label, feature, node, cwd, since, leaseId }) => ({ pid, label, feature, node, cwd, since, ...(leaseId ? { leaseId } : {}) })),
21
+ exclusive: status.exclusive ? { pid: status.exclusive.pid, label: status.exclusive.label, since: status.exclusive.since } : null,
22
+ };
23
+
24
+ if (options.json) {
25
+ console.log(JSON.stringify(report, null, 2));
26
+ return report;
27
+ }
28
+
29
+ logger.header('Slots de nós pesados (build, testes, e2e)');
30
+ if (!config.enabled) console.log(chalk.yellow(' ⚠ semáforo desligado (heavy.enabled: false ou MORPH_HEAVY=off)'));
31
+ console.log(chalk.gray(` diretório: ${report.dir}`));
32
+ const memMark = report.freeGb < config.minFreeMemGb ? chalk.yellow('⚠') : chalk.green('✓');
33
+ console.log(` ${memMark} RAM livre: ${report.freeGb.toFixed(1)} GB (mínimo ${config.minFreeMemGb} GB)`);
34
+ console.log(` slots em uso: ${status.holders.length}/${config.slots}`);
35
+ for (const h of status.holders) console.log(chalk.gray(` • ${describeHolder(h)}${h.cwd ? ` — ${h.cwd}` : ''}`));
36
+ if (status.exclusive) console.log(chalk.yellow(` ⚠ limpeza do servidor de build em curso (${describeHolder(status.exclusive)})`));
37
+ logger.blank();
38
+ return report;
39
+ }
40
+
41
+ export function heavyRunCommand(command, options = {}) {
42
+ const [file, ...args] = command;
43
+ const gate = createHeavyGate({ projectPath: process.cwd(), label: options.label ?? 'run' });
44
+ try {
45
+ gate.enter(file);
46
+ } catch (err) {
47
+ if (!(err instanceof HeavyBusyError)) throw err;
48
+ console.error(chalk.yellow(`\n⏳ ${file} não rodou: ${err.message}. Tente de novo quando liberar (\`morph-spec heavy status\`).\n`));
49
+ process.exit(EXIT_BUSY);
50
+ }
51
+
52
+ const exe = process.platform === 'win32' && NEEDS_CMD_SHIM.test(file) ? `${file}.cmd` : file;
53
+ const res = spawnSync(exe, args, { stdio: 'inherit', shell: exe.endsWith('.cmd') });
54
+ gate.release();
55
+
56
+ if (res.error) {
57
+ console.error(chalk.red(`\n❌ não consegui executar ${file}: ${res.error.message}\n`));
58
+ process.exit(127);
59
+ }
60
+ process.exit(res.status ?? 1);
61
+ }
@@ -288,7 +288,7 @@ export async function initCommand(options) {
288
288
  spinner.stop();
289
289
  const existingMcps = claudeConfigDetected?.mcpServers || [];
290
290
  const orchestration = orchestrateMcpSetup(targetPath, detectedStack, existingMcps,
291
- { integrations: config.project?.integrations ?? [] });
291
+ { integrations: config.project?.integrations ?? [], enabledPlugins: claudeConfigDetected?.enabledPlugins ?? [] });
292
292
 
293
293
  const autoNames = Object.keys(orchestration.autoInstallable);
294
294
  const manualNames = Object.keys(orchestration.needsManualSetup);
@@ -33,6 +33,7 @@ import { getProjectTags } from '../../lib/stack-filter.js';
33
33
  import { updateClaudeMdAgentsBlock, installRulesForStack } from '../../scripts/setup-infra.js';
34
34
  import { reconcileLegacyTemplatesCopy } from '../../lib/installers/templates-reconcile.js';
35
35
  import { createEffects } from '../../lib/installers/effects.js';
36
+ import { applyEditorSettings } from '../../lib/worktree/vscode-settings.js';
36
37
  import { registerProject } from '../../lib/projects/registry.js';
37
38
  import {
38
39
  writeGraphifyignore, describeOutdatedIgnore, graphifyignoreTracked,
@@ -729,6 +730,9 @@ export async function updateCommand(options = {}) {
729
730
  await updateGitignore(targetPath, fx);
730
731
  await ensureGitattributes(targetPath, fx);
731
732
 
733
+ updateSpinner.text = 'Editor excludes for worktrees (.vscode/settings.json)...';
734
+ try { applyEditorSettings(targetPath, { fx }); } catch { /* fail-open */ }
735
+
732
736
  // Migration: a .graphifyignore the previous version generated and nobody
733
737
  // edited is swapped for the current one when its RULES change (test doubles
734
738
  // and fixtures back in the graph — a header-only difference is left alone);
@@ -0,0 +1,34 @@
1
+ import chalk from 'chalk';
2
+ import { reapAgentWorktrees } from '../../lib/worktree/agent-reaper.js';
3
+
4
+ const label = (item) => item.path ?? item.branch ?? item.hex;
5
+
6
+ function printReport(out) {
7
+ const mode = out.dryRun ? ' (simulação)' : '';
8
+ console.log(chalk.bold(`\nWorktrees de sub-agent${mode} — graça de ${out.graceMinutes} min\n`));
9
+ if (out.busy) console.log(chalk.yellow(' ⏳ outro reaper está em execução; nada foi feito\n'));
10
+
11
+ for (const item of out.wouldReap ?? []) console.log(chalk.cyan(` → removeria: ${label(item)}`));
12
+ for (const item of out.reaped) console.log(chalk.green(` ✓ removido: ${label(item)}${item.backup ? chalk.gray(` (backup: ${item.backup})`) : ''}`));
13
+ for (const item of out.preserved) {
14
+ console.log(chalk.yellow(` ⚠ preservado: ${label(item)} — ${item.reason}${item.detail ? `: ${item.detail}` : ''}`));
15
+ }
16
+ if (out.active) console.log(chalk.gray(` ${out.active} agente(s) ainda ativo(s), intocado(s)`));
17
+ const nothing = !out.reaped.length && !out.preserved.length && !out.active && !(out.wouldReap ?? []).length;
18
+ if (nothing && !out.busy) console.log(chalk.green(' ✓ nenhuma worktree de sub-agent sobrando'));
19
+ console.log();
20
+ }
21
+
22
+ export async function worktreeReapCommand(options = {}) {
23
+ const grace = options.grace === undefined ? undefined : Number(options.grace);
24
+ if (grace !== undefined && !(Number.isFinite(grace) && grace >= 0)) {
25
+ console.error(chalk.red(`--grace precisa ser um número de minutos >= 0 (recebi "${options.grace}")`));
26
+ process.exit(2);
27
+ }
28
+
29
+ const out = await reapAgentWorktrees(options.cwd || process.cwd(), { dryRun: options.dryRun === true, graceMinutes: grace });
30
+ if (options.json) console.log(JSON.stringify(out));
31
+ else if (out.ok) printReport(out);
32
+ else console.log(chalk.gray(` fora de um repositório git — nada a fazer (${out.error})`));
33
+ return out;
34
+ }