@tea-agent/loop-agent 0.39.0-next.9 → 0.39.1

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 (288) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +269 -69
  3. package/README.md +11 -5
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/application/dag/generate-task-dag.js +9 -0
  6. package/dist/application/task-lifecycle/advance.js +16 -0
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/command-definitions.js +16 -5
  9. package/dist/cli/program.js +16 -2
  10. package/dist/cli/update/notifier.js +2 -2
  11. package/dist/cli/update/runtime-activity.js +1 -29
  12. package/dist/cli.js +2 -1
  13. package/dist/commands/dag-rerun.js +55 -1
  14. package/dist/commands/init-model-catalog.js +464 -0
  15. package/dist/commands/init-upgrade.js +265 -97
  16. package/dist/commands/init.js +428 -27
  17. package/dist/commands/inspect-next.js +8 -0
  18. package/dist/commands/task-advance.js +31 -0
  19. package/dist/executors/dag-pi-executor.js +124 -18
  20. package/dist/executors/pi-event-serializer.js +42 -1
  21. package/dist/executors/pi-executor.js +51 -0
  22. package/dist/executors/pi-extension-resolver.js +233 -0
  23. package/dist/executors/pi-playwright-cli-tool.js +74 -28
  24. package/dist/executors/pi-sdk-executor.js +187 -59
  25. package/dist/executors/shell-executor.js +555 -34
  26. package/dist/executors/shell-write-guard.js +7 -0
  27. package/dist/shared/dag-failure-category.js +138 -0
  28. package/dist/shared/dag-prompt-override.js +27 -0
  29. package/dist/shared/operator/capabilities.js +181 -27
  30. package/dist/shared/playwright-cli-command-policy.js +15 -0
  31. package/dist/shared/update/console-notifier.js +100 -0
  32. package/dist/{cli → shared}/update/npm-client.js +70 -15
  33. package/dist/{cli → shared}/update/state.js +41 -13
  34. package/dist/task/config-types.js +28 -0
  35. package/dist/task/contract/apply.js +11 -1
  36. package/dist/task/contract/constants.js +3 -0
  37. package/dist/task/contract/diff.js +26 -0
  38. package/dist/task/contract/hash.js +3 -0
  39. package/dist/task/contract/observe.js +25 -4
  40. package/dist/task/contract/paths.js +2 -1
  41. package/dist/task/contract/project.js +20 -1
  42. package/dist/task/contract/schema.js +8 -1
  43. package/dist/task/contract/transaction.js +12 -0
  44. package/dist/task/source-prepare/build-draft.js +15 -0
  45. package/dist/task/source-prepare/completeness.js +188 -0
  46. package/dist/task/source-prepare/fragment-inventory.js +474 -0
  47. package/dist/task/source-prepare/index.js +3 -0
  48. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  49. package/dist/task/source-prepare/ledger-review.js +214 -0
  50. package/dist/task/source-prepare/ledger.js +545 -0
  51. package/dist/task/source-prepare/parse-intent.js +24 -4
  52. package/dist/task/source-prepare/prepare.js +262 -1
  53. package/dist/task/source-prepare/semantic-intake.js +19 -3
  54. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  55. package/dist/task/source-prepare/types.js +22 -0
  56. package/dist/worker/cli.js +18 -2
  57. package/dist/worker/console/chat/deferred-turn.js +91 -0
  58. package/dist/worker/console/chat/mcp-inventory.js +221 -0
  59. package/dist/worker/console/chat/operation-card.js +8 -1
  60. package/dist/worker/console/chat/pi-console-config.js +81 -10
  61. package/dist/worker/console/chat/pi-runtime.js +165 -83
  62. package/dist/worker/console/chat/repo-walk.js +10 -1
  63. package/dist/worker/console/chat/routes.js +480 -55
  64. package/dist/worker/console/chat/semantic-activity.js +20 -8
  65. package/dist/worker/console/chat/session-store.js +146 -2
  66. package/dist/worker/console/chat/turn-process.js +10 -10
  67. package/dist/worker/console/console-handoff.js +82 -0
  68. package/dist/worker/console/console-update-and-init.js +115 -0
  69. package/dist/worker/console/console-update-runtime.js +84 -0
  70. package/dist/worker/console/dag-execution-receipt.js +33 -0
  71. package/dist/worker/console/draft-store.js +49 -0
  72. package/dist/worker/console/inspect-split.js +18 -0
  73. package/dist/worker/console/operation-run-facts.js +19 -3
  74. package/dist/worker/console/operation-store.js +1 -1
  75. package/dist/worker/console/operator-actions.js +320 -4
  76. package/dist/worker/console/operator-surface-health.js +1 -0
  77. package/dist/worker/console/operator-user-error.js +2 -2
  78. package/dist/worker/console/pi-readiness.js +5 -4
  79. package/dist/worker/console/prd-intake-bridge.js +13 -0
  80. package/dist/worker/console/recovery-cta.js +4 -4
  81. package/dist/worker/console/recovery-error-copy.js +4 -4
  82. package/dist/worker/console/routes.js +46 -2
  83. package/dist/worker/console/security.js +63 -9
  84. package/dist/worker/console/server.js +111 -7
  85. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BLXFe4qI.js → abnfDiagram-N423BO3Z-CTx1TrNa.js} +1 -1
  86. package/dist/worker/console/static/assets/{arc-7iOJeqho.js → arc-B4i9cVxX.js} +1 -1
  87. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CYoyj5K7.js → architectureDiagram-T3A2C74G-BAXE8_Rl.js} +1 -1
  88. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-fK01JNlR.js → blockDiagram-VBNYF7ZC-C5Xh59at.js} +1 -1
  89. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-bzbw5D_Z.js → c4Diagram-5PPSVZJV-B_asOzAu.js} +1 -1
  90. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +1 -0
  91. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Cf6hJFFG.js → chunk-2GRJ4B5K-wRO_iRNc.js} +1 -1
  92. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DSFXR-et.js → chunk-2Q5K7J3B-ByOaSh_G.js} +1 -1
  93. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BagJechl.js → chunk-5RXB4S5H-DjLzudan.js} +1 -1
  94. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-E7QNMFaW.js → chunk-5VM5RSS4-JCNGkXYT.js} +1 -1
  95. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BG_eOBrC.js → chunk-6Q2QTUOP-BhrPXXMZ.js} +1 -1
  96. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bo42dqol.js → chunk-GF5L2VYU-TJ5JuEBT.js} +1 -1
  97. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-dibM-87v.js → chunk-JWPE2WC7-VVthn_mD.js} +1 -1
  98. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Bytrp3Oe.js → chunk-KBJHAD2P-D60-Mny4.js} +1 -1
  99. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DBABILrr.js → chunk-RYQCIY6F-zXv7aVye.js} +1 -1
  100. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DCUk5Pf9.js → chunk-XXDRQBXY-By9VQoi1.js} +1 -1
  101. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +1 -0
  102. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +1 -0
  103. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BlGE9v6E.js → cose-bilkent-JH36ORCC-C4zQLe-M.js} +1 -1
  104. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DGl5ewph.js → cynefin-VYW2F7L2-wh5N2ReN.js} +1 -1
  105. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DsJ_SVH_.js → cynefinDiagram-MW4NZA55-Dlu5wIqs.js} +1 -1
  106. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BIhjFdzv.js → dagre-VZM6K2ZE-CrDgthc6.js} +1 -1
  107. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dmy1ctTb.js → diagram-7IWD3JNH-zN1clg27.js} +1 -1
  108. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DYCtWUF3.js → diagram-B4RE2ZJO-BN-o7J7-.js} +1 -1
  109. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-BMZFHVd2.js → diagram-LBJQPF4R-C8SuPt0d.js} +1 -1
  110. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-b_U8gfaO.js → diagram-Q27KOJAE-BoizuHbZ.js} +1 -1
  111. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Dzy_bn-I.js → diagram-UB23O5K3-BzUzXixG.js} +1 -1
  112. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DJCiMNEI.js → ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js} +1 -1
  113. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-28lD016B.js → erDiagram-JOGREHBK-GALUzRbs.js} +1 -1
  114. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DGyHJmIw.js → flowDiagram-UKHOOZJN-CZJN65Sb.js} +1 -1
  115. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CzdwUjrO.js → ganttDiagram-PKOTCBZU-JLB9OESl.js} +1 -1
  116. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-D9bGwuo8.js → gitGraphDiagram-DS77QQ5N-CcvCr4GD.js} +1 -1
  117. package/dist/worker/console/static/assets/index-CW88Fjbw.js +406 -0
  118. package/dist/worker/console/static/assets/index-DP0ZJl39.css +1 -0
  119. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-LJgJUQFC.js → infoDiagram-6WML65LV-HG5z7aVg.js} +1 -1
  120. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-98LaB8zb.js → ishikawaDiagram-WSZJBQD7-BHEu9chR.js} +1 -1
  121. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-D-ISzwlQ.js → journeyDiagram-NVQOT4AX-Cp_Up8-a.js} +1 -1
  122. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-gfYOI-EL.js → kanban-definition-27J2QSJJ-DvVeY-em.js} +1 -1
  123. package/dist/worker/console/static/assets/{linear-CyntZgKV.js → linear-aGmcjgTn.js} +1 -1
  124. package/dist/worker/console/static/assets/{mermaid.core-BBOR7dg0.js → mermaid.core-Biyo-tqA.js} +5 -5
  125. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CQAgwPMk.js → mindmap-definition-FAOFIHXS-DyxY2pIb.js} +1 -1
  126. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DkMiZFuV.js → pegDiagram-VL7TDLO6-CkdGuc50.js} +1 -1
  127. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DLNXa_0J.js → pieDiagram-7S7Q4E2Y-NNQI9xVR.js} +1 -1
  128. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Dc7bdzjm.js → quadrantDiagram-CIZ2JOQS-PAJkuz_G.js} +1 -1
  129. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BxMB0QqW.js → railroadDiagram-AXF67PYL-ZwrQod0O.js} +1 -1
  130. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-FjumoKn4.js → requirementDiagram-LRYGKXZP-D3ZfrzM2.js} +1 -1
  131. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CkhKc_E3.js → sankeyDiagram-W5VNT64P-DDGFmWk5.js} +1 -1
  132. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DoBWqTTH.js → sequenceDiagram-SI44F4Z6-DO6mmXTA.js} +1 -1
  133. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CQeh_YeE.js → sizeCapture-X5ZJPWSS-DMbCQiJt.js} +1 -1
  134. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-LuHmFW-I.js → stateDiagram-OKZ733FA-Di_YUEfC.js} +1 -1
  135. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +1 -0
  136. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CHwbDctY.js → swimlanes-SLNWSIFB-Bn2PphNJ.js} +2 -2
  137. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +8 -0
  138. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CoMngk0X.js → timeline-definition-Z64GVDOM-DPh8pRsp.js} +1 -1
  139. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CURYbtbO.js → vennDiagram-T6HMQDX7-GMfl26pW.js} +1 -1
  140. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-BdqSECjU.js → wardleyDiagram-T6FBY63Y-DTlmVrmF.js} +1 -1
  141. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DbMUYuAg.js → xychartDiagram-ELKLHX3M-YGBjKAS5.js} +1 -1
  142. package/dist/worker/console/static/index.html +2 -2
  143. package/dist/worker/console/static-src/app/console-types.js +40 -1
  144. package/dist/worker/console/static-src/app/useOperatorActions.js +6 -1
  145. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  146. package/dist/worker/console/static-src/app/useRecoveryConsole.js +38 -2
  147. package/dist/worker/console/static-src/app/useRunProgress.js +46 -0
  148. package/dist/worker/console/static-src/app/useTaskWizard.js +73 -1
  149. package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
  150. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +74 -26
  151. package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
  152. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  153. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  154. package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
  155. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  156. package/dist/worker/console/static-src/operator-chat/turn-submission.js +54 -0
  157. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  158. package/dist/worker/console/static-src/operator-chat/useChatStream.js +237 -11
  159. package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
  160. package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
  161. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
  162. package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
  163. package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
  164. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
  165. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  166. package/dist/worker/materialize/harness-task-lineage.js +5 -2
  167. package/dist/worker/observability/init-runtime-activity.js +26 -0
  168. package/dist/worker/observability/read-model.js +22 -0
  169. package/dist/worker/observe/node-input.js +160 -3
  170. package/dist/worker/observe/node-process.js +377 -0
  171. package/dist/worker/observe/routes.js +224 -3
  172. package/dist/worker/observe/shell-handler-keys.js +34 -0
  173. package/dist/worker/observe/static/api.js +67 -1
  174. package/dist/worker/observe/static/constants.js +8 -1
  175. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  176. package/dist/worker/observe/static/format.js +60 -0
  177. package/dist/worker/observe/static/index.html +5 -0
  178. package/dist/worker/observe/static/markdown-render.js +20 -1
  179. package/dist/worker/observe/static/operator-chrome.js +4 -0
  180. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  181. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  182. package/dist/worker/observe/static/state.js +13 -2
  183. package/dist/worker/observe/static/styles.css +85 -0
  184. package/dist/worker/observe/static/views/dag-inspector.js +912 -8
  185. package/dist/worker/observe/static/views/dag.js +8 -1
  186. package/dist/worker/observe/static/views/failures.js +5 -2
  187. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  188. package/dist/worker/observe/static/views/task.js +55 -0
  189. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +856 -54
  190. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  191. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  192. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  193. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  194. package/dist/workflows/dag/backend-test-layout.js +133 -0
  195. package/dist/workflows/dag/backend-test-markdown-workflow.js +343 -33
  196. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  197. package/dist/workflows/dag/backend-test-pytest-collection.js +454 -36
  198. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  199. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  200. package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
  201. package/dist/workflows/dag/backend-test-writer-completeness.js +110 -61
  202. package/dist/workflows/dag/contract-output-registry.js +15 -0
  203. package/dist/workflows/dag/contract-validator-registrations.js +3 -1
  204. package/dist/workflows/dag/convergence/controller.js +141 -0
  205. package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
  206. package/dist/workflows/dag/dynamic-runtime/map.js +25 -9
  207. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  208. package/dist/workflows/dag/failure-category.js +7 -116
  209. package/dist/workflows/dag/frontend-implementation-contract.js +465 -6
  210. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  211. package/dist/workflows/dag/frontend-prewrite-gate.js +293 -84
  212. package/dist/workflows/dag/frontend-repair.js +14 -0
  213. package/dist/workflows/dag/frontend-review-context.js +92 -10
  214. package/dist/workflows/dag/frontend-risk.js +15 -2
  215. package/dist/workflows/dag/frontend-test-case-checklist.js +30 -4
  216. package/dist/workflows/dag/frontend-test-case-manifest.js +11 -4
  217. package/dist/workflows/dag/frontend-test-case-quality.js +11 -16
  218. package/dist/workflows/dag/frontend-test-environment-probe.js +230 -0
  219. package/dist/workflows/dag/frontend-test-html-report.js +3 -1
  220. package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
  221. package/dist/workflows/dag/frontend-test-layout.js +159 -0
  222. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  223. package/dist/workflows/dag/frontend-test-result-contract.js +188 -42
  224. package/dist/workflows/dag/frontend-test-standard-scenarios.js +70 -0
  225. package/dist/workflows/dag/frontend-verification-trace.js +29 -4
  226. package/dist/workflows/dag/init-hybrid.js +1019 -209
  227. package/dist/workflows/dag/node-execution.js +228 -15
  228. package/dist/workflows/dag/prompt.js +60 -1
  229. package/dist/workflows/dag/rerun-feedback.js +212 -0
  230. package/dist/workflows/dag/rerun-plan.js +224 -27
  231. package/dist/workflows/dag/rerun-run.js +61 -6
  232. package/dist/workflows/dag/rerun-task.js +61 -2
  233. package/dist/workflows/dag/retry-policy.js +109 -0
  234. package/dist/workflows/dag/structured-output-repair.js +712 -0
  235. package/dist/workflows/dag/types.js +185 -12
  236. package/dist/workflows/dag/validate.js +64 -20
  237. package/docs/README.md +1 -0
  238. package/docs/architecture/evolution.md +45 -0
  239. package/docs/init-surface.manifest.json +6 -0
  240. package/docs/operations/README.md +1 -1
  241. package/docs/operations/local-development-environment.md +1 -5
  242. package/docs/skills/vetted-skill-registry.md +14 -0
  243. package/docs/templates/README.md +2 -2
  244. package/docs/templates/agent-dag.schema.json +44 -0
  245. package/docs/templates/backend-test-dag.json +60 -35
  246. package/docs/templates/frontend-implementation-contract.schema.json +681 -24
  247. package/docs/templates/frontend-test-case-checklist.md +2 -2
  248. package/docs/templates/frontend-test-dag.json +8 -10
  249. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  250. package/docs/templates/init-managed-agents.md +13 -3
  251. package/harness.json +6 -4
  252. package/package.json +12 -4
  253. package/scripts/next-info.mjs +356 -0
  254. package/scripts/next-publish-gate.mjs +238 -0
  255. package/scripts/release-source-binding.mjs +251 -0
  256. package/skills/analyze-product-requirements/SKILL.md +8 -5
  257. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  258. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  259. package/skills/analyze-product-requirements/references/example.md +50 -0
  260. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  261. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  262. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  263. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  264. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  265. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  266. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  267. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  268. package/skills/codebase-scout/SKILL.md +1 -1
  269. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  270. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  271. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  272. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  273. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  274. package/skills/improve-codebase-architecture/deepening.md +37 -0
  275. package/skills/improve-codebase-architecture/html-report.md +123 -0
  276. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  277. package/skills/improve-codebase-architecture/language.md +53 -0
  278. package/skills/loop-agent/references/command-reference.md +11 -4
  279. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  280. package/skills/playwright-cli/SKILL.md +23 -1
  281. package/skills/playwright-cli-case-generator/SKILL.md +29 -8
  282. package/dist/worker/console/static/assets/channel-BBOTJHvD.js +0 -1
  283. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cm8Yf8tD.js +0 -1
  284. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cm8Yf8tD.js +0 -1
  285. package/dist/worker/console/static/assets/index-B-qWGThd.js +0 -325
  286. package/dist/worker/console/static/assets/index-C-xRgY_0.css +0 -1
  287. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DZihfdKc.js +0 -1
  288. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CKnXCjHX.js +0 -8
