@tea-agent/loop-agent 0.38.3 → 0.38.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +153 -96
  3. package/README.md +9 -5
  4. package/dist/build-stamp.json +3 -3
  5. package/dist/cli/command-definitions.js +16 -5
  6. package/dist/cli/program.js +16 -2
  7. package/dist/cli/update/notifier.js +2 -2
  8. package/dist/cli.js +2 -1
  9. package/dist/commands/dag-rerun.js +55 -1
  10. package/dist/commands/init-model-catalog.js +464 -0
  11. package/dist/commands/init-upgrade.js +265 -97
  12. package/dist/commands/init.js +428 -27
  13. package/dist/commands/inspect-next.js +8 -0
  14. package/dist/executors/dag-pi-executor.js +18 -9
  15. package/dist/executors/pi-playwright-cli-tool.js +51 -12
  16. package/dist/executors/shell-executor.js +209 -13
  17. package/dist/shared/dag-prompt-override.js +28 -0
  18. package/dist/shared/operator/capabilities.js +32 -0
  19. package/dist/shared/playwright-cli-command-policy.js +15 -0
  20. package/dist/shared/update/console-notifier.js +100 -0
  21. package/dist/{cli → shared}/update/npm-client.js +41 -8
  22. package/dist/{cli → shared}/update/state.js +41 -13
  23. package/dist/task/source-prepare/parse-intent.js +24 -4
  24. package/dist/worker/cli.js +31 -1
  25. package/dist/worker/console/chat/routes.js +1 -0
  26. package/dist/worker/console/dag-execution-receipt.js +33 -0
  27. package/dist/worker/console/operator-actions.js +116 -0
  28. package/dist/worker/console/operator-user-error.js +2 -2
  29. package/dist/worker/console/routes.js +43 -2
  30. package/dist/worker/console/security.js +63 -9
  31. package/dist/worker/console/server.js +36 -5
  32. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-gauhD9kD.js} +1 -1
  33. package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-pm3c2Rsl.js} +1 -1
  34. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-D_JAg3wH.js} +1 -1
  35. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-Dk7-ex52.js} +1 -1
  36. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-Uval9oRI.js} +1 -1
  37. package/dist/worker/console/static/assets/channel-CVrnUdZl.js +1 -0
  38. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-BSYjHs-v.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-KGUuBXI8.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-JqAtxD9N.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-D6EJOVQK.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-B1JriSjm.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-D62b96Jd.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-BsNKEC_r.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-B56v6-ZP.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-D2KPnd8K.js} +1 -1
  47. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-kzKSCaaL.js} +1 -1
  48. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-aX91WTya.js +1 -0
  49. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-aX91WTya.js +1 -0
  50. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-C6N6KNJd.js} +1 -1
  51. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-DiyymT2D.js} +1 -1
  52. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-BeKAXvf3.js} +1 -1
  53. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-TkIdXfPF.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-BFmSG3ks.js} +1 -1
  55. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-DqpJT2Fs.js} +1 -1
  56. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-r5ATN43t.js} +1 -1
  57. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-DEcSCMuR.js} +1 -1
  58. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-VOvYRLVc.js} +1 -1
  59. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-D7f4Cb1u.js} +1 -1
  60. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-ziUf3U0G.js} +1 -1
  61. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-BoRdQ-1y.js} +1 -1
  62. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-B1-GncNm.js} +1 -1
  63. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-CTW-ph8W.js} +1 -1
  64. package/dist/worker/console/static/assets/{index-Ya5FE7cD.css → index-C8rOIAJr.css} +1 -1
  65. package/dist/worker/console/static/assets/{index-DTZOKgAn.js → index-DEbyrEfW.js} +71 -71
  66. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-BFA337tZ.js} +1 -1
  67. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-BtEujgRR.js} +1 -1
  68. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-C8ZwD2Jp.js} +1 -1
  69. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-Ck-z8B11.js} +1 -1
  70. package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-CAyXjQjN.js} +1 -1
  71. package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-CcWKn6tS.js} +5 -5
  72. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-C2NuDO7E.js} +1 -1
  73. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-BFub3PDz.js} +1 -1
  74. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-BJx5THZ5.js} +1 -1
  75. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-DhalkDWV.js} +1 -1
  76. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-CVcBM36o.js} +1 -1
  77. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-D_vy4n0w.js} +1 -1
  78. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-DGYdJL1D.js} +1 -1
  79. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-C1xJFg3k.js} +1 -1
  80. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-D_Fc43df.js} +1 -1
  81. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-BhGbIjbu.js} +1 -1
  82. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DUt1lyFL.js +1 -0
  83. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-DVkNcO-V.js} +2 -2
  84. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BM6SGr7j.js +8 -0
  85. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-BQU9ufV0.js} +1 -1
  86. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-BCQhRmYB.js} +1 -1
  87. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-DWerOyHO.js} +1 -1
  88. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-DxLeebQU.js} +1 -1
  89. package/dist/worker/console/static/index.html +2 -2
  90. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  91. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  92. package/dist/worker/observe/node-input.js +2 -0
  93. package/dist/worker/observe/node-process.js +377 -0
  94. package/dist/worker/observe/routes.js +36 -3
  95. package/dist/worker/observe/shell-handler-keys.js +34 -0
  96. package/dist/worker/observe/static/api.js +66 -0
  97. package/dist/worker/observe/static/constants.js +8 -1
  98. package/dist/worker/observe/static/format.js +23 -0
  99. package/dist/worker/observe/static/markdown-render.js +20 -1
  100. package/dist/worker/observe/static/operator-chrome.js +4 -0
  101. package/dist/worker/observe/static/state.js +11 -0
  102. package/dist/worker/observe/static/styles.css +73 -0
  103. package/dist/worker/observe/static/views/dag-inspector.js +561 -8
  104. package/dist/worker/observe/static/views/dag.js +8 -1
  105. package/dist/worker/observe/static/views/failures.js +5 -2
  106. package/dist/worker/observe/static/views/session-timeline.js +60 -4
  107. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +20 -2
  108. package/dist/workflows/dag/failure-category.js +8 -0
  109. package/dist/workflows/dag/frontend-implementation-contract.js +5 -1
  110. package/dist/workflows/dag/frontend-repair.js +14 -0
  111. package/dist/workflows/dag/frontend-risk.js +15 -2
  112. package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
  113. package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
  114. package/dist/workflows/dag/frontend-test-result-contract.js +151 -11
  115. package/dist/workflows/dag/frontend-verification-trace.js +29 -4
  116. package/dist/workflows/dag/init-hybrid.js +125 -24
  117. package/dist/workflows/dag/node-execution.js +20 -2
  118. package/dist/workflows/dag/rerun-plan.js +112 -0
  119. package/dist/workflows/dag/rerun-run.js +58 -6
  120. package/dist/workflows/dag/retry-policy.js +91 -0
  121. package/dist/workflows/dag/types.js +9 -1
  122. package/docs/init-surface.manifest.json +5 -0
  123. package/docs/operations/README.md +1 -1
  124. package/docs/templates/init-managed-agents.md +13 -3
  125. package/harness.json +2 -2
  126. package/package.json +5 -1
  127. package/scripts/next-info.mjs +356 -0
  128. package/scripts/next-publish-gate.mjs +238 -0
  129. package/scripts/release-source-binding.mjs +251 -0
  130. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  131. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  132. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  133. package/skills/loop-agent/references/command-reference.md +11 -4
  134. package/skills/playwright-cli/SKILL.md +22 -0
  135. package/skills/playwright-cli-case-generator/SKILL.md +20 -1
  136. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
  137. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
  138. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
  139. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
  140. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
