@polymorphism-tech/morph-spec 9.7.0 → 10.0.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 (211) hide show
  1. package/CLAUDE.md +11 -11
  2. package/README.md +4 -4
  3. package/bin/morph-spec.js +41 -44
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +24 -30
  8. package/framework/MORPH.md +47 -123
  9. package/framework/agents.json +6 -374
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-archive.md +4 -3
  12. package/framework/commands/morph-hotfix.md +8 -30
  13. package/framework/commands/morph-infra.md +2 -2
  14. package/framework/commands/morph-media.md +10 -9
  15. package/framework/commands/morph-review.md +15 -0
  16. package/framework/commands/morph-status.md +1 -1
  17. package/framework/docs/CLI.md +100 -102
  18. package/framework/evals/review-checklist.md +46 -54
  19. package/framework/hooks/README.md +4 -4
  20. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  21. package/framework/hooks/claude-code/core/README.md +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +4 -4
  23. package/framework/hooks/claude-code/core/gate-guard.js +9 -0
  24. package/framework/hooks/claude-code/core/masked-exit.js +49 -0
  25. package/framework/hooks/claude-code/core/state-sync.js +4 -1
  26. package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
  27. package/framework/hooks/claude-code/core/test-budget.js +10 -70
  28. package/framework/hooks/claude-code/core/validate-completion.js +11 -24
  29. package/framework/hooks/claude-code/core/worktree-context.js +5 -22
  30. package/framework/hooks/claude-code/core/worktree-create.js +6 -45
  31. package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
  32. package/framework/hooks/shared/e2e-check.js +0 -0
  33. package/framework/hooks/shared/gate-event.js +2 -2
  34. package/framework/hooks/shared/graph-refresh.js +20 -3
  35. package/framework/hooks/shared/heavy-command.js +32 -1
  36. package/framework/hooks/shared/heavy-config.js +1 -1
  37. package/framework/hooks/shared/heavy-slots.js +41 -4
  38. package/framework/hooks/shared/next-step.js +63 -24
  39. package/framework/hooks/shared/phase-utils.js +6 -9
  40. package/framework/hooks/shared/report-progress.js +86 -0
  41. package/framework/hooks/shared/state-reader.js +3 -2
  42. package/framework/hooks/statusline.js +332 -0
  43. package/framework/phases.json +32 -10
  44. package/framework/rules/ai-agents-standards.md +9 -1
  45. package/framework/rules/csharp-standards.md +4 -0
  46. package/framework/rules/ef-standards.md +20 -0
  47. package/framework/rules/frontend-standards.md +1 -1
  48. package/framework/rules/implement-report.md +25 -0
  49. package/framework/rules/infrastructure-standards.md +2 -0
  50. package/framework/rules/nextjs-standards.md +7 -0
  51. package/framework/rules/plan-grammar.md +53 -0
  52. package/framework/rules/ui-design.md +27 -0
  53. package/framework/schemas/config.schema.json +12 -19
  54. package/framework/schemas/feature.schema.json +7 -1
  55. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  56. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
  57. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
  58. package/framework/skills/core/morph-apply/SKILL.md +38 -166
  59. package/framework/skills/core/morph-apply/references/debugging.md +8 -0
  60. package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
  61. package/framework/skills/core/morph-apply/references/machine.md +29 -0
  62. package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
  63. package/framework/skills/core/morph-apply/references/review.md +65 -0
  64. package/framework/skills/core/morph-plan/SKILL.md +42 -108
  65. package/framework/skills/core/morph-plan/references/critique.md +40 -0
  66. package/framework/skills/core/morph-plan/references/interview.md +25 -0
  67. package/framework/skills/core/morph-uiux/SKILL.md +47 -64
  68. package/framework/skills/core/morph-uiux/surfaces.json +32 -0
  69. package/framework/standards/STANDARDS.json +32 -5
  70. package/framework/standards/STANDARDS.meta.json +5 -1
  71. package/framework/standards/ai-agents/agent-session.md +2 -3
  72. package/framework/standards/ai-agents/context-providers.md +2 -2
  73. package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
  74. package/framework/standards/ai-agents/providers/model-registry.md +2 -2
  75. package/framework/standards/ai-agents/rag-custom-pgvector.md +6 -6
  76. package/framework/standards/ai-agents/setup.md +1 -1
  77. package/framework/standards/ai-agents/tool-adapters.md +2 -2
  78. package/framework/standards/architecture/stack-baseline.md +47 -0
  79. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -4
  80. package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
  81. package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
  82. package/framework/standards/frontend/design-system/art-direction.md +2 -2
  83. package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
  84. package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
  85. package/framework/templates/.idea/morph-templates.xml +92 -0
  86. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  87. package/framework/templates/IDE-SNIPPETS.md +262 -262
  88. package/framework/templates/REGISTRY.json +1 -2
  89. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  90. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  91. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  92. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  93. package/framework/templates/context/ARCHITECTURE.md +1 -1
  94. package/framework/templates/docs/plan.md +12 -12
  95. package/framework/templates/docs/ui-mockups.md +17 -105
  96. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  97. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  98. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  99. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  100. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  101. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  102. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  103. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  104. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  105. package/framework/templates/state.template.json +17 -17
  106. package/framework/templates/ui/design-system.css +226 -226
  107. package/package.json +1 -1
  108. package/src/commands/e2e/e2e.js +88 -23
  109. package/src/commands/graph/diff.js +74 -0
  110. package/src/commands/graph/status.js +1 -1
  111. package/src/commands/observability/cost.js +2 -4
  112. package/src/commands/observability/telemetry.js +1 -1
  113. package/src/commands/project/doctor.js +23 -39
  114. package/src/commands/project/fleet.js +5 -6
  115. package/src/commands/project/init.js +63 -36
  116. package/src/commands/project/stack-scan.js +3 -42
  117. package/src/commands/project/stack-set.js +26 -0
  118. package/src/commands/project/status.js +12 -13
  119. package/src/commands/project/update.js +39 -3
  120. package/src/commands/project/worktree.js +27 -16
  121. package/src/commands/state/advance.js +17 -17
  122. package/src/commands/state/approve.js +0 -6
  123. package/src/commands/state/archive.js +27 -1
  124. package/src/commands/state/auto-approval.js +1 -1
  125. package/src/commands/state/create.js +0 -5
  126. package/src/commands/state/finish.js +43 -1
  127. package/src/commands/state/gate-check.js +37 -30
  128. package/src/commands/state/gate-decision.js +3 -2
  129. package/src/commands/state/next.js +1 -0
  130. package/src/commands/state/recap.js +67 -0
  131. package/src/commands/state/report-check.js +39 -146
  132. package/src/commands/validation/plan-check.js +16 -11
  133. package/src/commands/validation/validate-feature.js +3 -3
  134. package/src/commands/validation/verify.js +6 -6
  135. package/src/core/paths/output-schema.js +3 -3
  136. package/src/core/state/state-manager.js +2 -4
  137. package/src/dashboard/app/components/CommandPalette.jsx +2 -5
  138. package/src/dashboard/app/components/GatePauses.jsx +116 -0
  139. package/src/dashboard/app/components/SessionMap.jsx +77 -0
  140. package/src/dashboard/app/main.jsx +13 -13
  141. package/src/dashboard/app/pages/Activity.jsx +4 -0
  142. package/src/dashboard/app/pages/AgentMap.jsx +1 -2
  143. package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
  144. package/src/dashboard/app/pages/Features.jsx +7 -7
  145. package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
  146. package/src/dashboard/app/pages/Portfolio.jsx +40 -5
  147. package/src/dashboard/scanner/activity-scanner.js +29 -0
  148. package/src/dashboard/scanner/agents-scanner.js +11 -14
  149. package/src/dashboard/scanner/features-scanner.js +45 -24
  150. package/src/dashboard/scanner/project-api.js +11 -2
  151. package/src/dashboard/scanner/skills-scanner.js +11 -1
  152. package/src/dashboard/vite.config.js +24 -24
  153. package/src/lib/e2e/applicability.js +3 -1
  154. package/src/lib/git/tree-state.js +1 -16
  155. package/src/lib/graph/diff-tree.js +127 -0
  156. package/src/lib/graph/graphify-runtime.js +12 -5
  157. package/src/lib/heavy/gate.js +22 -1
  158. package/src/lib/installers/memory-installer.js +2 -2
  159. package/src/lib/installers/rules-installer.js +57 -0
  160. package/src/lib/installers/stack-assets.js +17 -0
  161. package/src/lib/installers/stack-sync.js +219 -0
  162. package/src/lib/plan/checks.js +55 -110
  163. package/src/lib/plan/critique.js +28 -0
  164. package/src/lib/plan/media.js +42 -0
  165. package/src/lib/plan/plan-html.js +125 -0
  166. package/src/lib/plan/plan-parser.js +34 -45
  167. package/src/lib/recap/piper.js +36 -0
  168. package/src/lib/recap/recap.js +232 -0
  169. package/src/lib/review-reports.js +33 -7
  170. package/src/lib/risk-detector.js +90 -11
  171. package/src/lib/specs/screen-checklist.js +33 -48
  172. package/src/lib/stack/dotnet-build-ignores.js +8 -9
  173. package/src/lib/tasks/test-nodes.js +24 -6
  174. package/src/lib/tasks/test-runner.js +40 -1
  175. package/src/lib/telemetry/metrics.js +27 -29
  176. package/src/lib/validators/reuse/reuse-validator.js +1 -9
  177. package/src/lib/validators/verify-runner.js +50 -19
  178. package/src/lib/worktree/fleet-scanner.js +7 -20
  179. package/src/lib/worktree/worktree-audit.js +0 -37
  180. package/src/scripts/setup-infra.js +15 -50
  181. package/src/utils/agents-installer.js +27 -201
  182. package/src/utils/hooks-installer.js +11 -95
  183. package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
  184. package/framework/hooks/claude-code/statusline.py +0 -699
  185. package/framework/hooks/claude-code/statusline.sh +0 -11
  186. package/framework/hooks/shared/agent-reap.js +0 -74
  187. package/framework/hooks/shared/slice-progress.js +0 -110
  188. package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
  189. package/framework/skills/core/morph-machine/SKILL.md +0 -46
  190. package/framework/skills/core/morph-review/SKILL.md +0 -129
  191. package/framework/skills/core/morph-standards/SKILL.md +0 -52
  192. package/framework/standards/PROVENANCE.json +0 -119
  193. package/src/commands/project/worktree-reap.js +0 -34
  194. package/src/commands/state/handoff.js +0 -65
  195. package/src/commands/validation/mutate.js +0 -263
  196. package/src/commands/validation/scout-check.js +0 -28
  197. package/src/lib/generators/handoff-generator.js +0 -172
  198. package/src/lib/mutation/adapters.js +0 -79
  199. package/src/lib/mutation/commands.js +0 -61
  200. package/src/lib/mutation/dotnet-introspection.js +0 -281
  201. package/src/lib/mutation/manifest.js +0 -144
  202. package/src/lib/mutation/plan.js +0 -44
  203. package/src/lib/mutation/report.js +0 -68
  204. package/src/lib/mutation/runner.js +0 -301
  205. package/src/lib/mutation/schemata.js +0 -60
  206. package/src/lib/mutation/snapshot.js +0 -65
  207. package/src/lib/mutation/subset.js +0 -109
  208. package/src/lib/mutation/trx.js +0 -50
  209. package/src/lib/scout/scout-check.js +0 -293
  210. package/src/lib/validators/traceability-check.js +0 -103
  211. package/src/lib/worktree/agent-reaper.js +0 -247
