@tea-agent/loop-agent 0.42.0-next.8 → 0.42.0

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 (161) hide show
  1. package/CHANGELOG.md +56 -50
  2. package/dist/application/dag/run-dag.js +41 -0
  3. package/dist/application/task-lifecycle/advance.js +9 -4
  4. package/dist/build-stamp.json +3 -3
  5. package/dist/cli/program.js +1 -1
  6. package/dist/commands/dag-rerun-task.js +2 -0
  7. package/dist/commands/task-source-prepare.js +3 -1
  8. package/dist/executors/dag-pi-executor.js +1809 -213
  9. package/dist/executors/pi-extension-resolver.js +14 -2
  10. package/dist/executors/shell-executor.js +178 -73
  11. package/dist/shared/dag-failure-category.js +6 -0
  12. package/dist/task/contract/apply.js +36 -2
  13. package/dist/task/source-prepare/parse-intent.js +7 -0
  14. package/dist/worker/console/chat/chat-event-store.js +4 -2
  15. package/dist/worker/console/chat/pi-runtime.js +45 -2
  16. package/dist/worker/console/chat/resource-loader.js +4 -1
  17. package/dist/worker/console/chat/routes.js +28 -12
  18. package/dist/worker/console/chat/sdd-data-alignment.js +13 -2
  19. package/dist/worker/console/chat/session-store.js +5 -1
  20. package/dist/worker/console/chat/subagents/agent-tool.js +62 -0
  21. package/dist/worker/console/chat/subagents/explore-agent.js +169 -0
  22. package/dist/worker/console/chat/subagents/index.js +5 -0
  23. package/dist/worker/console/chat/subagents/orchestrator.js +273 -0
  24. package/dist/worker/console/chat/subagents/tool-policy.js +53 -0
  25. package/dist/worker/console/chat/subagents/types.js +13 -0
  26. package/dist/worker/console/chat/tool-preview.js +10 -0
  27. package/dist/worker/console/chat/tools.js +11 -1
  28. package/dist/worker/console/chat/turn-process.js +1 -0
  29. package/dist/worker/console/interview/tools.js +1 -0
  30. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C6n9_m0P.js → abnfDiagram-N423BO3Z-B0Q0nClf.js} +1 -1
  31. package/dist/worker/console/static/assets/{arc-DQh-IfZ1.js → arc-DCPjC19G.js} +1 -1
  32. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-54NnrwUC.js → architectureDiagram-T3A2C74G-CP5n9jmG.js} +1 -1
  33. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-pivRALGK.js → blockDiagram-VBNYF7ZC-CmaWi_Wg.js} +1 -1
  34. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BR7OV2NJ.js → c4Diagram-5PPSVZJV-Ct5tcMfZ.js} +1 -1
  35. package/dist/worker/console/static/assets/channel-DAS07MdS.js +1 -0
  36. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B1Aq6BcK.js → chunk-2GRJ4B5K-C0osm1Zf.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CbdU0rjo.js → chunk-2Q5K7J3B-CDWviED7.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Dqi2GJeD.js → chunk-5RXB4S5H-mtSk1-j7.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BYn1Hu4R.js → chunk-5VM5RSS4-D5J9PHC7.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DRLjDL0k.js → chunk-6Q2QTUOP-Begg4WAa.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CY9Xx2jV.js → chunk-GF5L2VYU-sF1AEy7T.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BNCZs7_z.js → chunk-JWPE2WC7-BqbsWXZb.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DZ8AStLL.js → chunk-KBJHAD2P-BKJrPcJ6.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DsxjYrzz.js → chunk-RYQCIY6F-CcwNMRho.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Ddx1KC1l.js → chunk-XXDRQBXY-BEJi3iIs.js} +1 -1
  46. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cwk-5jiW.js +1 -0
  47. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cwk-5jiW.js +1 -0
  48. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-W9TveCnK.js → cose-bilkent-JH36ORCC-DRLgTvVu.js} +1 -1
  49. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-l9j_ztZH.js → cynefin-VYW2F7L2-BoAVG3cs.js} +1 -1
  50. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BMJMKi4G.js → cynefinDiagram-MW4NZA55-Bzol06hR.js} +1 -1
  51. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-Bb4mG9pH.js → dagre-VZM6K2ZE-UmliJM77.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BMgL_Qv0.js → diagram-7IWD3JNH-BWLcJkFo.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Bvo2T4OQ.js → diagram-B4RE2ZJO-CgApOm-O.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-_5kWRN9c.js → diagram-LBJQPF4R-Bh3RgTLs.js} +1 -1
  55. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DqdMrltM.js → diagram-Q27KOJAE-Dsh-D5nE.js} +1 -1
  56. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CDDYsNkp.js → diagram-UB23O5K3-Dkbbpcpb.js} +1 -1
  57. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-c4nfnZUV.js → ebnfDiagram-BXEA7PRR-3EBaA3t3.js} +1 -1
  58. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DnkUcNMs.js → erDiagram-JOGREHBK-Bq2Dc5ok.js} +1 -1
  59. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-Dz0KGLZE.js → flowDiagram-UKHOOZJN-De7y55ha.js} +1 -1
  60. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cj1t1uka.js → ganttDiagram-PKOTCBZU-fLiDbQRh.js} +1 -1
  61. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-tp2FrBHd.js → gitGraphDiagram-DS77QQ5N-Du617mp1.js} +1 -1
  62. package/dist/worker/console/static/assets/index-C0O48S_P.js +449 -0
  63. package/dist/worker/console/static/assets/index-CzKf4U8P.css +1 -0
  64. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DQwJS8DH.js → infoDiagram-6WML65LV-BwmlF-XP.js} +1 -1
  65. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-eimCQWD4.js → ishikawaDiagram-WSZJBQD7-zsGtS59u.js} +1 -1
  66. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Dd9Gbwgv.js → journeyDiagram-NVQOT4AX-DTTPTe3f.js} +1 -1
  67. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Qve3cyft.js → kanban-definition-27J2QSJJ-BkXg9aP-.js} +1 -1
  68. package/dist/worker/console/static/assets/{linear-UXKSe36Z.js → linear-7U2ue5IE.js} +1 -1
  69. package/dist/worker/console/static/assets/{mermaid.core-CWhj4JXN.js → mermaid.core-BUuGHmWO.js} +5 -5
  70. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-yTcwf4SJ.js → mindmap-definition-FAOFIHXS-7q6fX0Sv.js} +1 -1
  71. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-_7qToaZP.js → pegDiagram-VL7TDLO6-CkQPJN55.js} +1 -1
  72. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CXmrKmDm.js → pieDiagram-7S7Q4E2Y-CCal_tfX.js} +1 -1
  73. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-hMmrJyaS.js → quadrantDiagram-CIZ2JOQS-B7bpxyAZ.js} +1 -1
  74. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BXw8tCBe.js → railroadDiagram-AXF67PYL-CJmww7D-.js} +1 -1
  75. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BmjgnLZl.js → requirementDiagram-LRYGKXZP-D6ldWJEv.js} +1 -1
  76. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Bj77SPpN.js → sankeyDiagram-W5VNT64P-VGh33I9e.js} +1 -1
  77. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-B2FDVysL.js → sequenceDiagram-SI44F4Z6-CCkDrIjU.js} +1 -1
  78. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-UChNY9ZZ.js → sizeCapture-X5ZJPWSS-NC8f5Otb.js} +1 -1
  79. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-wbAEkbd7.js → stateDiagram-OKZ733FA-_92ezZdF.js} +1 -1
  80. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyJg5gzW.js +1 -0
  81. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DCEkU8HR.js → swimlanes-SLNWSIFB-BdUCGtbP.js} +2 -2
  82. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B1cw_0Uy.js +8 -0
  83. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BGp7H06U.js → timeline-definition-Z64GVDOM-CdD1H8ia.js} +1 -1
  84. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DsCIQuWR.js → vennDiagram-T6HMQDX7-Dkdk3oFo.js} +1 -1
  85. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-cRZLP4Xe.js → wardleyDiagram-T6FBY63Y-CKu2uPPY.js} +1 -1
  86. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CP0xLR9Y.js → xychartDiagram-ELKLHX3M-ClqAnuG8.js} +1 -1
  87. package/dist/worker/console/static/index.html +2 -2
  88. package/dist/worker/console/static-src/chat-view-types.js +1 -1
  89. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +23 -0
  90. package/dist/worker/console/static-src/operator-chat/pending-user-message.js +32 -0
  91. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +21 -2
  92. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +2 -0
  93. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +2 -0
  94. package/dist/worker/console/static-src/operator-chat/useChatStream.js +30 -14
  95. package/dist/worker/console/static-src/operator-chat/useChatThread.js +133 -5
  96. package/dist/worker/console/static-src/shell/workspace-route.js +4 -0
  97. package/dist/worker/observe/routes.js +4 -0
  98. package/dist/worker/observe/static/constants.js +22 -22
  99. package/dist/worker/observe/static/dag-context-reason-labels.js +19 -0
  100. package/dist/worker/observe/static/dag-history-labels.js +1 -0
  101. package/dist/worker/observe/static/dag-inspector-humanize.d.ts +16 -0
  102. package/dist/worker/observe/static/dag-inspector-humanize.js +339 -0
  103. package/dist/worker/observe/static/dag-node-purpose.js +10 -10
  104. package/dist/worker/observe/static/index.html +4 -4
  105. package/dist/worker/observe/static/prompt-restart-candidates.js +4 -2
  106. package/dist/worker/observe/static/relations.js +5 -5
  107. package/dist/worker/observe/static/styles.css +171 -0
  108. package/dist/worker/observe/static/views/dag-graph.js +1 -1
  109. package/dist/worker/observe/static/views/dag-inspector.js +329 -174
  110. package/dist/worker/observe/static/views/dag.js +16 -12
  111. package/dist/worker/observe/static/views/session-timeline.js +5 -3
  112. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +18 -0
  113. package/dist/workflows/dag/backend-test-plan-protocol.js +104 -0
  114. package/dist/workflows/dag/backend-test-scenario-param.js +30 -1
  115. package/dist/workflows/dag/dag-retry-schema.js +3 -0
  116. package/dist/workflows/dag/frontend-contract-facts.js +130 -0
  117. package/dist/workflows/dag/frontend-design-policy.js +101 -16
  118. package/dist/workflows/dag/frontend-implementation-contract.js +410 -41
  119. package/dist/workflows/dag/frontend-plan-render.js +13 -2
  120. package/dist/workflows/dag/frontend-prewrite-gate.js +1 -1
  121. package/dist/workflows/dag/frontend-recovery-controller.js +25 -1
  122. package/dist/workflows/dag/frontend-recovery-plan.js +6 -2
  123. package/dist/workflows/dag/frontend-recovery-run.js +169 -15
  124. package/dist/workflows/dag/frontend-risk.js +2 -0
  125. package/dist/workflows/dag/frontend-shadow-dual-write.js +17 -1
  126. package/dist/workflows/dag/frontend-shape.js +16 -6
  127. package/dist/workflows/dag/frontend-test-execution-evidence.js +48 -0
  128. package/dist/workflows/dag/frontend-typed-event-store.js +3 -0
  129. package/dist/workflows/dag/frontend-verification-trace.js +38 -4
  130. package/dist/workflows/dag/frontend-writer-admission.js +46 -12
  131. package/dist/workflows/dag/init-hybrid.js +59 -29
  132. package/dist/workflows/dag/node-execution.js +277 -85
  133. package/dist/workflows/dag/recovery-lease.js +106 -16
  134. package/dist/workflows/dag/rerun-feedback.js +60 -0
  135. package/dist/workflows/dag/rerun-plan.js +90 -4
  136. package/dist/workflows/dag/rerun-run.js +28 -6
  137. package/dist/workflows/dag/rerun-task.js +224 -15
  138. package/dist/workflows/dag/retry-policy.js +27 -10
  139. package/dist/workflows/dag/runner-exit-diagnostics.js +125 -0
  140. package/dist/workflows/dag/runner.js +238 -29
  141. package/dist/workflows/dag/scheduler.js +21 -6
  142. package/dist/workflows/dag/structured-output-repair.js +4 -1
  143. package/dist/workflows/dag/types.js +4 -3
  144. package/dist/workflows/dag/validate.js +10 -8
  145. package/dist/workflows/dag/workspace-checkpoint.js +66 -0
  146. package/docs/operations/README.md +1 -0
  147. package/docs/templates/agent-dag.schema.json +2 -2
  148. package/docs/templates/backend-test-dag.json +6 -4
  149. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  150. package/harness.json +1 -1
  151. package/package.json +4 -5
  152. package/skills/frontend-plan/SKILL.md +14 -1
  153. package/skills/frontend-plan/references/decision-contract.md +101 -5
  154. package/skills/frontend-plan/references/design-decisions.md +32 -0
  155. package/dist/worker/console/static/assets/channel-DsZxrgqe.js +0 -1
  156. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CuToPeGV.js +0 -1
  157. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CuToPeGV.js +0 -1
  158. package/dist/worker/console/static/assets/index-D9Sc0f0y.js +0 -449
  159. package/dist/worker/console/static/assets/index-DDQc5a50.css +0 -1
  160. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-yPoT19ft.js +0 -1
  161. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-VWdarchG.js +0 -8
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
1
2
  import { isTerminalOperationState, } from "../../infrastructure/console/operation-store.js";
