@tea-agent/loop-agent 0.38.1 → 0.38.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +109 -13
  2. package/dist/application/dag/generate-task-dag.js +9 -0
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/commands/init.js +2 -14
  5. package/dist/executors/dag-pi-executor.js +41 -4
  6. package/dist/executors/shell-executor.js +269 -15
  7. package/dist/governance/manifest-types.js +0 -2
  8. package/dist/shared/operator/capabilities.js +87 -22
  9. package/dist/task/config-types.js +22 -0
  10. package/dist/worker/console/chat/deferred-turn.js +91 -0
  11. package/dist/worker/console/chat/pi-runtime.js +10 -5
  12. package/dist/worker/console/chat/routes.js +458 -47
  13. package/dist/worker/console/chat/semantic-activity.js +13 -13
  14. package/dist/worker/console/chat/session-store.js +146 -2
  15. package/dist/worker/console/harness-pi-model-matrix.js +2 -11
  16. package/dist/worker/console/operator-actions.js +5 -4
  17. package/dist/worker/console/pi-readiness.js +1 -0
  18. package/dist/worker/console/recovery-cta.js +4 -4
  19. package/dist/worker/console/recovery-error-copy.js +4 -4
  20. package/dist/worker/console/server.js +6 -1
  21. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Hfsot6rP.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
  22. package/dist/worker/console/static/assets/{arc-YGwEASin.js → arc-SAD7McOS.js} +1 -1
  23. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CmOM0F92.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
  24. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DDLuaHC5.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
  25. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B0Um43W4.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
  26. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
  27. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DvBN4JZc.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B1UVgW4E.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Cf0GgXzX.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DbNA24sj.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bi_0Be06.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C8pPGvRd.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BitQY8Z-.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-U67GCiEo.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-IoJwXPBA.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-YDZ_UHgE.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
  37. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
  38. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
  39. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CUZgsiRC.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
  40. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-COmCh4pT.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
  41. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Ta6FSm68.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
  42. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UdcR-VW9.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
  43. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DybcO3Et.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-wErDFWLw.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DkVHZWqz.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DdqWs7QN.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C9dMVmDo.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
  48. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B2T2WcBK.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
  49. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt9rjDFq.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
  50. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-U8ic_tvi.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
  51. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BfQJdQoO.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
  52. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CpMftH4O.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
  53. package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
  54. package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
  55. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DzqWXNO_.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
  56. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
  57. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ChPtKFLz.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
  58. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PRe7wB5u.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
  59. package/dist/worker/console/static/assets/{linear-DpKX6QNp.js → linear-D9Py31Ld.js} +1 -1
  60. package/dist/worker/console/static/assets/{mermaid.core-Dnl76b7Y.js → mermaid.core-D7Occzk_.js} +5 -5
  61. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-S9PCXc3J.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
  62. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Du8e-FSX.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
  63. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CY6OmHTc.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
  64. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C65GPhpj.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
  65. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-U6IUiTCL.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
  66. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-I_9Qhi.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
  67. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B57Fhed0.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
  68. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CJOtiw21.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
  69. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-FyX8zxSh.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
  70. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eF-pbs9t.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
  71. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
  72. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CcfkA-Dq.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
  73. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
  74. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DH8FjnOK.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
  75. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-beoTagJg.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
  76. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CQSfw4cK.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
  77. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQgzQ6jn.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
  78. package/dist/worker/console/static/index.html +2 -2
  79. package/dist/worker/console/static-src/app/console-types.js +0 -12
  80. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  81. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  82. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +38 -1
  83. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  84. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  85. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  86. package/dist/worker/console/static-src/operator-chat/useChatStream.js +204 -11
  87. package/dist/worker/console/static-src/operator-chat/useChatThread.js +23 -1
  88. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
  89. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  90. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  91. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  92. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  93. package/dist/workflows/dag/backend-test-layout.js +133 -0
  94. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  95. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  96. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  97. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  98. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  99. package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
  100. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  101. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  102. package/dist/workflows/dag/convergence/controller.js +141 -0
  103. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  104. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  105. package/dist/workflows/dag/frontend-implementation-contract.js +236 -8
  106. package/dist/workflows/dag/frontend-prewrite-gate.js +95 -35
  107. package/dist/workflows/dag/init-hybrid.js +652 -87
  108. package/dist/workflows/dag/node-execution.js +45 -18
  109. package/dist/workflows/dag/prompt.js +45 -0
  110. package/dist/workflows/dag/rerun-feedback.js +212 -0
  111. package/dist/workflows/dag/rerun-plan.js +93 -27
  112. package/dist/workflows/dag/rerun-run.js +3 -0
  113. package/dist/workflows/dag/rerun-task.js +61 -2
  114. package/dist/workflows/dag/retry-policy.js +18 -0
  115. package/dist/workflows/dag/types.js +98 -0
  116. package/docs/templates/backend-test-dag.json +60 -35
  117. package/docs/templates/harness.schema.json +0 -8
  118. package/harness.json +1 -8
  119. package/package.json +1 -1
  120. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  121. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +0 -1
  122. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +0 -1
  123. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +0 -1
  124. package/dist/worker/console/static/assets/index-BA5-N5ER.css +0 -1
  125. package/dist/worker/console/static/assets/index-DKPXBrdc.js +0 -325
  126. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +0 -1
  127. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +0 -8
