@tea-agent/loop-agent 0.40.0-next.1 → 0.40.0-next.11

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 (213) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +114 -109
  3. package/README.md +2 -2
  4. package/dist/application/dag/generate-task-dag.js +6 -2
  5. package/dist/application/task-lifecycle/advance.js +12 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/update/runtime-activity.js +1 -29
  8. package/dist/commands/dag-rerun.js +1 -1
  9. package/dist/executors/dag-pi-executor.js +69 -7
  10. package/dist/executors/pi-executor.js +4 -2
  11. package/dist/executors/pi-sdk-executor.js +46 -1
  12. package/dist/executors/pi-writer-tool-policy.js +57 -1
  13. package/dist/executors/shell-executor.js +107 -20
  14. package/dist/executors/workspace-write-snapshot.js +68 -0
  15. package/dist/governance/checks.js +1 -0
  16. package/dist/infrastructure/harness/atomic-write.js +23 -0
  17. package/dist/shared/dag-prompt-override.js +4 -5
  18. package/dist/shared/update/npm-client.js +33 -11
  19. package/dist/task/config-types.js +10 -0
  20. package/dist/task/contract/apply.js +11 -1
  21. package/dist/task/contract/constants.js +2 -0
  22. package/dist/task/contract/hash.js +3 -0
  23. package/dist/task/contract/observe.js +25 -4
  24. package/dist/task/contract/paths.js +2 -1
  25. package/dist/task/contract/project.js +15 -1
  26. package/dist/task/contract/schema.js +2 -0
  27. package/dist/task/contract/transaction.js +12 -0
  28. package/dist/task/source-prepare/completeness.js +188 -0
  29. package/dist/task/source-prepare/fragment-inventory.js +474 -0
  30. package/dist/task/source-prepare/index.js +3 -0
  31. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  32. package/dist/task/source-prepare/ledger-review.js +214 -0
  33. package/dist/task/source-prepare/ledger.js +545 -0
  34. package/dist/task/source-prepare/prepare.js +262 -1
  35. package/dist/task/source-prepare/semantic-intake.js +19 -3
  36. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  37. package/dist/task/source-prepare/types.js +22 -0
  38. package/dist/worker/cli.js +12 -26
  39. package/dist/worker/console/chat/chat-event-store.js +75 -5
  40. package/dist/worker/console/chat/context-panel.js +7 -0
  41. package/dist/worker/console/chat/deferred-turn.js +12 -0
  42. package/dist/worker/console/chat/pi-runtime.js +331 -18
  43. package/dist/worker/console/chat/provider-error.js +98 -0
  44. package/dist/worker/console/chat/routes.js +389 -56
  45. package/dist/worker/console/chat/session-stats.js +179 -0
  46. package/dist/worker/console/chat/session-store.js +93 -63
  47. package/dist/worker/console/chat/todos.js +115 -0
  48. package/dist/worker/console/chat/tool-preview.js +42 -0
  49. package/dist/worker/console/chat/turn-process.js +20 -55
  50. package/dist/worker/console/chat/user-questions.js +266 -0
  51. package/dist/worker/console/chat/workspace-landing.js +79 -0
  52. package/dist/worker/console/console-handoff.js +82 -0
  53. package/dist/worker/console/console-update-and-init.js +115 -0
  54. package/dist/worker/console/console-update-runtime.js +84 -0
  55. package/dist/worker/console/dag-execution-receipt.js +20 -2
  56. package/dist/worker/console/draft-store.js +49 -0
  57. package/dist/worker/console/native-directory-picker.js +13 -0
  58. package/dist/worker/console/operation-store.js +1 -1
  59. package/dist/worker/console/operator-actions.js +133 -7
  60. package/dist/worker/console/operator-surface-health.js +1 -0
  61. package/dist/worker/console/prd-intake-bridge.js +13 -0
  62. package/dist/worker/console/routes.js +312 -1
  63. package/dist/worker/console/security.js +4 -4
  64. package/dist/worker/console/server.js +583 -172
  65. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Br92EGb7.js → abnfDiagram-N423BO3Z-Bs-CDAXM.js} +1 -1
  66. package/dist/worker/console/static/assets/{arc-oulImtQq.js → arc-CAkA3We3.js} +1 -1
  67. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-tyHc6aqO.js → architectureDiagram-T3A2C74G-DEmI_zqr.js} +1 -1
  68. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CY86BSpT.js → blockDiagram-VBNYF7ZC-uDHANebE.js} +1 -1
  69. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BKKsDmNs.js → c4Diagram-5PPSVZJV-Bv97b4_B.js} +1 -1
  70. package/dist/worker/console/static/assets/channel-amxUpk7o.js +1 -0
  71. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CXZdjl3B.js → chunk-2GRJ4B5K-shmQKsFz.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B-aIFDQ5.js → chunk-2Q5K7J3B-BAraNvcN.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BDgAW_B2.js → chunk-5RXB4S5H-BirhxUop.js} +1 -1
  74. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DABa4UPR.js → chunk-5VM5RSS4-C9LgUJpR.js} +1 -1
  75. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-aGbysVOx.js → chunk-6Q2QTUOP-DrU-s-t7.js} +1 -1
  76. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B8MSZDVs.js → chunk-GF5L2VYU-Tx-H_FA2.js} +1 -1
  77. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CpelV73g.js → chunk-JWPE2WC7-BjLtNFrR.js} +1 -1
  78. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BE04ty3z.js → chunk-KBJHAD2P-UNLVwSsy.js} +1 -1
  79. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BUxTkaaR.js → chunk-RYQCIY6F-CBEUX8XD.js} +1 -1
  80. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CYYBVKfX.js → chunk-XXDRQBXY-WTL7IZOq.js} +1 -1
  81. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +1 -0
  82. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +1 -0
  83. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CEQF_xmQ.js → cose-bilkent-JH36ORCC-CkHG6W3s.js} +1 -1
  84. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CqpxN5IT.js → cynefin-VYW2F7L2-BRzFqTaL.js} +1 -1
  85. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-B3PzanVi.js → cynefinDiagram-MW4NZA55-2_CFp_i7.js} +1 -1
  86. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BSUGCNx1.js → dagre-VZM6K2ZE-BJtsmdkE.js} +1 -1
  87. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CdLevU5T.js → diagram-7IWD3JNH-DNEFFo6w.js} +1 -1
  88. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BjpF_Kok.js → diagram-B4RE2ZJO-B_IWV7eo.js} +1 -1
  89. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C4I6NJny.js → diagram-LBJQPF4R-DvvRx2dz.js} +1 -1
  90. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C8hecCr5.js → diagram-Q27KOJAE-CsibsWLg.js} +1 -1
  91. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Daod6tOS.js → diagram-UB23O5K3-les_djBQ.js} +1 -1
  92. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BvvmazGv.js → ebnfDiagram-BXEA7PRR-BEm2n7qJ.js} +1 -1
  93. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DeN_vfV4.js → erDiagram-JOGREHBK-BI7vHHVm.js} +1 -1
  94. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D6dq1t9z.js → flowDiagram-UKHOOZJN-CUFCERrB.js} +1 -1
  95. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DziIdNY4.js → ganttDiagram-PKOTCBZU-wxyLiGWt.js} +1 -1
  96. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dy4s5CXr.js → gitGraphDiagram-DS77QQ5N-Bt897K8Z.js} +1 -1
  97. package/dist/worker/console/static/assets/index-BHhUOTri.css +1 -0
  98. package/dist/worker/console/static/assets/index-DqJbO3-p.js +407 -0
  99. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Czv5eYx2.js → infoDiagram-6WML65LV-DYNx_IPv.js} +1 -1
  100. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Bsk3nFRT.js → ishikawaDiagram-WSZJBQD7-CzpLNEu6.js} +1 -1
  101. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DxGmNj5K.js → journeyDiagram-NVQOT4AX-97f9owNU.js} +1 -1
  102. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CYCFqkO_.js → kanban-definition-27J2QSJJ-BxM9hyBn.js} +1 -1
  103. package/dist/worker/console/static/assets/{linear-B0YzRwrd.js → linear-ByuxcHvp.js} +1 -1
  104. package/dist/worker/console/static/assets/{mermaid.core-t3EZtIGh.js → mermaid.core-DwPGvpCv.js} +5 -5
  105. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BTXpTv0T.js → mindmap-definition-FAOFIHXS-DCoqG9RT.js} +1 -1
  106. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DU8UkKqA.js → pegDiagram-VL7TDLO6-BLSBmBMx.js} +1 -1
  107. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DP4y8ohp.js → pieDiagram-7S7Q4E2Y-B0t_0oJW.js} +1 -1
  108. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B07vNaYR.js → quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js} +1 -1
  109. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-xDWktb2i.js → railroadDiagram-AXF67PYL-B3jRdATC.js} +1 -1
  110. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BLNMKqeS.js → requirementDiagram-LRYGKXZP-D-cTtXEN.js} +1 -1
  111. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-n0adalxH.js → sankeyDiagram-W5VNT64P-B25igJRE.js} +1 -1
  112. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BbnS-2DL.js → sequenceDiagram-SI44F4Z6-jW_lM1_D.js} +1 -1
  113. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4uPpCLu.js → sizeCapture-X5ZJPWSS-CrzTgsHE.js} +1 -1
  114. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA--MMbB0fX.js → stateDiagram-OKZ733FA-B01MvlqE.js} +1 -1
  115. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +1 -0
  116. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BXUgp2QY.js → swimlanes-SLNWSIFB-YcF12FiU.js} +2 -2
  117. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +8 -0
  118. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CsaMxki7.js → timeline-definition-Z64GVDOM-ClnRcg2s.js} +1 -1
  119. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-C2IuG9iH.js → vennDiagram-T6HMQDX7-B6gr_z-q.js} +1 -1
  120. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CmEWOXCW.js → wardleyDiagram-T6FBY63Y-CvSgguGH.js} +1 -1
  121. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-D_9LE0MG.js → xychartDiagram-ELKLHX3M-23OtSZCb.js} +1 -1
  122. package/dist/worker/console/static/index.html +2 -2
  123. package/dist/worker/console/static-src/app/console-types.js +68 -4
  124. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  125. package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
  126. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  127. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  128. package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
  129. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  130. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  131. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  132. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  133. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  134. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  135. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  136. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  137. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  138. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  139. package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
  140. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  141. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  142. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  143. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  144. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  145. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  146. package/dist/worker/console/workspace-context.js +234 -0
  147. package/dist/worker/console/workspace-registry.js +214 -0
  148. package/dist/worker/observability/init-runtime-activity.js +26 -0
  149. package/dist/worker/observe/node-input.js +88 -2
  150. package/dist/worker/observe/routes.js +188 -0
  151. package/dist/worker/observe/server.js +4 -0
  152. package/dist/worker/observe/static/api.js +29 -9
  153. package/dist/worker/observe/static/app.js +18 -70
  154. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  155. package/dist/worker/observe/static/dag-node-purpose.js +203 -0
  156. package/dist/worker/observe/static/format.js +37 -0
  157. package/dist/worker/observe/static/index.html +6 -1
  158. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  159. package/dist/worker/observe/static/operator-chrome.css +145 -20
  160. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  161. package/dist/worker/observe/static/operator-chrome.js +330 -100
  162. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  163. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  164. package/dist/worker/observe/static/router.d.ts +46 -0
  165. package/dist/worker/observe/static/router.js +25 -1
  166. package/dist/worker/observe/static/state.d.ts +50 -0
  167. package/dist/worker/observe/static/state.js +174 -2
  168. package/dist/worker/observe/static/styles.css +357 -35
  169. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  170. package/dist/worker/observe/static/views/dag-inspector.js +533 -79
  171. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  172. package/dist/worker/observe/static/views/dag.js +6 -4
  173. package/dist/worker/observe/static/views/dashboard.js +6 -4
  174. package/dist/worker/observe/static/views/night.js +2 -2
  175. package/dist/worker/observe/static/views/pool.js +1 -0
  176. package/dist/worker/observe/static/views/run.js +3 -1
  177. package/dist/worker/observe/static/views/task.js +56 -1
  178. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +186 -8
  179. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  180. package/dist/workflows/dag/backend-test-pytest-collection.js +168 -16
  181. package/dist/workflows/dag/backend-test-result-contract.js +4 -0
  182. package/dist/workflows/dag/backend-test-scenario-param.js +339 -53
  183. package/dist/workflows/dag/backend-test-writer-completeness.js +28 -17
  184. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  185. package/dist/workflows/dag/frontend-implementation-contract.js +88 -2
  186. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  187. package/dist/workflows/dag/frontend-prewrite-gate.js +197 -0
  188. package/dist/workflows/dag/frontend-review-context.js +92 -10
  189. package/dist/workflows/dag/init-hybrid.js +239 -52
  190. package/dist/workflows/dag/interrupt-request.js +3 -3
  191. package/dist/workflows/dag/lifecycle.js +3 -3
  192. package/dist/workflows/dag/node-execution.js +45 -0
  193. package/dist/workflows/dag/types.js +62 -12
  194. package/docs/README.md +2 -0
  195. package/docs/architecture/evolution.md +45 -0
  196. package/docs/init-surface.manifest.json +1 -0
  197. package/docs/operations/local-development-environment.md +21 -0
  198. package/docs/templates/README.md +1 -1
  199. package/docs/templates/agent-dag.schema.json +18 -0
  200. package/docs/templates/backend-test-dag.json +267 -238
  201. package/docs/templates/frontend-implementation-contract.schema.json +681 -24
  202. package/harness.json +3 -3
  203. package/package.json +3 -1
  204. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  205. package/dist/worker/console/static/assets/channel-TY36zt-i.js +0 -1
  206. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Dk7QCChM.js +0 -1
  207. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Dk7QCChM.js +0 -1
  208. package/dist/worker/console/static/assets/index-C3GgHg-g.css +0 -1
  209. package/dist/worker/console/static/assets/index-Ci8ksvBm.js +0 -406
  210. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqOigMQ4.js +0 -1
  211. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Caxz9UA2.js +0 -8
  212. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  213. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