@@ -3,8 +3,18 @@ import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { priorityOnlyBackendPytestScripts } from "./backend-test-module-stem.js";
6
+ import { resolveBackendTestLayout, } from "./backend-test-layout.js";
6
7
  const SHA256 = /^[a-f0-9]{64}$/;
7
8
  const MAX_DIAGNOSTIC_CHARS = 12_000;
9
+ /** Upper bound for assertBackendTestExecutionReadinessFresh Error.message (not collection excerpts). */
10
+ export const MAX_READINESS_ERROR_CHARS = 2_048;
11
+ const READINESS_EXCLUSION_CATEGORIES = [
12
+ "correspondence-missing",
13
+ "correspondence-status",
14
+ "payload-contract-status",
15
+ "scenario-param-field",
16
+ "other",
17
+ ];
8
18
  export const backendPytestCollectionFindingSchema = z.object({
9
19
  kind: z.string().min(1),
10
20
  classification: z.literal("test-asset-defect"),
@@ -27,6 +37,8 @@ export const backendPytestCollectionFactsSchema = z.object({
27
37
  missingMappedScripts: z.array(z.string()),
28
38
  assetFiles: z.array(z.string()),
29
39
  inputHashes: z.record(z.string(), z.string().regex(SHA256)),
40
+ hazardOccurrenceCounts: z.record(z.string(), z.record(z.string(), z.number().int().min(0))),
41
+ hazardOccurrences: z.record(z.string(), z.record(z.string(), z.array(z.string()))).optional(),
30
42
  pytestExitCode: z.number().int().nullable(),
31
43
  collectedItemCount: z.number().int().min(0),
32
44
  collectedItemIds: z.array(z.string()),
@@ -49,8 +61,14 @@ export const backendPytestCollectionFactsSchema = z.object({
49
61
  context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
50
62
  }
51
63
  });
64
+ const backendTestExcludedItemSchema = z.object({
65
+ itemId: z.string().min(1),
66
+ caseId: z.string().optional(),
67
+ symbol: z.string().optional(),
68
+ reasons: z.array(z.string().min(1)).min(1),
69
+ }).strict();
52
70
  export const backendTestExecutionReadinessSchema = z.object({
53
- schemaId: z.literal("backend-test-execution-readiness-v1"),
71
+ schemaId: z.literal("backend-test-execution-readiness-v2"),
54
72
  status: z.enum(["PASS", "PARTIAL", "BLOCKED"]),
55
73
  collectionStatus: z.literal("PASS"),
56
74
  fixtureResolutionStatus: z.literal("PASS"),
@@ -61,9 +79,111 @@ export const backendTestExecutionReadinessSchema = z.object({
61
79
  }).strict(),
62
80
  mappedScripts: z.array(z.string()).min(1),
63
81
  collectedItemIds: z.array(z.string()),
82
+ eligibleItemIds: z.array(z.string()),
83
+ excludedItems: z.array(backendTestExcludedItemSchema),
64
84
  fixtureIssues: z.array(z.string()),
65
85
  assetHashes: z.record(z.string(), z.string().regex(SHA256)),
86
+ eligibilityInputHashes: z.record(z.string(), z.string().regex(SHA256)),
66
87
  }).strict();
88
+ const REPAIR_HAZARD_PATTERNS = [
89
+ { label: "skip/xfail", source: "pytest\\.mark\\.(?:skip|skipif|xfail)\\b|pytest\\.(?:skip|xfail)\\s*\\(" },
90
+ { label: "sys.path/PYTHONPATH mutation", source: "sys\\.path\\s*\\.|PYTHONPATH" },
91
+ { label: "ImportError fallback", source: "except\\s+ImportError\\b" },
92
+ { label: "broad exception swallowing", source: "except\\s+(?:Exception|BaseException)\\b[\\s\\S]{0,160}?\\bpass\\b" },
93
+ ];
94
+ function normalizeSourceNewlines(source) {
95
+ return source.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
96
+ }
97
+ /**
98
+ * Normalize a hazard occurrence identity by stripping trailing Python `#` comments
99
+ * while preserving `#` characters inside string literals. Fail closed: if quote
100
+ * state is ambiguous, keep the original trimmed line rather than over-stripping.
101
+ */
102
+ function normalizeHazardOccurrenceIdentity(line) {
103
+ const trimmed = line.trimEnd().trim();
104
+ if (!trimmed.includes("#"))
105
+ return trimmed;
106
+ let inSingle = false;
107
+ let inDouble = false;
108
+ let escaped = false;
109
+ for (let i = 0; i < trimmed.length; i += 1) {
110
+ const ch = trimmed[i];
111
+ if (escaped) {
112
+ escaped = false;
113
+ continue;
114
+ }
115
+ if ((inSingle || inDouble) && ch === "\\") {
116
+ escaped = true;
117
+ continue;
118
+ }
119
+ if (!inDouble && ch === "'") {
120
+ inSingle = !inSingle;
121
+ continue;
122
+ }
123
+ if (!inSingle && ch === '"') {
124
+ inDouble = !inDouble;
125
+ continue;
126
+ }
127
+ if (!inSingle && !inDouble && ch === "#") {
128
+ return trimmed.slice(0, i).trimEnd();
129
+ }
130
+ }
131
+ // Unclosed quote or no out-of-string `#`: do not invent a comment boundary.
132
+ return trimmed;
133
+ }
134
+ /**
135
+ * Bare `__NNN_*` helper module names imported by a Python source snippet.
136
+ * Only the head token of an `import`/`from` statement is considered a module
137
+ * name; dotted submodules (`__551_manager_helpers.x`) collapse to the head.
138
+ */
139
+ function importedBareHelperModules(source) {
140
+ const names = [];
141
+ const seen = new Set();
142
+ for (const match of normalizeSourceNewlines(source).matchAll(/\b(?:import|from)\s+([A-Za-z0-9_.]+)/g)) {
143
+ const head = match[1].split(".")[0];
144
+ if (/^__\d+[A-Za-z0-9_]*$/.test(head) && !seen.has(head)) {
145
+ seen.add(head);
146
+ names.push(head);
147
+ }
148
+ }
149
+ return names;
150
+ }
151
+ function listPatternOccurrences(source, patternSource, flags = "") {
152
+ const normalized = normalizeSourceNewlines(source);
153
+ const pattern = new RegExp(patternSource, flags.includes("g") ? flags : `${flags}g`);
154
+ const identities = [];
155
+ for (const match of normalized.matchAll(pattern)) {
156
+ const index = match.index ?? 0;
157
+ const lineStart = normalized.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
158
+ const lineEnd = normalized.indexOf("\n", index);
159
+ const line = normalized.slice(lineStart, lineEnd === -1 ? normalized.length : lineEnd).trimEnd();
160
+ identities.push(normalizeHazardOccurrenceIdentity(line));
161
+ }
162
+ return identities;
163
+ }
164
+ function scanRepairHazardOccurrenceLists(source) {
165
+ const lists = {};
166
+ for (const rule of REPAIR_HAZARD_PATTERNS) {
167
+ lists[rule.label] = listPatternOccurrences(source, rule.source, rule.flags);
168
+ }
169
+ return lists;
170
+ }
171
+ function multisetCounts(identities) {
172
+ const counts = new Map();
173
+ for (const identity of identities) {
174
+ counts.set(identity, (counts.get(identity) ?? 0) + 1);
175
+ }
176
+ return counts;
177
+ }
178
+ function hasMultisetIncrement(initialIdentities, finalIdentities) {
179
+ const initial = multisetCounts(initialIdentities);
180
+ const final = multisetCounts(finalIdentities);
181
+ for (const [identity, finalCount] of final) {
182
+ if (finalCount > (initial.get(identity) ?? 0))
183
+ return true;
184
+ }
185
+ return false;
186
+ }
67
187
  function repoRef(workspaceRoot, absolutePath) {
68
188
  return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
69
189
  }
@@ -94,27 +214,43 @@ async function walkPythonFiles(root) {
94
214
  return results;
95
215
  }
96
216
  export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScripts, options = {}) {
217
+ const layout = options.layout ?? resolveBackendTestLayout(undefined);
218
+ const scriptRoot = layout.scriptDir;
97
219
  const normalizedScripts = [...new Set(mappedScripts.map((value) => value.replaceAll("\\", "/")))].sort();
98
220
  if ((options.requireMappedScripts ?? true) && normalizedScripts.length === 0) {
99
221
  throw new Error("backend pytest collection requires mapped scripts");
100
222
  }
223
+ const scriptPattern = new RegExp(`^${scriptRoot.replaceAll(/[.*+?^[\]{}()|$]/g, "\\$&")}(?:/[A-Za-z0-9_.-]+)*/test_[A-Za-z0-9_.-]+\\.py$`);
224
+ const rootScriptPattern = new RegExp(`^${scriptRoot.replaceAll(/[.*+?^[\]{}()|$]/g, "\\$&")}/test_[A-Za-z0-9_.-]+\\.py$`);
101
225
  for (const script of normalizedScripts) {
102
- if (!/^testcase(?:\/[A-Za-z0-9_.-]+)*\/test_[A-Za-z0-9_.-]+\.py$/.test(script) && !/^testcase\/test_[A-Za-z0-9_.-]+\.py$/.test(script)) {
226
+ if (!scriptPattern.test(script) && !rootScriptPattern.test(script)) {
103
227
  throw new Error(`unsafe backend pytest mapped script: ${script}`);
104
228
  }
105
229
  const absolute = path.resolve(workspaceRoot, script);
106
- const testcaseRoot = path.resolve(workspaceRoot, "testcase");
230
+ const testcaseRoot = path.resolve(workspaceRoot, layout.testRoot);
107
231
  if (absolute !== testcaseRoot && !absolute.startsWith(`${testcaseRoot}${path.sep}`)) {
108
- throw new Error(`backend pytest mapped script escapes testcase: ${script}`);
232
+ throw new Error(`backend pytest mapped script escapes test root ${layout.testRoot}: ${script}`);
109
233
  }
110
234
  await readFile(absolute);
111
235
  }
112
- const pythonFiles = await walkPythonFiles(path.resolve(workspaceRoot, "testcase"));
236
+ const pythonFiles = await walkPythonFiles(path.resolve(workspaceRoot, layout.testRoot));
113
237
  const assetFiles = [...new Set(pythonFiles.map((file) => repoRef(workspaceRoot, file)))].sort();
114
238
  const inputHashes = {};
115
- for (const file of assetFiles)
116
- inputHashes[file] = sha256(await readFile(path.resolve(workspaceRoot, file)));
117
- return { mappedScripts: normalizedScripts, assetFiles, inputHashes };
239
+ const hazardOccurrenceCounts = {};
240
+ const hazardOccurrences = {};
241
+ const scriptBareImports = {};
242
+ const mappedScriptSet = new Set(normalizedScripts);
243
+ for (const file of assetFiles) {
244
+ const bytes = await readFile(path.resolve(workspaceRoot, file));
245
+ const source = bytes.toString("utf8");
246
+ inputHashes[file] = sha256(bytes);
247
+ const lists = scanRepairHazardOccurrenceLists(source);
248
+ hazardOccurrences[file] = lists;
249
+ hazardOccurrenceCounts[file] = Object.fromEntries(Object.entries(lists).map(([label, identities]) => [label, identities.length]));
250
+ if (mappedScriptSet.has(file))
251
+ scriptBareImports[file] = importedBareHelperModules(source);
252
+ }
253
+ return { mappedScripts: normalizedScripts, assetFiles, inputHashes, hazardOccurrenceCounts, hazardOccurrences, scriptBareImports };
118
254
  }
119
255
  function bounded(value) {
120
256
  const normalized = value.replaceAll(/(Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+/gi, "$1 [REDACTED]");
@@ -131,13 +267,26 @@ function collectionItems(stdout) {
131
267
  return /^testcase\//.test(line.replaceAll("\\", "/"));
132
268
  }).map((line) => line.replaceAll("\\", "/")))];
133
269
  }
134
- function testcasePythonPaths(output) {
135
- return [...new Set((output.replaceAll("\\", "/").match(/testcase\/[A-Za-z0-9_./-]+\.py/gi) ?? []).map((item) => item.replace(/:\d+$/, "")))].sort();
270
+ function escapeRegExp(value) {
271
+ return value.replace(/[.*+?^[\]{}()|$]/g, "\\$&");
136
272
  }
137
- function classifyFixtureResolutionFailure(output) {
273
+ function testcasePythonPaths(output, layout) {
274
+ const root = (layout ?? resolveBackendTestLayout(undefined)).testRoot;
275
+ const pattern = new RegExp(`(?:${escapeRegExp(root)})/[A-Za-z0-9_./-]+\\.py`, "gi");
276
+ return [...new Set((output.replaceAll("\\", "/").match(pattern) ?? []).map((item) => item.replace(/:\d+$/, "")))].sort();
277
+ }
278
+ function classifyFixtureResolutionFailure(output, layout) {
279
+ const resolvedLayout = layout ?? resolveBackendTestLayout(undefined);
280
+ const rootPrefix = `${resolvedLayout.testRoot}/`;
138
281
  const normalized = output.replaceAll("\\", "/");
139
- const paths = testcasePythonPaths(normalized);
140
- const generatedProviderPaths = paths.filter((item) => /testcase\/(?:helpers|factories)\//.test(item));
282
+ const paths = testcasePythonPaths(normalized, resolvedLayout);
283
+ const generatedProviderPaths = paths.filter((item) => new RegExp(`^${escapeRegExp(rootPrefix)}(?:helpers|factories)/`).test(item) ||
284
+ new RegExp(`^${escapeRegExp(resolvedLayout.scriptDir)}/(?:helpers|factories)/`).test(item));
285
+ const generatedTestModulePaths = paths.filter((item) => new RegExp(`^${escapeRegExp(resolvedLayout.scriptDir)}(?:/[A-Za-z0-9_./-]+)?/test_[A-Za-z0-9_.-]+\\.py$`, "i").test(item) ||
286
+ new RegExp(`^${escapeRegExp(rootPrefix)}test_[A-Za-z0-9_.-]+\\.py$`, "i").test(item));
287
+ const onlyGeneratedTestcasePaths = paths.length > 0 && paths.every((item) => item.startsWith(rootPrefix));
288
+ const mentionsProduction = /(?:apps|src|server)\/[A-Za-z0-9_./-]+\.py/i.test(normalized) ||
289
+ new RegExp(`No module named ['\"](?!${escapeRegExp(resolvedLayout.testRoot)}(?:\\.|['\"]))`, "i").test(normalized);
141
290
  if (/fixture ['"][^'"]+['"] not found/i.test(normalized) && generatedProviderPaths.length > 0) {
142
291
  return {
143
292
  status: "REPAIRABLE",
@@ -146,6 +295,33 @@ function classifyFixtureResolutionFailure(output) {
146
295
  repairPaths: generatedProviderPaths,
147
296
  };
148
297
  }
298
+ // ScopeMismatch and other setup failures fully attributed to generated test modules are
299
+ // writer-local and may be fixed by one bounded repair (align scopes / define fixtures).
300
+ if (!mentionsProduction &&
301
+ onlyGeneratedTestcasePaths &&
302
+ generatedTestModulePaths.length > 0 &&
303
+ /ScopeMismatch|scope mismatch|function scoped fixture|module scoped request object|module scoped fixture|ERROR at setup of/i.test(normalized)) {
304
+ const isScope = /ScopeMismatch|scope mismatch|function scoped fixture|module scoped request object|module scoped fixture/i.test(normalized);
305
+ return {
306
+ status: "REPAIRABLE",
307
+ kind: isScope ? "generated-fixture-scope-mismatch" : "generated-fixture-setup-defect",
308
+ detail: isScope
309
+ ? "generated pytest fixture scopes are inconsistent inside mapped test modules"
310
+ : "generated pytest fixture setup failed inside mapped test modules",
311
+ repairPaths: [...new Set([...generatedTestModulePaths, ...generatedProviderPaths])].sort(),
312
+ };
313
+ }
314
+ if (!mentionsProduction &&
315
+ onlyGeneratedTestcasePaths &&
316
+ generatedTestModulePaths.length > 0 &&
317
+ /fixture ['"][^'"]+['"] not found/i.test(normalized)) {
318
+ return {
319
+ status: "REPAIRABLE",
320
+ kind: "missing-generated-fixture",
321
+ detail: "mapped pytest module requests a fixture not defined in generated testcase assets",
322
+ repairPaths: [...new Set([...generatedTestModulePaths, ...generatedProviderPaths])].sort(),
323
+ };
324
+ }
149
325
  return {
150
326
  status: "BLOCKED",
151
327
  kind: "unresolved-fixture-dependency",
@@ -153,8 +329,68 @@ function classifyFixtureResolutionFailure(output) {
153
329
  repairPaths: paths,
154
330
  };
155
331
  }
156
- function classifyCollectionFailure(output) {
332
+ /**
333
+ * Generated-local bare helper module signatures found in the bound pytest
334
+ * asset inventory. A file such as `testcase/__551_manager_helpers.py` in the
335
+ * test root/script dir is generated-local: when collection reports
336
+ * `No module named '__551_manager_helpers'`, the defect can be precisely
337
+ * attributed to the generated mapped script instead of a missing third-party
338
+ * package.
339
+ */
340
+ function generatedLocalBareHelperNames(inventory) {
341
+ if (!inventory)
342
+ return [];
343
+ const names = [];
344
+ for (const file of inventory.assetFiles) {
345
+ const stem = path.posix.basename(file).replace(/\.py$/, "");
346
+ if (/^__\d+[A-Za-z0-9_]*$/.test(stem))
347
+ names.push(stem);
348
+ }
349
+ return [...new Set(names)];
350
+ }
351
+ function classifyCollectionFailure(output, evidence) {
157
352
  const normalized = output.replaceAll("\\", "/");
353
+ const helperNames = generatedLocalBareHelperNames(evidence?.inventory);
354
+ const helperByLower = new Map(helperNames.map((name) => [name.toLowerCase(), name]));
355
+ // A bare `__NNN_...` module import that collection cannot find is only a
356
+ // candidate generated-local helper signature. REPAIRABLE requires two
357
+ // deterministic pieces of evidence: (1) a helper asset of the same name in
358
+ // the bound inventory, and (2) at least one mapped generated script whose
359
+ // source truly imports that bare module. Without both — or when attribution
360
+ // is impossible — fail closed as BLOCKED so an un-attributable dependency
361
+ // error is never widened into a repair. Genuine third-party modules (no
362
+ // `__NNN_` signature) stay BLOCKED below.
363
+ const missingModule = /No module named ['\"]([^'\"]+)['\"]/i.exec(normalized)?.[1];
364
+ if (missingModule && /^__\d+[A-Za-z0-9_]*$/.test(missingModule)) {
365
+ const helperAsset = helperByLower.get(missingModule.toLowerCase());
366
+ const inventory = evidence?.inventory;
367
+ const scriptBareImports = inventory?.scriptBareImports ?? {};
368
+ const missingLower = missingModule.toLowerCase();
369
+ const importedByMappedScript = inventory
370
+ ? inventory.mappedScripts.some((script) => (scriptBareImports[script] ?? []).some((name) => name.toLowerCase() === missingLower))
371
+ : false;
372
+ if (helperAsset && importedByMappedScript && inventory) {
373
+ const repairPaths = [...new Set([
374
+ ...(helperAsset
375
+ ? inventory.assetFiles.filter((file) => path.posix.basename(file).replace(/\.py$/, "") === helperAsset)
376
+ : []),
377
+ ...inventory.mappedScripts,
378
+ ])].sort();
379
+ return {
380
+ status: "REPAIRABLE",
381
+ kind: "missing-generated-local-helper",
382
+ detail: `generated testcase asset imports missing bare generated-local helper module ${missingModule}; inline the helper or remove the generated shared dependency`,
383
+ repairPaths,
384
+ };
385
+ }
386
+ return {
387
+ status: "BLOCKED",
388
+ kind: "missing-generated-local-helper-without-evidence",
389
+ detail: helperAsset
390
+ ? `missing bare generated-local helper module ${missingModule} has a name match but no mapped generated script imports it (no precise generated-local attribution)`
391
+ : `missing bare __NNN_* module ${missingModule} has no generated-local helper asset in the bound inventory (cannot be precisely attributed)`,
392
+ };
393
+ }
158
394
  const blockedPatterns = [
159
395
  [/No module named ['\"](?!testcase(?:\.|['\"]))/i, "missing-third-party-module"],
160
396
  [/pytest(?:_|-)plugin|pluggy|unknown hook|unrecognized arguments/i, "pytest-plugin-or-config"],
@@ -199,6 +435,8 @@ export function assessBackendPytestCollection(input) {
199
435
  missingMappedScripts: [],
200
436
  assetFiles: input.inventory.assetFiles,
201
437
  inputHashes: input.inventory.inputHashes,
438
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
439
+ hazardOccurrences: input.inventory.hazardOccurrences,
202
440
  pytestExitCode: input.exitCode,
203
441
  collectedItemCount: items.length,
204
442
  collectedItemIds: items,
@@ -227,6 +465,8 @@ export function assessBackendPytestCollection(input) {
227
465
  missingMappedScripts: [],
228
466
  assetFiles: input.inventory.assetFiles,
229
467
  inputHashes: input.inventory.inputHashes,
468
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
469
+ hazardOccurrences: input.inventory.hazardOccurrences,
230
470
  pytestExitCode: input.exitCode,
231
471
  collectedItemCount: items.length,
232
472
  collectedItemIds: items,
@@ -242,7 +482,7 @@ export function assessBackendPytestCollection(input) {
242
482
  fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
243
483
  });
244
484
  }
245
- const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`);
485
+ const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`, { layout: input.layout, inventory: input.inventory });
246
486
  return backendPytestCollectionFactsSchema.parse({
247
487
  schemaId: "backend-test-pytest-collection-v3",
248
488
  phase: input.phase,
@@ -253,12 +493,16 @@ export function assessBackendPytestCollection(input) {
253
493
  fixtureResolutionAttempted: false,
254
494
  fixtureResolutionStatus: "NOT_RUN",
255
495
  fixtureResolutionExitCode: null,
256
- repairPaths: testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
496
+ repairPaths: classification.repairPaths && classification.repairPaths.length > 0
497
+ ? classification.repairPaths
498
+ : testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
257
499
  mappedScripts: input.inventory.mappedScripts,
258
500
  existingMappedScripts: input.inventory.mappedScripts,
259
501
  missingMappedScripts: [],
260
502
  assetFiles: input.inventory.assetFiles,
261
503
  inputHashes: input.inventory.inputHashes,
504
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
505
+ hazardOccurrences: input.inventory.hazardOccurrences,
262
506
  pytestExitCode: input.exitCode,
263
507
  collectedItemCount: items.length,
264
508
  collectedItemIds: items,
@@ -294,6 +538,8 @@ export function assessPriorityOnlyBackendPytestModules(input) {
294
538
  missingMappedScripts: [],
295
539
  assetFiles: input.inventory.assetFiles,
296
540
  inputHashes: input.inventory.inputHashes,
541
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
542
+ hazardOccurrences: input.inventory.hazardOccurrences,
297
543
  pytestExitCode: null,
298
544
  collectedItemCount: 0,
299
545
  collectedItemIds: [],
@@ -326,6 +572,8 @@ export function assessMissingBackendPytestScripts(input) {
326
572
  missingMappedScripts: [...input.missingMappedScripts],
327
573
  assetFiles: [...input.assetFiles],
328
574
  inputHashes: input.inputHashes,
575
+ hazardOccurrenceCounts: input.hazardOccurrenceCounts ?? Object.fromEntries(input.assetFiles.map((file) => [file, {}])),
576
+ hazardOccurrences: input.hazardOccurrences ?? Object.fromEntries(input.assetFiles.map((file) => [file, {}])),
329
577
  pytestExitCode: null,
330
578
  collectedItemCount: 0,
331
579
  collectedItemIds: [],
@@ -415,17 +663,21 @@ async function assertBackendPytestRepairSafety(workspaceRoot, initial, final) {
415
663
  if (JSON.stringify(initial.mappedScripts) !== JSON.stringify(final.mappedScripts)) {
416
664
  throw new Error("backend pytest repair changed mapped script scope");
417
665
  }
418
- const forbidden = [
419
- [/pytest\.mark\.(?:skip|skipif|xfail)\b|pytest\.(?:skip|xfail)\s*\(/, "skip/xfail"],
420
- [/sys\.path\s*\.|PYTHONPATH/, "sys.path/PYTHONPATH mutation"],
421
- [/except\s+ImportError\b/, "ImportError fallback"],
422
- [/except\s+(?:Exception|BaseException)\b[\s\S]{0,160}?\bpass\b/, "broad exception swallowing"],
423
- ];
424
666
  for (const file of final.assetFiles) {
425
667
  const source = await readFile(path.resolve(workspaceRoot, file), "utf8");
426
- for (const [pattern, label] of forbidden) {
427
- if (pattern.test(source))
428
- throw new Error(`backend pytest repair safety blocked ${label}: ${file}`);
668
+ const liveHash = sha256(source);
669
+ if (final.inputHashes[file] && final.inputHashes[file] !== liveHash) {
670
+ throw new Error(`backend pytest collection hash drift: ${file}`);
671
+ }
672
+ if (initial.inputHashes[file] === final.inputHashes[file])
673
+ continue;
674
+ const finalLists = scanRepairHazardOccurrenceLists(source);
675
+ for (const rule of REPAIR_HAZARD_PATTERNS) {
676
+ const baseline = initial.hazardOccurrences?.[file]?.[rule.label] ?? [];
677
+ const finalIdentities = finalLists[rule.label] ?? [];
678
+ if (hasMultisetIncrement(baseline, finalIdentities)) {
679
+ throw new Error(`backend pytest repair safety blocked ${rule.label}: ${file}`);
680
+ }
429
681
  }
430
682
  }
431
683
  }
@@ -439,23 +691,65 @@ function assertSameInventory(expected, actual) {
439
691
  throw new Error(`backend pytest collection hash drift: ${file}`);
440
692
  }
441
693
  }
694
+ export function buildBackendTestItemEligibility(collectedItemIds, input) {
695
+ const eligibleItemIds = [];
696
+ const excludedItems = [];
697
+ for (const itemId of collectedItemIds) {
698
+ const segments = itemId.split("::");
699
+ const symbolSegment = [...segments].reverse().find((segment) => /^test_/.test(segment));
700
+ const symbol = symbolSegment?.replace(/\[.*$/, "");
701
+ const parameterTokens = orderedUnique((itemId.match(/TP-[A-Z0-9-]+/g) ?? []));
702
+ const mapping = input.correspondenceEntries.find((entry) => symbol && (entry.declaredPrimarySymbol === symbol || entry.pytestSymbols.includes(symbol)));
703
+ const reasons = [];
704
+ if (!mapping)
705
+ reasons.push("no exact Markdown Case/primary-symbol correspondence");
706
+ if (mapping && mapping.status !== "EXACT_1_TO_1")
707
+ reasons.push(`correspondence status is ${mapping.status}`);
708
+ if (mapping && mapping.payloadAssessment.status !== "SAFE")
709
+ reasons.push(`payload contract status is ${mapping.payloadAssessment.status}`);
710
+ const scenarioEntries = mapping?.caseId ? input.scenarioEntries.filter((entry) => entry.caseId === mapping.caseId) : [];
711
+ const relevantScenarioEntries = parameterTokens.length > 0
712
+ ? scenarioEntries.filter((entry) => parameterTokens.includes(entry.tpId))
713
+ : scenarioEntries;
714
+ const payloadPaths = orderedUnique([
715
+ ...(mapping?.payloadAssessment.requiredPaths ?? []),
716
+ ...(mapping?.payloadAssessment.allowedPaths ?? []),
717
+ ...(mapping?.payloadAssessment.observedPaths ?? []),
718
+ ]).map((item) => item.toLowerCase());
719
+ for (const entry of relevantScenarioEntries) {
720
+ // Field names heuristically inferred only from a TP token (for example
721
+ // TP-HEALTH-HTTP-200 -> hEALTH) are not execution-safety evidence. Exclude
722
+ // only when the field is also present in the deterministic payload contract
723
+ // or observed Python payload shape.
724
+ const normalizedField = entry.field?.toLowerCase();
725
+ const fieldIsPayloadBound = Boolean(normalizedField && payloadPaths.some((item) => item === normalizedField || item.endsWith(`.${normalizedField}`)));
726
+ if (fieldIsPayloadBound && entry.status !== "MATCH")
727
+ reasons.push(`scenario-param ${entry.tpId} field ${entry.field} is ${entry.status}`);
728
+ }
729
+ if (reasons.length === 0)
730
+ eligibleItemIds.push(itemId);
731
+ else
732
+ excludedItems.push({ itemId, ...(mapping?.caseId ? { caseId: mapping.caseId } : {}), ...(symbol ? { symbol } : {}), reasons: orderedUnique(reasons) });
733
+ }
734
+ return { eligibleItemIds, excludedItems };
735
+ }
736
+ function orderedUnique(values) {
737
+ return [...new Set(values)];
738
+ }
442
739
  export async function materializeBackendTestExecutionReadiness(input) {
443
740
  if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
444
741
  throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
445
742
  }
446
743
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
447
- const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
448
- if (priorityOnly.length > 0) {
449
- throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
450
- }
451
744
  assertSameInventory(input.effective, current);
452
- const status = input.scenarioParamStatus === "FAIL"
745
+ const eligibility = input.eligibility ?? { eligibleItemIds: input.effective.collectedItemIds, excludedItems: [] };
746
+ const status = eligibility.eligibleItemIds.length === 0
453
747
  ? "BLOCKED"
454
- : input.scenarioParamStatus === "PASS"
748
+ : input.scenarioParamStatus === "PASS" && eligibility.excludedItems.length === 0
455
749
  ? "PASS"
456
750
  : "PARTIAL";
457
751
  const readiness = backendTestExecutionReadinessSchema.parse({
458
- schemaId: "backend-test-execution-readiness-v1",
752
+ schemaId: "backend-test-execution-readiness-v2",
459
753
  status,
460
754
  collectionStatus: "PASS",
461
755
  fixtureResolutionStatus: "PASS",
@@ -466,8 +760,11 @@ export async function materializeBackendTestExecutionReadiness(input) {
466
760
  },
467
761
  mappedScripts: input.effective.mappedScripts,
468
762
  collectedItemIds: input.effective.collectedItemIds,
763
+ eligibleItemIds: eligibility.eligibleItemIds,
764
+ excludedItems: eligibility.excludedItems,
469
765
  fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
470
766
  assetHashes: input.effective.inputHashes,
767
+ eligibilityInputHashes: input.eligibilityInputHashes ?? {},
471
768
  });
472
769
  const contractsDir = path.join(input.runDir, "contracts");
473
770
  await mkdir(contractsDir, { recursive: true });
@@ -477,27 +774,145 @@ export async function materializeBackendTestExecutionReadiness(input) {
477
774
  export async function readBackendTestExecutionReadiness(filePath) {
478
775
  return backendTestExecutionReadinessSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
479
776
  }
777
+ function formatReadinessCounts(readiness) {
778
+ return `mapped=${readiness.mappedScripts.length} collected=${readiness.collectedItemIds.length} eligible=${readiness.eligibleItemIds.length} excluded=${readiness.excludedItems.length}`;
779
+ }
780
+ export function classifyExclusionReason(reason) {
781
+ if (reason === "no exact Markdown Case/primary-symbol correspondence")
782
+ return "correspondence-missing";
783
+ if (/^correspondence status is\b/.test(reason))
784
+ return "correspondence-status";
785
+ if (/^payload contract status is\b/.test(reason))
786
+ return "payload-contract-status";
787
+ if (/^scenario-param\b.*\bfield\b/.test(reason))
788
+ return "scenario-param-field";
789
+ return "other";
790
+ }
791
+ /** Fixed-category tally for readiness Error messages — never echoes raw reason text. */
792
+ function tallyExclusionReasonCategories(excludedItems) {
793
+ const counts = new Map();
794
+ for (const item of excludedItems) {
795
+ for (const reason of item.reasons) {
796
+ const category = classifyExclusionReason(reason);
797
+ counts.set(category, (counts.get(category) ?? 0) + 1);
798
+ }
799
+ }
800
+ const parts = READINESS_EXCLUSION_CATEGORIES
801
+ .filter((category) => (counts.get(category) ?? 0) > 0)
802
+ .map((category) => `${category}=${counts.get(category)}`);
803
+ return parts.length > 0 ? parts.join("; ") : "none";
804
+ }
805
+ const READINESS_ERROR_TRUNCATION_SUFFIX = "...[truncated]";
806
+ export function boundReadinessErrorMessage(message) {
807
+ if (message.length <= MAX_READINESS_ERROR_CHARS)
808
+ return message;
809
+ // Truncate to leave room for the suffix so the final message never exceeds
810
+ // MAX_READINESS_ERROR_CHARS. Never append beyond the declared limit.
811
+ const limit = MAX_READINESS_ERROR_CHARS - READINESS_ERROR_TRUNCATION_SUFFIX.length;
812
+ return `${message.slice(0, Math.max(0, limit))}${READINESS_ERROR_TRUNCATION_SUFFIX}`;
813
+ }
814
+ function throwReadinessError(message) {
815
+ throw new Error(boundReadinessErrorMessage(message));
816
+ }
817
+ function firstInventoryDeltaPath(expectedHashes, actualHashes) {
818
+ const expectedFiles = Object.keys(expectedHashes).sort();
819
+ const actualFiles = Object.keys(actualHashes).sort();
820
+ const actualSet = new Set(actualFiles);
821
+ const expectedSet = new Set(expectedFiles);
822
+ const onlyExpected = expectedFiles.find((file) => !actualSet.has(file));
823
+ if (onlyExpected)
824
+ return onlyExpected;
825
+ return actualFiles.find((file) => !expectedSet.has(file));
826
+ }
827
+ async function firstUnreadableAssetPath(workspaceRoot, readiness) {
828
+ const candidatePaths = [...new Set([
829
+ ...Object.keys(readiness.assetHashes),
830
+ ...readiness.mappedScripts,
831
+ ])].sort();
832
+ for (const relativePath of candidatePaths) {
833
+ try {
834
+ await readFile(path.resolve(workspaceRoot, relativePath));
835
+ }
836
+ catch {
837
+ return relativePath;
838
+ }
839
+ }
840
+ return undefined;
841
+ }
480
842
  export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
481
843
  if (!["PASS", "PARTIAL"].includes(readiness.status)) {
482
- throw new Error(`backend pytest execution readiness is ${readiness.status}`);
844
+ const counts = formatReadinessCounts(readiness);
845
+ const reasonTally = readiness.eligibleItemIds.length === 0 && readiness.excludedItems.length > 0
846
+ ? `; exclusionReasons: ${tallyExclusionReasonCategories(readiness.excludedItems)}`
847
+ : "";
848
+ throwReadinessError(`backend pytest execution readiness is ${readiness.status}: ${counts}${reasonTally}`);
849
+ }
850
+ if (readiness.eligibleItemIds.length === 0) {
851
+ throwReadinessError(`backend pytest execution readiness authorizes zero eligible items: ${tallyExclusionReasonCategories(readiness.excludedItems)}`);
852
+ }
853
+ const missingEligibleCount = readiness.eligibleItemIds.filter((itemId) => !readiness.collectedItemIds.includes(itemId)).length;
854
+ if (missingEligibleCount > 0) {
855
+ // Do not replay raw pytest item IDs: they may contain TP-*, field, payload,
856
+ // secret-like or parameter markers. Report only the fixed category + count.
857
+ throwReadinessError(`backend pytest readiness has ${missingEligibleCount} eligible item(s) absent from collected items: ${formatReadinessCounts(readiness)}`);
858
+ }
859
+ const unreadableAsset = await firstUnreadableAssetPath(workspaceRoot, readiness);
860
+ if (unreadableAsset) {
861
+ throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${unreadableAsset}`);
862
+ }
863
+ let current;
864
+ try {
865
+ current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
866
+ }
867
+ catch {
868
+ const fallbackPath = [...readiness.mappedScripts].sort()[0] ?? Object.keys(readiness.assetHashes).sort()[0] ?? "unknown";
869
+ throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${fallbackPath}`);
483
870
  }
484
- const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
485
871
  const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
486
872
  if (priorityOnly.length > 0) {
487
- throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
873
+ throwReadinessError(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
874
+ }
875
+ const expectedHashes = readiness.assetHashes;
876
+ const inventoryDelta = firstInventoryDeltaPath(expectedHashes, current.inputHashes);
877
+ if (inventoryDelta) {
878
+ throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${inventoryDelta}`);
879
+ }
880
+ for (const file of Object.keys(expectedHashes).sort()) {
881
+ if (expectedHashes[file] !== current.inputHashes[file]) {
882
+ throwReadinessError(`backend pytest collection hash drift: ${file}`);
883
+ }
884
+ }
885
+ for (const relativePath of Object.keys(readiness.eligibilityInputHashes).sort()) {
886
+ const expectedHash = readiness.eligibilityInputHashes[relativePath];
887
+ const absolute = path.resolve(workspaceRoot, relativePath);
888
+ let actualHash;
889
+ try {
890
+ actualHash = sha256(await readFile(absolute));
891
+ }
892
+ catch {
893
+ throwReadinessError(`backend pytest eligibility input hash drift: firstPath=${relativePath}`);
894
+ }
895
+ if (actualHash !== expectedHash) {
896
+ throwReadinessError(`backend pytest eligibility input hash drift: ${relativePath}`);
897
+ }
488
898
  }
489
- assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
490
899
  }
491
900
  export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
492
901
  if (effective.phase !== "effective" || effective.status !== "PASS") {
493
902
  throw new Error("backend pytest execution requires effective collection PASS facts");
494
903
  }
495
904
  const current = await buildBackendPytestAssetInventory(workspaceRoot, effective.mappedScripts);
905
+ const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
906
+ if (priorityOnly.length > 0)
907
+ throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
496
908
  assertSameInventory(effective, current);
497
909
  }
498
910
  export async function materializeEffectiveBackendPytestCollection(input) {
499
911
  if (input.initial.status === "PASS") {
500
912
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.initial.mappedScripts);
913
+ const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
914
+ if (priorityOnly.length > 0)
915
+ throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
501
916
  assertSameInventory(input.initial, current);
502
917
  return backendPytestCollectionFactsSchema.parse({
503
918
  ...input.initial,
@@ -513,6 +928,9 @@ export async function materializeEffectiveBackendPytestCollection(input) {
513
928
  throw new Error("backend pytest collection still fails after bounded repair");
514
929
  await assertBackendPytestRepairSafety(input.workspaceRoot, input.initial, input.final);
515
930
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.final.mappedScripts);
931
+ const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
932
+ if (priorityOnly.length > 0)
933
+ throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
516
934
  assertSameInventory(input.final, current);
517
935
  return backendPytestCollectionFactsSchema.parse({
518
936
  ...input.final,