@tea-agent/loop-agent 0.38.1 → 0.38.3

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 (180) hide show
  1. package/CHANGELOG.md +126 -12
  2. package/README.md +2 -0
  3. package/bin/loop-agent.js +7 -3
  4. package/dist/application/dag/generate-task-dag.js +9 -0
  5. package/dist/application/task-lifecycle/advance.js +4 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/commands/init.js +2 -14
  8. package/dist/commands/task-advance.js +31 -0
  9. package/dist/executors/dag-pi-executor.js +99 -5
  10. package/dist/executors/pi-event-serializer.js +42 -1
  11. package/dist/executors/pi-executor.js +51 -0
  12. package/dist/executors/pi-extension-resolver.js +233 -0
  13. package/dist/executors/pi-sdk-executor.js +187 -59
  14. package/dist/executors/shell-executor.js +324 -16
  15. package/dist/executors/shell-write-guard.js +7 -0
  16. package/dist/governance/manifest-types.js +0 -2
  17. package/dist/shared/operator/capabilities.js +87 -22
  18. package/dist/task/config-types.js +22 -0
  19. package/dist/task/contract/constants.js +1 -0
  20. package/dist/task/contract/diff.js +26 -0
  21. package/dist/task/contract/project.js +5 -0
  22. package/dist/task/contract/schema.js +6 -1
  23. package/dist/task/source-prepare/build-draft.js +15 -0
  24. package/dist/worker/console/chat/deferred-turn.js +91 -0
  25. package/dist/worker/console/chat/pi-runtime.js +10 -5
  26. package/dist/worker/console/chat/routes.js +458 -47
  27. package/dist/worker/console/chat/semantic-activity.js +13 -13
  28. package/dist/worker/console/chat/session-store.js +146 -2
  29. package/dist/worker/console/harness-pi-model-matrix.js +2 -11
  30. package/dist/worker/console/operator-actions.js +5 -4
  31. package/dist/worker/console/pi-readiness.js +5 -4
  32. package/dist/worker/console/recovery-cta.js +4 -4
  33. package/dist/worker/console/recovery-error-copy.js +4 -4
  34. package/dist/worker/console/server.js +6 -1
  35. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Hfsot6rP.js → abnfDiagram-N423BO3Z-C4kVYweA.js} +1 -1
  36. package/dist/worker/console/static/assets/{arc-YGwEASin.js → arc-Bj2M8iO5.js} +1 -1
  37. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CmOM0F92.js → architectureDiagram-T3A2C74G-CLlXe4-6.js} +1 -1
  38. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DDLuaHC5.js → blockDiagram-VBNYF7ZC-psEp5xH0.js} +1 -1
  39. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B0Um43W4.js → c4Diagram-5PPSVZJV-DeKcOCGJ.js} +1 -1
  40. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +1 -0
  41. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DvBN4JZc.js → chunk-2GRJ4B5K-K0C744p1.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B1UVgW4E.js → chunk-2Q5K7J3B-xbNw4J9-.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Cf0GgXzX.js → chunk-5RXB4S5H-DjLaSDUO.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DbNA24sj.js → chunk-5VM5RSS4-CtiPlKel.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bi_0Be06.js → chunk-6Q2QTUOP-DZtolir7.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C8pPGvRd.js → chunk-GF5L2VYU-CPcdNeew.js} +1 -1
  47. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BitQY8Z-.js → chunk-JWPE2WC7-NgHc6V37.js} +1 -1
  48. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-U67GCiEo.js → chunk-KBJHAD2P-DQ_T-hg8.js} +1 -1
  49. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-IoJwXPBA.js → chunk-RYQCIY6F-DgLsxcVP.js} +1 -1
  50. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-YDZ_UHgE.js → chunk-XXDRQBXY-UrVoM9z1.js} +1 -1
  51. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +1 -0
  52. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +1 -0
  53. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CUZgsiRC.js → cose-bilkent-JH36ORCC-CLkYvTb3.js} +1 -1
  54. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-COmCh4pT.js → cynefin-VYW2F7L2-BwT_xKrE.js} +1 -1
  55. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Ta6FSm68.js → cynefinDiagram-MW4NZA55-Bi9tuzif.js} +1 -1
  56. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UdcR-VW9.js → dagre-VZM6K2ZE-BCIqkWBV.js} +1 -1
  57. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DybcO3Et.js → diagram-7IWD3JNH-Dip6l9_Z.js} +1 -1
  58. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-wErDFWLw.js → diagram-B4RE2ZJO-B-xkh_wK.js} +1 -1
  59. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DkVHZWqz.js → diagram-LBJQPF4R-DZO0kTt_.js} +1 -1
  60. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DdqWs7QN.js → diagram-Q27KOJAE-D6ZplNbZ.js} +1 -1
  61. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C9dMVmDo.js → diagram-UB23O5K3-BPekbhoS.js} +1 -1
  62. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B2T2WcBK.js → ebnfDiagram-BXEA7PRR-BtmaJpK5.js} +1 -1
  63. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt9rjDFq.js → erDiagram-JOGREHBK-6DS0Xc44.js} +1 -1
  64. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-U8ic_tvi.js → flowDiagram-UKHOOZJN-CihnROSm.js} +1 -1
  65. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BfQJdQoO.js → ganttDiagram-PKOTCBZU-CZC4zOE2.js} +1 -1
  66. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CpMftH4O.js → gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js} +1 -1
  67. package/dist/worker/console/static/assets/index-DTZOKgAn.js +404 -0
  68. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +1 -0
  69. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DzqWXNO_.js → infoDiagram-6WML65LV-DlSl4BGx.js} +1 -1
  70. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js → ishikawaDiagram-WSZJBQD7-snnzFl_U.js} +1 -1
  71. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ChPtKFLz.js → journeyDiagram-NVQOT4AX-BDE7qpMx.js} +1 -1
  72. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PRe7wB5u.js → kanban-definition-27J2QSJJ-CD2Ci04G.js} +1 -1
  73. package/dist/worker/console/static/assets/{linear-DpKX6QNp.js → linear-DdnapKIH.js} +1 -1
  74. package/dist/worker/console/static/assets/{mermaid.core-Dnl76b7Y.js → mermaid.core-BVjAT9b8.js} +5 -5
  75. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-S9PCXc3J.js → mindmap-definition-FAOFIHXS-D0yJk-zA.js} +1 -1
  76. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Du8e-FSX.js → pegDiagram-VL7TDLO6-BVo9tFP-.js} +1 -1
  77. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CY6OmHTc.js → pieDiagram-7S7Q4E2Y-DnOV-mZ_.js} +1 -1
  78. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C65GPhpj.js → quadrantDiagram-CIZ2JOQS-CzUJo58i.js} +1 -1
  79. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-U6IUiTCL.js → railroadDiagram-AXF67PYL-Bvikrolh.js} +1 -1
  80. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-I_9Qhi.js → requirementDiagram-LRYGKXZP-DtaSVCap.js} +1 -1
  81. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B57Fhed0.js → sankeyDiagram-W5VNT64P-C2c9A0wW.js} +1 -1
  82. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CJOtiw21.js → sequenceDiagram-SI44F4Z6-DmXJcU7r.js} +1 -1
  83. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-FyX8zxSh.js → sizeCapture-X5ZJPWSS-B4GUFW92.js} +1 -1
  84. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eF-pbs9t.js → stateDiagram-OKZ733FA-Dsad2MXf.js} +1 -1
  85. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +1 -0
  86. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CcfkA-Dq.js → swimlanes-SLNWSIFB-DGq48Fbi.js} +2 -2
  87. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +8 -0
  88. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DH8FjnOK.js → timeline-definition-Z64GVDOM-CSSi6OFf.js} +1 -1
  89. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-beoTagJg.js → vennDiagram-T6HMQDX7-BqyzPrWv.js} +1 -1
  90. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CQSfw4cK.js → wardleyDiagram-T6FBY63Y-DlznVb6S.js} +1 -1
  91. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQgzQ6jn.js → xychartDiagram-ELKLHX3M-CfBcgJ_K.js} +1 -1
  92. package/dist/worker/console/static/index.html +2 -2
  93. package/dist/worker/console/static-src/app/console-types.js +0 -12
  94. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  95. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  96. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +38 -1
  97. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  98. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  99. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  100. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  101. package/dist/worker/console/static-src/operator-chat/useChatStream.js +204 -11
  102. package/dist/worker/console/static-src/operator-chat/useChatThread.js +23 -1
  103. package/dist/worker/observe/node-input.js +72 -3
  104. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  105. package/dist/worker/observe/static/state.js +2 -2
  106. package/dist/worker/observe/static/views/dag-inspector.js +51 -0
  107. package/dist/worker/observe/static/views/session-timeline.js +18 -5
  108. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +809 -53
  109. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  110. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  111. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  112. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  113. package/dist/workflows/dag/backend-test-layout.js +133 -0
  114. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  115. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  116. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  117. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  118. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  119. package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
  120. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  121. package/dist/workflows/dag/contract-output-registry.js +15 -0
  122. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  123. package/dist/workflows/dag/convergence/controller.js +141 -0
  124. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  125. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  126. package/dist/workflows/dag/failure-category.js +4 -0
  127. package/dist/workflows/dag/frontend-implementation-contract.js +355 -26
  128. package/dist/workflows/dag/frontend-prewrite-gate.js +96 -84
  129. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  130. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  131. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  132. package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
  133. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  134. package/dist/workflows/dag/init-hybrid.js +720 -177
  135. package/dist/workflows/dag/node-execution.js +164 -26
  136. package/dist/workflows/dag/prompt.js +60 -1
  137. package/dist/workflows/dag/rerun-feedback.js +212 -0
  138. package/dist/workflows/dag/rerun-plan.js +112 -27
  139. package/dist/workflows/dag/rerun-run.js +3 -0
  140. package/dist/workflows/dag/rerun-task.js +61 -2
  141. package/dist/workflows/dag/retry-policy.js +18 -0
  142. package/dist/workflows/dag/structured-output-repair.js +712 -0
  143. package/dist/workflows/dag/types.js +121 -0
  144. package/dist/workflows/dag/validate.js +41 -1
  145. package/docs/operations/local-development-environment.md +1 -5
  146. package/docs/skills/vetted-skill-registry.md +14 -0
  147. package/docs/templates/README.md +1 -1
  148. package/docs/templates/agent-dag.schema.json +44 -0
  149. package/docs/templates/backend-test-dag.json +60 -35
  150. package/docs/templates/frontend-test-dag.json +8 -10
  151. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  152. package/docs/templates/harness.schema.json +0 -8
  153. package/harness.json +1 -8
  154. package/package.json +8 -4
  155. package/skills/analyze-product-requirements/SKILL.md +8 -5
  156. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  157. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  158. package/skills/analyze-product-requirements/references/example.md +50 -0
  159. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  160. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  161. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  162. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  163. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  164. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  165. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  166. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  167. package/skills/codebase-scout/SKILL.md +1 -1
  168. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  169. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  170. package/skills/improve-codebase-architecture/deepening.md +37 -0
  171. package/skills/improve-codebase-architecture/html-report.md +123 -0
  172. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  173. package/skills/improve-codebase-architecture/language.md +53 -0
  174. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +0 -1
  175. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +0 -1
  176. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +0 -1
  177. package/dist/worker/console/static/assets/index-BA5-N5ER.css +0 -1
  178. package/dist/worker/console/static/assets/index-DKPXBrdc.js +0 -325
  179. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +0 -1
  180. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +0 -8
