@polymorphism-tech/morph-spec 10.1.1 → 10.5.2

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 (86) hide show
  1. package/README.md +3 -7
  2. package/bin/morph-spec.js +10 -41
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -4
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/MORPH.md +1 -1
  7. package/framework/agents.json +1 -2
  8. package/framework/commands/morph-archive.md +1 -1
  9. package/framework/commands/morph-preflight.md +3 -4
  10. package/framework/docs/CLI.md +45 -22
  11. package/framework/evals/review-checklist.md +5 -5
  12. package/framework/hooks/README.md +2 -2
  13. package/framework/hooks/claude-code/core/heavy-gate.js +21 -2
  14. package/framework/hooks/claude-code/core/validate-completion.js +0 -35
  15. package/framework/hooks/shared/e2e-check.js +44 -7
  16. package/framework/hooks/shared/heavy-command.js +12 -1
  17. package/framework/hooks/shared/heavy-config.js +12 -0
  18. package/framework/hooks/shared/host-services.js +9 -6
  19. package/framework/hooks/shared/next-step.js +12 -8
  20. package/framework/phases.json +4 -4
  21. package/framework/rules/plan-grammar.md +4 -1
  22. package/framework/schemas/config.schema.json +59 -1
  23. package/framework/schemas/feature.schema.json +15 -0
  24. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  25. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  26. package/framework/skills/core/morph-apply/SKILL.md +8 -5
  27. package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
  28. package/framework/skills/core/morph-apply/references/machine.md +3 -2
  29. package/framework/skills/core/morph-apply/references/review.md +9 -7
  30. package/framework/skills/core/morph-plan/SKILL.md +7 -6
  31. package/framework/skills/core/morph-plan/references/critique.md +1 -1
  32. package/framework/skills/core/morph-uiux/SKILL.md +2 -2
  33. package/framework/templates/IDE-SNIPPETS.md +262 -262
  34. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  35. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  36. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  37. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  38. package/framework/templates/docs/plan.md +2 -1
  39. package/framework/templates/examples/design-system-examples.md +0 -3
  40. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  41. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  42. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  43. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  44. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  45. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  46. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  47. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  48. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  49. package/framework/templates/state.template.json +17 -17
  50. package/framework/templates/ui/design-system.css +226 -226
  51. package/package.json +1 -1
  52. package/src/commands/project/doctor.js +0 -1
  53. package/src/commands/project/heavy.js +13 -0
  54. package/src/commands/state/approve.js +9 -109
  55. package/src/commands/state/report-check.js +44 -2
  56. package/src/commands/validation/verify.js +57 -7
  57. package/src/core/state/state-manager.js +0 -24
  58. package/src/dashboard/app/main.jsx +13 -13
  59. package/src/dashboard/vite.config.js +24 -24
  60. package/src/lib/heavy/cleanup.js +8 -2
  61. package/src/lib/heavy/gate.js +24 -1
  62. package/src/lib/heavy/reclaim.js +107 -0
  63. package/src/lib/heavy/watchdog.js +46 -0
  64. package/src/lib/next-step.js +1 -0
  65. package/src/lib/plan/checks.js +1 -1
  66. package/src/lib/plan/critique.js +23 -3
  67. package/src/lib/procs/prune.js +23 -1
  68. package/src/lib/review-confirmation.js +67 -0
  69. package/src/lib/review-reports.js +1 -0
  70. package/src/lib/risk-detector.js +2 -2
  71. package/src/lib/tasks/test-nodes.js +10 -0
  72. package/src/lib/tasks/test-runner.js +37 -43
  73. package/src/lib/telemetry/telemetry-reader.js +1 -2
  74. package/src/lib/telemetry/verify-event.js +7 -0
  75. package/src/lib/validators/verify-cache.js +63 -0
  76. package/src/lib/validators/verify-runner.js +131 -26
  77. package/src/lib/verify/affected.js +141 -0
  78. package/src/lib/verify/detached.js +154 -0
  79. package/src/utils/agents-installer.js +2 -2
  80. package/src/utils/hooks-installer.js +0 -20
  81. package/bin/validate.js +0 -212
  82. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  83. package/framework/templates/.idea/morph-templates.xml +0 -92
  84. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
  85. package/src/commands/observability/telemetry.js +0 -60
  86. package/src/commands/validation/validate-feature.js +0 -337
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { execFileSync } from 'child_process';
2
3
  import { existsSync, readFileSync } from 'fs';
3
4
  import { join } from 'path';
4
5
  import { implementProgress, parseReport, reportPath } from '../../../framework/hooks/shared/report-progress.js';
@@ -14,6 +15,8 @@ import { isTestPath } from '../../lib/graph/sanitize.js';
14
15
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
15
16
  import { collectReviewRequirement } from './gate-check.js';
16
17
  import { featureGeneratesMedia } from '../../lib/plan/media.js';
18
+ import { resolveBaseRef } from '../../lib/git/branch-classifier.js';
19
+ import { reviewConfirmation } from '../../lib/review-confirmation.js';
17
20
 
