@tea-agent/loop-agent 0.38.0 → 0.38.2

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 (147) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/dist/application/dag/generate-task-dag.js +9 -0
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/commands/init-upgrade.js +97 -19
  5. package/dist/executors/dag-pi-executor.js +42 -4
  6. package/dist/executors/shell-executor.js +269 -15
  7. package/dist/infrastructure/harness/atomic-write.js +2 -1
  8. package/dist/shared/openspec-spec.js +76 -0
  9. package/dist/shared/operator/capabilities.js +149 -27
  10. package/dist/task/config-types.js +43 -0
  11. package/dist/task/frontend-project-capability.js +292 -38
  12. package/dist/worker/console/chat/deferred-turn.js +91 -0
  13. package/dist/worker/console/chat/mcp-inventory.js +221 -0
  14. package/dist/worker/console/chat/pi-runtime.js +165 -83
  15. package/dist/worker/console/chat/repo-walk.js +10 -1
  16. package/dist/worker/console/chat/routes.js +477 -55
  17. package/dist/worker/console/chat/semantic-activity.js +20 -8
  18. package/dist/worker/console/chat/session-store.js +146 -2
  19. package/dist/worker/console/chat/turn-process.js +17 -30
  20. package/dist/worker/console/harness-pi-model-matrix.js +137 -0
  21. package/dist/worker/console/operator-actions.js +128 -4
  22. package/dist/worker/console/operator-user-error.js +19 -0
  23. package/dist/worker/console/pi-readiness.js +8 -1
  24. package/dist/worker/console/recovery-cta.js +4 -4
  25. package/dist/worker/console/recovery-error-copy.js +4 -4
  26. package/dist/worker/console/security.js +20 -1
  27. package/dist/worker/console/server.js +6 -1
  28. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
  29. package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-SAD7McOS.js} +1 -1
  30. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
  31. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
  32. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
  33. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
  34. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
  44. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
  45. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
  46. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
  47. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
  48. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
  49. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
  55. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
  56. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
  57. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
  58. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
  59. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
  60. package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
  61. package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
  62. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
  63. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
  64. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
  65. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
  66. package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-D9Py31Ld.js} +1 -1
  67. package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-D7Occzk_.js} +5 -5
  68. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
  69. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
  70. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
  71. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
  72. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
  73. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
  74. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
  75. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
  76. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
  77. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
  78. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
  79. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
  80. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
  81. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
  82. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
  83. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
  84. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
  85. package/dist/worker/console/static/index.html +2 -2
  86. package/dist/worker/console/static-src/app/console-types.js +39 -0
  87. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  88. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  89. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +82 -22
  90. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
  91. package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
  92. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  93. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  94. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +18 -0
  95. package/dist/worker/console/static-src/operator-chat/useChatStream.js +216 -10
  96. package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
  97. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
  98. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
  99. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  100. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  101. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  102. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  103. package/dist/workflows/dag/backend-test-layout.js +133 -0
  104. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  105. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  106. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  107. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  108. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  109. package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
  110. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  111. package/dist/workflows/dag/contract-validator-registrations.js +3 -1
  112. package/dist/workflows/dag/convergence/controller.js +141 -0
  113. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  114. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  115. package/dist/workflows/dag/failure-routing.js +9 -4
  116. package/dist/workflows/dag/frontend-implementation-contract.js +386 -0
  117. package/dist/workflows/dag/frontend-plan-render.js +90 -0
  118. package/dist/workflows/dag/frontend-prewrite-gate.js +479 -28
  119. package/dist/workflows/dag/init-hybrid.js +920 -110
  120. package/dist/workflows/dag/lifecycle.js +4 -0
  121. package/dist/workflows/dag/node-execution.js +77 -8
  122. package/dist/workflows/dag/prompt.js +45 -0
  123. package/dist/workflows/dag/report.js +6 -0
  124. package/dist/workflows/dag/rerun-feedback.js +212 -0
  125. package/dist/workflows/dag/rerun-plan.js +93 -27
  126. package/dist/workflows/dag/rerun-run.js +3 -0
  127. package/dist/workflows/dag/rerun-task.js +61 -2
  128. package/dist/workflows/dag/retry-policy.js +33 -0
  129. package/dist/workflows/dag/types.js +148 -4
  130. package/dist/workflows/dag/validate.js +7 -2
  131. package/docs/templates/backend-test-dag.json +60 -35
  132. package/docs/templates/frontend-design-contract.md +40 -14
  133. package/docs/templates/frontend-implementation-contract.schema.json +9 -2
  134. package/docs/templates/frontend-task-constraints.md +40 -13
  135. package/docs/templates/frontend-task-requirement.md +54 -20
  136. package/harness.json +3 -1
  137. package/package.json +1 -1
  138. package/skills/frontend-design-review/references/review-checklist.md +2 -0
  139. package/skills/frontend-implementation/references/design-spec.md +16 -19
  140. package/skills/frontend-implementation/references/node-contracts.md +15 -11
  141. package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
  142. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
  143. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
  144. package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
  145. package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
  146. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
  147. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
