@tea-agent/loop-agent 0.38.2 → 0.38.4

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 (192) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +171 -96
  3. package/README.md +11 -5
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/application/task-lifecycle/advance.js +4 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/command-definitions.js +16 -5
  8. package/dist/cli/program.js +16 -2
  9. package/dist/cli/update/notifier.js +2 -2
  10. package/dist/cli.js +2 -1
  11. package/dist/commands/dag-rerun.js +55 -1
  12. package/dist/commands/init-model-catalog.js +464 -0
  13. package/dist/commands/init-upgrade.js +265 -97
  14. package/dist/commands/init.js +428 -27
  15. package/dist/commands/inspect-next.js +8 -0
  16. package/dist/commands/task-advance.js +31 -0
  17. package/dist/executors/dag-pi-executor.js +76 -10
  18. package/dist/executors/pi-event-serializer.js +42 -1
  19. package/dist/executors/pi-executor.js +51 -0
  20. package/dist/executors/pi-extension-resolver.js +233 -0
  21. package/dist/executors/pi-playwright-cli-tool.js +51 -12
  22. package/dist/executors/pi-sdk-executor.js +187 -59
  23. package/dist/executors/shell-executor.js +265 -15
  24. package/dist/executors/shell-write-guard.js +7 -0
  25. package/dist/shared/dag-prompt-override.js +28 -0
  26. package/dist/shared/operator/capabilities.js +32 -0
  27. package/dist/shared/playwright-cli-command-policy.js +15 -0
  28. package/dist/shared/update/console-notifier.js +100 -0
  29. package/dist/{cli → shared}/update/npm-client.js +41 -8
  30. package/dist/{cli → shared}/update/state.js +41 -13
  31. package/dist/task/contract/constants.js +1 -0
  32. package/dist/task/contract/diff.js +26 -0
  33. package/dist/task/contract/project.js +5 -0
  34. package/dist/task/contract/schema.js +6 -1
  35. package/dist/task/source-prepare/build-draft.js +15 -0
  36. package/dist/task/source-prepare/parse-intent.js +24 -4
  37. package/dist/worker/cli.js +31 -1
  38. package/dist/worker/console/chat/routes.js +1 -0
  39. package/dist/worker/console/dag-execution-receipt.js +33 -0
  40. package/dist/worker/console/operator-actions.js +116 -0
  41. package/dist/worker/console/operator-user-error.js +2 -2
  42. package/dist/worker/console/pi-readiness.js +4 -4
  43. package/dist/worker/console/routes.js +43 -2
  44. package/dist/worker/console/security.js +63 -9
  45. package/dist/worker/console/server.js +36 -5
  46. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4bmo3iY.js → abnfDiagram-N423BO3Z-gauhD9kD.js} +1 -1
  47. package/dist/worker/console/static/assets/{arc-SAD7McOS.js → arc-pm3c2Rsl.js} +1 -1
  48. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DAVLUdK1.js → architectureDiagram-T3A2C74G-D_JAg3wH.js} +1 -1
  49. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CPPdv4vj.js → blockDiagram-VBNYF7ZC-Dk7-ex52.js} +1 -1
  50. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-D1GRGZax.js → c4Diagram-5PPSVZJV-Uval9oRI.js} +1 -1
  51. package/dist/worker/console/static/assets/channel-CVrnUdZl.js +1 -0
  52. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D_Bq2ZqK.js → chunk-2GRJ4B5K-BSYjHs-v.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CPLVtamK.js → chunk-2Q5K7J3B-KGUuBXI8.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DnaZWcL3.js → chunk-5RXB4S5H-JqAtxD9N.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BRTmFgnq.js → chunk-5VM5RSS4-D6EJOVQK.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BRlfYZoH.js → chunk-6Q2QTUOP-B1JriSjm.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CjNybD4u.js → chunk-GF5L2VYU-D62b96Jd.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-D3e-74fG.js → chunk-JWPE2WC7-BsNKEC_r.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Br3ir6f2.js → chunk-KBJHAD2P-B56v6-ZP.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-1NgVuRbh.js → chunk-RYQCIY6F-D2KPnd8K.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BTYuvuWN.js → chunk-XXDRQBXY-kzKSCaaL.js} +1 -1
  62. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-aX91WTya.js +1 -0
  63. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-aX91WTya.js +1 -0
  64. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BC6Bgosf.js → cose-bilkent-JH36ORCC-C6N6KNJd.js} +1 -1
  65. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DV-HypfL.js → cynefin-VYW2F7L2-DiyymT2D.js} +1 -1
  66. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DhTaddO-.js → cynefinDiagram-MW4NZA55-BeKAXvf3.js} +1 -1
  67. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BnEcOmiI.js → dagre-VZM6K2ZE-TkIdXfPF.js} +1 -1
  68. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-xphGRVKp.js → diagram-7IWD3JNH-BFmSG3ks.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CrdD1mRG.js → diagram-B4RE2ZJO-DqpJT2Fs.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-D4YdLsaF.js → diagram-LBJQPF4R-r5ATN43t.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-LCKyGZ5U.js → diagram-Q27KOJAE-DEcSCMuR.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BeiTe7t0.js → diagram-UB23O5K3-VOvYRLVc.js} +1 -1
  73. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BO2cq8GS.js → ebnfDiagram-BXEA7PRR-D7f4Cb1u.js} +1 -1
  74. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-CmWww3Nt.js → erDiagram-JOGREHBK-ziUf3U0G.js} +1 -1
  75. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-C2kkUYg5.js → flowDiagram-UKHOOZJN-BoRdQ-1y.js} +1 -1
  76. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DY_JZ2iQ.js → ganttDiagram-PKOTCBZU-B1-GncNm.js} +1 -1
  77. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BizhCSw9.js → gitGraphDiagram-DS77QQ5N-CTW-ph8W.js} +1 -1
  78. package/dist/worker/console/static/assets/index-C8rOIAJr.css +1 -0
  79. package/dist/worker/console/static/assets/{index-BXN89VPX.js → index-DEbyrEfW.js} +147 -68
  80. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Dlo2eK_d.js → infoDiagram-6WML65LV-BFA337tZ.js} +1 -1
  81. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js → ishikawaDiagram-WSZJBQD7-BtEujgRR.js} +1 -1
  82. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DiMDe5Yh.js → journeyDiagram-NVQOT4AX-C8ZwD2Jp.js} +1 -1
  83. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-sN553oIN.js → kanban-definition-27J2QSJJ-Ck-z8B11.js} +1 -1
  84. package/dist/worker/console/static/assets/{linear-D9Py31Ld.js → linear-CAyXjQjN.js} +1 -1
  85. package/dist/worker/console/static/assets/{mermaid.core-D7Occzk_.js → mermaid.core-CcWKn6tS.js} +5 -5
  86. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DjDRboiR.js → mindmap-definition-FAOFIHXS-C2NuDO7E.js} +1 -1
  87. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Cgc4qlzp.js → pegDiagram-VL7TDLO6-BFub3PDz.js} +1 -1
  88. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DxZj8pKd.js → pieDiagram-7S7Q4E2Y-BJx5THZ5.js} +1 -1
  89. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CJ50MngR.js → quadrantDiagram-CIZ2JOQS-DhalkDWV.js} +1 -1
  90. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BY-OD8Tr.js → railroadDiagram-AXF67PYL-CVcBM36o.js} +1 -1
  91. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CQFButfK.js → requirementDiagram-LRYGKXZP-D_vy4n0w.js} +1 -1
  92. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BB_QUK_b.js → sankeyDiagram-W5VNT64P-DGYdJL1D.js} +1 -1
  93. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CqhXZjyx.js → sequenceDiagram-SI44F4Z6-C1xJFg3k.js} +1 -1
  94. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-2l1U4rgJ.js → sizeCapture-X5ZJPWSS-D_Fc43df.js} +1 -1
  95. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BX1FbrhJ.js → stateDiagram-OKZ733FA-BhGbIjbu.js} +1 -1
  96. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DUt1lyFL.js +1 -0
  97. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DOSnT7hb.js → swimlanes-SLNWSIFB-DVkNcO-V.js} +2 -2
  98. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BM6SGr7j.js +8 -0
  99. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DVJQA7AN.js → timeline-definition-Z64GVDOM-BQU9ufV0.js} +1 -1
  100. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-74uxl9gx.js → vennDiagram-T6HMQDX7-BCQhRmYB.js} +1 -1
  101. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DsBxlcg_.js → wardleyDiagram-T6FBY63Y-DWerOyHO.js} +1 -1
  102. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-snuW9Mjw.js → xychartDiagram-ELKLHX3M-DxLeebQU.js} +1 -1
  103. package/dist/worker/console/static/index.html +2 -2
  104. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  105. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  106. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  107. package/dist/worker/observe/node-input.js +74 -3
  108. package/dist/worker/observe/node-process.js +377 -0
  109. package/dist/worker/observe/routes.js +36 -3
  110. package/dist/worker/observe/shell-handler-keys.js +34 -0
  111. package/dist/worker/observe/static/api.js +66 -0
  112. package/dist/worker/observe/static/constants.js +8 -1
  113. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  114. package/dist/worker/observe/static/format.js +23 -0
  115. package/dist/worker/observe/static/markdown-render.js +20 -1
  116. package/dist/worker/observe/static/operator-chrome.js +4 -0
  117. package/dist/worker/observe/static/state.js +13 -2
  118. package/dist/worker/observe/static/styles.css +73 -0
  119. package/dist/worker/observe/static/views/dag-inspector.js +612 -8
  120. package/dist/worker/observe/static/views/dag.js +8 -1
  121. package/dist/worker/observe/static/views/failures.js +5 -2
  122. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  123. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +76 -6
  124. package/dist/workflows/dag/backend-test-scenario-partitions.js +73 -1
  125. package/dist/workflows/dag/contract-output-registry.js +15 -0
  126. package/dist/workflows/dag/failure-category.js +12 -0
  127. package/dist/workflows/dag/frontend-implementation-contract.js +145 -40
  128. package/dist/workflows/dag/frontend-prewrite-gate.js +87 -135
  129. package/dist/workflows/dag/frontend-repair.js +14 -0
  130. package/dist/workflows/dag/frontend-risk.js +15 -2
  131. package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
  132. package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
  133. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  134. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  135. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  136. package/dist/workflows/dag/frontend-test-result-contract.js +160 -28
  137. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  138. package/dist/workflows/dag/frontend-verification-trace.js +29 -4
  139. package/dist/workflows/dag/init-hybrid.js +196 -117
  140. package/dist/workflows/dag/node-execution.js +139 -10
  141. package/dist/workflows/dag/prompt.js +15 -1
  142. package/dist/workflows/dag/rerun-plan.js +134 -3
  143. package/dist/workflows/dag/rerun-run.js +58 -6
  144. package/dist/workflows/dag/retry-policy.js +91 -0
  145. package/dist/workflows/dag/structured-output-repair.js +712 -0
  146. package/dist/workflows/dag/types.js +32 -1
  147. package/dist/workflows/dag/validate.js +41 -1
  148. package/docs/init-surface.manifest.json +5 -0
  149. package/docs/operations/README.md +1 -1
  150. package/docs/operations/local-development-environment.md +1 -5
  151. package/docs/skills/vetted-skill-registry.md +14 -0
  152. package/docs/templates/README.md +1 -1
  153. package/docs/templates/agent-dag.schema.json +44 -0
  154. package/docs/templates/backend-test-dag.json +2 -2
  155. package/docs/templates/frontend-test-dag.json +8 -10
  156. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  157. package/docs/templates/init-managed-agents.md +13 -3
  158. package/harness.json +2 -2
  159. package/package.json +12 -4
  160. package/scripts/next-info.mjs +356 -0
  161. package/scripts/next-publish-gate.mjs +238 -0
  162. package/scripts/release-source-binding.mjs +251 -0
  163. package/skills/analyze-product-requirements/SKILL.md +8 -5
  164. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  165. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  166. package/skills/analyze-product-requirements/references/example.md +50 -0
  167. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  168. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  169. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  170. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  171. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  172. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  173. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  174. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  175. package/skills/codebase-scout/SKILL.md +1 -1
  176. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  177. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  178. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  179. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  180. package/skills/improve-codebase-architecture/deepening.md +37 -0
  181. package/skills/improve-codebase-architecture/html-report.md +123 -0
  182. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  183. package/skills/improve-codebase-architecture/language.md +53 -0
  184. package/skills/loop-agent/references/command-reference.md +11 -4
  185. package/skills/playwright-cli/SKILL.md +22 -0
  186. package/skills/playwright-cli-case-generator/SKILL.md +20 -1
  187. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +0 -1
  188. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +0 -1
  189. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +0 -1
  190. package/dist/worker/console/static/assets/index-CuH5CNFu.css +0 -1
  191. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +0 -1
  192. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +0 -8
