@polymorphism-tech/morph-spec 8.47.0 → 9.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 (222) hide show
  1. package/CLAUDE.md +48 -39
  2. package/README.md +3 -4
  3. package/bin/morph-spec.js +37 -51
  4. package/bin/validate.js +3 -111
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +75 -18
  11. package/framework/commands/morph-apply.md +5 -28
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +8 -8
  14. package/framework/commands/morph-infra.md +1 -1
  15. package/framework/commands/morph-preflight.md +2 -3
  16. package/framework/commands/morph-proposal.md +2 -50
  17. package/framework/commands/morph-status.md +3 -3
  18. package/framework/docs/CLI.md +585 -286
  19. package/framework/evals/review-checklist.md +82 -41
  20. package/framework/hooks/README.md +10 -11
  21. package/framework/hooks/claude-code/core/comment-guard.js +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +33 -3
  23. package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
  24. package/framework/hooks/claude-code/core/gate-guard.js +2 -11
  25. package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
  26. package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
  27. package/framework/hooks/claude-code/core/state-sync.js +7 -21
  28. package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
  29. package/framework/hooks/claude-code/core/validate-completion.js +33 -97
  30. package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
  31. package/framework/hooks/claude-code/core/worktree-context.js +5 -4
  32. package/framework/hooks/claude-code/statusline.py +55 -41
  33. package/framework/hooks/package.json +3 -0
  34. package/framework/hooks/shared/activity-logger.js +1 -1
  35. package/framework/hooks/shared/build-check.js +10 -6
  36. package/framework/hooks/shared/command-tokens.js +169 -0
  37. package/framework/hooks/shared/comment-rules.js +1 -1
  38. package/framework/hooks/shared/comment-scanner.js +10 -23
  39. package/framework/hooks/shared/comment-stripper.js +1 -1
  40. package/framework/hooks/shared/e2e-check.js +64 -27
  41. package/framework/hooks/shared/errata.js +72 -0
  42. package/framework/hooks/shared/feature-locator.js +1 -1
  43. package/framework/hooks/shared/frontend-locations.js +6 -0
  44. package/framework/hooks/shared/gate-question.js +18 -0
  45. package/framework/hooks/shared/phase-utils.js +15 -32
  46. package/framework/hooks/shared/slice-progress.js +118 -0
  47. package/framework/hooks/shared/spawn-command.js +23 -0
  48. package/framework/hooks/shared/state-reader.js +61 -87
  49. package/framework/hooks/shared/state-writer.js +2 -3
  50. package/framework/mcp/registry.json +3 -4
  51. package/framework/phases.json +23 -39
  52. package/framework/schemas/config.schema.json +14 -9
  53. package/framework/schemas/feature.schema.json +15 -50
  54. package/framework/skills/README.md +3 -4
  55. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  56. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  57. package/framework/skills/core/morph-apply/SKILL.md +149 -222
  58. package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
  59. package/framework/skills/core/morph-machine/SKILL.md +2 -2
  60. package/framework/skills/core/morph-media/SKILL.md +1 -1
  61. package/framework/skills/core/morph-plan/SKILL.md +137 -299
  62. package/framework/skills/core/morph-review/SKILL.md +111 -136
  63. package/framework/skills/core/morph-standards/SKILL.md +27 -39
  64. package/framework/skills/core/morph-uiux/SKILL.md +77 -152
  65. package/framework/standards/STANDARDS.json +86 -1218
  66. package/framework/standards/STANDARDS.meta.json +1305 -0
  67. package/framework/standards/ai-agents/evals-with-cache.md +1 -2
  68. package/framework/standards/ai-agents/setup.md +1 -1
  69. package/framework/standards/architecture/code-comments.md +6 -6
  70. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  71. package/framework/templates/README.md +2 -3
  72. package/framework/templates/REGISTRY.json +6 -170
  73. package/framework/templates/docs/plan.md +72 -0
  74. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  75. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  76. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  77. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  78. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  79. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  80. package/package.json +1 -1
  81. package/src/commands/e2e/e2e.js +2 -2
  82. package/src/commands/observability/cost.js +18 -60
  83. package/src/commands/project/doctor-procs.js +1 -1
  84. package/src/commands/project/doctor.js +9 -9
  85. package/src/commands/project/fleet.js +4 -4
  86. package/src/commands/project/heavy.js +3 -3
  87. package/src/commands/project/init.js +4 -0
  88. package/src/commands/project/setup-infra-cmd.js +2 -1
  89. package/src/commands/project/stack-scan.js +58 -0
  90. package/src/commands/project/standards.js +79 -25
  91. package/src/commands/project/status.js +29 -69
  92. package/src/commands/project/update.js +17 -1
  93. package/src/commands/project/worktree.js +1 -1
  94. package/src/commands/state/advance.js +62 -46
  95. package/src/commands/state/approve.js +81 -141
  96. package/src/commands/state/archive.js +20 -84
  97. package/src/commands/state/auto-approval.js +68 -0
  98. package/src/commands/state/create.js +28 -1
  99. package/src/commands/state/delete.js +1 -1
  100. package/src/commands/state/finish.js +11 -23
  101. package/src/commands/state/gate-check.js +85 -84
  102. package/src/commands/state/gate-decision.js +6 -4
  103. package/src/commands/state/handoff.js +1 -1
  104. package/src/commands/state/list.js +9 -9
  105. package/src/commands/state/report-check.js +157 -311
  106. package/src/commands/state/retype.js +9 -3
  107. package/src/commands/validation/mutate.js +77 -14
  108. package/src/commands/validation/plan-check.js +81 -0
  109. package/src/commands/validation/validate-feature.js +60 -135
  110. package/src/commands/validation/verify.js +40 -72
  111. package/src/core/paths/output-schema.js +19 -66
  112. package/src/core/state/gate-order.js +5 -6
  113. package/src/core/state/lifecycle-audit.js +2 -2
  114. package/src/core/state/state-manager.js +17 -98
  115. package/src/core/templates/template-registry.js +2 -2
  116. package/src/core/templates/template-seed.js +22 -0
  117. package/src/core/work-types.js +10 -10
  118. package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
  119. package/src/dashboard/app/components/CommandPalette.jsx +5 -5
  120. package/src/dashboard/app/components/EntityLink.jsx +1 -1
  121. package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
  122. package/src/dashboard/app/lib/phases.js +2 -4
  123. package/src/dashboard/app/pages/Activity.jsx +1 -1
  124. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
  125. package/src/dashboard/app/pages/Features.jsx +9 -9
  126. package/src/dashboard/app/pages/Guide.jsx +2 -2
  127. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
  128. package/src/dashboard/app/pages/Standards.jsx +1 -1
  129. package/src/dashboard/scanner/activity-scanner.js +5 -15
  130. package/src/dashboard/scanner/agents-scanner.js +1 -1
  131. package/src/dashboard/scanner/docs-scanner.js +2 -2
  132. package/src/dashboard/scanner/features-scanner.js +95 -60
  133. package/src/dashboard/scanner/project-api.js +1 -1
  134. package/src/dashboard/scanner/standards-scanner.js +2 -1
  135. package/src/lib/ci/ci-verify.js +17 -1
  136. package/src/lib/ci/github-ci.js +1 -1
  137. package/src/lib/detectors/index.js +3 -8
  138. package/src/lib/generators/handoff-generator.js +34 -65
  139. package/src/lib/generators/settings-generator.js +11 -89
  140. package/src/lib/git/evidence-staleness.js +1 -1
  141. package/src/lib/git/tree-fingerprint.js +19 -7
  142. package/src/lib/git/tree-state.js +2 -2
  143. package/src/lib/heavy/cleanup.js +1 -5
  144. package/src/lib/installers/memory-installer.js +6 -8
  145. package/src/lib/mutation/commands.js +3 -2
  146. package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
  147. package/src/lib/mutation/plan.js +14 -6
  148. package/src/lib/mutation/runner.js +1 -1
  149. package/src/lib/mutation/subset.js +46 -9
  150. package/src/lib/plan/artifact-vocabulary.js +60 -0
  151. package/src/lib/plan/checks.js +234 -0
  152. package/src/lib/plan/plan-parser.js +209 -0
  153. package/src/lib/plan/plan-template.js +18 -0
  154. package/src/lib/review-reports.js +222 -0
  155. package/src/lib/risk-detector.js +131 -140
  156. package/src/lib/specs/adr-index.js +50 -69
  157. package/src/lib/specs/design-seed.js +18 -0
  158. package/src/lib/specs/frontmatter.js +44 -0
  159. package/src/lib/specs/gfm-table.js +17 -0
  160. package/src/lib/specs/old-format.js +68 -0
  161. package/src/lib/specs/revocations.js +37 -95
  162. package/src/lib/specs/screen-checklist.js +18 -17
  163. package/src/lib/stack/dotnet-build-ignores.js +47 -0
  164. package/src/lib/stack/stack-scan.js +267 -0
  165. package/src/lib/standards/registry.js +43 -0
  166. package/src/lib/standards/sections.js +58 -0
  167. package/src/lib/tasks/eval-node.js +4 -12
  168. package/src/lib/tasks/test-nodes.js +1 -74
  169. package/src/lib/tasks/test-runner.js +75 -117
  170. package/src/lib/telemetry/metrics.js +8 -69
  171. package/src/lib/telemetry/telemetry-reader.js +5 -118
  172. package/src/lib/telemetry/verify-event.js +5 -7
  173. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  174. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
  175. package/src/lib/validators/reuse/reuse-validator.js +22 -45
  176. package/src/lib/validators/traceability-check.js +85 -129
  177. package/src/lib/validators/verify-runner.js +118 -235
  178. package/src/lib/worktree/docker-audit.js +1 -2
  179. package/src/lib/worktree/fleet-scanner.js +25 -38
  180. package/src/scripts/setup-infra.js +9 -0
  181. package/src/utils/agents-installer.js +76 -9
  182. package/src/utils/claude-settings-manager.js +0 -35
  183. package/src/utils/file-copier.js +27 -1
  184. package/src/utils/hooks-installer.js +16 -9
  185. package/src/utils/skills-installer.js +1 -2
  186. package/framework/evals/trace-schema.md +0 -67
  187. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  188. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  189. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  190. package/framework/hooks/shared/tasks-validator.js +0 -111
  191. package/framework/rules/morph-task-tracking.md +0 -54
  192. package/framework/rules/morph-workflow.md +0 -244
  193. package/framework/schemas/tasks.schema.json +0 -79
  194. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  195. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  196. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  197. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  198. package/framework/templates/docs/proposal.md +0 -192
  199. package/framework/templates/docs/spec.md +0 -57
  200. package/framework/templates/docs/ui-components.md +0 -52
  201. package/framework/templates/docs/ui-design-system.md +0 -76
  202. package/framework/templates/docs/ui-flows.md +0 -167
  203. package/framework/templates/examples/spec-examples.md +0 -91
  204. package/framework/templates/feature/decisions.md +0 -124
  205. package/framework/templates/feature/recap.md +0 -145
  206. package/framework/templates/tasks.template.json +0 -17
  207. package/src/commands/state/usecase.js +0 -143
  208. package/src/commands/tasks/dag.js +0 -181
  209. package/src/lib/agents/output-collector.js +0 -76
  210. package/src/lib/evaluator-report.js +0 -104
  211. package/src/lib/specs/acervo-format.js +0 -70
  212. package/src/lib/specs/acervo-lifecycle.js +0 -264
  213. package/src/lib/specs/done-criteria-check.js +0 -200
  214. package/src/lib/specs/usecase-check.js +0 -301
  215. package/src/lib/specs/usecase-promoter.js +0 -301
  216. package/src/lib/specs/usecase-registry.js +0 -237
  217. package/src/lib/tasks/dispatch-planner.js +0 -319
  218. package/src/lib/tasks/mermaid-renderer.js +0 -132
  219. package/src/lib/tasks/task-classifier.js +0 -30
  220. package/src/lib/tasks/task-parser.js +0 -209
  221. package/src/lib/tasks/tasks-json-parser.js +0 -296
  222. package/src/lib/tasks/test-scope.js +0 -597
