@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
@@ -22,6 +22,10 @@ import {
22
22
  } from '../tasks/eval-node.js';
23
23
  import { runValidation } from './validation-runner.js';
24
24
  import { createHeavyGate } from '../heavy/gate.js';
25
+ import { selectAffected } from '../verify/affected.js';
26
+ import {
27
+ cacheEntryFrom, planReuse, readVerifyCache, reusedEntry, writeVerifyCache,
28
+ } from './verify-cache.js';
25
29
 
26
30
  const DEFAULT_BUILD_TIMEOUT = 1_800_000;
27
31
 
@@ -45,12 +49,34 @@ export class VerifyUsageError extends Error {
45
49
  export async function runVerify(projectPath, featureName, options = {}) {
46
50
  const gate = options._heavyGate ?? createHeavyGate({ projectPath, feature: featureName });
47
51
  try {
48
- return await runVerifyGated(projectPath, featureName, options, gate);
52
+ return await runVerifyGated(projectPath, featureName, applyAffectedScope(projectPath, options), gate);
49
53
  } finally {
50
54
  gate.release();
51
55
  }
52
56
  }
53
57
 
58
+ export function withCpuShare(node, share) {
59
+ if (!share || node.runner !== 'vitest' || /--max-?workers/i.test(node.command ?? '')) return node;
60
+ return { ...node, command: `${node.command} --maxWorkers=${share}` };
61
+ }
62
+
63
+ function applyAffectedScope(projectPath, options) {
64
+ if (!options.affected) return options;
65
+ const conflict = [['filter', '--filter'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'], ['linux', '--linux']]
66
+ .find(([key]) => options[key]);
67
+ if (conflict) throw new VerifyUsageError(`--affected escolhe os testes sozinho; não combina com ${conflict[1]}`);
68
+
69
+ const selection = selectAffected({ projectPath, nodes: resolveTestNodes(projectPath).nodes, deps: options._affectedDeps });
70
+ if (selection.scope === 'full') return { ...options, escalatedBecause: selection.reason };
71
+ return {
72
+ ...options,
73
+ selection,
74
+ skipE2e: true,
75
+ skipBuild: options.skipBuild || selection.docsOnly,
76
+ skipTests: options.skipTests || selection.nodes.length === 0,
77
+ };
78
+ }
79
+
54
80
  async function runVerifyGated(projectPath, featureName, options, gate) {
55
81
  const {
56
82
  timeout = DEFAULT_BUILD_TIMEOUT,
@@ -62,6 +88,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
62
88
  refreshCache = false,
63
89
  linux = false,
64
90
  linuxImage = null,
91
+ fingerprint = null,
92
+ fresh = false,
65
93
  _runLinuxSuite = runLinuxSuite,
66
94
  _exec = undefined,
67
95
  _runBuildCheck = runBuildCheck,
@@ -76,7 +104,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
76
104
 
77
105
  // Usage errors are judged before the build: paying for a build to discover a typo
78
106
  // is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
79
- const testPlan = resolveTestNodes(projectPath);
107
+ const resolvedPlan = resolveTestNodes(projectPath);
108
+ const testPlan = options.selection ? { ...resolvedPlan, nodes: options.selection.nodes } : resolvedPlan;
80
109
  const filter = parseTestFilter(options.filter);
81
110
  if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
82
111
  const declared = testPlan.nodes.map((node) => node.id).join(', ') || '(none)';
@@ -116,14 +145,29 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
116
145
 
117
146
  const nodes = {};
118
147
  const feedback = [];
148
+ if (options.escalatedBecause) feedback.push(`[verify] rodada completa: ${options.escalatedBecause}`);
149
+ if (options.selection) {
150
+ const kept = options.selection.nodes.map((n) => n.id).join(', ') || 'nenhuma';
151
+ feedback.push(`[verify] rodada afetada, suítes: ${kept}; a completa roda uma vez antes da aprovação`);
152
+ }
119
153
 
120
- nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
154
+ // Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
155
+ const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
156
+ && !refreshCache && !linux && !freshE2e
157
+ ? resolveGitCommonDir(projectPath)
158
+ : null;
159
+ const reuse = planReuse(cacheDir && !fresh ? readVerifyCache(cacheDir, featureName) : null, fingerprint);
160
+
161
+ nodes.build = reuse.build
162
+ ? reusedEntry(reuse.build)
163
+ : await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
121
164
  appendBuildFeedback(feedback, nodes.build);
122
165
 
123
166
  // Only a TYPED --timeout reaches the tests: the build default would outrank
124
167
  // config.project.testTimeoutMs and the per-project scaling.
125
168
  const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
126
169
  const stackNodes = [];
170
+ const buildFacts = { solutionBuilt: builtDotnet(nodes.build), builtTargets: new Set() };
127
171
  nodes.tests = await runTestsNode({
128
172
  projectPath,
129
173
  testPlan,
@@ -136,6 +180,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
136
180
  linuxImage,
137
181
  gate,
138
182
  stackNodes,
183
+ reuse,
184
+ buildFacts,
139
185
  _runTestSuite,
140
186
  _runLinuxSuite,
141
187
  _exec,
@@ -145,25 +191,28 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
145
191
  nodes.validators = await runValidatorsNode({ projectPath, featureName, _runValidation });
146
192
  appendValidatorsFeedback(feedback, nodes.validators);
147
193
 
148
- const e2e = await runE2eNode({
149
- projectPath,
150
- featureName,
151
- skipE2e,
152
- freshE2e,
153
- buildNode: nodes.build,
154
- testsNode: nodes.tests,
155
- hasStackNodes: stackNodes.length > 0,
156
- gate,
157
- _runE2eCheck,
158
- _e2eApplicability,
159
- });
194
+ // A suite with `e2e: true` still to run needs the stack's env, which only a real run returns.
195
+ const e2e = reuse.e2e && stackNodes.length === 0 && nodes.tests.status !== 'fail'
196
+ ? { node: reusedEntry(reuse.e2e), stackEnv: null }
197
+ : await runE2eNode({
198
+ projectPath,
199
+ featureName,
200
+ skipE2e,
201
+ freshE2e,
202
+ buildNode: nodes.build,
203
+ testsNode: nodes.tests,
204
+ hasStackNodes: stackNodes.length > 0,
205
+ gate,
206
+ _runE2eCheck,
207
+ _e2eApplicability,
208
+ });
160
209
  nodes.e2e = e2e.node;
161
210
  appendE2eFeedback(feedback, nodes.e2e);
162
211
 
163
212
  if (stackNodes.length > 0) {
164
213
  nodes.tests = await runStackTestNodes({
165
214
  projectPath, testsNode: nodes.tests, stackNodes, e2eNode: nodes.e2e, stackEnv: e2e.stackEnv,
166
- timeout: explicitTimeout, gate, _runTestSuite,
215
+ timeout: explicitTimeout, gate, buildFacts, _runTestSuite,
167
216
  });
168
217
  appendTestsFeedback(feedback, nodes.tests);
169
218
  }
@@ -180,17 +229,43 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
180
229
 
181
230
  const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
182
231
 
232
+ if (cacheDir) writeVerifyCache(cacheDir, featureName, cacheEntryFrom(fingerprint, nodes));
233
+
234
+ const reused = reusedNames(nodes);
235
+ if (reused.length > 0) feedback.push(`[verify] aproveitado da rodada anterior na mesma árvore: ${reused.join(', ')}`);
236
+ const flaky = flakyNames(nodes, reuse.retried);
237
+ if (flaky.length > 0) feedback.push(`[verify] vermelho sem mudança de código, verde agora (instável): ${flaky.join(', ')}`);
238
+
183
239
  return {
184
240
  version: 1,
185
241
  feature: featureName,
186
242
  stack,
187
243
  status,
244
+ scope: options.selection ? 'affected' : 'full',
188
245
  nodes,
189
246
  feedback,
247
+ reused,
248
+ flaky,
190
249
  ...(baseline ? { baseline } : {}),
191
250
  };
192
251
  }
193
252
 
253
+ function reusedNames(nodes) {
254
+ return [
255
+ ...(nodes.build.reused ? ['build'] : []),
256
+ ...(nodes.tests.runs || []).filter((run) => run.reused).map((run) => `tests/${run.id}`),
257
+ ...(nodes.e2e.reused ? ['e2e'] : []),
258
+ ];
259
+ }
260
+
261
+ function flakyNames(nodes, retried) {
262
+ return [
263
+ ...(nodes.tests.runs || []).filter((run) => run.status === 'pass' && retried.has(run.id)).map((run) => `tests/${run.id}`),
264
+ ...(nodes.e2e.status === 'pass' && retried.has('e2e') ? ['e2e'] : []),
265
+ ...(nodes.e2e.flakySpecs || []).map((spec) => `e2e: ${spec}`),
266
+ ];
267
+ }
268
+
194
269
  /**
195
270
  * A root `dotnet build` with no solution at the root fails MSB1003: build the
196
271
  * repository's single solution instead, or say which candidates and which key decide.
@@ -259,7 +334,7 @@ function appendBuildFeedback(feedback, buildNode) {
259
334
  // with `status` because verify.js and the persisted stamp read `nodes.tests.status`.
260
335
  async function runTestsNode({
261
336
  projectPath, testPlan, filter, skipTests, buildNode, timeout, refreshCache = false,
262
- linux = false, linuxImage = null, gate, stackNodes = [], _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
337
+ linux = false, linuxImage = null, gate, stackNodes = [], reuse, buildFacts, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
263
338
  }) {
264
339
  const { nodes, source } = testPlan;
265
340
  const command = nodes.map((n) => n.command).filter(Boolean).join(' && ') || null;
@@ -282,24 +357,53 @@ async function runTestsNode({
282
357
  return rollUpTestRuns(runs, { command, source, environment });
283
358
  }
284
359
 
360
+ const declaredOrder = new Map(nodes.map((node, index) => [node.id, index]));
361
+ const redFirst = [...nodes].sort((a, b) => reuse.redFirst.has(b.id) - reuse.redFirst.has(a.id));
362
+
363
+ // A round chasing a red suite stays serial: it must be able to stop at the first one.
364
+ const together = reuse.redFirst.size > 0
365
+ ? []
366
+ : nodes.filter((node) => node.parallel && !node.e2e && !reuse.tests.has(node.id));
367
+ const finished = new Map(await Promise.all(together.map(async (node) => [node.id, await runOneTestNode({
368
+ projectPath, node, filter, timeout, refreshCache, gate, buildFacts, _runTestSuite,
369
+ })])));
370
+
285
371
  const runs = [];
286
- for (const node of nodes) {
372
+ let stillRed = null;
373
+ for (const node of redFirst) {
374
+ if (reuse.tests.has(node.id)) {
375
+ runs.push(reusedEntry(reuse.tests.get(node.id)));
376
+ continue;
377
+ }
378
+ if (finished.has(node.id)) {
379
+ runs.push(finished.get(node.id));
380
+ continue;
381
+ }
382
+ if (stillRed) {
383
+ runs.push({ ...emptyRunEntry(node), reason: `adiado: ${stillRed} segue vermelho` });
384
+ continue;
385
+ }
287
386
  if (node.e2e && !preDecideRun({ node, filter, projectPath, refreshCache })) {
288
387
  stackNodes.push(node);
289
388
  runs.push(emptyRunEntry(node));
290
389
  continue;
291
390
  }
292
- runs.push(await runOneTestNode({
293
- projectPath, node, filter, timeout, refreshCache, gate, _runTestSuite,
294
- }));
391
+ const run = await runOneTestNode({
392
+ projectPath, node, filter, timeout, refreshCache, gate, buildFacts, _runTestSuite,
393
+ });
394
+ runs.push(run);
395
+ if (run.status === 'fail' && reuse.redFirst.has(node.id)) stillRed = node.id;
295
396
  }
296
397
 
398
+ runs.sort((a, b) => declaredOrder.get(a.id) - declaredOrder.get(b.id));
297
399
  return rollUpTestRuns(runs, { command, source });
298
400
  }
299
401
 
300
402
  // No stack, no run: the skip carries the e2e verdict as reason AND warning, so the
301
403
  // suite never disappears behind a green rollup.
302
- async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode, stackEnv, timeout, gate, _runTestSuite }) {
404
+ const builtDotnet = (buildNode) => buildNode.status === 'pass' && /\bdotnet build\b/.test(buildNode.command ?? '');
405
+
406
+ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode, stackEnv, timeout, gate, buildFacts, _runTestSuite }) {
303
407
  const byId = new Map(stackNodes.map((node) => [node.id, node]));
304
408
  const runs = [];
305
409
  for (const run of testsNode.runs) {
@@ -310,7 +414,7 @@ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode,
310
414
  runs.push({ ...run, reason, warnings: [reason] });
311
415
  continue;
312
416
  }
313
- runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, _runTestSuite }));
417
+ runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, buildFacts, _runTestSuite }));
314
418
  }
315
419
  return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
316
420
  }
@@ -364,7 +468,7 @@ async function runNodesInLinux({
364
468
  }
365
469
 
366
470
  async function runOneTestNode({
367
- projectPath, node, filter, timeout, refreshCache = false, gate, env = null, _runTestSuite,
471
+ projectPath, node, filter, timeout, refreshCache = false, gate, env = null, buildFacts = {}, _runTestSuite,
368
472
  }) {
369
473
  const decided = preDecideRun({ node, filter, projectPath, refreshCache });
370
474
  if (decided) return decided;
@@ -380,7 +484,7 @@ async function runOneTestNode({
380
484
  const start = Date.now();
381
485
  const childEnv = env || evalChannel ? { ...(env || {}), ...(evalChannel?.env || {}) } : null;
382
486
  const result = await _runTestSuite(projectPath, {
383
- timeout, node, ...(childEnv ? { env: childEnv } : {}),
487
+ timeout, node: withCpuShare(node, gate.cpuShare), ...buildFacts, background: node.parallel === true, ...(childEnv ? { env: childEnv } : {}),
384
488
  });
385
489
  const durationMs = Date.now() - start;
386
490
  const logPath = writeFailedRunLog(projectPath, node, result);
@@ -522,7 +626,7 @@ function judgeRun({ node, result, durationMs, logPath = null }) {
522
626
  if (verdict.verdict === 'drop') warnings.push(verdict.reason);
523
627
 
524
628
  // Without a baseline, zero is the floor: a green run over no test is a suite that stopped running.
525
- if (result.passed && count === 0 && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
629
+ if (result.passed && count === 0 && !node.allowEmpty && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
526
630
  return { ...entry, status: 'fail', durationMs, count, warnings, reason: 'suíte verde sem nenhum teste executado', output: '' };
527
631
  }
528
632
 
@@ -763,6 +867,7 @@ async function runE2eNode({
763
867
  output: tailLines(result.output || '', OUTPUT_TAIL_LINES),
764
868
  count: Number.isInteger(result.count) ? result.count : null,
765
869
  warnings: Array.isArray(result.warnings) ? result.warnings : [],
870
+ flakySpecs: result.flakySpecs ?? [],
766
871
  },
767
872
  stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
768
873
  };
@@ -0,0 +1,141 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename, extname, join } from 'node:path';
4
+ import { changedPathsAgainst, detectDefaultBranch } from '../git/default-branch.js';
5
+ import { resolveBaseRef } from '../git/branch-classifier.js';
6
+ import { contractsTouched, migrationsTouched } from '../risk-detector.js';
7
+ import { isTestFile } from '../validators/reuse/symbol-scanner.js';
8
+
9
+ const MAX_TEST_CLASSES = 40;
10
+ const INLINE_FILTER = /(^|\s)(--filter|-Filter)\s+(?:"([^"]*)"|(\S+))/i;
11
+ const SCRIPT_IN_COMMAND = /([\w./\\-]+\.(?:ps1|sh))\b/i;
12
+ const PS_FILTER_PARAM = /\[string\]\s*\$Filter\b(?:\s*=\s*['"]([^'"]*)['"])?/i;
13
+ const PS_FILTER_USED = /dotnet\s+test[^\n]*\$Filter\b/i;
14
+ const FORWARDS_ARGS = /dotnet\s+test[^\n]*("?\$@"?|\$\{@\}|@args|\$args)/i;
15
+ const EVAL_NODE =/(^|[-_])evals?($|[-_])/i;
16
+ const DOC_PATH =/\.(md|mdx|txt|png|jpe?g|gif|svg|webp)$/i;
17
+ const DOTNET_SOURCE = /\.(cs|razor|cshtml)$/i;
18
+ const SCRIPT_SOURCE = /\.(tsx?|jsx?|mjs|cjs|css|json)$/i;
19
+ // Everything compiles or resolves through these, so the diff says nothing about what they reach.
20
+ const WIDE_PATH = /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|global\.json|Directory\.(Build|Packages)\.(props|targets)|tsconfig[^/]*\.json|vitest\.config\.[^/]+|[^/]+\.(csproj|sln|slnx))$/i;
21
+
22
+ const defaultGit = (args, cwd) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
23
+
24
+ function mergeBase(baseRef, cwd, git) {
25
+ try {
26
+ return git(['merge-base', 'HEAD', baseRef], cwd).trim() || null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function testFilesNaming(names, cwd, git) {
33
+ if (names.length === 0) return [];
34
+ try {
35
+ const output = git(['grep', '-lw', ...names.flatMap((n) => ['-e', n]), '--', '*.cs'], cwd);
36
+ return output.split(/\r?\n/).filter((path) => path && isTestFile(path));
37
+ } catch {
38
+ // git grep exits 1 when nothing matches.
39
+ return [];
40
+ }
41
+ }
42
+
43
+ const classOf = (path) => basename(path, extname(path));
44
+
45
+ const readText = (path) => {
46
+ try {
47
+ return readFileSync(path, 'utf8');
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /** Reads the wrapper the command calls, so a project gets the narrowing without declaring anything. */
54
+ function scriptFilter(node, cwd, read) {
55
+ const script = SCRIPT_IN_COMMAND.exec(node.command ?? '')?.[1];
56
+ const text = script ? read(join(cwd, node.cwd ?? '.', script)) : null;
57
+ if (!text) return null;
58
+ if (PS_FILTER_USED.test(text)) {
59
+ const param = PS_FILTER_PARAM.exec(text);
60
+ if (param) return { flag: '-Filter', baseFilter: param[1] ?? null };
61
+ }
62
+ return FORWARDS_ARGS.test(text) ? { flag: '--filter' } : null;
63
+ }
64
+
65
+ function narrowDotnet(node, classes, cwd, read) {
66
+ const selected = classes.map((c) => `FullyQualifiedName~${c}`).join('|');
67
+ const inline = INLINE_FILTER.exec(node.command ?? '');
68
+ if (inline) {
69
+ const [whole, lead, flag, quoted, bare] = inline;
70
+ return node.command.replace(whole, `${lead}${flag} "(${quoted ?? bare})&(${selected})"`);
71
+ }
72
+ const plain = /\bdotnet\s+test\b/.test(node.command ?? '') ? { flag: '--filter' } : null;
73
+ const how = node.affected ?? plain ?? scriptFilter(node, cwd, read);
74
+ if (!how) return null;
75
+ const filter = how.baseFilter ? `(${how.baseFilter})&(${selected})` : selected;
76
+ return `${node.command} ${how.flag} "${filter}"`;
77
+ }
78
+
79
+ function selectDotnet(node, relevant, cwd, git, read) {
80
+ const tests = relevant.filter(isTestFile);
81
+ const sources = relevant.filter((p) => !isTestFile(p)).map(classOf);
82
+ const classes = [...new Set([...tests, ...testFilesNaming(sources, cwd, git)].map(classOf))].sort();
83
+ if (classes.length === 0) return { skip: true };
84
+ if (classes.length > MAX_TEST_CLASSES) return { wide: classes.length };
85
+ const command = narrowDotnet(node, classes, cwd, read);
86
+ // A command nothing can narrow runs whole, as it would without this.
87
+ return command ? { node: { ...node, command, allowEmpty: true }, classes } : { node };
88
+ }
89
+
90
+ function selectVitest(node, base) {
91
+ return { node: { ...node, command: `${node.command} --changed ${base} --passWithNoTests`, allowEmpty: true } };
92
+ }
93
+
94
+ function selectNode(node, { changed, base, cwd, git, read }) {
95
+ // A project may run its evals as a plain dotnet node: they cost money, so the name counts too.
96
+ if (node.e2e || node.evals || EVAL_NODE.test(node.id ?? '')) return { skip: true };
97
+ const prefix = node.cwd && node.cwd !== '.' ? `${node.cwd.replace(/\\/g, '/').replace(/\/$/, '')}/` : '';
98
+ const inside = changed.filter((p) => p.startsWith(prefix));
99
+ if (node.runner === 'dotnet') {
100
+ const relevant = inside.filter((p) => DOTNET_SOURCE.test(p));
101
+ return relevant.length ? selectDotnet(node, relevant, cwd, git, read) : { skip: true };
102
+ }
103
+ const relevant = inside.filter((p) => SCRIPT_SOURCE.test(p));
104
+ if (relevant.length === 0) return { skip: true };
105
+ return node.runner === 'vitest' ? selectVitest(node, base) : { node };
106
+ }
107
+
108
+ /** @returns {{ scope: 'full', reason: string } | { scope: 'affected', base: string, docsOnly: boolean, nodes: object[], skipped: string[] }} */
109
+ export function selectAffected({ projectPath, nodes, deps = {} }) {
110
+ const {
111
+ git = defaultGit, changedPaths = changedPathsAgainst, read = readText,
112
+ baseRefOf = (p) => resolveBaseRef(detectDefaultBranch(p), p),
113
+ } = deps;
114
+ const baseRef = baseRefOf(projectPath);
115
+ const base = baseRef ? mergeBase(baseRef, projectPath, git) : null;
116
+ if (!base) return { scope: 'full', reason: 'sem base de comparação com a branch padrão' };
117
+
118
+ const changed = changedPaths(base, projectPath)
119
+ .map((p) => p.replace(/\\/g, '/'))
120
+ .filter((p) => !p.startsWith('.morph/'));
121
+
122
+ const migration = migrationsTouched(changed);
123
+ if (migration.length) return { scope: 'full', reason: `migration tocada: ${migration[0]}` };
124
+ const contract = contractsTouched(changed);
125
+ if (contract.length) return { scope: 'full', reason: `contrato tocado: ${contract[0]}` };
126
+ // A test project's own file reaches only tests, which the complete run still covers.
127
+ const wide = changed.find((p) => WIDE_PATH.test(p) && !isTestFile(p));
128
+ if (wide) return { scope: 'full', reason: `arquivo que alcança o projeto inteiro: ${wide}` };
129
+
130
+ const code = changed.filter((p) => !DOC_PATH.test(p));
131
+ const context = { changed: code, base, cwd: projectPath, git, read };
132
+ const selected = [];
133
+ const skipped = [];
134
+ for (const node of nodes) {
135
+ const choice = selectNode(node, context);
136
+ if (choice.wide) return { scope: 'full', reason: `código compartilhado: ${choice.wide} classes de teste alcançadas` };
137
+ if (choice.skip) skipped.push(node.id);
138
+ else selected.push(choice.node);
139
+ }
140
+ return { scope: 'affected', base, docsOnly: code.length === 0, nodes: selected, skipped };
141
+ }
@@ -0,0 +1,154 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { heavyDir } from '../../../framework/hooks/shared/heavy-holders.js';
7
+ import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
8
+ import { treeFingerprint } from '../git/tree-fingerprint.js';
9
+
10
+ export const DETACH_STAGE_ENV = 'MORPH_VERIFY_STAGE';
11
+ export const DETACH_RUN_ENV = 'MORPH_VERIFY_RUN';
12
+ export const DETACH_WORKER_ENV = 'MORPH_VERIFY_WORKER_PID';
13
+
14
+ const POLL_MS = 2_000;
15
+ const MAX_RUN_MS = 3 * 60 * 60_000;
16
+
17
+ export const EXIT_BUSY = 3;
18
+
19
+ export function runPaths(cwd, feature, env = process.env) {
20
+ const id = createHash('sha1').update(`${cwd}\0${feature}`).digest('hex').slice(0, 12);
21
+ const base = join(heavyDir(env), `verify-${id}`);
22
+ return { run: `${base}.json`, exit: `${base}.exit`, out: `${base}.out`, err: `${base}.err` };
23
+ }
24
+
25
+ const readJson = (path) => {
26
+ try {
27
+ return JSON.parse(readFileSync(path, 'utf8'));
28
+ } catch {
29
+ return null;
30
+ }
31
+ };
32
+
33
+ const readExit = (path) => {
34
+ try {
35
+ return Number.parseInt(readFileSync(path, 'utf8'), 10);
36
+ } catch {
37
+ return null;
38
+ }
39
+ };
40
+
41
+ const readText = (path) => {
42
+ try {
43
+ return readFileSync(path, 'utf8');
44
+ } catch {
45
+ return '';
46
+ }
47
+ };
48
+
49
+ const removeRun = (paths) => {
50
+ for (const file of Object.values(paths)) rmSync(file, { force: true });
51
+ };
52
+
53
+ export function matchesRun(record, { key, args }) {
54
+ return record?.key === key && JSON.stringify(record.args) === JSON.stringify(args);
55
+ }
56
+
57
+ export function isRunning(record, paths, { isAlive = pidAlive, now = Date.now } = {}) {
58
+ if (!record || existsSync(paths.exit)) return false;
59
+ return isAlive(record.pid) && now() - record.startedAt < MAX_RUN_MS;
60
+ }
61
+
62
+ export const runFilesOf = (runFile) => ({
63
+ run: runFile,
64
+ exit: runFile.replace(/\.json$/, '.exit'),
65
+ out: runFile.replace(/\.json$/, '.out'),
66
+ err: runFile.replace(/\.json$/, '.err'),
67
+ });
68
+
69
+ function spawnStage(stage, { argv, cwd, env, paths, script, spawnFn = spawn }) {
70
+ mkdirSync(join(paths.run, '..'), { recursive: true });
71
+ const out = openSync(paths.out, 'a');
72
+ const err = openSync(paths.err, 'a');
73
+ try {
74
+ const child = spawnFn(process.execPath, [script, ...argv], {
75
+ cwd,
76
+ detached: true,
77
+ windowsHide: true,
78
+ stdio: ['ignore', out, err],
79
+ env: { ...env, [DETACH_STAGE_ENV]: stage, [DETACH_RUN_ENV]: paths.run },
80
+ });
81
+ child.unref();
82
+ return child;
83
+ } finally {
84
+ closeSync(out);
85
+ closeSync(err);
86
+ }
87
+ }
88
+
89
+ export function markWorkerExit(runFile, { processRef = process } = {}) {
90
+ const exitFile = runFilesOf(runFile).exit;
91
+ processRef.once('exit', (code) => {
92
+ try {
93
+ writeFileSync(exitFile, String(code ?? 1));
94
+ } catch {}
95
+ });
96
+ }
97
+
98
+ /**
99
+ * The launcher exists to die: killing a process tree walks parent pids, so a worker whose
100
+ * parent has exited is out of reach of whatever kills the session's background shell.
101
+ */
102
+ export function launchWorker({ env = process.env, cwd = process.cwd(), argv, script = process.argv[1], spawnFn }) {
103
+ const runFile = env[DETACH_RUN_ENV];
104
+ const paths = runFilesOf(runFile);
105
+ const child = spawnStage('worker', { argv, cwd, env, paths, script, spawnFn });
106
+ writeFileSync(runFile, JSON.stringify({ ...readJson(runFile), pid: child.pid }));
107
+ spawnStage('watchdog', {
108
+ argv, cwd, env: { ...env, [DETACH_WORKER_ENV]: String(child.pid) }, paths, script, spawnFn,
109
+ });
110
+ }
111
+
112
+ export async function superviseVerify({
113
+ feature, args, cwd = process.cwd(), env = process.env, script = process.argv[1], log = (m) => process.stderr.write(`${m}\n`),
114
+ deps = {},
115
+ }) {
116
+ const {
117
+ fingerprint = treeFingerprint, spawnFn, isAlive = pidAlive, now = Date.now, wait = sleep, pollMs = POLL_MS,
118
+ } = deps;
119
+ const paths = runPaths(cwd, feature, env);
120
+ const key = fingerprint(cwd);
121
+ let record = readJson(paths.run);
122
+ const liveness = { isAlive, now };
123
+
124
+ if (isRunning(record, paths, liveness) && !matchesRun(record, { key, args })) {
125
+ log('verify: outra rodada desta feature ainda corre, sobre outra árvore; espero ela acabar');
126
+ while (isRunning(record, paths, liveness)) await wait(pollMs);
127
+ record = null;
128
+ }
129
+
130
+ const attachable = matchesRun(record, { key, args }) && (isRunning(record, paths, liveness) || existsSync(paths.exit));
131
+ if (attachable) {
132
+ log(`verify: retomo a rodada destacada iniciada às ${new Date(record.startedAt).toLocaleTimeString()}`);
133
+ } else {
134
+ removeRun(paths);
135
+ mkdirSync(join(paths.run, '..'), { recursive: true });
136
+ record = { pid: null, key, args, startedAt: now() };
137
+ writeFileSync(paths.run, JSON.stringify(record));
138
+ spawnStage('launch', { argv: args, cwd, env, paths, script, spawnFn });
139
+ }
140
+
141
+ while (!existsSync(paths.exit)) {
142
+ const current = readJson(paths.run);
143
+ const launching = !current?.pid && now() - record.startedAt < 30_000;
144
+ if (!launching && !isRunning(current, paths, liveness)) break;
145
+ await wait(pollMs);
146
+ }
147
+
148
+ const exitCode = readExit(paths.exit);
149
+ const stdout = readText(paths.out);
150
+ const stderr = readText(paths.err);
151
+ removeRun(paths);
152
+ const died = exitCode === null || (exitCode === EXIT_BUSY && !stdout.trim());
153
+ return { exitCode: exitCode ?? EXIT_BUSY, stdout, stderr, died };
154
+ }
@@ -183,7 +183,7 @@ function buildFrontmatter(agent, description, knownMcpIds, maxTurns) {
183
183
  `maxTurns: ${maxTurns}`,
184
184
  ];
185
185
  if (agent.color) lines.push(`color: ${agent.color}`);
186
- lines.push(`memory: project`, '');
186
+ lines.push('');
187
187
  return lines.join('\n');
188
188
  }
189
189
 
@@ -208,7 +208,7 @@ export const REVIEW_REPORT_CONTRACT = [
208
208
  'tabela `## Bloqueadores` (`| id | tipo | onde | evidência |`; vazia = `Nenhum.`) e `## Notas`. ' +
209
209
  'Bloqueador só com evidência e tipo; o resto é nota, e nota nunca pede rodada nova.',
210
210
  '',
211
- 'Numa confirmação (o orquestrador te retoma depois da correção, com o `sha` novo) acrescente ao fim uma seção nova com ' +
211
+ 'Numa confirmação (um dispatch novo te traz depois da correção, com o `sha` novo) acrescente ao fim uma seção nova com ' +
212
212
  `\`${VERDICT_TOKEN}\` e \`${REVIEWED_SHA_TOKEN} {sha novo}\`: se você abriu bloqueadores, julgue só eles; ` +
213
213
  'se não abriu, julgue só o delta `{sha anterior}..{sha novo}` — o que a correção abriu. ' +
214
214
  'O relatório é append-only: vale o último veredito e, na `## Checks`, a última linha de cada Cn.',
@@ -6,7 +6,6 @@ import { realEffects } from '../lib/installers/effects.js';
6
6
 
7
7
  const HOOKS_VERSION = '3.17.0';
8
8
 
9
- const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
10
9
 
11
10
  // `Edit(path)` only: an Edit rule also covers Write, while a `Write(path)` rule
12
11
  // matches nothing and makes Claude Code warn on every session start.
@@ -195,7 +194,6 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
195
194
  const managed = settings._morph_managed || {};
196
195
  const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
197
196
 
198
- removeOldDispatch(settings);
199
197
  removeMorphHooks(settings);
200
198
 
201
199
  let installed = 0;
@@ -250,8 +248,6 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
250
248
  const STALE_ALLOW = [
251
249
  'Write(.morph/features/**)',
252
250
  'Write(.morph/context/**)',
253
- 'Bash(morph-spec mutate:*)',
254
- 'Bash(npx morph-spec mutate:*)',
255
251
  ];
256
252
  settings.permissions.allow = settings.permissions.allow.filter(
257
253
  p => !MORPH_ALLOW.includes(p) && !STALE_ALLOW.includes(p)
@@ -319,22 +315,6 @@ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOve
319
315
  return { installed: true, globalDir };
320
316
  }
321
317
 
322
- function removeOldDispatch(settings) {
323
- if (!settings.hooks?.PostToolUse) return;
324
-
325
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
326
- if (!Array.isArray(entry.hooks)) return true;
327
- return !entry.hooks.some(h =>
328
- h.command === OLD_DISPATCH_COMMAND ||
329
- (typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
330
- );
331
- });
332
-
333
- if (settings.hooks.PostToolUse.length === 0) {
334
- delete settings.hooks.PostToolUse;
335
- }
336
- }
337
-
338
318
  function removeMorphHooks(settings) {
339
319
  const morphPathPattern = /framework\/hooks\/claude-code\//;
340
320