@tea-agent/loop-agent 0.42.0-next.9 → 0.43.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +65 -59
  2. package/dist/application/dag/run-dag.js +3 -2
  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 +1514 -175
  9. package/dist/executors/pi-extension-resolver.js +14 -2
  10. package/dist/executors/shell-executor.js +131 -54
  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/static/constants.js +22 -22
  98. package/dist/worker/observe/static/dag-context-reason-labels.js +19 -0
  99. package/dist/worker/observe/static/dag-history-labels.js +1 -0
  100. package/dist/worker/observe/static/dag-inspector-humanize.d.ts +16 -0
  101. package/dist/worker/observe/static/dag-inspector-humanize.js +339 -0
  102. package/dist/worker/observe/static/dag-node-purpose.js +10 -10
  103. package/dist/worker/observe/static/index.html +4 -4
  104. package/dist/worker/observe/static/prompt-restart-candidates.js +4 -2
  105. package/dist/worker/observe/static/relations.js +5 -5
  106. package/dist/worker/observe/static/styles.css +171 -0
  107. package/dist/worker/observe/static/views/dag-graph.js +1 -1
  108. package/dist/worker/observe/static/views/dag-inspector.js +329 -174
  109. package/dist/worker/observe/static/views/dag.js +16 -12
  110. package/dist/worker/observe/static/views/session-timeline.js +5 -3
  111. package/dist/workflows/dag/backend-test-plan-protocol.js +104 -0
  112. package/dist/workflows/dag/dag-retry-schema.js +3 -0
  113. package/dist/workflows/dag/frontend-contract-facts.js +130 -0
  114. package/dist/workflows/dag/frontend-design-policy.js +101 -16
  115. package/dist/workflows/dag/frontend-implementation-contract.js +410 -41
  116. package/dist/workflows/dag/frontend-plan-render.js +13 -2
  117. package/dist/workflows/dag/frontend-prewrite-gate.js +1 -1
  118. package/dist/workflows/dag/frontend-recovery-controller.js +25 -1
  119. package/dist/workflows/dag/frontend-recovery-plan.js +4 -1
  120. package/dist/workflows/dag/frontend-recovery-run.js +142 -16
  121. package/dist/workflows/dag/frontend-risk.js +2 -0
  122. package/dist/workflows/dag/frontend-shadow-dual-write.js +17 -1
  123. package/dist/workflows/dag/frontend-shape.js +16 -6
  124. package/dist/workflows/dag/frontend-test-execution-evidence.js +48 -0
  125. package/dist/workflows/dag/frontend-typed-event-store.js +3 -0
  126. package/dist/workflows/dag/frontend-verification-trace.js +38 -4
  127. package/dist/workflows/dag/frontend-writer-admission.js +2 -2
  128. package/dist/workflows/dag/init-hybrid.js +57 -28
  129. package/dist/workflows/dag/node-execution.js +201 -85
  130. package/dist/workflows/dag/recovery-lease.js +106 -16
  131. package/dist/workflows/dag/rerun-feedback.js +1 -0
  132. package/dist/workflows/dag/rerun-plan.js +90 -4
  133. package/dist/workflows/dag/rerun-run.js +28 -6
  134. package/dist/workflows/dag/rerun-task.js +221 -18
  135. package/dist/workflows/dag/retry-policy.js +27 -10
  136. package/dist/workflows/dag/runner-exit-diagnostics.js +125 -0
  137. package/dist/workflows/dag/runner.js +220 -18
  138. package/dist/workflows/dag/structured-output-repair.js +4 -1
  139. package/dist/workflows/dag/types.js +4 -3
  140. package/dist/workflows/dag/validate.js +10 -8
  141. package/dist/workflows/dag/workspace-checkpoint.js +66 -0
  142. package/docs/operations/README.md +1 -0
  143. package/docs/templates/agent-dag.schema.json +2 -2
  144. package/docs/templates/backend-test-dag.json +6 -4
  145. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  146. package/harness.json +1 -1
  147. package/package.json +4 -5
  148. package/skills/frontend-plan/SKILL.md +14 -1
  149. package/skills/frontend-plan/references/decision-contract.md +101 -5
  150. package/skills/frontend-plan/references/design-decisions.md +32 -0
  151. package/dist/worker/console/static/assets/channel-DsZxrgqe.js +0 -1
  152. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CuToPeGV.js +0 -1
  153. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CuToPeGV.js +0 -1
  154. package/dist/worker/console/static/assets/index-D9Sc0f0y.js +0 -449
  155. package/dist/worker/console/static/assets/index-DDQc5a50.css +0 -1
  156. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-yPoT19ft.js +0 -1
  157. 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",
