@polymorphism-tech/morph-spec 8.42.0 → 8.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CLAUDE.md +15 -5
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +48 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +5 -4
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +1 -1
  10. package/framework/docs/CLI.md +22 -10
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  13. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  14. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  15. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  16. package/framework/hooks/claude-code/core/worktree-env-inject.js +1 -4
  17. package/framework/hooks/shared/agent-reap.js +89 -0
  18. package/framework/hooks/shared/e2e-check.js +3 -1
  19. package/framework/hooks/shared/graph-refresh.js +1 -9
  20. package/framework/hooks/shared/heavy-command.js +128 -0
  21. package/framework/hooks/shared/heavy-config.js +65 -0
  22. package/framework/hooks/shared/heavy-holders.js +85 -0
  23. package/framework/hooks/shared/heavy-slots.js +207 -0
  24. package/framework/hooks/shared/process-alive.js +8 -0
  25. package/framework/hooks/shared/worktree-root.js +93 -0
  26. package/framework/rules/ai-agents-standards.md +1 -0
  27. package/framework/schemas/config.schema.json +80 -0
  28. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  29. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  30. package/framework/skills/core/morph-apply/SKILL.md +8 -3
  31. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  32. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  33. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  34. package/framework/skills/core/morph-implement/SKILL.md +9 -4
  35. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  36. package/framework/skills/core/morph-media/SKILL.md +1 -1
  37. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  38. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  39. package/framework/skills/core/morph-review/SKILL.md +45 -16
  40. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  41. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  42. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  43. package/framework/standards/PROVENANCE.json +10 -10
  44. package/framework/standards/STANDARDS.json +45 -1
  45. package/framework/standards/ai-agents/tool-adapters.md +226 -0
  46. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  47. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  48. package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
  49. package/package.json +1 -1
  50. package/src/commands/e2e/e2e.js +27 -8
  51. package/src/commands/mcp/mcp-setup.js +10 -9
  52. package/src/commands/project/doctor-procs.js +162 -0
  53. package/src/commands/project/doctor.js +42 -4
  54. package/src/commands/project/heavy.js +61 -0
  55. package/src/commands/project/init.js +1 -1
  56. package/src/commands/project/update.js +4 -0
  57. package/src/commands/project/worktree-reap.js +34 -0
  58. package/src/commands/project/worktree.js +112 -43
  59. package/src/commands/state/approve.js +2 -1
  60. package/src/commands/state/finish.js +4 -0
  61. package/src/commands/state/gate-check.js +2 -1
  62. package/src/commands/validation/mutate.js +200 -0
  63. package/src/commands/validation/verify.js +39 -22
  64. package/src/dashboard/scanner/features-scanner.js +4 -4
  65. package/src/dashboard/scanner/project-api.js +4 -4
  66. package/src/lib/detectors/claude-config-detector.js +30 -1
  67. package/src/lib/e2e/compose-env.js +5 -2
  68. package/src/lib/git/evidence-staleness.js +39 -0
  69. package/src/lib/heavy/cleanup.js +70 -0
  70. package/src/lib/heavy/gate.js +96 -0
  71. package/src/lib/installers/mcp-installer.js +15 -2
  72. package/src/lib/mutation/adapters.js +90 -0
  73. package/src/lib/mutation/commands.js +60 -0
  74. package/src/lib/mutation/manifest.js +138 -0
  75. package/src/lib/mutation/plan.js +36 -0
  76. package/src/lib/mutation/report.js +66 -0
  77. package/src/lib/mutation/runner.js +351 -0
  78. package/src/lib/mutation/schemata.js +60 -0
  79. package/src/lib/mutation/snapshot.js +69 -0
  80. package/src/lib/mutation/subset.js +75 -0
  81. package/src/lib/mutation/trx.js +51 -0
  82. package/src/lib/procs/classify.js +91 -0
  83. package/src/lib/procs/list.js +124 -0
  84. package/src/lib/procs/mcp-config.js +103 -0
  85. package/src/lib/procs/prune.js +25 -0
  86. package/src/lib/procs/wslconfig.js +70 -0
  87. package/src/lib/projects/registry.js +6 -2
  88. package/src/lib/risk-detector.js +3 -3
  89. package/src/lib/tasks/eval-node.js +4 -34
  90. package/src/lib/tasks/linux-runner.js +30 -3
  91. package/src/lib/tasks/test-runner.js +1 -1
  92. package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
  93. package/src/lib/validators/verify-runner.js +26 -8
  94. package/src/lib/worktree/agent-reaper.js +247 -0
  95. package/src/lib/worktree/dirs.js +3 -32
  96. package/src/lib/worktree/env-allocator.js +35 -5
  97. package/src/lib/worktree/vscode-settings.js +113 -0
  98. package/src/lib/worktree/worktree-audit.js +49 -5
  99. package/src/lib/worktree/worktree-include.js +46 -0
  100. package/src/scripts/setup-infra.js +4 -0
  101. package/src/utils/hooks-installer.js +11 -4