2
3
  import { runOperation, } from "../../worker/console/operation-runner.js";
3
4
  import { buildFailureCapsule, classifyFailureOwner, projectProtocolFailureReason, } from "./frontend-recovery-capsule.js";
@@ -78,7 +79,13 @@ export async function runRecoveryOperationController(input) {
78
79
  const readChangedFiles = deps.readChangedFiles ?? (async () => []);
79
80
  const fingerprints = [...(deps.previousFingerprints ?? [])];
80
81
  let budgetAggregate;
81
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
82
+ const pollIntervalMs = Math.max(10, deps.operationPollIntervalMs ?? 1_000);
83
+ const waitTimeoutMs = Math.max(pollIntervalMs, deps.operationWaitTimeoutMs ??
84
+ deps.operationRunnerDeps.defaultTimeoutMs ??
85
+ 30 * 60_000);
86
+ const waitStartedAt = Date.now();
87
+ let attempt = 1;
88
+ while (attempt <= maxAttempts) {
82
89
  const operation = await deps.store.get(operationId);
83
90
  if (!operation) {
84
91
  return {
@@ -190,7 +197,24 @@ export async function runRecoveryOperationController(input) {
190
197
  detail: `interrupt projection: ${projected}`,
191
198
  };
192
199
  }
200
+ const operationAlreadyInFlight = operation.state === "starting" || operation.state === "running";
201
+ const operationOwnedElsewhere = input.observeOnly &&
202
+ (operation.state === "queued" || operation.state === "needs-reconcile");
203
+ if (operationAlreadyInFlight || operationOwnedElsewhere) {
204
+ if (Date.now() - waitStartedAt >= waitTimeoutMs) {
205
+ return {
206
+ terminalReason: "needs-attention",
207
+ attempts: attempt,
208
+ budgetAggregate,
209
+ fingerprints,
210
+ detail: `recovery operation did not settle within ${waitTimeoutMs}ms`,
211
+ };
212
+ }
213
+ await delay(pollIntervalMs);
214
+ continue;
215
+ }
193
216
  await runOperation(operationId, deps.operationRunnerDeps);
217
+ attempt += 1;
194
218
  }
195
219
  return {
196
220
  terminalReason: "max-attempts",
@@ -26,7 +26,9 @@ export function deriveFrontendRecoveryFailureSource(result) {
26
26
  * Compute the frontend candidate-continuation reset partition:
27
27
  * - `frontend-plan-pi` failure → reset plan + design-review + policy/admission (+ descendants);
28
28
  * - `frontend-design-policy-shell` failure → reset policy/admission (+ descendants);
29
- * - admission/design-review failure → reset writer admission (+ descendants);
29
+ * - design-review rejection → reset plan + design review + admission (+ descendants);
30
+ * - final-review rejection → reset implementation + verification/review (+ descendants);
31
+ * - other admission failures → reset writer admission (+ descendants);
30
32
  *
31
33
  * Everything upstream of the failure source is imported as verified parent facts.
32
34
  */
@@ -61,7 +63,9 @@ function computeFrontendRecoveryPlanForSource(input) {
61
63
  // reset from implement: the recovery re-implements and re-verifies.
62
64
  const resetRoot = input.failureSource === "frontend-verify-shell"
63
65
  ? "frontend-implement-pi"
64
- : input.failureSource;
66
+ : input.failureSource === "frontend-review-pi"
67
+ ? "frontend-implement-pi"
68
+ : input.failureSource;
65
69
  const resetNodeIds = computeResetClosure(input.spec, resetRoot);
66
70
  const resetSet = new Set(resetNodeIds);
67
71
  const importedNodeIds = input.spec.tasks
@@ -3,8 +3,10 @@ import path from "node:path";
3
3
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
4
4
  import { sha256Hex } from "./frontend-implementation-contract.js";
5
5
  import { FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH, FRONTEND_RECOVERY_INTENT_REL_DIR, FRONTEND_RECOVERY_STAGING_PREFIX, computeFrontendRecoveryPlan, computeFrontendRecoveryPlanForSource, } from "./frontend-recovery-plan.js";
6
- import { dagRunDirExists, getDagRunDir, readDagRunSpec, readDagRunState, transferDagRunDir, writeDagRunState, } from "./lifecycle.js";
6
+ import { dagRunDirExists, getDagRunDir, locateDagRun, readDagRunSpec, readDagRunState, transferDagRunDir, writeDagRunState, } from "./lifecycle.js";
7
7
  import { buildDagRunId } from "./runner.js";
8
+ import { buildFrontendTopologyBinding } from "./frontend-shape.js";
9
+ import { FRONTEND_SHAPE_BOUNDARIES, isCanonicalFrontendShapeDag, } from "./frontend-shape-facts.js";
8
10
  import { prepareRunDir, writeNodeRecord } from "./run-store.js";
9
11
  import { FRONTEND_WRITER_ADMISSION_RESULT_ARTIFACT, readFrontendWriterAdmissionResult, } from "./scheduler.js";
10
12
  import { topoSortToRanks } from "./topo.js";
@@ -273,14 +275,63 @@ async function casUpdateFrontendRecoveryState(parentRunDir, expectedRevision, de
273
275
  await deps.writeParentState(parentRunDir, state);
274
276
  }
275
277
  async function materializeChildStaging(input) {
276
- const { cwd, parentRunDir, parentRunId, parentSpec, parentState, recovery, requestId, childRunId, stagingDir, createdAt, } = input;
278
+ const { cwd, parentRunDir, parentRunId, parentSpec, parentState, recovery, recoveryPlan, requestId, childRunId, stagingDir, createdAt, } = input;
277
279
  await prepareRunDir(stagingDir);
278
- await writeJsonVerified(stagingDir, "run.json", parentSpec);
280
+ // Design-review rejection must be visible to the reset planner. Recovery
281
+ // children otherwise copy the old spec and only rerun design review, which
282
+ // deterministically re-rejects the unchanged plan. Bind the parent's typed
283
+ // findings into the child spec as ordinary rerun feedback.
284
+ let childSpec = parentSpec;
285
+ if (recovery.failureSource === "frontend-plan-pi" ||
286
+ recovery.failureSource === "frontend-review-pi") {
287
+ const designRecovery = parentState.terminalReason === "design-request-changes";
288
+ const reviewRecovery = recovery.failureSource === "frontend-review-pi" ||
289
+ parentState.terminalReason === "review-request-changes";
290
+ let feedback;
291
+ try {
292
+ const { deriveDagRerunFeedback } = await import("./rerun-feedback.js");
293
+ const taskId = parentSpec.taskContractBinding?.taskId ??
294
+ parentSpec.sourceBinding?.taskId ??
295
+ parentRunId;
296
+ feedback = await deriveDagRerunFeedback({
297
+ parentRunId,
298
+ taskId,
299
+ runDir: parentRunDir,
300
+ spec: parentSpec,
301
+ state: parentState,
302
+ });
303
+ if (reviewRecovery &&
304
+ feedback &&
305
+ feedback.sourceNodeId !== "frontend-review-pi") {
306
+ // Do not accidentally carry an older design/verify finding when a
307
+ // review-triggered continuation has lost its typed review fact.
308
+ feedback = undefined;
309
+ }
310
+ }
311
+ catch (error) {
312
+ if (designRecovery || reviewRecovery) {
313
+ throw new Error(`frontend recovery feedback unavailable: ${error instanceof Error ? error.message : String(error)}`);
314
+ }
315
+ }
316
+ if (feedback) {
317
+ childSpec = { ...parentSpec, rerunFeedback: feedback };
318
+ }
319
+ else if (designRecovery || reviewRecovery) {
320
+ // A design/review rejection cannot be safely retried without its typed
321
+ // findings. Fail closed instead of deterministically re-running the
322
+ // unchanged plan and producing another rejection.
323
+ throw new Error(`frontend recovery feedback unavailable: no typed ${reviewRecovery ? "review" : "design"} findings were found for recovery`);
324
+ }
325
+ }
326
+ await writeJsonVerified(stagingDir, "run.json", childSpec);
279
327
  // Phase 5: writer transient partial-write recovery resets the writer subtree
280
328
  // (frontend-implement-pi) instead of the candidate producer; everything
281
329
  // upstream is imported as verified parent facts.
282
330
  let plan;
283
- if (recovery.failureSource) {
331
+ if (recoveryPlan) {
332
+ plan = recoveryPlan;
333
+ }
334
+ else if (recovery.failureSource) {
284
335
  plan = computeFrontendRecoveryPlanForSource({
285
336
  spec: parentSpec,
286
337
  parentRunId,
@@ -300,10 +351,10 @@ async function materializeChildStaging(input) {
300
351
  result: admissionRead.result,
301
352
  });
302
353
  }
303
- const { ranks } = topoSortToRanks(parentSpec);
354
+ const { ranks } = topoSortToRanks(childSpec);
304
355
  const resetSet = new Set(plan.resetNodeIds);
305
356
  const importedSet = new Set(plan.importedNodeIds);
306
- const tasksById = new Map(parentSpec.tasks.map((task) => [task.id, task]));
357
+ const tasksById = new Map(childSpec.tasks.map((task) => [task.id, task]));
307
358
  const nodes = {};
308
359
  const importedFacts = [];
309
360
  for (const nodeId of plan.importedNodeIds) {
@@ -328,7 +379,7 @@ async function materializeChildStaging(input) {
328
379
  }
329
380
  nodes[nodeId] = buildPendingNodeRecord(task);
330
381
  }
331
- for (const task of parentSpec.tasks) {
382
+ for (const task of childSpec.tasks) {
332
383
  if (!resetSet.has(task.id) && !importedSet.has(task.id)) {
333
384
  throw new Error(`node ${task.id} missing from frontend recovery plan partition`);
334
385
  }
@@ -338,13 +389,29 @@ async function materializeChildStaging(input) {
338
389
  const canonicalContract = await copyArtifactVerified({
339
390
  parentRunDir,
340
391
  newRunDir: stagingDir,
341
- relativePath: resolveCanonicalContractRelPath(parentSpec),
392
+ relativePath: resolveCanonicalContractRelPath(childSpec),
342
393
  });
343
394
  const writerAdmissionResult = await copyArtifactVerified({
344
395
  parentRunDir,
345
396
  newRunDir: stagingDir,
346
397
  relativePath: FRONTEND_WRITER_ADMISSION_RESULT_ARTIFACT,
347
398
  });
399
+ // The scheduler gates every shape boundary by its committed fact once the
400
+ // boundary's source node is FINISHED. Imported nodes stay FINISHED in the
401
+ // child, so their boundaries must inherit the parent's committed facts or
402
+ // the child hard-blocks at the first gated rank. Reset boundaries re-commit
403
+ // their own facts when their source nodes re-run.
404
+ const inheritedCommittedFacts = {};
405
+ if (isCanonicalFrontendShapeDag(new Map(childSpec.tasks.map((task) => [task.id, task])))) {
406
+ for (const entry of FRONTEND_SHAPE_BOUNDARIES) {
407
+ if (!importedSet.has(entry.sourceNodeId))
408
+ continue;
409
+ const parentFact = parentState.frontendShapeState?.committedFacts[entry.boundary];
410
+ if (parentFact) {
411
+ inheritedCommittedFacts[entry.boundary] = structuredClone(parentFact);
412
+ }
413
+ }
414
+ }
348
415
  const childState = {
349
416
  version: 1,
350
417
  title: parentSpec.title,
@@ -354,6 +421,18 @@ async function materializeChildStaging(input) {
354
421
  status: "pending",
355
422
  ranks,
356
423
  nodes,
424
+ // Mirror createInitialRunState: a canonical frontend child must carry its
425
+ // own frozen topology binding or executeDag's binding assert rejects the
426
+ // staged child before its first node runs.
427
+ ...(isCanonicalFrontendShapeDag(new Map(childSpec.tasks.map((task) => [task.id, task])))
428
+ ? {
429
+ frontendShapeState: {
430
+ frozenTopology: buildFrontendTopologyBinding(childSpec.tasks.map((task) => task.id)),
431
+ revision: 0,
432
+ committedFacts: inheritedCommittedFacts,
433
+ },
434
+ }
435
+ : {}),
357
436
  frontendRecoveryState: {
358
437
  schemaVersion: 1,
359
438
  phase: "child-running",
@@ -362,8 +441,11 @@ async function materializeChildStaging(input) {
362
441
  parentRunId,
363
442
  childRunId,
364
443
  attemptId: recovery.attemptId,
365
- attemptIndex: 1,
366
- continuationCount: 1,
444
+ attemptIndex: recovery.attemptIndex + 1,
445
+ continuationCount: recovery.continuationCount + 1,
446
+ ...(recovery.failureSource
447
+ ? { failureSource: recovery.failureSource }
448
+ : {}),
367
449
  revision: 0,
368
450
  },
369
451
  ...(parentState.evaluation
@@ -432,7 +514,41 @@ export async function stageRecoveryChild(options) {
432
514
  const parentRunDir = getDagRunDir(cwd, "active", parentRunId);
433
515
  const parentState = await deps.readParentState(parentRunDir);
434
516
  const parentSpec = await readDagRunSpec(parentRunDir);
435
- const recovery = parentState.frontendRecoveryState;
517
+ let recovery = parentState.frontendRecoveryState;
518
+ if (options.recoveryIntent) {
519
+ const intent = options.recoveryIntent;
520
+ if (intent.parentRunId !== parentRunId ||
521
+ intent.requestId.length === 0 ||
522
+ intent.phase !== "child-staging" ||
523
+ intent.childRunId !== undefined ||
524
+ intent.revision !== 0) {
525
+ throw new Error("frontend recovery intent binding mismatch");
526
+ }
527
+ if (recovery?.requestId === intent.requestId) {
528
+ if (recovery.parentRunId !== intent.parentRunId ||
529
+ recovery.recoveryRootRunId !== intent.recoveryRootRunId ||
530
+ recovery.attemptIndex !== intent.attemptIndex ||
531
+ recovery.continuationCount !== intent.continuationCount ||
532
+ recovery.failureSource !== intent.failureSource) {
533
+ throw new Error("frontend recovery intent replay binding mismatch");
534
+ }
535
+ }
536
+ else {
537
+ const previousAttemptIndex = recovery?.attemptIndex ?? 0;
538
+ const previousContinuationCount = recovery?.continuationCount ?? 0;
539
+ const expectedRoot = recovery?.recoveryRootRunId ?? parentRunId;
540
+ if (intent.recoveryRootRunId !== expectedRoot ||
541
+ intent.attemptIndex !== previousAttemptIndex ||
542
+ intent.continuationCount !== previousContinuationCount) {
543
+ throw new Error("frontend recovery intent lineage mismatch");
544
+ }
545
+ recovery = structuredClone(intent);
546
+ await deps.writeParentState(parentRunDir, {
547
+ ...parentState,
548
+ frontendRecoveryState: recovery,
549
+ });
550
+ }
551
+ }
436
552
  if (!recovery) {
437
553
  throw new Error(`parent run ${parentRunId} has no frontend recovery intent`);
438
554
  }
@@ -440,17 +556,49 @@ export async function stageRecoveryChild(options) {
440
556
  if (!requestId) {
441
557
  throw new Error(`parent run ${parentRunId} recovery intent has no requestId`);
442
558
  }
559
+ if (options.recoveryPlan) {
560
+ if (options.recoveryPlan.parentRunId !== parentRunId ||
561
+ options.recoveryPlan.requestId !== requestId ||
562
+ options.recoveryPlan.failureSource !== recovery.failureSource) {
563
+ throw new Error("frontend recovery plan binding mismatch");
564
+ }
565
+ const expectedPlan = computeFrontendRecoveryPlanForSource({
566
+ spec: parentSpec,
567
+ parentRunId,
568
+ requestId,
569
+ failureSource: recovery.failureSource,
570
+ });
571
+ if (JSON.stringify(expectedPlan.resetNodeIds) !==
572
+ JSON.stringify(options.recoveryPlan.resetNodeIds) ||
573
+ JSON.stringify(expectedPlan.importedNodeIds) !==
574
+ JSON.stringify(options.recoveryPlan.importedNodeIds)) {
575
+ throw new Error("frontend recovery plan drifted from parent DAG");
576
+ }
577
+ }
443
578
  // Idempotent short-circuit (D4.2): reserved childRunId + activated marker +
444
579
  // existing child dir means the request was already fully committed.
445
580
  if (recovery.childRunId) {
446
581
  const existingMarker = await readFrontendRecoveryMarker(parentRunDir, requestId);
447
582
  if (existingMarker) {
448
- const existingChildDir = getDagRunDir(cwd, "active", existingMarker.childRunId);
449
- if (await dagRunDirExists(existingChildDir)) {
583
+ const existingChild = await locateDagRun(cwd, existingMarker.childRunId);
584
+ if (existingChild &&
585
+ (existingChild.lifecycle === "active" ||
586
+ existingChild.lifecycle === "paused" ||
587
+ existingChild.lifecycle === "completed")) {
588
+ await verifyThreeWayConsistency({
589
+ parentRunDir,
590
+ activeChildDir: existingChild.runDir,
591
+ marker: existingMarker,
592
+ parentRunId,
593
+ requestId,
594
+ recoveryRootRunId: recovery.recoveryRootRunId,
595
+ childRunId: existingMarker.childRunId,
596
+ deps,
597
+ });
450
598
  return {
451
599
  ok: true,
452
600
  childRunId: existingMarker.childRunId,
453
- childRunDir: existingChildDir,
601
+ childRunDir: existingChild.runDir,
454
602
  marker: existingMarker,
455
603
  idempotentReplay: true,
456
604
  };
@@ -461,8 +609,13 @@ export async function stageRecoveryChild(options) {
461
609
  // buildDagRunId is deterministic for the same task title on the same day. A
462
610
  // recovery child must never reuse its still-active parent directory, or the
463
611
  // staging path aliases the parent and the required import manifest is absent.
612
+ // The default builder must receive `{ cwd }` (its options object), not the
613
+ // bare string: only the options form enables the collision-safe `-rN`
614
+ // suffix across active/completed/paused runs.
464
615
  const childRunId = recovery.childRunId ?? await (async () => {
465
- const generatedChildRunId = await (options.buildChildRunId ?? buildDagRunId)(parentSpec, cwd);
616
+ const generatedChildRunId = options.buildChildRunId
617
+ ? await options.buildChildRunId(parentSpec, cwd)
618
+ : await buildDagRunId(parentSpec, { cwd });
466
619
  return generatedChildRunId === parentRunId
467
620
  ? `${generatedChildRunId}-recovery-${requestId.slice(0, 8)}`
468
621
  : generatedChildRunId;
@@ -500,6 +653,7 @@ export async function stageRecoveryChild(options) {
500
653
  parentSpec,
501
654
  parentState,
502
655
  recovery,
656
+ recoveryPlan: options.recoveryPlan,
503
657
  requestId,
504
658
  childRunId,
505
659
  stagingDir,
@@ -70,8 +70,10 @@ function blobOf(input) {
70
70
  ].join("\n");
71
71
  }
72
72
  function pathSpreadScore(paths) {
73
+ const auxiliaryRoot = /^(?:test|tests|__tests__|e2e|cypress|spec|specs|docs|documentation)$/i;
73
74
  const tops = new Set(paths
74
75
  .map((p) => p.replace(/\\/g, "/").split("/").filter(Boolean)[0] ?? "")
76
+ .filter((root) => !auxiliaryRoot.test(root))
75
77
  .filter(Boolean));
76
78
  return tops.size;
77
79
  }
@@ -226,6 +226,7 @@ export function parseTargetSurfaceFacts(text) {
226
226
  }
227
227
  export const PLAN_LEDGER_FACT_KINDS = [
228
228
  "target-surface",
229
+ "state-registry",
229
230
  "component-choice",
230
231
  "state-flow",
231
232
  "data-flow",
@@ -484,7 +485,22 @@ export function assemblePlanPatchFromCommittedFacts(records, contractRequirement
484
485
  }
485
486
  const verificationTargets = verificationTargetOrder
486
487
  .map((id) => verificationTargetById.get(id))
487
- .filter((entry) => Boolean(entry));
488
+ .filter((entry) => Boolean(entry))
489
+ .map((entry) => {
490
+ // Coverage precedes UX. A later state-flow fact explicitly naming
491
+ // this VT establishes the reverse binding without another model call.
492
+ // No path/name heuristics: unbound or ambiguous decisions stay subject
493
+ // to canonical validation. Recompute from live states after removals.
494
+ const boundStates = [...stateByName.values()]
495
+ .filter((state) => state.applicable === true &&
496
+ asStringArray(state.verificationTargetIds).includes(asString(entry.id)))
497
+ .map((state) => asString(state.name));
498
+ if (boundStates.length === 0)
499
+ return entry;
500
+ return { ...entry, uiStates: [...new Set([
501
+ ...asStringArray(entry.uiStates), ...boundStates,
502
+ ])].sort() };
503
+ });
488
504
  if (verificationTargets.length > 0) {
489
505
  patch.verificationTargets = verificationTargets;
490
506
  }
@@ -46,8 +46,15 @@ const EXECUTABLE_SHAPE_RANK = {
46
46
  high: 3,
47
47
  };
48
48
  function pathSpreadScore(paths) {
49
+ // Verification-only roots are routinely authorized together with the
50
+ // implementation root (for example `src/**` + `test/**`). They do not make
51
+ // a UI change cross-domain, so exclude them from the topology spread signal.
52
+ // Keep product packages and source roots intact: `apps/**` + `packages/**`
53
+ // must still count as a real spread and prevent an unsafe small topology.
54
+ const auxiliaryRoot = /^(?:test|tests|__tests__|e2e|cypress|spec|specs|docs|documentation)$/i;
49
55
  const tops = new Set(paths
50
56
  .map((p) => p.replace(/\\/g, "/").split("/").filter(Boolean)[0] ?? "")
57
+ .filter((root) => !auxiliaryRoot.test(root))
51
58
  .filter(Boolean));
52
59
  return tops.size;
53
60
  }
@@ -65,6 +72,13 @@ function collectEscalationSignals(blob, supervised) {
65
72
  }
66
73
  return [...new Set(signals)];
67
74
  }
75
+ /** Negation applies to the API mention itself, never to unrelated Mock policy. */
76
+ function hasFrontendRemoteSignal(blob) {
77
+ const remaining = blob
78
+ .replace(/\bno\s+api\b/gi, "")
79
+ .replace(/无\s*(?:api\b|接口)/gi, "");
80
+ return /\b(api|fetch|axios|graphql|endpoint)\b|远程|接口/i.test(remaining);
81
+ }
68
82
  function resolveBaselineFrontendTaskShape(input) {
69
83
  const blob = [
70
84
  input.requirementMarkdown ?? "",
@@ -84,9 +98,7 @@ function resolveBaselineFrontendTaskShape(input) {
84
98
  const paths = input.allowedPaths ?? [];
85
99
  const spread = pathSpreadScore(paths);
86
100
  const concentrated = spread <= 1 && paths.length <= 4;
87
- const hasApiSignal = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i.test(blob) &&
88
- !(/(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])/i.test(blob) ||
89
- /无\s*api|无\s*接口/i.test(blob));
101
+ const hasApiSignal = hasFrontendRemoteSignal(blob);
90
102
  const complexity = (input.complexity ?? "").toLowerCase();
91
103
  // split-required wins over everything: it is a termination/escalation
92
104
  // signal, not an executable topology.
@@ -266,9 +278,7 @@ export function deriveFrontendRuntimeShapeFacts(input) {
266
278
  const supervised = /\bsupervised\b/i.test(blob);
267
279
  const escalationSignals = collectEscalationSignals(blob, supervised);
268
280
  const paths = input.allowedPaths ?? [];
269
- const hasApiSignal = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i.test(blob) &&
270
- !(/(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])/i.test(blob) ||
271
- /无\s*api|无\s*接口/i.test(blob));
281
+ const hasApiSignal = hasFrontendRemoteSignal(blob);
272
282
  const requiresFull = escalationSignals.length > 0 || pathSpreadScore(paths) > 1 || hasApiSignal;
273
283
  return {
274
284
  splitRequired: input.splitRequired,
@@ -0,0 +1,48 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { z } from "zod";
4
+ /** Observe supported frozen Vitest commands without changing their selection. */
5
+ export async function withFrontendTestReporter(input) {
6
+ // Compound commands/wrappers need their own adapter: never append flags to
7
+ // a different shell command, change selection, or override a user reporter.
8
+ if (/[;&|<>`\n\r$]/.test(input.command) || /--(?:reporter|outputFile)/.test(input.command))
9
+ return undefined;
10
+ const direct = /^(?:(?:npx|pnpm exec|yarn exec)\s+)?vitest\s+run(?:\s|$)/.test(input.command.trim());
11
+ const npm = /^npm\s+(?:run\s+([\w:-]+)|(test))(?=\s|$)(.*)$/.exec(input.command.trim());
12
+ let separator = "";
13
+ if (!direct) {
14
+ if (!npm)
15
+ return undefined;
16
+ let manifest;
17
+ try {
18
+ manifest = JSON.parse(await readFile(path.join(input.cwd, "package.json"), "utf8"));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ const script = manifest.scripts?.[npm[1] ?? "test"] ?? "";
24
+ if (!/^vitest\s+run(?:\s|$)/.test(script) || /[;&|<>`\n\r$]|--(?:reporter|outputFile)/.test(script))
25
+ return undefined;
26
+ if (npm[3]?.trim() && !/^--(?:\s|$)/.test(npm[3].trim()))
27
+ return undefined;
28
+ separator = npm[3]?.trim() ? "" : " --";
29
+ }
30
+ const quote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
31
+ return `${input.command}${separator} --reporter=json --outputFile=${quote(input.reportPath)}`;
32
+ }
33
+ const resultSchema = z.object({
34
+ testResults: z.array(z.object({
35
+ name: z.string().min(1),
36
+ assertionResults: z.array(z.object({
37
+ title: z.string(), fullName: z.string().optional(),
38
+ ancestorTitles: z.array(z.string()).optional(), status: z.string(),
39
+ })),
40
+ })),
41
+ });
42
+ export function parseFrontendTestExecutionReport(raw, cwd) {
43
+ return resultSchema.parse(raw).testResults.flatMap((suite) => suite.assertionResults.map((test) => ({
44
+ file: path.relative(cwd, path.resolve(cwd, suite.name)).replace(/\\/g, "/"),
45
+ title: test.fullName ?? [...(test.ancestorTitles ?? []), test.title].join(" "),
46
+ status: test.status,
47
+ })));
48
+ }
@@ -95,6 +95,8 @@ export const CONTRACT_FACT_KINDS = [
95
95
  "open-question",
96
96
  "split-proposal",
97
97
  "openspec-selection",
98
+ "ui-state-declaration",
99
+ "required-deliverables",
98
100
  "contract-finalized",
99
101
  ];
100
102
  export const CONTRACT_TERMINAL_FACT_KINDS = ["contract-finalized"];
@@ -124,6 +126,7 @@ export const PLAN_RECORD_FACT_KINDS = [
124
126
  "plan-requirement",
125
127
  "plan-verification-target",
126
128
  "plan-evidence-gap",
129
+ "state-registry",
127
130
  ];
128
131
  /**
129
132
  * A+B (AC-009): typed issue category shared by review and design change
@@ -152,6 +152,7 @@ export function extractFrontendTestTitles(content) {
152
152
  const tokens = lexFrontendTestSource(content);
153
153
  const titles = [];
154
154
  const configurationModifiers = new Set(["each", "runIf", "skipIf"]);
155
+ const nonExecutingModifiers = new Set(["skip", "todo", "fails"]);
155
156
  function closingParenIndex(openIndex) {
156
157
  let depth = 0;
157
158
  for (let cursor = openIndex; cursor < tokens.length; cursor += 1) {
@@ -177,12 +178,21 @@ export function extractFrontendTestTitles(content) {
177
178
  }
178
179
  let cursor = index + 1;
179
180
  let hasConfigurationModifier = false;
181
+ let hasNonExecutingModifier = false;
182
+ let conditionalModifier;
180
183
  while (tokens[cursor]?.type === "punctuation" &&
181
184
  tokens[cursor]?.value === "." &&
182
185
  tokens[cursor + 1]?.type === "identifier") {
183
186
  if (tokens[cursor + 1]?.type === "identifier" &&
184
187
  configurationModifiers.has(tokens[cursor + 1].value)) {
185
188
  hasConfigurationModifier = true;
189
+ if (["runIf", "skipIf"].includes(tokens[cursor + 1].value)) {
190
+ conditionalModifier = tokens[cursor + 1].value;
191
+ }
192
+ }
193
+ if (tokens[cursor + 1]?.type === "identifier" &&
194
+ nonExecutingModifiers.has(tokens[cursor + 1].value)) {
195
+ hasNonExecutingModifier = true;
186
196
  }
187
197
  cursor += 2;
188
198
  }
@@ -191,6 +201,13 @@ export function extractFrontendTestTitles(content) {
191
201
  continue;
192
202
  }
193
203
  if (hasConfigurationModifier) {
204
+ if (conditionalModifier && tokens[cursor + 1]?.type === "identifier" &&
205
+ tokens[cursor + 2]?.value === ")") {
206
+ const condition = tokens[cursor + 1].value;
207
+ if ((conditionalModifier === "runIf" && condition === "false") ||
208
+ (conditionalModifier === "skipIf" && condition === "true"))
209
+ hasNonExecutingModifier = true;
210
+ }
194
211
  const close = closingParenIndex(cursor);
195
212
  if (close === null ||
196
213
  tokens[close + 1]?.type !== "punctuation" ||
@@ -199,6 +216,11 @@ export function extractFrontendTestTitles(content) {
199
216
  }
200
217
  cursor = close + 1;
201
218
  }
219
+ if (hasNonExecutingModifier) {
220
+ // A skipped suite also skips every nested test title in its body.
221
+ index = closingParenIndex(cursor) ?? tokens.length;
222
+ continue;
223
+ }
202
224
  if (tokens[cursor + 1]?.type !== "string")
203
225
  continue;
204
226
  titles.push(tokens[cursor + 1].value);
@@ -226,7 +248,7 @@ async function evaluateTargetFileTrace(input) {
226
248
  workspaceRoot: input.workspaceRoot,
227
249
  file: input.target.file,
228
250
  });
229
- if (fileIssues.length || input.target.type === "static") {
251
+ if (fileIssues.length || input.target.mode === "static") {
230
252
  return { issues: fileIssues };
231
253
  }
232
254
  const absolute = path.resolve(input.workspaceRoot, input.target.file);
@@ -261,10 +283,11 @@ export async function evaluateFrontendWriterTracePreflight(input) {
261
283
  }
262
284
  targets.push({
263
285
  id: target.id,
286
+ commandId: target.commandId,
264
287
  commandLabel: target.commandLabel,
265
288
  file: target.file,
266
289
  ...(target.symbol ? { symbol: target.symbol } : {}),
267
- ...(target.type !== "static" ? { traceKey: target.id } : {}),
290
+ ...(target.mode !== "static" ? { traceKey: target.id } : {}),
268
291
  status: fileTrace.issues.length ? "failed" : "ok",
269
292
  matchedNodeIds: [],
270
293
  issues: fileTrace.issues,
@@ -301,15 +324,25 @@ export async function evaluateVerificationTargets(input) {
301
324
  target,
302
325
  });
303
326
  issues.push(...fileTrace.issues);
327
+ const executed = input.executedTests?.get(target.commandLabel);
328
+ if (target.mode !== "static" && executed !== undefined) {
329
+ // safePath permits "./" prefixes; reporter files are cwd-relative.
330
+ const targetFile = path.posix.normalize(target.file.replace(/\\/g, "/"));
331
+ const matching = executed.filter((test) => test.file === targetFile && traceKeyMatchesTitle(target.id, test.title));
332
+ if (matching.length === 0 || matching.some((test) => test.status !== "passed")) {
333
+ issues.push(`behavior target was not fully executed and passed: ${target.id} in ${target.file}`);
334
+ }
335
+ }
304
336
  const status = issues.length ? "failed" : "ok";
305
337
  if (issues.length)
306
338
  hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
307
339
  targets.push({
308
340
  id: target.id,
341
+ commandId: target.commandId,
309
342
  commandLabel: target.commandLabel,
310
343
  file: target.file,
311
344
  symbol: target.symbol,
312
- ...(target.type !== "static" ? { traceKey: target.id } : {}),
345
+ ...(target.mode !== "static" ? { traceKey: target.id } : {}),
313
346
  status,
314
347
  matchedNodeIds: owners ?? [],
315
348
  issues,
@@ -383,7 +416,7 @@ export async function runFrontendVerificationTraceGate(input) {
383
416
  .join("; ")}`);
384
417
  }
385
418
  const contract = parsed.data;
386
- const requiresBehaviorVerification = contract.verificationTargets.some((target) => target.type !== "static");
419
+ const requiresBehaviorVerification = contract.verificationTargets.some((target) => target.mode !== "static");
387
420
  // Prefer post-repair reverify nodes when present (M3); else initial verify nodes (M2).
388
421
  const staticCandidates = [
389
422
  "frontend-static-reverify-shell",
@@ -471,6 +504,7 @@ export async function runFrontendVerificationTraceGate(input) {
471
504
  contract,
472
505
  labelOwners,
473
506
  workspaceRoot: input.workspaceRoot,
507
+ executedTests: input.evidence?.behavior.executedTests,
474
508
  });
475
509
  if (hardIssues.length) {
476
510
  throw new Error(`trace: ${hardIssues.join("; ")}`);