@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,200 @@
1
+ import chalk from 'chalk';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { resolveTestBuildTargets } from '../../lib/tasks/test-runner.js';
6
+ import { dotnetTestMode } from '../../lib/tasks/dotnet-test-introspection.js';
7
+ import { dotnetBuildCommands } from '../../lib/mutation/commands.js';
8
+ import { resolveTestNodes } from '../../lib/tasks/test-nodes.js';
9
+ import { changedPathsAgainst, detectDefaultBranch, getHeadSha } from '../../lib/git/default-branch.js';
10
+ import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
11
+ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
12
+ import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
13
+ import { BACKSLASH_TOKEN, ManifestError } from '../../lib/mutation/manifest.js';
14
+ import { groupByNode, loadPlan } from '../../lib/mutation/plan.js';
15
+ import { dotnetSubset, jsSubset, testProjectDirsOf } from '../../lib/mutation/subset.js';
16
+ import { dotnetAdapter, genericAdapter, spawnExec } from '../../lib/mutation/adapters.js';
17
+ import { runMutation } from '../../lib/mutation/runner.js';
18
+ import { renderSection, summarize } from '../../lib/mutation/report.js';
19
+ import { readJournal, restoreSnapshot } from '../../lib/mutation/snapshot.js';
20
+
21
+ const EXIT = { ok: 0, failed: 1, usage: 2, busy: 3, unsafe: 4 };
22
+ const KEEP_RUNS = 2;
23
+ const DEFAULT_JOBS = 2;
24
+
25
+ /**
26
+ * One heavy slot per worker: the first waits like any heavy node, the others
27
+ * are taken only if free right now. The run uses as many workers as it got,
28
+ * so a busy machine degrades to sequential instead of queueing.
29
+ */
30
+ export function workerGate({ root, feature, jobs }) {
31
+ const config = readHeavyConfig(root);
32
+ const gates = [createHeavyGate({ projectPath: root, label: 'mutate', feature, config })];
33
+ const quiet = { ...config, waitTimeoutSec: 0, warnings: [] };
34
+ for (let i = 1; i < jobs; i++) {
35
+ const { MORPH_HEAVY_LEASE: _inherited, ...env } = process.env;
36
+ gates.push(createHeavyGate({ projectPath: root, label: 'mutate', feature, config: quiet, env }));
37
+ }
38
+ let acquired = 0;
39
+ return {
40
+ get jobs() { return acquired; },
41
+ enter(node) {
42
+ gates[0].enter(node);
43
+ acquired = 1;
44
+ for (const gate of gates.slice(1)) {
45
+ try { gate.enter(node); acquired += 1; } catch (err) {
46
+ if (!(err instanceof HeavyBusyError)) throw err;
47
+ break;
48
+ }
49
+ }
50
+ },
51
+ release() {
52
+ for (const gate of [...gates].reverse()) gate.release();
53
+ acquired = 0;
54
+ },
55
+ };
56
+ }
57
+
58
+ const gitOut = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8', windowsHide: true });
59
+
60
+ function workRoot(root) {
61
+ const common = resolveGitCommonDir(root);
62
+ return common ? join(common, 'morph-spec', 'mutation') : join(root, '.morph', 'tmp', 'mutation');
63
+ }
64
+
65
+ function pruneOldRuns(base, keep) {
66
+ if (!existsSync(base)) return;
67
+ const runs = readdirSync(base).sort();
68
+ for (const name of runs.slice(0, Math.max(0, runs.length - keep))) {
69
+ const done = ['g0', 'g1', 'g2', 'g3'].every((g) => {
70
+ const journal = readJournal(join(base, name, g, 'snapshot'));
71
+ return journal === null || journal.state === 'restored';
72
+ });
73
+ if (done) rmSync(join(base, name), { recursive: true, force: true });
74
+ }
75
+ }
76
+
77
+ function fail(message, code) {
78
+ console.error(chalk.red(message));
79
+ process.exitCode = code;
80
+ return null;
81
+ }
82
+
83
+ export function restoreLatest(root, out = console) {
84
+ const base = workRoot(root);
85
+ if (!existsSync(base)) { out.log('nada a restaurar: nenhuma rodada de mutação registrada'); return []; }
86
+ const restored = [];
87
+ for (const run of readdirSync(base).sort()) {
88
+ for (const group of readdirSync(join(base, run))) {
89
+ const journal = readJournal(join(base, run, group, 'snapshot'));
90
+ if (!journal || journal.state === 'restored') continue;
91
+ const report = restoreSnapshot(journal);
92
+ restored.push({ run, group, files: report });
93
+ out.log(`${run}/${group}: ${report.every((r) => r.ok) ? 'restaurado' : 'FALHOU'} — ${report.map((r) => `${r.file} ${r.ok ? 'ok' : 'md5 diverge'}`).join(', ')}`);
94
+ }
95
+ }
96
+ if (restored.length === 0) out.log('nada a restaurar: toda rodada registrada já foi restaurada');
97
+ return restored;
98
+ }
99
+
100
+ function subsetFor({ node, group, root, options }) {
101
+ if (options.subset === false) return { usable: false, reason: '--no-subset' };
102
+ const mutatedFiles = group.regions.map((r) => r.file);
103
+ if (node.runner === 'dotnet') {
104
+ const base = detectDefaultBranch(root);
105
+ const changed = base ? changedPathsAgainst(base, root) : [];
106
+ return dotnetSubset({ root, testProjectDirs: testProjectDirsOf(root, resolveTestBuildTargets(node)), mutatedFiles, changed });
107
+ }
108
+ return jsSubset({ runner: node.runner, command: node.command, root, mutatedFiles });
109
+ }
110
+
111
+ export async function mutateCommand(feature, options = {}) {
112
+ const root = resolve(options.cwd ?? process.cwd());
113
+ if (options.restore) { restoreLatest(root); return null; }
114
+
115
+ const planPath = resolve(root, options.plan ?? join('.morph', 'features', feature ?? '', '4-review', 'mutants.json'));
116
+ if (!existsSync(planPath)) return fail(`manifesto não encontrado: ${planPath}\nEscreva-o com a ferramenta Write, se você a tem; só com Bash, heredoc de delimitador entre aspas simples, com a barra invertida do código como ${BACKSLASH_TOKEN} — formato em .morph/framework/CLI.md, seção mutate.`, EXIT.usage);
117
+
118
+ let regions;
119
+ try {
120
+ regions = loadPlan({ root, planPath });
121
+ } catch (err) {
122
+ if (err instanceof ManifestError) return fail(err.message, EXIT.usage);
123
+ if (err instanceof SyntaxError) return fail(`${err.message}\nSe o manifesto tem barra invertida, o transporte do Bash pode tê-la alterado: escreva cada uma como ${BACKSLASH_TOKEN}, que a ferramenta expande.`, EXIT.usage);
124
+ throw err;
125
+ }
126
+
127
+ const { nodes } = resolveTestNodes(root);
128
+ const { groups, orphans } = groupByNode(regions, nodes, root);
129
+ if (orphans.length > 0) {
130
+ return fail(`nenhuma suíte declarada roda: ${orphans.map((o) => `${o.id} (${o.file})`).join(', ')}\nDeclare o nó em project.tests[] (.morph/config/config.json).`, EXIT.usage);
131
+ }
132
+ const mtp = groups.find((g) => g.node.runner === 'dotnet' && dotnetTestMode(resolve(root, g.node.cwd || '.')) === 'mtp');
133
+ if (mtp) return fail(`a suíte "${mtp.node.id}" roda no Microsoft.Testing.Platform (global.json): o TRX do VSTest, de onde o veredito e o motivo saem, não existe nela. Suporte a MTP ainda não há — rode a mutação com a suíte inteira à mão.`, EXIT.usage);
134
+ const uncompilable = groups.find((g) => g.node.runner === 'dotnet' && dotnetBuildCommands(g.node, root).length === 0);
135
+ if (uncompilable) return fail(`a suíte "${uncompilable.node.id}" não aponta nenhum .csproj (solution, ou nada): sem saber o que compilar, todo mutante rodaria sobre o binário antigo e sairia "sobrevivente". Declare o campo build no nó (config.json, project.tests[]).`, EXIT.usage);
136
+ const preDecided = groups.find((g) => g.node.status);
137
+ if (preDecided) return fail(`a suíte "${preDecided.node.id}" já vem decidida como ${preDecided.node.status}: ${preDecided.node.reason ?? ''}`, EXIT.usage);
138
+
139
+ if (!options.allowDirty) {
140
+ const dirty = gitOut(root, ['status', '--porcelain', '--', ...new Set(regions.map((r) => r.file))]).trim();
141
+ if (dirty) return fail(`há alteração não commitada nos arquivos a mutar:\n${dirty}\nCommite o fix ANTES de mutar (ou use --allow-dirty sabendo que o snapshot é o único caminho de volta).`, EXIT.usage);
142
+ }
143
+
144
+ const runId = `mut-${Date.now().toString(36)}`;
145
+ const base = workRoot(root);
146
+ pruneOldRuns(base, KEEP_RUNS);
147
+ const git = { porcelain: () => gitOut(root, ['status', '--porcelain', '--', '.', ':(exclude).morph']), head: () => getHeadSha(root) };
148
+ const jobs = Math.max(1, Number.isInteger(options.jobs) ? options.jobs : DEFAULT_JOBS);
149
+ const gate = workerGate({ root, feature: feature ?? null, jobs });
150
+ const exec = options.exec ?? spawnExec;
151
+
152
+ const merged = { wallMs: 0, mutants: [], timings: [], restore: { files: [], ok: true, treeClean: true }, binary: { proof: 'unavailable', projects: [] }, baseline: {}, method: { schemata: 0, rebuild: 0, subset: {} } };
153
+ const subsets = [];
154
+ try {
155
+ for (const [index, group] of groups.entries()) {
156
+ const workDir = join(base, runId, `g${index}`);
157
+ mkdirSync(workDir, { recursive: true });
158
+ const subset = subsetFor({ node: group.node, group, root, options });
159
+ subsets.push(subset);
160
+ const timeoutMs = options.timeoutMs ?? group.node.timeoutMs ?? 900_000;
161
+ const make = group.node.runner === 'dotnet' ? dotnetAdapter : genericAdapter;
162
+ const adapter = make({ node: group.node, root, workDir, exec, subset, timeoutMs });
163
+ const result = await runMutation({ root, node: group.node, regions: group.regions, workDir, runId, exec, adapter, gate, git, options: { timeoutMs, jobs }, log: (m) => options.json || console.error(chalk.gray(m)) });
164
+ merged.mutants.push(...result.mutants);
165
+ merged.timings.push(...result.timings);
166
+ merged.wallMs += result.wallMs;
167
+ merged.restore.files.push(...result.restore.files);
168
+ merged.restore.ok &&= result.restore.ok;
169
+ merged.restore.treeClean &&= result.restore.treeClean;
170
+ merged.restore.porcelainAfter = result.restore.porcelainAfter;
171
+ if (result.binary.proof !== 'unavailable') merged.binary = ['mismatch', 'unlocated'].includes(merged.binary.proof) ? merged.binary : result.binary;
172
+ merged.baseline = { ...merged.baseline, ...Object.fromEntries(Object.entries(result.baseline).filter(([, v]) => v)) };
173
+ merged.method.schemata += result.method.schemata;
174
+ merged.method.rebuild += result.method.rebuild;
175
+ merged.method.subset = result.method.subset;
176
+ merged.method.jobs = Math.max(merged.method.jobs ?? 1, result.method.jobs);
177
+ }
178
+ } catch (err) {
179
+ if (err instanceof HeavyBusyError) return fail(`suíte não rodou: ${err.message}. Tente de novo quando liberar (morph-spec heavy status).`, EXIT.busy);
180
+ if (err.code === 'baseline-build-failed') return fail(`${err.message}\n${String(err.output ?? '').split('\n').slice(-12).join('\n')}`, EXIT.failed);
181
+ throw err;
182
+ }
183
+
184
+ const order = new Map(regions.map((r, i) => [r.id, i]));
185
+ merged.mutants.sort((a, b) => order.get(a.id) - order.get(b.id));
186
+ const section = renderSection(merged, { head: git.head(), date: new Date().toISOString().slice(0, 10), subset: subsets[0] ?? null });
187
+ const outPath = resolve(root, options.out ?? join('.morph', 'features', feature ?? '_', '4-review', 'mutation-section.md'));
188
+ mkdirSync(join(outPath, '..'), { recursive: true });
189
+ writeFileSync(outPath, section);
190
+
191
+ const tally = summarize(merged);
192
+ if (options.json) console.log(JSON.stringify({ ...merged, tally, section: outPath }, null, 2));
193
+ else {
194
+ console.log(section);
195
+ console.log(chalk.gray(`seção gravada em ${outPath}`));
196
+ console.log(`${chalk.green(`${tally.killed} mortos`)} · ${tally.survived ? chalk.yellow(`${tally.survived} sobreviveram`) : '0 sobreviveram'} · ${tally['build-error']} não compilaram · ${tally.inconclusive} inconclusivos`);
197
+ }
198
+ if (!merged.restore.ok || ['mismatch', 'unlocated'].includes(merged.binary.proof) || !merged.restore.treeClean) process.exitCode = EXIT.unsafe;
199
+ return { ...merged, tally, section: outPath };
200
+ }
@@ -9,6 +9,9 @@ import { getHeadSha } from '../../lib/git/default-branch.js';
9
9
  import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