@@ -21,6 +21,17 @@ const REVIEW_VERDICT_NODE_ID = "review-verdict-recovery-pi";
21
21
  const INITIAL_REVIEW_NODE_ID = "initial-review-pi";
22
22
  const INITIAL_REVIEW_GATE_NODE_ID = "initial-review-gate-shell";
23
23
  const INITIAL_REVIEW_PASS_NODE_ID = "initial-review-pass-shell";
24
+ /**
25
+ * Standard-dag governance verify chain ids. When the convergence chain
26
+ * observes the governance standard gate, a legitimate verify-pi
27
+ * `request-revision` re-enters a bounded implement-repair loop instead of
28
+ * terminating the run at the gate, mirroring the review-chain re-entry
29
+ * semantics (AC3/AC4). Protocol/provider/safety failures on verify-pi keep
30
+ * failing closed; only a safe `request-revision` drives code repair here.
31
+ */
32
+ const GOVERNANCE_VERIFY_GATE_NODE_ID = "governance-standard-gate-shell";
33
+ const GOVERNANCE_VERIFY_NODE_ID = "verify-pi";
34
+ const GOVERNANCE_VERIFY_WRITER_NODE_ID = "implement-pi";
24
35
  // Automatic code repair is authorized only for an ordinary command/test
25
36
  // failure. Unknown, protocol, validation, provider, safety, governance, and
26
37
  // human-decision categories all fail closed instead of reaching a writer.
@@ -57,6 +68,15 @@ export async function runConvergencePassController(input) {
57
68
  }
58
69
  const chain = getConvergenceChain(input.spec);
59
70
  const observesReview = chain.includes(REVIEW_GATE_NODE_ID);