@@ -200,14 +200,170 @@ export async function inspectBackendTestEnvironment(input) {
200
200
  serverEntries,
201
201
  };
202
202
  }
203
+ /**
204
+ * Filenames declared by the canonical `## Module Index` section of the backend
205
+ * README. Only a unique exact `## Module Index` heading is authoritative; a
206
+ * missing/duplicate section yields an empty declaration set (callers then fall
207
+ * back to the whole directory so legacy workspaces without a manifest keep
208
+ * their prior behaviour). Reads `[x](./x.md)`, `` `testcase/md/<x>.md` `` and
209
+ * inline `x.md` tokens inside the section; README.md itself is never a module.
210
+ */
211
+ export function extractDeclaredModuleIndexFilenames(readme) {
212
+ const lines = readme
213
+ .replaceAll("\r\n", "\n")
214
+ .replaceAll("\r", "\n")
215
+ .split("\n");
216
+ const headings = lines.flatMap((line, index) => line.trim() === "## Module Index" ? [index] : []);
217
+ if (headings.length !== 1)
218
+ return [];
219
+ const start = headings[0] + 1;
220
+ const relativeEnd = lines
221
+ .slice(start)
222
+ .findIndex((line) => /^##\s+\S/.test(line.trim()));
223
+ const end = relativeEnd < 0 ? lines.length : start + relativeEnd;
224
+ const section = lines.slice(start, end).join("\n");
225
+ return unique([...section.matchAll(/`?[A-Za-z0-9_.-]+\.md`?/g)]
226
+ .map((match) => match[0].replaceAll("`", ""))
227
+ .filter((name) => name.toLowerCase() !== "readme.md"));
228
+ }
229
+ /**
230
+ * Count exact `## Module Index` headings in the backend README.
231
+ */
232
+ function moduleIndexHeadingCountIn(readme) {
233
+ return readme
234
+ .replaceAll("\r\n", "\n")
235
+ .replaceAll("\r", "\n")
236
+ .split("\n")
237
+ .filter((line) => line.trim() === "## Module Index").length;
238
+ }
239
+ /**
240
+ * Machine-readable fail-closed finding for an invalid backend-test Module Index.
241
+ * These are distinct states (missing/unreadable/duplicate/empty-unparsable) so a
242
+ * stripped Module Index can never silently fall back to a whole-directory scan.
243
+ */
244
+ export function backendManifestStatusFinding(manifestStatus, moduleIndexHeadingCount = 0) {
245
+ switch (manifestStatus) {
246
+ case "missing":
247
+ return "README is missing; backend-test current round fails closed without a declarative Module Index";
248
+ case "unreadable":
249
+ return "README is unreadable; backend-test current round fails closed without a usable Module Index";
250
+ case "duplicate":
251
+ return moduleIndexHeadingCount === 0
252
+ ? "README Module Index heading is missing; backend-test current round fails closed without a declarative Module Index"
253
+ : `duplicate ## Module Index heading (${moduleIndexHeadingCount} occurrences); backend-test current round fails closed without a unique Module Index`;
254
+ case "empty-unparsable":
255
+ return "README Module Index is empty/unparsable; backend-test current round fails closed with no declared module";
256
+ case "valid":
257
+ return "";
258
+ }
259
+ }
260
+ /**
261
+ * Resolve the current-round Markdown module manifest. The README `## Module
262
+ * Index` is the authoritative source for which module files belong to this
263
+ * round. Fail closed on every non-`valid` state (missing/unreadable/duplicate/
264
+ * empty-unparsable): the backend-test current round must NEVER re-scan the whole
265
+ * directory, or historical `.md` files could leak back into Case/coverage/
266
+ * partition facts.
267
+ *
268
+ * - `manifestStatus==="valid"` — unique heading with >= 1 declared `.md` file.
269
+ * - `missing` — README file does not exist.
270
+ * - `unreadable` — README exists but could not be read.
271
+ * - `duplicate` — README has 0 or duplicate `## Module Index`.
272
+ * - `empty-unparsable` — unique heading but no `.md` filename parsed.
273
+ * - `declaredMdFiles` — declared filenames that exist on disk (relative paths).
274
+ * - `declaredMissing` — declared filenames with no file on disk (fail-closed
275
+ * evidence, never silently dropped).
276
+ * - `unmanifested` — existing `.md` files that the Module Index does not
277
+ * declare (non-silent advisory; they must not enter Case/coverage/partition).
278
+ */
279
+ export async function resolveBackendTestManifestModules(workspaceRoot, layout) {
280
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
281
+ const readmePath = path.join(workspaceRoot, ...resolved.readmePath.split("/"));
282
+ const invalid = (manifestStatus, moduleIndexHeadingCount) => ({
283
+ hasManifest: false,
284
+ manifestStatus,
285
+ moduleIndexHeadingCount,
286
+ declaredStems: [],
287
+ declaredMdFiles: [],
288
+ declaredMissing: [],
289
+ unmanifested: [],
290
+ });
291
+ let readme = "";
292
+ if (await exists(readmePath)) {
293
+ try {
294
+ readme = await readFile(readmePath, "utf8");
295
+ }
296
+ catch {
297
+ // Unreadable README is fail-closed (not silently treated as no manifest).
298
+ return invalid("unreadable", 0);
299
+ }
300
+ }
301
+ else {
302
+ return invalid("missing", 0);
303
+ }
304
+ const moduleIndexHeadingCount = moduleIndexHeadingCountIn(readme);
305
+ if (moduleIndexHeadingCount !== 1) {
306
+ return invalid("duplicate", moduleIndexHeadingCount);
307
+ }
308
+ const declaredStems = extractDeclaredModuleIndexFilenames(readme);
309
+ if (declaredStems.length === 0) {
310
+ return invalid("empty-unparsable", moduleIndexHeadingCount);
311
+ }
312
+ const hasManifest = true;
313
+ const directory = path.join(workspaceRoot, ...resolved.markdownDir.split("/"));
314
+ let existingNames = [];
315
+ try {
316
+ existingNames = (await readdir(directory, { withFileTypes: true }))
317
+ .filter((entry) => entry.isFile() &&
318
+ entry.name.toLowerCase().endsWith(".md") &&
319
+ entry.name.toLowerCase() !== "readme.md")
320
+ .map((entry) => entry.name);
321
+ }
322
+ catch {
323
+ // missing markdown directory -> no existing modules
324
+ }
325
+ const declaredByLower = new Map();
326
+ for (const name of declaredStems) {
327
+ const base = path.posix.basename(name);
328
+ declaredByLower.set(base.toLowerCase(), base);
329
+ }
330
+ const declaredMdFiles = [];
331
+ const declaredMissing = [];
332
+ for (const base of declaredByLower.values()) {
333
+ const disk = existingNames.find((name) => name.toLowerCase() === base.toLowerCase());
334
+ if (disk)
335
+ declaredMdFiles.push(`${resolved.markdownDir}/${disk}`);
336
+ else
337
+ declaredMissing.push(`${resolved.markdownDir}/${base}`);
338
+ }
339
+ const unmanifested = existingNames
340
+ .filter((name) => !declaredByLower.has(name.toLowerCase()))
341
+ .map((name) => `${resolved.markdownDir}/${name}`);
342
+ return {
343
+ hasManifest,
344
+ manifestStatus: "valid",
345
+ moduleIndexHeadingCount,
346
+ declaredStems,
347
+ declaredMdFiles: declaredMdFiles.sort(),
348
+ declaredMissing: declaredMissing.sort(),
349
+ unmanifested: unmanifested.sort(),
350
+ };
351
+ }
203
352
  export async function markdownFiles(root, layout) {
204
353
  const resolved = layout ?? resolveBackendTestLayout(undefined);
205
354
  const directory = path.join(root, ...resolved.markdownDir.split("/"));
206
355
  const entries = await readdir(directory, { withFileTypes: true });
207
- return entries
356
+ const all = entries
208
357
  .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
209
358
  .map((entry) => path.join(directory, entry.name))
210
359
  .sort();
360
+ const manifest = await resolveBackendTestManifestModules(root, resolved);
361
+ if (manifest.manifestStatus !== "valid")
362
+ return [];
363
+ const declaredAbs = new Set(manifest.declaredMdFiles.map((rel) => path.join(root, ...rel.split("/")).toLowerCase()));
364
+ return all
365
+ .filter((file) => declaredAbs.has(file.toLowerCase()))
366
+ .sort();
211
367
  }
212
368
  function canonicalBackendCaseId(value) {
213
369
  const upper = value.toUpperCase();
@@ -453,6 +609,17 @@ export async function validateBackendMarkdownCases(input) {
453
609
  const readme = path.join(input.workspaceRoot, ...layout.readmePath.split("/"));
454
610
  if (!(await exists(readme)))
455
611
  findings.push(`missing ${layout.readmePath}`);
612
+ const manifest = await resolveBackendTestManifestModules(input.workspaceRoot, layout);
613
+ // Fail closed on README Module Index entries that were declared but never
614
+ // materialized — never let a declared module silently vanish from the round.
615
+ if (manifest.declaredMissing.length > 0) {
616
+ findings.push(`declared Markdown module is missing (Module Index): ${manifest.declaredMissing.join(", ")}`);
617
+ }
618
+ // Fail closed on every non-valid Module Index state: without a valid unique
619
+ // Module Index the current round must not re-scan historical `.md` files.
620
+ if (manifest.manifestStatus !== "valid") {
621
+ findings.push(backendManifestStatusFinding(manifest.manifestStatus, manifest.moduleIndexHeadingCount));
622
+ }
456
623
  const moduleFiles = files.filter((file) => path.basename(file).toLowerCase() !== "readme.md");
457
624
  if (moduleFiles.length === 0)
458
625
  findings.push(`no ${layout.markdownDir} module case file found`);
@@ -562,6 +729,7 @@ export async function validateBackendMarkdownCases(input) {
562
729
  `- Cases: ${caseCount}`,
563
730
  `- Covered AC IDs: ${[...coveredAc].sort().join(", ") || "none"}`,
564
731
  `- Environment evidence present: ${input.environmentMarkdown.trim() ? "yes" : "no"}`,
732
+ `- Manifest scope: ${manifest.manifestStatus === "valid" ? "declared-modules" : `fails-closed:${manifest.manifestStatus}`} (declared-missing=${manifest.declaredMissing.length}, unmanifested-advisory=${manifest.unmanifested.length}, module-index-heading-count=${manifest.moduleIndexHeadingCount})`,
565
733
  `- Findings: ${findings.length}`,
566
734
  "- Required sections: Preconditions/前置条件, Steps/操作步骤, Expected Results/预期结果",
567
735
  "- Source-reference validity check: not applied",
@@ -583,6 +751,13 @@ export async function validateBackendMarkdownCases(input) {
583
751
  ? safetyFindings.map((finding) => `- ${finding}`)
584
752
  : ["- No secret-shaped values detected."]),
585
753
  "",
754
+ "## Unmanifested Markdown Modules",
755
+ "",
756
+ "- Advisory: existing .md files not declared by the README Module Index are excluded from this round's Case/coverage/partition scope.",
757
+ ...(manifest.unmanifested.length > 0
758
+ ? manifest.unmanifested.map((item) => `- ${item}`)
759
+ : ["- None"]),
760
+ "",
586
761
  "## Findings",
587
762
  "",
588
763
  ...(findings.length > 0 ? findings.map((finding) => `- ${finding}`) : ["- None"]),
@@ -20,6 +20,14 @@ export const backendPytestCollectionFindingSchema = z.object({
20
20
  classification: z.literal("test-asset-defect"),
21
21
  repairability: z.enum(["repairable", "blocked"]),
22
22
  detail: z.string().min(1),
23
+ caseId: z.string().min(1).optional(),
24
+ reasonCodes: z.array(z.string().min(1)).optional(),
25
+ repairPaths: z.array(z.string().min(1)).optional(),
26
+ payloadExpectedPaths: z.array(z.string()).optional(),
27
+ payloadObservedPaths: z.array(z.string()).optional(),
28
+ payloadMissingPaths: z.array(z.string()).optional(),
29
+ payloadUnexpectedPaths: z.array(z.string()).optional(),
30
+ payloadEnumMismatches: z.array(z.string()).optional(),
23
31
  }).strict();
24
32
  export const backendPytestCollectionFactsSchema = z.object({
25
33
  schemaId: z.literal("backend-test-pytest-collection-v3"),
@@ -61,11 +69,22 @@ export const backendPytestCollectionFactsSchema = z.object({
61
69
  context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
62
70
  }
63
71
  });
72
+ const backendTestScenarioParamDiagnosticSchema = z.object({
73
+ reasonCode: z.enum(["SCENARIO_PARAM_MISMATCH", "SCENARIO_PARAM_UNDETERMINED"]),
74
+ tpId: z.string().min(1).max(256),
75
+ field: z.string().min(1).max(256).optional(),
76
+ expectedRaw: z.string().max(512).optional(),
77
+ observedRaw: z.string().max(512),
78
+ expectedNormalized: z.string().max(512).optional(),
79
+ observedNormalized: z.string().max(512).optional(),
80
+ sourceLocation: z.string().max(512).optional(),
81
+ }).strict();
64
82
  const backendTestExcludedItemSchema = z.object({
65
83
  itemId: z.string().min(1),
66
84
  caseId: z.string().optional(),
67
85
  symbol: z.string().optional(),
68
86
  reasons: z.array(z.string().min(1)).min(1),
87
+ scenarioParamDiagnostics: z.array(backendTestScenarioParamDiagnosticSchema).max(16).optional(),
69
88
  }).strict();
70
89
  export const backendTestExecutionReadinessSchema = z.object({
71
90
  schemaId: z.literal("backend-test-execution-readiness-v2"),
@@ -81,6 +100,14 @@ export const backendTestExecutionReadinessSchema = z.object({
81
100
  collectedItemIds: z.array(z.string()),
82
101
  eligibleItemIds: z.array(z.string()),
83
102
  excludedItems: z.array(backendTestExcludedItemSchema),
103
+ exclusionSummary: z.object({
104
+ excludedItemCount: z.number().int().min(0),
105
+ excludedCaseCount: z.number().int().min(0),
106
+ excludedByReason: z.record(z.string(), z.number().int().min(1)),
107
+ excludedByCase: z.record(z.string(), z.number().int().min(1)),
108
+ }).strict(),
109
+ pytestSpawned: z.literal(false),
110
+ businessTestBodyExecutedCount: z.literal(0),
84
111
  fixtureIssues: z.array(z.string()),
85
112
  assetHashes: z.record(z.string(), z.string().regex(SHA256)),
86
113
  eligibilityInputHashes: z.record(z.string(), z.string().regex(SHA256)),
@@ -131,6 +158,23 @@ function normalizeHazardOccurrenceIdentity(line) {
131
158
  // Unclosed quote or no out-of-string `#`: do not invent a comment boundary.
132
159
  return trimmed;
133
160
  }
161
+ /**
162
+ * Bare `__NNN_*` helper module names imported by a Python source snippet.
163
+ * Only the head token of an `import`/`from` statement is considered a module
164
+ * name; dotted submodules (`__551_manager_helpers.x`) collapse to the head.
165
+ */
166
+ function importedBareHelperModules(source) {
167
+ const names = [];
168
+ const seen = new Set();
169
+ for (const match of normalizeSourceNewlines(source).matchAll(/\b(?:import|from)\s+([A-Za-z0-9_.]+)/g)) {
170
+ const head = match[1].split(".")[0];
171
+ if (/^__\d+[A-Za-z0-9_]*$/.test(head) && !seen.has(head)) {
172
+ seen.add(head);
173
+ names.push(head);
174
+ }
175
+ }
176
+ return names;
177
+ }
134
178
  function listPatternOccurrences(source, patternSource, flags = "") {
135
179
  const normalized = normalizeSourceNewlines(source);
136
180
  const pattern = new RegExp(patternSource, flags.includes("g") ? flags : `${flags}g`);
@@ -221,6 +265,8 @@ export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScri
221
265
  const inputHashes = {};
222
266
  const hazardOccurrenceCounts = {};
223
267
  const hazardOccurrences = {};
268
+ const scriptBareImports = {};
269
+ const mappedScriptSet = new Set(normalizedScripts);
224
270
  for (const file of assetFiles) {
225
271
  const bytes = await readFile(path.resolve(workspaceRoot, file));
226
272
  const source = bytes.toString("utf8");
@@ -228,8 +274,10 @@ export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScri
228
274
  const lists = scanRepairHazardOccurrenceLists(source);
229
275
  hazardOccurrences[file] = lists;
230
276
  hazardOccurrenceCounts[file] = Object.fromEntries(Object.entries(lists).map(([label, identities]) => [label, identities.length]));
277
+ if (mappedScriptSet.has(file))
278
+ scriptBareImports[file] = importedBareHelperModules(source);
231
279
  }
232
- return { mappedScripts: normalizedScripts, assetFiles, inputHashes, hazardOccurrenceCounts, hazardOccurrences };
280
+ return { mappedScripts: normalizedScripts, assetFiles, inputHashes, hazardOccurrenceCounts, hazardOccurrences, scriptBareImports };
233
281
  }
234
282
  function bounded(value) {
235
283
  const normalized = value.replaceAll(/(Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+/gi, "$1 [REDACTED]");
@@ -237,13 +285,14 @@ function bounded(value) {
237
285
  ? normalized
238
286
  : `${normalized.slice(0, MAX_DIAGNOSTIC_CHARS)}\n...[truncated]`;
239
287
  }
240
- function collectionItems(stdout) {
288
+ function collectionItems(stdout, layout) {
289
+ const rootPrefix = `${(layout ?? resolveBackendTestLayout(undefined)).testRoot}/`;
241
290
  return [...new Set(stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => {
242
291
  if (!line.includes("::"))
243
292
  return false;
244
293
  if (/^(ERROR|FAILED|E\s+)/.test(line))
245
294
  return false;
246
- return /^testcase\//.test(line.replaceAll("\\", "/"));
295
+ return line.replaceAll("\\", "/").startsWith(rootPrefix);
247
296
  }).map((line) => line.replaceAll("\\", "/")))];
248
297
  }
249
298
  function escapeRegExp(value) {
@@ -308,8 +357,68 @@ function classifyFixtureResolutionFailure(output, layout) {
308
357
  repairPaths: paths,
309
358
  };
310
359
  }
311
- function classifyCollectionFailure(output) {
360
+ /**
361
+ * Generated-local bare helper module signatures found in the bound pytest
362
+ * asset inventory. A file such as `testcase/__551_manager_helpers.py` in the
363
+ * test root/script dir is generated-local: when collection reports
364
+ * `No module named '__551_manager_helpers'`, the defect can be precisely
365
+ * attributed to the generated mapped script instead of a missing third-party
366
+ * package.
367
+ */
368
+ function generatedLocalBareHelperNames(inventory) {
369
+ if (!inventory)
370
+ return [];
371
+ const names = [];
372
+ for (const file of inventory.assetFiles) {
373
+ const stem = path.posix.basename(file).replace(/\.py$/, "");
374
+ if (/^__\d+[A-Za-z0-9_]*$/.test(stem))
375
+ names.push(stem);
376
+ }
377
+ return [...new Set(names)];
378
+ }
379
+ function classifyCollectionFailure(output, evidence) {
312
380
  const normalized = output.replaceAll("\\", "/");
381
+ const helperNames = generatedLocalBareHelperNames(evidence?.inventory);
382
+ const helperByLower = new Map(helperNames.map((name) => [name.toLowerCase(), name]));
383
+ // A bare `__NNN_...` module import that collection cannot find is only a
384
+ // candidate generated-local helper signature. REPAIRABLE requires two
385
+ // deterministic pieces of evidence: (1) a helper asset of the same name in
386
+ // the bound inventory, and (2) at least one mapped generated script whose
387
+ // source truly imports that bare module. Without both — or when attribution
388
+ // is impossible — fail closed as BLOCKED so an un-attributable dependency
389
+ // error is never widened into a repair. Genuine third-party modules (no
390
+ // `__NNN_` signature) stay BLOCKED below.
391
+ const missingModule = /No module named ['\"]([^'\"]+)['\"]/i.exec(normalized)?.[1];
392
+ if (missingModule && /^__\d+[A-Za-z0-9_]*$/.test(missingModule)) {
393
+ const helperAsset = helperByLower.get(missingModule.toLowerCase());
394
+ const inventory = evidence?.inventory;
395
+ const scriptBareImports = inventory?.scriptBareImports ?? {};
396
+ const missingLower = missingModule.toLowerCase();
397
+ const importedByMappedScript = inventory
398
+ ? inventory.mappedScripts.some((script) => (scriptBareImports[script] ?? []).some((name) => name.toLowerCase() === missingLower))
399
+ : false;
400
+ if (helperAsset && importedByMappedScript && inventory) {
401
+ const repairPaths = [...new Set([
402
+ ...(helperAsset
403
+ ? inventory.assetFiles.filter((file) => path.posix.basename(file).replace(/\.py$/, "") === helperAsset)
404
+ : []),
405
+ ...inventory.mappedScripts,
406
+ ])].sort();
407
+ return {
408
+ status: "REPAIRABLE",
409
+ kind: "missing-generated-local-helper",
410
+ detail: `generated testcase asset imports missing bare generated-local helper module ${missingModule}; inline the helper or remove the generated shared dependency`,
411
+ repairPaths,
412
+ };
413
+ }
414
+ return {
415
+ status: "BLOCKED",
416
+ kind: "missing-generated-local-helper-without-evidence",
417
+ detail: helperAsset
418
+ ? `missing bare generated-local helper module ${missingModule} has a name match but no mapped generated script imports it (no precise generated-local attribution)`
419
+ : `missing bare __NNN_* module ${missingModule} has no generated-local helper asset in the bound inventory (cannot be precisely attributed)`,
420
+ };
421
+ }
313
422
  const blockedPatterns = [
314
423
  [/No module named ['\"](?!testcase(?:\.|['\"]))/i, "missing-third-party-module"],
315
424
  [/pytest(?:_|-)plugin|pluggy|unknown hook|unrecognized arguments/i, "pytest-plugin-or-config"],
@@ -335,7 +444,7 @@ function classifyCollectionFailure(output) {
335
444
  return { status: "BLOCKED", kind: "unclassified-collection-error", detail: "collection failure cannot be safely attributed to generated testcase assets" };
336
445
  }
337
446
  export function assessBackendPytestCollection(input) {
338
- const items = collectionItems(input.stdout);
447
+ const items = collectionItems(input.stdout, input.layout);
339
448
  const fixtureResolution = input.fixtureResolution ?? { exitCode: 0, stdout: "fixture resolution assumed by direct assessor", stderr: "" };
340
449
  if (input.exitCode === 0 && fixtureResolution.exitCode === 0) {
341
450
  return backendPytestCollectionFactsSchema.parse({
@@ -401,7 +510,7 @@ export function assessBackendPytestCollection(input) {
401
510
  fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
402
511
  });
403
512
  }
404
- const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`);
513
+ const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`, { layout: input.layout, inventory: input.inventory });
405
514
  return backendPytestCollectionFactsSchema.parse({
406
515
  schemaId: "backend-test-pytest-collection-v3",
407
516
  phase: input.phase,
@@ -412,7 +521,9 @@ export function assessBackendPytestCollection(input) {
412
521
  fixtureResolutionAttempted: false,
413
522
  fixtureResolutionStatus: "NOT_RUN",
414
523
  fixtureResolutionExitCode: null,
415
- repairPaths: testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
524
+ repairPaths: classification.repairPaths && classification.repairPaths.length > 0
525
+ ? classification.repairPaths
526
+ : testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
416
527
  mappedScripts: input.inventory.mappedScripts,
417
528
  existingMappedScripts: input.inventory.mappedScripts,
418
529
  missingMappedScripts: [],
@@ -618,6 +729,7 @@ export function buildBackendTestItemEligibility(collectedItemIds, input) {
618
729
  const parameterTokens = orderedUnique((itemId.match(/TP-[A-Z0-9-]+/g) ?? []));
619
730
  const mapping = input.correspondenceEntries.find((entry) => symbol && (entry.declaredPrimarySymbol === symbol || entry.pytestSymbols.includes(symbol)));
620
731
  const reasons = [];
732
+ const scenarioParamDiagnostics = [];
621
733
  if (!mapping)
622
734
  reasons.push("no exact Markdown Case/primary-symbol correspondence");
623
735
  if (mapping && mapping.status !== "EXACT_1_TO_1")
@@ -640,13 +752,30 @@ export function buildBackendTestItemEligibility(collectedItemIds, input) {
640
752
  // or observed Python payload shape.
641
753
  const normalizedField = entry.field?.toLowerCase();
642
754
  const fieldIsPayloadBound = Boolean(normalizedField && payloadPaths.some((item) => item === normalizedField || item.endsWith(`.${normalizedField}`)));
643
- if (fieldIsPayloadBound && entry.status !== "MATCH")
755
+ if (fieldIsPayloadBound && entry.status !== "MATCH") {
644
756
  reasons.push(`scenario-param ${entry.tpId} field ${entry.field} is ${entry.status}`);
757
+ scenarioParamDiagnostics.push({
758
+ reasonCode: entry.status === "MISMATCH" ? "SCENARIO_PARAM_MISMATCH" : "SCENARIO_PARAM_UNDETERMINED",
759
+ tpId: entry.tpId,
760
+ ...(entry.field ? { field: entry.field } : {}),
761
+ ...(entry.expectedRaw !== undefined ? { expectedRaw: entry.expectedRaw.slice(0, 512) } : {}),
762
+ observedRaw: (entry.observedRaw ?? "unavailable").slice(0, 512),
763
+ ...(entry.expectedNormalized !== undefined ? { expectedNormalized: entry.expectedNormalized.slice(0, 512) } : {}),
764
+ ...(entry.observedNormalized !== undefined ? { observedNormalized: entry.observedNormalized.slice(0, 512) } : {}),
765
+ ...(entry.sourceLocation ? { sourceLocation: entry.sourceLocation.slice(0, 512) } : {}),
766
+ });
767
+ }
645
768
  }
646
769
  if (reasons.length === 0)
647
770
  eligibleItemIds.push(itemId);
648
771
  else
649
- excludedItems.push({ itemId, ...(mapping?.caseId ? { caseId: mapping.caseId } : {}), ...(symbol ? { symbol } : {}), reasons: orderedUnique(reasons) });
772
+ excludedItems.push({
773
+ itemId,
774
+ ...(mapping?.caseId ? { caseId: mapping.caseId } : {}),
775
+ ...(symbol ? { symbol } : {}),
776
+ reasons: orderedUnique(reasons),
777
+ ...(scenarioParamDiagnostics.length > 0 ? { scenarioParamDiagnostics: scenarioParamDiagnostics.slice(0, 16) } : {}),
778
+ });
650
779
  }
651
780
  return { eligibleItemIds, excludedItems };
652
781
  }
@@ -657,7 +786,7 @@ export async function materializeBackendTestExecutionReadiness(input) {
657
786
  if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
658
787
  throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
659
788
  }
660
- const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
789
+ const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts, { layout: input.layout });
661
790
  assertSameInventory(input.effective, current);
662
791
  const eligibility = input.eligibility ?? { eligibleItemIds: input.effective.collectedItemIds, excludedItems: [] };
663
792
  const status = eligibility.eligibleItemIds.length === 0
@@ -679,6 +808,9 @@ export async function materializeBackendTestExecutionReadiness(input) {
679
808
  collectedItemIds: input.effective.collectedItemIds,
680
809
  eligibleItemIds: eligibility.eligibleItemIds,
681
810
  excludedItems: eligibility.excludedItems,
811
+ exclusionSummary: summarizeBackendTestExclusions(eligibility.excludedItems),
812
+ pytestSpawned: false,
813
+ businessTestBodyExecutedCount: 0,
682
814
  fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
683
815
  assetHashes: input.effective.inputHashes,
684
816
  eligibilityInputHashes: input.eligibilityInputHashes ?? {},
@@ -694,6 +826,26 @@ export async function readBackendTestExecutionReadiness(filePath) {
694
826
  function formatReadinessCounts(readiness) {
695
827
  return `mapped=${readiness.mappedScripts.length} collected=${readiness.collectedItemIds.length} eligible=${readiness.eligibleItemIds.length} excluded=${readiness.excludedItems.length}`;
696
828
  }
829
+ export function summarizeBackendTestExclusions(excludedItems) {
830
+ const reasonCounts = new Map();
831
+ const caseCounts = new Map();
832
+ for (const item of excludedItems) {
833
+ if (item.caseId)
834
+ caseCounts.set(item.caseId, (caseCounts.get(item.caseId) ?? 0) + 1);
835
+ for (const reason of item.reasons) {
836
+ const category = classifyExclusionReason(reason);
837
+ reasonCounts.set(category, (reasonCounts.get(category) ?? 0) + 1);
838
+ }
839
+ }
840
+ return {
841
+ excludedItemCount: excludedItems.length,
842
+ excludedCaseCount: caseCounts.size,
843
+ excludedByReason: Object.fromEntries(READINESS_EXCLUSION_CATEGORIES
844
+ .filter((category) => (reasonCounts.get(category) ?? 0) > 0)
845
+ .map((category) => [category, reasonCounts.get(category)])),
846
+ excludedByCase: Object.fromEntries([...caseCounts.entries()].sort(([left], [right]) => left.localeCompare(right))),
847
+ };
848
+ }
697
849
  export function classifyExclusionReason(reason) {
698
850
  if (reason === "no exact Markdown Case/primary-symbol correspondence")
699
851
  return "correspondence-missing";
@@ -756,7 +908,7 @@ async function firstUnreadableAssetPath(workspaceRoot, readiness) {
756
908
  }
757
909
  return undefined;
758
910
  }
759
- export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
911
+ export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness, layout) {
760
912
  if (!["PASS", "PARTIAL"].includes(readiness.status)) {
761
913
  const counts = formatReadinessCounts(readiness);
762
914
  const reasonTally = readiness.eligibleItemIds.length === 0 && readiness.excludedItems.length > 0
@@ -779,7 +931,7 @@ export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, re
779
931
  }
780
932
  let current;
781
933
  try {
782
- current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
934
+ current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts, { layout });
783
935
  }
784
936
  catch {
785
937
  const fallbackPath = [...readiness.mappedScripts].sort()[0] ?? Object.keys(readiness.assetHashes).sort()[0] ?? "unknown";
@@ -814,11 +966,11 @@ export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, re
814
966
  }
815
967
  }
816
968
  }
817
- export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
969
+ export async function assertBackendPytestCollectionFresh(workspaceRoot, effective, layout) {
818
970
  if (effective.phase !== "effective" || effective.status !== "PASS") {
819
971
  throw new Error("backend pytest execution requires effective collection PASS facts");
820
972
  }
821
- const current = await buildBackendPytestAssetInventory(workspaceRoot, effective.mappedScripts);
973
+ const current = await buildBackendPytestAssetInventory(workspaceRoot, effective.mappedScripts, { layout });
822
974
  const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
823
975
  if (priorityOnly.length > 0)
824
976
  throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
@@ -826,7 +978,7 @@ export async function assertBackendPytestCollectionFresh(workspaceRoot, effectiv
826
978
  }
827
979
  export async function materializeEffectiveBackendPytestCollection(input) {
828
980
  if (input.initial.status === "PASS") {
829
- const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.initial.mappedScripts);
981
+ const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.initial.mappedScripts, { layout: input.layout });
830
982
  const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
831
983
  if (priorityOnly.length > 0)
832
984
  throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
@@ -844,7 +996,7 @@ export async function materializeEffectiveBackendPytestCollection(input) {
844
996
  if (input.final.status !== "PASS")
845
997
  throw new Error("backend pytest collection still fails after bounded repair");
846
998
  await assertBackendPytestRepairSafety(input.workspaceRoot, input.initial, input.final);
847
- const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.final.mappedScripts);
999
+ const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.final.mappedScripts, { layout: input.layout });
848
1000
  const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
849
1001
  if (priorityOnly.length > 0)
850
1002
  throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
@@ -70,6 +70,7 @@ export const backendTestResultContractSchema = z
70
70
  schemaVersion: z.literal(1),
71
71
  /** Process-level status of the pytest invocation. Task Pool: use with outcome. */
72
72
  executionStatus: backendTestExecutionStatusSchema,
73
+ pytestSpawned: z.literal(true),
73
74
  pytestExitCode: z.number().int().min(0).max(255),
74
75
  collectionStatus: backendTestCollectionStatusSchema,
75
76
  /** Total tests counted from JUnit (or 0 when report unavailable). */
@@ -659,6 +660,7 @@ export function deriveBackendTestResult(input) {
659
660
  const result = {
660
661
  schemaVersion: 1,
661
662
  executionStatus,
663
+ pytestSpawned: true,
662
664
  pytestExitCode: input.pytestExitCode,
663
665
  collectionStatus: "unknown",
664
666
  tests: 0,
@@ -733,6 +735,7 @@ export function deriveBackendTestResult(input) {
733
735
  const result = {
734
736
  schemaVersion: 1,
735
737
  executionStatus,
738
+ pytestSpawned: true,
736
739
  pytestExitCode: exit,
737
740
  collectionStatus,
738
741
  tests: parsed.tests,
@@ -936,6 +939,7 @@ export async function materializeBackendTestResultFromPytestHtml(input) {
936
939
  const result = backendTestResultContractSchema.parse({
937
940
  schemaVersion: 1,
938
941
  executionStatus,
942
+ pytestSpawned: true,
939
943
  pytestExitCode: exit,
940
944
  collectionStatus,
941
945
  tests: parsed.tests,