@@ -250,6 +250,54 @@ export function buildSessionTimelineItems(events) {
250
250
  return items;
251
251
  }
252
252
 
253
+ /**
254
+ * Split a node's shared session-events log into per-attempt segments. Every
255
+ * attempt appends one agent run beginning with agent_start; events recorded
256
+ * before the first agent_start (defensive) stay in an implicit first segment.
257
+ */
258
+ export function splitSessionEventsByAttempt(events) {
259
+ const segments = [];
260
+ let current = null;
261
+ for (const event of events) {
262
+ if (typeof event?.type === "string" && event.type === "agent_start") {
263
+ current = [];
264
+ segments.push(current);
265
+ }
266
+ if (current === null) {
267
+ current = [];
268
+ segments.push(current);
269
+ }
270
+ current.push(event);
271
+ }
272
+ return segments;
273
+ }
274
+
275
+ /**
276
+ * Resolve the events visible in the「执行过程」tab for the selected execution
277
+ * key. attempt-N shows only that attempt's segment when the shared log carries
278
+ * multiple attempts; other keys (pass-N, null) keep the full stream.
279
+ */
280
+ export function resolveTimelineVisibleEvents(events, executionKey) {
281
+ const match =
282
+ typeof executionKey === "string"
283
+ ? /^attempt-(\d+)$/.exec(executionKey)
284
+ : null;
285
+ if (!match) return { events, note: null };
286
+ const segments = splitSessionEventsByAttempt(events);
287
+ if (segments.length <= 1) return { events, note: null };
288
+ const requested = Number(match[1]);
289
+ if (requested >= 1 && requested <= segments.length) {
290
+ return {
291
+ events: segments[requested - 1],
292
+ note: `已按第 ${requested} 次执行过滤 · 共 ${segments.length} 次执行`,
293
+ };
294
+ }
295
+ return {
296
+ events,
297
+ note: `未检测到第 ${requested} 次执行的边界(共 ${segments.length} 次执行),暂显示全部事件`,
298
+ };
299
+ }
300
+
253
301
  /**
254
302
  * Build rich timeline items: merge tool start/end by toolCallId, collapse
255
303
  * protocol noise, surface turn thinking/text without dumping agent_end.messages.
@@ -657,7 +705,7 @@ export async function mergeSessionEvents(dagRunId, nodeId) {
657
705
  }
658
706
  }
659
707
 
660
- export function renderSessionTimeline(content, nodeId) {
708
+ export function renderSessionTimeline(content, nodeId, options = {}) {
661
709
  content.dataset.dagRunId = uiState.currentDagRunId ?? "";
662
710
  content.dataset.nodeId = nodeId ?? "";
663
711
  content.classList.add("process-timeline-scroll");
@@ -666,6 +714,14 @@ export function renderSessionTimeline(content, nodeId) {
666
714
  return;
667
715
  }
668
716
 
717
+ const visible = resolveTimelineVisibleEvents(
718
+ uiState.sessionEvents,
719
+ options.executionKey,
720
+ );
721
+ if (visible.note) {
722
+ content.appendChild(el("p", "process-timeline-note", visible.note));
723
+ }
724
+
669
725
  const showProtocol = Boolean(uiState.sessionTimelineShowProtocol);
670
726
  const toolbar = el("div", "process-timeline-toolbar");
671
727
  const protocolToggle = document.createElement("button");
@@ -681,17 +737,17 @@ export function renderSessionTimeline(content, nodeId) {
681
737
  const scrollTop = parent.scrollTop;
682
738
  const scrollLeft = parent.scrollLeft;
683
739
  while (parent.firstChild) parent.removeChild(parent.firstChild);
684
- renderSessionTimeline(parent, nodeId);
740
+ renderSessionTimeline(parent, nodeId, options);
685
741
  parent.scrollTop = scrollTop;
686
742
  parent.scrollLeft = scrollLeft;
687
743
  });
688
744
  toolbar.appendChild(protocolToggle);
689
745
  content.appendChild(toolbar);
690
746
 
691
- const timelineItems = buildRichTimelineItems(uiState.sessionEvents, {
747
+ const timelineItems = buildRichTimelineItems(visible.events, {
692
748
  showProtocol,
693
749
  });
694
- const hasEventTimes = uiState.sessionEvents.some((event) =>
750
+ const hasEventTimes = visible.events.some((event) =>
695
751
  Boolean(sessionEventTimestamp(event)),
696
752
  );
697
753
  if (!hasEventTimes) {
@@ -1500,8 +1500,26 @@ print(json.dumps(out,ensure_ascii=True))
1500
1500
  // Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
1501
1501
  // source through that locale can create surrogateescape code points and make
1502
1502
  // ast.parse fail with "surrogates not allowed".
1503
- const result = spawnSync("python", ["-c", program], { input: Buffer.from(source, "utf8"), encoding: "utf8", maxBuffer: 4 * 1024 * 1024, windowsHide: true });
1504
- if (result.status !== 0 || !result.stdout.trim())
1503
+ // POSIX images usually expose only `python3` (modern macOS has no `python`);
1504
+ // Windows often has a real `python` plus a Microsoft Store `python3` stub.
1505
+ // A missing interpreter must not silently degrade to empty payload shapes.
1506
+ const interpreterCandidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
1507
+ let result;
1508
+ for (const interpreter of interpreterCandidates) {
1509
+ const attempt = spawnSync(interpreter, ["-c", program], {
1510
+ input: Buffer.from(source, "utf8"),
1511
+ encoding: "utf8",
1512
+ maxBuffer: 4 * 1024 * 1024,
1513
+ windowsHide: true,
1514
+ });
1515
+ // Interpreter missing/unspawnable → try the next candidate. A spawned
1516
+ // interpreter that exits nonzero analyzed real input; do not retry then.
1517
+ if (attempt.error)
1518
+ continue;
1519
+ result = attempt;
1520
+ break;
1521
+ }
1522
+ if (!result || result.status !== 0 || !result.stdout.trim())
1505
1523
  return new Map();
1506
1524
  try {
1507
1525
  const parsed = JSON.parse(result.stdout);
@@ -29,6 +29,7 @@ const RAW_TO_NORMALIZED = {
29
29
  "decision-envelope-invalid": "decision-envelope",
30
30
  "nonzero-exit": "shell-command",
31
31
  "spawn-error": "shell-command",
32
+ "test-timeout": "timeout",
32
33
  "static-error": "static-error",
33
34
  "invalid-output": "validation",
34
35
  "structured-output-truncated": "validation",
@@ -44,6 +45,13 @@ const RAW_TO_NORMALIZED = {
44
45
  quota: "executor",
45
46
  "rate-limit": "executor",
46
47
  unavailable: "executor",
48
+ // Deterministic capability/policy refusal: missing verified launcher JS
49
+ // entry, capability allowlist rejection, or missing bounded write tool
50
+ // profile. These are environment/config defects the operator repairs
51
+ // before rerunning; classify as executor so recovery recommends
52
+ // rerun-after-fix WITHOUT forcing a human-decision gate on
53
+ // standaloneTaskRerun (run-failed same-task rerun).
54
+ "tool-policy": "executor",
47
55
  unknown: "unknown",
48
56
  };
49
57
  function isFinishedStatus(status) {
@@ -1723,7 +1723,11 @@ async function dropUnresolvedVerificationSymbols(input) {
1723
1723
  return changed;
1724
1724
  }
1725
1725
  function extractSingleOpenspecCitationsBlock(text) {
1726
- const blocks = text.match(/```openspec-citations[ \t]*\r?\n[\s\S]*?\r?\n```/g) ?? [];
1726
+ // Line-anchored so a directly closed empty fence (no candidate rows) still
1727
+ // counts as the one required block instead of "found 0" retry loops.
1728
+ const blocks = [
1729
+ ...text.matchAll(/^```openspec-citations[ \t]*\r?\n([\s\S]*?)^```[ \t]*$/gim),
1730
+ ].map((match) => match[0]);
1727
1731
  if (blocks.length !== 1) {
1728
1732
  throw new Error(`frontend plan patch output must include exactly one fenced openspec-citations block (found ${blocks.length})`);
1729
1733
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { pathMatchesPattern } from "../../shared/git-progress.js";
5
5
  import { FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, frontendImplementationContractSchema, } from "./frontend-implementation-contract.js";
6
+ import { FRONTEND_NO_VERIFICATION_MARKER_TEXT, FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE, } from "./frontend-verification-trace.js";
6
7
  export const FRONTEND_REPAIR_ASSESSMENT_SCHEMA_ID = "frontend-repair-assessment-v1";
7
8
  export const frontendRepairFailureClassSchema = z.enum([
8
9
  "typecheck",
@@ -82,6 +83,12 @@ export function classifyFrontendFailure(input) {
82
83
  if (input.failureCategory === "write-guard") {
83
84
  return "path";
84
85
  }
86
+ if (`${input.stderr ?? ""}\n${input.stdout ?? ""}`.includes(FRONTEND_NO_VERIFICATION_MARKER_TEXT)) {
87
+ // Verification unavailability is a generation-time contract gap: no
88
+ // authorized verification command exists. Bounded repair cannot fix it;
89
+ // the operator must declare commands and regenerate the DAG.
90
+ return "contract";
91
+ }
85
92
  if (input.traceTarget || input.commandCategory === "trace") {
86
93
  return "trace";
87
94
  }
@@ -424,6 +431,13 @@ export async function runFrontendFailureAssessGate(input) {
424
431
  : !baseEligible
425
432
  ? `non-repairable or ineligible: failureClass=${failureClass} attempt=${attempt}`
426
433
  : `missing structured evidence for failureClass=${failureClass} on ${primary.nodeId}`;
434
+ if (!eligible &&
435
+ `${primary.record.stderr ?? ""}`.includes(FRONTEND_NO_VERIFICATION_MARKER_TEXT)) {
436
+ // Surface the actionable unavailable-verification reason in the
437
+ // assessment (and the non-repairable gate error) instead of a
438
+ // generic class label.
439
+ reason = FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE;
440
+ }
427
441
  }
428
442
  // fixScope and changedPaths must stay inside the implement writeSet.
429
443
  if (evidence.fixScope?.length) {
@@ -2,6 +2,12 @@
2
2
  * Deterministic frontend-implementation risk classifier (M4).
3
3
  * Pure function: no I/O, no model, no side effects.
4
4
  */