@@ -2,7 +2,7 @@
2
2
  * Verify Runner — deterministic build+tests+validators+e2e cadence.
3
3
  *
4
4
  * v8 adds a DETERMINISTIC verification node that runs BEFORE the LLM judge
5
- * in the per-task loop: build + tests + validators (code, free) first; the
5
+ * in the implement loop: build + tests + validators (code, free) first; the
6
6
  * LLM judge is only invoked once verify passes. This is a BINARY gate
7
7
  * (pass/fail per node).
8
8
  *
@@ -15,7 +15,6 @@
15
15
  * - runBuildCheck() from framework/hooks/shared/build-check.js
16
16
  * - runTestSuite() from src/lib/tasks/test-runner.js (one node)
17
17
  * - resolveTestNodes() from src/lib/tasks/test-nodes.js (the LIST of suites)
18
- * - applyTaskScope() from src/lib/tasks/test-scope.js (the task-scoped cut, #87)
19
18
  * - runValidation() from ./validation-runner.js
20
19
  * - runE2eCheck() from framework/hooks/shared/e2e-check.js
21
20
  *
@@ -26,16 +25,15 @@ import { execFileSync } from 'child_process';
26
25
  import { existsSync, readFileSync, rmSync } from 'fs';
27
26
  import { join, resolve } from 'path';
28
27
 
29
- import { runBuildCheck, getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
30
- import { runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
28
+ import { runBuildCheck, getBuildSteps, isDotnetStack } from '../../../framework/hooks/shared/build-check.js';
29
+ import { composeProjectName, runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
31
30
  import { buildComposeEnv } from '../e2e/compose-env.js';
32
31
  import {
33
- runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, isZeroTestRun, mtpSummaryTotals,
32
+ runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution,
34
33
  } from '../tasks/test-runner.js';
35
34
  import {
36
35
  resolveTestNodes, parseTestFilter, nodeMatchesFilter, recordTestBaselines, morphConfigPath,
37
36
  } from '../tasks/test-nodes.js';
38
- import { applyTaskScope } from '../tasks/test-scope.js';
39
37
  import { runLinuxSuite, checkLinuxPreconditions } from '../tasks/linux-runner.js';
40
38
  import {
41
39
  EVAL_RUNNER, evalPreflight, evalNodeEnv, readEvalSummary, evalScenarioCount,
@@ -44,7 +42,6 @@ import {
44
42
  } from '../tasks/eval-node.js';
45
43
  import { runValidation } from './validation-runner.js';
46
44
  import { checkOutputsTraceability } from './traceability-check.js';
47
- import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
48
45
  import { createHeavyGate } from '../heavy/gate.js';
49
46
 
50
47
  /** Default build timeout — dotnet cold build is slow; 30s (the Stop-hook default) is too short. */
@@ -56,14 +53,6 @@ const VALIDATOR_FEEDBACK_LIMIT = 10;
56
53
  /** Number of trailing output lines kept for feedback/reporting. */
57
54
  const OUTPUT_TAIL_LINES = 20;
58
55
 
59
- /** A narrowed run that proved ZERO tests ran (#87) — wording pinned by the plan. */
60
- const NARROWED_ZERO_TESTS = 'narrowed-zero-tests: o recorte executou 0 testes';
61
- const NARROWED_ZERO_TESTS_LINK = 'narrowed-zero-tests: um link do recorte executou 0 testes (bloco MTP com total: 0)';
62
-
63
- /** A narrowed run that exited green with no count anyone could read (#87): null is UNKNOWN, and unknown is never green. */
64
- const NARROWED_COUNT_UNKNOWN = 'narrowed-count-unknown: o recorte saiu verde sem contagem legível — '
65
- + 'nada prova que algum teste rodou (desconhecido nunca vira verde)';
66
-
67
56
  /**
68
57
  * The caller asked for something impossible — not a red verification, a wrong
69
58
  * invocation. The distinction is the exit code: `verify` answers 1 for "the
@@ -82,11 +71,10 @@ export class VerifyUsageError extends Error {
82
71
  }
83
72
 
84
73
  /**
85
- * Run the deterministic verify cadence for a feature (or a single task within it).
74
+ * Run the deterministic verify cadence for a feature.
86
75
  *
87
76
  * @param {string} projectPath - Project root directory
88
77
  * @param {string} featureName - Feature name
89
- * @param {string|null} [taskId] - Task ID for task-scoped verify, or null/undefined for feature scope
90
78
  * @param {Object} [options]
91
79
  * @param {number} [options.timeout] - Timeout in ms for the build AND tests nodes
92
80
  * (default: 120000 for build; for tests, see resolveTestTimeout())
@@ -99,8 +87,6 @@ export class VerifyUsageError extends Error {
99
87
  * @param {boolean} [options.linux] - Run the test nodes inside a Linux container, on a COPY
100
88
  * of the tree (see linux-runner.js for why a bind mount would prove nothing)
101
89
  * @param {string} [options.linuxImage] - Image override for --linux
102
- * @param {boolean} [options.full] - Task scope only: switch the recorte off and run every
103
- * node whole (#87). A documented no-op at feature scope, which never narrows anything.
104
90
  * @param {Function} [options._runBuildCheck] - Injectable override for runBuildCheck (testing)
105
91
  * @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
106
92
  * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
@@ -110,16 +96,16 @@ export class VerifyUsageError extends Error {
110
96
  * @returns {Promise<Object>} Aggregated verify result — see module docs for shape
111
97
  * @throws {HeavyBusyError} when no heavy slot (or RAM) freed within `heavy.waitTimeoutSec`
112
98
  */
113
- export async function runVerify(projectPath, featureName, taskId, options = {}) {
99
+ export async function runVerify(projectPath, featureName, options = {}) {
114
100
  const gate = options._heavyGate ?? createHeavyGate({ projectPath, feature: featureName });
115
101
  try {
116
- return await runVerifyGated(projectPath, featureName, taskId, options, gate);
102
+ return await runVerifyGated(projectPath, featureName, options, gate);
117
103
  } finally {
118
104
  gate.release();
119
105
  }
120
106
  }
121
107
 
122
- async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
108
+ async function runVerifyGated(projectPath, featureName, options, gate) {
123
109
  const {
124
110
  timeout = DEFAULT_BUILD_TIMEOUT,
125
111
  skipTests = false,
@@ -130,7 +116,6 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
130
116
  refreshCache = false,
131
117
  linux = false,
132
118
  linuxImage = null,
133
- full = false,
134
119
  _runLinuxSuite = runLinuxSuite,
135
120
  _exec = undefined,
136
121
  _runBuildCheck = runBuildCheck,
@@ -156,18 +141,13 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
156
141
  );
157
142
  }
158
143
 
159
- // The task is read ONCE: its group picks the e2e applicability and, with its
160
- // outputs, the test scope. One reader of tasks.json for verify, so the two
161
- // can never disagree about the task.
162
- const task = taskId ? readTask(projectPath, featureName, taskId) : null;
163
-
164
144
  // `--record-baseline` is judged in the same breath, and for the same reason:
165
145
  // discovering that there is nowhere to write AFTER running the whole fleet
166
146
  // would charge the user a full suite for a usage error. `config.json` is
167
147
  // never created here — this repository deliberately has none, and inventing
168
148
  // one would break `doctor --full`.
169
149
  if (recordBaseline) {
170
- const problem = baselineTargetProblem(projectPath, testPlan, { skipTests, taskId });
150
+ const problem = baselineTargetProblem(projectPath, testPlan, { skipTests });
171
151
  if (problem) throw new VerifyUsageError(problem);
172
152
  }
173
153
 
@@ -206,16 +186,6 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
206
186
  }
207
187
  }
208
188
 
209
- // #87: at task scope the task's group picks the nodes and its outputs narrow
210
- // the command inside each; test-scope.js DECIDES, this runner executes. The
211
- // scoping happens ONCE, here, before runTestsNode, so the host loop and the
212
- // `--linux` container receive the SAME scoped nodes (the container script is
213
- // written from `node.command`) and preDecideRun/judgeRun stay the single copy
214
- // of each rule for both. `--filter` was judged above on the ORIGINAL ids: a
215
- // typo is a typo whatever the task. Feature scope gets the nodes back
216
- // unchanged, because Gate 3 always runs every suite, whole.
217
- const scoped = applyTaskScope({ projectPath, nodes: testPlan.nodes, task, taskId, filter, full });
218
-
219
189
  const nodes = {};
220
190
  const feedback = [];
221
191
 
@@ -229,27 +199,24 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
229
199
  // default down would silently outrank config.project.testTimeoutMs and the
230
200
  // per-project scaling (see resolveTestTimeout()).
231
201
  const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
202
+ const stackNodes = [];
232
203
  nodes.tests = await runTestsNode({
233
204
  projectPath,
234
- testPlan: { ...testPlan, nodes: scoped.nodes },
205
+ testPlan,
235
206
  filter,
236
207
  skipTests,
237
208
  buildNode: nodes.build,
238
209
  timeout: explicitTimeout,
239
- taskId,
240
210
  refreshCache,
241
211
  linux,
242
212
  linuxImage,
243
213
  gate,
214
+ stackNodes,
244
215
  _runTestSuite,
245
216
  _runLinuxSuite,
246
217
  _exec,
247
218
  });
248
- // How the nodes were chosen, for whoever reads --json. It lives on the rollup
249
- // and nowhere else: nodeStatuses() (verify.js) persists status strings only,
250
- // so no part of the selection ever reaches feature.json.
251
- nodes.tests.scope = scoped.summary;
252
- appendTestsFeedback(feedback, nodes.tests);
219
+ if (stackNodes.length === 0) appendTestsFeedback(feedback, nodes.tests);
253
220
 
254
221
  // 3. validators — ALWAYS runs, even on a red build (free static feedback).
255
222
  nodes.validators = await runValidatorsNode({ projectPath, featureName, _runValidation });
@@ -258,28 +225,30 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
258
225
  // 4. e2e — the only node that runs the application. Like tests, it is not
259
226
  // invoked at all on a red build: a stack that cannot compile cannot come up,
260
227
  // and the docker output would bury the actual compile error.
261
- nodes.e2e = await runE2eNode({
228
+ const e2e = await runE2eNode({
262
229
  projectPath,
263
230
  featureName,
264
- taskId,
265
- task,
266
231
  skipE2e,
267
232
  freshE2e,
268
233
  buildNode: nodes.build,
269
234
  gate,
270
235
  _runE2eCheck,
271
236
  });
237
+ nodes.e2e = e2e.node;
272
238
  appendE2eFeedback(feedback, nodes.e2e);
273
239
 
274
- // 5. traceability — advisory-only, feature-scope only (see traceability-check.js
275
- // for the recap-vs-outputs disagreement it flags, and what it deliberately
276
- // cannot catch). Lives OUTSIDE `nodes` on purpose: `status` below is computed
277
- // from `nodes` alone, so this can never flip build/tests/validators/e2e green
278
- // to red — recap/outputs disagreement is a rastreabilidade signal, not a
279
- // correctness gate.
280
- const traceability = taskId
281
- ? { status: 'skip', warnings: [], reason: 'task-scoped verify does not run traceability', citedCount: 0 }
282
- : _checkOutputsTraceability(projectPath, featureName);
240
+ // `e2e: true` suites need the stack the e2e node just brought up (and left up).
241
+ if (stackNodes.length > 0) {
242
+ nodes.tests = await runStackTestNodes({
243
+ projectPath, testsNode: nodes.tests, stackNodes, e2eNode: nodes.e2e, stackEnv: e2e.stackEnv,
244
+ timeout: explicitTimeout, gate, _runTestSuite,
245
+ });
246
+ appendTestsFeedback(feedback, nodes.tests);
247
+ }
248
+
249
+ // Fora de `nodes` de propósito: o status sai só dos nós, então a divergência
250
+ // relatório × fatias (aviso de rastreabilidade) nunca vira vermelho de correção.
251
+ const traceability = _checkOutputsTraceability(projectPath, featureName);
283
252
 
284
253
  const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
285
254
 
@@ -293,8 +262,6 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
293
262
  return {
294
263
  version: 1,
295
264
  feature: featureName,
296
- task: taskId || null,
297
- scope: taskId ? 'task' : 'feature',
298
265
  stack,
299
266
  status,
300
267
  nodes,
@@ -308,12 +275,29 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
308
275
  // Build node
309
276
  // ============================================================================
310
277
 
311
- async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild, gate, _runBuildCheck }) {
278
+ /**
279
+ * A root `dotnet build` with no solution at the root fails MSB1003: build the
280
+ * repository's single solution instead, or say which candidates and which key decide.
281
+ */
282
+ export function resolveDotnetBuild(projectPath, stack, paths = {}) {
283
+ if (!stack || !isDotnetStack(stack.toLowerCase()) || paths.backendPath) return { paths, hint: null };
284
+ const { target, candidates } = discoverDotnetSolution(projectPath);
285
+ if (target === '.') return { paths, hint: null };
286
+ if (target) return { paths: { ...paths, backendTarget: target }, hint: null };
287
+ const found = candidates.length ? `candidatos: ${candidates.join(', ')}` : 'nenhum encontrado abaixo dela';
288
+ return {
289
+ paths,
290
+ hint: `nenhum .sln/.slnx/.csproj na raiz (${found}): declare project.backendPath em .morph/config/config.json com a pasta da solução, relativa à raiz`,
291
+ };
292
+ }
293
+
294
+ async function runBuildNode({ projectPath, stack, paths: declaredPaths = {}, timeout, skipBuild, gate, _runBuildCheck }) {
312
295
  if (skipBuild) {
313
296
  return { status: 'skip', command: null, durationMs: 0, reason: 'requested', output: '' };
314
297
  }
315
298
 
316
- const steps = getBuildSteps(stack, { cwd: projectPath, frontendPath: paths.frontendPath, backendPath: paths.backendPath });
299
+ const { paths, hint } = resolveDotnetBuild(projectPath, stack, declaredPaths);
300
+ const steps = getBuildSteps(stack, { cwd: projectPath, frontendPath: paths.frontendPath, backendPath: paths.backendPath, backendTarget: paths.backendTarget });
317
301
  if (steps.length === 0) {
318
302
  const reason = stack ? `no build command for stack "${stack}"` : 'no stack configured';
319
303
  return { status: 'skip', command: null, durationMs: 0, reason, output: '' };
@@ -330,7 +314,7 @@ async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild
330
314
  command,
331
315
  durationMs,
332
316
  reason: result.status === 'skip' ? 'no build command for stack' : null,
333
- output: result.output || '',
317
+ output: [result.output, result.status === 'fail' ? hint : null].filter(Boolean).join('\n'),
334
318
  };
335
319
  }
336
320
 
@@ -363,8 +347,8 @@ function appendBuildFeedback(feedback, buildNode) {
363
347
  * `source`, `environment` (null outside a container) and `runs[]`.
364
348
  */
365
349
  async function runTestsNode({
366
- projectPath, testPlan, filter, skipTests, buildNode, timeout, taskId, refreshCache = false,
367
- linux = false, linuxImage = null, gate, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
350
+ projectPath, testPlan, filter, skipTests, buildNode, timeout, refreshCache = false,
351
+ linux = false, linuxImage = null, gate, stackNodes = [], _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
368
352
  }) {
369
353
  const { nodes, source } = testPlan;
370
354
  // Kept readable for whoever only prints the string — the same join the
@@ -384,28 +368,49 @@ async function runTestsNode({
384
368
 
385
369
  if (linux) {
386
370
  const { runs, environment } = await runNodesInLinux({
387
- projectPath, nodes, filter, taskId, timeout, linuxImage, gate, _runLinuxSuite, _exec,
371
+ projectPath, nodes, filter, timeout, linuxImage, gate, _runLinuxSuite, _exec,
388
372
  });
389
373
  return rollUpTestRuns(runs, { command, source, environment });
390
374
  }
391
375
 
392
376
  const runs = [];
393
377
  for (const node of nodes) {
378
+ if (node.e2e && !preDecideRun({ node, filter, projectPath, refreshCache })) {
379
+ stackNodes.push(node);
380
+ runs.push(emptyRunEntry(node));
381
+ continue;
382
+ }
394
383
  runs.push(await runOneTestNode({
395
- projectPath, node, filter, taskId, timeout, refreshCache, gate, _runTestSuite,
384
+ projectPath, node, filter, timeout, refreshCache, gate, _runTestSuite,
396
385
  }));
397
386
  }
398
387
 
399
388
  return rollUpTestRuns(runs, { command, source });
400
389
  }
401
390
 
391
+ // No stack, no run: the skip carries the e2e verdict as reason AND warning, so the
392
+ // suite never disappears behind a green rollup.
393
+ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode, stackEnv, timeout, gate, _runTestSuite }) {
394
+ const byId = new Map(stackNodes.map((node) => [node.id, node]));
395
+ const runs = [];
396
+ for (const run of testsNode.runs) {
397
+ const node = byId.get(run.id);
398
+ if (!node) { runs.push(run); continue; }
399
+ if (e2eNode.status !== 'pass') {
400
+ const reason = `a stack do e2e não está de pé (e2e: ${e2eNode.status}${e2eNode.reason ? ` — ${e2eNode.reason}` : ''}): nó e2e: true não roda sem ela`;
401
+ runs.push({ ...run, reason, warnings: [reason] });
402
+ continue;
403
+ }
404
+ runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, _runTestSuite }));
405
+ }
406
+ return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
407
+ }
408
+
402
409
  /**
403
410
  * Run the executable nodes inside ONE Linux container and judge them with the
404
411
  * SAME rules the local path uses.
405
412
  *
406
- * The pre-decisions (invalid config, `--filter`, a node outside the task
407
- * scope, an e2e node at task scope)
408
- * happen HERE, before the container exists: a node that must not run must not
413
+ * The pre-decisions (invalid config, `--filter`, the eval pre-flight) happen HERE, before the container exists: a node that must not run must not
409
414
  * be written into the container script either, or the flag would quietly run
410
415
  * what the user filtered out. What the container returns — an exit code and an
411
416
  * output — is then handed to `judgeRun()`, the same function the host path
@@ -415,12 +420,12 @@ async function runTestsNode({
415
420
  * bind mount: see linux-runner.js. That is the whole reason this path exists.
416
421
  */
417
422
  async function runNodesInLinux({
418
- projectPath, nodes, filter, taskId, timeout, linuxImage, gate, _runLinuxSuite, _exec,
423
+ projectPath, nodes, filter, timeout, linuxImage, gate, _runLinuxSuite, _exec,
419
424
  }) {
420
425
  const decided = new Map();
421
426
  const toRun = [];
422
427
  for (const node of nodes) {
423
- const pre = preDecideRun({ node, filter, taskId, projectPath });
428
+ const pre = preDecideRun({ node, filter, projectPath });
424
429
  if (pre) decided.set(node.id, pre);
425
430
  else toRun.push(node);
426
431
  }
@@ -471,9 +476,9 @@ async function runNodesInLinux({
471
476
  * from the report is the defect this whole feature exists to close.
472
477
  */
473
478
  async function runOneTestNode({
474
- projectPath, node, filter, taskId, timeout, refreshCache = false, gate, _runTestSuite,
479
+ projectPath, node, filter, timeout, refreshCache = false, gate, env = null, _runTestSuite,
475
480
  }) {
476
- const decided = preDecideRun({ node, filter, taskId, projectPath, refreshCache });
481
+ const decided = preDecideRun({ node, filter, projectPath, refreshCache });
477
482
  if (decided) return decided;
478
483
 
479
484
  // The eval channel: the CLI DICTATES where the cache, the dataset and the
@@ -491,8 +496,9 @@ async function runOneTestNode({
491
496
 
492
497
  gate.enter('tests');
493
498
  const start = Date.now();
499
+ const childEnv = env || evalChannel ? { ...(env || {}), ...(evalChannel?.env || {}) } : null;
494
500
  const result = await _runTestSuite(projectPath, {
495
- timeout, node, ...(evalChannel ? { env: evalChannel.env } : {}),
501
+ timeout, node, ...(childEnv ? { env: childEnv } : {}),
496
502
  });
497
503
  const entry = judgeRun({ node, result, durationMs: Date.now() - start });
498
504
  if (!evalChannel) return entry;
@@ -572,11 +578,6 @@ function emptyRunEntry(node) {
572
578
  reason: null,
573
579
  warnings: [],
574
580
  output: '',
575
- // What the task-scope recorte did to this node (#87): `command` above is
576
- // what actually ran, `selection.declaredCommand` what was declared. null at
577
- // feature scope, where applyTaskScope leaves the key ABSENT; absent is read
578
- // as null here so no reader has to tell the two apart.
579
- selection: node.selection ?? null,
580
581
  };
581
582
  }
582
583
 
@@ -589,7 +590,7 @@ function emptyRunEntry(node) {
589
590
  * two copies of this ordering is how a filtered node ends up invisible in one
590
591
  * of them.
591
592
  */
592
- function preDecideRun({ node, filter, taskId, projectPath = '.', refreshCache = false }) {
593
+ function preDecideRun({ node, filter, projectPath = '.', refreshCache = false }) {
593
594
  const entry = emptyRunEntry(node);
594
595
 
595
596
  // The resolver already decided: invalid config (fail-closed, D4) or an
@@ -606,26 +607,12 @@ function preDecideRun({ node, filter, taskId, projectPath = '.', refreshCache =
606
607
  return { ...entry, reason: `filtered out (--filter ${filter.raw})` };
607
608
  }
608
609
 
609
- // The task's group scoped this node out (#87). AFTER the filter on purpose:
610
- // a node the user filtered out reports the filter, never a scope it was not
611
- // judged by. BEFORE the e2e and eval checks: a node outside the task scope
612
- // has nothing left to decide.
613
- if (node.selection?.mode === 'out-of-scope') {
614
- return { ...entry, reason: node.selection.reason };
615
- }
616
-
617
- if (node.e2e && taskId) {
618
- return { ...entry, reason: 'e2e node runs at feature scope only' };
619
- }
620
-
621
610
  // The eval pre-flight lives HERE, beside the other pre-decisions, so the host
622
611
  // loop and the `--linux` path refuse for the same reason with the same words.
623
612
  // Its three cache verdicts are FAILURES, never skips: a skip over an unusable
624
- // cache is a green gate above a verification that never happened. The only
625
- // skip it produces is scope — an eval suite does not pay for itself inside the
626
- // per-task loop, and the result Gate 3 reads is the feature-scoped one.
613
+ // cache is a green gate above a verification that never happened.
627
614
  if (node.runner === EVAL_RUNNER) {
628
- const pre = evalPreflight(projectPath, node, { taskId, refresh: refreshCache });
615
+ const pre = evalPreflight(projectPath, node, { refresh: refreshCache });
629
616
  if (pre.status) return { ...entry, status: pre.status, reason: pre.reason };
630
617
  }
631
618
 
@@ -668,12 +655,6 @@ function judgeRun({ node, result, durationMs }) {
668
655
  const parsed = parseTestCount(result.output, node.runner);
669
656
  const count = parsed ? parsed.total : null;
670
657
 
671
- // A narrowed run is PARTIAL by construction (#87): it has a verdict of its
672
- // own, and the count rule below never sees it.
673
- if (entry.selection?.mode === 'narrowed') {
674
- return judgeNarrowedRun({ entry, node, result, durationMs, count });
675
- }
676
-
677
658
  // An eval node's unit is the SCENARIO (#110 part 2): its baseline is the
678
659
  // summary's scenario count, and `judgeEvalRun` is the one that compares it.
679
660
  // Comparing here too, with the extractor's count of xunit cases, failed a
@@ -707,68 +688,6 @@ function judgeRun({ node, result, durationMs }) {
707
688
  };
708
689
  }
709
690
 
710
- /**
711
- * The verdict of a NARROWED run (#87): what `judgeRun` hands over instead of
712
- * its count rule. Reached from the host loop and from `--linux` alike, because
713
- * both go through `judgeRun`.
714
- *
715
- * Two things change, and both follow from "partial by construction":
716
- *
717
- * - NO `compareTestCount`. `expectedCount` is the WHOLE suite's baseline: a
718
- * recorte running 12 of 3.082 is not a drop, and a superset selection
719
- * running more than the baseline is not a rise. Either comparison would
720
- * report something about the suite that this run never measured.
721
- * - a pass needs PROOF that tests ran. A filter that matches nothing exits 0
722
- * under VSTest, and "zero tests, all green" is exactly the verdict issue #87
723
- * must never buy. What counts as proof is the selection's `zeroGuard`
724
- * (test-scope.js):
725
- * `count` only a parsed count > 0. **null is UNKNOWN, never green**;
726
- * `mtp-exit-8` a parsed count > 0 proves it (the MTP summary is read in
727
- * pt-BR and en-US, #115); with no readable summary (another
728
- * locale), exit 0 does — MTP exits 8 on zero tests.
729
- * A parsed 0 is refused under either guard: it is positive evidence. It is
730
- * also what closes limit (f): with exit 8 IGNORED, MTP exits 0 over a
731
- * summary that says `total: 0` (measured, mtp-ignore-exit-code-8-zero-tests.txt).
732
- * In a narrowed `&&` chain the SUM would hide that link behind another's
733
- * count, so under `mtp-exit-8` every MTP block is checked on its own — MTP
734
- * prints one block per invocation (measured), and a `total: 0` block is red.
735
- *
736
- * A FAILED run is renamed `narrowed-zero-tests (…)` only when it carries the
737
- * runner's zero-test signature AND no count proves tests ran. A multi-project
738
- * VSTest output may print "No test matches…" for one project while another
739
- * ran and failed; that failure is the lead, never a zero. Anything else,
740
- * exit 9 included, keeps the executor's own raw reason: a project that imposes
741
- * `--minimum-expected-tests` is narrowed only with its floor lowered to 1 by
742
- * `-p:` (#114, test-scope.js), where a run with tests cannot exit 9 — so a 9
743
- * here is not ours to reinterpret.
744
- *
745
- * Every red leads its output with the verdict, like a timeout does: the
746
- * feedback line reads `output` before `reason`, and a tail alone would bury it.
747
- */
748
- function judgeNarrowedRun({ entry, node, result, durationMs, count }) {
749
- const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
750
- const base = { ...entry, durationMs, count, warnings: [] };
751
- const red = (reason) => ({ ...base, status: 'fail', reason, output: tail ? `${reason}\n${tail}` : reason });
752
- const green = () => ({ ...base, status: 'pass', reason: result.reason ?? null, output: '' });
753
- const ranTests = Number.isInteger(count) && count > 0;
754
-
755
- if (result.passed) {
756
- if (count === 0) return red(NARROWED_ZERO_TESTS);
757
- if (entry.selection.zeroGuard === 'mtp-exit-8' && mtpSummaryTotals(result.output).includes(0)) {
758
- return red(NARROWED_ZERO_TESTS_LINK);
759
- }
760
- if (ranTests) return green();
761
- if (entry.selection.zeroGuard === 'mtp-exit-8' && result.exitCode === 0) return green();
762
- return red(NARROWED_COUNT_UNKNOWN);
763
- }
764
-
765
- if (!ranTests && isZeroTestRun(result.output, node.runner, result.exitCode)) {
766
- return red(`narrowed-zero-tests (${node.runner}, exit ${result.exitCode ?? 'desconhecido'}): o recorte executou 0 testes`);
767
- }
768
-
769
- return { ...base, status: 'fail', reason: result.reason ?? null, output: tail };
770
- }
771
-
772
691
  /**
773
692
  * Aggregate the per-node runs into the single node `verify` publishes.
774
693
  *
@@ -898,22 +817,12 @@ function refreshCacheProblem(projectPath, testPlan, filter, { skipTests }) {
898
817
  * never created — the framework repository has none on purpose (creating it
899
818
  * breaks `doctor --full`), so the honest answer is a named usage error.
900
819
  *
901
- * A TASK id is refused first (#87): at task scope the runner narrows the nodes
902
- * AND the commands, so what it measures is a recorte, while `expectedCount` is
903
- * the whole fleet's number. `recordTestBaselines` refuses a measurement that
904
- * carries a selection (the second barrier), but paying for the recorte to
905
- * learn that is the ordering this function exists to prevent.
906
- *
907
820
  * @param {string} projectPath
908
821
  * @param {{ nodes: Array, source: string }} testPlan
909
- * @param {{ skipTests: boolean, taskId?: string|null }} opts
822
+ * @param {{ skipTests: boolean }} opts
910
823
  * @returns {string|null}
911
824
  */
912
- function baselineTargetProblem(projectPath, testPlan, { skipTests, taskId = null }) {
913
- if (taskId) {
914
- return `--record-baseline é manutenção da frota: rode sem {task} (recebeu "${taskId}") — `
915
- + 'no escopo de task o verify recorta nós e testes, e baseline de recorte não é baseline';
916
- }
825
+ function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
917
826
  if (skipTests) {
918
827
  return '--record-baseline com --skip-tests não mede nada — uma baseline vem de uma execução, não de uma omissão';
919
828
  }
@@ -957,12 +866,7 @@ function recordBaselineFrom(projectPath, testsNode) {
957
866
  );
958
867
  }
959
868
 
960
- // The selection travels with the count so the WRITER can refuse a recorte
961
- // (#87) — the second barrier behind baselineTargetProblem's usage error.
962
- return recordTestBaselines(
963
- projectPath,
964
- runs.map((r) => ({ id: r.id, count: r.count, selection: r.selection ?? null }))
965
- );
869
+ return recordTestBaselines(projectPath, runs.map((r) => ({ id: r.id, count: r.count })));
966
870
  }
967
871
 
968
872
  // ============================================================================
@@ -1035,53 +939,60 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
1035
939
  // E2E node
1036
940
  // ============================================================================
1037
941
 
1038
- async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE2e, freshE2e, buildNode, gate, _runE2eCheck }) {
942
+ async function runE2eNode({ projectPath, featureName, skipE2e, freshE2e, buildNode, gate, _runE2eCheck }) {
1039
943
  if (buildNode.status === 'fail') {
1040
944
  return {
1041
- status: 'skip',
1042
- command: null,
1043
- durationMs: 0,
1044
- reason: 'build failed — the stack could not come up anyway',
1045
- output: '',
945
+ node: {
946
+ status: 'skip',
947
+ command: null,
948
+ durationMs: 0,
949
+ reason: 'build failed — the stack could not come up anyway',
950
+ output: '',
951
+ },
952
+ stackEnv: null,
1046
953
  };
1047
954
  }
1048
955
 
1049
956
  const config = readConfig(projectPath);
1050
- const taskGroup = task?.group ?? null;
1051
957
  const gitCommonDir = resolveGitCommonDir(projectPath);
958
+ // The port block matters MORE here than in `e2e up`: this is the path that
959
+ // runs in the loop, and two worktrees verifying at once would otherwise get
960
+ // distinct container names and then collide on host port 3000 — a red gate
961
+ // over an infrastructure clash instead of a defect.
962
+ const env = buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir });
1052
963
 
1053
964
  const start = Date.now();
1054
965
  const result = _runE2eCheck(projectPath, {
1055
966
  config,
1056
967
  featureName,
1057
- taskGroup,
1058
- scope: taskId ? 'task' : 'feature',
1059
968
  gitCommonDir,
1060
- // The port block matters MORE here than in `e2e up`: this is the path that
1061
- // runs in the loop, and two worktrees verifying at once would otherwise get
1062
- // distinct container names and then collide on host port 3000 — a red gate
1063
- // over an infrastructure clash instead of a defect.
1064
- env: buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir }),
969
+ env,
1065
970
  skipRequested: skipE2e,
1066
971
  forceFresh: freshE2e,
1067
972
  beforeHeavy: () => gate.enter('e2e'),
973
+ countSpecs: (output) => parseTestCount(output, 'playwright')?.total ?? null,
1068
974
  // The stack stays up between verifies on purpose: cold-starting a .NET
1069
- // container per task is the difference between a usable loop and one
975
+ // container per verify is the difference between a usable loop and one
1070
976
  // nobody runs. `morph-spec e2e down` (and `finish`) tear it down.
1071
977
  keepUp: true,
1072
978
  });
1073
979
  const durationMs = Date.now() - start;
1074
980
 
1075
981
  return {
1076
- status: result.status,
1077
- command: result.command ?? null,
1078
- durationMs,
1079
- reason: result.reason ?? null,
1080
- output: result.output || '',
1081
- // Non-fatal findings (e.g. a compose with fixed ports at the ROOT, where
1082
- // nothing collides yet). Surfaced so the same compose does not fail
1083
- // unannounced the day the feature moves into a worktree.
1084
- warnings: Array.isArray(result.warnings) ? result.warnings : [],
982
+ node: {
983
+ status: result.status,
984
+ command: result.command ?? null,
985
+ durationMs,
986
+ reason: result.reason ?? null,
987
+ output: result.output || '',
988
+ // Specs Playwright counted in this run; null = no specs ran or unreadable summary.
989
+ count: Number.isInteger(result.count) ? result.count : null,
990
+ // Non-fatal findings (e.g. a compose with fixed ports at the ROOT, where
991
+ // nothing collides yet). Surfaced so the same compose does not fail
992
+ // unannounced the day the feature moves into a worktree.
993
+ warnings: Array.isArray(result.warnings) ? result.warnings : [],
994
+ },
995
+ stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
1085
996
  };
1086
997
  }
1087
998
 
@@ -1096,34 +1007,6 @@ function appendE2eFeedback(feedback, e2eNode) {
1096
1007
  }
1097
1008
  }
1098
1009
 
1099
- /**
1100
- * The task as verify needs it, or null when tasks.json or the task is missing.
1101
- *
1102
- * The ONE reader of tasks.json for verify: the group picks the e2e
1103
- * applicability, group + outputs pick the test scope, and the command uses the
1104
- * null to refuse an unknown task id.
1105
- *
1106
- * @param {string} projectPath
1107
- * @param {string} featureName
1108
- * @param {string} taskId
1109
- * @returns {{ group: string|null, outputs: string[] }|null}
1110
- */
1111
- export function readTask(projectPath, featureName, taskId) {
1112
- try {
1113
- const tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
1114
- if (!existsSync(tasksPath)) return null;
1115
- const tasksJson = JSON.parse(readFileSync(tasksPath, 'utf8'));
1116
- const task = (tasksJson.tasks || []).find((t) => t.id === taskId);
1117
- if (!task) return null;
1118
- return {
1119
- group: task.group ?? null,
1120
- outputs: Array.isArray(task.outputs) ? task.outputs : [],
1121
- };
1122
- } catch {
1123
- return null;
1124
- }
1125
- }
1126
-
1127
1010
  // ============================================================================
1128
1011
  // Shared helpers
1129
1012
  // ============================================================================
@@ -74,8 +74,7 @@ function resolveGitCommonDir(cwd) {
74
74
  * treating those as different paths would make every stack look foreign.
75
75
  *
76
76
  * Exported as the ONE copy of that rule: `doctor.js` compares a declared test
77
- * node `cwd` with a discovered `package.json` folder through it (#87 F5), and
78
- * `test-scope.js` takes only the CASE of a canonical path through it (#87 N3).
77
+ * node `cwd` with a discovered `package.json` folder through it.
79
78
  */
80
79
  export const samePathCase = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
81
80
  const norm = (p) => samePathCase(resolve(String(p)).replace(/\\/g, '/'));