71
+ const observesGovernanceVerify = chain.includes(GOVERNANCE_VERIFY_GATE_NODE_ID);
72
+ // Standard-dag governance verify convergence: verify-pi drives a bounded
73
+ // implement-repair loop on `request-revision`. This shape has no
74
+ // hard-verify/review nodes; dispatch before the legacy hard-verify logic so
75
+ // the standard governance DAG never falls into `unsupported-dag-shape`/
76
+ // `non-retry-failure` dead ends.
77
+ if (observesGovernanceVerify && !observesReview) {
78
+ return handleGovernanceVerifyConvergence(input);
79
+ }
60
80
  if (input.tasksById.has(INITIAL_REVIEW_NODE_ID)) {
61
81
  const initialReview = input.state.nodes[INITIAL_REVIEW_NODE_ID];
62
82
  const initialGate = input.state.nodes[INITIAL_REVIEW_GATE_NODE_ID];
@@ -397,6 +417,127 @@ async function handleReviewRequestRevision(input, reviewGate) {
397
417
  function getConvergenceChain(spec) {
398
418
  return spec.convergence?.chainNodeIds ?? DEFAULT_CONVERGENCE_CHAIN_NODE_IDS;
399
419
  }
420
+ /**
421
+ * Legitimate governance verify revision: verify-pi finished and emitted
422
+ * `request-revision`. No secondary verdict-recovery node exists on the
423
+ * standard dag, so the single verify-pi verdict plus a non-transport failure
424
+ * category is the legitimacy bar (protocol-invalid/invalid-output/timeout on
425
+ * verify-pi fail closed instead of driving repair).
426
+ */
427
+ function isLegitimateGovernanceVerifyRevision(state) {
428
+ const verifyNode = state.nodes[GOVERNANCE_VERIFY_NODE_ID];
429
+ return (verifyNode?.status === "FINISHED" &&
430
+ parseProcessVerdict(verifyNode) === "request-revision");
431
+ }
432
+ function isLegitimateGovernanceVerifyPass(state) {
433
+ const verifyNode = state.nodes[GOVERNANCE_VERIFY_NODE_ID];
434
+ return (verifyNode?.status === "FINISHED" &&
435
+ parseProcessVerdict(verifyNode) === "pass");
436
+ }
437
+ /**
438
+ * Standard-dag governance verify convergence loop. Terminal pass when
439
+ * verify-pi passes; bounded re-entry (reset implement-pi + verify chain) when
440
+ * verify-pi legitimately requests revision. The reset lets the next
441
+ * implement-pi pass consume the prior verify findings through the structured
442
+ * convergence feedback block (node-execution injects it for implementer
443
+ * roles), so `already-satisfied` no-ops are explicitly contradicted by the
444
+ * preserved verify-pi evidence refs.
445
+ */
446
+ async function handleGovernanceVerifyConvergence(input) {
447
+ const convergence = input.state.convergence;
448
+ const currentPass = convergence.currentPass || 1;
449
+ const verifyNode = input.state.nodes[GOVERNANCE_VERIFY_NODE_ID];
450
+ const verifyShell = input.state.nodes["verify-shell"];
451
+ // Hard verify commands failed: not a governance verdict issue. Without a
452
+ // supervised repair chain, this stays terminal so the failure-aware
453
+ // closeout reports it truthfully.
454
+ if (verifyShell?.status === "ERROR") {
455
+ const passRecord = await buildConvergencePassRecord({
456
+ pass: currentPass,
457
+ status: "terminal",
458
+ reason: "hard-verify-failed",
459
+ state: input.state,
460
+ runDir: input.runDir,
461
+ spec: input.spec,
462
+ });
463
+ convergence.passHistory.push(passRecord);
464
+ convergence.terminalReason = "hard-verify-failed";
465
+ await input.persistState();
466
+ return { retry: false };
467
+ }
468
+ if (isLegitimateGovernanceVerifyPass(input.state)) {
469
+ convergence.terminalReason = "hard-verify-pass";
470
+ await appendConvergenceKnowledgePattern({
471
+ cwd: input.cwd,
472
+ state: input.state,
473
+ });
474
+ return { retry: false };
475
+ }
476
+ const verifyFailureCategory = verifyNode?.failureCategory;
477
+ const legitimateRevision = isLegitimateGovernanceVerifyRevision(input.state);
478
+ if (!legitimateRevision ||
479
+ (verifyFailureCategory &&
480
+ REVIEW_SOURCE_NON_RETRY_FAILURES.has(verifyFailureCategory))) {
481
+ const passRecord = await buildConvergencePassRecord({
482
+ pass: currentPass,
483
+ status: "terminal",
484
+ reason: "non-retry-failure",
485
+ state: input.state,
486
+ runDir: input.runDir,
487
+ spec: input.spec,
488
+ });
489
+ convergence.passHistory.push(passRecord);
490
+ convergence.terminalReason = "non-retry-failure";
491
+ await input.persistState();
492
+ return { retry: false };
493
+ }
494
+ const passRecord = await buildConvergencePassRecord({
495
+ pass: currentPass,
496
+ status: "retrying",
497
+ reason: "verify-request-revision",
498
+ state: input.state,
499
+ runDir: input.runDir,
500
+ spec: input.spec,
501
+ });
502
+ if (currentPass >= convergence.maxPasses) {
503
+ passRecord.status = "terminal";
504
+ passRecord.reason = "max-passes";
505
+ convergence.passHistory.push(passRecord);
506
+ convergence.terminalReason = "max-passes";
507
+ await input.persistState();
508
+ return { retry: false };
509
+ }
510
+ convergence.passHistory.push(passRecord);
511
+ convergence.currentPass = currentPass + 1;
512
+ // Reset the writer plus the governance verify chain: the next implement-pi
513
+ // pass must consume the preserved verify-pi findings (injected as
514
+ // convergence feedback) instead of returning already-satisfied.
515
+ for (const id of collectGovernanceVerifyResetIds(input.spec)) {
516
+ const task = input.tasksById.get(id);
517
+ if (!task)
518
+ continue;
519
+ input.state.nodes[id] = freshNodeRecord(task);
520
+ }
521
+ await input.persistState();
522
+ return { retry: true };
523
+ }
524
+ /**
525
+ * Reset set for the governance verify loop: the exclusive writer and the
526
+ * verify chain closure (verify-shell, verify-pi, governance gate, and every
527
+ * transitive descendant so closeout re-adjudicates from fresh evidence).
528
+ */
529
+ function collectGovernanceVerifyResetIds(spec) {
530
+ const resetIds = new Set([
531
+ GOVERNANCE_VERIFY_WRITER_NODE_ID,
532
+ "verify-shell",
533
+ GOVERNANCE_VERIFY_NODE_ID,
534
+ GOVERNANCE_VERIFY_GATE_NODE_ID,
535
+ ]);
536
+ for (const id of collectTransitiveDescendantTaskIds(spec, GOVERNANCE_VERIFY_GATE_NODE_ID)) {
537
+ resetIds.add(id);
538
+ }
539
+ return resetIds;
540
+ }
400
541
  function hasConvergenceChain(tasksById, spec) {
401
542
  const chain = getConvergenceChain(spec);
402
543
  return chain.every((id) => tasksById.has(id));
@@ -367,19 +367,26 @@ export async function executeDynamicMapExpansion(input) {
367
367
  }
368
368
  }
369
369
  const budgetBlockedIds = new Set(blockedChildren.map((entry) => entry.nodeId));
370
+ const failOnTokenBudgetExhaustion = input.expansion.failOnTokenBudgetExhaustion === true;
370
371
  const failedChildren = tolerateChildFailures
371
372
  ? []
372
373
  : childNodeIds.filter((nodeId) => {
373
374
  const record = input.state.nodes[nodeId];
374
375
  if (!record || record.status === "FINISHED")
375
376
  return false;
376
- if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record))
377
- return false;
377
+ if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record)) {
378
+ return failOnTokenBudgetExhaustion;
379
+ }
378
380
  return true;