@@ -5,30 +5,19 @@
5
5
  import { createHash } from 'crypto';
6
6
  import { existsSync, readFileSync } from 'fs';
7
7
  import { dirname, join } from 'path';
8
- import { fileURLToPath } from 'url';
9
8
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
10
9
  import { verifierScreenChecklist } from '../review-reports.js';
11
10
  import { LEGACY_MOCKUP_EXEMPTION, uiuxChecklistPause } from '../risk-detector.js';
12
11
 
13
- const PACKAGE_AGENTS = fileURLToPath(new URL('../../../framework/agents.json', import.meta.url));
14
-
15
12
  /** `match` roda sobre o rótulo normalizado; um rótulo que casa com vários itens responde a todos. */
16
13
  export const SCREEN_CHECKLIST_ITEMS = [
17
- { id: 'superficie', label: 'Superfície', match: /\bsuperficie\b/ },
18
- { id: 'volume', label: 'Volume esperado', match: /\bvolume\b/ },
19
- { id: 'paginacao', label: 'Paginação', match: /\bpaginacao\b/ },
20
- { id: 'ordenacao', label: 'Ordenação', match: /\bordenacao\b/ },
21
- { id: 'filtro', label: 'Filtro', match: /\bfiltros?\b/ },
22
- { id: 'vazio', label: 'Estado vazio', match: /\bvazio\b/ },
23
- { id: 'carregando', label: 'Estado carregando', match: /\bcarregando\b|\bcarregamento\b/ },
24
- { id: 'erro', label: 'Estado de erro', match: /\berro\b/ },
25
- { id: 'mobile', label: 'Mobile', match: /\bmobile\b/ },
26
- { id: 'abas', label: 'Abas', match: /\babas\b/ },
14
+ { id: 'servidor', label: 'Lista no servidor', match: /\bpaginacao\b|\bordenacao\b|\bservidor\b/ },
15
+ { id: 'estados', label: 'Estados vazio e carregando', match: /\bvazio\b|\bcarregando\b|\bcarregamento\b/ },
16
+ { id: 'erro', label: 'Erro com próxima ação', match: /\berro\b/ },
17
+ { id: 'acao', label: 'Uma ação primária', match: /\bprimaria\b/ },
18
+ { id: 'mobile', label: 'Celular', match: /\bmobile\b|\bcelular\b/ },
27
19
  ];