@@ -27,11 +27,13 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
27
27
  import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
28
28
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
29
29
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
30
+ import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
31
+ import { backendTestGapDocSchema, ingestBackendTestGap, } from "../workflows/dag/backend-test-gap-fill.js";
30
32
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
31
33
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
32
- import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, hasBlockingBackendMarkdownModuleStemFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
34
+ import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, hasBlockingBackendMarkdownModuleStemFindings, inspectBackendTestEnvironment, markdownFiles, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
33
35
  import { applyDeterministicScenarioParamRepairs, assessBackendScenarioParamConsistency, classifyBackendTestFailureWithScenarioParam, readBackendScenarioParamFacts, renderBackendTestFailureAnalysis, writeBackendScenarioParamArtifacts, writeScenarioParamRepairAudit, } from "../workflows/dag/backend-test-scenario-param.js";
34
- import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assessPriorityOnlyBackendPytestModules, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
36
+ import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assessPriorityOnlyBackendPytestModules, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, buildBackendTestItemEligibility, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
35
37
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
36
38
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
37
39
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -48,6 +50,7 @@ async function runBackendScenarioParamPreflight(input) {
48
50
  phase: "initial",
49
51
  repairAttempt: 0,
50
52
  strictScenarioParamGate: input.strictScenarioParamGate,
53
+ layout: input.layout,
51
54
  });