10
10
  import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../lib/telemetry/verify-event.js';
11
11
  import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
12
+ import { HeavyBusyError } from '../../lib/heavy/gate.js';
13
+ import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
14
+ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
12
15
 
13
16
  /** ✓ pass · ✗ fail · – skip */
14
17
  function statusIcon(status) {
@@ -252,9 +255,27 @@ export async function verifyCommand(featureName, taskId, options = {}) {
252
255
  console.error(chalk.red(`\n❌ ${err.message}\n`));
253
256
  process.exit(2);
254
257
  }
258
+ // Ocupado não é veredito: nada foi verificado, então não há stamp nem
259
+ // evento, e o exit 3 diz "tente de novo", nunca "vermelho".
260
+ if (err instanceof HeavyBusyError) {
261
+ if (options.json) {
262
+ const holders = err.holders.map(({ pid, label, feature, node, since }) => ({ pid, label, feature, node, since }));
263
+ console.log(JSON.stringify({
264
+ status: 'busy', persisted: false, busy: { reason: err.reason, holders, freeGb: err.freeGb, waitedMs: err.waitedMs },
265
+ }, null, 2));
266
+ }
267
+ console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — tente de novo quando liberar (\`morph-spec heavy status\`).\n`));
268
+ process.exit(3);
269
+ }
255
270
  throw err;