379
381
  });
382
+ const finishedItemCount = childNodeIds.filter((nodeId) => input.state.nodes[nodeId]?.status === "FINISHED").length;
383
+ const blockedItemCount = budgetBlockedIds.size;
380
384
  const aggregate = {
381
385
  workflowNodeId: input.expansion.workflowNodeId,
382
386
  itemCount: items.length,
387
+ expectedItemCount: items.length,
388
+ finishedItemCount,
389
+ blockedItemCount,
383
390
  children: childNodeIds.map((nodeId, index) => ({
384
391
  nodeId,
385
392
  item: items[index],
@@ -399,21 +406,30 @@ export async function executeDynamicMapExpansion(input) {
399
406
  ?.reason,
400
407
  })),
401
408
  tokensUsed: totalTokensUsed,
409
+ maxTotalTokens: tokenBudget?.maxTotalTokens,
402
410
  caseOutcomeNotes: caseOutcomeNotes.length > 0 ? caseOutcomeNotes : undefined,
403
411
  };
412
+ const tokenBudgetFailure = failOnTokenBudgetExhaustion && blockedItemCount > 0;
404
413
  return {
405
- // With tolerateChildFailures: barrier always succeeds; product outcomes live in
406
- // case evidence + result materialization. Without it: real child failures fail-close.
407
- ok: failedChildren.length === 0,
414
+ // With tolerateChildFailures: barrier succeeds and product outcomes live in
415
+ // case evidence. Backend-test opts into token-budget fail-closed because a
416
+ // partial generated module set is never a valid downstream input.
417
+ ok: failedChildren.length === 0 && !tokenBudgetFailure,
408
418
  stdout: JSON.stringify(aggregate),
409
419
  stderr: caseOutcomeNotes.length > 0
410
420
  ? `map children recorded as case outcomes: ${caseOutcomeNotes
411
421
  .map((entry) => `${entry.nodeId}=${entry.status}:${entry.reason}`)
412
422
  .join(", ")}`
423
+ : tokenBudgetFailure
424
+ ? `dynamic map token budget exhausted: expected=${items.length} finished=${finishedItemCount} blocked=${blockedItemCount} tokensUsed=${totalTokensUsed} maxTotalTokens=${tokenBudget?.maxTotalTokens ?? "unbounded"}`
425
+ : failedChildren.length > 0
426
+ ? `dynamic map children failed: ${failedChildren.join(", ")}`
427
+ : "",
428
+ failureCategory: tokenBudgetFailure
429
+ ? "dynamic-expansion-token-budget-exhausted"
413
430
  : failedChildren.length > 0
414
- ? `dynamic map children failed: ${failedChildren.join(", ")}`
415
- : "",
416
- failureCategory: failedChildren.length > 0 ? "dynamic-expansion-child-failed" : "success",
431
+ ? "dynamic-expansion-child-failed"
432
+ : "success",
417
433
  durationMs: Date.now() - started,
418
434
  };
419
435
  }