18
21
  /** Each review report: complete when it carries a readable verdict and is not partial. */
19
22
  export function inspectReviewReports(cwd, feature) {
@@ -75,10 +78,44 @@ function treeFor(cwd, feature, beforeEval) {
75
78
  const tree = treeState(cwd, { snapshot });
76
79
  if (!beforeEval) return { tree };
77
80
  const reviewModels = reviewDispatch(cwd, feature);
81
+ const gate3 = gate3Briefing(cwd, feature);
78
82
  const next = snapshotTree(cwd);
79
- if (!next) return { tree, beforeEval: null, reviewModels };
83
+ if (!next) return { tree, beforeEval: null, reviewModels, gate3 };
80
84
  atomicWriteJson(path, next);
81
- return { tree, beforeEval: { at: next.at, files: Object.keys(next.files).length }, reviewModels };
85
+ return { tree, beforeEval: { at: next.at, files: Object.keys(next.files).length }, reviewModels, gate3 };
86
+ }
87
+
88
+ function gitLine(cwd, args) {
89
+ try {
90
+ return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim() || null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function readVerification(cwd, feature) {
97
+ try {
98
+ return JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8')).verification ?? null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ // What every dispatch needs, in one read. `redispatch: null` = no report yet, every required role goes.
105
+ function gate3Briefing(cwd, feature) {
106
+ const sha = gitLine(cwd, ['rev-parse', 'HEAD']);
107
+ const defaultRef = resolveBaseRef(detectDefaultBranch(cwd), cwd);
108
+ const reports = reviewReports(cwd, feature);
109
+ const anyReport = Object.values(reports).some((report) => report.status !== 'missing');
110
+ const moved = anyReport && sha ? reviewConfirmation(cwd, reports, sha).moved : [];
111
+ const { checks } = readPlanChecks(cwd, feature);
112
+ return {
113
+ base: defaultRef ? gitLine(cwd, ['merge-base', defaultRef, 'HEAD']) : null,
114
+ sha,
115
+ flaky: readVerification(cwd, feature)?.flaky ?? [],
116
+ e2eChecks: (checks?.checks ?? []).filter((check) => check.proof === 'e2e').map((check) => check.id),
117
+ redispatch: anyReport ? moved.filter((role) => !role.skippable).map((role) => role.role) : null,
118
+ };
82
119
  }
83
120
 
84
121
  export function reportCheckCommand(feature, options = {}) {
@@ -128,6 +165,11 @@ function printHuman(obj) {
128
165
  const m = obj.reviewModels;
129
166
  console.log(chalk.gray(` dispatch: verifier ${m.verifier.model}, reviewer ${m.reviewer.model}${m.security.required ? `, security ${m.security.model}` : ''}`));
130
167
  }
168
+ if (obj.gate3) {
169
+ const g = obj.gate3;
170
+ console.log(chalk.gray(` gate 3: base ${g.base?.slice(0, 8) ?? '-'}, sha ${g.sha?.slice(0, 8) ?? '-'}`
171
+ + `${g.redispatch ? `, voltam: ${g.redispatch.join(', ') || 'nenhum'}` : ''}${g.flaky.length ? `, instáveis: ${g.flaky.join(', ')}` : ''}`));
172
+ }
131
173
  printTree(obj.tree);
132
174
  console.log('');
133
175
  }
@@ -15,6 +15,25 @@ import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
15
15
  import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
16
16
  import { runCiVerify, CiUsageError, reusableLocalStamp } from '../../lib/ci/ci-verify.js';
17
17
  import { syncStackRules } from '../../lib/installers/stack-sync.js';
18
+ import {
19
+ DETACH_RUN_ENV, DETACH_STAGE_ENV, DETACH_WORKER_ENV, launchWorker, markWorkerExit, runFilesOf, superviseVerify,
20
+ } from '../../lib/verify/detached.js';
21
+ import { watchRun } from '../../lib/heavy/watchdog.js';
22
+
23
+ const detachedArgs = () => process.argv.slice(2).filter((arg) => arg !== '--detach');
24
+
25
+ async function runDetached(featureName, options) {
26
+ const result = await superviseVerify({ feature: featureName, args: detachedArgs() });
27
+ if (result.stderr) process.stderr.write(result.stderr);
28
+ if (result.died) {
29
+ const reason = 'a rodada destacada parou sem resultado';
30
+ if (options.json) console.log(JSON.stringify({ status: 'busy', persisted: false, reason }));
31
+ else console.error(chalk.yellow(`⏳ ${reason}: sem stamp, não é vermelho.`));
32
+ } else if (result.stdout) {
33
+ process.stdout.write(result.stdout);
34
+ }
35
+ process.exit(result.exitCode);
36
+ }
18
37
 
19
38
  function statusIcon(status) {
20
39
  if (status === 'pass') return chalk.green('✓');
@@ -109,12 +128,15 @@ const PARTIAL_RUN_FLAGS = [['filter', 'filtered'], ['skipTests', 'skip-tests'],
109
128
  const CI_EXCLUSIVE_FLAGS = [
110
129
  ['filter', '--filter'], ['skipTests', '--skip-tests'], ['skipBuild', '--skip-build'], ['skipE2e', '--skip-e2e'],
111
130
  ['freshE2e', '--fresh-e2e'], ['linux', '--linux'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'],
112
- ['noPersist', '--no-persist'],
131
+ ['noPersist', '--no-persist'], ['fresh', '--fresh'], ['affected', '--affected'],
113
132
  ];
114
133
 
115
- async function runLocalVerify(featureName, options) {
134
+ async function runLocalVerify(featureName, options, fingerprint) {
116
135
  try {
117
136
  return await runVerify(process.cwd(), featureName, {
137
+ fingerprint,
138
+ fresh: options.fresh,
139
+ affected: options.affected,
118
140
  timeout: options.timeout,
119
141
  skipTests: options.skipTests,
120
142
  skipBuild: options.skipBuild,
@@ -164,6 +186,28 @@ function exitWithReusedStamp(featureName, reused, why, options) {
164
186
  // Exit 2 is "the command cannot mean what you typed" (unknown feature, `--filter` matching
165
187
  // nothing, `--record-baseline` without config.json, `--linux` without Docker): never a verdict.
166
188
  export async function verifyCommand(featureName, options = {}) {
189
+ const stage = process.env[DETACH_STAGE_ENV];
190
+ if (stage === 'launch') {
191
+ launchWorker({ argv: process.argv.slice(2) });
192
+ process.exit(0);
193
+ }
194
+ if (stage === 'watchdog') {
195
+ const { exit, err } = runFilesOf(process.env[DETACH_RUN_ENV]);
196
+ await watchRun({
197
+ workerPid: Number(process.env[DETACH_WORKER_ENV]), exitFile: exit, errFile: err,
198
+ projectPath: process.cwd(), feature: featureName, config: readHeavyConfig(process.cwd()),
199
+ });
200
+ process.exit(0);
201
+ }
202
+ if (stage === 'worker') {
203
+ markWorkerExit(process.env[DETACH_RUN_ENV]);
204
+ // A verify nested in a test suite must not report as this run.
205
+ delete process.env[DETACH_STAGE_ENV];
206
+ delete process.env[DETACH_RUN_ENV];
207
+ // Out of a shell's reach there is no call timeout to beat, so the queue never needs to refuse.
208
+ process.env.MORPH_HEAVY_WAIT_SEC = String(readHeavyConfig(process.cwd()).detachedWaitMinutes * 60);
209
+ }
210
+
167
211
  let feature;
168
212
  try {
169
213
  feature = getFeature(featureName);
@@ -174,6 +218,7 @@ export async function verifyCommand(featureName, options = {}) {
174
218
  console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
175
219
  process.exit(2);
176
220
  }
221
+ if (options.detach) await runDetached(featureName, options);
177
222
 
178
223
  syncStackRules(process.cwd(), { persistTags: false, log: (line) => console.error(chalk.gray(line)) });
179
224
 
@@ -214,14 +259,17 @@ export async function verifyCommand(featureName, options = {}) {
214
259
  // Taken before the run: it is the tree the run is about to read.
215
260
  const fingerprint = treeFingerprint(process.cwd());
216
261
 
217
- const result = ci ? ci.result : await runLocalVerify(featureName, options);
262
+ const result = ci ? ci.result : await runLocalVerify(featureName, options, fingerprint);
218
263
 
219
264
  const cleanup = ci ? { status: 'skipped', reason: 'ci' } : afterHeavyRun({
220
265
  projectPath: process.cwd(),
221
266
  config: readHeavyConfig(process.cwd()),
267
+ onlyUnderPressure: true,
222
268
  });
223
269
 
224
- const historyKey = 'verifications.feature';
270
+ // Only a complete run may stand in for the proof the review gate asks for: the affected one keeps its own record.
271
+ const scopedRun = result.scope === 'affected';
272
+ const historyKey = scopedRun ? 'verifications.affected' : 'verifications.feature';
225
273
  // A run that measured only part of the suite must not stand in for the proof a gate asks for:
226
274
  // --filter and --skip-*, or a --record-baseline that recorded nothing; --no-persist keeps its own reason.
227
275
  const partialRun = options.noPersist ? null : PARTIAL_RUN_FLAGS.find(([flag]) => options[flag])?.[1] ?? null;
@@ -234,7 +282,7 @@ export async function verifyCommand(featureName, options = {}) {
234
282
 
235
283
  let compact = null;
236
284
  if (persist) {
237
- const prevEntry = feature.verifications?.feature;
285
+ const prevEntry = feature.verifications?.[scopedRun ? 'affected' : 'feature'];
238
286
  const now = new Date().toISOString();
239
287
  compact = {
240
288
  status: result.status,
@@ -242,6 +290,7 @@ export async function verifyCommand(featureName, options = {}) {
242
290
  attempts: (prevEntry?.attempts ?? 0) + 1,
243
291
  failStreak: result.status === 'fail' ? (prevEntry?.failStreak ?? 0) + 1 : 0,
244
292
  updatedAt: now,
293
+ ...(scopedRun ? { sha: getHeadSha(process.cwd()) } : {}),
245
294
  };
246
295
  await updateFeature(featureName, historyKey, compact);
247
296
 
@@ -254,9 +303,10 @@ export async function verifyCommand(featureName, options = {}) {
254
303
  e2eReason: result.nodes.e2e.status === 'skip' ? (result.nodes.e2e.reason ?? null) : null,
255
304
  sha: ci ? ci.run.headSha : getHeadSha(process.cwd()),
256
305
  ts: now,
306
+ ...(result.flaky?.length ? { flaky: result.flaky } : {}),
257
307
  ...(ci ? { source: 'ci', ciRun: ci.run.url } : {}),
258
308
  };
259
- await updateFeature(featureName, 'verification', stamp);
309
+ if (!scopedRun) await updateFeature(featureName, 'verification', stamp);
260
310
  }
261
311
 
262
312
  // A run over the exact tree of the previous one proves nothing
@@ -266,7 +316,7 @@ export async function verifyCommand(featureName, options = {}) {
266
316
  const cwd = process.cwd();
267
317
  if (persist && !ci) repeatOf = findRepeatedVerify(cwd, { feature: featureName, fingerprint });
268
318
  const tree = resolve(cwd).replace(/\\/g, '/') === resolvePrimaryRoot(cwd) ? 'root' : 'worktree';
269
- recordVerifyEvent(cwd, buildVerifyEvent(result, { sha: ci ? ci.run.headSha : getHeadSha(cwd), tree, persisted: persist, reason, fingerprint, source: ci ? 'ci' : 'local' }));
319
+ recordVerifyEvent(cwd, buildVerifyEvent(result, { sha: ci ? ci.run.headSha : getHeadSha(cwd), tree, persisted: persist && !scopedRun, reason: scopedRun ? 'affected' : reason, fingerprint, source: ci ? 'ci' : 'local' }));
270
320
  } catch { /* telemetry never changes the verdict */ }
271
321
 
272
322
  // The refusal always goes to stderr, so it survives --json (whose stdout has
@@ -521,27 +521,3 @@ export function getApprovalGate(featureName, gate) {
521
521
  return feature.approvalGates[gate] || null;
522
522
  }
523
523
 
524
- export function getApprovalHistory(featureName) {
525
- const state = loadState();
526
- const feature = state.features[featureName];
527
-
528
- if (!feature || !feature.approvalGates) {
529
- return [];
530
- }
531
-
532
- const history = [];
533
-
534
- Object.entries(feature.approvalGates).forEach(([gate, data]) => {
535
- if (data.timestamp) {
536
- history.push({
537
- gate,
538
- approved: data.approved,
539
- timestamp: data.timestamp,
540
- approvedBy: data.approvedBy || data.rejectedBy,
541
- reason: data.reason
542
- });
543
- }
544
- });
545
-
546
- return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
547
- }
@@ -1,13 +1,13 @@
1
- import { StrictMode } from 'react';
2
- import { createRoot } from 'react-dom/client';
3
- import { BrowserRouter } from 'react-router-dom';
4
- import App from './App.jsx';
5
- import './styles/index.css';
6
-
7
- createRoot(document.getElementById('root')).render(
8
- <StrictMode>
9
- <BrowserRouter>
10
- <App />
11
- </BrowserRouter>
12
- </StrictMode>
13
- );
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import App from './App.jsx';
5
+ import './styles/index.css';
6
+
7
+ createRoot(document.getElementById('root')).render(
8
+ <StrictMode>
9
+ <BrowserRouter>
10
+ <App />
11
+ </BrowserRouter>
12
+ </StrictMode>
13
+ );
@@ -1,24 +1,24 @@
1
- import { defineConfig } from 'vite';
2
- import react from '@vitejs/plugin-react';
3
- import tailwindcss from '@tailwindcss/vite';
4
- import morphDataPlugin from './vite-plugin-morph-data.js';
5
- import { fileURLToPath } from 'url';
6
- import { dirname, resolve } from 'path';
7
-
8
- const __filename = fileURLToPath(import.meta.url);
9
- const __dirname = dirname(__filename);
10
-
11
- const frameworkDir = resolve(__dirname, '..', '..', 'framework');
12
- const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
13
-
14
- export default defineConfig({
15
- root: __dirname,
16
- plugins: [
17
- react(),
18
- tailwindcss(),
19
- morphDataPlugin(frameworkDir, projectDir),
20
- ],
21
- server: {
22
- port: 3200,
23
- },
24
- });
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import morphDataPlugin from './vite-plugin-morph-data.js';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, resolve } from 'path';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ const frameworkDir = resolve(__dirname, '..', '..', 'framework');
12
+ const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
13
+
14
+ export default defineConfig({
15
+ root: __dirname,
16
+ plugins: [
17
+ react(),
18
+ tailwindcss(),
19
+ morphDataPlugin(frameworkDir, projectDir),
20
+ ],
21
+ server: {
22
+ port: 3200,
23
+ },
24
+ });
@@ -1,4 +1,5 @@
1
1
  import { readdirSync } from 'node:fs';
2
+ import { freemem } from 'node:os';
2
3
  import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
3
4
  import { listProcesses, defaultProcExec } from '../procs/list.js';
4
5
  import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
@@ -8,6 +9,7 @@ const DOTNET_ACTIVE = /\bdotnet(?:\.exe)?["']?\s+(?:build|test|publish|pack|msbu
8
9
  const IDLE_WORKER = /\/nodemode:\d|vbcscompiler|razorserver/i;
9
10
  const SHUTDOWN_TIMEOUT_MS = 30_000;
10
11
  const TAIL_CHARS = 400;
12
+ const GB = 1024 ** 3;
11
13
 
12
14
  function hasDotnet(projectPath) {
13
15
  const config = readProjectConfig(projectPath);
@@ -32,14 +34,18 @@ export function dotnetBusy(procs) {
32
34
  });
33
35
  }
34
36
 
35
- export function afterHeavyRun({ projectPath, config, env = process.env, deps = {} }) {
37
+ export function afterHeavyRun({ projectPath, config, env = process.env, onlyUnderPressure = false, deps = {} }) {
36
38
  const {
37
- exec = defaultProcExec, list = listProcesses, dotnet = hasDotnet, exclusive = runExclusive,
39
+ exec = defaultProcExec, list = listProcesses, dotnet = hasDotnet, exclusive = runExclusive, freeMem = freemem,
38
40
  } = deps;
39
41
 
40
42
  if (!config.enabled) return { status: 'skipped', reason: 'semáforo desligado: sem ele nada prova que outra sessão não está compilando' };
41
43
  if (!config.shutdownBuildServer) return { status: 'skipped', reason: 'desligado em heavy.shutdownBuildServer' };
42
44
  if (!dotnet(projectPath)) return { status: 'skipped', reason: 'projeto sem .NET' };
45
+ // A warm compiler is what makes the next round short; twice the floor keeps the next queue open.
46
+ if (onlyUnderPressure && freeMem() / GB >= config.minFreeMemGb * 2) {
47
+ return { status: 'skipped', reason: 'RAM livre de sobra: o compilador fica quente para a próxima rodada' };
48
+ }
43
49
 
44
50
  const guarded = exclusive({
45
51
  dir: heavyDir(env), label: 'build-server-shutdown', maxHoldMs: config.maxHoldMinutes * 60_000, env,
@@ -1,8 +1,11 @@
1
+ import { availableParallelism, freemem } from 'node:os';
1
2
  import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
2
3
  import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
4
+ import { GB } from '../../../framework/hooks/shared/heavy-holders.js';
3
5
  import {
4
6
  acquireHeavy, awaitFreeMemory, describeBusy, enterVerifySuite, heavyDir, isVerifySuite, releaseHeavy, updateLease,
5
7
  } from '../../../framework/hooks/shared/heavy-slots.js';
8
+ import { reclaimUnlessDisabled } from './reclaim.js';
6
9
 
7
10
  export class HeavyBusyError extends Error {
8
11
  constructor(result, config) {
@@ -24,6 +27,9 @@ export class HeavyBusyError extends Error {
24
27
 
25
28
  const NODE_REUSE_ENV = 'MSBUILDDISABLENODEREUSE';
26
29
  const LEASE_ENV = 'MORPH_HEAVY_LEASE';
30
+ const CPU_ENV = 'DOTNET_PROCESSOR_COUNT';
31
+
32
+ export const cpuShareOf = (slots, cores = availableParallelism()) => Math.max(2, Math.floor(cores / slots));
27
33
 
28
34
  export function createHeavyGate({
29
35
  projectPath, label = 'verify', feature = null, config = readHeavyConfig(projectPath),
@@ -36,6 +42,19 @@ export function createHeavyGate({
36
42
  let suiteEntered = false;
37
43
  let exitHandler = null;
38
44
  let saved = null;
45
+ let reclaimed = false;
46
+ const politeRun = config.enabled && config.yieldToUser;
47
+
48
+ const reclaimBelow = (floor) => {
49
+ const { freeMem = freemem, reclaim = reclaimUnlessDisabled } = deps;
50
+ if (reclaimed || !config.reclaimIdleMinutes || freeMem() / GB >= floor) return;
51
+ reclaimed = true;
52
+ try {
53
+ const { freed, containers = [] } = reclaim({ projectPath, feature, config, env });
54
+ const gone = [...freed, ...containers];
55
+ if (gone.length) log(`heavy: RAM abaixo do piso, derrubei stacks paradas: ${gone.join(', ')}`);
56
+ } catch {}
57
+ };
39
58
 
40
59
  const restoreEnv = () => {
41
60
  if (!saved) return;
@@ -59,6 +78,7 @@ export function createHeavyGate({
59
78
  const raiseFloor = (kind) => {
60
79
  const floor = minFreeMemFor(config, kind);
61
80
  if (!lease || floor <= admittedGb) return;
81
+ reclaimBelow(floor);
62
82
  const result = awaitFreeMemory({
63
83
  minFreeMemGb: floor, waitTimeoutMs: config.waitTimeoutSec * 1000, kind, log, ...deps,
64
84
  });
@@ -85,6 +105,7 @@ export function createHeavyGate({
85
105
  return {
86
106
  config,
87
107
  get waitedMs() { return waitedMs; },
108
+ get cpuShare() { return politeRun ? cpuShareOf(config.slots) : null; },
88
109
 
89
110
  enter(node, kind = heavyClass(node)) {
90
111
  const suite = isVerifySuite({ label, node });
@@ -98,6 +119,7 @@ export function createHeavyGate({
98
119
 
99
120
  if (config.enabled) {
100
121
  const minFreeMemGb = minFreeMemFor(config, kind);
122
+ reclaimBelow(minFreeMemGb);
101
123
  const result = acquireHeavy({
102
124
  dir: heavyDir(env),
103
125
  slots: config.slots,
@@ -121,8 +143,9 @@ export function createHeavyGate({
121
143
  waitedMs = result.waitedMs;
122
144
  }
123
145
 
124
- saved = { [NODE_REUSE_ENV]: env[NODE_REUSE_ENV], [LEASE_ENV]: env[LEASE_ENV] };
146
+ saved = { [NODE_REUSE_ENV]: env[NODE_REUSE_ENV], [LEASE_ENV]: env[LEASE_ENV], [CPU_ENV]: env[CPU_ENV] };
125
147
  if (config.disableNodeReuse) env[NODE_REUSE_ENV] = '1';
148
+ if (politeRun) env[CPU_ENV] ??= String(cpuShareOf(config.slots));
126
149
  if (lease) env[LEASE_ENV] = String(process.pid);
127
150
  entered = true;
128
151
  if (lease) {
@@ -0,0 +1,107 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { defaultExec } from '../../../framework/hooks/shared/e2e-check.js';
4
+ import { heavyDir, readHolders } from '../../../framework/hooks/shared/heavy-holders.js';
5
+ import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
6
+ import { EVENTS_FILE } from '../../../framework/hooks/shared/telemetry-logger.js';
7
+ import { tearDownFeatureStack } from '../e2e/stack-teardown.js';
8
+ import { projectKey, readRegistry } from '../projects/registry.js';
9
+ import { resolveFeatureTree } from '../worktree/feature-truth.js';
10
+ import { afterHeavyRun } from './cleanup.js';
11
+ import { liveE2eStacks, repoMainRoot } from './live-stacks.js';
12
+
13
+ const DOCKER_TIMEOUT_MS = 10_000;
14
+
15
+ function lastActivityMs(tree) {
16
+ try {
17
+ return statSync(join(tree, EVENTS_FILE)).mtimeMs;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function liveHolders(config, env, now) {
24
+ return readHolders(heavyDir(env), { now, isAlive: pidAlive, maxHoldMs: config.maxHoldMinutes * 60_000 });
25
+ }
26
+
27
+ function registeredRoots() {
28
+ try {
29
+ return readRegistry().projects.map((p) => p.dir);
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ const uniqueRoots = (roots) => [...new Map(roots.map((r) => [projectKey(r), r])).values()];
36
+
37
+ // One daemon answers for every project: asking it once per project would add a hung timeout each.
38
+ function onceByArgs(exec) {
39
+ const seen = new Map();
40
+ return (file, args, opts) => {
41
+ const key = args.join(' ');
42
+ if (!seen.has(key)) seen.set(key, exec(file, args, opts));
43
+ return seen.get(key);
44
+ };
45
+ }
46
+
47
+ function reclaimStacks({ root, feature, held, idleMs, now, stacks, treeOf, activityOf, tearDown, exec }) {
48
+ const freed = [];
49
+ for (const stack of stacks(root, { exec })) {
50
+ if (!stack.feature || stack.state === 'unknown' || stack.feature === feature || held.has(stack.feature)) continue;
51
+ const archived = stack.state === 'archived';
52
+ // A feature without a worktree lives in the root that listed it.
53
+ const tree = archived ? null : treeOf(stack.feature, root) ?? root;
54
+ if (!archived) {
55
+ // Unknown activity counts as live: another session's verifier may be navigating this stack.
56
+ const last = activityOf(tree);
57
+ if (last === null || now() - last < idleMs) continue;
58
+ }
59
+ // An active feature keeps its data: the next `e2e up` only rebuilds.
60
+ const result = tearDown(stack.feature, { root, worktreePath: tree, volumes: archived });
61
+ if (result?.status !== 'fail') freed.push(stack.feature);
62
+ }
63
+ return freed;
64
+ }
65
+
66
+ function reclaimContainers(prefixes, exec) {
67
+ const removed = [];
68
+ for (const prefix of prefixes) {
69
+ const ps = exec('docker', ['ps', '-a', '--filter', `name=^${prefix}`, '--format', '{{.Names}}'], { timeout: DOCKER_TIMEOUT_MS });
70
+ if (ps.code !== 0) continue;
71
+ for (const name of String(ps.output ?? '').split(/\r?\n/).map((n) => n.trim()).filter(Boolean)) {
72
+ if (exec('docker', ['rm', '-f', name], { timeout: DOCKER_TIMEOUT_MS }).code === 0) removed.push(name);
73
+ }
74
+ }
75
+ return removed;
76
+ }
77
+
78
+ /** The entry every real caller uses: a test suite faking low RAM must never reach this machine's stacks. */
79
+ export function reclaimUnlessDisabled(options) {
80
+ if (process.env.MORPH_HEAVY_RECLAIM === 'off') return { freed: [], containers: [] };
81
+ return reclaimMemory(options);
82
+ }
83
+
84
+ export function reclaimMemory({ projectPath, feature = null, config, env = process.env, deps = {} }) {
85
+ const {
86
+ stacks = liveE2eStacks, tearDown = tearDownFeatureStack, treeOf = (f, root) => resolveFeatureTree(f, root).tree,
87
+ activityOf = lastActivityMs, holders = liveHolders, buildServer = afterHeavyRun, now = Date.now,
88
+ roots = registeredRoots, exec = defaultExec,
89
+ } = deps;
90
+ if (!config.reclaimIdleMinutes) return { freed: [], containers: [] };
91
+
92
+ const cachedExec = onceByArgs(exec);
93
+ const live = holders(config, env, now());
94
+ const held = new Set(live.map((h) => h.feature).filter(Boolean));
95
+ const idleMs = config.reclaimIdleMinutes * 60_000;
96
+
97
+ const freed = uniqueRoots([repoMainRoot(projectPath), ...roots()]).flatMap((root) => reclaimStacks({
98
+ root, feature, held, idleMs, now, stacks, treeOf, activityOf, tearDown, exec: cachedExec,
99
+ }));
100
+
101
+ // A test container has no owner to ask: any holder that may be testing protects them all.
102
+ const testing = live.some((h) => h.node === 'tests' || !h.node);
103
+ const containers = testing ? [] : reclaimContainers(config.reclaimContainers, exec);
104
+
105
+ buildServer({ projectPath, config, env, onlyUnderPressure: true });
106
+ return { freed, containers };
107
+ }
@@ -0,0 +1,46 @@
1
+ import { appendFileSync, existsSync, writeFileSync } from 'node:fs';
2
+ import { freemem } from 'node:os';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { GB } from '../../../framework/hooks/shared/heavy-holders.js';
5
+ import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
6
+ import { stopTree } from '../procs/prune.js';
7
+ import { reclaimUnlessDisabled } from './reclaim.js';
8
+
9
+ const SAMPLE_MS = 10_000;
10
+ const LOW_SAMPLES_TO_STOP = 6;
11
+ const EXIT_BUSY = '3';
12
+
13
+ /** The floor judges only the start of a node; this judges the middle of one. It only ever stops its own run. */
14
+ export async function watchRun({
15
+ workerPid, exitFile, errFile, projectPath, feature, config, env = process.env, deps = {},
16
+ }) {
17
+ const {
18
+ freeMem = freemem, isAlive = pidAlive, wait = sleep, sampleMs = SAMPLE_MS, reclaim = reclaimUnlessDisabled,
19
+ list, kill,
20
+ } = deps;
21
+ if (!config.criticalFreeMemGb) return 'off';
22
+
23
+ let low = 0;
24
+ while (isAlive(workerPid) && !existsSync(exitFile)) {
25
+ const freeGb = freeMem() / GB;
26
+ if (freeGb >= config.criticalFreeMemGb) {
27
+ low = 0;
28
+ } else {
29
+ low += 1;
30
+ if (low === 1) {
31
+ try {
32
+ reclaim({ projectPath, feature, config, env });
33
+ } catch {}
34
+ }
35
+ if (low >= LOW_SAMPLES_TO_STOP) {
36
+ if (existsSync(exitFile)) return 'finished';
37
+ stopTree(workerPid, { list, kill });
38
+ appendFileSync(errFile, `verify parado: ${freeGb.toFixed(1)} GB livres, abaixo de heavy.criticalFreeMemGb (${config.criticalFreeMemGb}) por ${LOW_SAMPLES_TO_STOP} leituras. Não é vermelho.\n`);
39
+ writeFileSync(exitFile, EXIT_BUSY);
40
+ return 'stopped';
41
+ }
42
+ }
43
+ await wait(sampleMs);
44
+ }
45
+ return 'finished';
46
+ }
@@ -6,5 +6,6 @@ export function nextStepFor(featureName, tree) {
6
6
  const signals = collectNextSignals(featureName, tree);
7
7
  if (!signals) return null;
8
8
  signals.verificationStale = treeMovedSince(tree, signals.verification?.sha);
9
+ signals.affectedStale = treeMovedSince(tree, signals.affected?.sha);
9
10
  return { ...decideNext(signals), phase: signals.phase, failStreak: signals.failStreak };
10
11
  }
@@ -103,7 +103,7 @@ export function validatePlan(plan, { workType = null, riskRules = true, text = n
103
103
  } else if (riskRules && proof === 'teste' && risk === null) {
104
104
  errors.push(`${c.id}: "prova: teste" sem "risco:" (${RISK_KINDS.join(' | ')}) — sem risco, a prova é "leitura" ou "e2e"${at(c.line)}`);
105
105
  } else if (risk && proof === 'leitura') {
106
- warnings.push(`${c.id}: risco "${risk}" provado só por leitura${at(c.line)}`);
106
+ (riskRules ? errors : warnings).push(`${c.id}: risco "${risk}" provado só por leitura — quem tem risco se prova executando ("teste" ou "e2e")${at(c.line)}`);
107
107
  }
108
108
  if (canonicalRung(c.rung) === false) {
109
109
  errors.push(`${c.id}: degrau "${c.rung}" fora de ${REWORK_RUNGS.join(' | ')}${at(c.line)}`);
@@ -1,8 +1,21 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
3
+ import { featureGeneratesMedia } from './media.js';
3
4
 
4
5
  export const CRITIQUE_EXEMPT_WORK_TYPES = ['chore', 'hotfix'];
5
6
 
7
+ const SMALL_PLAN_MAX_CHECKS = 3;
8
+ const SMALL_PLAN_PROOFS = ['teste', 'leitura'];
9
+
10
+ /** A plan this size has little for a fresh reader to catch; a rung or a proof outside the code does. */
11
+ export function isSmallPlan(checks, planText = '') {
12
+ const scenarios = checks?.checks ?? [];
13
+ return scenarios.length > 0
14
+ && scenarios.length <= SMALL_PLAN_MAX_CHECKS
15
+ && scenarios.every((scenario) => SMALL_PLAN_PROOFS.includes(scenario.proof) && !scenario.rung)
16
+ && !featureGeneratesMedia(planText);
17
+ }
18
+
6
19
  const PLAN_HASH_RE = /^\*\*Plan-Hash:\*\*\s*`?([0-9a-f]{64})`?\s*$/m;
7
20
  const APPLIED_RE = /^\*\*Aplicado-em:\*\*\s*`?([0-9a-f]{64})`?\s*$/gm;
8
21
 
@@ -21,8 +34,15 @@ export function readPlanCritique(cwd, featureName, { workType, planChecks }) {
21
34
  if (CRITIQUE_EXEMPT_WORK_TYPES.includes(workType)) return 'exempt';
22
35
  const currentHash = planChecks?.status === 'fresh' ? planChecks.checks?.planHash : null;
23
36
  if (!currentHash) return null;
37
+ if (isSmallPlan(planChecks.checks, readText(getAbsoluteOutputPath(cwd, featureName, 'plan')) ?? '')) return 'exempt';
24
38
  const path = getAbsoluteOutputPath(cwd, featureName, 'planCritique');
25
- let text = null;
26
- try { text = existsSync(path) ? readFileSync(path, 'utf8') : null; } catch { text = null; }
27
- return critiqueStatus(text, currentHash);
39
+ return critiqueStatus(readText(path), currentHash);
40
+ }
41
+
42
+ function readText(path) {
43
+ try {
44
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
45
+ } catch {
46
+ return null;
47
+ }
28
48
  }
@@ -1,4 +1,26 @@
1
- import { readProcess } from './list.js';
1
+ import { listProcesses, readProcess } from './list.js';
2
+
3
+ export function descendantsOf(pid, procs) {
4
+ const found = [];
5
+ const queue = [pid];
6
+ while (queue.length) {
7
+ const parent = queue.shift();
8
+ for (const child of procs.filter((p) => p.ppid === parent && !found.includes(p.pid))) {
9
+ found.push(child.pid);
10
+ queue.push(child.pid);
11
+ }
12
+ }
13
+ return found;
14
+ }
15
+
16
+ export function stopTree(pid, { list = listProcesses, kill = (target) => process.kill(target) } = {}) {
17
+ const { procs } = list();
18
+ for (const target of [...descendantsOf(pid, procs ?? []).reverse(), pid]) {
19
+ try {
20
+ kill(target);
21
+ } catch {}
22
+ }
23
+ }
2
24
 
3
25
  export function pruneOrphans(rows, { read = readProcess, kill = (pid) => process.kill(pid) } = {}) {
4
26
  const results = [];