@@ -11,7 +11,7 @@ import { pathMatchesPattern } from "../../shared/git-progress.js";
11
11
  import { extractTaskSourceOpenspecPaths } from "../../shared/openspec-spec.js";
12
12
  import { BASELINE_FORBIDDEN_PATHS } from "./governance-constants.js";
13
13
  import { buildDecisionEnvelopePromptContract } from "./decision-envelope.js";
14
- import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PLANNER_OUTPUT_LIMIT_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRUCTURED_REQUIRED_PI_RETRY_POLICY, BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY, WRITER_TRANSPORT_RETRY_POLICY, isSafeReadOnlyPiRetryCandidate, isWriterTransportRetryCandidate, } from "./retry-policy.js";
14
+ import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PLANNER_OUTPUT_LIMIT_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRUCTURED_REQUIRED_PI_RETRY_POLICY, BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY, WRITER_TRANSPORT_RETRY_POLICY, TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE, TARGET_TEMPLATE_WRITER_TRANSPORT_RETRY_POLICY, isCanonicalFinalVerifyShellRetryCandidate, isSafeReadOnlyPiRetryCandidate, isTargetTemplateImplementPi, isWriterTransportRetryCandidate, } from "./retry-policy.js";
15
15
  import { REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL, REVIEW_VERDICT_OUTPUT_PROTOCOL, } from "./output-protocol.js";
16
16
  import { resolveAdapter } from "../../adapters/index.js";
17
17
  import { loadHarnessManifest } from "../../governance/harness.js";
@@ -21,7 +21,6 @@ import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-em
21
21
  import { discoverProjectGovernancePresence } from "./project-governance-context.js";
22
22
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
23
23
  import { materializeTaskReferenceDocs } from "../../task/source-references.js";
24
- import { REQUIREMENT_FACT_ROLES } from "../../task/source-prepare/artifact-meta.js";
25
24
  import { observeTaskContract } from "../../task/contract/observe.js";
26
25
  import { dagHasWriterExecution } from "./task-contract-binding.js";
27
26
  import { DEFAULT_VERIFY_TIMEOUT_MS, resolveVerifyPreset, } from "../../executors/shell-verification.js";
@@ -35,6 +34,7 @@ import { buildFrontendTestOutcomeGateShellSnippet } from "./frontend-test-result
35
34
  import { classifyFrontendRisk, } from "./frontend-risk.js";
36
35
  import { discoverFrontendProjectCapability, } from "./frontend-project-capability.js";
37
36
  import { buildFrontendImplementationContractSkeleton, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID, loadFrontendImplementationContractJsonSchema, } from "./frontend-implementation-contract.js";
37
+ import { FRONTEND_NO_VERIFICATION_MARKER_TEXT } from "./frontend-verification-trace.js";
38
38
  import { serializeDagTaskSourcePath } from "../../task/dag-source-paths.js";
39
39
  const REQUIREMENT_FILE = "需求.md";
40
40
  const CONSTRAINT_FILE = "执行约束.md";
@@ -1282,11 +1282,15 @@ function deriveParallelScoutPaths(taskConfig) {
1282
1282
  : allowed,
1283
1283
  };
1284
1284
  }
1285
+ const FRONTEND_NO_STATIC_VERIFICATION_MARKER = `node -e "console.log('${FRONTEND_NO_VERIFICATION_MARKER_TEXT}; static/behavior verification not-run')"`;
1285
1286
  /**
1286
1287
  * Resolve frontend verification fallbacks from the target project's own
1287
1288
  * package scripts. The DAG builder is also used by unit fixtures without a
1288
- * package.json, so those fixtures retain the historical generic fallback.
1289
- * Real projects never inherit loop-agent's commands when package.json exists.
1289
+ * repoRoot, so those fixtures retain the historical generic fallback. A real
1290
+ * project (repoRoot set) without a readable package.json has no scripts to
1291
+ * invoke; the generic fallback would only inject commands guaranteed to fail
1292
+ * at verify time, so such projects fall through to the tsc probe and may end
1293
+ * up with no fallback commands plus a generation-time advisory.
1290
1294
  */