@@ -27,6 +27,7 @@ export function deriveFrontendRecoveryFailureSource(result) {
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
29
  * - design-review rejection → reset plan + design review + admission (+ descendants);
30
+ * - final-review rejection → reset implementation + verification/review (+ descendants);
30
31
  * - other admission failures → reset writer admission (+ descendants);
31
32
  *
32
33
  * Everything upstream of the failure source is imported as verified parent facts.
@@ -62,7 +63,9 @@ function computeFrontendRecoveryPlanForSource(input) {
62
63
  // reset from implement: the recovery re-implements and re-verifies.
63
64
  const resetRoot = input.failureSource === "frontend-verify-shell"
64
65
  ? "frontend-implement-pi"
65
- : input.failureSource;
66
+ : input.failureSource === "frontend-review-pi"
67
+ ? "frontend-implement-pi"
68
+ : input.failureSource;
66
69
  const resetNodeIds = computeResetClosure(input.spec, resetRoot);
67
70
  const resetSet = new Set(resetNodeIds);
68
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,34 +275,52 @@ 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
280
  // Design-review rejection must be visible to the reset planner. Recovery
279
281
  // children otherwise copy the old spec and only rerun design review, which
280
282
  // deterministically re-rejects the unchanged plan. Bind the parent's typed
281
283
  // findings into the child spec as ordinary rerun feedback.
282
284
  let childSpec = parentSpec;
283
- if (recovery.failureSource === "frontend-plan-pi") {
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;
284
291
  try {
285
292
  const { deriveDagRerunFeedback } = await import("./rerun-feedback.js");
286
293
  const taskId = parentSpec.taskContractBinding?.taskId ??
287
294
  parentSpec.sourceBinding?.taskId ??
288
295
  parentRunId;
289
- const feedback = await deriveDagRerunFeedback({
296
+ feedback = await deriveDagRerunFeedback({
290
297
  parentRunId,
291
298
  taskId,
292
299
  runDir: parentRunDir,
293
300
  spec: parentSpec,
294
301
  state: parentState,
295
302
  });
296
- if (feedback)
297
- childSpec = { ...parentSpec, rerunFeedback: feedback };
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
+ }
298
310
  }
299
311
  catch (error) {
300
- // A design rejection cannot be safely retried without its typed
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
301
321
  // findings. Fail closed instead of deterministically re-running the
302
322
  // unchanged plan and producing another rejection.
303
- throw new Error(`frontend recovery feedback unavailable: ${error instanceof Error ? error.message : String(error)}`);
323
+ throw new Error(`frontend recovery feedback unavailable: no typed ${reviewRecovery ? "review" : "design"} findings were found for recovery`);
304
324
  }
305
325
  }
306
326
  await writeJsonVerified(stagingDir, "run.json", childSpec);
@@ -308,7 +328,10 @@ async function materializeChildStaging(input) {
308
328
  // (frontend-implement-pi) instead of the candidate producer; everything
309
329
  // upstream is imported as verified parent facts.
310
330
  let plan;
311
- if (recovery.failureSource) {
331
+ if (recoveryPlan) {
332
+ plan = recoveryPlan;
333
+ }
334
+ else if (recovery.failureSource) {
312
335
  plan = computeFrontendRecoveryPlanForSource({
313
336
  spec: parentSpec,
314
337
  parentRunId,
@@ -373,6 +396,22 @@ async function materializeChildStaging(input) {
373
396
  newRunDir: stagingDir,
374
397
  relativePath: FRONTEND_WRITER_ADMISSION_RESULT_ARTIFACT,
375
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
+ }
376
415
  const childState = {
377
416
  version: 1,
378
417
  title: parentSpec.title,
@@ -382,6 +421,18 @@ async function materializeChildStaging(input) {
382
421
  status: "pending",
383
422
  ranks,
384
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
+ : {}),
385
436
  frontendRecoveryState: {
386
437
  schemaVersion: 1,
387
438
  phase: "child-running",
@@ -390,8 +441,11 @@ async function materializeChildStaging(input) {
390
441
  parentRunId,
391
442
  childRunId,
392
443
  attemptId: recovery.attemptId,
393
- attemptIndex: 1,
394
- continuationCount: 1,
444
+ attemptIndex: recovery.attemptIndex + 1,
445
+ continuationCount: recovery.continuationCount + 1,
446
+ ...(recovery.failureSource
447
+ ? { failureSource: recovery.failureSource }
448
+ : {}),
395
449
  revision: 0,
396
450
  },
397
451
  ...(parentState.evaluation
@@ -460,7 +514,41 @@ export async function stageRecoveryChild(options) {
460
514
  const parentRunDir = getDagRunDir(cwd, "active", parentRunId);
461
515
  const parentState = await deps.readParentState(parentRunDir);
462
516
  const parentSpec = await readDagRunSpec(parentRunDir);
463
- 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
+ }
464
552
  if (!recovery) {
465
553
  throw new Error(`parent run ${parentRunId} has no frontend recovery intent`);
466
554
  }
@@ -468,17 +556,49 @@ export async function stageRecoveryChild(options) {
468
556
  if (!requestId) {
469
557
  throw new Error(`parent run ${parentRunId} recovery intent has no requestId`);
470
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
+ }
471
578
  // Idempotent short-circuit (D4.2): reserved childRunId + activated marker +
472
579
  // existing child dir means the request was already fully committed.
473
580
  if (recovery.childRunId) {
474
581
  const existingMarker = await readFrontendRecoveryMarker(parentRunDir, requestId);
475
582
  if (existingMarker) {
476
- const existingChildDir = getDagRunDir(cwd, "active", existingMarker.childRunId);
477
- 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
+ });
478
598
  return {
479
599
  ok: true,
480
600
  childRunId: existingMarker.childRunId,
481
- childRunDir: existingChildDir,
601
+ childRunDir: existingChild.runDir,
482
602
  marker: existingMarker,
483
603
  idempotentReplay: true,
484
604
  };
@@ -489,8 +609,13 @@ export async function stageRecoveryChild(options) {
489
609
  // buildDagRunId is deterministic for the same task title on the same day. A
490
610
  // recovery child must never reuse its still-active parent directory, or the
491
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.
492
615
  const childRunId = recovery.childRunId ?? await (async () => {
493
- const generatedChildRunId = await (options.buildChildRunId ?? buildDagRunId)(parentSpec, cwd);
616
+ const generatedChildRunId = options.buildChildRunId
617
+ ? await options.buildChildRunId(parentSpec, cwd)
618
+ : await buildDagRunId(parentSpec, { cwd });
494
619
  return generatedChildRunId === parentRunId
495
620
  ? `${generatedChildRunId}-recovery-${requestId.slice(0, 8)}`
496
621
  : generatedChildRunId;
@@ -528,6 +653,7 @@ export async function stageRecoveryChild(options) {
528
653
  parentSpec,
529
654
  parentState,
530
655
  recovery,
656
+ recoveryPlan: options.recoveryPlan,
531
657
  requestId,
532
658
  childRunId,
533
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("; ")}`);
@@ -156,7 +156,7 @@ export function collectVerificationCommandFiles(commands) {
156
156
  }
157
157
  return [...files].sort();
158
158
  }
159
- /** Derive the concrete writeSet from implementation targets ∪ non-static
159
+ /** Derive the concrete writeSet from implementation targets ∪ behavior
160
160
  * verification target files. Entries are de-duplicated and lexicographically
161
161
  * sorted (frozen). */
162
162
  export function deriveWriteSet(contract, options) {
@@ -164,7 +164,7 @@ export function deriveWriteSet(contract, options) {
164
164
  const candidates = [
165
165
  ...contract.targets.files,
166
166
  ...contract.verificationTargets
167
- .filter((target) => target.type !== "static")
167
+ .filter((target) => target.mode !== "static")
168
168
  .map((target) => target.file),
169
169
  ];
170
170
  // Expand glob entries (e.g. `src/**` inherited from the skeleton) against