5
+ /**
6
+ * Dependency-installation language in task text. Mentioning package.json or
7
+ * allowing its path is NOT dependency introduction by itself; the manifest
8
+ * path check in classifyFrontendRisk requires this language to escalate.
9
+ */
10
+ const DEPENDENCY_INSTALL_LANGUAGE = /new\s+dependency|add\s+dependencies?|install\s+dependencies?|npm\s+(?:install|i)\b|yarn\s+(?:add|install)\b|pnpm\s+(?:add|install)\b|bun\s+(?:add|install)\b|新增依赖|添加依赖|安装依赖|引入依赖/i;
5
11
  const HIGH_RISK_PATTERNS = [
6
12
  {
7
13
  id: "new-route",
@@ -25,7 +31,7 @@ const HIGH_RISK_PATTERNS = [
25
31
  },
26
32
  {
27
33
  id: "new-dependency",
28
- re: /new\s+dependency|add\s+dependency|npm\s+install|新增依赖|package\.json/i,
34
+ re: DEPENDENCY_INSTALL_LANGUAGE,
29
35
  },
30
36
  {
31
37
  id: "schema-api-change",
@@ -88,7 +94,14 @@ export function classifyFrontendRisk(input) {
88
94
  const paths = input.allowedPaths ?? [];
89
95
  const spread = pathSpreadScore(paths);
90
96
  if (paths.some((p) => /package\.json|pnpm-lock|yarn\.lock|package-lock/.test(p))) {
91
- signals.push("new-dependency");
97
+ if (DEPENDENCY_INSTALL_LANGUAGE.test(blob)) {
98
+ signals.push("new-dependency");
99
+ }
100
+ else {
101
+ // Editing manifest scripts/config is not dependency introduction;
102
+ // record the rejected escalation instead of forcing full gates.
103
+ rejectedSignals.push("new-dependency:manifest-path-without-install-language");
104
+ }
92
105
  }
93
106
  // Explicit multi-domain language only — path count alone is not high-risk
94
107
  if (spread >= 3 &&
@@ -1,16 +1,19 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { isPlaywrightCliCommand } from "../../shared/playwright-cli-command-policy.js";
4
+ import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
4
5
  const COMMAND_FENCE_LANGUAGE = /^(?:|bash|sh|shell|zsh|fish|console|terminal|command|cmd|powershell|pwsh|javascript|js|typescript|ts|python|py)$/i;
5
6
  const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
6
7
  const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
7
8
  const EXPLICIT_COMMAND = /^(?:(?:shell|terminal)(?:\s+command)?|command|run command|execute command)\s*:\s*(.+)$/i;
8
9
  const BLOCKED_REASON_PREFIX = /^(?:(?:blocked|forbidden|reject(?:ed)?|disallow(?:ed)?|prohibited)(?:\s+reason)?\s*(?::|\bbecause\b)|(?:do not|must not|never)\s+(?:run|execute|use)\b)/i;
9
10
  /** Standalone blockedReason / environment tokens (not executable commands). */
10
- const BLOCKED_REASON_TOKEN = /^(?:playwright-cli-unavailable|frontend-base-url-unreachable|curl-unavailable|browser-command-capability-unavailable|browser-command-evidence-missing|token-budget-exhausted|current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable|invalid-evidence-shape)(?:\s|$|[.,;:)`'"\]])/i;
11
+ const BLOCKED_REASON_TOKEN = /^(?:playwright-cli-unavailable|frontend-base-url-unreachable|curl-unavailable|browser-command-capability-unavailable|browser-command-evidence-missing|token-budget-exhausted|current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable|unique-control-unavailable|invalid-evidence-shape)(?:\s|$|[.,;:)`'"\]])/i;
11
12
  const BLOCKED_PROSE_HINT = /(?:blockedReason|blocked\s*reason|environmentProbe|evidenceDir|\bunavailable\b|不可用|写\s*blocked|写入\s*blocked|伪命令|自然语言|元数据|fail-closed|探测失败)/i;
12
13
  const IMPERATIVE_COMMAND = /^(?:(?:run|execute|use)(?:\s+(?:the\s+)?(?:(?:shell|terminal)\s+)?command)?|in\s+(?:the\s+)?(?:shell|terminal|console)\s*,?\s*(?:run|execute|use))\s*:?\s+(.+)$/i;
13
14
  const INLINE_CODE_STEP = /^`([^`\r\n]+)`[.!?]?$/;
15
+ /** Assertion-count annotations appended to a playwright-cli literal (e.g. `find "锚点">=1`, `>=N`, `≥N`, `(≥1)`) document an expected match count; they are documentation, not shell arguments. */
16
+ const ASSERTION_COUNT_SUFFIX = /\s*[((]?\s*(?:>=|≥|>|超过|至少)\s*\d+\s*[))]?\s*$/;
14
17
  const AUTOMATION_EXECUTABLE = /^(?:playwright-cli\b|playwright\b|@playwright\/test\b|npx\b|npm\b|pnpm\b|yarn\b|bunx?\b|node(?:js)?\b|python(?:3)?\b|bash\b|sh\b|zsh\b|fish\b|powershell\b|pwsh\b|cmd(?:\.exe)?\b|cypress\b|selenium\b|webdriverio\b|chromedriver\b|google-chrome\b|chrome\b|firefox\b|curl\b|wget\b)/i;
15
18
  const SHELL_WRAPPED_EXECUTABLE = /^(?:sudo\s+|env(?:\s+[A-Za-z_][A-Za-z0-9_]*=[^\s]+)*\s+|(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\/)[^\s]+)/i;
16
19
  const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)/;
@@ -44,6 +47,11 @@ function isBlockedReason(value) {
44
47
  return true;
45
48
  if (/^blockedReason\s*[:=]/i.test(trimmed))
46
49
  return true;
50
+ // Expected-outcome field lines ("status=blocked" / "status: failed") document
51
+ // the intended terminal state of a designed-negative case; they are
52
+ // case-result metadata, never executable commands.
53
+ if (/^["'`]?status["'`]?\s*[:=]\s*["'`]?(passed|failed|blocked)["'`]?\s*[.。]?$/i.test(trimmed))
54
+ return true;
47
55
  return false;
48
56
  }
49
57
  /**
@@ -238,6 +246,12 @@ function commandGateIssue(input) {
238
246
  if (input.instruction.fromFence && isBlockedReason(input.instruction.command)) {
239
247
  return null;
240
248
  }
249
+ // An expected-match-count annotation (`find "锚点">=1`, with or without a
250
+ // playwright-cli prefix) documents the assertion; it is not part of the
251
+ // command and never executable input.
252
+ if (/^(?:playwright-cli\s+)?find\s+"[^"]*"/i.test(input.instruction.command) && ASSERTION_COUNT_SUFFIX.test(input.instruction.command)) {
253
+ return null;
254
+ }
241
255
  const commandMatch = input.instruction.command.match(/^playwright-cli\s+([^\s`]+)/i);
242
256
  if (commandMatch) {
243
257
  const command = commandMatch[1].toLowerCase().replace(/^[`'"]+|[`'":,,。→]+$/g, "");
@@ -282,7 +296,13 @@ export async function validateFrontendCaseChecklist(input) {
282
296
  const manifestPath = await exists(draft) ? draft : await exists(final) ? final : "";
283
297
  if (!manifestPath)
284
298
  throw new Error("checklist: missing manifest.draft.json or manifest.json");
285
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
299
+ let manifest;
300
+ try {
301
+ manifest = JSON.parse(await readFile(manifestPath, "utf8"));
302
+ }
303
+ catch {
304
+ throw new Error("checklist: manifest is malformed JSON");
305
+ }
286
306
  if (!Array.isArray(manifest.cases) || manifest.cases.length === 0)
287
307
  throw new Error("checklist: empty cases");
288
308
  const declaredAc = new Set(input.declaredAcIds ?? []);
@@ -334,6 +354,10 @@ export async function validateFrontendCaseChecklist(input) {
334
354
  for (const ac of item.acIds) {
335
355
  if (typeof ac !== "string" || !acIdRe.test(ac))
336
356
  issues.push({ ruleId: "ac-id-shape", caseId: id, casePath, detail: String(ac) });
357
+ else if (declaredAc.size > 0 && isTruncatedFrontendAcId(ac))
358
+ issues.push({ ruleId: "ac-id-truncated", caseId: id, casePath, detail: `${ac} is a truncated acceptance id (missing feature prefix or sequence number)` });
359
+ else if (declaredAc.size === 0 && isTruncatedWhenUndeclaredFrontendAcId(ac))
360
+ issues.push({ ruleId: "ac-id-truncated", caseId: id, casePath, detail: `${ac} is a truncated acceptance id (missing feature prefix or sequence number)` });
337
361
  else if (declaredAc.size > 0 && !declaredAc.has(ac))
338
362
  issues.push({ ruleId: "unknown-ac", caseId: id, casePath, detail: `${ac} not in sourceBinding` });
339
363
  }
@@ -1,5 +1,6 @@
1
1
  import { readFile, rename, unlink, writeFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
3
4
  const DIMENSIONS = new Set(["core", "boundary", "flow", "backend"]);
4
5
  function fail(ruleId, detail) {
5
6
  const msg = JSON.stringify({ ruleId, detail: String(detail ?? "") });
@@ -62,6 +63,10 @@ export async function materializeFrontendTestCaseManifest(input) {
62
63
  for (const ac of c.acIds) {
63
64
  if (typeof ac !== "string" || !acIdRe.test(ac))
64
65
  fail("ac-id-shape", `acIds entry must be AC-* acceptance id, not caseId: ${ac}`);
66
+ if ((declaredAc.size > 0
67
+ ? isTruncatedFrontendAcId(ac)
68
+ : isTruncatedWhenUndeclaredFrontendAcId(ac)))
69
+ fail("ac-id-truncated", `acIds entry is a truncated acceptance id (missing feature prefix or sequence number): ${ac}`);
65
70
  if (declaredAc.size > 0 && !declaredAc.has(ac))
66
71
  fail("unknown-ac", `${ac} not in sourceBinding; repair generator input or AC list`);
67
72
  acIds.push(ac);
@@ -7,6 +7,25 @@ import { readPlaywrightCliReceipts, summarizePlaywrightCliReceipts, } from "../.
7
7
  import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
8
8
  import { markdownH1Title, markdownList, markdownSection, } from "./frontend-test-markdown.js";
9
9
  export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
10
+ /**
11
+ * Truncated acceptance-id shapes observed in generator output: a bare
12
+ * numeric segment ("AC-002" dropped its AC-FE-<FEATURE> prefix) or an
13
+ * AC-FE-<FEATURE> literal without the trailing -NNN sequence number
14
+ * ("AC-FE-CHAT"). Generic short ids like "AC-1"/"AC-FE-001" stay valid;
15
+ * declared-source cross-checks (unknown-ac) remain the primary guard when a
16
+ * sourceBinding exists.
17
+ */
18
+ export function isTruncatedFrontendAcId(acId) {
19
+ return /^AC-FE-[A-Z]+$/i.test(acId.trim());
20
+ }
21
+ /**
22
+ * Bare numeric ids ("AC-002") only indicate truncation when no declared AC
23
+ * list exists to adjudicate them: with a sourceBinding they are ordinary
24
+ * unknown-ac findings ("AC-999" may be a legitimately declared id style).
25
+ */
26
+ export function isTruncatedWhenUndeclaredFrontendAcId(acId) {
27
+ return /^AC-\d{3}$/i.test(acId.trim()) || isTruncatedFrontendAcId(acId);
28
+ }
10
29
  const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
11
30
  !path.win32.isAbsolute(value) &&
12
31
  !value.includes("\\") &&
@@ -57,6 +76,8 @@ export const frontendTestResultContractSchema = z.object({
57
76
  passed: z.number().int().min(0),
58
77
  failed: z.number().int().min(0),
59
78
  blocked: z.number().int().min(0),
79
+ /** Stability metric: passed on attempt 0 (no rerun/self-heal retry). */
80
+ firstTryPassed: z.number().int().min(0).optional(),
60
81
  }).strict(),
61
82
  acceptanceCoverage: z.object({
62
83
  covered: z.array(z.string().min(1)),
@@ -110,7 +131,13 @@ export async function validateFrontendCaseEvidence(input) {
110
131
  catch {
111
132
  throw new Error("missing testcase/frontend/cases/manifest.json");
112
133
  }
113
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
134
+ let manifest;
135
+ try {
136
+ manifest = JSON.parse(await readFile(manifestPath, "utf8"));
137
+ }
138
+ catch {
139
+ throw new Error("invalid frontend case manifest: malformed JSON");
140
+ }
114
141
  if (!Array.isArray(manifest.cases))
115
142
  throw new Error("invalid frontend case manifest");
116
143
  const statuses = new Set(["passed", "failed", "blocked"]);
@@ -195,11 +222,17 @@ export function evaluatePassedCaseBrowserReceipts(summary, caseId) {
195
222
  }
196
223
  return { ok: true };
197
224
  }
198
- async function loadCaseBrowserReceiptSummary(input) {
225
+ async function loadCaseBrowserReceiptsWithSummary(input) {
199
226
  try {
200
227
  const entries = await readdir(input.runDir, { withFileTypes: true });
201
228
  // A passed chain must belong to exactly one browser child stream. Never
202
- // stitch receipts across DAG nodes or accept ambiguous duplicate authority.
229
+ // stitch receipts across DAG nodes or accept ambiguous duplicate
230
+ // authority — with one exception: bounded rerun creates a second,
231
+ // later stream for the same case (the rerun overwrites the disk
232
+ // case-result, so the final attempt is the authoritative one). When
233
+ // multiple streams authorize, prefer the highest rerun round
234
+ // (rerun-frontend-case-rN-<idx> directories); ties within the same
235
+ // round remain ambiguous and fail closed.
203
236
  const authorizingStreams = [];
204
237
  for (const entry of entries) {
205
238
  if (!entry.isDirectory())
@@ -207,17 +240,99 @@ async function loadCaseBrowserReceiptSummary(input) {
207
240
  const nodeReceipts = (await readPlaywrightCliReceipts(input.runDir, entry.name))
208
241
  .filter((item) => item.caseId === input.caseId);
209
242
  const summary = summarizePlaywrightCliReceipts(nodeReceipts);
210
- if (summary.hasOrderedPassedReceiptChain)
211
- authorizingStreams.push(summary);
243
+ if (summary.hasOrderedPassedReceiptChain) {
244
+ const roundMatch = /rerun-frontend-case-r(\d+)-/i.exec(entry.name);
245
+ authorizingStreams.push({
246
+ receipts: nodeReceipts,
247
+ summary,
248
+ round: roundMatch ? Number(roundMatch[1]) : 0,
249
+ });
250
+ }
251
+ }
252
+ if (authorizingStreams.length === 0) {
253
+ return { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
212
254
  }
213
- return authorizingStreams.length === 1
214
- ? authorizingStreams[0]
215
- : summarizePlaywrightCliReceipts([]);
255
+ const maxRound = Math.max(...authorizingStreams.map((s) => s.round));
256
+ const finalists = authorizingStreams.filter((s) => s.round === maxRound);
257
+ return finalists.length === 1
258
+ ? finalists[0]
259
+ : { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
260
+ }
261
+ catch {
262
+ return { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
263
+ }
264
+ }
265
+ /**
266
+ * Extract `find "literal" >=N` / `find "literal"` declarations from a case
267
+ * document. Only quoted literals are binding assertions — the count suffix
268
+ * (`>=N`, `==N`) is the expected minimum/exact match count from the ledger.
269
+ * Placeholder literals ("...", "…", "<字面>", "eX") appear in prose discussing
270
+ * the syntax itself; they are documentation, never declarations.
271
+ */
272
+ export function extractDeclaredFindAssertions(caseMarkdown) {
273
+ const source = stripFencedCodeBlocks(caseMarkdown);
274
+ const out = [];
275
+ const seen = new Set();
276
+ const pattern = /find\s+"((?:[^"\\]|\\.)+)"\s*(>=|==)\s*(\d+)/g;
277
+ const isPlaceholder = (literal) => /^(?:\.{2,}|…+|<[^>]*>|e\d+)$/.test(literal.trim());
278
+ let match;
279
+ while ((match = pattern.exec(source)) !== null) {
280
+ const literal = match[1].replace(/\\(.)/g, "$1");
281
+ if (isPlaceholder(literal))
282
+ continue;
283
+ const operator = match[2];
284
+ const expected = Number(match[3]);
285
+ const key = `${operator}:${literal}`;
286
+ if (seen.has(key))
287
+ continue;
288
+ seen.add(key);
289
+ out.push({ literal, expected, operator });
290
+ }
291
+ return out;
292
+ }
293
+ function stripFencedCodeBlocks(markdown) {
294
+ return markdown.replace(/```[\s\S]*?```/g, "");
295
+ }
296
+ async function readCaseMarkdownForAudit(workspaceRoot, casePath) {
297
+ try {
298
+ return await readFile(path.join(workspaceRoot, casePath), "utf8");
216
299
  }
217
300
  catch {
218
- return summarizePlaywrightCliReceipts([]);
301
+ return "";
219
302
  }
220
303
  }
304
+ /**
305
+ * Controller-side assertion audit: every find-family declaration in the case
306
+ * document must be backed by a successful find receipt whose parsed match
307
+ * count satisfies the declared operator. This moves pass authority from
308
+ * "the model says it asserted" to "receipts prove it asserted".
309
+ */
310
+ export function auditDeclaredFindAssertions(declared, receipts) {
311
+ if (declared.length === 0)
312
+ return { ok: true };
313
+ const successfulFinds = receipts.filter((receipt) => (receipt.command === "find" || receipt.command === "find-unique") &&
314
+ receipt.exitCode === 0 &&
315
+ !receipt.timedOut);
316
+ const missing = [];
317
+ for (const decl of declared) {
318
+ const backing = successfulFinds.some((receipt) => receipt.argsRedacted.some((arg) => arg === decl.literal || arg === `"${decl.literal}"`) &&
319
+ typeof receipt.matchCount === "number" &&
320
+ (decl.operator === ">="
321
+ ? receipt.matchCount >= decl.expected
322
+ : receipt.matchCount === decl.expected));
323
+ if (!backing) {
324
+ missing.push(`${decl.operator}${decl.expected} "${decl.literal.slice(0, 40)}"`);
325
+ }
326
+ }
327
+ if (missing.length > 0) {
328
+ return {
329
+ ok: false,
330
+ reason: "assertion-not-receipt-backed",
331
+ missing,
332
+ };
333
+ }
334
+ return { ok: true };
335
+ }
221
336
  function sha256(content) {
222
337
  return createHash("sha256").update(content).digest("hex");
223
338
  }
@@ -324,7 +439,13 @@ export async function materializeFrontendTestResult(input) {
324
439
  }
325
440
  const manifestPath = "testcase/frontend/cases/manifest.json";
326
441
  const manifestAbsolute = path.resolve(input.workspaceRoot, manifestPath);
327
- const manifest = JSON.parse(await readFile(manifestAbsolute, "utf-8"));
442
+ let manifest;
443
+ try {
444
+ manifest = JSON.parse(await readFile(manifestAbsolute, "utf-8"));
445
+ }
446
+ catch {
447
+ throw new Error("invalid or empty frontend case manifest: malformed JSON");
448
+ }
328
449
  if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.cases) || manifest.cases.length === 0) {
329
450
  throw new Error("invalid or empty frontend case manifest");
330
451
  }
@@ -387,7 +508,7 @@ export async function materializeFrontendTestResult(input) {
387
508
  advisoryFindings.push({ ruleId: "passed-without-browser-evidence", caseId: item.caseId, detail: "passed case has no browser evidence" });
388
509
  let blockedReason = status === "blocked" && typeof resultRaw.blockedReason === "string" && resultRaw.blockedReason.trim() ? resultRaw.blockedReason.trim() : undefined;
389
510
  if (status === "passed") {
390
- const receiptSummary = await loadCaseBrowserReceiptSummary({
511
+ const { receipts: caseReceipts, summary: receiptSummary } = await loadCaseBrowserReceiptsWithSummary({
391
512
  runDir: input.runDir,
392
513
  caseId: item.caseId,
393
514
  });
@@ -401,6 +522,24 @@ export async function materializeFrontendTestResult(input) {
401
522
  detail: "passed case lacks controller-owned open + interaction/assertion + cleanup receipts",
402
523
  });
403
524
  }
525
+ else {
526
+ // Controller-side assertion audit: every `find "literal" >=N` declared
527
+ // in the case document must be backed by a successful find receipt
528
+ // whose parsed matchCount satisfies the operator. Pass authority moves
529
+ // from model prose to receipt evidence.
530
+ const caseMarkdown = await readCaseMarkdownForAudit(input.workspaceRoot, item.casePath);
531
+ const declared = extractDeclaredFindAssertions(caseMarkdown);
532
+ const audit = auditDeclaredFindAssertions(declared, caseReceipts);
533
+ if (!audit.ok) {
534
+ status = "blocked";
535
+ blockedReason = audit.reason;
536
+ advisoryFindings.push({
537
+ ruleId: "assertion-not-receipt-backed",
538
+ caseId: item.caseId,
539
+ detail: `declared find assertions without receipt backing: ${audit.missing.join("; ")}`,
540
+ });
541
+ }
542
+ }
404
543
  }
405
544
  const { caseContent, testPoints } = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
406
545
  // Parse bounded fixed sections from execution.md. Missing optional
@@ -451,6 +590,7 @@ export async function materializeFrontendTestResult(input) {
451
590
  passed: cases.filter((item) => item.status === "passed").length,
452
591
  failed: cases.filter((item) => item.status === "failed").length,
453
592
  blocked: cases.filter((item) => item.status === "blocked").length,
593
+ firstTryPassed: cases.filter((item) => item.status === "passed" && !item.rerunAttempt).length,
454
594
  };
455
595
  const outcome = totals.failed > 0 ? "failed" : totals.blocked > 0 || missing.length > 0 ? "incomplete" : "passed";
456
596
  const result = frontendTestResultContractSchema.parse({
@@ -2,6 +2,18 @@ import { readFile, stat, writeFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, frontendImplementationContractSchema, } from "./frontend-implementation-contract.js";
4
4
  export const FRONTEND_VERIFICATION_TRACE_SCHEMA_ID = "frontend-verification-trace-v1";
5
+ /**
6
+ * Shared marker for a project that exposes no usable verification command at
7
+ * generation time. The marker command runs (and logs not-run honestly), but it
8
+ * must never count as successful verification evidence: labels carrying this
9
+ * text are excluded from successful evidence and targets bound to it fail the
10
+ * trace gate instead of passing it.
11
+ */
12
+ export const FRONTEND_NO_VERIFICATION_MARKER_TEXT = "No frontend verification command configured";
13
+ export function isFrontendNoVerificationMarkerLabel(label) {
14
+ return label.includes(FRONTEND_NO_VERIFICATION_MARKER_TEXT);
15
+ }
16
+ export const FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE = `frontend verification unavailable: the only frozen static command is the no-op not-run marker (${FRONTEND_NO_VERIFICATION_MARKER_TEXT}); declare verification commands via task.json --verify or 执行约束.md and regenerate the DAG`;
5
17
  async function readJsonFile(filePath) {
6
18
  return JSON.parse(await readFile(filePath, "utf8"));
7
19
  }
@@ -52,7 +64,14 @@ export async function evaluateVerificationTargets(input) {
52
64
  const hardIssues = [];
53
65
  for (const target of input.contract.verificationTargets) {
54
66
  const issues = [];
55
- const owners = input.labelOwners.get(target.commandLabel);
67
+ if (isFrontendNoVerificationMarkerLabel(target.commandLabel)) {
68
+ // A target bound to the not-run marker has no verification behind it;
69
+ // passing it would let the run finish green without any check.
70
+ issues.push(`${target.id}: ${FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE}`);
71
+ }
72
+ const owners = isFrontendNoVerificationMarkerLabel(target.commandLabel)
73
+ ? []
74
+ : (input.labelOwners.get(target.commandLabel) ?? []);
56
75
  if (!owners?.length) {
57
76
  issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
58
77
  }
@@ -189,11 +208,17 @@ export async function runFrontendVerificationTraceGate(input) {
189
208
  throw new Error("trace: selected Mock strategy requires successful Mock verification commands");
190
209
  }
191
210
  if (input.evidence) {
192
- if (input.evidence.static.commandLabels.length === 0) {
193
- throw new Error("trace: no successful static verification commands in current run");
211
+ // The no-op not-run marker never counts as successful verification
212
+ // evidence, even if it executed with exit code 0.
213
+ const effectiveStaticLabels = input.evidence.static.commandLabels.filter((label) => !isFrontendNoVerificationMarkerLabel(label));
214
+ const effectiveBehaviorLabels = input.evidence.behavior.commandLabels.filter((label) => !isFrontendNoVerificationMarkerLabel(label));
215
+ if (effectiveStaticLabels.length === 0) {
216
+ throw new Error(input.evidence.static.commandLabels.length > 0
217
+ ? `trace: ${FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE}`
218
+ : "trace: no successful static verification commands in current run");
194
219
  }
195
220
  if (requiresBehaviorVerification &&
196
- input.evidence.behavior.commandLabels.length === 0) {
221
+ effectiveBehaviorLabels.length === 0) {
197
222
  throw new Error("trace: no successful behavior verification commands in current run");
198
223
  }
199
224
  }