@@ -0,0 +1,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) {
@@ -126,6 +129,13 @@ function decidePersistence({ cwd, featureName, noPersist }) {
126
129
  return { persist: false, reason: samePath(root, cwd) ? 'owner-tree-elsewhere' : 'unregistered-worktree' };
127
130
  }
128
131
 
132
+ function baselineStampBlock(baseline) {
133
+ if (!baseline?.recorded) return baseline?.reason ? `record-baseline: ${baseline.reason}` : 'record-baseline';
134
+ const fell = baseline.updates.filter((u) => u.from !== null && u.to < u.from);
135
+ if (fell.length === 0) return null;
136
+ return `record-baseline: a contagem caiu em ${fell.map((u) => `${u.id} (${u.from} → ${u.to})`).join(', ')}`;
137
+ }
138
+
129
139
  /**
130
140
  * The per-node status map, in the shape `feature.schema.json` fixes for BOTH
131
141
  * writers of it — the per-scope history entry (`verifications.*`) and the
@@ -252,34 +262,37 @@ export async function verifyCommand(featureName, taskId, options = {}) {
252
262
  console.error(chalk.red(`\n❌ ${err.message}\n`));
253
263
  process.exit(2);
254
264
  }
265
+ // Ocupado não é veredito: nada foi verificado, então não há stamp nem
266
+ // evento, e o exit 3 diz "tente de novo", nunca "vermelho".
267
+ if (err instanceof HeavyBusyError) {
268
+ if (options.json) {
269
+ const holders = err.holders.map(({ pid, label, feature, node, since }) => ({ pid, label, feature, node, since }));
270
+ console.log(JSON.stringify({
271
+ status: 'busy', persisted: false, busy: { reason: err.reason, holders, freeGb: err.freeGb, waitedMs: err.waitedMs },
272
+ }, null, 2));
273
+ }
274
+ console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — tente de novo quando liberar (\`morph-spec heavy status\`).\n`));
275
+ process.exit(3);
276
+ }
255
277
  throw err;
256
278
  }
257
279
 
280
+ const cleanup = afterHeavyRun({
281
+ projectPath: process.cwd(),
282
+ config: readHeavyConfig(process.cwd()),
283
+ scope: taskId ? 'task' : 'feature',
284
+ });
285
+
258
286
  const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
259
- // `--record-baseline` is config maintenance, not gate evidence: it measures
260
- // the suite in order to write `expectedCount`. Persisting a `verification`
261
- // stamp from it would let a maintenance run stand in for the proof a gate
262
- // asks for — the same class of confusion as a hand-typed baseline.
263
- //
264
- // `--filter` at FEATURE scope is the other side of the same hole (#87): the
265
- // run is narrowed to a subset of nodes, but the stamp it would write carries
266
- // `scope: 'feature'` — the one scope risk-detector.js trusts unconditionally.
267
- // A partial run must not stand in for the proof a gate asks for, same as
268
- // `--record-baseline` above. `--skip-tests` and `--skip-build` are the same
269
- // partial run through another door (#113): a skipped node never reddens the
270
- // rollup, so the stamp said `pass` over a suite (or a build) that never ran,
271
- // and no gate rule reads a skipped `tests`/`build`. Task scope is unaffected:
272
- // that stamp already says `scope: 'task:{id}'` (which pauses the ship gate on
273
- // its own), and `taskVerifications` never claimed to cover more than the
274
- // task. Checked AFTER `--no-persist` (via the `!options.noPersist` guard) so
275
- // an explicit `--no-persist` keeps its own reason instead of being reported
276
- // as one of these — the flags are all "don't write", but decidePersistence()
277
- // already owns the former's reason.
287
+ // A run that measured only part of the suite must not stand in for the proof a gate asks for:
288
+ // --filter and --skip-* at feature scope, or a --record-baseline that recorded nothing. Task scope
289
+ // is unaffected (its stamp already says scope 'task:{id}'); --no-persist keeps its own reason.
278
290
  const partialFeatureRun = (!options.noPersist && !taskId)
279
291
  ? PARTIAL_RUN_FLAGS.find(([flag]) => options[flag])?.[1] ?? null
280
292
  : null;
281
- const { persist, reason } = options.recordBaseline
282
- ? { persist: false, reason: 'record-baseline' }
293
+ const baselineBlock = options.recordBaseline ? baselineStampBlock(result.baseline) : null;
294
+ const { persist, reason } = baselineBlock
295
+ ? { persist: false, reason: baselineBlock }
283
296
  : partialFeatureRun
284
297
  ? { persist: false, reason: partialFeatureRun }
285
298
  : decidePersistence({ cwd: process.cwd(), featureName, noPersist: options.noPersist });
@@ -343,10 +356,14 @@ export async function verifyCommand(featureName, taskId, options = {}) {
343
356
  }
344
357
 
345
358
  if (options.json) {
346
- const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf };
359
+ const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup };
347
360
  console.log(JSON.stringify(output, null, 2));
348
361
  } else {
349
362
  console.log(formatVerifyResult(result));
363
+ if (cleanup.status !== 'skipped') {
364
+ const mark = cleanup.status === 'ran' ? chalk.green('✓') : chalk.yellow('⚠');
365
+ console.log(chalk.gray(`${mark} dotnet build-server shutdown: ${cleanup.status}${cleanup.reason ? ` (${cleanup.reason})` : ''}\n`));
366
+ }
350
367
  if (repeatOf) {
351
368
  console.log(chalk.yellow(`⚠️ Mesma árvore do verify de feature de ${repeatOf.ts} (${repeatOf.status}): nada mudou fora de .morph/ desde então. `
352
369
  + '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 {
@@ -23,7 +23,7 @@ import { join } from 'path';
23
23
  import chalk from 'chalk';
24
24
 
25
25
  import { loadEnvFile } from '../../../framework/hooks/shared/e2e-check.js';
26
- import { allocateEnvBlock, portLayout, resolveFleetConfig } from '../worktree/env-allocator.js';
26
+ import { allocateEnvBlock, portLayout, resolveFleetConfig, resolvePortAliases } from '../worktree/env-allocator.js';
27
27
 
28
28
  /**
29
29
  * Build the environment a `docker compose` invocation should carry.
@@ -69,8 +69,11 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
69
69
  const { basePort, blockSize } = resolveFleetConfig(cwd);
70
70
  const block = allocateEnvBlock(featureName, { gitCommonDir, basePort, blockSize });
71
71
 
72
+ const { aliases, problems } = resolvePortAliases(config);
73
+ for (const problem of problems) console.warn(chalk.yellow(` ⚠ ${problem} — ignorado`));
74
+
72
75
  const layout = {
73
- ...portLayout(block, { stack: config?.project?.stack ?? '', feature: featureName }),
76
+ ...portLayout(block, { stack: config?.project?.stack ?? '', feature: featureName, aliases }),
74
77
  PLAYWRIGHT_BASE_URL: `http://localhost:${block.base}`,
75
78
  };
76
79
  Object.assign(env, layout);
@@ -0,0 +1,39 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { getHeadSha } from './default-branch.js';
4
+
5
+ const MORPH_DIR = '.morph/';
6
+ const CONFIG_PATH = '.morph/config/config.json';
7
+
8
+ const gitOut = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe', maxBuffer: 64 * 1024 * 1024 }).toString();
9
+
10
+ function withoutBaseline(text) {
11
+ const config = JSON.parse(text);
12
+ if (Array.isArray(config?.project?.tests)) {
13
+ for (const node of config.project.tests) delete node?.expectedCount;
14
+ }
15
+ return config;
16
+ }
17
+
18
+ function configMoved(cwd, sha, head) {
19
+ try {
20
+ const before = withoutBaseline(gitOut(cwd, ['show', `${sha}:${CONFIG_PATH}`]));
21
+ const after = withoutBaseline(gitOut(cwd, ['show', `${head}:${CONFIG_PATH}`]));
22
+ return !isDeepStrictEqual(before, after);
23
+ } catch {
24
+ return true;
25
+ }
26
+ }
27
+
28
+ // `.morph/` is bookkeeping, except the config: it names the test commands, and only its
29
+ // expectedCount is something a baseline run writes.
30
+ export function treeMovedSince(cwd, sha, head = getHeadSha(cwd)) {
31
+ if (!sha || !head || sha === head) return false;
32
+ let changed;
33
+ try {
34
+ changed = gitOut(cwd, ['diff', '--name-only', '--no-renames', '-z', sha, head]).split('\0').filter(Boolean);
35
+ } catch {
36
+ return true;
37
+ }
38
+ return changed.some((path) => !path.startsWith(MORPH_DIR) || (path === CONFIG_PATH && configMoved(cwd, sha, head)));
39
+ }
@@ -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
+ }