@@ -30,7 +30,7 @@ export function getNodeOutputAsJson(state, nodeId) {
30
30
  /** Normalize a line the same way verdict gates do (strip whole-line Markdown emphasis). */
31
31
  export function normalizeVerdictCandidateLine(value) {
32
32
  const trimmed = String(value).trim();
33
- const emphasized = trimmed.match(/^\*{1,3}\s*(VERDICT:[^*]+?)\s*\*{1,3}$/);
33
+ const emphasized = trimmed.match(/^(?:`{1,3}|\*{1,3})\s*(VERDICT:[^`*]+?)\s*(?:`{1,3}|\*{1,3})$/);
34
34
  const line = (emphasized ? emphasized[1] : trimmed).trim();
35
35
  // Canonicalize the colon separator and trailing sentence punctuation so
36
36
  // "VERDICT:pass", "VERDICT: pass", or "VERDICT: pass." match the declared
@@ -13,6 +13,7 @@ export const frontendNormalizationActionSchema = z.enum([
13
13
  "strip-comments",
14
14
  "canonicalize-requirement-ids",
15
15
  "canonicalize-verification-alias",
16
+ "drop-unresolved-verification-symbols",
16
17
  "inject-source-binding",
17
18
  ]);
18
19
  export function sha256Hex(input) {
@@ -102,6 +103,21 @@ async function deriveFrozenCommandLabelsFromRun(runDir) {
102
103
  return [...labels];
103
104
  }
104
105
  export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
106
+ export const FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID = "frontend-implementation-contract-plan-patch-v1";
107
+ /**
108
+ * Build the deterministic, run-owned portion of the frontend contract. The
109
+ * planner is allowed to fill semantic fields with an RFC 7386 merge patch but
110
+ * cannot redefine task identity, risk, or the implementation write boundary.
111
+ */
112
+ export function buildFrontendImplementationContractSkeleton(input) {
113
+ return {
114
+ schemaVersion: 1,
115
+ sourceBinding: canonicalFrontendContractSourceBinding(input.sourceBinding),
116
+ riskLevel: input.riskLevel,
117
+ targets: { files: [...input.targetFiles] },
118
+ mockApi: { productionDefaultOff: true },
119
+ };
120
+ }
105
121
  /**
106
122
  * Load the canonical frontend-implementation-contract-v1 JSON Schema from the
107
123
  * installed loop-agent package docs/templates/ path. Package-root discovery
@@ -499,9 +515,9 @@ function isPlainObject(value) {
499
515
  * ever materialized, so a patch that removes a required field still fails
500
516
  * closed at the gate.
501
517
  */
502
- export function applyFrontendImplementationContractPatch(target, patch) {
518
+ export function applyFrontendContractMergePatch(target, patch) {
503
519
  if (!isPlainObject(patch)) {
504
- throw new Error("frontend revision patch must be a JSON object");
520
+ throw new Error("frontend contract patch must be a JSON object");
505
521
  }
506
522
  const merge = (base, delta) => {
507
523
  if (delta === null)
@@ -526,6 +542,9 @@ export function applyFrontendImplementationContractPatch(target, patch) {
526
542
  };
527
543
  return merge(target, patch);
528
544
  }
545
+ export function applyFrontendImplementationContractPatch(target, patch) {
546
+ return applyFrontendContractMergePatch(target, patch);
547
+ }
529
548
  export async function assertFrontendSourceBindingFresh(input) {
530
549
  for (const source of input.binding.sources) {
531
550
  const absolute = resolveDagTaskSourcePath({
@@ -1608,6 +1627,206 @@ export async function writeFrontendImplementationContractArtifact(input) {
1608
1627
  schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
1609
1628
  };
1610
1629
  }
1630
+ const FRONTEND_PLAN_PATCH_PROTECTED_PATHS = [
1631
+ "/schemaVersion",
1632
+ "/sourceBinding",
1633
+ "/riskLevel",
1634
+ "/targets/files",
1635
+ "/mockApi/productionDefaultOff",
1636
+ ];
1637
+ function frontendPlanPatchProtectedPathViolations(patch) {
1638
+ const record = asRecord(patch);
1639
+ if (!record)
1640
+ return ["/"];
1641
+ const violations = [];
1642
+ for (const key of ["schemaVersion", "sourceBinding", "riskLevel"]) {
1643
+ if (Object.hasOwn(record, key))
1644
+ violations.push(`/${key}`);
1645
+ }
1646
+ const targets = asRecord(record.targets);
1647
+ if (targets && Object.hasOwn(targets, "files"))
1648
+ violations.push("/targets/files");
1649
+ const mockApi = asRecord(record.mockApi);
1650
+ if (mockApi && Object.hasOwn(mockApi, "productionDefaultOff"))
1651
+ violations.push("/mockApi/productionDefaultOff");
1652
+ return violations;
1653
+ }
1654
+ function workspaceRootFromDagRunDir(runDir) {
1655
+ const absolute = path.resolve(runDir);
1656
+ const segments = absolute.split(path.sep);
1657
+ const harnessIndex = segments.lastIndexOf(".harness");
1658
+ if (harnessIndex < 0 ||
1659
+ segments[harnessIndex + 1] !== "dag-runs" ||
1660
+ harnessIndex + 3 >= segments.length) {
1661
+ return null;
1662
+ }
1663
+ const joined = segments.slice(0, harnessIndex).join(path.sep);
1664
+ return joined || path.parse(absolute).root;
1665
+ }
1666
+ function verificationSymbolMatchesFile(symbol, content) {
1667
+ if (symbol.trim().toLowerCase() === "all describe blocks")
1668
+ return content.includes("describe(");
1669
+ const symbols = symbol.includes(" / ")
1670
+ ? symbol
1671
+ .split(/[((]/, 1)[0]
1672
+ .split("/")
1673
+ .map((item) => item.trim())
1674
+ .filter(Boolean)
1675
+ : [symbol];
1676
+ return symbols.every((item) => {
1677
+ const describeTitle = item.match(/^describe\((?:['"])(.+?)(?:['"])/i)?.[1];
1678
+ return [item, describeTitle, describeTitle ? "describe(" : undefined]
1679
+ .filter((candidate) => Boolean(candidate))
1680
+ .some((candidate) => content.includes(candidate));
1681
+ });
1682
+ }
1683
+ async function dropUnresolvedVerificationSymbols(input) {
1684
+ const workspaceRoot = workspaceRootFromDagRunDir(input.runDir);
1685
+ const targets = asRecord(input.contract)?.verificationTargets;
1686
+ if (!workspaceRoot || !Array.isArray(targets))
1687
+ return false;
1688
+ let changed = false;
1689
+ for (const targetValue of targets) {
1690
+ const target = asRecord(targetValue);
1691
+ const file = asString(target?.file);
1692
+ const symbol = asString(target?.symbol);
1693
+ if (!target || !file || !symbol)
1694
+ continue;
1695
+ const absolute = path.resolve(workspaceRoot, file);
1696
+ const relative = path.relative(workspaceRoot, absolute);
1697
+ if (relative.startsWith("..") ||
1698
+ path.isAbsolute(relative) ||
1699
+ relative.includes("..")) {
1700
+ continue;
1701
+ }
1702
+ let content;
1703
+ try {
1704
+ content = await readFile(absolute, "utf8");
1705
+ }
1706
+ catch {
1707
+ // Missing files remain a trace-time hard failure; do not hide them by
1708
+ // changing the optional symbol during candidate normalization.
1709
+ continue;
1710
+ }
1711
+ if (verificationSymbolMatchesFile(symbol, content))
1712
+ continue;
1713
+ delete target.symbol;
1714
+ changed = true;
1715
+ }
1716
+ return changed;
1717
+ }
1718
+ function extractSingleOpenspecCitationsBlock(text) {
1719
+ const blocks = text.match(/```openspec-citations[ \t]*\r?\n[\s\S]*?\r?\n```/g) ?? [];
1720
+ if (blocks.length !== 1) {
1721
+ throw new Error(`frontend plan patch output must include exactly one fenced openspec-citations block (found ${blocks.length})`);
1722
+ }
1723
+ return blocks[0];
1724
+ }
1725
+ async function writeFrontendPlanCandidateRaw(input) {
1726
+ const relativePath = path.posix.join("contracts", "candidates", input.nodeId, `attempt-${input.attempt}.raw.md`);
1727
+ await writeTextArtifactFile(path.join(input.runDir, relativePath), input.text.endsWith("\n") ? input.text : `${input.text}\n`);
1728
+ return relativePath;
1729
+ }
1730
+ /**
1731
+ * Compile the planner's editable RFC 7386 patch against the deterministic
1732
+ * runtime skeleton. Every attempt is preserved under contracts/candidates so
1733
+ * format/schema failures can be inspected and replayed without trusting model
1734
+ * prose. A successful result returns canonical full-contract text for all
1735
+ * downstream review and prewrite nodes.
1736
+ */
1737
+ export async function validateFrontendPlanPatchNodeOutput(input) {
1738
+ const nodeId = input.nodeId ?? "frontend-plan-pi";
1739
+ const attempt = input.attempt ?? 1;
1740
+ const candidateDir = path.posix.join("contracts", "candidates", nodeId);
1741
+ const rawPath = await writeFrontendPlanCandidateRaw({
1742
+ runDir: input.runDir,
1743
+ nodeId,
1744
+ attempt,
1745
+ text: input.text,
1746
+ });
1747
+ const reportBase = {
1748
+ schemaVersion: 1,
1749
+ schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID,
1750
+ nodeId,
1751
+ attempt,
1752
+ rawPath,
1753
+ protectedPaths: [...FRONTEND_PLAN_PATCH_PROTECTED_PATHS],
1754
+ candidateRawSha256: sha256Hex(input.text),
1755
+ };
1756
+ let extractedPatchPath;
1757
+ let extractedPatchSha256;
1758
+ const writeReport = async (value) => {
1759
+ await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.validation.json`), {
1760
+ ...reportBase,
1761
+ ...(extractedPatchPath
1762
+ ? { extractedPatchPath, extractedPatchSha256 }
1763
+ : {}),
1764
+ ...value,
1765
+ });
1766
+ };
1767
+ try {
1768
+ if (!input.sourceBinding)
1769
+ throw new Error("frontend plan patch validator requires DAG sourceBinding");
1770
+ const skeleton = input.structuredContractOutput?.skeleton;
1771
+ if (!skeleton)
1772
+ throw new Error("frontend plan patch validator requires a deterministic runtime skeleton");
1773
+ const patch = extractFrontendImplementationJson(input.text);
1774
+ if (!isPlainObject(patch))
1775
+ throw new Error("frontend plan patch must extract to one JSON object");
1776
+ extractedPatchPath = path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`);
1777
+ const extractedArtifact = await writeDeterministicJsonArtifact(input.runDir, extractedPatchPath, patch);
1778
+ extractedPatchSha256 = extractedArtifact.sha256;
1779
+ const citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1780
+ const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
1781
+ if (protectedViolations.length > 0)
1782
+ throw new Error(`frontend plan patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`);
1783
+ const merged = applyFrontendContractMergePatch(skeleton, patch);
1784
+ const droppedUnresolvedSymbols = await dropUnresolvedVerificationSymbols({
1785
+ runDir: input.runDir,
1786
+ contract: merged,
1787
+ });
1788
+ const analysis = await analyzeFrontendImplementationContract({
1789
+ runDir: input.runDir,
1790
+ rawContractText: serializeDeterministicJson(merged),
1791
+ sourceBinding: input.sourceBinding,
1792
+ });
1793
+ const normalizedArtifact = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.normalized.json`), analysis.canonical);
1794
+ await writeReport({
1795
+ classification: "accepted-normalized",
1796
+ normalizedContractPath: path.posix.relative(input.runDir.replaceAll(path.sep, "/"), normalizedArtifact.path.replaceAll(path.sep, "/")),
1797
+ normalizedContractSha256: normalizedArtifact.sha256,
1798
+ normalizationActions: [
1799
+ "apply-runtime-skeleton",
1800
+ ...(droppedUnresolvedSymbols
1801
+ ? ["drop-unresolved-verification-symbols"]
1802
+ : []),
1803
+ ...analysis.normalizationActions,
1804
+ ],
1805
+ errors: [],
1806
+ });
1807
+ return {
1808
+ ok: true,
1809
+ contract: analysis.canonical,
1810
+ normalizedText: [
1811
+ "Frontend implementation contract compiled from the runtime skeleton.",
1812
+ "",
1813
+ "```json",
1814
+ serializeDeterministicJson(analysis.canonical),
1815
+ "```",
1816
+ citationsBlock,
1817
+ ].join("\n"),
1818
+ };
1819
+ }
1820
+ catch (error) {
1821
+ const reason = error instanceof Error ? error.message : String(error);
1822
+ await writeReport({
1823
+ classification: "retryable-invalid",
1824
+ normalizationActions: [],
1825
+ errors: [{ path: "$", code: "invalid-output", message: reason }],
1826
+ });
1827
+ return { ok: false, reason };
1828
+ }
1829
+ }
1611
1830
  /**
1612
1831
  * Node-output self-check for plan nodes that produce the implementation
1613
1832
  * contract. Mirrors the prewrite gate validation so schema/typo/null violations
@@ -1635,7 +1854,7 @@ export async function validateFrontendContractNodeOutput(input) {
1635
1854
  /**
1636
1855
  * Node-output self-check for the plan revision node, which emits an RFC 7386
1637
1856
  * merge-patch delta against the original contract instead of a full contract.
1638
- * Deliberately loose and format-level: the output must contain at least one
1857
+ * Deliberately loose and format-level: the output must contain exactly one
1639
1858
  * ```openspec-citations fenced block and must extract (after the citations
1640
1859
  * blocks are stripped) to exactly one JSON object. The delta carries no
1641
1860
  * schemaVersion/targets, so no full-contract schema/semantic checks run here;
@@ -1643,17 +1862,19 @@ export async function validateFrontendContractNodeOutput(input) {
1643
1862
  * the prewrite gate, which remains the only authority.
1644
1863
  */
