@polymorphism-tech/morph-spec 10.1.1 → 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 (48) hide show
  1. package/README.md +3 -7
  2. package/bin/morph-spec.js +1 -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/agents.json +0 -1
  7. package/framework/commands/morph-archive.md +1 -1
  8. package/framework/commands/morph-preflight.md +3 -4
  9. package/framework/docs/CLI.md +23 -20
  10. package/framework/hooks/README.md +2 -2
  11. package/framework/hooks/claude-code/core/validate-completion.js +0 -35
  12. package/framework/hooks/shared/e2e-check.js +44 -7
  13. package/framework/hooks/shared/heavy-command.js +12 -1
  14. package/framework/hooks/shared/next-step.js +2 -2
  15. package/framework/phases.json +4 -4
  16. package/framework/schemas/config.schema.json +20 -1
  17. package/framework/schemas/feature.schema.json +1 -0
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +5 -5
  21. package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
  22. package/framework/skills/core/morph-apply/references/review.md +5 -5
  23. package/framework/skills/core/morph-plan/SKILL.md +4 -4
  24. package/framework/skills/core/morph-uiux/SKILL.md +2 -2
  25. package/framework/templates/examples/design-system-examples.md +0 -3
  26. package/package.json +1 -1
  27. package/src/commands/project/doctor.js +0 -1
  28. package/src/commands/state/approve.js +9 -109
  29. package/src/commands/state/report-check.js +44 -2
  30. package/src/commands/validation/verify.js +7 -3
  31. package/src/core/state/state-manager.js +0 -24
  32. package/src/lib/heavy/cleanup.js +8 -2
  33. package/src/lib/plan/critique.js +23 -3
  34. package/src/lib/review-confirmation.js +67 -0
  35. package/src/lib/review-reports.js +1 -0
  36. package/src/lib/risk-detector.js +2 -2
  37. package/src/lib/tasks/test-nodes.js +5 -0
  38. package/src/lib/tasks/test-runner.js +37 -43
  39. package/src/lib/telemetry/telemetry-reader.js +1 -2
  40. package/src/lib/telemetry/verify-event.js +7 -0
  41. package/src/lib/validators/verify-cache.js +63 -0
  42. package/src/lib/validators/verify-runner.js +98 -23
  43. package/src/utils/agents-installer.js +2 -2
  44. package/src/utils/hooks-installer.js +0 -20
  45. package/bin/validate.js +0 -212
  46. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  47. package/src/commands/observability/telemetry.js +0 -60
  48. package/src/commands/validation/validate-feature.js +0 -337
@@ -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
  }
