@polymorphism-tech/morph-spec 10.0.0 → 10.2.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 (81) hide show
  1. package/CLAUDE.md +2 -0
  2. package/README.md +3 -7
  3. package/bin/morph-spec.js +1 -41
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +27 -38
  6. package/docs/QUICKSTART.md +31 -191
  7. package/framework/CLAUDE.md +2 -2
  8. package/framework/MORPH.md +2 -2
  9. package/framework/agents.json +0 -1
  10. package/framework/commands/morph-archive.md +1 -1
  11. package/framework/commands/morph-preflight.md +6 -7
  12. package/framework/docs/CLI.md +24 -21
  13. package/framework/hooks/README.md +2 -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/next-step.js +4 -4
  18. package/framework/mcp/registry.json +4 -4
  19. package/framework/phases.json +4 -4
  20. package/framework/rules/ai-agents-standards.md +1 -1
  21. package/framework/rules/data-standards.md +1 -1
  22. package/framework/rules/hangfire-standards.md +1 -1
  23. package/framework/rules/neon-auth-standards.md +1 -1
  24. package/framework/rules/neon-standards.md +3 -3
  25. package/framework/schemas/config.schema.json +20 -1
  26. package/framework/schemas/feature.schema.json +1 -0
  27. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  28. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  29. package/framework/skills/core/morph-apply/SKILL.md +8 -7
  30. package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
  31. package/framework/skills/core/morph-apply/references/review.md +5 -5
  32. package/framework/skills/core/morph-plan/SKILL.md +22 -16
  33. package/framework/skills/core/morph-plan/references/critique.md +11 -8
  34. package/framework/skills/core/morph-plan/references/interview.md +18 -6
  35. package/framework/skills/core/morph-uiux/SKILL.md +2 -2
  36. package/framework/standards/STANDARDS.json +20 -24
  37. package/framework/standards/STANDARDS.meta.json +12 -19
  38. package/framework/standards/ai-agents/rag-custom-pgvector.md +1 -0
  39. package/framework/standards/architecture/stack-baseline.md +26 -3
  40. package/framework/standards/backend/database/postgresql/database.md +22 -1
  41. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
  42. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
  43. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +10 -9
  44. package/framework/standards/infrastructure/docker/local-compose-isolation.md +2 -2
  45. package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
  46. package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
  47. package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
  48. package/framework/standards/integration/mcp/mcp-tools.md +20 -20
  49. package/framework/templates/docs/plan.md +4 -1
  50. package/framework/templates/examples/design-system-examples.md +0 -3
  51. package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
  52. package/package.json +1 -1
  53. package/src/commands/project/doctor.js +0 -1
  54. package/src/commands/state/approve.js +9 -109
  55. package/src/commands/state/auto-approval.js +1 -1
  56. package/src/commands/state/create.js +1 -1
  57. package/src/commands/state/gate-check.js +2 -2
  58. package/src/commands/state/report-check.js +44 -2
  59. package/src/commands/validation/verify.js +7 -3
  60. package/src/core/state/state-manager.js +0 -24
  61. package/src/lib/detectors/index.js +1 -1
  62. package/src/lib/heavy/cleanup.js +8 -2
  63. package/src/lib/installers/memory-installer.js +1 -1
  64. package/src/lib/plan/checks.js +8 -1
  65. package/src/lib/plan/critique.js +23 -3
  66. package/src/lib/review-confirmation.js +67 -0
  67. package/src/lib/review-reports.js +1 -0
  68. package/src/lib/risk-detector.js +2 -2
  69. package/src/lib/tasks/test-nodes.js +5 -0
  70. package/src/lib/tasks/test-runner.js +37 -43
  71. package/src/lib/telemetry/telemetry-reader.js +1 -2
  72. package/src/lib/telemetry/verify-event.js +7 -0
  73. package/src/lib/validators/verify-cache.js +63 -0
  74. package/src/lib/validators/verify-runner.js +98 -23
  75. package/src/utils/agents-installer.js +2 -2
  76. package/src/utils/hooks-installer.js +0 -20
  77. package/bin/validate.js +0 -212
  78. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  79. package/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
  80. package/src/commands/observability/telemetry.js +0 -60
  81. package/src/commands/validation/validate-feature.js +0 -337