256
271
  }
257
272
 
273
+ const cleanup = afterHeavyRun({
274
+ projectPath: process.cwd(),
275
+ config: readHeavyConfig(process.cwd()),
276
+ scope: taskId ? 'task' : 'feature',
277
+ });
278
+
258
279
  const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
259
280
  // `--record-baseline` is config maintenance, not gate evidence: it measures
260
281
  // the suite in order to write `expectedCount`. Persisting a `verification`
@@ -343,10 +364,14 @@ export async function verifyCommand(featureName, taskId, options = {}) {
343
364
  }
344
365
 
345
366
  if (options.json) {
346
- const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf };
367
+ const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup };
347
368
  console.log(JSON.stringify(output, null, 2));
348
369
  } else {
349
370
  console.log(formatVerifyResult(result));
371
+ if (cleanup.status !== 'skipped') {
372
+ const mark = cleanup.status === 'ran' ? chalk.green('✓') : chalk.yellow('⚠');
373
+ console.log(chalk.gray(`${mark} dotnet build-server shutdown: ${cleanup.status}${cleanup.reason ? ` (${cleanup.reason})` : ''}\n`));
374
+ }
350
375
  if (repeatOf) {
351
376
  console.log(chalk.yellow(`⚠️ Mesma árvore do verify de feature de ${repeatOf.ts} (${repeatOf.status}): nada mudou fora de .morph/ desde então. `
352
377
  + 'Esta rodada não prova nada novo — reuse o resultado anterior, a menos que o ambiente (Docker, banco, serviço externo) tenha mudado.\n'));
@@ -26,7 +26,7 @@ import { GATE_PREREQ } from '../../core/state/gate-order.js';
26
26
  import { validateTasksJson } from '../../lib/tasks/tasks-json-parser.js';
27
27
  import { evaluatorVerdictFromText } from '../../lib/evaluator-report.js';
28
28
  import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
29
- import { WORKTREE_DIR_NAMES } from '../../lib/worktree/dirs.js';
29
+ import { containerDirs } from '../../../framework/hooks/shared/worktree-root.js';
30
30
  import { canonicalPath, isInside, readMorphConfig } from '../../lib/tasks/test-runner.js';
31
31
  import { isFinished } from './feature-status.js';
32
32
 
@@ -79,9 +79,9 @@ function collectLocations(projectDir) {
79
79
  for (const name of subdirs(rootFeatures)) {
80
80
  if (existsSync(join(rootFeatures, name, 'feature.json'))) slot(name).root = join(rootFeatures, name);
81
81
  }
82
- for (const wtDirName of WORKTREE_DIR_NAMES) {
83
- for (const wt of subdirs(join(projectDir, wtDirName))) {
84
- const wtFeatures = join(projectDir, wtDirName, wt, '.morph', 'features');
82
+ for (const container of containerDirs(projectDir)) {
83
+ for (const wt of subdirs(container)) {
84
+ const wtFeatures = join(container, wt, '.morph', 'features');
85
85
  for (const name of subdirs(wtFeatures)) {
86
86
  if (existsSync(join(wtFeatures, name, 'feature.json'))) slot(name).worktrees.push(join(wtFeatures, name));
87
87
  }
@@ -12,7 +12,7 @@ import { join } from 'path';
12
12
  import { existsSync, readdirSync } from 'fs';
13
13
  import { scanFeatures, readFeatureDetail, readFeatureFile } from './features-scanner.js';
14
14
  import { createEventStore, buildFeed, lastActivityByFeature, ALL_KINDS, DEFAULT_KINDS } from './activity-scanner.js';
15
- import { WORKTREE_DIR_NAMES } from '../../lib/worktree/dirs.js';
15
+ import { containerDirs } from '../../../framework/hooks/shared/worktree-root.js';
16
16
  import { isFinished } from './feature-status.js';
17
17
  import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
18
18
 
@@ -51,11 +51,11 @@ export function isMorphDataReload(path) {
51
51
  */
52
52
  export function watchTargets(projectDir) {
53
53
  const morphDirs = [join(projectDir, '.morph')];
54
- for (const name of WORKTREE_DIR_NAMES) {
54
+ for (const container of containerDirs(projectDir)) {
55
55
  let entries = [];
56
- try { entries = readdirSync(join(projectDir, name), { withFileTypes: true }); } catch { continue; }
56
+ try { entries = readdirSync(container, { withFileTypes: true }); } catch { continue; }
57
57
  for (const e of entries) {
58
- if (e.isDirectory()) morphDirs.push(join(projectDir, name, e.name, '.morph'));
58
+ if (e.isDirectory()) morphDirs.push(join(container, e.name, '.morph'));
59
59
  }
60
60
  }
61
61
  const targets = [];
@@ -11,21 +11,50 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
11
11
  import { join } from 'path';
12
12
  import { homedir } from 'os';
13
13
 
14
+ /**
15
+ * Plugin ids (`name@marketplace`) that are switched ON. User settings first, then
16
+ * the project's; a later file overrides an earlier one, and `false` turns one off.
17
+ *
18
+ * @param {string} targetPath - Root path of the project
19
+ * @param {string} [home] - Home directory holding `.claude/settings.json`
20
+ * @returns {string[]}
21
+ */
22
+ export function readEnabledPlugins(targetPath, home = homedir()) {
23
+ const merged = new Map();
24
+ const files = [
25
+ join(home, '.claude', 'settings.json'),
26
+ join(targetPath, '.claude', 'settings.json'),
27
+ join(targetPath, '.claude', 'settings.local.json'),
28
+ ];
29
+ for (const file of files) {
30
+ if (!existsSync(file)) continue;
31
+ try {
32
+ const map = JSON.parse(readFileSync(file, 'utf8').replace(/^/, '')).enabledPlugins;
33
+ if (!map || typeof map !== 'object') continue;
34
+ for (const [id, on] of Object.entries(map)) merged.set(id, on === true);
35
+ } catch { /* invalid JSON */ }
36
+ }
37
+ return [...merged].filter(([, on]) => on).map(([id]) => id);
38
+ }
39
+
14
40
  /**
15
41
  * Detect Claude Code configuration for a project.
16
42
  *
17
43
  * @param {string} targetPath - Root path of the project
18
- * @returns {{ plugins: string[], mcpServers: Array<{name: string, config: Object}>, customSkills: string[], customCommands: string[], hooks: string[] }}
44
+ * @returns {{ plugins: string[], enabledPlugins: string[], mcpServers: Array<{name: string, config: Object}>, customSkills: string[], customCommands: string[], hooks: string[] }}
19
45
  */
20
46
  export function detectClaudeConfig(targetPath) {
21
47
  const result = {
22
48
  plugins: [],
49
+ enabledPlugins: [],
23
50
  mcpServers: [],
24
51
  customSkills: [],
25
52
  customCommands: [],
26
53
  hooks: [],
27
54
  };
28
55
 
56
+ result.enabledPlugins = readEnabledPlugins(targetPath);
57
+
29
58
  const mcpJsonNames = new Set();
30
59
 
31
60
  try {
@@ -0,0 +1,70 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
3
+ import { listProcesses, defaultProcExec } from '../procs/list.js';
4
+ import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
5
+ import { heavyDir, runExclusive } from '../../../framework/hooks/shared/heavy-slots.js';
6
+
7
+ const DOTNET_ACTIVE = /\bdotnet(?:\.exe)?["']?\s+(?:build|test|publish|pack|msbuild|restore|run|watch|vstest)\b/i;
8
+ const IDLE_WORKER = /\/nodemode:\d|vbcscompiler|razorserver/i;
9
+ const SHUTDOWN_SCOPES = new Set(['feature', 'e2e-down', 'prune']);
10
+ const SHUTDOWN_TIMEOUT_MS = 30_000;
11
+ const TAIL_CHARS = 400;
12
+
13
+ function hasDotnet(projectPath) {
14
+ const config = readProjectConfig(projectPath);
15
+ const stack = config?.project?.stack;
16
+ try {
17
+ if (stack && getBuildSteps(stack, { cwd: projectPath }).some((s) => /\bdotnet\b/.test(s.command))) return true;
18
+ } catch { /* stack sem passo de build */ }
19
+ try {
20
+ return readdirSync(projectPath).some((n) => /\.(sln|slnx|csproj)$/i.test(n));
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function dotnetBusy(procs) {
27
+ return procs.some((p) => {
28
+ if (p.name === 'testhost') return true;
29
+ const cmd = p.cmd ?? '';
30
+ if (p.name === 'vbcscompiler' || IDLE_WORKER.test(cmd)) return false;
31
+ if (p.name === 'msbuild') return true;
32
+ return p.name === 'dotnet' && DOTNET_ACTIVE.test(cmd);
33
+ });
34
+ }
35
+
36
+ export function afterHeavyRun({ projectPath, config, scope, env = process.env, deps = {} }) {
37
+ const {
38
+ exec = defaultProcExec, list = listProcesses, dotnet = hasDotnet, exclusive = runExclusive,
39
+ } = deps;
40
+
41
+ if (!config.enabled) return { status: 'skipped', reason: 'semáforo desligado: sem ele nada prova que outra sessão não está compilando' };
42
+ if (!config.shutdownBuildServer) return { status: 'skipped', reason: 'desligado em heavy.shutdownBuildServer' };
43
+ if (!SHUTDOWN_SCOPES.has(scope)) {
44
+ return { status: 'skipped', reason: 'escopo de task: o servidor de compilação segue quente para a próxima task' };
45
+ }
46
+ if (!dotnet(projectPath)) return { status: 'skipped', reason: 'projeto sem .NET' };
47
+
48
+ const guarded = exclusive({
49
+ dir: heavyDir(env), label: 'build-server-shutdown', maxHoldMs: config.maxHoldMinutes * 60_000, env,
50
+ }, () => {
51
+ const listing = list();
52
+ if (listing.procs === null) return { status: 'skipped', reason: `não consegui listar processos (${listing.reason})` };
53
+ if (dotnetBusy(listing.procs)) return { status: 'skipped', reason: 'há dotnet build/test vivo fora do semáforo' };
54
+
55
+ const res = exec('dotnet', ['build-server', 'shutdown'], { timeout: SHUTDOWN_TIMEOUT_MS });
56
+ if (res.missing) return { status: 'skipped', reason: 'dotnet indisponível no PATH' };
57
+ if (res.code !== 0) {
58
+ return { status: 'failed', reason: `dotnet build-server shutdown saiu ${res.code}`, output: String(res.output ?? '').slice(-TAIL_CHARS) };
59
+ }
60
+ return { status: 'ran' };
61
+ });
62
+
63
+ if (!guarded.ran) {
64
+ const why = guarded.reason === 'holders'
65
+ ? 'outra sessão segura slot do semáforo pesado'
66
+ : `semáforo indisponível (${guarded.reason})`;
67
+ return { status: 'skipped', reason: why };
68
+ }
69
+ return guarded.result;
70
+ }
@@ -0,0 +1,96 @@
1
+ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
2
+ import {
3
+ acquireHeavy, describeBusy, heavyDir, releaseHeavy, updateLease,
4
+ } from '../../../framework/hooks/shared/heavy-slots.js';
5
+
6
+ export class HeavyBusyError extends Error {
7
+ constructor(result, config) {
8
+ super(HeavyBusyError.message(result, config));
9
+ this.name = 'HeavyBusyError';
10
+ this.code = 'heavy-busy';
11
+ this.reason = result.reason;
12
+ this.holders = result.holders;
13
+ this.freeGb = result.freeGb;
14
+ this.waitedMs = result.waitedMs;
15
+ }
16
+
17
+ static message(result, config) {
18
+ return describeBusy(result, config);
19
+ }
20
+ }
21
+
22
+ const NODE_REUSE_ENV = 'MSBUILDDISABLENODEREUSE';
23
+ const LEASE_ENV = 'MORPH_HEAVY_LEASE';
24
+
25
+ export function createHeavyGate({
26
+ projectPath, label = 'verify', feature = null, config = readHeavyConfig(projectPath),
27
+ env = process.env, deps = {}, log = (m) => process.stderr.write(`${m}\n`),
28
+ } = {}) {
29
+ let entered = false;
30
+ let lease = null;
31
+ let waitedMs = 0;
32
+ let exitHandler = null;
33
+ let saved = null;
34
+
35
+ const restoreEnv = () => {
36
+ if (!saved) return;
37
+ for (const [key, value] of Object.entries(saved)) {
38
+ if (value === undefined) delete env[key];
39
+ else env[key] = value;
40
+ }
41
+ saved = null;
42
+ };
43
+
44
+ const release = () => {
45
+ if (exitHandler) process.removeListener('exit', exitHandler);
46
+ exitHandler = null;
47
+ releaseHeavy(lease);
48
+ lease = null;
49
+ restoreEnv();
50
+ entered = false;
51
+ };
52
+
53
+ return {
54
+ config,
55
+ get waitedMs() { return waitedMs; },
56
+
57
+ enter(node) {
58
+ if (entered) {
59
+ updateLease(lease, { node });
60
+ return;
61
+ }
62
+ for (const warning of config.warnings ?? []) log(`heavy: ${warning}`);
63
+
64
+ if (config.enabled) {
65
+ const result = acquireHeavy({
66
+ dir: heavyDir(env),
67
+ slots: config.slots,
68
+ label,
69
+ feature,
70
+ node,
71
+ cwd: projectPath,
72
+ minFreeMemGb: config.minFreeMemGb,
73
+ waitTimeoutMs: config.waitTimeoutSec * 1000,
74
+ maxHoldMs: config.maxHoldMinutes * 60_000,
75
+ env,
76
+ log,
77
+ ...deps,
78
+ });
79
+ if (result.status === 'busy') throw new HeavyBusyError(result, config);
80
+ lease = result.lease;
81
+ waitedMs = result.waitedMs;
82
+ }
83
+
84
+ saved = { [NODE_REUSE_ENV]: env[NODE_REUSE_ENV], [LEASE_ENV]: env[LEASE_ENV] };
85
+ if (config.disableNodeReuse) env[NODE_REUSE_ENV] = '1';
86
+ if (lease) env[LEASE_ENV] = String(process.pid);
87
+ entered = true;
88
+ if (lease) {
89
+ exitHandler = release;
90
+ process.once('exit', exitHandler);
91
+ }
92
+ },
93
+
94
+ release,
95
+ };
96
+ }
@@ -11,6 +11,7 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
11
11
  import { execSync, spawn } from 'child_process';
12
12
  import { homedir } from 'os';
13
13
  import { installMcpServers } from '../../utils/claude-settings-manager.js';
14
+ import { mcpServerNames } from '../../utils/agents-installer.js';
14
15
 
15
16
  /**
16
17
  * Load the enhanced MCP registry (v3.0.0)
@@ -56,9 +57,11 @@ export function filterByStack(registry, stack, integrations = []) {
56
57
  * Match detected MCP servers against the registry
57
58
  * @param {Array} existingServers - From detectClaudeConfig().mcpServers
58
59
  * @param {Object} registry - MCP registry
60
+ * @param {string[]} [enabledPlugins] - From detectClaudeConfig().enabledPlugins. A plugin
61
+ * that ships the server already runs it, and writing it to `.mcp.json` too starts it twice.
59
62
  * @returns {Object} Map of mcpName → { installed: true, source }
60
63
  */
61
- export function getInstalledMcps(existingServers, registry) {
64
+ export function getInstalledMcps(existingServers, registry, enabledPlugins = []) {
62
65
  const installed = {};
63
66
  const registryNames = Object.keys(registry.mcps);
64
67
 
@@ -71,6 +74,15 @@ export function getInstalledMcps(existingServers, registry) {
71
74
  }
72
75
  }
73
76
 
77
+ const prefixes = enabledPlugins.map(id => `plugin_${String(id).split('@')[0].replace(/[^A-Za-z0-9_-]/g, '_')}_`);
78
+ for (const regName of registryNames) {
79
+ if (installed[regName]) continue;
80
+ const names = mcpServerNames(regName, registry.mcps[regName]);
81
+ if (names.some(n => prefixes.some(p => n.startsWith(p)))) {
82
+ installed[regName] = { installed: true, source: 'plugin' };
83
+ }
84
+ }
85
+
74
86
  return installed;
75
87
  }
76
88
 
@@ -597,12 +609,13 @@ export function generateSetupInstructions(name, mcpEntry) {
597
609
  * @param {Object} options
598
610
  * @param {boolean} options.autoOnly - Only handle auto-installable MCPs
599
611
  * @param {string[]} [options.integrations] - `project.integrations`, matched like the stack
612
+ * @param {string[]} [options.enabledPlugins] - `detectClaudeConfig().enabledPlugins`
600
613
  * @returns {Object} { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing }
601
614
  */
602
615
  export function orchestrateMcpSetup(targetPath, stack, existingMcps, options = {}) {
603
616
  const registry = loadMcpRegistry();
604
617
  const relevant = filterByStack(registry, stack, options.integrations ?? []);
605
- const installed = getInstalledMcps(existingMcps || [], registry);
618
+ const installed = getInstalledMcps(existingMcps || [], registry, options.enabledPlugins ?? []);
606
619
 
607
620
  const autoInstallable = {};
608
621
  const alreadyInstalled = {};
@@ -0,0 +1,90 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { dotnetBuildCommands, withFilter, withTrx, withoutBuild } from './commands.js';
5
+ import { methodOf, parseTrx } from './trx.js';
6
+ import { stripAnsi } from '../tasks/test-runner.js';
7
+
8
+ const MAX_CAPTURE = 16 * 1024 * 1024;
9
+
10
+ export function spawnExec(command, { cwd, env, timeoutMs }) {
11
+ return new Promise((done) => {
12
+ const start = Date.now();
13
+ let stdout = '';
14
+ let stderr = '';
15
+ let timedOut = false;
16
+ const child = spawn(command, { cwd, env, shell: true, windowsHide: true });
17
+ child.stdout.setEncoding('utf8');
18
+ child.stderr.setEncoding('utf8');
19
+ child.stdout.on('data', (chunk) => { if (stdout.length < MAX_CAPTURE) stdout += chunk; });
20
+ child.stderr.on('data', (chunk) => { if (stderr.length < MAX_CAPTURE) stderr += chunk; });
21
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs);
22
+ const finish = (status) => { clearTimeout(timer); done({ status, stdout, stderr, durationMs: Date.now() - start, timedOut }); };
23
+ child.on('error', (err) => { stderr += String(err.message); finish(null); });
24
+ child.on('close', (status) => finish(status));
25
+ });
26
+ }
27
+
28
+ const FAIL_LINE = /^\s*(?:FAIL|✗|×|✖|not ok)\b|AssertionError|Error:|Expected/i;
29
+
30
+ function genericFailures(output) {
31
+ const lines = stripAnsi(output).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
32
+ const first = lines.findIndex((l) => FAIL_LINE.test(l));
33
+ if (first === -1) return lines.slice(-3).map((l) => ({ name: l, message: '' }));
34
+ return [{ name: lines[first], message: lines.slice(first + 1, first + 3).join(' ⏎ ') }];
35
+ }
36
+
37
+ export function dotnetAdapter({ node, root, workDir, exec, subset, timeoutMs }) {
38
+ const cwd = resolve(root, node.cwd || '.');
39
+ const trxDir = join(workDir, 'trx');
40
+ mkdirSync(trxDir, { recursive: true });
41
+ const base = withoutBuild(node.command);
42
+ const filterOf = { subset: () => subset.filter, names: (filter) => filter };
43
+ const commandFor = (scope, label, filter) => withTrx(
44
+ scope === 'full' ? base : withFilter(base, filterOf[scope](filter)),
45
+ { dir: trxDir, name: `${label}.trx` },
46
+ );
47
+ const runTests = async ({ scope, label, env, filter }) => {
48
+ const trxPath = join(trxDir, `${label}.trx`);
49
+ const r = await exec(commandFor(scope, label, filter), { cwd, env, timeoutMs, label });
50
+ if (!existsSync(trxPath)) {
51
+ return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: r.timedOut ? `timeout de ${timeoutMs} ms` : `sem TRX (exit ${r.status})` };
52
+ }
53
+ const t = parseTrx(readFileSync(trxPath, 'utf8'));
54
+ if (!t.parsed || t.aborted > 0 || /abort/i.test(t.runOutcome ?? '')) {
55
+ return { outcome: 'crash', executed: t.executed, total: t.total, failed: t.failed, failedTests: t.failedTests, durationMs: r.durationMs, note: 'a rodada foi abortada (testhost caiu ou estourou o tempo)' };
56
+ }
57
+ return { outcome: t.failed > 0 ? 'red' : 'green', executed: t.executed, total: t.total, failed: t.failed, failedTests: t.failedTests, durationMs: r.durationMs };
58
+ };
59
+ return {
60
+ kind: 'dotnet',
61
+ hasSubset: Boolean(subset?.usable),
62
+ buildCommands: dotnetBuildCommands(node, root),
63
+ cwd,
64
+ runTests,
65
+ reproduce: ({ names, label, env }) => runTests({
66
+ scope: 'names',
67
+ label,
68
+ env,
69
+ filter: [...new Set(names.map(methodOf))].map((n) => `FullyQualifiedName~${n}`).join('|'),
70
+ }),
71
+ };
72
+ }
73
+
74
+ export function genericAdapter({ node, root, exec, subset, timeoutMs }) {
75
+ const cwd = resolve(root, node.cwd || '.');
76
+ return {
77
+ kind: node.runner,
78
+ hasSubset: Boolean(subset?.usable),
79
+ buildCommands: [],
80
+ cwd,
81
+ async runTests({ scope, label, env }) {
82
+ const command = scope === 'subset' ? subset.command : node.command;
83
+ const r = await exec(command, { cwd, env, timeoutMs, label });
84
+ if (r.timedOut) return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: `timeout de ${timeoutMs} ms` };
85
+ if (r.status === 0) return { outcome: 'green', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs };
86
+ const failedTests = genericFailures(`${r.stdout}\n${r.stderr}`);
87
+ return { outcome: 'red', executed: null, total: null, failed: failedTests.length, failedTests, durationMs: r.durationMs };
88
+ },
89
+ };
90
+ }