28
20
 
29
- // A superfície decide quais standards a tela lê, não um comportamento que a tela entregue exiba.
30
- const NOT_VERIFIED_AT_REVIEW = new Set(['superficie']);
31
-
32
21
  // As exatas, e as que começam adiando a decisão ("a definir depois", "TBD.").
33
22
  const PLACEHOLDERS = new Set(['', '-', '--', '—', '–', '...', 'x', 'todo', 'definir', 'pendente']);
34
23
  const PLACEHOLDER_PREFIX_RE = /^(a definir|tbd|\?|…)/;
@@ -141,30 +130,9 @@ const isNotApplicable = (answer) => NOT_APPLICABLE_RE.test(normalizeLabel(answer
141
130
 
142
131
  const labelOf = (id) => SCREEN_CHECKLIST_ITEMS.find((item) => item.id === id)?.label ?? id;
143
132
 
144
- // O vocabulário válido de `Superfície`: as `surfaces` da `ui-designer`, da cópia instalada ou do pacote.
145
- // null quando a persona não declara o campo, e então o valor não é validado.
146
- export function knownSurfaces(cwd) {
147
- const surfaces = readAgentsJson(cwd)?.agents?.['ui-designer']?.surfaces;
148
- return surfaces && typeof surfaces === 'object' && !Array.isArray(surfaces) ? Object.keys(surfaces) : null;
149
- }
150
-
151
- /** The project's installed `agents.json`, else the package's; null when unreadable. */
152
- export function readAgentsJson(cwd) {
153
- for (const path of [join(cwd, '.morph', 'framework', 'agents.json'), PACKAGE_AGENTS]) {
154
- if (!existsSync(path)) continue;
155
- try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
156
- }
157
- return null;
158
- }
159
-
160
- function namesSurface(answer, surfaces) {
161
- const norm = normalizeLabel(answer);
162
- return surfaces.some((key) => new RegExp(`(^|[^a-z0-9-])${key}([^a-z0-9-]|$)`).test(norm));
163
- }
164
-
165
133
  // Sem nenhuma `## Tela:`, só o formato antigo é legado; qualquer outro heading é "nenhuma tela
166
134
  // declarada", senão trocar o heading pularia o checklist.
167
- export function auditScreenChecklist(markdown, { surfaces = null } = {}) {
135
+ export function auditScreenChecklist(markdown) {
168
136
  const parsed = parseScreens(markdown);
169
137
  const problems = [];
170
138
  if (parsed.length === 0) {
@@ -185,18 +153,25 @@ export function auditScreenChecklist(markdown, { surfaces = null } = {}) {
185
153
  } else {
186
154
  if (missing.length) problems.push(`tela "${screen.name}": falta ${missing.map(labelOf).join(', ')}`);
187
155
  if (unanswered.length) problems.push(`tela "${screen.name}": sem resposta em ${unanswered.map(labelOf).join(', ')}`);
188
- const surface = screen.answers.get('superficie');
189
- if (surfaces?.length && surface !== undefined && !isPlaceholder(surface) && !isNotApplicable(surface)
190
- && !namesSurface(surface, surfaces)) {
191
- problems.push(`tela "${screen.name}": Superfície "${surface}" não é uma das superfícies da ui-designer (${surfaces.join(', ')})`);
192
- }
193
156
  }
194
- return { name: screen.name, hasChecklist: screen.hasChecklist, missing, unanswered, prototype: screen.prototype };
157
+ return { name: screen.name, hasChecklist: screen.hasChecklist, missing, unanswered, prototype: screen.prototype, states: statesOf(screen) };
195
158
  });
196
159
  return { legacy: false, screens, problems };
197
160
  }
198
161
 
199
- // Linha **Protótipo:** da tela + o HTML + os prints do laço de autocrítica ao lado dele.
162
+ /** The states the prototype must switch to, by the checklist items that apply to the screen. */
163
+ export const PROTOTYPE_STATES = { estados: ['vazio', 'carregando'], erro: ['erro'] };
164
+ export const STATE_PRINT_WIDTH = 390;
165
+
166
+ function statesOf(screen) {
167
+ return Object.entries(PROTOTYPE_STATES).flatMap(([id, states]) => {
168
+ const answer = screen.answers.get(id);
169
+ return answer && !isPlaceholder(answer) && !isNotApplicable(answer) ? states : [];
170
+ });
171
+ }
172
+
173
+ // Linha **Protótipo:** da tela + o HTML + os prints do laço de autocrítica ao lado dele; cada estado
174
+ // que o checklist diz que se aplica tem `data-estado` no HTML e o print de celular.
200
175
  export function auditPrototypes(screens, designDir) {
201
176
  const problems = [];
202
177
  for (const screen of screens) {
@@ -205,9 +180,19 @@ export function auditPrototypes(screens, designDir) {
205
180
  continue;
206
181
  }
207
182
  const base = screen.prototype.replace(/\.html$/i, '');
208
- const expected = [screen.prototype, ...PROTOTYPE_WIDTHS.map((width) => `${base}-${width}.png`)];
183
+ const states = screen.states ?? [];
184
+ const expected = [
185
+ screen.prototype,
186
+ ...PROTOTYPE_WIDTHS.map((width) => `${base}-${width}.png`),
187
+ ...states.map((state) => `${base}-${state}-${STATE_PRINT_WIDTH}.png`),
188
+ ];
209
189
  const missing = expected.filter((rel) => !existsSync(join(designDir, rel)));
210
190
  if (missing.length) problems.push(`tela "${screen.name}": falta ${missing.map((rel) => `1-design/${rel}`).join(', ')}`);
191
+ const html = existsSync(join(designDir, screen.prototype)) ? readFileSync(join(designDir, screen.prototype), 'utf8') : '';
192
+ const unswitchable = html ? states.filter((state) => !new RegExp(`data-estado\\s*=\\s*["']${state}["']`).test(html)) : [];
193
+ if (unswitchable.length) {
194
+ problems.push(`tela "${screen.name}": o protótipo não alterna para ${unswitchable.join(', ')} (falta \`data-estado="…"\` no seletor de estado)`);
195
+ }
211
196
  }
212
197
  return problems;
213
198
  }
@@ -216,7 +201,7 @@ export function auditPrototypes(screens, designDir) {
216
201
  export function collectScreenChecklist(cwd, featureName, gateName) {
217
202
  const mockups = readMockups(cwd, featureName);
218
203
  if (mockups === null) return { mockups: false, legacy: false, screens: [], problems: [] };
219
- const audit = auditScreenChecklist(mockups, { surfaces: knownSurfaces(cwd) });
204
+ const audit = auditScreenChecklist(mockups);
220
205
  const signal = { mockups: true, legacy: audit.legacy, screens: audit.screens.map((s) => s.name), problems: audit.problems };
221
206
  if (audit.legacy) signal.exempt = LEGACY_MOCKUP_EXEMPTION;
222
207
  if (gateName === 'uiux' && !audit.legacy) {
@@ -255,7 +240,7 @@ export function checklistCoverage(designMarkdown, reportSection) {
255
240
  for (const screen of parseScreens(designMarkdown)) {
256
241
  const done = covered.get(screenKey(screen.name)) ?? new Set();
257
242
  const owed = [...screen.answers]
258
- .filter(([id, answer]) => !NOT_VERIFIED_AT_REVIEW.has(id) && !isPlaceholder(answer) && !isNotApplicable(answer))
243
+ .filter(([, answer]) => !isPlaceholder(answer) && !isNotApplicable(answer))
259
244
  .map(([id]) => id)
260
245
  .filter((id) => !done.has(id));
261
246
  if (owed.length) gaps.push(`${screen.name}: ${owed.map(labelOf).join(', ')}`);
@@ -1,15 +1,14 @@
1
- import { readdirSync } from 'fs';
1
+ import { existsSync, readdirSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { ensureDotnetBuildIgnores } from '../../utils/file-copier.js';
4
4
  import { getProjectTags } from '../stack-filter.js';
5
5
  import { readMorphConfig } from '../tasks/test-runner.js';
6
- import { readPlanChecks } from '../plan/checks.js';
6
+ import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
7
7
  import { WORKTREE_DIR_NAMES } from '../worktree/dirs.js';
8
8
 
9
- export const DOTNET_IMPLEMENTERS = ['dotnet-senior', 'ef-modeler', 'maf-expert'];
10
-
11
9
  const SKIPPED_DIRS = new Set([...WORKTREE_DIR_NAMES, 'node_modules', 'bin', 'obj']);
12
10
  const DOTNET_PROJECT_FILE = /\.(?:csproj|slnx?)$/i;
11
+ const DOTNET_IN_PLAN = /\.(?:cs|csproj|slnx?)\b|\bdotnet\s+(?:test|build|new)\b/i;
13
12
  const MAX_DEPTH = 6;
14
13
 
15
14
  export function hasDotnetProjectFile(root, depth = 0) {
@@ -21,16 +20,16 @@ export function hasDotnetProjectFile(root, depth = 0) {
21
20
  && hasDotnetProjectFile(join(root, e.name), depth + 1));
22
21
  }
23
22
 
24
- function planHasDotnetSlice(tree, featureName) {
23
+ function planMentionsDotnet(tree, featureName) {
25
24
  if (!featureName) return false;
26
- const { checks } = readPlanChecks(tree, featureName);
27
- return (checks?.slices || []).some((slice) => DOTNET_IMPLEMENTERS.includes(String(slice.persona || '').replace(/^morph-/, '')));
25
+ const path = getAbsoluteOutputPath(tree, featureName, 'plan');
26
+ try { return existsSync(path) && DOTNET_IN_PLAN.test(readFileSync(path, 'utf8')); } catch { return false; }
28
27
  }
29
28
 
30
- // The plan's slices are checked because a greenfield tree has no stack or .csproj yet.
29
+ // The plan is read because a greenfield tree has no stack or .csproj yet.
31
30
  export function isDotnetProject(tree, { featureName, configRoot = tree } = {}) {
32
31
  if (getProjectTags(readMorphConfig(configRoot)).includes('dotnet')) return true;
33
- return planHasDotnetSlice(tree, featureName) || hasDotnetProjectFile(tree);
32
+ return planMentionsDotnet(tree, featureName) || hasDotnetProjectFile(tree);
34
33
  }
35
34
 
36
35
  /** Returns the `.gitignore` entries added; `[]` when not .NET or on any error. */
@@ -9,6 +9,14 @@ import { PACKAGE_LOCATIONS } from '../detectors/index.js';
9
9
  /** `none` yields an UNKNOWN count, never a fabricated zero. */
10
10
  export const TEST_RUNNERS = ['dotnet', 'vitest', 'jest', 'node-test', 'playwright', 'none', EVAL_RUNNER];
11
11
 
12
+ // A count is a baseline only where it stays still: dotnet/vitest/jest/playwright grow every
13
+ // slice, so their baseline only warned or blocked a deliberate deletion.
14
+ const BASELINE_RUNNERS = ['node-test', EVAL_RUNNER];
15
+
16
+ export function keepsBaseline(runner) {
17
+ return BASELINE_RUNNERS.includes(runner);
18
+ }
19
+
12
20
  export const TEST_NODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
13
21
 
14
22
  const FALLBACK_NODE_ID = 'default';
@@ -67,12 +75,13 @@ export function inferRunner(command) {
67
75
  }
68
76
 
69
77
  function makeNode(partial) {
78
+ const runner = partial.runner ?? inferRunner(partial.command);
70
79
  return {
71
80
  id: partial.id,
72
81
  command: partial.command ?? null,
73
82
  cwd: partial.cwd ?? '.',
74
- runner: partial.runner ?? inferRunner(partial.command),
75
- expectedCount: partial.expectedCount ?? null,
83
+ runner,
84
+ expectedCount: keepsBaseline(runner) ? partial.expectedCount ?? null : null,
76
85
  timeoutMs: partial.timeoutMs ?? null,
77
86
  build: partial.build ?? [],
78
87
  e2e: partial.e2e ?? false,
@@ -329,11 +338,11 @@ export function morphConfigPath(projectPath = '.') {
329
338
  * Refuses a non-integer count (null is UNKNOWN, never 0), an undeclared id, or a
330
339
  * declared node with no measurement. Every other config key survives.
331
340
  * @param {Array<{id: string, count: number|null}>} measurements
332
- * @returns {{ recorded: boolean, reason: string|null, updates: Array<{id: string, from: number|null, to: number}>, path: string }}
341
+ * @returns {{ recorded: boolean, reason: string|null, updates: Array<{id: string, from: number|null, to: number}>, removed: string[], path: string }}
333
342
  */
334
343
  export function recordTestBaselines(projectPath, measurements) {
335
344
  const path = morphConfigPath(projectPath);
336
- const refuse = (reason) => ({ recorded: false, reason, updates: [], path });
345
+ const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [], path });
337
346
 
338
347
  if (!existsSync(path)) return refuse('não existe .morph/config/config.json — não há onde gravar');
339
348
 
@@ -349,7 +358,10 @@ export function recordTestBaselines(projectPath, measurements) {
349
358
  return refuse('config.json não declara project.tests[] — não há entrada onde gravar a baseline');
350
359
  }
351
360
 
352
- const unreadable = measurements.filter((m) => !Number.isInteger(m.count)).map((m) => m.id);
361
+ const runnerOf = new Map(declared.filter(isPlainObject).map((e) => [e.id, e.runner ?? inferRunner(e.command)]));
362
+ const unreadable = measurements
363
+ .filter((m) => keepsBaseline(runnerOf.get(m.id)) && !Number.isInteger(m.count))
364
+ .map((m) => m.id);
353
365
  if (unreadable.length > 0) {
354
366
  return refuse(`contagem desconhecida em: ${unreadable.join(', ')} — desconhecido nunca vira 0`);
355
367
  }
@@ -370,8 +382,14 @@ export function recordTestBaselines(projectPath, measurements) {
370
382
  }
371
383
 
372
384
  const updates = [];
385
+ const removed = [];
373
386
  for (const entry of declared) {
374
387
  if (!entry || !byId.has(entry.id)) continue;
388
+ if (!keepsBaseline(runnerOf.get(entry.id))) {
389
+ if ('expectedCount' in entry) removed.push(entry.id);
390
+ delete entry.expectedCount;
391
+ continue;
392
+ }
375
393
  const to = byId.get(entry.id);
376
394
  updates.push({
377
395
  id: entry.id,
@@ -382,5 +400,5 @@ export function recordTestBaselines(projectPath, measurements) {
382
400
  }
383
401
 
384
402
  atomicWriteJson(path, config);
385
- return { recorded: true, reason: null, updates, path };
403
+ return { recorded: true, reason: null, updates, removed, path };
386
404
  }
@@ -8,7 +8,7 @@ import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
8
8
  import { CONFIG_FILE_NAME } from '../../core/state/state-manager.js';
9
9
 
10
10
  // Per chained project: a fixed cap timed a long `a && b` chain out for being long, not broken.
11
- export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 120_000;
11
+ export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 1_800_000;
12
12
  const TEST_OUTPUT_MAX_BUFFER = 256 * 1024 * 1024;
13
13
 
14
14
  /**
@@ -424,6 +424,45 @@ export function parseTestCount(output, runner) {
424
424
  return extractor(stripAnsi(output));
425
425
  }
426
426
 
427
+ const FAILURE_LINES_MAX = 10;
428
+ const TAP_NOT_OK = /^(\s*)not ok \d+ - (.+?)\s*$/;
429
+ const TAP_DIRECTIVE = /\s#\s*(?:TODO|SKIP)\b/i;
430
+ const VITEST_FAIL = /^\s*FAIL\s+(.+?)\s*$/;
431
+ const VITEST_CROSS = /^\s*[×✗]\s+(.+?)(?:\s+\d+(?:\.\d+)?\s*m?s)?\s*$/;
432
+ // The duration suffix is required: it separates a test line from the summary's `failed: 1` / `Failed!`.
433
+ const DOTNET_FAILED = /^\s*(?:failed|com falha)\s+(\S.*?)\s*(?:\[[^\]]*\]|\([^)]*\))\s*$/i;
434
+
435
+ function tapLeafFailures(lines) {
436
+ const seen = [];
437
+ for (const line of lines) {
438
+ const match = TAP_NOT_OK.exec(line);
439
+ if (!match || TAP_DIRECTIVE.test(match[2])) continue;
440
+ const depth = match[1].length;
441
+ // TAP prints a subtest's result before its parent's: deeper unclaimed entries above are its children.
442
+ const children = seen.filter((e) => !e.claimed && e.depth > depth);
443
+ for (const child of children) child.claimed = true;
444
+ seen.push({ depth, name: match[2], leaf: children.length === 0, claimed: false });
445
+ }
446
+ return seen.filter((e) => e.leaf).map((e) => e.name);
447
+ }
448
+
449
+ function captures(lines, pattern) {
450
+ return lines.map((line) => pattern.exec(line)?.[1]).filter(Boolean);
451
+ }
452
+
453
+ /** Failed test names anywhere in the output (TAP leaves, vitest, dotnet), deduplicated, at most 10; [] when no form matches. */
454
+ export function failureLines(output) {
455
+ if (typeof output !== 'string' || !output) return [];
456
+ const lines = stripAnsi(output).split(/\r?\n/);
457
+ const vitestFiles = captures(lines, VITEST_FAIL);
458
+ const names = [
459
+ ...tapLeafFailures(lines),
460
+ ...(vitestFiles.length > 0 ? vitestFiles : captures(lines, VITEST_CROSS)),
461
+ ...captures(lines, DOTNET_FAILED),
462
+ ];
463
+ return [...new Set(names)].slice(0, FAILURE_LINES_MAX);
464
+ }
465
+
427
466
  /**
428
467
  * Asymmetric on purpose: only a drop fails (non-null `reason`); a rise only warns,
429
468
  * or every new test would redden the gate and teach deleting tests.
@@ -95,36 +95,16 @@ function addRun(slot, durationMs) {
95
95
  const REVIEW_NAME = /^(?:ver|rev|sec|g3|gate3|eval|review|reviewer|security)\d*-/;
96
96
  const isReviewAgent = (type) => REVIEW_AGENT_TYPES.has(type) || type === 'morph-evaluator' || REVIEW_NAME.test(String(type ?? ''));
97
97
 
98
- // Same split as the test-budget hook: review first, then an unfiltered verify, then the round count.
99
98
  // A run refused by the full heavy queue ran no test.
100
99
  function testOutsideVerify(bashEvents) {
101
- const out = {
102
- total: newTimeSlot(),
103
- subagentVerify: newTimeSlot(),
104
- implementerRound3Plus: newTimeSlot(),
105
- reviewTests: { ...newTimeSlot(), byPersona: newTimeSlot() },
106
- };
107
- const byAgent = new Map();
100
+ const out = { total: newTimeSlot(), reviewTests: { ...newTimeSlot(), byPersona: newTimeSlot() } };
108
101
  for (const e of bashEvents) {
109
102
  if (e.event === 'PostToolUseFailure' && e.exitCode === QUEUE_FULL_EXIT) continue;
110
- const verify = morphVerifyCall(e.command);
111
- if (!verify && !testRunKind(e.command)) continue;
103
+ if (!morphVerifyCall(e.command) && !testRunKind(e.command)) continue;
112
104
  addRun(out.total, e.durationMs);
113
- if (!e.agentId) continue;
114
- if (isReviewAgent(e.agentType)) {
115
- addRun(out.reviewTests, e.durationMs);
116
- if (REVIEW_AGENT_TYPES.has(e.agentType)) addRun(out.reviewTests.byPersona, e.durationMs);
117
- continue;
118
- }
119
- if (!byAgent.has(e.agentId)) byAgent.set(e.agentId, []);
120
- byAgent.get(e.agentId).push({ e, unfilteredVerify: Boolean(verify && !verify.filtered) });
121
- }
122
- for (const runs of byAgent.values()) {
123
- runs.sort((a, b) => String(a.e.ts ?? '').localeCompare(String(b.e.ts ?? '')));
124
- runs.forEach(({ e, unfilteredVerify }, round) => {
125
- if (unfilteredVerify) addRun(out.subagentVerify, e.durationMs);
126
- else if (round >= 2) addRun(out.implementerRound3Plus, e.durationMs);
127
- });
105
+ if (!e.agentId || !isReviewAgent(e.agentType)) continue;
106
+ addRun(out.reviewTests, e.durationMs);
107
+ if (REVIEW_AGENT_TYPES.has(e.agentType)) addRun(out.reviewTests.byPersona, e.durationMs);
128
108
  }
129
109
  return out;
130
110
  }
@@ -164,7 +144,7 @@ function reuseStateOf(e) {
164
144
  return { state: 'absent', reason: null };
165
145
  }
166
146
 
167
- const newGateSlot = () => ({ decisions: 0, paused: 0, autoPassed: 0, approvedAfterPause: 0, reworkedAfterPause: 0, unapproved: 0, bySignal: {} });
147
+ const newGateSlot = () => ({ decisions: 0, paused: 0, pausedForHuman: 0, pausedUnattributed: 0, autoPassed: 0, approvedAfterPause: 0, reworkedAfterPause: 0, unapproved: 0, bySignal: {} });
168
148
 
169
149
  // A pause stays open until the next approve or a decision that no longer pauses on the same signal:
170
150
  // re-running gate-check over the same unchanged pause is not rework.
@@ -173,7 +153,7 @@ function trackGate(gates, open, e) {
173
153
  const slot = gates[e.gate] ||= newGateSlot();
174
154
  const key = `${e.feature || ''}\u0000${e.gate}`;
175
155
  const pending = open.get(key);
176
- const bySignal = (signal) => slot.bySignal[signal || 'desconhecido'] ||= { paused: 0, approvedAfterPause: 0, reworkedAfterPause: 0 };
156
+ const bySignal = (signal) => slot.bySignal[signal || 'desconhecido'] ||= { paused: 0, pausedForHuman: 0, pausedUnattributed: 0, approvedAfterPause: 0, reworkedAfterPause: 0 };
177
157
  const credit = (field) => {
178
158
  slot[field] += 1;
179
159
  bySignal(pending.signal)[field] += 1;
@@ -189,12 +169,30 @@ function trackGate(gates, open, e) {
189
169
  return;
190
170
  }
191
171
  slot.decisions += 1;
192
- if (pending && e.pause && e.signal === pending.signal) return;
172
+ const resolver = e.resolver ?? 'human';
173
+ if (pending && e.pause && e.signal === pending.signal) {
174
+ // Same signal, new owner: the session ran out of attempts and the pause escalated to the human.
175
+ if (pending.resolver === 'session' && resolver === 'human') {
176
+ slot.pausedForHuman += 1;
177
+ bySignal(pending.signal).pausedForHuman += 1;
178
+ }
179
+ pending.resolver = resolver;
180
+ return;
181
+ }
193
182
  if (pending) credit(e.source === 'manual' && !e.pause ? 'approvedAfterPause' : 'reworkedAfterPause');
194
183
  if (e.pause) {
195
184
  slot.paused += 1;
196
185
  bySignal(e.signal).paused += 1;
197
- open.set(key, { signal: e.signal });
186
+ if (resolver === 'human') {
187
+ slot.pausedForHuman += 1;
188
+ bySignal(e.signal).pausedForHuman += 1;
189
+ }
190
+ // Written before the resolver existed: counted as human above, but nobody recorded who cleared it.
191
+ if (e.resolver === undefined) {
192
+ slot.pausedUnattributed += 1;
193
+ bySignal(e.signal).pausedUnattributed += 1;
194
+ }
195
+ open.set(key, { signal: e.signal, resolver });
198
196
  } else {
199
197
  slot.autoPassed += 1;
200
198
  open.delete(key);
@@ -123,16 +123,8 @@ export function readReuseVerdicts(projectPath, featureName) {
123
123
  return verdicts;
124
124
  }
125
125
 
126
- // União de duas fontes cegas em momentos diferentes: `arquivos:` das fatias sobrevivem
127
- // ao commit; `git status` pega o que o plano não previu.
128
- export function collectChangedFiles(projectPath, featureName) {
126
+ export function collectChangedFiles(projectPath) {
129
127
  const files = new Set();
130
- for (const slice of readPlanChecks(projectPath, featureName).checks?.slices ?? []) {
131
- for (const file of slice.files ?? []) {
132
- if (typeof file === 'string') files.add(file.replace(/\\/g, '/'));
133
- }
134
- }
135
-
136
128
  try {
137
129
  const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], {
138
130
  cwd: projectPath,
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'child_process';
2
- import { existsSync, readFileSync, rmSync } from 'fs';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
 
5
5
  import { runBuildCheck, getBuildSteps, isDotnetStack } from '../../../framework/hooks/shared/build-check.js';
@@ -9,7 +9,7 @@ import { composeProjectName, runE2eCheck } from '../../../framework/hooks/shared
9
9
  import { buildComposeEnv } from '../e2e/compose-env.js';
10
10
  import { e2eApplicability } from '../e2e/applicability.js';
11
11
  import {
12
- runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution,
12
+ runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution, failureLines,
13
13
  } from '../tasks/test-runner.js';
14
14
  import {
15
15
  resolveTestNodes, parseTestFilter, nodeMatchesFilter, recordTestBaselines, morphConfigPath,
@@ -21,11 +21,9 @@ import {
21
21
  evalEmptyRunProblem, evalColdCacheWarning,
22
22
  } from '../tasks/eval-node.js';
23
23
  import { runValidation } from './validation-runner.js';
24
- import { checkOutputsTraceability } from './traceability-check.js';
25
24
  import { createHeavyGate } from '../heavy/gate.js';
26
25
 
27
- // A cold dotnet build takes minutes, not seconds.
28
- const DEFAULT_BUILD_TIMEOUT = 120_000;
26
+ const DEFAULT_BUILD_TIMEOUT = 1_800_000;
29
27
 
30
28
  const VALIDATOR_FEEDBACK_LIMIT = 10;
31
29
 
@@ -71,7 +69,6 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
71
69
  _runValidation = runValidation,
72
70
  _runE2eCheck = runE2eCheck,
73
71
  _e2eApplicability = e2eApplicability,
74
- _checkOutputsTraceability = checkOutputsTraceability,
75
72
  } = options;
76
73
 
77
74
  const stack = readStack(projectPath);
@@ -171,10 +168,6 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
171
168
  appendTestsFeedback(feedback, nodes.tests);
172
169
  }
173
170
 
174
- // Outside `nodes` on purpose: status comes only from the nodes, so a traceability
175
- // warning never turns red.
176
- const traceability = _checkOutputsTraceability(projectPath, featureName);
177
-
178
171
  const baseline = recordBaseline ? recordBaselineFrom(projectPath, nodes.tests) : null;
179
172
  if (baseline && !baseline.recorded) {
180
173
  feedback.push(`[baseline] não gravada: ${baseline.reason}`);
@@ -194,7 +187,6 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
194
187
  status,
195
188
  nodes,
196
189
  feedback,
197
- traceability,
198
190
  ...(baseline ? { baseline } : {}),
199
191
  };
200
192
  }
@@ -364,7 +356,8 @@ async function runNodesInLinux({
364
356
  // No exit code is NOT a pass: the container died before this node reported.
365
357
  ? { passed: false, output: raw.output || '', reason: raw.reason, exitCode: null }
366
358
  : { passed: raw.exitCode === 0, output: raw.output || '', exitCode: raw.exitCode };
367
- return judgeRun({ node, result, durationMs: raw.durationMs ?? 0 });
359
+ const logPath = writeFailedRunLog(projectPath, node, result);
360
+ return judgeRun({ node, result, durationMs: raw.durationMs ?? 0, logPath });
368
361
  });
369
362
 
370
363
  return { runs, environment: suite.environment };
@@ -389,7 +382,9 @@ async function runOneTestNode({
389
382
  const result = await _runTestSuite(projectPath, {
390
383
  timeout, node, ...(childEnv ? { env: childEnv } : {}),
391
384
  });
392
- const entry = judgeRun({ node, result, durationMs: Date.now() - start });
385
+ const durationMs = Date.now() - start;
386
+ const logPath = writeFailedRunLog(projectPath, node, result);
387
+ const entry = judgeRun({ node, result, durationMs, logPath });
393
388
  if (!evalChannel) return entry;
394
389
 
395
390
  const judged = judgeEvalRun(entry, node, evalChannel.summaryPath);
@@ -397,6 +392,19 @@ async function runOneTestNode({
397
392
  return judged;
398
393
  }
399
394
 
395
+ // Fail-open: a log that cannot be written must not change the verdict.
396
+ function writeFailedRunLog(projectPath, node, result) {
397
+ if (result.passed || result.skipped || !result.output) return null;
398
+ const relPath = `.morph/logs/verify-tests-${String(node.id).replace(/[^\w.-]/g, '_')}.log`;
399
+ try {
400
+ mkdirSync(join(projectPath, '.morph', 'logs'), { recursive: true });
401
+ writeFileSync(join(projectPath, relPath), result.output);
402
+ return relPath;
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
407
+
400
408
  function discardFile(path) {
401
409
  try { rmSync(path, { force: true }); } catch { /* the next run removes it first */ }
402
410
  }
@@ -476,13 +484,15 @@ function preDecideRun({ node, filter, projectPath = '.', refreshCache = false })
476
484
  }
477
485
 
478
486
  // The single count rule for host and container runs; a second copy makes them disagree.
479
- function judgeRun({ node, result, durationMs }) {
487
+ function judgeRun({ node, result, durationMs, logPath = null }) {
480
488
  const entry = emptyRunEntry(node);
481
489
 
482
490
  if (result.skipped) {
483
491
  return { ...entry, durationMs, reason: result.reason || 'no test command detected' };
484
492
  }
485
493
 
494
+ const logLine = logPath ? [`log completo: ${logPath}`] : [];
495
+
486
496
  // A truncated log tail reads like a passing run cut mid-sentence: lead with the timeout.
487
497
  if (result.timedOut) {
488
498
  const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
@@ -492,7 +502,7 @@ function judgeRun({ node, result, durationMs }) {
492
502
  durationMs,
493
503
  timedOut: true,
494
504
  reason: result.message,
495
- output: tail ? `${result.message}\n${tail}` : result.message,
505
+ output: [result.message, ...logLine, tail].filter(Boolean).join('\n'),
496
506
  };
497
507
  }
498
508
 
@@ -511,18 +521,39 @@ function judgeRun({ node, result, durationMs }) {
511
521
  }
512
522
  if (verdict.verdict === 'drop') warnings.push(verdict.reason);
513
523
 
524
+ // 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)) {
526
+ return { ...entry, status: 'fail', durationMs, count, warnings, reason: 'suíte verde sem nenhum teste executado', output: '' };
527
+ }
528
+
529
+ if (result.passed) {
530
+ return { ...entry, status: 'pass', durationMs, count, warnings, reason: result.reason ?? null, output: '' };
531
+ }
532
+
533
+ // The failing test sits thousands of lines above a TAP summary, never in the tail.
534
+ const failed = failureLines(result.output);
514
535
  return {
515
536
  ...entry,
516
- status: result.passed ? 'pass' : 'fail',
537
+ status: 'fail',
517
538
  durationMs,
518
539
  count,
519
540
  warnings,
520
541
  // `test-build-failed` / `stale-binary`: refused before any test ran.
521
- reason: result.reason ?? null,
522
- output: result.passed ? '' : tailLines(result.output, OUTPUT_TAIL_LINES),
542
+ reason: result.reason ?? failureSummary(failed),
543
+ output: [
544
+ ...failed.map((name) => `falhou: ${name}`),
545
+ ...logLine,
546
+ tailLines(result.output, OUTPUT_TAIL_LINES),
547
+ ].filter(Boolean).join('\n'),
523
548
  };
524
549
  }
525
550
 
551
+ function failureSummary(failed) {
552
+ if (failed.length === 0) return null;
553
+ const more = failed.length > 3 ? ` (+${failed.length - 3})` : '';
554
+ return `falhou: ${failed.slice(0, 3).join('; ')}${more}`;
555
+ }
556
+
526
557
  function acceptRecordedDrops(testsNode) {
527
558
  const runs = testsNode.runs.map(({ countDrop, ...run }) => (countDrop ? { ...run, status: 'pass', reason: null } : run));
528
559
  return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
@@ -615,7 +646,7 @@ function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
615
646
  // exited 0 is the one red it accepts: lowering the number is how deleting tests passes.
616
647
  function recordBaselineFrom(projectPath, testsNode) {
617
648
  const runs = testsNode.runs || [];
618
- const refuse = (reason) => ({ recorded: false, reason, updates: [] });
649
+ const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [] });
619
650
 
620
651
  if (runs.length === 0) return refuse('nenhum nó de teste executou');
621
652