@@ -1,5 +1,5 @@
1
- import { execSync, execFileSync } from 'child_process';
2
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'fs';
1
+ import { exec as execCallback, execSync, execFileSync } from 'child_process';
2
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
3
3
  import { join, resolve, relative, isAbsolute } from 'path';
4
4
  import { globSync } from 'glob';
5
5
 
@@ -157,6 +157,7 @@ function countCompletedRuns(output) {
157
157
  }
158
158
 
159
159
  const NO_BUILD_FLAG = /(^|\s)--no-build(\s|$)/;
160
+ const DOTNET_COMMAND = /^\s*dotnet\s/;
160
161
 
161
162
  // `[]` is the safe answer: nothing compiled here, so a `--no-build` in the same command is refused.
162
163
  export function extractCsprojTargets(command) {
@@ -177,28 +178,6 @@ export function resolveTestBuildTargets(node) {
177
178
  return extractCsprojTargets(node.command);
178
179
  }
179
180
 
180
- const DOTNET_BUILDABLE = /"([^"]+\.(?:slnx?|csproj))"|'([^']+\.(?:slnx?|csproj))'|(\S+\.(?:slnx?|csproj))/gi;
181
-
182
- /**
183
- * What `dotnet test` compiles for this node, relative to its `cwd`: declared `build`,
184
- * else what the command names, else the directory's single solution/project; `[]` = undiscoverable.
185
- */
186
- export function discoverDotnetBuildTargets(node, cwd) {
187
- if (node?.runner !== 'dotnet') return [];
188
- if (Array.isArray(node.build) && node.build.length > 0) return node.build;
189
- const command = typeof node.command === 'string' ? node.command : '';
190
- const named = [...command.matchAll(DOTNET_BUILDABLE)].map((m) => m[1] ?? m[2] ?? m[3]);
191
- if (named.length > 0) return [...new Set(named)];
192
-
193
- const dirArg = command.split(/\s+/).find((token) => token && !token.startsWith('-') && token !== 'dotnet' && token !== 'test'
194
- && isDirectory(join(cwd, token)));
195
- const dir = dirArg ?? '.';
196
- let entries;
197
- try { entries = readdirSync(join(cwd, dir)); } catch { return []; }
198
- const only = singleDotnetBuildable(entries);
199
- return only ? [dir === '.' ? only : join(dir, only).replace(/\\/g, '/')] : [];
200
- }
201
-
202
181
  function singleDotnetBuildable(files) {
203
182
  const solutions = files.filter((f) => /\.slnx?$/i.test(f));
204
183
  const projects = files.filter((f) => /\.csproj$/i.test(f));
@@ -237,10 +216,6 @@ export function discoverDotnetSolution(root) {
237
216
  return { target: single, candidates: pool };
238
217
  }
239
218
 
240
- function isDirectory(path) {
241
- try { return statSync(path).isDirectory(); } catch { return false; }
242
- }
243
-
244
219
  // Derived from build-check.js, the single spelling of "dotnet build". `--no-restore` is
245
220
  // dropped: a test project outside the solution may never have been restored.
246
221
  export function dotnetBuildCommandFor(target, projectPath) {
@@ -253,7 +228,7 @@ export function dotnetBuildCommandFor(target, projectPath) {
253
228
 
254
229
  // Runs only with declared `build[]` or `--no-build`: a plain `dotnet test` builds
255
230
  // itself, and pre-compiling it would double the clock of the hot path.
256
- function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
231
+ function runTestPrebuild({ node, cwd, exec, timeout, projectPath, builtTargets = new Set() }) {
257
232
  if (node.runner !== 'dotnet') return { built: false, failure: null };
258
233
 
259
234
  const declared = Array.isArray(node.build) ? node.build : [];
@@ -265,10 +240,13 @@ function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
265
240
  const outputs = [];
266
241
 
267
242
  for (const target of targets) {
243
+ const key = `${cwd}\0${target}`;
244
+ if (builtTargets.has(key)) continue;
268
245
  const command = dotnetBuildCommandFor(target, projectPath);
269
246
  if (!command) return { built: false, failure: null };
270
247
  try {
271
248
  exec(command, { cwd, timeout, encoding: 'utf8', stdio: 'pipe' });
249
+ builtTargets.add(key);
272
250
  } catch (err) {
273
251
  outputs.push(`[${command}]\n${(err.stdout || '') + (err.stderr || '')}`);
274
252
  return {
@@ -511,12 +489,12 @@ export function runTestSuite(projectPath = '.', opts = {}) {
511
489
  const cwd = resolve(projectPath, node.cwd || '.');
512
490
 
513
491
  // Prebuild BEFORE judging `--no-build`: the build is what makes the flag legal.
514
- const prebuild = runTestPrebuild({ node, cwd, exec, timeout, projectPath });
492
+ const prebuild = runTestPrebuild({ node, cwd, exec, timeout, projectPath, builtTargets: opts.builtTargets });
515
493
  if (prebuild.failure) return prebuild.failure;
516
494
 
517
495
  // `--no-build` without a build here would run a stale assembly. The command is never
518
496
  // rewritten: a silent fix would make the reported `command` a lie about what ran.
519
- if (node.runner === 'dotnet' && NO_BUILD_FLAG.test(cmd) && !prebuild.built) {
497
+ if (DOTNET_COMMAND.test(cmd) && NO_BUILD_FLAG.test(cmd) && !prebuild.built && !opts.solutionBuilt) {
520
498
  return {
521
499
  passed: false,
522
500
  buildFailed: false,
@@ -530,18 +508,17 @@ export function runTestSuite(projectPath = '.', opts = {}) {
530
508
  // Layered OVER process.env: a child that lost PATH fails for reasons unrelated to the suite.
531
509
  const env = opts.env ? { ...process.env, ...opts.env } : undefined;
532
510
 
533
- try {
534
- const output = exec(cmd, {
535
- cwd,
536
- timeout,
537
- encoding: 'utf8',
538
- stdio: 'pipe',
539
- // Node's 1 MiB default kills a large suite mid-run (ENOBUFS), which then reads as a red suite.
540
- maxBuffer: TEST_OUTPUT_MAX_BUFFER,
541
- ...(env ? { env } : {}),
542
- });
543
- return { passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout };
544
- } catch (err) {
511
+ const execOptions = {
512
+ cwd,
513
+ timeout,
514
+ encoding: 'utf8',
515
+ stdio: 'pipe',
516
+ // Node's 1 MiB default kills a large suite mid-run (ENOBUFS), which then reads as a red suite.
517
+ maxBuffer: TEST_OUTPUT_MAX_BUFFER,
518
+ ...(env ? { env } : {}),
519
+ };
520
+ const passed = (output) => ({ passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout });
521
+ const failed = (err) => {
545
522
  // A spawn failure or a kill leaves both streams empty; its own error is then the only lead.
546
523
  const output = (err.stdout || '') + (err.stderr || '')
547
524
  || [err.code, err.signal, err.message].filter(Boolean).join(' ');
@@ -571,5 +548,22 @@ export function runTestSuite(projectPath = '.', opts = {}) {
571
548
  exitCode: err.status ?? 1,
572
549
  timeoutMs: timeout,
573
550
  };
551
+ };
552
+
553
+ if (opts.background) return runInBackground(cmd, execOptions).then(passed, failed);
554
+ try {
555
+ return passed(exec(cmd, execOptions));
556
+ } catch (err) {
557
+ return failed(err);
574
558
  }
575
559
  }
560
+
561
+ // `exec` reports the exit code in `err.code`; the shared handler reads `err.status`, as from execSync.
562
+ function runInBackground(cmd, options) {
563
+ return new Promise((resolvePromise, reject) => {
564
+ execCallback(cmd, options, (err, stdout, stderr) => {
565
+ if (!err) return resolvePromise(stdout);
566
+ return reject(Object.assign(err, { stdout, stderr, status: Number.isInteger(err.code) ? err.code : null }));
567
+ });
568
+ });
569
+ }
@@ -164,8 +164,7 @@ export function aggregateTelemetry(events, opts = {}) {
164
164
  };
165
165
  }
166
166
 
167
- // Kept apart from aggregateTelemetry: its return shape is spread into `telemetry --json`, so new fields
168
- // there would leak into that contract. Bytes re-ingested is a proxy for cost, never a token count.
167
+ // Bytes re-ingested is a proxy for cost, never a token count.
169
168
 
170
169
  function mapToObj(map) {
171
170
  const out = {};
@@ -11,6 +11,11 @@ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerp
11
11
  if (!node) continue;
12
12
  nodes[name] = { status: node.status, durationMs: Number.isFinite(node.durationMs) ? node.durationMs : null };
13
13
  }
14
+ if (nodes.tests && Array.isArray(result.nodes.tests.runs)) {
15
+ nodes.tests.runs = result.nodes.tests.runs.map(({ id, status, durationMs }) => ({
16
+ id, status: status ?? null, durationMs: Number.isFinite(durationMs) ? durationMs : null,
17
+ }));
18
+ }
14
19
  if (nodes.validators) {
15
20
  nodes.validators.ran = [...(result.nodes.validators.ran || [])];
16
21
  nodes.validators.skipped = (result.nodes.validators.skipped || [])
@@ -26,6 +31,8 @@ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerp
26
31
  persisted,
27
32
  reason: persisted ? null : (reason ?? null),
28
33
  source,
34
+ reused: result.reused ?? [],
35
+ flaky: result.flaky ?? [],
29
36
  nodes,
30
37
  };
31
38
  }
@@ -0,0 +1,63 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+
4
+ // Ignored files (`.env`, a database) are outside the fingerprint: `verify --fresh` is the way out.
5
+ function cachePath(gitCommonDir, featureName) {
6
+ return join(gitCommonDir, 'morph-spec', `verify-nodes-${featureName}.json`);
7
+ }
8
+
9
+ export function readVerifyCache(gitCommonDir, featureName) {
10
+ try {
11
+ return JSON.parse(readFileSync(cachePath(gitCommonDir, featureName), 'utf8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ /** Fail-open: a cache that cannot be written costs a rerun, never a verdict. */
18
+ export function writeVerifyCache(gitCommonDir, featureName, entry) {
19
+ try {
20
+ const path = cachePath(gitCommonDir, featureName);
21
+ mkdirSync(dirname(path), { recursive: true });
22
+ writeFileSync(path, JSON.stringify(entry));
23
+ } catch { /* see above */ }
24
+ }
25
+
26
+ const withoutOutput = ({ output: _output, ...rest }) => rest;
27
+
28
+ export function cacheEntryFrom(fingerprint, nodes) {
29
+ return {
30
+ fingerprint,
31
+ build: withoutOutput(nodes.build),
32
+ tests: (nodes.tests.runs || []).map(withoutOutput),
33
+ e2e: withoutOutput(nodes.e2e),
34
+ };
35
+ }
36
+
37
+ const NOTHING = Object.freeze({
38
+ build: null, tests: new Map(), e2e: null, redFirst: new Set(), retried: new Set(),
39
+ });
40
+
41
+ /**
42
+ * Same tree: green nodes stand, red ones run again (`retried`, to name a flake when they turn green).
43
+ * Tree moved: nothing stands, and last round's red suites run first (`redFirst`).
44
+ */
45
+ export function planReuse(previous, fingerprint) {
46
+ if (!previous || !fingerprint) return NOTHING;
47
+ const runs = previous.tests || [];
48
+ const red = new Set(runs.filter((run) => run.status === 'fail').map((run) => run.id));
49
+
50
+ if (previous.fingerprint !== fingerprint) return { ...NOTHING, redFirst: red };
51
+
52
+ return {
53
+ build: previous.build?.status === 'pass' ? previous.build : null,
54
+ tests: new Map(runs.filter((run) => run.status === 'pass').map((run) => [run.id, run])),
55
+ e2e: previous.e2e?.status === 'pass' ? previous.e2e : null,
56
+ redFirst: red,
57
+ retried: previous.e2e?.status === 'fail' ? new Set([...red, 'e2e']) : red,
58
+ };
59
+ }
60
+
61
+ export function reusedEntry(entry) {
62
+ return { ...entry, output: '', durationMs: 0, reused: true };
63
+ }
@@ -22,6 +22,9 @@ 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 {
26
+ cacheEntryFrom, planReuse, readVerifyCache, reusedEntry, writeVerifyCache,
27
+ } from './verify-cache.js';
25
28
 
26
29
  const DEFAULT_BUILD_TIMEOUT = 1_800_000;
27
30
 
@@ -62,6 +65,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
62
65
  refreshCache = false,
63
66
  linux = false,
64
67
  linuxImage = null,
68
+ fingerprint = null,
69
+ fresh = false,
65
70
  _runLinuxSuite = runLinuxSuite,
66
71
  _exec = undefined,
67
72
  _runBuildCheck = runBuildCheck,
@@ -117,13 +122,23 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
117
122
  const nodes = {};
118
123
  const feedback = [];
119
124
 
120
- nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
125
+ // Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
126
+ const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
127
+ && !refreshCache && !linux && !freshE2e
128
+ ? resolveGitCommonDir(projectPath)
129
+ : null;
130
+ const reuse = planReuse(cacheDir && !fresh ? readVerifyCache(cacheDir, featureName) : null, fingerprint);
131
+
132
+ nodes.build = reuse.build
133
+ ? reusedEntry(reuse.build)
134
+ : await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
121
135
  appendBuildFeedback(feedback, nodes.build);
122
136
 
123
137
  // Only a TYPED --timeout reaches the tests: the build default would outrank
124
138
  // config.project.testTimeoutMs and the per-project scaling.
125
139
  const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
126
140
  const stackNodes = [];
141
+ const buildFacts = { solutionBuilt: builtDotnet(nodes.build), builtTargets: new Set() };
127
142
  nodes.tests = await runTestsNode({
128
143
  projectPath,
129
144
  testPlan,
@@ -136,6 +151,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
136
151
  linuxImage,
137
152
  gate,
138
153
  stackNodes,
154
+ reuse,
155
+ buildFacts,
139
156
  _runTestSuite,
140
157
  _runLinuxSuite,
141
158
  _exec,
@@ -145,25 +162,28 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
145
162
  nodes.validators = await runValidatorsNode({ projectPath, featureName, _runValidation });
146
163
  appendValidatorsFeedback(feedback, nodes.validators);
147
164
 
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
- });
165
+ // A suite with `e2e: true` still to run needs the stack's env, which only a real run returns.
166
+ const e2e = reuse.e2e && stackNodes.length === 0 && nodes.tests.status !== 'fail'
167
+ ? { node: reusedEntry(reuse.e2e), stackEnv: null }
168
+ : await runE2eNode({
169
+ projectPath,
170
+ featureName,
171
+ skipE2e,
172
+ freshE2e,
173
+ buildNode: nodes.build,
174
+ testsNode: nodes.tests,
175
+ hasStackNodes: stackNodes.length > 0,
176
+ gate,
177
+ _runE2eCheck,
178
+ _e2eApplicability,
179
+ });
160
180
  nodes.e2e = e2e.node;
161
181
  appendE2eFeedback(feedback, nodes.e2e);
162
182
 
163
183
  if (stackNodes.length > 0) {
164
184
  nodes.tests = await runStackTestNodes({
165
185
  projectPath, testsNode: nodes.tests, stackNodes, e2eNode: nodes.e2e, stackEnv: e2e.stackEnv,
166
- timeout: explicitTimeout, gate, _runTestSuite,
186
+ timeout: explicitTimeout, gate, buildFacts, _runTestSuite,
167
187
  });
168
188
  appendTestsFeedback(feedback, nodes.tests);
169
189
  }
@@ -180,6 +200,13 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
180
200
 
181
201
  const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
182
202
 
203
+ if (cacheDir) writeVerifyCache(cacheDir, featureName, cacheEntryFrom(fingerprint, nodes));
204
+
205
+ const reused = reusedNames(nodes);
206
+ if (reused.length > 0) feedback.push(`[verify] aproveitado da rodada anterior na mesma árvore: ${reused.join(', ')}`);
207
+ const flaky = flakyNames(nodes, reuse.retried);
208
+ if (flaky.length > 0) feedback.push(`[verify] vermelho sem mudança de código, verde agora (instável): ${flaky.join(', ')}`);
209
+
183
210
  return {
184
211
  version: 1,
185
212
  feature: featureName,
@@ -187,10 +214,28 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
187
214
  status,
188
215
  nodes,
189
216
  feedback,
217
+ reused,
218
+ flaky,
190
219
  ...(baseline ? { baseline } : {}),
191
220
  };
192
221
  }
193
222
 
223
+ function reusedNames(nodes) {
224
+ return [
225
+ ...(nodes.build.reused ? ['build'] : []),
226
+ ...(nodes.tests.runs || []).filter((run) => run.reused).map((run) => `tests/${run.id}`),
227
+ ...(nodes.e2e.reused ? ['e2e'] : []),
228
+ ];
229
+ }
230
+
231
+ function flakyNames(nodes, retried) {
232
+ return [
233
+ ...(nodes.tests.runs || []).filter((run) => run.status === 'pass' && retried.has(run.id)).map((run) => `tests/${run.id}`),
234
+ ...(nodes.e2e.status === 'pass' && retried.has('e2e') ? ['e2e'] : []),
235
+ ...(nodes.e2e.flakySpecs || []).map((spec) => `e2e: ${spec}`),
236
+ ];
237
+ }
238
+
194
239
  /**
195
240
  * A root `dotnet build` with no solution at the root fails MSB1003: build the
196
241
  * repository's single solution instead, or say which candidates and which key decide.
@@ -259,7 +304,7 @@ function appendBuildFeedback(feedback, buildNode) {
259
304
  // with `status` because verify.js and the persisted stamp read `nodes.tests.status`.
260
305
  async function runTestsNode({
261
306
  projectPath, testPlan, filter, skipTests, buildNode, timeout, refreshCache = false,
262
- linux = false, linuxImage = null, gate, stackNodes = [], _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
307
+ linux = false, linuxImage = null, gate, stackNodes = [], reuse, buildFacts, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
263
308
  }) {
264
309
  const { nodes, source } = testPlan;
265
310
  const command = nodes.map((n) => n.command).filter(Boolean).join(' && ') || null;
@@ -282,24 +327,53 @@ async function runTestsNode({
282
327
  return rollUpTestRuns(runs, { command, source, environment });
283
328
  }
284
329
 
330
+ const declaredOrder = new Map(nodes.map((node, index) => [node.id, index]));
331
+ const redFirst = [...nodes].sort((a, b) => reuse.redFirst.has(b.id) - reuse.redFirst.has(a.id));
332
+
333
+ // A round chasing a red suite stays serial: it must be able to stop at the first one.
334
+ const together = reuse.redFirst.size > 0
335
+ ? []
336
+ : nodes.filter((node) => node.parallel && !node.e2e && !reuse.tests.has(node.id));
337
+ const finished = new Map(await Promise.all(together.map(async (node) => [node.id, await runOneTestNode({
338
+ projectPath, node, filter, timeout, refreshCache, gate, buildFacts, _runTestSuite,
339
+ })])));
340
+
285
341
  const runs = [];
286
- for (const node of nodes) {
342
+ let stillRed = null;
343
+ for (const node of redFirst) {
344
+ if (reuse.tests.has(node.id)) {
345
+ runs.push(reusedEntry(reuse.tests.get(node.id)));
346
+ continue;
347
+ }
348
+ if (finished.has(node.id)) {
349
+ runs.push(finished.get(node.id));
350
+ continue;
351
+ }
352
+ if (stillRed) {
353
+ runs.push({ ...emptyRunEntry(node), reason: `adiado: ${stillRed} segue vermelho` });
354
+ continue;
355
+ }
287
356
  if (node.e2e && !preDecideRun({ node, filter, projectPath, refreshCache })) {
288
357
  stackNodes.push(node);
289
358
  runs.push(emptyRunEntry(node));
290
359
  continue;
291
360
  }
292
- runs.push(await runOneTestNode({
293
- projectPath, node, filter, timeout, refreshCache, gate, _runTestSuite,
294
- }));
361
+ const run = await runOneTestNode({
362
+ projectPath, node, filter, timeout, refreshCache, gate, buildFacts, _runTestSuite,
363
+ });
364
+ runs.push(run);
365
+ if (run.status === 'fail' && reuse.redFirst.has(node.id)) stillRed = node.id;
295
366
  }
296
367
 
368
+ runs.sort((a, b) => declaredOrder.get(a.id) - declaredOrder.get(b.id));
297
369
  return rollUpTestRuns(runs, { command, source });
298
370
  }
299
371
 
300
372
  // No stack, no run: the skip carries the e2e verdict as reason AND warning, so the
301
373
  // suite never disappears behind a green rollup.
302
- async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode, stackEnv, timeout, gate, _runTestSuite }) {
374
+ const builtDotnet = (buildNode) => buildNode.status === 'pass' && /\bdotnet build\b/.test(buildNode.command ?? '');
375
+
376
+ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode, stackEnv, timeout, gate, buildFacts, _runTestSuite }) {
303
377
  const byId = new Map(stackNodes.map((node) => [node.id, node]));
304
378
  const runs = [];
305
379
  for (const run of testsNode.runs) {
@@ -310,7 +384,7 @@ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode,
310
384
  runs.push({ ...run, reason, warnings: [reason] });
311
385
  continue;
312
386
  }
313
- runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, _runTestSuite }));
387
+ runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, buildFacts, _runTestSuite }));
314
388
  }
315
389
  return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
316
390
  }
@@ -364,7 +438,7 @@ async function runNodesInLinux({
364
438
  }
365
439
 
366
440
  async function runOneTestNode({
367
- projectPath, node, filter, timeout, refreshCache = false, gate, env = null, _runTestSuite,
441
+ projectPath, node, filter, timeout, refreshCache = false, gate, env = null, buildFacts = {}, _runTestSuite,
368
442
  }) {
369
443
  const decided = preDecideRun({ node, filter, projectPath, refreshCache });
370
444
  if (decided) return decided;
@@ -380,7 +454,7 @@ async function runOneTestNode({
380
454
  const start = Date.now();
381
455
  const childEnv = env || evalChannel ? { ...(env || {}), ...(evalChannel?.env || {}) } : null;
382
456
  const result = await _runTestSuite(projectPath, {
383
- timeout, node, ...(childEnv ? { env: childEnv } : {}),
457
+ timeout, node, ...buildFacts, background: node.parallel === true, ...(childEnv ? { env: childEnv } : {}),
384
458
  });
385
459
  const durationMs = Date.now() - start;
386
460
  const logPath = writeFailedRunLog(projectPath, node, result);
@@ -763,6 +837,7 @@ async function runE2eNode({
763
837
  output: tailLines(result.output || '', OUTPUT_TAIL_LINES),
764
838
  count: Number.isInteger(result.count) ? result.count : null,
765
839
  warnings: Array.isArray(result.warnings) ? result.warnings : [],
840
+ flakySpecs: result.flakySpecs ?? [],
766
841
  },
767
842
  stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
768
843
  };
@@ -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