1291
1295
  async function discoverFrontendFallbackVerifyCommands(repoRoot) {
1292
1296
  const genericFallback = {
@@ -1303,10 +1307,8 @@ async function discoverFrontendFallbackVerifyCommands(repoRoot) {
1303
1307
  }
1304
1308
  }
1305
1309
  catch {
1306
- return genericFallback;
1310
+ // No readable manifest: leave scripts unset and keep probing.
1307
1311
  }
1308
- if (!scripts)
1309
- return genericFallback;
1310
1312
  let packageManager = "npm";
1311
1313
  for (const [lockfile, manager] of [
1312
1314
  ["pnpm-lock.yaml", "pnpm"],
@@ -1383,19 +1385,66 @@ function toDagSourcePath(sources, absolutePath) {
1383
1385
  absolutePath,
1384
1386
  });
1385
1387
  }
1386
- function extractExplicitRequirementIds(...markdownInputs) {
1388
+ /**
1389
+ * Declaration-line shapes that assert a requirement id: list items
1390
+ * ("- AC-1: ..."), numbered/ordered acceptance entries ("1. AC-1 ...",
1391
+ * "1、AC-1"), table rows ("| AC-1 | ..."), and explicit key/value or
1392
+ * heading declarations ("AC-1:...", "id: AC-1"). Ids that merely appear
1393
+ * inside narrative prose (e.g. a fixture run id mentioned in background:
1394
+ * "夹具 run 2026-08-23-req-f03535aa 已存在") are incidental mentions, not
1395
+ * declared requirements, and must not pollute sourceBinding.requirementIds.
1396
+ */
1397
+ const REQUIREMENT_ID_DECLARATION_LINE = /^\s*(?:[-+*]\s+|\d+[.、))]\s*|\|\s*)?(?:[-A-Z0-9]+\s*[::]\s*)?(?:\*\*)?(?:REQ|BR|AC)-/i;
1398
+ function isRequirementIdDeclarationLine(line) {
1399
+ return REQUIREMENT_ID_DECLARATION_LINE.test(line);
1400
+ }
1401
+ function extractExplicitRequirementIds(requirementMarkdown, ...fallbackMarkdown) {
1387
1402
  const ids = [];
1388
1403
  const seen = new Set();
1389
- for (const markdown of markdownInputs) {
1404
+ const collect = (markdown, declarationsOnly) => {
1390
1405
  if (!markdown)
1391
- continue;
1392
- for (const match of markdown.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
1406
+ return;
1407
+ const source = declarationsOnly
1408
+ ? markdown
1409
+ .split(/\r?\n/)
1410
+ .filter((line) => isRequirementIdDeclarationLine(line))
1411
+ .join("\n")
1412
+ : markdown;
1413
+ for (const match of source.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
1393
1414
  const id = match[0].toUpperCase();
1394
1415
  if (!seen.has(id)) {
1395
1416
  seen.add(id);
1396
1417
  ids.push(id);
1397
1418
  }
1398
1419
  }
1420
+ };
1421
+ // The requirement document itself is human-authored acceptance prose:
1422
+ // only declaration lines declare requirements there. If the document has
1423
+ // no declaration-shaped lines at all (minimal free-form tasks like
1424
+ // "covers AC-001"), fall back to its full text so bindings never go
1425
+ // missing for unstructured input. References and constraints are bound
1426
+ // documents (acceptance yaml, analysis docs) whose ids are authoritative
1427
+ // wherever they appear — keep full scan for them.
1428
+ const declarationLines = requirementMarkdown
1429
+ .split(/\r?\n/)
1430
+ .filter((line) => isRequirementIdDeclarationLine(line));
1431
+ collect(requirementMarkdown, declarationLines.length > 0);
1432
+ for (const markdown of fallbackMarkdown) {
1433
+ if (markdown)
1434
+ collect(markdown, false);
1435
+ }
1436
+ return ids;
1437
+ }
1438
+ /** Extract ids from an explicit authoritative section (full scan inside it). */
1439
+ function extractSectionRequirementIds(sectionMarkdown) {
1440
+ const ids = [];
1441
+ const seen = new Set();
1442
+ for (const match of sectionMarkdown.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
1443
+ const id = match[0].toUpperCase();
1444
+ if (!seen.has(id)) {
1445
+ seen.add(id);
1446
+ ids.push(id);
1447
+ }
1399
1448
  }
1400
1449
  return ids;
1401
1450
  }
@@ -1409,7 +1458,7 @@ export function extractTaskScopedRequirementIds(requirementMarkdown, ...fallback
1409
1458
  // Without it, preserve legacy full-scan behavior (requirement + references).
1410
1459
  const sectionMatch = requirementMarkdown.match(/(?:^|\n)##\s*Acceptance References\s*\n([\s\S]*?)(?=\n##\s+|\n#\s+|$)/i);
1411
1460
  if (sectionMatch?.[1]) {
1412
- const fromSection = extractExplicitRequirementIds(sectionMatch[1]);
1461
+ const fromSection = extractSectionRequirementIds(sectionMatch[1]);
1413
1462
  if (fromSection.length > 0)
1414
1463
  return fromSection;
1415
1464
  }
@@ -1502,27 +1551,11 @@ function buildSourceContextBlock(sources) {
1502
1551
  .relative(path.join(sources.taskDir, "source"), reference.path)
1503
1552
  .replaceAll(path.sep, "/");
1504
1553
  const referenceRef = toDagSourcePath(sources, reference.path);
1505
- // Fact-source roles (requirement/acceptance) carry the fields contracts
1506
- // depend on (AC/scope/columns). Inject them in full so a fixed excerpt
1507
- // cannot silently drop definitions; archival roles stay excerpted.
1508
- const isFactRole = reference.role !== undefined &&
1509
- REQUIREMENT_FACT_ROLES.has(reference.role);
1510
- const referenceExcerpt = isFactRole
1511
- ? {
1512
- text: reference.markdown.trim(),
1513
- truncated: false,
1514
- originalChars: reference.markdown.trim().length,
1515
- maxChars: Number.POSITIVE_INFINITY,
1516
- }
1517
- : excerptMarkdown(reference.markdown, {
1518
- sourceRef: referenceRef,
1519
- });
1520
- boundReadPaths.push(`- reference ${relativePath}${isFactRole ? " (full source)" : ""}: ${referenceRef}`);
1521
- parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, ...(isFactRole
1522
- ? [
1523
- `Full source injected (role: ${reference.role}) — complete and authoritative; no excerpt truncation applied.`,
1524
- ]
1525
- : []), referenceExcerpt.text);
1554
+ const referenceExcerpt = excerptMarkdown(reference.markdown, {
1555
+ sourceRef: referenceRef,
1556
+ });
1557
+ boundReadPaths.push(`- reference ${relativePath}: ${referenceRef}`);
1558
+ parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, referenceExcerpt.text);
1526
1559
  }
1527
1560
  parts.push("## Bound source read paths", ...boundReadPaths, "Use these repository-readable paths for any Pi read-tool calls. Bound files under `.harness/tasks/<taskId>/source/**` are read-only inputs: reading them is allowed even though writing `.harness/**` is forbidden.", "Never resolve task-relative citations such as `source/需求.md` or `source/references/*` against the repository root, invent `source/<taskId>/...`, search for substitutes, or fall back to `docs/**` when a bound read fails.", "## Task config summary", `- taskId: ${sources.taskConfig.taskId}`, `- flow: ${sources.taskConfig.flow}`, `- complexity: ${sources.taskConfig.complexity}`, `- contextProfile: ${sources.taskConfig.contextProfile}`, `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none — review before execute)"}`, `- forbiddenPaths: ${sources.taskConfig.forbiddenPaths.join(", ") || "(none)"}`, '- Pi DAG nodes are read-only unless toolProfile="write" is explicitly selected for a bounded writer node.', "- Agent DAG read-only nodes must not write root artifacts/**; root artifacts/ is not a per-node scratchpad.", `- Derived execution contract and immutable references live under the Bound source read paths above (not as repo-root \`source/...\`).`);
1528
1561
  if (sources.taskConfig.hardConstraints.length > 0) {
@@ -1560,49 +1593,10 @@ async function loadMaterializedSourceReferences(sourceDir) {
1560
1593
  }
1561
1594
  await collect(referenceDir);
1562
1595
  referencePaths.sort((left, right) => left.localeCompare(right));
1563
- const roleByPath = await loadReferenceRoles(sourceDir);
1564
- return Promise.all(referencePaths.map(async (filePath) => {
1565
- const relative = path
1566
- .relative(sourceDir, filePath)
1567
- .replaceAll(path.sep, "/");
1568
- const role = roleByPath.get(relative);
1569
- return {
1570
- path: filePath,
1571
- markdown: await readFile(filePath, "utf-8"),
1572
- ...(role !== undefined ? { role } : {}),
1573
- };
1574
- }));
1575
- }
1576
- /**
1577
- * Reads `source-manifest.json` into a materializedPath → role map so the DAG
1578
- * generator can tell fact-source references (requirement/acceptance) apart
1579
- * from archival ones (analysis/clarification/design). A missing or malformed
1580
- * manifest yields an empty map; such references keep the bounded-excerpt
1581
- * treatment instead of being injected in full.
1582
- */
1583
- async function loadReferenceRoles(sourceDir) {
1584
- const roleByPath = new Map();
1585
- const manifestPath = path.join(sourceDir, "source-manifest.json");
1586
- let raw;
1587
- try {
1588
- raw = await readFile(manifestPath, "utf-8");
1589
- }
1590
- catch {
1591
- return roleByPath;
1592
- }
1593
- try {
1594
- const manifest = JSON.parse(raw);
1595
- for (const document of manifest.documents ?? []) {
1596
- if (typeof document.materializedPath === "string" &&
1597
- typeof document.role === "string") {
1598
- roleByPath.set(document.materializedPath.replaceAll(path.sep, "/"), document.role);
1599
- }
1600
- }
1601
- }
1602
- catch {
1603
- // A malformed manifest must never break reference injection.
1604
- }
1605
- return roleByPath;
1596
+ return Promise.all(referencePaths.map(async (filePath) => ({
1597
+ path: filePath,
1598
+ markdown: await readFile(filePath, "utf-8"),
1599
+ })));
1606
1600
  }
1607
1601
  export async function loadTaskHybridSources(repoRoot, taskId) {
1608
1602
  const paths = getTaskPaths(repoRoot, taskId);
@@ -1775,6 +1769,7 @@ export function buildStandardHybridDagFromTask(sources) {
1775
1769
  forbiddenPaths,
1776
1770
  outputContract: "Archived final shell verification stdout/stderr with exit codes; no worktree writes.",
1777
1771
  subtask_prompt: "Run the adapter-resolved final verification commands before read-only verification review.",
1772
+ transientRetryProfile: TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE,
1778
1773
  shell: {
1779
1774
  commands: verifyShellCommands,
1780
1775
  verifyEvidence: buildVerifyEvidence({
@@ -1895,6 +1890,7 @@ export function buildStandardHybridDagFromTask(sources) {
1895
1890
  allowedPaths: implementPaths.allowedPaths,
1896
1891
  forbiddenPaths,
1897
1892
  writerOutcomePolicy: { type: "implementation-outcome-v1" },
1893
+ transientRetryProfile: TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE,
1898
1894
  subtask_prompt: [
1899
1895
  "Implement the approved plan with minimal focused changes.",
1900
1896
  "Stay within writeSet. Do not write root artifacts/** unless artifacts paths are explicitly declared in writeSet.",
@@ -1954,6 +1950,7 @@ export function buildStandardHybridDagFromTask(sources) {
1954
1950
  ],
1955
1951
  };
1956
1952
  applySddEmbeddedEnhancements(spec, sources.sddEmbeddedSkills ?? new Set());
1953
+ stampTargetTemplateTransientRetryProfile(spec);
1957
1954
  applyDefaultReadOnlyRetryPolicy(spec);
1958
1955
  parseDagSpec(spec);
1959
1956
  assertValidDagSpec(spec);
@@ -2433,7 +2430,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2433
2430
  "## Runtime contract skeleton (deterministic and protected)",
2434
2431
  JSON.stringify(frontendContractSkeleton),
2435
2432
  "",
2436
- "The initial planner emits an editable RFC 7386 patch against this skeleton. It MUST omit schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. The runtime merges and validates the final contract, then replaces the planner output with canonical full-contract JSON for downstream review.",
2433
+ "The initial planner emits an editable RFC 7386 patch against this skeleton. It MUST omit schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. The runtime merges and validates the final contract, then writes a hash-bound canonical JSON artifact; downstream review and prewrite consume that artifact path, not planner stdout.",
2437
2434
  "",
2438
2435
  "## Forbidden fields (these are NOT in the schema; do not emit)",
2439
2436
  "- schemaId",
@@ -2550,11 +2547,6 @@ async function buildFrontendHybridDagFromTask(sources) {
2550
2547
  `Non-static targets (type unit/component/integration/mock) MUST set file to a concrete code file inside the implementation writeSet (task allowedPaths); the prewrite gate rejects any non-static target whose file falls outside the writeSet.`,
2551
2548
  `Command-level checks that run project-wide (all tests, typecheck, build, governance) MUST use type "static" and must NOT be bound as non-static targets with file=package.json/tsconfig.json/vite.config.ts/scripts/*. Static targets are exempt from the writeSet containment check.`,
2552
2549
  ].join("\n");
2553
- const mandatorySourceReadInstruction = [
2554
- "## Mandatory full source read before contracting",
2555
- "Before producing this contract/plan, use the Pi read tool to read the FULL bound source files (需求.md, 执行约束.md, and every `references/*` Bound readPath listed above) — the inline copies above may be truncated excerpts, and requirement/acceptance references are authoritative only in their full form.",
2556
- "Do not drop scope fields, acceptance criteria, non-goals, UI states, or column/field definitions that exist in the full sources but are absent from the inline excerpts; if a field appears in the full source, it belongs in the contract.",
2557
- ].join("\n");
2558
2550
  const strategy = resolveDagVerifyStrategy(taskConfig);
2559
2551
  const readOnlyPaths = taskConfig.allowedPaths.length > 0 ? taskConfig.allowedPaths : ["**"];
2560
2552
  const behaviorPaths = deriveFrontendBehaviorPaths(taskConfig);
@@ -2588,7 +2580,14 @@ async function buildFrontendHybridDagFromTask(sources) {
2588
2580
  return buildBlockedFrontendMockDag(frontendSources, readOnlyPaths, forbiddenPaths, globalConstraints, blockedReason);
2589
2581
  }
2590
2582
  const fallbackVerifyCommands = await discoverFrontendFallbackVerifyCommands(sources.repoRoot);
2591
- const staticFallbackCommands = fallbackVerifyCommands.staticCommands;
2583
+ // The verification bundle schema requires at least one static command. When
2584
+ // a real project exposes no usable verification command at all, run an
2585
+ // explicit no-op marker instead of a command that is guaranteed to fail:
2586
+ // the trace then records not-run honestly and the advisory asks the task
2587
+ // to declare verification commands and regenerate.
2588
+ const staticFallbackCommands = fallbackVerifyCommands.staticCommands.length > 0
2589
+ ? fallbackVerifyCommands.staticCommands
2590
+ : [FRONTEND_NO_STATIC_VERIFICATION_MARKER];
2592
2591
  const behaviorFallbackCommands = fallbackVerifyCommands.behaviorCommands;
2593
2592
  const parsedFrontendVerifyCommands = extractFrontendVerifyCommandsFromMarkdown({
2594
2593
  repoRoot: sources.repoRoot,
@@ -2687,6 +2686,11 @@ async function buildFrontendHybridDagFromTask(sources) {
2687
2686
  ...behaviorVerifyEvidence.commandLabels.map((command) => ` - ${JSON.stringify(command)}`),
2688
2687
  ].join("\n");
2689
2688
  const advisories = [];
2689
+ if (!hasDeclaredFrontendVerification &&
2690
+ fallbackVerifyCommands.staticCommands.length === 0 &&
2691
+ fallbackVerifyCommands.behaviorCommands.length === 0) {
2692
+ advisories.push("未发现可用的前端验证命令:目标项目没有可读取的 package.json scripts,也未探测到本地 TypeScript,verify 节点将没有静态/行为命令可执行。请在 task.json --verify 或执行约束.md 的验证约束中显式声明命令(例如 node --check src/app.js),然后重新生成 DAG。");
2693
+ }
2690
2694
  if (frontendSourceMentionsMock(frontendSources) &&
2691
2695
  frontendMockStrategyMustBeNotNeeded(frontendSources)) {
2692
2696
  advisories.push("auto 模式已将 Mock 策略收窄为 not-needed:任务源提到接口/API/Mock 需求,但仓库无确认 Mock 能力或无确定性 Mock 验证命令。若项目规范要求 Mock,请声明 frontendMock.verifyCommands 或 policy:required 后重新生成 DAG。");
@@ -2751,7 +2755,6 @@ async function buildFrontendHybridDagFromTask(sources) {
2751
2755
  "Read task source and produce a concise frontend implementation contract.",
2752
2756
  "Cover scope, non-goals, acceptance criteria, UI states, target runtime environment, risks, and verification expectations.",
2753
2757
  "Read-only: do not modify code, docs, artifacts, or repository files.",
2754
- mandatorySourceReadInstruction,
2755
2758
  sourceContext,
2756
2759
  ].join("\n\n"),
2757
2760
  },
@@ -2792,7 +2795,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2792
2795
  allowedPaths: readOnlyPaths,
2793
2796
  forbiddenPaths,
2794
2797
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
2795
- outputContract: "JSON-only patch output: one-line lead-in, then exactly ONE fenced json object (```json ... ```) containing only the editable RFC 7386 plan patch for the runtime contract skeleton. Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. Immediately after it, append exactly one ```openspec-citations``` fenced citation block. The runtime applies the patch, validates it, and promotes canonical full-contract JSON for downstream review. Do NOT emit a full contract, Markdown plan explanation, raw JSON, or any other fenced block. No file writes.",
2798
+ outputContract: "JSON-only patch output: one-line lead-in, then exactly ONE fenced json object (```json ... ```) containing only the editable RFC 7386 plan patch for the runtime contract skeleton. Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. Immediately after it, append exactly one ```openspec-citations``` fenced citation block. The runtime applies the patch, validates it, and writes a hash-bound canonical JSON artifact for downstream review. Do NOT emit a full contract, Markdown plan explanation, raw JSON, or any other fenced block. No file writes.",
2796
2799
  subtask_prompt: [
2797
2800
  "Use frontend-contract-pi, frontend-scout-pi, task sources, and the generation-time Mock capability evidence to fill the runtime-owned frontend contract skeleton. Return JSON-only output containing only an editable RFC 7386 plan patch. The runtime already owns schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff; omit those protected paths even when their values look obvious.",
2798
2801
  "The patch fields become the complete implementation plan after deterministic merge. Do not produce a separate plan document, prose mirror, or full contract.",
@@ -2806,7 +2809,6 @@ async function buildFrontendHybridDagFromTask(sources) {
2806
2809
  "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2807
2810
  verificationTargetFileInstruction,
2808
2811
  "Read-only: do not modify code, docs, artifacts, or repository files.",
2809
- mandatorySourceReadInstruction,
2810
2812
  fixedVerificationContext,
2811
2813
  sourceContext,
2812
2814
  mockContextBlock,
@@ -2828,7 +2830,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2828
2830
  outputProtocol: REVIEW_VERDICT_OUTPUT_PROTOCOL,
2829
2831
  outputContract: "Plain Markdown whose first non-empty line is VERDICT: pass or VERDICT: request-revision, followed by Findings, Required Plan Corrections, and Checked Items. No file writes.",
2830
2832
  subtask_prompt: [
2831
- "Audit the frontend plan before implementation. frontend-plan-pi is emitted to you as canonical full-contract JSON after the runtime applied and validated the planner's editable patch against its protected skeleton; there is no separate plan prose.",
2833
+ "Audit the frontend plan before implementation. frontend-plan-pi is emitted as a hash-bound canonical JSON artifact after the runtime applied and validated the planner's editable patch against its protected skeleton; read that artifact with the read tool and do not infer the contract from stdout. There is no separate plan prose.",
2832
2834
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
2833
2835
  "Request revision when the Mock strategy is MOCK_STRATEGY: blocked, missing, unsupported by repository evidence, inconsistent with the API contract, outside authorized paths/dependencies, unable to prove production-default-off behavior with the fixed production/default-real-path static check, or missing deterministic behavior verification for a declared behavior target or selected Mock strategy. Mock strategies require Mock-backed evidence. A static-only contract is allowed only when every verification target is static and maps to a declared static entrypoint. not-needed otherwise requires applicable real/no-remote behavior evidence unless auto mode explicitly skipped Mock because no project Mock capability exists; in that case the plan must preserve the real request path and record the Real Integration Gap.",
2834
2836
  "Also request revision for missing applicable UI states, unsupported dependency additions, design-system drift without reason, weak interaction coverage, broad scope, inline fake data, schema drift, or missing deterministic verification commands.",
@@ -2857,15 +2859,15 @@ async function buildFrontendHybridDagFromTask(sources) {
2857
2859
  allowedPaths: readOnlyPaths,
2858
2860
  forbiddenPaths,
2859
2861
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
2860
- outputContract: "When the initial design review requests revision, return a one-line lead-in followed by exactly ONE fenced json object (```json ... ```) containing an RFC 7386 merge-patch delta against the original frontend-implementation-contract-v1 (only the fields you change; null deletes a key; arrays and scalars replace; plain objects merge recursively). Immediately after it, append exactly one ```openspec-citations``` fenced citation block. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only; the gate applies the patch on the original contract and renders plan.md deterministically. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes.",
2862
+ outputContract: "When the initial design review requests revision, return a one-line lead-in followed by exactly ONE fenced json object (```json ... ```) containing an RFC 7386 merge-patch delta against the original frontend-implementation-contract-v1 (only the fields you change; null deletes a key; arrays and scalars replace; plain objects merge recursively). Immediately after it, append exactly one ```openspec-citations``` fenced citation block. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only; this node compiles the patch onto the original canonical artifact and writes a hash-bound revised contract. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes.",
2861
2863
  subtask_prompt: [
2862
2864
  "Consume frontend-plan-pi (original contract JSON) and frontend-design-review-pi (first design review findings).",
2863
2865
  "This node runs only when frontend-design-review-pi emitted VERDICT: request-revision. Produce an RFC 7386 merge-patch delta against the original contract JSON that addresses every Required Plan Correction from the design findings.",
2864
- "The patch delta may update editable contract fields such as requirements, implementationSteps, targets.routes/publicApiChanges, uiStates, interactions, mockApi.strategy/activation/endpoints, dependencyPolicy, stylingStrategy, uiComponentChoices, verificationTargets, evidenceGaps, residualRisks, and realIntegrationGap. It must not modify protected schemaVersion, sourceBinding, riskLevel, targets.files, or mockApi.productionDefaultOff. Only include fields you change; omit unchanged fields (the gate applies the patch on the original contract). null deletes a key; arrays and scalars replace; plain objects merge recursively.",
2866
+ "The patch delta may update editable contract fields such as requirements, implementationSteps, targets.routes/publicApiChanges, uiStates, interactions, mockApi.strategy/activation/endpoints, dependencyPolicy, stylingStrategy, uiComponentChoices, verificationTargets, evidenceGaps, residualRisks, and realIntegrationGap. It must not modify protected schemaVersion, sourceBinding, riskLevel, targets.files, or mockApi.productionDefaultOff. Only include fields you change; omit unchanged fields (this node applies the patch on the original canonical artifact). null deletes a key; arrays and scalars replace; plain objects merge recursively.",
2865
2867
  requirementCoverageInstruction,
2866
2868
  "Do not turn MOCK_STRATEGY: blocked into an implementable strategy without new repository or contract evidence that resolves every blocker.",
2867
2869
  "Read-only: do not modify code, docs, artifacts, or repository files. This node revises the plan only.",
2868
- "Output in this exact order: (1) exactly one fenced json object containing the merge-patch delta — this is the only plan output the prewrite gate applies on the original contract; (2) exactly one openspec-citations citation fenced block appended immediately after it. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only. Do not emit any raw JSON or JSON objects in prose. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block. Do not include secrets or unsafe paths.",
2870
+ "Output in this exact order: (1) exactly one fenced json object containing the merge-patch delta — this node compiles it onto the original canonical artifact; (2) exactly one openspec-citations citation fenced block appended immediately after it. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only. Do not emit any raw JSON or JSON objects in prose. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block. Do not include secrets or unsafe paths.",
2869
2871
  "Preserve each requirement expectedOutcome and each interaction trigger/expectedBehavior in the effective (merged) contract; do not reduce behavior semantics to IDs and paths.",
2870
2872
  "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2871
2873
  verificationTargetFileInstruction,
@@ -3799,11 +3801,31 @@ const BACKEND_TEST_DEFAULTS = {
3799
3801
  export function applyBackendTestLayoutToText(text, layout) {
3800
3802
  if (layout.isDefault)
3801
3803
  return text;
3802
- return text
3803
- .replaceAll("testcase/md/", `${layout.markdownDir}/`)
3804
- .replaceAll("testcase/test_", `${layout.scriptDir}/test_`)
3805
- .replaceAll("testcase/**", `${layout.testRoot}/**`)
3806
- .replaceAll("testcase/", `${layout.testRoot}/`);
3804
+ const replacements = [
3805
+ ["testcase/test_", `${layout.scriptDir}/test_`],
3806
+ ["testcase/md/", `${layout.markdownDir}/`],
3807
+ ["testcase/**", `${layout.testRoot}/**`],
3808
+ ["testcase/", `${layout.testRoot}/`],
3809
+ ];
3810
+ replacements.sort((a, b) => b[0].length - a[0].length);
3811
+ let output = "";
3812
+ const resolvedPrefix = `${layout.testRoot}/`;
3813
+ for (let i = 0; i < text.length;) {
3814
+ if (text.startsWith(resolvedPrefix, i)) {
3815
+ output += resolvedPrefix;
3816
+ i += resolvedPrefix.length;
3817
+ continue;
3818
+ }
3819
+ const hit = replacements.find(([token]) => text.startsWith(token, i));
3820
+ if (hit) {
3821
+ output += hit[1];
3822
+ i += hit[0].length;
3823
+ continue;
3824
+ }
3825
+ output += text[i];
3826
+ i += 1;
3827
+ }
3828
+ return output;
3807
3829
  }
3808
3830
  /**
3809
3831
  * Builds the `node -e` command for the backend-test module manifest shell.
@@ -4369,7 +4391,7 @@ async function buildBackendTestHybridDag(sources) {
4369
4391
  "Coverage priority is strict inside the declared scope: P0 product requirements/task hard constraints always remain in scope; P1 exhaustively supplements documented operations, fields, business rules, statuses and errors only for Affected Operations; P2 adds bounded protocol robustness only when it is relevant to the change and does not invent product behavior. Coverage percentages describe the declared affected scope, never whole-API completeness unless every operation is explicitly listed. Conflicts or undefined expectations must stay visible as GAP/CONFLICT with precise source pointers, never guessed.",
4370
4392
  "For uniqueness/lifecycle rules cover absent, active-existing, deleted-existing, create-delete-recreate, restore-then-recreate and documented scope/case-normalization states. For every enum cover every valid value plus bounded invalid equivalence classes (unknown, case variant, whitespace, empty, null/missing and wrong types as applicable). For every length/number rule cover min-1, min, nominal, max and max+1. For format rules cover each allowed class separately plus a valid mixed value, and representative forbidden classes including uppercase, internal/leading/trailing whitespace, tab/newline, unsupported punctuation, slash, emoji or control characters when the source contract supports that expectation.",
4371
4393
  "Mandatory module index: include a `## Module Index` table in README that lists every planned module as a canonical relative link of the exact form `[label](./<stem>.md)` plus a `testcase/md/<stem>.md` path cell, so a downstream deterministic manifest can parse the module list. Group by stable business resource/domain, not by CRUD operation: one resource's list/detail/create/update/delete cases belong in one module such as `resource_notes`; split only when a single module would exceed the per-child 16K output protocol, keep the total module count at the smallest safe value, and never exceed 8 modules. Name each module file with a stable lowercase business stem such as `health` or `resource_notes`. Pure hexadecimal/hash-like opaque stems such as `a401606` or `deadbeef` are forbidden. Do not use priority-only stems `p0`, `p1` or `p2`; Priority belongs only in the Coverage Matrix and never defines module files. Do not use Case-ID-like module filenames such as `BE-HEALTH.md` or `BE-NOTES.md`. The relative link target MUST equal the on-disk filename stem the sharded writer will create. For every automatable case, `自动化映射` must name exactly `testcase/test_<module>.py`, where <module> is that Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `testcase/md/health.md` → `testcase/test_health.py`; `testcase/md/resource_notes.md` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.",
4372
- "Scenario Partitions (query/filter axes): for every affected GET/list operation, declare one row per enum or classification axis used for filtering (query/path parameters such as type/status/category). Add a mandatory machine-readable `## Scenario Partitions` section after the Coverage Matrix using exactly `| Partition ID | Operation | Axis | Domain | Required Slots | Expected by Slot | Bind Rule |` with the separator row. Partition ID is a stable `SP-<OPERATION>-<AXIS>` token; Domain must copy the legal values verbatim from the bound OpenAPI enum or requirement sentence (never guess); Required Slots writes `each-value` plus `omitted` only when the parameter is optional; Expected by Slot states the documented expectation per slot kind (`domain-value`, `default-behavior`, `empty-result`/`excluded-result` when documented, or `GAP` when the source does not document the complement expectation — never invent 空列表/400). POST/PUT body field-validation enums stay in the Coverage Matrix as `TP-<FIELD>-ENUM-*` and MUST NOT get a Scenario Partition row. Do not create partitions for axes without a documented legal-value domain. Cross-axis combinations stay as ONE nominal Case; never declare a cross-axis cartesian partition.",
4394
+ "Scenario Partitions (query/filter axes): for every affected GET/list operation, declare one row per enum or classification axis used for filtering (query/path parameters such as type/status/category). Add a mandatory machine-readable `## Scenario Partitions` section after the Coverage Matrix using exactly `| Partition ID | Operation | Axis | Domain | Required Slots | Expected by Slot | Bind Rule |` with the separator row. Partition ID is a stable `SP-<OPERATION>-<AXIS>` token; Domain must copy the legal values verbatim from the bound OpenAPI enum or requirement sentence (never guess); Required Slots writes `each-value` plus `omitted` only when the parameter is optional; Expected by Slot states the documented expectation per slot kind (`domain-value`, `default-behavior`, `empty-result`/`excluded-result` when documented, or `GAP` when the source does not document the complement expectation — never invent 空列表/400). POST/PUT body field-validation enums stay in the Coverage Matrix as `TP-<FIELD>-ENUM-*` and MUST NOT get a Scenario Partition row. Do not create partitions for axes without a documented legal-value domain. Only GET/list query or path parameters whose bound source documents a finite enum or classification set may become a Scenario Partition. Do not create partitions for free-form strings, primary keys, required-or-optional-only parameters, or boundary/format-only axes. If an axis has no finite legal-value domain, do not declare a Partition row and do not invent NOT-IN-SET cases. Cross-axis combinations stay as ONE nominal Case; never declare a cross-axis cartesian partition.",
4373
4395
  "Before finalizing README, calculate the predicted collected-item count as `sum(max(1, number of variant Test Points in each Case))`. If the task declares an item budget, the prediction must not exceed it. Reduce excess only by removing duplicate execution and converting same-request checkpoints to assertions; never drop required rules, boundaries, enums, operation-specific inputs, or business states. Record the prediction in README. Use only environment-supported fixtures/targets/isolation, record evidence gaps in Chinese, and do not emit JSON, pytest, or execute commands.",
4374
4396
  ...(sharedSetupPrompt ? [sharedSetupPrompt] : []),
4375
4397
  intake.boundedSourceContext,
@@ -4479,7 +4501,7 @@ async function buildBackendTestHybridDag(sources) {
4479
4501
  "Correct testcase/md/** directly: add documented omissions, remove unsupported cases, rename module files to stable lowercase stems when needed, normalize every Case ID to hyphen-separated module segments plus exactly three zero-padded digits (`BE-RESOURCE_NOTES-01` → `BE-RESOURCE-NOTES-001`; `BE-RN-011A` must be renumbered or merged) consistently across headings/index/mappings, fix automation mappings so each automatable case points at `testcase/test_<module>.py` derived from that module filename and declares exactly one primary symbol (evidence-only meta cases may keep `脚本/primary symbol=无` with empty variants), assign every Test Point exactly one of `变体测试点`/`场景断言测试点`/`横切证据测试点`, then perform an exact-set check: each Case's `### 测试点` set must equal (not merely contain) the union of those three binding lists; delete stale/legacy aliases and ensure every binding-list Test Point is present, expand every variant parameter row into its own atomic TP ID, make every non-cross-cutting TP Case-specific and owned by exactly one Case, require every primary symbol to start with the canonical Case prefix, ensure every explicit AC ID appears in an applicable Case `验收标准`, merge execution duplicates, improve navigation/tables/Chinese wording, or record gaps in Chinese. Remove every credential/header value, placeholder, fake token and anti-example from Markdown. Sensitive key names may remain only as a plain list; values must be described as runtime-only and omitted, with no colon/value pair or literal example anywhere, including details blocks and explanatory text. Keep Case IDs, AC/REQ/BR IDs, HTTP methods, paths, fields, enum values, filenames, code symbols and source citations as exact machine-readable identifiers; only normalize Case ID separator/sequence formatting as specified above. Recalculate predicted collected items as `sum(max(1, variant count per Case))`; when the task declares a budget, directly merge redundant journeys/reclassify same-request checkpoints until the prediction is within budget, while preserving all required coverage. The validator accepts Chinese and legacy English section aliases; retain or converge to the Chinese human-readable headings without losing structure.",
4480
4502
  "This is the single Markdown incremental synchronization round. Read every authoritative reference index entry whose role hints include acceptance-criteria, api-contract, data-contract or business-rule; do not rely on the derived PRD as a complete inventory. Preserve every explicit AC/REQ/BR ID, every documented HTTP/business error code, every DTO/JSON field, enum value, boundary, format, nested shape, transaction/state/idempotency/uniqueness/auth/tenant/cross-field rule. For each natural-language normative business rule preserved as required scope, include its exact source sentence without paraphrase together with source path and line/heading anchor so the deterministic ledger can verify quote/hash provenance. Ensure every Case declares exactly `Payload Contract: none` or the three labels `Payload Required Paths`, `Payload Allowed Paths`, and `Payload Enum`; every label must occupy its own machine-readable list line, and a Case must never concatenate target/setup operations or multiple `Payload Contract` tokens onto one line, and explanatory prose/details must not repeat any `Payload Contract:` token; never infer missing keys or enum values. A target GET/DELETE operation with no request body must remain `Payload Contract: none` even when its setup journey performs POST/PUT with a DTO; setup payloads never redefine the target Case payload contract. Add only missing Matrix rows/Test Points/Cases/assertions or repair exact drift; do not rewrite already-valid unrelated modules. Work gap-targeted: inspect source anchors and affected modules first, leave unrelated valid modules byte-stable, and return `already-satisfied` without restating the full suite when no gap exists.",
4481
4503
  "For affected API fields, use one valid nominal payload plus atomic required/missing/null/empty/wrong-type, every documented enum value plus bounded invalid classes, documented min-1/min/nominal/max/max+1, formats and nested object/array constraints. Do not generate a Cartesian product or invent undocumented constraints. Do not invent a concrete identifier type when the source only requires presence; for a missing-resource 404 path with unspecified identifier syntax/type, synchronize the Case to a create-delete-derived valid identifier journey rather than an arbitrary UUID/text placeholder.",
4482
- "Scenario Partitions synchronization: when README declares `## Scenario Partitions`, verify each declared partition's slots are fully materialized as variant Test Points with exact `TP-<Partition ID>-...` IDs (each-value per Domain value, OMITTED only for optional axes, exactly one NOT-IN-SET with intent=enum-invalid). Directly add missing slot rows/Cases; never delete a declared partition or drop its complement slot to force coverage green. When the bound source does not document the complement expectation, keep the slot with GAP expected instead of guessing. Body-field validation enums (`TP-<FIELD>-ENUM-*`) are NOT partitions — do not add partition rows for them.",
4504
+ "Scenario Partitions synchronization: when README declares `## Scenario Partitions`, verify each declared partition's slots are fully materialized as variant Test Points with exact `TP-<Partition ID>-...` IDs (each-value per Domain value, OMITTED only for optional axes, exactly one NOT-IN-SET with intent=enum-invalid). Directly add missing slot rows/Cases. You may delete an illegal Partition row that has no source-backed finite domain, together with its derived `TP-SP-*` slots/Cases. Never delete a legal source-backed partition or drop its complement slot to force coverage green. When the bound source does not document the complement expectation, keep the slot with GAP expected instead of guessing. Body-field validation enums (`TP-<FIELD>-ENUM-*`) are NOT partitions — do not add partition rows for them.",
4483
4505
  "Before returning, verify that every explicit source AC/REQ/BR, error code and strong DTO field token appears in README or an applicable module Case. If a fact cannot be safely automated, retain it as GAP/CONFLICT with its exact source pointer instead of dropping it. Return already-satisfied only when no target file needs an incremental edit.",
4484
4506
  "Read only indexed source paths. Do not scan the repository, modify source/**, generate pytest, execute tests, or emit JSON.",
4485
4507
  ...(sharedSetupPrompt ? [sharedSetupPrompt] : []),
@@ -4802,6 +4824,13 @@ function buildFrontendTestHybridDag(sources) {
4802
4824
  };
4803
4825
  const declaredRequirementIds = buildDagSourceBinding(sources).requirementIds;
4804
4826
  const declaredAcIds = declaredRequirementIds.filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
4827
+ // Optional project-local UI anchor ledger: only mention it in prompts when it
4828
+ // exists so ledger-less projects keep generating without noise.
4829
+ const hasUiAnchorsLedger = Boolean(sources.repoRoot &&
4830
+ existsSync(path.join(sources.repoRoot, "testcase/frontend/rag/ui-anchors.md")));
4831
+ const uiAnchorsLedgerInstruction = hasUiAnchorsLedger
4832
+ ? "Also read testcase/frontend/rag/ui-anchors.md (UI anchor ledger). Every passing find assertion in generated cases must quote a ledger row whose 状态 is 有效 for the matching page x state section; rows marked 不可断言/失效 must not be used as passing assertions. When an AC names a control absent from the ledger section for that state, emit a blocked case note (blockedReason unique-control-unavailable) instead of exploratory find steps, and follow ledger 备注 alternatives (split-node short literals) exactly."
4833
+ : "";
4805
4834
  const reviewMode = config.reviewMode;
4806
4835
  const blockingReview = reviewMode === "blocking";
4807
4836
  const strictOutcomeGate = config.strictOutcomeGate;
@@ -4842,6 +4871,8 @@ function buildFrontendTestHybridDag(sources) {
4842
4871
  "const caseIdRe=/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;",
4843
4872
  ,
4844
4873
  "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
4874
+ "const truncatedAcRe=/^AC-FE-[A-Z]+$/i;",
4875
+ "const truncatedUndeclaredAcRe=/^(?:AC-\\d{3}|AC-FE-[A-Z]+)$/i;",
4845
4876
  "for(const c of manifest.cases){",
4846
4877
  " const id=c&&c.caseId||'?';",
4847
4878
  " if(typeof c.caseId!=='string'||!caseIdRe.test(c.caseId))issues.push({ruleId:'case-id-shape',caseId:id,detail:'caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*'});",
@@ -4860,6 +4891,7 @@ function buildFrontendTestHybridDag(sources) {
4860
4891
  " if(typeof ac!=='string'){issues.push({ruleId:'ac-id-shape',caseId:id,detail:String(ac)+' must look like AC-FE-001'});continue;}",
4861
4892
  " if(/^FE-/i.test(ac)){issues.push({ruleId:'ac-id-is-case',caseId:id,detail:ac+' looks like caseId; acIds must be AC-*'});continue;}",
4862
4893
  " if(!acIdRe.test(ac)){issues.push({ruleId:'ac-id-shape',caseId:id,detail:ac+' must look like AC-FE-001'});continue;}",
4894
+ " if(truncatedAcRe.test(ac)||(declaredAc.size===0&&truncatedUndeclaredAcRe.test(ac))){issues.push({ruleId:'ac-id-truncated',caseId:id,detail:ac+' is a truncated acceptance id (missing feature prefix or sequence number)'});continue;}",
4863
4895
  " if(declaredAc.size>0&&!declaredAc.has(ac))issues.push({ruleId:'unknown-ac',caseId:id,detail:ac+' not in task sourceBinding.requirementIds'});",
4864
4896
  " }",
4865
4897
  " }",
@@ -4891,6 +4923,8 @@ function buildFrontendTestHybridDag(sources) {
4891
4923
  `const declaredAc=new Set(${declaredAcIdsLiteral});`,
4892
4924
  "const seen=new Set(); const seenCasePath=new Set(); const seenEvidenceDir=new Set();",
4893
4925
  "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
4926
+ "const truncatedAcRe=/^AC-FE-[A-Z]+$/i;",
4927
+ "const truncatedUndeclaredAcRe=/^(?:AC-\\d{3}|AC-FE-[A-Z]+)$/i;",
4894
4928
  "for(const c of manifest.cases){",
4895
4929
  " if(!c||typeof c.caseId!=='string'||!/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId)) fail('case-id-shape','caseId must be FE-*, never AC-FE-*: '+String(c&&c.caseId));",
4896
4930
  " if(/^AC-/i.test(c.caseId)) fail('case-id-is-ac','caseId must not be an acceptance id: '+c.caseId);",
@@ -4898,7 +4932,7 @@ function buildFrontendTestHybridDag(sources) {
4898
4932
  " seen.add(c.caseId);",
4899
4933
  " if(typeof c.dimension!=='string'||!dims.has(c.dimension)) fail('invalid-dimension',String(c.dimension));",
4900
4934
  " if(!Array.isArray(c.acIds)||c.acIds.length===0||c.acIds.some(a=>typeof a!=='string'||!a.trim())) fail('ac-mapping','invalid acIds for '+c.caseId);",
4901
- " for(const ac of c.acIds){ if(!acIdRe.test(ac)) fail('ac-id-shape','acIds entry must be AC-* acceptance id, not caseId: '+ac); if(declaredAc.size>0&&!declaredAc.has(ac)) fail('unknown-ac',ac+' not in sourceBinding; repair generator input or AC list'); }",
4935
+ " for(const ac of c.acIds){ if(!acIdRe.test(ac)) fail('ac-id-shape','acIds entry must be AC-* acceptance id, not caseId: '+ac); if(truncatedAcRe.test(ac)||(declaredAc.size===0&&truncatedUndeclaredAcRe.test(ac))) fail('ac-id-truncated','acIds entry is a truncated acceptance id (missing feature prefix or sequence number): '+ac); if(declaredAc.size>0&&!declaredAc.has(ac)) fail('unknown-ac',ac+' not in sourceBinding; repair generator input or AC list'); }",
4902
4936
  " c.casePath='testcase/frontend/cases/'+c.caseId+'.md';",
4903
4937
  " c.evidenceDir='testcase/frontend/evidence/'+c.caseId+'/';",
4904
4938
  " for(const k of ['casePath','evidenceDir']){ const v=c[k]; if(typeof v!=='string'||path.isAbsolute(v)||v.includes('..')) fail('unsafe-path',k+': '+String(v)); }",
@@ -4958,15 +4992,11 @@ function buildFrontendTestHybridDag(sources) {
4958
4992
  writeSet: ragWriteSet,
4959
4993
  allowedPaths: [...ragWriteSet],
4960
4994
  forbiddenPaths: forbidden,
4961
- outputContract: "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage.",
4962
- subtask_prompt: "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package.",
4995
+ outputContract: "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage. Copy docs/templates or harness.json governanceRoot templates (including ai_workspace/loop-agent/templates) when present; otherwise write the minimal STD-FE-SMOKE-ENTRY fallback.",
4996
+ subtask_prompt: "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package from docs/templates, governanceRoot/templates, or the init-projected ai_workspace/loop-agent/templates path.",
4963
4997
  shell: {
4964
- commands: [
4965
- [
4966
- "node -e",
4967
- JSON.stringify("const fs=require('fs'),path=require('path');const dest='testcase/frontend/rag/standard-scenarios.v1.json';const candidates=[path.join('docs','templates','frontend-test-standard-scenarios.v1.json')];let src=null;for(const c of candidates){if(fs.existsSync(c)){src=c;break;}}fs.mkdirSync(path.dirname(dest),{recursive:true});if(src){fs.copyFileSync(src,dest);process.stdout.write(JSON.stringify({status:'copied',from:src,to:dest}));}else{const minimal={schemaVersion:1,id:'frontend-test-standard-scenarios-v1',scenarios:[{id:'STD-FE-SMOKE-ENTRY',title:'入口可打开',category:'smoke',priority:'must',testPoints:['open'],minCases:1}]};fs.writeFileSync(dest,JSON.stringify(minimal,null,2)+'\n');process.stdout.write(JSON.stringify({status:'fallback',to:dest}));}"),
4968
- ].join(" "),
4969
- ],
4998
+ commands: [],
4999
+ frontendTestStandardScenarios: {},
4970
5000
  cwd: ".",
4971
5001
  timeoutMs: 60_000,
4972
5002
  },
@@ -5002,15 +5032,11 @@ function buildFrontendTestHybridDag(sources) {
5002
5032
  writeSet: ragWriteSet,
5003
5033
  allowedPaths: [...ragWriteSet],
5004
5034
  forbiddenPaths: forbidden,
5005
- outputContract: "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable (node ERROR so generate/map do not run).",
5006
- subtask_prompt: "Parse the resolved absolute baseUrl from testcase/frontend/rag/context.md, reject production/non-http(s)/credential/query/fragment URLs, then probe it with curl HEAD and GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. 4xx/5xx/DNS/timeout/connection refused/TLS => blockedReason frontend-base-url-unreachable. Missing curl => blockedReason curl-unavailable. Do not start the app. Fixture/reset remain soft guidance.",
5035
+ outputContract: "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable with errorClass (connection-refused / dns-unresolved / connect-timeout / http-N / curl-exit-N). Node ERROR so generate/map do not run. Does not start the app.",
5036
+ subtask_prompt: "Parse the resolved absolute baseUrl from testcase/frontend/rag/context.md, reject production/non-http(s)/credential/query/fragment URLs, then probe it with curl HEAD and GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. Connection refused (curl 7) records errorClass=connection-refused and tells the operator to start the local app (scripts/serve.sh or npm start) then rerun from this node. 4xx/5xx/DNS/timeout/TLS => blockedReason frontend-base-url-unreachable with a distinct errorClass. Missing curl => blockedReason curl-unavailable. Do not start the app. Fixture/reset remain soft guidance.",
5007
5037
  shell: {
5008
- commands: [
5009
- [
5010
- "node -e",
5011
- JSON.stringify(`const fs=require('fs');const {spawnSync}=require('child_process');const p='testcase/frontend/rag/context.md';const probePath='testcase/frontend/rag/environment-probe.json';function writeProbe(obj){try{fs.mkdirSync('testcase/frontend/rag',{recursive:true});fs.writeFileSync(probePath,JSON.stringify(obj,null,2)+'\\n');let ctx=fs.existsSync(p)?fs.readFileSync(p,'utf8'):'';const line='environmentProbe: '+obj.status+(obj.blockedReason?(' ('+obj.blockedReason+')'):'');if(/environmentProbe\\s*[:=]/i.test(ctx)){ctx=ctx.replace(/environmentProbe\\s*[:=]\\s*.*/i,line);}else{ctx=ctx.trimEnd()+'\\n\\n'+line+'\\n';}fs.writeFileSync(p,ctx);}catch(e){console.error('probe-write-failed',e&&e.message||e);}}function redactUrl(u){try{const x=new URL(u);x.username='';x.password='';if(x.search){x.search='';}return x.toString();}catch(_){return String(u).replace(/\\/\\/[^@\\s]+@/g,'//');}}function failBlocked(reason,extra){const payload=Object.assign({status:'unreachable',blockedReason:reason,baseUrlRedacted:extra&&extra.baseUrlRedacted||null,httpStatus:extra&&extra.httpStatus||null,method:extra&&extra.method||null,curlExit:extra&&extra.curlExit||null,errorClass:extra&&extra.errorClass||null},extra||{});writeProbe(payload);console.error('frontend-test preflight blocked: '+JSON.stringify({blockedReason:reason,baseUrl:payload.baseUrlRedacted,httpStatus:payload.httpStatus,errorClass:payload.errorClass}));throw new Error('frontend-test preflight blocked: '+reason);}if(!fs.existsSync(p))throw new Error('missing '+p);const s=fs.readFileSync(p,'utf8');const patterns=[/baseUrl\\s*[:=]\\s*["'\\x60]?((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/base[-_ ]url\\s*[:=]\\s*["'\\x60]?((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/playwright-cli open --browser=chrome\\s+((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/(https?:\\/\\/(?:localhost|127\\.0\\.0\\.1)[^\\s)\\}\\],"']*)/i];let baseUrl=null;for(const re of patterns){const m=s.match(re);if(m){baseUrl=m[1];break;}}if(!baseUrl)throw new Error('frontend-test preflight missing absolute baseUrl from context.md');baseUrl=baseUrl.replace(/[)\\}\\],."'\\x60]+$/,'');const sourceMatch=s.match(/baseUrlSource\\s*[:=]\\s*([^\\r\\n]+)/i);const baseUrlSource=sourceMatch?sourceMatch[1].trim():'context.md';let parsed;try{parsed=new URL(baseUrl);}catch(_){throw new Error('baseUrl must be absolute http(s): '+baseUrl);}if((parsed.protocol!=='http:'&&parsed.protocol!=='https:')||parsed.username||parsed.password||parsed.search||parsed.hash)throw new Error('unsafe baseUrl from context.md: '+redactUrl(baseUrl));if(/(?:^|\\.)(?:www\\.)?[^.]*(?:prod|production)/i.test(parsed.hostname))throw new Error('production URL forbidden: '+redactUrl(baseUrl));baseUrl=parsed.toString();const safe=redactUrl(baseUrl);const curlCheck=spawnSync('curl',['--version'],{encoding:'utf8'});if(curlCheck.error||curlCheck.status!==0){failBlocked('curl-unavailable',{baseUrlRedacted:safe,errorClass:'curl-missing'});}function probe(method){const args=['-sS','-o','/dev/null','-w','%{http_code}','--connect-timeout','3','--max-time','8','-X',method,'-L','--max-redirs','3','--http1.1','--proto-redir','=http,https',safe];const r=spawnSync('curl',args,{encoding:'utf8'});return r;}let used='HEAD';let r=probe('HEAD');let code=String(r.stdout||'').trim();let statusNum=parseInt(code,10);const headRejected=r.status!==0||!statusNum||statusNum===405||statusNum===501;if(headRejected){used='GET';r=probe('GET');code=String(r.stdout||'').trim();statusNum=parseInt(code,10);}const ok=statusNum>=200&&statusNum<400;if(!ok){const errClass=r.error?'spawn-error':(r.status!==0?'curl-exit-'+r.status:('http-'+statusNum));failBlocked('frontend-base-url-unreachable',{baseUrlRedacted:safe,httpStatus:statusNum||null,method:used,curlExit:r.status,errorClass:errClass});}writeProbe({status:'reachable',blockedReason:null,baseUrl:safe,baseUrlRedacted:safe,baseUrlSource,httpStatus:statusNum,method:used,curlExit:r.status});console.log('frontend-test-execution-v1 validated contextBaseUrl='+safe+' source='+baseUrlSource+' probe=reachable method='+used+' httpStatus='+statusNum);`),
5012
- ].join(" "),
5013
- ],
5038
+ commands: [],
5039
+ frontendTestEnvironmentProbe: {},
5014
5040
  cwd: ".",
5015
5041
  timeoutMs: 60000,
5016
5042
  },
@@ -5031,6 +5057,7 @@ function buildFrontendTestHybridDag(sources) {
5031
5057
  subtask_prompt: [
5032
5058
  "Use skill playwright-cli-case-generator.",
5033
5059
  "Read testcase/frontend/rag/standard-scenarios.v1.json and cover priority=must scenarios (or record GAP in coverage-map). Include ## 测试点 and ## 测试步骤 in each case.",
5060
+ ...(uiAnchorsLedgerInstruction ? [uiAnchorsLedgerInstruction] : []),
5034
5061
  "Read only testcase/frontend/rag/context.md, testcase/frontend/rag/coverage-map.md, and the draft case paths testcase/frontend/cases/FE-*.md, testcase/frontend/cases/index.md, and testcase/frontend/cases/manifest.draft.json. Write only those same draft paths. Do not write testcase/frontend/cases/manifest.json.",
5035
5062
  "Generate Markdown cases, index.md and manifest.draft.json (schemaVersion 1; cases[] with caseId, casePath, dimension, acIds, evidenceDir).",
5036
5063
  "HARD ID CONTRACT (do not confuse these):",
@@ -5190,7 +5217,7 @@ function buildFrontendTestHybridDag(sources) {
5190
5217
  subtaskPromptTemplate: [
5191
5218
  "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session; do not use /new). playwright-cli-only: never bare Playwright CLI/API/test runner; no fallback.",
5192
5219
  "Use the structured playwright_cli tool for every browser action. Do not request or search for bash. Do not execute raw shell commands. Translate each playwright-cli line in the case Markdown into one playwright_cli tool call ({command, args?, timeoutSeconds?}).",
5193
- "1) Read the concrete baseUrl from testcase/frontend/rag/context.md; it has already passed the environment shell safety/reachability gate. Start browser ONLY via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>] (default session only; no -s=). 2) Dynamic refs: eX/eY in case Markdown are documentation placeholders, never tool args. Immediately before every structured playwright_cli call that references an element, parse the current real eNN from the immediately preceding latest snapshot and pass only that real eNN; never send literal `eX`/`eY`. A new snapshot invalidates prior refs, so never reuse stale refs. File outputs are canonical: screenshot args [--filename, final.png] (or [e5, --filename, final.png] for a real target), PDF args [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or any output path as a positional target. Follow case steps with snapshot before element refs using only playwright_cli. A passed case requires this same child receipt order: successful open → successful find → controller post-execution cleanup. Only successful find is a meaningful assertion; snapshot, goto, screenshot, request/console, click/fill and other ordinary interactions cannot establish passed authority. 3) Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence (blockedReason playwright-cli-unavailable | frontend-base-url-unreachable); never invent CLI-unavailable solely because bash is absent. 4) For U/D: enforce current-user ownership / create-or-mock-or-blocked; never mutate other users' data.",
5220
+ "1) Read the concrete baseUrl from testcase/frontend/rag/context.md; it has already passed the environment shell safety/reachability gate. Start browser ONLY via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>] (default session only; no -s=). 2) Dynamic refs: eX/eY in case Markdown are documentation placeholders, never tool args. Immediately before every structured playwright_cli call that references an element, parse the current real eNN from the immediately preceding latest snapshot and pass only that real eNN; never send literal `eX`/`eY`. A new snapshot invalidates prior refs, so never reuse stale refs. File outputs are canonical: screenshot args [--filename, final.png] (or [e5, --filename, final.png] for a real target), PDF args [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or any output path as a positional target. Follow case steps with snapshot before element refs using only playwright_cli. A passed case requires this same child receipt order: successful open → successful find → controller post-execution cleanup. Only successful find is a meaningful assertion; snapshot, goto, screenshot, request/console, click/fill and other ordinary interactions cannot establish passed authority. 3) Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence (blockedReason playwright-cli-unavailable | frontend-base-url-unreachable); never invent CLI-unavailable solely because bash is absent. Unique-control early stop: when an AC names a specific control that is absent from the first post-navigation snapshot after reaching the required state (and no setup-required gate appeared), do ONE find with the case's literal as evidence; if it returns 0 matches, record status=blocked with blockedReason unique-control-unavailable citing that single find - do NOT re-explore with alternative literals, repeated snapshots, navigation detours, or rerun loops. 4) For U/D: enforce current-user ownership / create-or-mock-or-blocked; never mutate other users' data.",
5194
5221
  "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId={{case.caseId}}, status passed|failed|blocked, evidencePaths (relative under evidenceDir). blocked needs non-empty blockedReason. After writing, self-check the same contract; if self-check fails, rewrite both files as status=blocked blockedReason=invalid-evidence-shape (never leave missing/malformed evidence).",
5195
5222
  "Business failed/blocked is a recorded result, not a node failure. Close browser via playwright_cli command=close. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
5196
5223
  ].join("\n\n"),
@@ -5213,7 +5240,7 @@ function buildFrontendTestHybridDag(sources) {
5213
5240
  "const manifestPath='testcase/frontend/cases/manifest.json';",
5214
5241
  "if(!fs.existsSync(manifestPath)){process.stdout.write(JSON.stringify({cases:[]}));process.exit(0);}",
5215
5242
  "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));const cases=[];",
5216
- "for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(!r.status){missing=true;reason='missing-result-files';}}catch(_){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||missing)&&attempt<" + String(round) + ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}",
5243
+ "for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(r.status==='failed')reason='failed-retry';if(!r.status){missing=true;reason='missing-result-files';}}catch(_){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||reason==='failed-retry'||missing)&&attempt<" + String(round) + ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}",
5217
5244
  `fs.mkdirSync('testcase/frontend/evidence',{recursive:true});fs.writeFileSync('testcase/frontend/evidence/${candidateArtifact}',JSON.stringify({schemaVersion:1,cases},null,2)+'\\n');process.stdout.write(JSON.stringify({cases}));`,
5218
5245
  ].join("");
5219
5246
  tasks.push({
@@ -5281,6 +5308,7 @@ function buildFrontendTestHybridDag(sources) {
5281
5308
  "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite the authoritative execution.md and case-result.json; its final status replaces the earlier case result.",
5282
5309
  "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use structured playwright_cli only; headless open.",
5283
5310
  "Read the concrete baseUrl from testcase/frontend/rag/context.md and start via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>]. Passed requires open → find → cleanup receipts.",
5311
+ "Unique-control early stop: if the first post-navigation snapshot lacks the control an AC names (and no setup-required gate appeared), one find returning 0 matches is sufficient evidence - write status=blocked blockedReason unique-control-unavailable instead of exploratory retries.",
5284
5312
  "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId, status, evidencePaths, rerunAttempt={{case.rerunAttempt}}. Write fixed sections `### 执行摘要` and `### 实际执行步骤` to execution.md when available.",
5285
5313
  ].join("\n\n"),
5286
5314
  },
@@ -6478,6 +6506,13 @@ async function buildHybridDagForTemplate(sources, template, options = {}) {
6478
6506
  spec = await buildSupervisedHybridDag(standard, sources);
6479
6507
  }
6480
6508
  applyProjectGovernanceReview(spec, template, sources);
6509
+ // Backend implementation templates only (D1 outer gate): assign the two
6510
+ // Pi extension buckets after all template-specific nodes exist.
6511
+ if (template === "standard-dag" ||
6512
+ template === "review-gated-dag" ||
6513
+ template === "supervised-implementation") {
6514
+ applyBackendPiExtensionBuckets(spec);
6515
+ }
6481
6516
  // New generate path always emits DagSpec v4 + bindings.
6482
6517
  spec.version = 4;
6483
6518
  if (!spec.runtimeContract) {
@@ -6486,7 +6521,12 @@ async function buildHybridDagForTemplate(sources, template, options = {}) {
6486
6521
  spec.sourceBinding = buildDagSourceBinding(sources);
6487
6522
  spec.taskContractBinding = taskContractBinding;
6488
6523
  assertNoGovernanceFlagOnDisallowedTemplate(spec, template);
6489
- parseDagSpec(spec);
6524
+ if (template === "standard-dag" ||
6525
+ template === "review-gated-dag" ||
6526
+ template === "supervised-implementation") {
6527
+ stampTargetTemplateTransientRetryProfile(spec);
6528
+ }
6529
+ spec = parseDagSpec(spec);
6490
6530
  assertValidDagSpec(spec);
6491
6531
  return spec;
6492
6532
  }
@@ -6537,6 +6577,36 @@ export async function buildHybridDagFromTask(sources, options = {}) {
6537
6577
  function cloneTask(task, patch = {}) {
6538
6578
  return { ...task, ...patch };
6539
6579
  }
6580
+ /**
6581
+ * Backend-implementation Pi extension buckets (plan 2026-08-21 D3):
6582
+ * - read side: every non-writer, non-closeout Pi node gets navigation
6583
+ * extensions (pi-codegraph + pi-lens);
6584
+ * - write side: bounded writers get only pi-codegraph (pi-lens registers
6585
+ * ast_grep_replace, which can bypass the writeSet — not loading it is
6586
+ * simpler and safer than filtering after load);
6587
+ * - everything else (closeout, shell, static, other templates) stays closed.
6588
+ * Idempotent: never overwrites an explicit piExtensions a task declares.
6589
+ */
6590
+ function applyBackendPiExtensionBuckets(spec) {
6591
+ for (const task of spec.tasks) {
6592
+ if (task.piExtensions !== undefined)
6593
+ continue;
6594
+ if (task.executor !== "pi")
6595
+ continue;
6596
+ if (task.id === "closeout-pi")
6597
+ continue;
6598
+ task.piExtensions =
6599
+ task.toolProfile === "write" ? ["pi-codegraph"] : ["pi-codegraph", "pi-lens"];
6600
+ }
6601
+ }
6602
+ function stampTargetTemplateTransientRetryProfile(spec) {
6603
+ for (const task of spec.tasks) {
6604
+ if (isTargetTemplateImplementPi(task) ||
6605
+ isCanonicalFinalVerifyShellRetryCandidate(task)) {
6606
+ task.transientRetryProfile = TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE;
6607
+ }
6608
+ }
6609
+ }
6540
6610
  /**
6541
6611
  * Apply default Pi retry policies to generated DAG nodes:
6542
6612
  * - safe read-only planner/scout/reviewer/verifier/supervisor/closeout
@@ -6557,6 +6627,10 @@ function applyDefaultReadOnlyRetryPolicy(spec) {
6557
6627
  : DEFAULT_READ_ONLY_PI_RETRY_POLICY;
6558
6628
  continue;
6559
6629
  }
6630
+ if (isTargetTemplateImplementPi(task)) {
6631
+ task.retryPolicy = TARGET_TEMPLATE_WRITER_TRANSPORT_RETRY_POLICY;
6632
+ continue;
6633
+ }
6560
6634
  if (isWriterTransportRetryCandidate(task)) {
6561
6635
  task.retryPolicy = WRITER_TRANSPORT_RETRY_POLICY;
6562
6636
  }
@@ -6817,6 +6891,7 @@ function buildReviewGatedHybridDag(standard, sources) {
6817
6891
  }));
6818
6892
  spec.tasks.splice(spec.tasks.length - 1, 0, buildReviewNode(sources), buildReviewVerdictRecoveryNode(sources), buildReviewGateNode(sources));
6819
6893
  applySddEmbeddedEnhancements(spec, sources.sddEmbeddedSkills ?? new Set());
6894
+ stampTargetTemplateTransientRetryProfile(spec);
6820
6895
  applyDefaultReadOnlyRetryPolicy(spec);
6821
6896
  parseDagSpec(spec);
6822
6897
  assertValidDagSpec(spec);
@@ -7148,6 +7223,9 @@ async function buildHardVerifyNode(sources) {
7148
7223
  forbiddenPaths: commonForbiddenPaths(sources),
7149
7224
  outputContract: "Archived hard verification stdout/stderr with exit codes; no worktree writes.",
7150
7225
  subtask_prompt: "Run hard verification after repair round.",
7226
+ transientRetryProfile: sources.verifyCommands && commands.length > 0
7227
+ ? TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE
7228
+ : undefined,
7151
7229
  shell: {
7152
7230
  commands,
7153
7231
  verifyEvidence: buildVerifyEvidence({
@@ -7318,6 +7396,7 @@ async function buildSupervisedHybridDag(standard, sources) {
7318
7396
  ],
7319
7397
  };
7320
7398
  applySddEmbeddedEnhancements(spec, sources.sddEmbeddedSkills ?? new Set());
7399
+ stampTargetTemplateTransientRetryProfile(spec);
7321
7400
  applyDefaultReadOnlyRetryPolicy(spec);
7322
7401
  parseDagSpec(spec);
7323
7402
  assertValidDagSpec(spec);