@@ -0,0 +1,321 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Plan C: deterministic query/filter axis scenario partitions.
4
+ *
5
+ * A README `## Scenario Partitions` table declares one partition per
6
+ * filterable enum/classification axis on an affected list/query operation.
7
+ * The deterministic expander turns each declared partition into machine
8
+ * slots — the model never invents slot IDs:
9
+ *
10
+ * each-value one slot per legal domain value (intent=nominal-filter)
11
+ * omitted only when the contract marks the parameter optional
12
+ * not-in-set exactly one complement slot (intent=enum-invalid)
13
+ *
14
+ * `not-in-set` expected semantics must come from the bound source; when the
15
+ * source does not document the complement expectation, the partition row
16
+ * keeps the slot but the expected column stays GAP so N6 reports it instead
17
+ * of guessing "empty list" or "400".
18
+ */
19
+ export const scenarioPartitionRowSchema = z.object({
20
+ partitionId: z.string().min(1),
21
+ operation: z.string().min(1),
22
+ axis: z.string().min(1),
23
+ /** Legal values copied verbatim from the bound OpenAPI enum / requirement sentence. */
24
+ domain: z.array(z.string().min(1)).min(1),
25
+ /** true when the parameter is optional and the omitted slot is required. */
26
+ optional: z.boolean(),
27
+ /**
28
+ * Complement expectation sourced from the contract. `unknown` keeps the
29
+ * not-in-set slot but marks expected as GAP (never guessed).
30
+ */
31
+ complementExpected: z.enum(["empty-result", "excluded-result", "unknown"]),
32
+ /** Which bind rule this partition enforces (advisory provenance). */
33
+ bindRule: z.string().min(1),
34
+ }).strict();
35
+ const PARTITION_HEADING = /^##\s+Scenario Partitions\s*$/;
36
+ const PARTITION_TABLE_HEADER = /^\|\s*Partition ID\s*\|\s*Operation\s*\|\s*Axis\s*\|\s*Domain\s*\|\s*Required Slots\s*\|\s*Expected by Slot\s*\|\s*Bind Rule\s*\|\s*$/;
37
+ const TABLE_ROW = /^\|(.+)\|$/;
38
+ const FINITE_DOMAIN_MEMBER = /^[A-Za-z0-9]+(?:[._\- ][A-Za-z0-9]+)*$/;
39
+ const RESERVED_SLOT_TOKENS = new Set(["OMITTED", "NOT-IN-SET"]);
40
+ function splitCells(raw) {
41
+ return raw
42
+ .split("|")
43
+ .map((cell) => cell.trim())
44
+ .filter((cell, index, cells) => !(cell === "" && (index === 0 || index === cells.length - 1)));
45
+ }
46
+ /**
47
+ * Parse the `## Scenario Partitions` section of the README into declared
48
+ * partitions. Returns { partitions, issues }; issues are fail-closed parse
49
+ * problems (malformed table), while semantic gaps surface in expansion.
50
+ */
51
+ export function parseScenarioPartitions(readmeMarkdown) {
52
+ const issues = [];
53
+ const partitions = [];
54
+ const lines = readmeMarkdown.split("\n");
55
+ const headingIndex = lines.findIndex((line) => PARTITION_HEADING.test(line));
56
+ if (headingIndex === -1)
57
+ return { partitions, issues };
58
+ // find table header within the section (before the next ## heading)
59
+ let headerIndex = -1;
60
+ for (let i = headingIndex + 1; i < lines.length; i++) {
61
+ if (/^##\s+/.test(lines[i]) && !PARTITION_HEADING.test(lines[i]))
62
+ break;
63
+ if (PARTITION_TABLE_HEADER.test(lines[i])) {
64
+ headerIndex = i;
65
+ break;
66
+ }
67
+ }
68
+ if (headerIndex === -1) {
69
+ issues.push("MISSING_PARTITION_TABLE: Scenario Partitions section has no canonical header row");
70
+ return { partitions, issues };
71
+ }
72
+ const seen = new Set();
73
+ for (let i = headerIndex + 2; i < lines.length; i++) {
74
+ const line = lines[i];
75
+ if (!TABLE_ROW.test(line)) {
76
+ if (/^##\s+/.test(line) || /^#\s+/.test(line))
77
+ break;
78
+ continue;
79
+ }
80
+ const cells = splitCells(line);
81
+ if (cells.length !== 7) {
82
+ issues.push(`MISSING_PARTITION: row has ${cells.length} cells, expected 7: ${line.trim()}`);
83
+ continue;
84
+ }
85
+ const [partitionId, operation, axis, domainRaw, requiredSlotsRaw, expectedBySlotRaw, bindRule,] = cells;
86
+ const domain = domainRaw
87
+ .split(/[;,,;]/)
88
+ .map((value) => value.trim())
89
+ .filter((value) => value.length > 0);
90
+ if (domain.length === 0) {
91
+ issues.push(`MISSING_PARTITION: empty domain: ${partitionId}`);
92
+ continue;
93
+ }
94
+ const optional = /omit/i.test(requiredSlotsRaw);
95
+ const complementExpected = /empty[- ]?result/i.test(expectedBySlotRaw)
96
+ ? "empty-result"
97
+ : /excluded[- ]?result/i.test(expectedBySlotRaw)
98
+ ? "excluded-result"
99
+ : "unknown";
100
+ if (seen.has(partitionId)) {
101
+ issues.push(`MISSING_PARTITION: duplicate partition id ${partitionId}`);
102
+ continue;
103
+ }
104
+ seen.add(partitionId);
105
+ const domainIssue = describeInvalidPartitionDomain(domain);
106
+ if (domainIssue) {
107
+ issues.push(`INVALID_PARTITION_DOMAIN: partition ${partitionId} ${domainIssue}`);
108
+ continue;
109
+ }
110
+ partitions.push({
111
+ partitionId,
112
+ operation,
113
+ axis,
114
+ domain,
115
+ optional,
116
+ complementExpected,
117
+ bindRule,
118
+ });
119
+ }
120
+ return { partitions, issues };
121
+ }
122
+ function slotToken(value) {
123
+ return value
124
+ .trim()
125
+ .toUpperCase()
126
+ .replace(/[^A-Z0-9]+/g, "-")
127
+ .replace(/^-+|-+$/g, "");
128
+ }
129
+ function describeInvalidPartitionDomain(domain) {
130
+ const tokens = [];
131
+ const seenTokens = new Set();
132
+ for (const value of domain) {
133
+ if (!FINITE_DOMAIN_MEMBER.test(value)) {
134
+ return `value is not a finite identifier member: ${value}`;
135
+ }
136
+ const token = slotToken(value);
137
+ if (!token) {
138
+ return `value cannot produce a stable non-empty slot token: ${value}`;
139
+ }
140
+ if (RESERVED_SLOT_TOKENS.has(token)) {
141
+ return `value maps to reserved slot token ${token}: ${value}`;
142
+ }
143
+ if (seenTokens.has(token)) {
144
+ return `values collapse to duplicate slot token ${token}`;
145
+ }
146
+ seenTokens.add(token);
147
+ tokens.push(token);
148
+ }
149
+ return tokens.length === 0 ? "domain produced no slot tokens" : undefined;
150
+ }
151
+ function normalizePartitionAxisName(value) {
152
+ return value.trim().replace(/^(?:query|path)\s*:\s*/i, "").toLowerCase();
153
+ }
154
+ function normalizePartitionOperation(value) {
155
+ return value.trim().replace(/\s+/g, " ").toUpperCase();
156
+ }
157
+ function sameFiniteSet(left, right) {
158
+ if (left.length !== right.length)
159
+ return false;
160
+ const expected = new Set(left);
161
+ return right.every((value) => expected.has(value));
162
+ }
163
+ const STABLE_PARTITION_ID = /^SP-[A-Z0-9][A-Z0-9._-]*$/i;
164
+ /**
165
+ * Slot IDs are `TP-<Partition ID>-<VALUE|OMITTED|NOT-IN-SET>`.
166
+ * The declared Partition ID is the single source of truth so model-written
167
+ * `TP-SP-GET-API-...` IDs match the expander (never drop the HTTP method).
168
+ */
169
+ function partitionToken(row) {
170
+ const id = row.partitionId.trim();
171
+ if (STABLE_PARTITION_ID.test(id))
172
+ return `TP-${id.toUpperCase()}`;
173
+ return `TP-SP-${slotToken(row.operation)}-${slotToken(row.axis)}`;
174
+ }
175
+ /**
176
+ * Deterministically expand declared partitions into slots. Pure function:
177
+ * same input rows always produce the same slot IDs.
178
+ */
179
+ export function expandScenarioPartitions(partitions) {
180
+ const slots = [];
181
+ for (const row of partitions) {
182
+ if (describeInvalidPartitionDomain(row.domain))
183
+ continue;
184
+ const prefix = partitionToken(row);
185
+ for (const value of row.domain) {
186
+ const token = slotToken(value);
187
+ slots.push({
188
+ partitionId: row.partitionId,
189
+ operation: row.operation,
190
+ axis: row.axis,
191
+ slotId: `${prefix}-${token}`,
192
+ kind: "each-value",
193
+ value,
194
+ intent: "nominal-filter",
195
+ expected: "domain-value",
196
+ });
197
+ }
198
+ if (row.optional) {
199
+ slots.push({
200
+ partitionId: row.partitionId,
201
+ operation: row.operation,
202
+ axis: row.axis,
203
+ slotId: `${prefix}-OMITTED`,
204
+ kind: "omitted",
205
+ value: "",
206
+ intent: "omitted",
207
+ expected: "default-behavior",
208
+ });
209
+ }
210
+ slots.push({
211
+ partitionId: row.partitionId,
212
+ operation: row.operation,
213
+ axis: row.axis,
214
+ slotId: `${prefix}-NOT-IN-SET`,
215
+ kind: "not-in-set",
216
+ value: "",
217
+ intent: "enum-invalid",
218
+ expected: row.complementExpected === "unknown" ? "GAP" : "complement",
219
+ });
220
+ }
221
+ return slots;
222
+ }
223
+ /**
224
+ * Bind parsed partitions to GET query/path OpenAPI axes.
225
+ * Unconfirmed axes keep identifier-valid Domain rows (requirement finite sets).
226
+ */
227
+ export function applyOpenApiPartitionDomainPolicy(partitions, axes) {
228
+ const issues = [];
229
+ const valid = [];
230
+ for (const row of partitions) {
231
+ const operation = normalizePartitionOperation(row.operation);
232
+ const axisName = normalizePartitionAxisName(row.axis);
233
+ const matched = axes.find((axis) => normalizePartitionOperation(axis.operation) === operation &&
234
+ normalizePartitionAxisName(axis.name) === axisName);
235
+ if (!matched) {
236
+ valid.push(row);
237
+ continue;
238
+ }
239
+ if (matched.enumValues === null) {
240
+ issues.push(`NO_FINITE_DOMAIN: partition ${row.partitionId} axis ${row.axis} exists on ${row.operation} without a documented enum`);
241
+ continue;
242
+ }
243
+ if (!sameFiniteSet(matched.enumValues, row.domain)) {
244
+ issues.push(`PARTITION_SOURCE_MISMATCH: partition ${row.partitionId} domain does not match OpenAPI enum for ${row.operation} ${row.axis}`);
245
+ continue;
246
+ }
247
+ valid.push(row);
248
+ }
249
+ return { partitions: valid, issues };
250
+ }
251
+ /** Facts projection consumed by N6 and plan D gap-fill. */
252
+ export const scenarioPartitionFactsSchema = z.object({
253
+ partitions: z.array(z.object({
254
+ partitionId: z.string(),
255
+ operation: z.string(),
256
+ axis: z.string(),
257
+ domain: z.array(z.string()),
258
+ optional: z.boolean(),
259
+ complementExpected: z.string(),
260
+ bindRule: z.string(),
261
+ slotIds: z.array(z.string()),
262
+ })),
263
+ slots: z.array(z.object({
264
+ slotId: z.string(),
265
+ partitionId: z.string(),
266
+ kind: z.string(),
267
+ intent: z.string(),
268
+ expected: z.string(),
269
+ })),
270
+ missingSlots: z.array(z.string()),
271
+ });
272
+ /**
273
+ * Compare expanded slots against the Case test-point universe. A slot is
274
+ * covered only when its exact slot ID is declared in some Case's 测试点 or
275
+ * binding lists. Missing each-value/omitted slots and the complement slot
276
+ * produce fail-closed finding codes; a GAP-expected complement that IS
277
+ * covered still reports a coverage gap (expected semantics undocumented).
278
+ */
279
+ export function assessScenarioPartitionCoverage(partitions, declaredTestPointIds) {
280
+ const slots = expandScenarioPartitions(partitions);
281
+ const findings = [];
282
+ const missingSlots = [];
283
+ for (const row of partitions) {
284
+ const rowSlots = slots.filter((slot) => slot.partitionId === row.partitionId);
285
+ for (const slot of rowSlots) {
286
+ if (!declaredTestPointIds.has(slot.slotId)) {
287
+ missingSlots.push(slot.slotId);
288
+ const code = slot.kind === "not-in-set" ? "MISSING_COMPLEMENT_SLOT" : "MISSING_PARTITION_SLOT";
289
+ findings.push(`${code}: partition ${row.partitionId} slot ${slot.slotId} (${slot.kind}${slot.value ? `=${slot.value}` : ""}) is not covered by any Case test point`);
290
+ }
291
+ else if (slot.kind === "not-in-set" && slot.expected === "GAP") {
292
+ findings.push(`PARTITION_COMPLEMENT_EXPECTED_GAP: partition ${row.partitionId} complement slot ${slot.slotId} has no documented expected semantics in the bound source; expected stays GAP instead of guessing`);
293
+ }
294
+ }
295
+ }
296
+ return {
297
+ facts: {
298
+ partitions: partitions.map((row) => ({
299
+ partitionId: row.partitionId,
300
+ operation: row.operation,
301
+ axis: row.axis,
302
+ domain: row.domain,
303
+ optional: row.optional,
304
+ complementExpected: row.complementExpected,
305
+ bindRule: row.bindRule,
306
+ slotIds: slots
307
+ .filter((slot) => slot.partitionId === row.partitionId)
308
+ .map((slot) => slot.slotId),
309
+ })),
310
+ slots: slots.map((slot) => ({
311
+ slotId: slot.slotId,
312
+ partitionId: slot.partitionId,
313
+ kind: slot.kind,
314
+ intent: slot.intent,
315
+ expected: slot.expected,
316
+ })),
317
+ missingSlots,
318
+ },
319
+ findings,
320
+ };
321
+ }
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { isPriorityOnlyBackendTestModuleStem } from "./backend-test-module-stem.js";
5
+ import { BACKEND_TEST_MAX_MODULE_COUNT, isOpaqueHashBackendTestModuleStem, isPriorityOnlyBackendTestModuleStem, } from "./backend-test-module-stem.js";
6
+ import { resolveBackendTestLayout, } from "./backend-test-layout.js";
6
7
  /**
7
8
  * Maps a backend-test generation writer task id to its writer-progress role.
8
9
  * Returns undefined for nodes that are not completeness-gated generators.
@@ -103,6 +104,8 @@ function moduleStemInvalidReason(raw) {
103
104
  const stem = normalizeBackendTestModuleStem(raw);
104
105
  if (isPriorityOnlyBackendTestModuleStem(stem))
105
106
  return "priority-only-module-stem";
107
+ if (isOpaqueHashBackendTestModuleStem(stem))
108
+ return "opaque-hash-module-stem";
106
109
  if (stem.toLowerCase() === "readme")
107
110
  return "reserved-module-stem";
108
111
  if (!/^[a-z][a-z0-9_]*$/.test(stem))
@@ -114,28 +117,40 @@ function moduleStemInvalidReason(raw) {
114
117
  function looksLikeValidModuleStem(raw) {
115
118
  return moduleStemInvalidReason(raw) === undefined;
116
119
  }
117
- /** Inspect only authoritative README module-index candidates. */
118
- export function inspectModuleStemsFromReadme(readme) {
119
- const candidates = [];
120
- const tableRowLines = readme
120
+ function extractCanonicalModuleIndexSection(readme) {
121
+ const lines = readme
121
122
  .replaceAll("\r\n", "\n")
122
123
  .replaceAll("\r", "\n")
123
- .split("\n")
124
- .filter((line) => line.includes("|"));
124
+ .split("\n");
125
+ const headings = lines.flatMap((line, index) => line.trim() === "## Module Index" ? [index] : []);
126
+ if (headings.length === 0) {
127
+ return { section: "", structureIssues: ["missing-module-index"] };
128
+ }
129
+ if (headings.length > 1) {
130
+ return { section: "", structureIssues: ["duplicate-module-index"] };
131
+ }
132
+ const start = headings[0] + 1;
133
+ const relativeEnd = lines.slice(start).findIndex((line) => /^##\s+\S/.test(line.trim()));
134
+ const end = relativeEnd < 0 ? lines.length : start + relativeEnd;
135
+ return { section: lines.slice(start, end).join("\n"), structureIssues: [] };
136
+ }
137
+ /** Inspect only candidates inside the unique exact `## Module Index` section. */
138
+ export function inspectModuleStemsFromReadme(readme) {
139
+ const extracted = extractCanonicalModuleIndexSection(readme);
140
+ const candidates = [];
141
+ const tableRowLines = extracted.section.split("\n").filter((line) => line.includes("|"));
125
142
  for (const line of tableRowLines) {
126
143
  for (const match of line.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
127
144
  candidates.push(match[1]);
128
145
  }
129
146
  for (const match of line.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
130
- // This compatibility shape can also capture a numeric count column.
131
- // Keep valid stems and the priority-only defect we diagnose, while
132
- // ignoring non-stem incidental cells such as `1`.
133
- if (looksLikeValidModuleStem(match[1]) || isPriorityOnlyBackendTestModuleStem(match[1])) {
134
- candidates.push(match[1]);
147
+ const raw = match[1];
148
+ if (looksLikeValidModuleStem(raw) || moduleStemInvalidReason(raw) !== "invalid-syntax") {
149
+ candidates.push(raw);
135
150
  }
136
151
  }
137
152
  }
138
- for (const match of readme.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
153
+ for (const match of extracted.section.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
139
154
  candidates.push(match[1]);
140
155
  }
141
156
  const uniqueCandidates = orderedUnique(candidates);
@@ -145,12 +160,18 @@ export function inspectModuleStemsFromReadme(readme) {
145
160
  ? [{ raw, normalized: normalizeBackendTestModuleStem(raw), reasonCode }]
146
161
  : [];
147
162
  });
163
+ const validStems = orderedUnique(uniqueCandidates
164
+ .filter(looksLikeValidModuleStem)
165
+ .map((stem) => normalizeBackendTestModuleStem(stem)));
166
+ const structureIssues = [...extracted.structureIssues];
167
+ if (validStems.length > BACKEND_TEST_MAX_MODULE_COUNT) {
168
+ structureIssues.push(`excessive-module-count: ${validStems.length} > ${BACKEND_TEST_MAX_MODULE_COUNT}`);
169
+ }
148
170
  return {
149
171
  candidates: uniqueCandidates,
150
- validStems: orderedUnique(uniqueCandidates
151
- .filter(looksLikeValidModuleStem)
152
- .map((stem) => normalizeBackendTestModuleStem(stem))),
172
+ validStems,
153
173
  invalid,
174
+ structureIssues,
154
175
  };
155
176
  }
156
177
  /**
@@ -161,8 +182,9 @@ export function inspectModuleStemsFromReadme(readme) {
161
182
  export function extractModuleStemsFromReadme(readme) {
162
183
  return inspectModuleStemsFromReadme(readme).validStems;
163
184
  }
164
- async function listMarkdownModules(workspaceRoot) {
165
- const root = path.join(workspaceRoot, "testcase", "md");
185
+ async function listMarkdownModules(workspaceRoot, layout) {
186
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
187
+ const root = path.join(workspaceRoot, ...resolved.markdownDir.split("/"));
166
188
  if (!(await exists(root)))
167
189
  return [];
168
190
  const entries = await readdir(root, { withFileTypes: true });
@@ -170,7 +192,7 @@ async function listMarkdownModules(workspaceRoot) {
170
192
  .filter((entry) => entry.isFile() &&
171
193
  entry.name.toLowerCase().endsWith(".md") &&
172
194
  entry.name.toLowerCase() !== "readme.md")
173
- .map((entry) => path.posix.join("testcase/md", entry.name.replaceAll("\\", "/"))));
195
+ .map((entry) => path.posix.join(resolved.markdownDir, entry.name.replaceAll("\\", "/"))));
174
196
  }
175
197
  /**
176
198
  * Recursively list `.py` file paths relative to `dirRoot` (forward slashes).
@@ -550,56 +572,58 @@ export async function assessBackendTestShardChildCompleteness(input) {
550
572
  issues,
551
573
  };
552
574
  }
553
- export async function assessBackendTestMdWriterCompleteness(workspaceRoot) {
575
+ export async function assessBackendTestMdWriterCompleteness(workspaceRoot, layout) {
576
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
577
+ const readmeRel = resolved.readmePath;
554
578
  const issues = [];
555
- const expectedPaths = ["testcase/md/README.md"];
579
+ const expectedPaths = [readmeRel];
556
580
  const actualPaths = [];
557
581
  const missingPaths = [];
558
582
  const brokenPaths = [];
559
- const readmePath = path.join(workspaceRoot, "testcase", "md", "README.md");
583
+ const readmePath = path.join(workspaceRoot, ...readmeRel.split("/"));
560
584
  let readme = "";
561
585
  if (!(await exists(readmePath))) {
562
- missingPaths.push("testcase/md/README.md");
586
+ missingPaths.push(readmeRel);
563
587
  issues.push({
564
588
  code: "T3",
565
- path: "testcase/md/README.md",
589
+ path: readmeRel,
566
590
  detail: "README.md is missing",
567
591
  recoverable: true,
568
592
  });
569
593
  }
570
594
  else {
571
- actualPaths.push("testcase/md/README.md");
595
+ actualPaths.push(readmeRel);
572
596
  readme = await readFile(readmePath, "utf8");
573
597
  if (!readme.includes("## Coverage Scope")) {
574
- brokenPaths.push("testcase/md/README.md");
598
+ brokenPaths.push(readmeRel);
575
599
  issues.push({
576
600
  code: "T5",
577
- path: "testcase/md/README.md",
601
+ path: readmeRel,
578
602
  detail: "README.md is missing ## Coverage Scope",
579
603
  recoverable: true,
580
604
  });
581
605
  }
582
606
  if (!readme.includes("## Coverage Matrix") ||
583
607
  !hasMarkdownTable(readme, "Rule Key")) {
584
- if (!brokenPaths.includes("testcase/md/README.md")) {
585
- brokenPaths.push("testcase/md/README.md");
608
+ if (!brokenPaths.includes(readmeRel)) {
609
+ brokenPaths.push(readmeRel);
586
610
  }
587
611
  issues.push({
588
612
  code: "T5",
589
- path: "testcase/md/README.md",
613
+ path: readmeRel,
590
614
  detail: "README.md Coverage Matrix table is missing or broken",
591
615
  recoverable: true,
592
616
  });
593
617
  }
594
618
  }
595
619
  const indexedStems = extractModuleStemsFromReadme(readme);
596
- const existingModules = await listMarkdownModules(workspaceRoot);
620
+ const existingModules = await listMarkdownModules(workspaceRoot, resolved);
597
621
  const existingByStem = new Map(existingModules.map((rel) => [
598
622
  normalizeBackendTestModuleStem(path.posix.basename(rel)),
599
623
  rel,
600
624
  ]));
601
625
  for (const stem of indexedStems) {
602
- const rel = `testcase/md/${stem}.md`;
626
+ const rel = `${resolved.markdownDir}/${stem}.md`;
603
627
  expectedPaths.push(rel);
604
628
  const existing = existingByStem.get(stem);
605
629
  if (!existing) {
@@ -668,15 +692,16 @@ export async function assessBackendTestMdWriterCompleteness(workspaceRoot) {
668
692
  issues,
669
693
  };
670
694
  }
671
- export async function assessBackendTestPytestWriterCompleteness(workspaceRoot) {
695
+ export async function assessBackendTestPytestWriterCompleteness(workspaceRoot, layout) {
696
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
672
697
  const issues = [];
673
698
  const expectedPaths = [];
674
699
  const actualPaths = [];
675
700
  const missingPaths = [];
676
701
  const brokenPaths = [];
677
- const modules = await listMarkdownModules(workspaceRoot);
702
+ const modules = await listMarkdownModules(workspaceRoot, resolved);
678
703
  for (const moduleRel of modules) {
679
- const expected = expectedBackendTestPytestScriptForMarkdownModule(path.posix.basename(moduleRel));
704
+ const expected = expectedBackendTestPytestScriptForMarkdownModule(path.posix.basename(moduleRel), resolved);
680
705
  expectedPaths.push(expected);
681
706
  const absolute = path.join(workspaceRoot, expected);
682
707
  if (!(await exists(absolute))) {
@@ -836,6 +861,11 @@ export function isBackendTestCompletenessRetryCandidate(task) {
836
861
  task.id === "generate-backend-pytest-pi" ||
837
862
  task.id === "generate-backend-md-plan-pi" ||
838
863
  task.id === "generate-backend-pytest-plan-pi" ||
864
+ // Plan D gap-fill patch writers share the same Completeness Gate retry
865
+ // contract as full-chain writers.
866
+ task.id === "patch-backend-readme-matrix-pi" ||
867
+ (task.id?.startsWith("patch-backend-md-case-") ?? false) ||
868
+ (task.id?.startsWith("patch-backend-pytest-case-") ?? false) ||
839
869
  (task.id?.startsWith("generate-backend-md-case-") ?? false) ||
840
870
  (task.id?.startsWith("generate-backend-pytest-case-") ?? false));
841
871
  }
@@ -872,42 +902,44 @@ export function isBackendTestPytestPlanTask(task) {
872
902
  * index), WITHOUT requiring the indexed module files to exist yet — those are
873
903
  * written by the downstream map_agent shard children.
874
904
  */
875
- export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
905
+ export async function assessBackendTestMdPlanCompleteness(workspaceRoot, layout) {
906
+ const resolvedPlan = layout ?? resolveBackendTestLayout(undefined);
907
+ const planReadmeRel = resolvedPlan.readmePath;
876
908
  const issues = [];
877
- const expectedPaths = ["testcase/md/README.md"];
909
+ const expectedPaths = [planReadmeRel];
878
910
  const actualPaths = [];
879
911
  const missingPaths = [];
880
912
  const brokenPaths = [];
881
- const readmePath = path.join(workspaceRoot, "testcase", "md", "README.md");
913
+ const readmePath = path.join(workspaceRoot, ...planReadmeRel.split("/"));
882
914
  if (!(await exists(readmePath))) {
883
- missingPaths.push("testcase/md/README.md");
915
+ missingPaths.push(planReadmeRel);
884
916
  issues.push({
885
917
  code: "T3",
886
- path: "testcase/md/README.md",
918
+ path: planReadmeRel,
887
919
  detail: "README.md is missing",
888
920
  recoverable: true,
889
921
  });
890
922
  }
891
923
  else {
892
- actualPaths.push("testcase/md/README.md");
924
+ actualPaths.push(planReadmeRel);
893
925
  const readme = await readFile(readmePath, "utf8");
894
926
  if (!readme.includes("## Coverage Scope")) {
895
- brokenPaths.push("testcase/md/README.md");
927
+ brokenPaths.push(planReadmeRel);
896
928
  issues.push({
897
929
  code: "T5",
898
- path: "testcase/md/README.md",
930
+ path: planReadmeRel,
899
931
  detail: "README.md is missing ## Coverage Scope",
900
932
  recoverable: true,
901
933
  });
902
934
  }
903
935
  if (!readme.includes("## Coverage Matrix") ||
904
936
  !hasMarkdownTable(readme, "Rule Key")) {
905
- if (!brokenPaths.includes("testcase/md/README.md")) {
906
- brokenPaths.push("testcase/md/README.md");
937
+ if (!brokenPaths.includes(planReadmeRel)) {
938
+ brokenPaths.push(planReadmeRel);
907
939
  }
908
940
  issues.push({
909
941
  code: "T5",
910
- path: "testcase/md/README.md",
942
+ path: planReadmeRel,
911
943
  detail: "README.md Coverage Matrix table is missing or broken",
912
944
  recoverable: true,
913
945
  });
@@ -917,24 +949,35 @@ export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
917
949
  // are produced by map_agent children and are NOT required here.
918
950
  const stemInspection = inspectModuleStemsFromReadme(readme);
919
951
  const indexedStems = stemInspection.validStems;
952
+ for (const structureIssue of stemInspection.structureIssues) {
953
+ if (!brokenPaths.includes(planReadmeRel)) {
954
+ brokenPaths.push(planReadmeRel);
955
+ }
956
+ issues.push({
957
+ code: "T5",
958
+ path: planReadmeRel,
959
+ detail: structureIssue,
960
+ recoverable: true,
961
+ });
962
+ }
920
963
  for (const invalid of stemInspection.invalid) {
921
- if (!brokenPaths.includes("testcase/md/README.md")) {
922
- brokenPaths.push("testcase/md/README.md");
964
+ if (!brokenPaths.includes(planReadmeRel)) {
965
+ brokenPaths.push(planReadmeRel);
923
966
  }
924
967
  issues.push({
925
968
  code: "T5",
926
- path: "testcase/md/README.md",
969
+ path: planReadmeRel,
927
970
  detail: `${invalid.reasonCode}: ${invalid.normalized}; use a stable lowercase business resource/domain stem`,
928
971
  recoverable: true,
929
972
  });
930
973
  }
931
974
  if (indexedStems.length === 0) {
932
- if (!brokenPaths.includes("testcase/md/README.md")) {
933
- brokenPaths.push("testcase/md/README.md");
975
+ if (!brokenPaths.includes(planReadmeRel)) {
976
+ brokenPaths.push(planReadmeRel);
934
977
  }
935
978
  issues.push({
936
979
  code: "T5",
937
- path: "testcase/md/README.md",
980
+ path: planReadmeRel,
938
981
  detail: "README.md module index is empty; downstream map_agent has no stems to shard",
939
982
  recoverable: true,
940
983
  });
@@ -960,16 +1003,17 @@ export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
960
1003
  * requiring per-module pytest scripts to exist yet — those are written by the
961
1004
  * downstream map_agent shard children.
962
1005
  */
963
- export async function assessBackendTestPytestPlanCompleteness(workspaceRoot) {
1006
+ export async function assessBackendTestPytestPlanCompleteness(workspaceRoot, layout) {
1007
+ const resolvedPlan = layout ?? resolveBackendTestLayout(undefined);
964
1008
  const issues = [];
965
1009
  const expectedPaths = [];
966
1010
  const actualPaths = [];
967
1011
  const missingPaths = [];
968
1012
  const brokenPaths = [];
969
- // Discover shared pytest resources under testcase/**/helpers/** and
970
- // testcase/**/factories/** (the plan node writeSet globs).
1013
+ // Discover shared pytest resources under <testRoot>/**/helpers/** and
1014
+ // <testRoot>/**/factories/** (the plan node writeSet globs).
971
1015
  const candidates = [];
972
- for (const dir of ["testcase/helpers", "testcase/factories"]) {
1016
+ for (const dir of [`${resolvedPlan.testRoot}/helpers`, `${resolvedPlan.testRoot}/factories`]) {
973
1017
  const absolute = path.join(workspaceRoot, dir);
974
1018
  if (await exists(absolute)) {
975
1019
  for (const file of await listPythonFiles(absolute)) {
@@ -1,4 +1,19 @@
1
1
  import { structuredContractOutputSchemaIds, } from "./types.js";
2
+ /**
3
+ * Registry of node-level structured contract validators, keyed by the
4
+ * `structuredContractOutput.schemaId` declared on a DAG task.
5
+ *
6
+ * The executor looks the validator up here instead of hardcoding per-contract
7
+ * imports, so new contracts (backend-test manifests, coverage contracts, ...)
8
+ * can opt into producing-node self-validation without touching the executor.
9
+ */
10
+ export const structuredContractValidationClassifications = [
11
+ "format",
12
+ "schema",
13
+ "truncated",
14
+ "semantic-repair",
15
+ "governance-blocked",
16
+ ];
2
17
  const validators = new Map();
3
18
  export function registerStructuredContractValidator(schemaId, validator) {
4
19
  if (validators.has(schemaId)) {