52
55
  const initialArtifacts = await writeBackendScenarioParamArtifacts({
53
56
  runDir: input.runDir,
@@ -73,6 +76,7 @@ async function runBackendScenarioParamPreflight(input) {
73
76
  phase: "final",
74
77
  repairAttempt: 1,
75
78
  strictScenarioParamGate: input.strictScenarioParamGate,
79
+ layout: input.layout,
76
80
  });
77
81
  finalFacts = reassessed.facts;
78
82
  finalMarkdown = reassessed.markdown;
@@ -492,11 +496,114 @@ async function readAdvisoryRunReport(reportsDir, filename, title) {
492
496
  async function executeBackendTestPipeline(input, meta) {
493
497
  const pipeline = input.task.shell?.backendTestPipeline;
494
498
  const started = Date.now();
499
+ // Plan A: prefer the frozen layout carried by the compiled spec; fall back
500
+ // to the historical default for hand-written legacy DAGs.
501
+ const backendLayout = resolveBackendTestLayout(meta.spec.backendTestLayout
502
+ ? {
503
+ testRoot: meta.spec.backendTestLayout.testRoot,
504
+ markdownRel: meta.spec.backendTestLayout.markdownRel,
505
+ scriptRel: meta.spec.backendTestLayout.scriptRel,
506
+ }
507
+ : undefined);
495
508
  try {
496
509
  if (!pipeline)
497
510
  throw new Error("missing backend-test pipeline id");
498
511
  const outputs = [];
499
- if (pipeline === "markdown-environment") {
512
+ if (pipeline === "ingest-backend-test-gap") {
513
+ // Plan D: deterministic gap ingest. Identity (taskId + requirement
514
+ // sha256) is fail-closed; a mismatched plan blocks every writer.
515
+ const gapDocRel = meta.spec.globalConstraints
516
+ ?.map((item) => /^backendTestGapDoc=(.+)$/i.exec(item.trim())?.[1])
517
+ .find(Boolean);
518
+ const binding = meta.spec.sourceBinding;
519
+ const requirementSha256 = binding?.sources.find((s) => s.kind === "requirement")?.sha256 ?? "";
520
+ if (!gapDocRel || !binding || !requirementSha256) {
521
+ return {
522
+ ok: false,
523
+ stdout: outputs.join("\n"),
524
+ stderr: "backend-test gap ingest requires the bound gap document path and the task source binding with a requirement sha256",
525
+ failureCategory: "invalid-output",
526
+ durationMs: Date.now() - started,
527
+ };
528
+ }
529
+ const gapDocAbs = path.join(input.cwd, ...gapDocRel.split("/"));
530
+ let parsed;
531
+ try {
532
+ parsed = JSON.parse(await readFile(gapDocAbs, "utf8"));
533
+ }
534
+ catch (error) {
535
+ return {
536
+ ok: false,
537
+ stdout: outputs.join("\n"),
538
+ stderr: `backend-test gap document is unreadable JSON at ${gapDocRel}: ${error instanceof Error ? error.message : String(error)}`,
539
+ failureCategory: "invalid-output",
540
+ durationMs: Date.now() - started,
541
+ };
542
+ }
543
+ const doc = backendTestGapDocSchema.safeParse(parsed);
544
+ if (!doc.success) {
545
+ return {
546
+ ok: false,
547
+ stdout: outputs.join("\n"),
548
+ stderr: `invalid backend-test-gap-v1 document: ${doc.error.issues
549
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
550
+ .join("; ")}`,
551
+ failureCategory: "invalid-output",
552
+ durationMs: Date.now() - started,
553
+ };
554
+ }
555
+ // previous N6 facts (may be absent on a fresh checkout)
556
+ const factsPath = path.join(input.cwd, "contracts", "backend-test-case-coverage-facts.json");
557
+ let previousMissingSlots;
558
+ let previousCoveredSlots;
559
+ try {
560
+ const facts = JSON.parse(await readFile(factsPath, "utf8"));
561
+ previousMissingSlots = facts.scenarioPartitions?.missingSlots;
562
+ previousCoveredSlots = (facts.rules ?? []).flatMap((rule) => rule.coveredTestPoints ?? []);
563
+ }
564
+ catch {
565
+ // no previous facts in the workspace: the gap doc alone drives the plan
566
+ }
567
+ // module index comes from the README on disk
568
+ let existingModuleStems = [];
569
+ try {
570
+ const files = await markdownFiles(input.cwd, backendLayout);
571
+ existingModuleStems = files
572
+ .map((file) => path.basename(file, ".md"))
573
+ .filter((stem) => stem.toLowerCase() !== "readme");
574
+ }
575
+ catch {
576
+ // no markdown tree: every slot becomes a conflict
577
+ }
578
+ const plan = ingestBackendTestGap({
579
+ gapDoc: doc.data,
580
+ previousMissingSlots,
581
+ previousCoveredSlots,
582
+ existingModuleStems,
583
+ sharedSetupPath: meta.spec.backendTestSharedSetup?.path ?? null,
584
+ layout: backendLayout,
585
+ taskId: binding.taskId,
586
+ requirementSha256,
587
+ });
588
+ const contractsDir = path.join(meta.runDir, "contracts");
589
+ await mkdir(contractsDir, { recursive: true });
590
+ const planPath = path.join(contractsDir, "backend-test-gap-plan-v1.json");
591
+ await writeFile(planPath, JSON.stringify(plan, null, 2), "utf8");
592
+ outputs.push(`gapPlan=${planPath}`, `gapPlanStatus=${plan.status}`);
593
+ if (plan.status === "error") {
594
+ return {
595
+ ok: false,
596
+ stdout: outputs.join("\n"),
597
+ stderr: [
598
+ "backend-test gap ingest failed closed (identity/conflicts):",
599
+ ...plan.conflicts,
600
+ ].join("\n"),
601
+ failureCategory: "invalid-output",
602
+ durationMs: Date.now() - started,
603
+ };
604
+ }
605
+ }
606
+ else if (pipeline === "markdown-environment") {
500
607
  const results = await executePipelineCommands(input, meta);
501
608
  if (!results.every((result) => result.ok)) {
502
609
  const failure = results.find((result) => !result.ok);
@@ -530,6 +637,8 @@ async function executeBackendTestPipeline(input, meta) {
530
637
  environmentMarkdown,
531
638
  requiredRequirementIds,
532
639
  sourceBinding: meta.spec.sourceBinding,
640
+ layout: backendLayout,
641
+ sharedSetup: meta.spec.backendTestSharedSetup ?? null,
533
642
  });
534
643
  }
535
644
  catch (error) {
@@ -583,6 +692,7 @@ async function executeBackendTestPipeline(input, meta) {
583
692
  const coverage = await analyzeBackendTestCaseCoverage({
584
693
  workspaceRoot: input.cwd,
585
694
  sourceBinding: normalizedBinding,
695
+ layout: backendLayout,
586
696
  });
587
697
  const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage.markdown);
588
698
  const contractsDir = path.join(meta.runDir, "contracts");
@@ -590,6 +700,23 @@ async function executeBackendTestPipeline(input, meta) {
590
700
  const factsPath = path.join(contractsDir, "backend-test-case-coverage-facts.json");
591
701
  await writeFile(factsPath, JSON.stringify(coverage.facts, null, 2), "utf8");
592
702
  outputs.push(`caseCoverage=${coveragePath}`, `caseCoverageFacts=${factsPath}`, coverage.markdown);
703
+ // Plan C: scenario-partition slot coverage is fail-closed. General
704
+ // coverage findings stay advisory, but a declared filter axis missing
705
+ // an each-value/omitted slot or its complement slot must block pytest
706
+ // generation until the Markdown declares the slot.
707
+ const partitionBlocking = coverage.facts.findings.filter((finding) => /^(?:MISSING_PARTITION|MISSING_PARTITION_SLOT|MISSING_COMPLEMENT_SLOT|MISSING_PARTITION_TABLE)/.test(finding));
708
+ if (partitionBlocking.length > 0) {
709
+ return {
710
+ ok: false,
711
+ stdout: outputs.join("\n\n"),
712
+ stderr: [
713
+ "backend-test scenario partitions are incomplete (fail-closed):",
714
+ ...partitionBlocking,
715
+ ].join("\n"),
716
+ failureCategory: "invalid-output",
717
+ durationMs: Date.now() - started,
718
+ };
719
+ }
593
720
  }
594
721
  catch (error) {
595
722
  const message = error instanceof Error ? error.message : String(error);
@@ -606,9 +733,9 @@ async function executeBackendTestPipeline(input, meta) {
606
733
  }
607
734
  }
608
735
  else if (pipeline === "markdown-collection-assess") {
609
- const resolution = await resolveBackendTestMappedPytestScripts(input.cwd);
736
+ const resolution = await resolveBackendTestMappedPytestScripts(input.cwd, backendLayout);
610
737
  let facts;
611
- const preflightInventory = await buildBackendPytestAssetInventory(input.cwd, resolution.existingScripts, { requireMappedScripts: false });
738
+ const preflightInventory = await buildBackendPytestAssetInventory(input.cwd, resolution.existingScripts, { requireMappedScripts: false, layout: backendLayout });
612
739
  const priorityOnlyFacts = assessPriorityOnlyBackendPytestModules({
613
740
  phase: "initial",
614
741
  mappedScripts: resolution.expectedScripts,
@@ -618,7 +745,7 @@ async function executeBackendTestPipeline(input, meta) {
618
745
  facts = priorityOnlyFacts;
619
746
  }
620
747
  else if (resolution.missingScripts.length > 0) {
621
- const inventory = await buildBackendPytestAssetInventory(input.cwd, resolution.existingScripts, { requireMappedScripts: false });
748
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, resolution.existingScripts, { requireMappedScripts: false, layout: backendLayout });
622
749
  facts = assessMissingBackendPytestScripts({
623
750
  mappedScripts: resolution.expectedScripts,
624
751
  existingMappedScripts: resolution.existingScripts,
@@ -632,10 +759,11 @@ async function executeBackendTestPipeline(input, meta) {
632
759
  const scenario = await runBackendScenarioParamPreflight({
633
760
  workspaceRoot: input.cwd,
634
761
  runDir: meta.runDir,
762
+ layout: backendLayout,
635
763
  strictScenarioParamGate: Boolean((meta.spec.globalConstraints ?? []).some((item) => /strictScenarioParamGate\s*=\s*true/i.test(item))),
636
764
  });
637
765
  outputs.push(...scenario.outputs);
638
- const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
766
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts, { layout: backendLayout });
639
767
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
640
768
  const targets = mappedScripts.map(shellQuote).join(" ");
641
769
  const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
@@ -669,6 +797,7 @@ async function executeBackendTestPipeline(input, meta) {
669
797
  fixtureResolution = { exitCode: fixtureResult.exitCode ?? 2, stdout: fixtureResult.stdout, stderr: fixtureResult.stderr };
670
798
  }
671
799
  facts = assessBackendPytestCollection({
800
+ layout: backendLayout,
672
801
  phase: "initial",
673
802
  mappedScripts,
674
803
  inventory,
@@ -678,6 +807,43 @@ async function executeBackendTestPipeline(input, meta) {
678
807
  fixtureResolution,
679
808
  });
680
809
  }
810
+ try {
811
+ const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({
812
+ workspaceRoot: input.cwd,
813
+ layout: backendLayout,
814
+ taskId: meta.spec.sourceBinding?.taskId ?? "unbound",
815
+ });
816
+ const correspondenceReportPath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence-initial.md", correspondence.markdown);
817
+ const contractsDir = path.join(meta.runDir, "contracts");
818
+ await mkdir(contractsDir, { recursive: true });
819
+ const correspondenceFactsPath = path.join(contractsDir, "backend-test-markdown-pytest-correspondence-initial.json");
820
+ await writeFile(correspondenceFactsPath, JSON.stringify(correspondence.facts, null, 2), "utf8");
821
+ outputs.push(`initialCorrespondence=${correspondenceReportPath}`, `initialCorrespondenceFacts=${correspondenceFactsPath}`);
822
+ if (correspondence.facts.status === "FAIL" && facts.status === "PASS") {
823
+ const correspondenceRepairPaths = Array.from(new Set(correspondence.facts.entries
824
+ .filter((entry) => entry.status !== "EXACT_1_TO_1")
825
+ .flatMap((entry) => [entry.expectedScript, ...entry.actualScripts])
826
+ .filter((candidate) => candidate.startsWith("testcase/") && candidate.endsWith(".py")))).sort();
827
+ if (correspondenceRepairPaths.length > 0) {
828
+ facts.status = "REPAIRABLE";
829
+ facts.repairEligible = true;
830
+ facts.repairPaths = Array.from(new Set([...facts.repairPaths, ...correspondenceRepairPaths])).sort();
831
+ facts.findings.push({
832
+ kind: "markdown-pytest-correspondence",
833
+ classification: "test-asset-defect",
834
+ repairability: "repairable",
835
+ detail: correspondence.facts.entries
836
+ .filter((entry) => entry.status !== "EXACT_1_TO_1")
837
+ .flatMap((entry) => entry.findings)
838
+ .join("; ")
839
+ .slice(0, 12_000) || "Markdown-to-pytest correspondence is incomplete",
840
+ });
841
+ }
842
+ }
843
+ }
844
+ catch (error) {
845
+ outputs.push(`initialCorrespondence=UNAVAILABLE: ${error instanceof Error ? error.message : String(error)}`);
846
+ }
681
847
  const artifacts = await writeBackendPytestCollectionArtifacts({
682
848
  runDir: meta.runDir,
683
849
  stem: "initial",
@@ -715,10 +881,11 @@ async function executeBackendTestPipeline(input, meta) {
715
881
  const scenario = await runBackendScenarioParamPreflight({
716
882
  workspaceRoot: input.cwd,
717
883
  runDir: meta.runDir,
884
+ layout: backendLayout,
718
885
  strictScenarioParamGate: Boolean((meta.spec.globalConstraints ?? []).some((item) => /strictScenarioParamGate\s*=\s*true/i.test(item))),
719
886
  });
720
887
  outputs.push(...scenario.outputs);
721
- const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
888
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts, { layout: backendLayout });
722
889
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
723
890
  const targets = mappedScripts.map(shellQuote).join(" ");
724
891
  const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
@@ -753,6 +920,7 @@ async function executeBackendTestPipeline(input, meta) {
753
920
  fixtureResolution = { exitCode: fixtureResult.exitCode ?? 2, stdout: fixtureResult.stdout, stderr: fixtureResult.stderr };
754
921
  }
755
922
  const finalFacts = assessBackendPytestCollection({
923
+ layout: backendLayout,
756
924
  phase: "final",
757
925
  mappedScripts,
758
926
  inventory,
@@ -776,12 +944,57 @@ async function executeBackendTestPipeline(input, meta) {
776
944
  facts: effective,
777
945
  });
778
946
  const scenarioFacts = await readBackendScenarioParamFacts(path.join(meta.runDir, "contracts", "backend-test-scenario-param-consistency-facts.json"));
947
+ const correspondence = meta.spec.sourceBinding
948
+ ? await analyzeBackendTestMarkdownPytestCorrespondence({
949
+ workspaceRoot: input.cwd,
950
+ layout: backendLayout,
951
+ taskId: meta.spec.sourceBinding.taskId,
952
+ })
953
+ : undefined;
954
+ const eligibility = correspondence
955
+ ? buildBackendTestItemEligibility(effective.collectedItemIds, {
956
+ correspondenceEntries: correspondence.facts.entries,
957
+ scenarioEntries: scenarioFacts.entries,
958
+ })
959
+ : { eligibleItemIds: effective.collectedItemIds, excludedItems: [] };
960
+ const eligibilityInputHashes = Object.fromEntries((correspondence?.facts.inputFiles ?? []).map((item) => [item.path, item.sha256]));
961
+ const eligibilityFactsPath = path.join(meta.runDir, "contracts", "backend-test-execution-eligibility.json");
962
+ await mkdir(path.dirname(eligibilityFactsPath), { recursive: true });
963
+ await writeFile(eligibilityFactsPath, `${JSON.stringify({
964
+ schemaId: "backend-test-execution-eligibility-v1",
965
+ status: eligibility.eligibleItemIds.length === 0 ? "BLOCKED" : eligibility.excludedItems.length > 0 ? "PARTIAL" : "PASS",
966
+ collectedItemCount: effective.collectedItemIds.length,
967
+ eligibleItemCount: eligibility.eligibleItemIds.length,
968
+ excludedItemCount: eligibility.excludedItems.length,
969
+ eligibleItemIds: eligibility.eligibleItemIds,
970
+ excludedItems: eligibility.excludedItems,
971
+ inputHashes: eligibilityInputHashes,
972
+ }, null, 2)}\n`, "utf8");
973
+ const eligibilityReportPath = await writeRunReport(meta.runDir, "backend-test-execution-eligibility.md", [
974
+ "# Backend Test Execution Eligibility",
975
+ "",
976
+ "## Status",
977
+ "",
978
+ eligibility.eligibleItemIds.length === 0 ? "BLOCKED" : eligibility.excludedItems.length > 0 ? "PARTIAL" : "PASS",
979
+ "",
980
+ `- Collected items: ${effective.collectedItemIds.length}`,
981
+ `- Eligible items: ${eligibility.eligibleItemIds.length}`,
982
+ `- Excluded items: ${eligibility.excludedItems.length}`,
983
+ "",
984
+ "## Excluded Items",
985
+ "",
986
+ ...(eligibility.excludedItems.length > 0 ? eligibility.excludedItems.map((item) => `- \`${item.itemId}\` (${item.caseId ?? "unknown Case"}): ${item.reasons.join("; ")}`) : ["- None"]),
987
+ "",
988
+ ].join("\n"));
989
+ outputs.push(`executionEligibility=${eligibilityReportPath}`, `executionEligibilityFacts=${eligibilityFactsPath}`);
779
990
  const readiness = await materializeBackendTestExecutionReadiness({
780
991
  runDir: meta.runDir,
781
992
  workspaceRoot: input.cwd,
782
993
  effective,
783
994
  scenarioParamStatus: scenarioFacts.overallStatus,
784
995
  scenarioParamRepairAttempt: scenarioFacts.repairAttempt,
996
+ eligibility,
997
+ eligibilityInputHashes,
785
998
  });
786
999
  return {
787
1000
  ok: true,
@@ -792,6 +1005,8 @@ async function executeBackendTestPipeline(input, meta) {
792
1005
  collectedItemCount: effective.collectedItemCount,
793
1006
  fixtureResolutionStatus: effective.fixtureResolutionStatus,
794
1007
  executionReadinessStatus: readiness.status,
1008
+ eligibleItemCount: readiness.eligibleItemIds.length,
1009
+ excludedItemCount: readiness.excludedItems.length,
795
1010
  factsPath: "contracts/backend-test-pytest-collection-effective.json",
796
1011
  readinessPath: "contracts/backend-test-execution-readiness.json",
797
1012
  reportPath: artifacts.reportPath,
@@ -826,6 +1041,7 @@ async function executeBackendTestPipeline(input, meta) {
826
1041
  try {
827
1042
  const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({
828
1043
  workspaceRoot: input.cwd,
1044
+ layout: backendLayout,
829
1045
  taskId: sourceBinding.taskId,
830
1046
  });
831
1047
  const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence.markdown);
@@ -888,9 +1104,32 @@ async function executeBackendTestPipeline(input, meta) {
888
1104
  else if (pipeline === "markdown-execute-html") {
889
1105
  const executionReadiness = await readBackendTestExecutionReadiness(path.join(meta.runDir, "contracts", "backend-test-execution-readiness.json"));
890
1106
  await assertBackendTestExecutionReadinessFresh(input.cwd, executionReadiness);
891
- const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
892
- const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
893
- const pytestTargets = mappedScripts.map(shellQuote).join(" ");
1107
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd, backendLayout);
1108
+ let eligibleItemIds = executionReadiness.eligibleItemIds;
1109
+ // Plan D gap-fill executeScope=affected: when a gap plan exists in
1110
+ // this run, prefer the newly planned items (pytest node ids contain
1111
+ // the TP slot id) plus every collected item of the patched modules;
1112
+ // scope=all keeps the full eligible set.
1113
+ const affectedScope = meta.spec.globalConstraints?.some((item) => /^backendTestExecuteScope=affected$/i.test(item.trim())) ?? false;
1114
+ if (affectedScope) {
1115
+ try {
1116
+ const plan = JSON.parse(await readFile(path.join(meta.runDir, "contracts", "backend-test-gap-plan-v1.json"), "utf8"));
1117
+ const newPoints = new Set(plan.newTestPoints ?? []);
1118
+ const modules = new Set((plan.targetModules ?? []).map((stem) => `test_${stem}.py`));
1119
+ const affected = eligibleItemIds.filter((id) => modules.size === 0 ||
1120
+ [...modules].some((script) => id.includes(script)) ||
1121
+ [...newPoints].some((point) => id.includes(point)));
1122
+ if (affected.length > 0)
1123
+ eligibleItemIds = affected;
1124
+ }
1125
+ catch {
1126
+ // no gap plan in this run: execute the full eligible set
1127
+ }
1128
+ }
1129
+ const pytestTargetsRelative = "reports/backend-test-pytest-targets.txt";
1130
+ const pytestTargetsAbsolute = path.join(meta.runDir, pytestTargetsRelative);
1131
+ await writeFile(pytestTargetsAbsolute, `${eligibleItemIds.join("\n")}\n`, "utf8");
1132
+ const pytestTargetsArg = `@\"\${HARNESS_DAG_RUN_DIR}/${pytestTargetsRelative}\"`;
894
1133
  // Split into short bash -c commands (aligned with markdown-environment).
895
1134
  // A single ultra-long compound command has been observed on Windows to
896
1135
  // exit 0xC0000142 (STATUS_DLL_INIT_FAILED) in ~40ms with empty stdout/
@@ -967,8 +1206,10 @@ async function executeBackendTestPipeline(input, meta) {
967
1206
  'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
968
1207
  'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
969
1208
  'echo "STEP=pytest-run"',
970
- `echo "- pytestCommand: python -m pytest ${mappedScripts.join(" ")} -v -p no:cacheprovider --html=reports/backend-test.html --self-contained-html" >> "\${DIAG_FILE}"`,
971
- `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 "\${PYTHON_BIN}" -m pytest ${pytestTargets} -v -p no:cacheprovider --html="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" --self-contained-html`,
1209
+ `echo "- pytestCommand: python -m pytest <${eligibleItemIds.length} eligibility-authorized-items> -v -p no:cacheprovider --html=reports/backend-test.html --self-contained-html" >> "\${DIAG_FILE}"`,
1210
+ `echo "- eligibleItemCount: ${eligibleItemIds.length}" >> "\${DIAG_FILE}"`,
1211
+ `echo "- excludedItemCount: ${executionReadiness.excludedItems.length}" >> "\${DIAG_FILE}"`,
1212
+ `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 "\${PYTHON_BIN}" -m pytest ${pytestTargetsArg} -v -p no:cacheprovider --html="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" --self-contained-html`,
972
1213
  "STATUS=$?",
973
1214
  'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
974
1215
  'echo "- pytestExitCode: ${STATUS}" >> "${DIAG_FILE}"',
@@ -1053,11 +1294,13 @@ async function executeBackendTestPipeline(input, meta) {
1053
1294
  htmlContent: pytestHtmlContent,
1054
1295
  pytestExitCode,
1055
1296
  });
1056
- const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
1297
+ const cases = await collectBackendTestHumanCaseCatalog(input.cwd, backendLayout);
1057
1298
  const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
1058
1299
  const caseCoverageSummary = await readAdvisoryRunReport(reportsDir, "backend-test-case-coverage-analysis.md", "Backend Test Case Coverage Analysis");
1059
1300
  const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
1060
- const correspondenceSummary = await readAdvisoryRunReport(reportsDir, "backend-test-markdown-pytest-correspondence.md", "Backend Test Markdown → pytest Correspondence");
1301
+ const correspondenceSummaryBase = await readAdvisoryRunReport(reportsDir, "backend-test-markdown-pytest-correspondence.md", "Backend Test Markdown → pytest Correspondence");
1302
+ const eligibilitySummary = await readAdvisoryRunReport(reportsDir, "backend-test-execution-eligibility.md", "Backend Test Execution Eligibility");
1303
+ const correspondenceSummary = `${correspondenceSummaryBase.trim()}\n\n${eligibilitySummary.trim()}\n`;
1061
1304
  const htmlContent = renderBackendTestHtml({
1062
1305
  title: meta.spec.title,
1063
1306
  parsed,
@@ -1245,6 +1488,7 @@ async function executeBackendTestPipeline(input, meta) {
1245
1488
  fromNodeId: "backend-test-execution-envelope",
1246
1489
  artifactName: "backend-test-execution.json",
1247
1490
  outputDir: "contracts",
1491
+ allowedTestRoot: backendLayout.testRoot,
1248
1492
  });
1249
1493
  outputs.push(`analysis=${analysis.path}`, `execution=${execution.path}`);
1250
1494
  }
@@ -1360,6 +1604,7 @@ async function executeBackendTestPipeline(input, meta) {
1360
1604
  artifactName: "backend-test-result-initial.json",
1361
1605
  outputDir: "contracts",
1362
1606
  junitRelativePath: "reports/backend-test-initial-junit.xml",
1607
+ testRoot: backendLayout.testRoot,
1363
1608
  });
1364
1609
  outputs.push(...results.map((result) => result.stdout), `result=${artifact.path}`);
1365
1610
  }
@@ -2246,6 +2491,13 @@ export async function executeDagShellNode(input, meta) {
2246
2491
  }
2247
2492
  if (shell?.jsonArtifactGate) {
2248
2493
  const started = Date.now();
2494
+ const backendLayout = resolveBackendTestLayout(meta.spec.backendTestLayout
2495
+ ? {
2496
+ testRoot: meta.spec.backendTestLayout.testRoot,
2497
+ markdownRel: meta.spec.backendTestLayout.markdownRel,
2498
+ scriptRel: meta.spec.backendTestLayout.scriptRel,
2499
+ }
2500
+ : undefined);
2249
2501
  try {
2250
2502
  const gate = shell.jsonArtifactGate;
2251
2503
  const sourceNodeId = gate.fallbackFromNodeIds?.length
@@ -2273,6 +2525,7 @@ export async function executeDagShellNode(input, meta) {
2273
2525
  fromNodeId: sourceNodeId,
2274
2526
  artifactName: gate.artifactName,
2275
2527
  outputDir: gate.outputDir,
2528
+ allowedTestRoot: backendLayout.testRoot,
2276
2529
  });
2277
2530
  break;
2278
2531
  case "backend-test-result-v1":
@@ -2282,6 +2535,7 @@ export async function executeDagShellNode(input, meta) {
2282
2535
  artifactName: gate.artifactName,
2283
2536
  outputDir: gate.outputDir,
2284
2537
  junitRelativePath: gate.junitRelativePath,
2538
+ testRoot: backendLayout.testRoot,
2285
2539
  });
2286
2540
  break;
2287
2541
  case "backend-test-classification-v1":
@@ -1,13 +1,14 @@
1
1
  import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { assertHarnessWriteAllowed, } from "./completed-facts-guard.js";
4
+ let atomicWriteSequence = 0;
4
5
  async function writeAtomic(targetPath, content, options) {
5
6
  assertHarnessWriteAllowed(targetPath, options);
6
7
  const resolved = options?.repoRoot && !path.isAbsolute(targetPath)
7
8
  ? path.resolve(options.repoRoot, targetPath)
8
9
  : path.resolve(targetPath);
9
10
  await mkdir(path.dirname(resolved), { recursive: true });
10
- const tempPath = `${resolved}.${process.pid}.tmp`;
11
+ const tempPath = `${resolved}.${process.pid}.${(atomicWriteSequence += 1)}.tmp`;
11
12
  try {
12
13
  await writeFile(tempPath, content, "utf-8");
13
14
  try {
@@ -14,6 +14,30 @@ export const OPENSPEC_SPEC_DIRS = [
14
14
  ];
15
15
  /** Allowed spec file extensions (case-insensitive). */
16
16
  export const OPENSPEC_SPEC_EXT_RE = /\.(md|mdx|json|yaml|yml)$/i;
17
+ /**
18
+ * Default governance subtree excluded from candidate discovery. Aligned with
19
+ * init.ts's DEFAULT_GOVERNANCE_ROOT; discovery derives the real root from
20
+ * harness.json.governanceRoot and falls back to this value.
21
+ */
22
+ export const DEFAULT_OPENSPEC_GOVERNANCE_ROOT = "ai_workspace/loop-agent";
23
+ /**
24
+ * Explicit safety ceiling for auto-discovered openspec candidates (>=200).
25
+ * Reaching the cap is reported via DagSpec.advisories, never silently dropped.
26
+ */
27
+ export const MAX_OPENSPEC_CANDIDATE_PATHS = 200;
28
+ /**
29
+ * Returns `true` when `filePath` is the governance subtree itself or any path
30
+ * under it. `filePath` must be a repo-relative POSIX path (backslashes are
31
+ * normalised internally). Comparison is case-sensitive, matching the other
32
+ * predicates in this module.
33
+ */
34
+ export function isGovernanceSubtreePath(filePath, governanceRoot) {
35
+ const normalized = filePath.replaceAll("\\", "/");
36
+ const root = governanceRoot.replaceAll("\\", "/").replace(/\/+$/, "");
37
+ if (!root)
38
+ return false;
39
+ return normalized === root || normalized.startsWith(root + "/");
40
+ }
17
41
  /**
18
42
  * Returns `true` when `filePath` is inside one of the canonical frontend spec
19
43
  * directories. `filePath` must be a repo-relative POSIX path (backslashes are
@@ -47,3 +71,55 @@ export function isOpenspecSpecSearchTarget(value) {
47
71
  const normalized = value.replaceAll("\\", "/");
48
72
  return /(?:^|[^A-Za-z0-9_.-])(?:openspec\/(?:schemas|project-specs)|ai_workspace)(?:\/|$)/.test(normalized);
49
73
  }
74
+ /** Normalizes a free-form path token to a repo-relative POSIX path or null. */
75
+ function normalizeCitationToken(raw) {
76
+ let token = raw.trim();
77
+ token = token.replace(/^[<"'`(\[\s]+|[>"'`)\]\s]+$/g, "");
78
+ // Strip trailing punctuation that is not part of a filename.
79
+ token = token.replace(/[),.;:!?]+$/g, "");
80
+ const hashIndex = token.indexOf("#");
81
+ if (hashIndex >= 0)
82
+ token = token.slice(0, hashIndex);
83
+ token = token.replace(/\\/g, "/");
84
+ if (!token)
85
+ return null;
86
+ return token;
87
+ }
88
+ /**
89
+ * Deterministically extracts repo-relative openspec spec paths explicitly cited
90
+ * in task-source markdown (requirement + execution constraint). Pure function,
91
+ * no IO.
92
+ *
93
+ * Scanned sources, in order:
94
+ * - backtick inline code spans (`path`)
95
+ * - markdown link targets ([text](path))
96
+ * - bare path tokens under openspec/ or ai_workspace/
97
+ *
98
+ * Every token is normalized to POSIX, stripped of a trailing `#fragment`,
99
+ * filtered through `isOpenspecSpecFilePath`, and excluded when it falls inside
100
+ * the governance subtree (matching `listOpenspecDiscovery`'s exclusion口径).
101
+ * Results are deduped and sorted.
102
+ */
103
+ export function extractTaskSourceOpenspecPaths(markdown, governanceRoot = DEFAULT_OPENSPEC_GOVERNANCE_ROOT) {
104
+ if (!markdown)
105
+ return [];
106
+ const raw = new Set();
107
+ const collect = (token) => {
108
+ const normalized = normalizeCitationToken(token);
109
+ if (normalized)
110
+ raw.add(normalized);
111
+ };
112
+ for (const match of markdown.matchAll(/`([^`\n]+)`/g)) {
113
+ collect(match[1] ?? "");
114
+ }
115
+ for (const match of markdown.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)) {
116
+ collect(match[1] ?? "");
117
+ }
118
+ for (const match of markdown.matchAll(/(?:^|[\s"'([>])((?:openspec|ai_workspace)\/[A-Za-z0-9._/-]+)/g)) {
119
+ collect(match[1] ?? "");
120
+ }
121
+ return [...raw]
122
+ .filter((token) => isOpenspecSpecFilePath(token) &&
123
+ !isGovernanceSubtreePath(token, governanceRoot))
124
+ .sort();
125
+ }