@@ -0,0 +1,67 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { basename } from 'path';
3
+ import { treeMovedSince } from './git/evidence-staleness.js';
4
+ import { isTestOnly, securityReviewFor } from './risk-detector.js';
5
+
6
+ function resolveCommit(cwd, ref) {
7
+ try {
8
+ return execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { cwd, stdio: 'pipe' }).toString().trim();
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function changedBetween(cwd, from, to) {
15
+ try {
16
+ return execFileSync('git', ['diff', '--name-only', '--no-renames', from, to, '--', '.', ':(exclude).morph'], { cwd, stdio: 'pipe' })
17
+ .toString().split('\n').map((line) => line.trim()).filter(Boolean);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function citedByBlockers(reports) {
24
+ return Object.values(reports)
25
+ .flatMap((report) => report.rounds ?? [])
26
+ .flatMap((round) => round.blockers ?? [])
27
+ .map((blocker) => `${blocker.where} ${blocker.evidence}`)
28
+ .join('\n');
29
+ }
30
+
31
+ const openedBlockers = (report) => (report.rounds ?? []).some((round) => (round.blockers ?? []).length > 0);
32
+ const isCleanShip = (report) => report.verdict === 'SHIP' && (report.blockers ?? []).length === 0;
33
+
34
+ function deltaIsNarrow(role, delta, cited) {
35
+ if (delta === null) return false;
36
+ if (role === 'security') return !securityReviewFor(delta).required;
37
+ return delta.every((file) => isTestOnly(file) || cited.includes(file) || cited.includes(basename(file)));
38
+ }
39
+
40
+ /**
41
+ * After a fix, a role that shipped clean need not return when the delta stays inside what the
42
+ * blockers named (security: when it touches no sensitive path). `moved` lists the roles behind
43
+ * `head`; `skippable` says it holds; `confirmed` says a role that opened blockers re-judged `head`,
44
+ * without which nothing is skippable at approval.
45
+ */
46
+ export function reviewConfirmation(cwd, reports, head) {
47
+ const cited = citedByBlockers(reports);
48
+ const moved = [];
49
+ let confirmed = false;
50
+
51
+ for (const [role, report] of Object.entries(reports)) {
52
+ if (report.status === 'missing' || !report.reviewedSha) continue;
53
+ const reviewed = resolveCommit(cwd, report.reviewedSha);
54
+ if (!reviewed) continue;
55
+ if (!treeMovedSince(cwd, reviewed, head)) {
56
+ if (openedBlockers(report)) confirmed = true;
57
+ continue;
58
+ }
59
+ moved.push({
60
+ role,
61
+ reviewed,
62
+ skippable: isCleanShip(report) && deltaIsNarrow(role, changedBetween(cwd, reviewed, head), cited),
63
+ });
64
+ }
65
+
66
+ return { moved, confirmed };
67
+ }
@@ -32,6 +32,7 @@ const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
32
32
 
33
33
  /** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Chore: só o revisor (review leve). */
34
34
  export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
35
+ // Sem `security` mesmo em caminho sensível: o humano assina todo hotfix.
35
36
  if (workType === 'hotfix') return ['verifier'];
36
37
  const base = workType === 'chore' ? ['reviewer'] : ['verifier', 'reviewer'];
37
38
  return securityRequired ? [...base, 'security'] : base;
@@ -83,7 +83,7 @@ export function reviewPause(signal) {
83
83
  + ' auto-avaliação de quem implementou não decide o ship';
84
84
  }
85
85
  if (r.status === 'partial') {
86
- return `\`${file}\` está PARCIAL — retome o mesmo agente por SendMessage; parcial não é veredito`;
86
+ return `\`${file}\` está PARCIAL — despache o mesmo papel de novo dizendo o que já está no relatório; parcial não é veredito`;
87
87
  }
88
88
  if (r.status !== 'ok') {
89
89
  return `\`${file}\` sem \`**Verdict:** SHIP|FIX\` — o relatório não seguiu o contrato`;
@@ -144,7 +144,7 @@ export function verifierModelFor({ promptModelTouched = false, generatesMedia =
144
144
 
145
145
  // Teste e artefato de teste não são superfície de ataque: `e2e/login.spec.ts` não pede a persona security.
146
146
  const E2E_DIR_RE = /(^|\/)e2e\//;
147
- const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
147
+ export const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
148
148
 
149
149
  export function securityReviewFor(changedFiles = []) {
150
150
  const risky = changedFiles
@@ -83,6 +83,7 @@ function makeNode(partial) {
83
83
  runner,
84
84
  expectedCount: keepsBaseline(runner) ? partial.expectedCount ?? null : null,
85
85
  timeoutMs: partial.timeoutMs ?? null,
86
+ parallel: partial.parallel ?? false,
86
87
  build: partial.build ?? [],
87
88
  e2e: partial.e2e ?? false,
88
89
  linuxImage: partial.linuxImage ?? null,
@@ -133,6 +134,9 @@ function firstProblem(raw, projectPath) {
133
134
  if (raw.timeoutMs !== undefined && !(Number.isInteger(raw.timeoutMs) && raw.timeoutMs > 0)) {
134
135
  return 'timeoutMs must be an integer > 0';
135
136
  }
137
+ if (raw.parallel !== undefined && typeof raw.parallel !== 'boolean') {
138
+ return 'parallel must be a boolean';
139
+ }
136
140
  if (raw.build !== undefined && (!Array.isArray(raw.build) || raw.build.some((b) => typeof b !== 'string'))) {
137
141
  return 'build must be an array of strings';
138
142
  }
@@ -208,6 +212,7 @@ export function validateTestNodes(config, projectPath = null) {
208
212
  runner: entry.runner ?? inferRunner(entry.command),
209
213
  expectedCount: entry.expectedCount ?? null,
210
214
  timeoutMs: entry.timeoutMs ?? null,
215
+ parallel: entry.parallel ?? false,
211
216
  build: entry.build ?? [],
212
217
  e2e: entry.e2e ?? false,
213
218
  linuxImage: entry.linuxImage ?? null,
@@ -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.',