1645
1864
  export async function validateFrontendRevisionPatchNodeOutput(input) {
1646
- // Mirror the prewrite gate's block-presence semantics (a fenced
1647
- // ```openspec-citations block with a newline-terminated body).
1648
- if (!/```openspec-citations[ \t]*\r?\n[\s\S]*?\r?\n```/.test(input.text)) {
1865
+ let citationsBlock;
1866
+ try {
1867
+ citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1868
+ }
1869
+ catch (error) {
1649
1870
  return {
1650
1871
  ok: false,
1651
- reason: "revision patch output must include at least one ```openspec-citations fenced block",
1872
+ reason: error instanceof Error ? error.message : String(error),
1652
1873
  };
1653
1874
  }
1654
1875
  // Strip the citation blocks before extraction: their per-row JSON objects
1655
1876
  // would otherwise count as competing balanced objects in the extractor.
1656
- const textWithoutCitations = input.text.replace(/```openspec-citations[ \t]*\r?\n[\s\S]*?\r?\n```/g, "");
1877
+ const textWithoutCitations = input.text.replace(citationsBlock, "");
1657
1878
  let patch;
1658
1879
  try {
1659
1880
  patch = extractFrontendImplementationJson(textWithoutCitations);
@@ -1670,6 +1891,13 @@ export async function validateFrontendRevisionPatchNodeOutput(input) {
1670
1891
  reason: "revision patch output must extract to exactly one JSON object (RFC 7386 merge-patch delta)",
1671
1892
  };
1672
1893
  }
1894
+ const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
1895
+ if (protectedViolations.length > 0) {
1896
+ return {
1897
+ ok: false,
1898
+ reason: `frontend revision patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`,
1899
+ };
1900
+ }
1673
1901
  return { ok: true, contract: patch };
1674
1902
  }
1675
1903
  export async function materializeFrontendImplementationContract(input) {