@tea-agent/loop-agent 0.39.0-next.30 → 0.39.0-next.32

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 (103) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/build-stamp.json +2 -2
  3. package/dist/executors/dag-pi-executor.js +7 -4
  4. package/dist/executors/pi-playwright-cli-tool.js +66 -27
  5. package/dist/executors/shell-executor.js +25 -8
  6. package/dist/shared/playwright-cli-command-policy.js +15 -0
  7. package/dist/task/config-types.js +6 -0
  8. package/dist/task/source-prepare/parse-intent.js +24 -4
  9. package/dist/worker/console/chat/pi-console-config.js +81 -10
  10. package/dist/worker/console/dag-execution-receipt.js +33 -0
  11. package/dist/worker/console/operator-user-error.js +1 -1
  12. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CiGOV5eG.js → abnfDiagram-N423BO3Z-B_Eawmjz.js} +1 -1
  13. package/dist/worker/console/static/assets/{arc-Ds9Gyfd8.js → arc-CAX7-_9s.js} +1 -1
  14. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C6V6uC98.js → architectureDiagram-T3A2C74G-kSHUPU8D.js} +1 -1
  15. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DezF7Ol9.js → blockDiagram-VBNYF7ZC-7uygSzId.js} +1 -1
  16. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BCWk3gCT.js → c4Diagram-5PPSVZJV-B7VBFqY8.js} +1 -1
  17. package/dist/worker/console/static/assets/channel-BqOf3G6O.js +1 -0
  18. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DbYr-Cxy.js → chunk-2GRJ4B5K-CToVTe0G.js} +1 -1
  19. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-XSlusW4G.js → chunk-2Q5K7J3B-C3Y6NsZJ.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BtcKAj9i.js → chunk-5RXB4S5H-D__FOJmB.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BF57lhXj.js → chunk-5VM5RSS4-DnbdnPQh.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DJIlR25o.js → chunk-6Q2QTUOP-fxcnbtmR.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DklNc7dm.js → chunk-GF5L2VYU-DkWP4DCN.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-fZNtEdQZ.js → chunk-JWPE2WC7-3crbQ-b9.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BBgLqV4h.js → chunk-KBJHAD2P-AW5fUBHx.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Dl9e5n_J.js → chunk-RYQCIY6F-BsiQcbN7.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-B0JK51NT.js → chunk-XXDRQBXY-CuRhHBel.js} +1 -1
  28. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BGuxW-g8.js +1 -0
  29. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BGuxW-g8.js +1 -0
  30. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-Ch-ajv4O.js → cose-bilkent-JH36ORCC-VNMSbhR1.js} +1 -1
  31. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-xn_NxcfB.js → cynefin-VYW2F7L2-9VXNKhPA.js} +1 -1
  32. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DRD0fyga.js → cynefinDiagram-MW4NZA55-DnA3i2aF.js} +1 -1
  33. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-COtnKp4B.js → dagre-VZM6K2ZE-CXf1ON53.js} +1 -1
  34. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BQp_CAqG.js → diagram-7IWD3JNH-DkHu5QHM.js} +1 -1
  35. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-x0y-vNgq.js → diagram-B4RE2ZJO-b3DVvxzf.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-N2ezwL31.js → diagram-LBJQPF4R-CMtGrmDr.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-CSaxRH6Q.js → diagram-Q27KOJAE-CTUKQdiN.js} +1 -1
  38. package/dist/worker/console/static/assets/{diagram-UB23O5K3-6Ro7Kpbd.js → diagram-UB23O5K3-XwXry4X0.js} +1 -1
  39. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CrIIQEAI.js → ebnfDiagram-BXEA7PRR-Dn6cLJ4_.js} +1 -1
  40. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DivKcq8g.js → erDiagram-JOGREHBK-MLibOYKC.js} +1 -1
  41. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BXrfDo8y.js → flowDiagram-UKHOOZJN-C3ErZkth.js} +1 -1
  42. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-LTTQmMRJ.js → ganttDiagram-PKOTCBZU-CN8OlZId.js} +1 -1
  43. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DP3o_5qd.js → gitGraphDiagram-DS77QQ5N-Cq4huIY9.js} +1 -1
  44. package/dist/worker/console/static/assets/{index-C2jmYBfu.js → index-DSWsdkbR.js} +52 -52
  45. package/dist/worker/console/static/assets/index-XZm1O8NF.css +1 -0
  46. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-wVfSY1WH.js → infoDiagram-6WML65LV-BG_CYrDS.js} +1 -1
  47. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BRKfFL0I.js → ishikawaDiagram-WSZJBQD7-DCWDQKJF.js} +1 -1
  48. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DEml3jLb.js → journeyDiagram-NVQOT4AX-DG_VS4XD.js} +1 -1
  49. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DTzBmOqN.js → kanban-definition-27J2QSJJ-DkmTDctP.js} +1 -1
  50. package/dist/worker/console/static/assets/{linear-Cpy1d873.js → linear-C1PpUkLo.js} +1 -1
  51. package/dist/worker/console/static/assets/{mermaid.core-C1cSavOy.js → mermaid.core-BXExQJbm.js} +5 -5
  52. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BEBSKvYM.js → mindmap-definition-FAOFIHXS-gUlCo9JV.js} +1 -1
  53. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BrLs4Oac.js → pegDiagram-VL7TDLO6-JoegdCzT.js} +1 -1
  54. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-B47AZcLa.js → pieDiagram-7S7Q4E2Y-CrJnvJe_.js} +1 -1
  55. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-s0dVeYR2.js → quadrantDiagram-CIZ2JOQS-BHZqrxkB.js} +1 -1
  56. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-lW8aJ7Q6.js → railroadDiagram-AXF67PYL-D2uwo4iG.js} +1 -1
  57. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BcHJPf6Q.js → requirementDiagram-LRYGKXZP-Cjg0rXAB.js} +1 -1
  58. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DdegpUUf.js → sankeyDiagram-W5VNT64P-0VbDOPz_.js} +1 -1
  59. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BxFWqNv8.js → sequenceDiagram-SI44F4Z6-S2UAiI-8.js} +1 -1
  60. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-cOdUDPbR.js → sizeCapture-X5ZJPWSS-DQYI8pSg.js} +1 -1
  61. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-DpxlYl8P.js → stateDiagram-OKZ733FA-EIDdI0hz.js} +1 -1
  62. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BF-RJ1_H.js +1 -0
  63. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DQOfurE0.js → swimlanes-SLNWSIFB-CfukQalg.js} +2 -2
  64. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-NswIVCtn.js +8 -0
  65. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CIp3tC6V.js → timeline-definition-Z64GVDOM-BOQ3dOWp.js} +1 -1
  66. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-C-adIPKn.js → vennDiagram-T6HMQDX7-ChhOpX0T.js} +1 -1
  67. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CiYrar9M.js → wardleyDiagram-T6FBY63Y-BVYvTZDn.js} +1 -1
  68. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-WHrdTV0-.js → xychartDiagram-ELKLHX3M-CPRbN4R2.js} +1 -1
  69. package/dist/worker/console/static/index.html +2 -2
  70. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  71. package/dist/worker/materialize/harness-task-lineage.js +5 -2
  72. package/dist/worker/observe/static/views/failures.js +5 -2
  73. package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
  74. package/dist/workflows/dag/dynamic-runtime/map.js +1 -1
  75. package/dist/workflows/dag/frontend-repair.js +14 -0
  76. package/dist/workflows/dag/frontend-risk.js +15 -2
  77. package/dist/workflows/dag/frontend-test-case-checklist.js +30 -4
  78. package/dist/workflows/dag/frontend-test-case-manifest.js +11 -4
  79. package/dist/workflows/dag/frontend-test-case-quality.js +6 -3
  80. package/dist/workflows/dag/frontend-test-environment-probe.js +10 -7
  81. package/dist/workflows/dag/frontend-test-html-report.js +3 -1
  82. package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
  83. package/dist/workflows/dag/frontend-test-layout.js +159 -0
  84. package/dist/workflows/dag/frontend-test-result-contract.js +179 -25
  85. package/dist/workflows/dag/frontend-test-standard-scenarios.js +3 -1
  86. package/dist/workflows/dag/frontend-verification-trace.js +29 -4
  87. package/dist/workflows/dag/init-hybrid.js +146 -25
  88. package/dist/workflows/dag/types.js +12 -0
  89. package/dist/workflows/dag/validate.js +23 -19
  90. package/docs/templates/frontend-test-case-checklist.md +2 -2
  91. package/harness.json +3 -3
  92. package/package.json +1 -1
  93. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  94. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  95. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  96. package/skills/playwright-cli/SKILL.md +23 -1
  97. package/skills/playwright-cli-case-generator/SKILL.md +29 -8
  98. package/dist/worker/console/static/assets/channel-DMvu1nmi.js +0 -1
  99. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DVzM-02m.js +0 -1
  100. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DVzM-02m.js +0 -1
  101. package/dist/worker/console/static/assets/index-C8rOIAJr.css +0 -1
  102. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BwecGAkM.js +0 -1
  103. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D5WW_LbN.js +0 -8
package/CHANGELOG.md CHANGED
@@ -4,9 +4,13 @@
4
4
 
5
5
  ### 重点更新
6
6
 
7
+ - Console「设置」页新增「模型配置(models.json)」区块:以弹框形式增/改/删 Pi 的模型条目,支持 6 个定义字段(id、name、contextWindow、maxTokens、input、reasoning)。新增时 Provider 用下拉选择、其余字段自动补默认值;编辑时 Provider 只读且仅在原位置就地替换,只改表单内字段、不覆盖配置里页面未展示的其它字段(如 cost/compat);不读写 API key(凭据走 Pi CLI)。编辑已存在的模型不再被移到最后。
8
+ - 前端风险分级细分 manifest 触及语义:`allowedPaths` 含 `package.json`/lockfile 或需求文本提到 package.json 不再单独触发 `new-dependency` high-risk(改一行 script、调构建参数的任务不再被顶到强制全门禁并误标高风险),只有任务文本同时出现安装语言(npm install / yarn add / pnpm add / 新增依赖等)才升级;仅路径触及时记 `new-dependency:manifest-path-without-install-language` 拒绝信号,审计可溯。
9
+ - 前端验证 fallback 生成期诚实化:真实项目无可读取的 `package.json` scripts 且未探测到本地 tsc 时,不再注入必然失败的 `npm run typecheck` / `npm run build` / `npm test`(纯静态 / vanilla JS 站点原会在 verify 阶段假失败),改为执行显式 no-op 标记命令(`No frontend verification command configured`,进入冻结命令集可被 contract 引用,trace 如实记录 not-run),并写生成期 advisory 提示用 `--verify` 或执行约束显式声明验证命令后重新生成 DAG。无 repoRoot 的单测 fixture 保留历史 generic fallback。**标记不构成验证证据(fail-closed)**:verification trace 将绑定标记的 target 判为失败、只含标记的 static 证据判为 unavailable,repair 分类归为 non-repairable `contract`,verify 节点以 `frontend verification unavailable` 硬失败阻断 review/closeout——无真实验证命令的 run 无法走完绿灯链。
7
10
  - frontend-test `passed` 回执链改按 dispatch 序号判序(B-HASH-01):`summarizePlaywrightCliReceipts` 先按 `sequence` 排序再判 open→find→cleanup 连续链;并行 `find`/`screenshot` 按完成时间落盘导致的乱序不再误杀执行已通过的 case(Wave 1 实测 4 条 passed 被改判 `browser-command-evidence-missing`)。真 gap / 重复序号仍 fail-closed。
8
11
  - `tool-policy` 失败归一为 `executor`(B-REC-03):前端 preflight 能力拒绝(缺 `@playwright/cli` JS entry、allowlist 拒绝)不再落 `unknown`→`manual-review`+`humanRequired`;`dag report` 给出 `rerun-after-fix`,Console `standaloneTaskRerun` 的 R2 gate 放行 run-failed 同 task 整单重跑,无需人工决策门或 sibling task。
9
12
  - Operator Chrome Band1 模式切换入口补显式 `aria-label`(B-FIND-01,方案 A):`操作`/`观测` mode link 原为 inline-flex 裸文本节点,不参与 name-from-content,a11y snapshot 序列化为无名 link,`find` 无法断言;补 `aria-label` 后实测 `find "观测"` 1 命中、click 后 `find "运行总览"` 1 命中,AC-FE-CHROME-003 恢复可见断言路径。
13
+ - Inspect 异常 Task 页空态文案统一(B-FIND-02):`failures.js` 运行时空态由 `当前没有失败或阻塞的 Task。` 改回与静态 `#failures-empty`、页面标题及回归目录 AC-FE-INS-004 一致的 `当前没有异常 Task`,消除静态/运行时文案漂移;实测新文案 find 1 命中、旧文案 0 命中。
10
14
 
11
15
  - `next-release.yml` 防重复发布守卫补充幂等语义:`mode=recover`(dist-tags.next 已指向出自本 HEAD 的 tarball)时,手动 `workflow_dispatch` 重入不再撞 duplicate publish 硬失败,改为写 summary 后干净退出(§5.1 规则 4);非 recover 的重复发布仍硬失败。
12
16
 
@@ -19,6 +23,7 @@
19
23
  ### 新增
20
24
 
21
25
  - Operator Chat 时间线左缘新增「用户消息锚点栏」:每个用户输入一枚横向小条(12×3px 微圆角),紧凑等距排列并在时间线高度内垂直居中;条数超出栏高时相邻锚点自动聚合为稍宽的聚合条(悬停展开「N 条聚合输入」逐条列表,时间 + 单行预览,逐条点击定位)。悬停时小条向对话一侧平滑伸长(12→18px)并变橙,浮窗右侧弹出(白底圆角,正文最多 3 行、超出省略号,附序号与时间);单击平滑滚动定位到对应消息(精确停靠在视口顶部偏移 8px);覆盖视口顶部的锚点保持高亮。空会话自动隐藏;历史分页/流式更新/窗口缩放时自动重算(rAF 节流)。
26
+ - frontend-test 产物根可配置:`task.json.frontendTest.testRoot` 缺省仍为 `testcase/frontend`,省略时生成的 DAG 路径与历史默认根一致;自定义根(如 `qa/frontend-test`)在编译期冻结并驱动 writer、gate、playwright 围栏与 `allowedPaths`。`.harness/**` 等隐藏/治理树在生成期拒绝。
22
27
  - 实验能力「编辑失败节点提示词后从节点重跑」(默认关闭):内部开关 `HARNESS_DAG_RERUN_PROMPT_OVERRIDE=1` 启用后,`dag rerun` 支持 `--prompt-override <nodeId>@<file>`(全文替换)与 `--prompt-override-append <nodeId>@<file>`(追加指令:保留冻结原提示词,末尾追加 `<operator_addendum>` 块)。仅限 ERROR 且位于 reset closure 内的节点;mode + base/最终 prompt sha256 计入 planHash(execute 漂移即 `PLAN_DRIFT`),父 run spec 冻结、provenance(含 mode)落 `continuation.promptOverrides`。Console 新增 `dagPromptOverrideConfig` 开关探测 action,`dagRerunPlan`/`dagRerun` 接受 `promptOverrides`(`{nodeId, prompt, mode?}`);Inspect DAG 节点 inspector 新增「提示词重启」tab——默认「追加指令」模式(只读底稿 + 追加编辑器),可切换「全文替换(高级)」,预检通过展示执行估算,提交后续跑并跳转新 run,全程不离开图页;开关开启时 `/inspect` 只注入 **scoped** 变体凭据(独立 cookie + `__INSPECT_MUTATION_TOKEN__`,与 Console 通用 token 互不可重放),mutations 路由对 inspect 凭据仅放行 `dagRerunPlan`/`dagRerun`(其余 action 403 `ACTION_NOT_IN_INSPECT_SCOPE`),Inspect 不获得通用写能力;开关关闭时不注入任何 token、tab 不出现,检视面保持严格只读。
23
28
  - 观察面 DAG Inspector 节点详情适配 Pi 扩展:执行器输入摘要展示节点声明的 `piExtensions` 短 id;新增「Pi 扩展运行事实」展示 `pi-extensions.json` 的 resolved(包名@版本)与 missing 降级原因(缺包降级不再静默);产物未生成/损坏时降级提示不阻断接口,绝对路径不外泄。
24
29
  - 后端实现 DAG(standard / review-gated / supervised)的 Pi 节点支持按桶显式加载本机 Pi 导航扩展:读侧节点(除 closeout)加载 `pi-codegraph` + `pi-lens`,写侧节点只加载 `pi-codegraph`(pi-lens 的 `ast_grep_replace` 可绕过 writeSet,不加载);前端/测试模板与省略字段节点保持扩展全关。扩展发现始终关闭,只经白名单解析 `~/.pi/agent` 与项目 `.pi` settings 的已装包(缺包降级不失败,产物 `pi-extensions.json` 记录 requested/resolved/missing);session 后 pin 工具面剔 builtin 同名、deny 面与写工具,writer 的 `edit`/`write` 仍为 policy-wrapped;`.codegraph/**` index 同步不再计入 write-guard 越界。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "version": "0.38.3",
4
- "gitSha": "07a0a9bcee0b9aa29cafc085485f3aed7ae03cec",
5
- "builtAt": "2026-08-22T12:04:10.935Z"
4
+ "gitSha": "e37cf1f3b4a5c98e7fc60de321be5a571ce3a105",
5
+ "builtAt": "2026-08-23T12:41:22.938Z"
6
6
  }
@@ -12,6 +12,7 @@ import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelai
12
12
  import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
13
13
  import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
14
14
  import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
15
+ import { frontendTestLayoutFromSpec, } from "../workflows/dag/frontend-test-layout.js";
15
16
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
16
17
  /**
17
18
  * Writer classification for a length-stopped thinking-only attempt: the model
@@ -512,10 +513,11 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
512
513
  throw new Error(`unknown command capability: ${capability}`);
513
514
  }
514
515
  if (capability === "playwright-cli") {
515
- const caseId = resolveCaseIdFromWriteSet(input.task.writeSet) ??
516
+ const layout = frontendTestLayoutFromSpec(meta.spec);
517
+ const caseId = resolveCaseIdFromWriteSet(input.task.writeSet, layout) ??
516
518
  input.task.id;
517
- const evidenceDir = resolveEvidenceDirFromWriteSet(input.task.writeSet) ??
518
- `testcase/frontend/evidence/${caseId}`;
519
+ const evidenceDir = resolveEvidenceDirFromWriteSet(input.task.writeSet, layout) ??
520
+ `${layout.evidenceDir}/${caseId}`;
519
521
  playwrightToolContext = {
520
522
  repoRoot: input.cwd,
521
523
  runDir: meta.runDir,
@@ -523,7 +525,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
523
525
  caseId,
524
526
  evidenceDir,
525
527
  baseUrl: await resolveValidatedFrontendBaseUrlFromContext(input.cwd, meta.runDir),
526
- inputRoot: "testcase/frontend/fixtures",
528
+ inputRoot: layout.fixturesDir,
529
+ layout,
527
530
  };
528
531
  // Cleanup must be confirmed before a new case; otherwise default-session
529
532
  // isolation is unknown and no Pi invocation is permitted.
@@ -4,7 +4,8 @@ import { appendFile, lstat, mkdir, readFile, realpath, stat, } from "node:fs/pro
4
4
  import path from "node:path";
5
5
  import { processTreeSpawnOptions, terminateProcessTree, } from "./process-tree.js";
6
6
  import { resolvePlaywrightCliLauncher } from "./playwright-cli-launcher.js";
7
- import { PLAYWRIGHT_CLI_ALLOWED_COMMANDS, isPlaywrightCliCommand, } from "../shared/playwright-cli-command-policy.js";
7
+ import { PLAYWRIGHT_CLI_ALLOWED_COMMANDS, isPlaywrightCliCommand, parsePlaywrightCliFindMatchCount, } from "../shared/playwright-cli-command-policy.js";
8
+ import { defaultFrontendTestLayout, escapeRegexLiteral, } from "../workflows/dag/frontend-test-layout.js";
8
9
  export { PLAYWRIGHT_CLI_ALLOWED_COMMANDS, isPlaywrightCliCommand, };
9
10
  /** Logical capability name only; execution always uses the verified JS launcher. */
10
11
  export const PLAYWRIGHT_CLI_EXECUTABLE = "playwright-cli";
@@ -144,8 +145,10 @@ export function summarizePlaywrightCliReceipts(receipts) {
144
145
  if (PLAYWRIGHT_CLI_INTERACTION_COMMANDS.has(receipt.command) && succeeded) {
145
146
  hasInteractionOrAssertion = true;
146
147
  }
147
- // A successful find is the only model-visible semantic assertion in v1.
148
- if (receipt.command === "find" && succeeded) {
148
+ // A successful find (or find-unique, its semantic wrapper) is the only
149
+ // model-visible semantic assertion in v1.
150
+ if ((receipt.command === "find" || receipt.command === "find-unique") &&
151
+ succeeded) {
149
152
  hasMeaningfulAssertion = true;
150
153
  if (ordered && sawOpen)
151
154
  sawMeaningfulAssertionAfterOpen = true;
@@ -265,7 +268,8 @@ function validateOpenArgs(args, baseUrl) {
265
268
  }
266
269
  function resolveEvidenceRelative(rawPath, ctx) {
267
270
  const evidenceDir = normalizePosix(ctx.evidenceDir).replace(/\/$/, "");
268
- const evidencePrefix = `testcase/frontend/evidence/${ctx.caseId}`;
271
+ const layout = ctx.layout ?? defaultFrontendTestLayout();
272
+ const evidencePrefix = `${layout.evidenceDir}/${ctx.caseId}`;
269
273
  if (!(evidenceDir === evidencePrefix ||
270
274
  evidenceDir.startsWith(`${evidencePrefix}/`))) {
271
275
  throw new PlaywrightCliPolicyError("evidence-dir-invalid", `evidenceDir must be under ${evidencePrefix}`);
@@ -289,7 +293,7 @@ function resolveEvidenceRelative(rawPath, ctx) {
289
293
  if (candidate.startsWith(`${evidenceDir}/`) || candidate === evidenceDir) {
290
294
  return candidate;
291
295
  }
292
- if (candidate.startsWith("testcase/frontend/evidence/") &&
296
+ if (candidate.startsWith(`${layout.evidenceDir}/`) &&
293
297
  !candidate.startsWith(`${evidencePrefix}/`)) {
294
298
  throw new PlaywrightCliPolicyError("path-cross-case", "output path escapes current case evidenceDir");
295
299
  }
@@ -470,11 +474,12 @@ function assertInputPathAllowed(rawPath, ctx) {
470
474
  if (!isSafeRelativePosix(candidate)) {
471
475
  throw new PlaywrightCliPolicyError("input-path-unsafe", `unsafe input path: ${rawPath}`);
472
476
  }
477
+ const layout = ctx.layout ?? defaultFrontendTestLayout();
473
478
  const allowedPrefixes = [
474
- `testcase/frontend/evidence/${ctx.caseId}/`,
475
- "testcase/frontend/cases/",
476
- "testcase/frontend/rag/",
477
- "testcase/frontend/fixtures/",
479
+ `${layout.evidenceDir}/${ctx.caseId}/`,
480
+ `${layout.casesDir}/`,
481
+ `${layout.ragDir}/`,
482
+ `${layout.fixturesDir}/`,
478
483
  ];
479
484
  if (!allowedPrefixes.some((prefix) => candidate === prefix.slice(0, -1) || candidate.startsWith(prefix)) &&
480
485
  !candidate.startsWith(normalizePosix(ctx.evidenceDir) + "/")) {
@@ -530,6 +535,17 @@ export function preparePlaywrightCliArgv(input, ctx) {
530
535
  else if (command === "pdf" || command === "snapshot") {
531
536
  args = normalizePdfOrSnapshotArgs(command, rawArgs, ctx);
532
537
  }
538
+ else if (command === "find-unique") {
539
+ // find-unique is a controller-side semantic wrapper: the underlying CLI
540
+ // only knows `find`; uniqueness (matchCount === 1) is judged by the
541
+ // executor after parsing the find output. Require exactly one text
542
+ // literal — a unique-control assertion is meaningless with multiple.
543
+ const literals = rawArgs.filter((arg) => !arg.startsWith("-"));
544
+ if (rawArgs.length !== 1 || literals.length !== 1) {
545
+ throw new PlaywrightCliPolicyError("find-unique-args", "find-unique requires exactly one text literal and no flags");
546
+ }
547
+ args = literals;
548
+ }
533
549
  else {
534
550
  args = rewriteArgsForPaths(command, rawArgs, ctx);
535
551
  }
@@ -616,8 +632,8 @@ async function validatePlaywrightCliOutputPath(command, args, ctx) {
616
632
  if (!outputFilename)
617
633
  return;
618
634
  const repoRoot = path.resolve(ctx.repoRoot);
619
- const caseEvidenceRoot = path.resolve(repoRoot, "testcase", "frontend", "evidence", ctx.caseId);
620
- const evidenceDir = path.resolve(repoRoot, ctx.evidenceDir);
635
+ const caseEvidenceRoot = path.resolve(repoRoot, ctx.evidenceDir);
636
+ const evidenceDir = caseEvidenceRoot;
621
637
  const target = path.resolve(repoRoot, outputFilename);
622
638
  if (!pathIsContained(repoRoot, caseEvidenceRoot) ||
623
639
  !pathIsContained(caseEvidenceRoot, evidenceDir) ||
@@ -825,7 +841,10 @@ export async function executePlaywrightCliCommand(input, ctx, signal) {
825
841
  };
826
842
  }
827
843
  let executable = PLAYWRIGHT_CLI_EXECUTABLE;
828
- let argv = [command, ...args];
844
+ // find-unique executes as the underlying CLI `find` command; uniqueness is
845
+ // a controller-side judgment on the parsed match count.
846
+ const cliCommand = command === "find-unique" ? "find" : command;
847
+ let argv = [cliCommand, ...args];
829
848
  try {
830
849
  await validatePlaywrightCliOutputPath(command, args, ctx);
831
850
  await validatePlaywrightCliInputFiles(command, args, ctx);
@@ -834,7 +853,7 @@ export async function executePlaywrightCliCommand(input, ctx, signal) {
834
853
  if (!ctx.runCommand) {
835
854
  const launcher = resolvePlaywrightCliLauncher(ctx.repoRoot);
836
855
  executable = launcher.executable;
837
- argv = [...launcher.argvPrefix, command, ...args];
856
+ argv = [...launcher.argvPrefix, cliCommand, ...args];
838
857
  }
839
858
  }
840
859
  catch (error) {
@@ -894,6 +913,19 @@ export async function executePlaywrightCliCommand(input, ctx, signal) {
894
913
  }
895
914
  const finishedAt = new Date();
896
915
  const combined = `${runResult.stdout}\n${runResult.stderr}`;
916
+ // find-family commands report their match count in stdout with exit code
917
+ // 0 even on zero matches — parse the count so receipts carry the real
918
+ // assertion signal for finalize-side audits.
919
+ const matchCount = command === "find" || command === "find-unique"
920
+ ? parsePlaywrightCliFindMatchCount(runResult.stdout || "")
921
+ : undefined;
922
+ // find-unique fails closed unless exactly one match was found (including
923
+ // zero and unparseable outputs).
924
+ const findUniqueFailed = command === "find-unique" && matchCount !== 1 && !runResult.timedOut;
925
+ const effectiveExitCode = findUniqueFailed && runResult.exitCode === 0 ? 1 : runResult.exitCode;
926
+ const effectiveStderr = findUniqueFailed
927
+ ? `${runResult.stderr}${runResult.stderr ? "\n" : ""}find-unique: expected exactly 1 match, got ${matchCount === undefined ? "unparseable output" : `${matchCount}`}`
928
+ : runResult.stderr;
897
929
  const receipt = {
898
930
  schemaVersion: 1,
899
931
  caseId: ctx.caseId,
@@ -902,25 +934,28 @@ export async function executePlaywrightCliCommand(input, ctx, signal) {
902
934
  argsRedacted: redactPlaywrightCliArgs(command, args),
903
935
  startedAt: startedAt.toISOString(),
904
936
  finishedAt: finishedAt.toISOString(),
905
- exitCode: runResult.exitCode,
937
+ exitCode: effectiveExitCode,
906
938
  durationMs: finishedAt.getTime() - startedAt.getTime(),
907
939
  outputSha256: createHash("sha256").update(combined).digest("hex"),
908
940
  toolVersion: ctx.toolVersion ?? "unknown",
909
941
  timedOut: runResult.timedOut || undefined,
942
+ ...(matchCount !== undefined ? { matchCount } : {}),
910
943
  errorClass: runResult.timedOut
911
944
  ? "timeout"
912
945
  : runResult.signal === "abort"
913
946
  ? "abort"
914
- : runResult.exitCode === 0
947
+ : effectiveExitCode === 0
915
948
  ? undefined
916
- : "nonzero-exit",
949
+ : findUniqueFailed
950
+ ? "find-unique-count-mismatch"
951
+ : "nonzero-exit",
917
952
  };
918
953
  await appendReceipt(ctx, receipt);
919
954
  return {
920
- ok: runResult.exitCode === 0 && !runResult.timedOut,
921
- exitCode: runResult.exitCode,
955
+ ok: effectiveExitCode === 0 && !runResult.timedOut,
956
+ exitCode: effectiveExitCode,
922
957
  stdout: runResult.stdout,
923
- stderr: runResult.stderr,
958
+ stderr: effectiveStderr,
924
959
  timedOut: runResult.timedOut,
925
960
  receipt,
926
961
  errorClass: receipt.errorClass,
@@ -992,22 +1027,26 @@ export async function createPlaywrightCliTool(ctx) {
992
1027
  export const PI_COMMAND_CAPABILITY_REGISTRY = {
993
1028
  "playwright-cli": createPlaywrightCliTool,
994
1029
  };
995
- export function resolveCaseIdFromWriteSet(writeSet) {
1030
+ export function resolveCaseIdFromWriteSet(writeSet, layout = defaultFrontendTestLayout()) {
1031
+ const prefix = layout.evidenceDir.replace(/\/$/, "");
1032
+ const escaped = escapeRegexLiteral(prefix);
1033
+ const exact = new RegExp(`^${escaped}/([^/]+)(?:/\\*\\*)?$`);
1034
+ const nested = new RegExp(`^${escaped}/([^/]+)/`);
996
1035
  for (const entry of writeSet ?? []) {
997
1036
  const normalized = normalizePosix(entry);
998
- const match = normalized.match(/^testcase\/frontend\/evidence\/([^/]+)(?:\/\*\*)?$/);
1037
+ const match = normalized.match(exact);
999
1038
  if (match?.[1] && match[1] !== "**" && match[1] !== "*") {
1000
1039
  return match[1];
1001
1040
  }
1002
- const nested = normalized.match(/^testcase\/frontend\/evidence\/([^/]+)\//);
1003
- if (nested?.[1])
1004
- return nested[1];
1041
+ const nestedMatch = normalized.match(nested);
1042
+ if (nestedMatch?.[1])
1043
+ return nestedMatch[1];
1005
1044
  }
1006
1045
  return undefined;
1007
1046
  }
1008
- export function resolveEvidenceDirFromWriteSet(writeSet) {
1009
- const caseId = resolveCaseIdFromWriteSet(writeSet);
1047
+ export function resolveEvidenceDirFromWriteSet(writeSet, layout = defaultFrontendTestLayout()) {
1048
+ const caseId = resolveCaseIdFromWriteSet(writeSet, layout);
1010
1049
  if (!caseId)
1011
1050
  return undefined;
1012
- return `testcase/frontend/evidence/${caseId}`;
1051
+ return `${layout.evidenceDir}/${caseId}`;
1013
1052
  }
@@ -30,6 +30,7 @@ import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } f
30
30
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
31
31
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
32
32
  import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
33
+ import { frontendTestLayoutFromSpec, } from "../workflows/dag/frontend-test-layout.js";
33
34
  import { backendTestGapDocSchema, ingestBackendTestGap, } from "../workflows/dag/backend-test-gap-fill.js";
34
35
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
35
36
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
@@ -2019,10 +2020,12 @@ async function executeFrontendVerificationBundle(input, meta) {
2019
2020
  async function executeFrontendTestCaseChecklist(input, meta) {
2020
2021
  const started = Date.now();
2021
2022
  try {
2023
+ const layout = frontendTestLayoutFromSpec(meta.spec);
2022
2024
  const declaredAcIds = (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
2023
2025
  const result = await validateFrontendCaseChecklist({
2024
2026
  workspaceRoot: input.cwd,
2025
2027
  declaredAcIds,
2028
+ layout,
2026
2029
  });
2027
2030
  if (result.issues.length) {
2028
2031
  return {
@@ -2057,6 +2060,7 @@ async function executeFrontendTestL5Report(input, meta) {
2057
2060
  const output = await renderFrontendTestL5Report({
2058
2061
  workspaceRoot: input.cwd,
2059
2062
  runDir: meta.runDir,
2063
+ layout: frontendTestLayoutFromSpec(meta.spec),
2060
2064
  });
2061
2065
  return {
2062
2066
  ok: true,
@@ -2082,6 +2086,7 @@ async function executeFrontendTestHtmlReport(input, meta) {
2082
2086
  const output = await renderFrontendTestHtmlReport({
2083
2087
  workspaceRoot: input.cwd,
2084
2088
  runDir: meta.runDir,
2089
+ layout: frontendTestLayoutFromSpec(meta.spec),
2085
2090
  });
2086
2091
  return {
2087
2092
  ok: true,
@@ -2101,11 +2106,12 @@ async function executeFrontendTestHtmlReport(input, meta) {
2101
2106
  };
2102
2107
  }
2103
2108
  }
2104
- async function executeFrontendTestStandardScenarios(input) {
2109
+ async function executeFrontendTestStandardScenarios(input, meta) {
2105
2110
  const started = Date.now();
2106
2111
  try {
2107
2112
  const result = await copyFrontendTestStandardScenarios({
2108
2113
  workspaceRoot: input.cwd,
2114
+ layout: frontendTestLayoutFromSpec(meta.spec),
2109
2115
  });
2110
2116
  return {
2111
2117
  ok: true,
@@ -2125,10 +2131,11 @@ async function executeFrontendTestStandardScenarios(input) {
2125
2131
  };
2126
2132
  }
2127
2133
  }
2128
- async function executeFrontendTestEnvironmentProbe(input) {
2134
+ async function executeFrontendTestEnvironmentProbe(input, meta) {
2129
2135
  const started = Date.now();
2130
2136
  const result = await probeFrontendTestEnvironment({
2131
2137
  workspaceRoot: input.cwd,
2138
+ layout: frontendTestLayoutFromSpec(meta.spec),
2132
2139
  });
2133
2140
  if (result.ok) {
2134
2141
  return {
@@ -2147,11 +2154,12 @@ async function executeFrontendTestEnvironmentProbe(input) {
2147
2154
  durationMs: Date.now() - started,
2148
2155
  };
2149
2156
  }
2150
- async function executeFrontendTestEvidenceValidation(input) {
2157
+ async function executeFrontendTestEvidenceValidation(input, meta) {
2151
2158
  const started = Date.now();
2152
2159
  try {
2153
2160
  const result = await validateFrontendCaseEvidence({
2154
2161
  workspaceRoot: input.cwd,
2162
+ layout: frontendTestLayoutFromSpec(meta.spec),
2155
2163
  });
2156
2164
  const output = `frontend case evidence validation cases=${result.cases} findings=${result.issues.length}${result.issues.length ? ` issues=${JSON.stringify(result.issues)}` : ""}`;
2157
2165
  if (result.hardFail) {
@@ -2190,6 +2198,7 @@ async function executeFrontendTestCaseManifest(input, meta) {
2190
2198
  const checklist = await validateFrontendCaseChecklist({
2191
2199
  workspaceRoot: input.cwd,
2192
2200
  declaredAcIds,
2201
+ layout: frontendTestLayoutFromSpec(meta.spec),
2193
2202
  });
2194
2203
  if (checklist.issues.length) {
2195
2204
  return {
@@ -2204,6 +2213,7 @@ async function executeFrontendTestCaseManifest(input, meta) {
2204
2213
  workspaceRoot: input.cwd,
2205
2214
  maxCases: gate.maxCases,
2206
2215
  declaredAcIds,
2216
+ layout: frontendTestLayoutFromSpec(meta.spec),
2207
2217
  });
2208
2218
  return {
2209
2219
  ok: true,
@@ -2242,6 +2252,7 @@ async function executeFrontendTestResultFinalize(input, meta) {
2242
2252
  try {
2243
2253
  const evidence = await validateFrontendCaseEvidence({
2244
2254
  workspaceRoot: input.cwd,
2255
+ layout: frontendTestLayoutFromSpec(meta.spec),
2245
2256
  });
2246
2257
  advisoryStdout = `frontend case evidence validation cases=${evidence.cases} findings=${evidence.issues.length}${evidence.issues.length ? ` issues=${JSON.stringify(evidence.issues)}` : ""}`;
2247
2258
  // Advisory findings must not block materialize; only hardFail (path escape) does.
@@ -2260,6 +2271,7 @@ async function executeFrontendTestResultFinalize(input, meta) {
2260
2271
  artifactName: "frontend-test-result.json",
2261
2272
  outputDir: "contracts",
2262
2273
  sourceBinding: meta.spec.sourceBinding,
2274
+ layout: frontendTestLayoutFromSpec(meta.spec),
2263
2275
  });
2264
2276
  return {
2265
2277
  ok: true,
@@ -2298,12 +2310,14 @@ async function executeFrontendTestReports(input, meta) {
2298
2310
  const l5 = await renderFrontendTestL5Report({
2299
2311
  workspaceRoot: input.cwd,
2300
2312
  runDir: meta.runDir,
2313
+ layout: frontendTestLayoutFromSpec(meta.spec),
2301
2314
  });
2302
2315
  lines.push(`Frontend L-5 report: ${l5.htmlPath}\nMarkdown: ${l5.markdownPath}\nStatus: ${l5.metrics.status}`);
2303
2316
  }
2304
2317
  const report = await renderFrontendTestHtmlReport({
2305
2318
  workspaceRoot: input.cwd,
2306
2319
  runDir: meta.runDir,
2320
+ layout: frontendTestLayoutFromSpec(meta.spec),
2307
2321
  });
2308
2322
  lines.push(`Frontend test report: ${report.htmlPath}\nMarkdown: ${report.markdownPath}\nOutcome: ${report.outcome}\nCases: ${report.caseCount}`);
2309
2323
  return {
@@ -2495,10 +2509,10 @@ async function executeDagShellNodeInner(input, meta) {
2495
2509
  }
2496
2510
  }
2497
2511
  if (shell?.frontendTestStandardScenarios) {
2498
- return executeFrontendTestStandardScenarios(input);
2512
+ return executeFrontendTestStandardScenarios(input, meta);
2499
2513
  }
2500
2514
  if (shell?.frontendTestEnvironmentProbe) {
2501
- return executeFrontendTestEnvironmentProbe(input);
2515
+ return executeFrontendTestEnvironmentProbe(input, meta);
2502
2516
  }
2503
2517
  if (shell?.frontendBrowserToolPreflight) {
2504
2518
  const started = Date.now();
@@ -2554,14 +2568,16 @@ async function executeDagShellNodeInner(input, meta) {
2554
2568
  label: "Pi SDK custom-tool 能力",
2555
2569
  ok: true,
2556
2570
  });
2571
+ const layout = frontendTestLayoutFromSpec(meta.spec);
2557
2572
  const toolProbe = await createPlaywrightCliTool({
2558
2573
  repoRoot: input.cwd,
2559
2574
  runDir: meta.runDir,
2560
2575
  nodeId: input.task.id,
2561
2576
  caseId: "frontend-browser-preflight",
2562
- evidenceDir: "testcase/frontend/evidence/frontend-browser-preflight",
2577
+ evidenceDir: `${layout.evidenceDir}/frontend-browser-preflight`,
2563
2578
  baseUrl: "http://localhost:5173/",
2564
- inputRoot: "testcase/frontend/fixtures",
2579
+ inputRoot: layout.fixturesDir,
2580
+ layout,
2565
2581
  });
2566
2582
  if (toolProbe.name !== "playwright_cli") {
2567
2583
  steps.push({
@@ -2703,7 +2719,7 @@ async function executeDagShellNodeInner(input, meta) {
2703
2719
  return executeFrontendTestCaseManifest(input, meta);
2704
2720
  }
2705
2721
  if (shell?.frontendTestEvidenceValidation) {
2706
- return executeFrontendTestEvidenceValidation(input);
2722
+ return executeFrontendTestEvidenceValidation(input, meta);
2707
2723
  }
2708
2724
  if (shell?.frontendTestResultFinalize) {
2709
2725
  return executeFrontendTestResultFinalize(input, meta);
@@ -2810,6 +2826,7 @@ async function executeDagShellNodeInner(input, meta) {
2810
2826
  artifactName: gate.artifactName,
2811
2827
  outputDir: gate.outputDir,
2812
2828
  sourceBinding: meta.spec.sourceBinding,
2829
+ layout: frontendTestLayoutFromSpec(meta.spec),
2813
2830
  });
2814
2831
  break;
2815
2832
  default:
@@ -9,6 +9,7 @@ export const PLAYWRIGHT_CLI_ALLOWED_COMMANDS = [
9
9
  "goto",
10
10
  "snapshot",
11
11
  "find",
12
+ "find-unique",
12
13
  "click",
13
14
  "dblclick",
14
15
  "fill",
@@ -35,6 +36,20 @@ export const PLAYWRIGHT_CLI_ALLOWED_COMMANDS = [
35
36
  "requests",
36
37
  "request",
37
38
  ];
39
+ /**
40
+ * Parse the match count from a playwright-cli find output. The CLI prints
41
+ * `Found N match(es) for "...":` on success and `No matches found for "...".`
42
+ * on zero — both with exit code 0, so the count is the only assertion signal.
43
+ * Returns undefined when the output shape is unrecognized (e.g. error pages).
44
+ */
45
+ export function parsePlaywrightCliFindMatchCount(output) {
46
+ const found = /Found\s+(\d+)\s+match(?:es)?\s+for\b/i.exec(output);
47
+ if (found)
48
+ return Number(found[1]);
49
+ if (/No matches found for\b/i.test(output))
50
+ return 0;
51
+ return undefined;
52
+ }
38
53
  const allowedCommandSet = new Set(PLAYWRIGHT_CLI_ALLOWED_COMMANDS);
39
54
  export function isPlaywrightCliCommand(value) {
40
55
  return allowedCommandSet.has(value);
@@ -87,6 +87,12 @@ export const frontendOpenspecConfigSchema = z.object({
87
87
  /** Batch limits and gate profile for the browser-driven frontend test DAG.
88
88
  * Token fields are post-case stop thresholds, not model-provider hard caps. */
89
89
  export const frontendTestConfigSchema = z.object({
90
+ /**
91
+ * Repository-relative frontend-test artifact root.
92
+ * Default "testcase/frontend". Semantic validation lives in
93
+ * resolveFrontendTestLayout (fail-closed), not in this schema.
94
+ */
95
+ testRoot: z.string().min(1).optional(),
90
96
  maxCasesPerBatch: z.number().int().min(1).max(50).optional(),
91
97
  maxTokensPerCase: z.number().int().positive().optional(),
92
98
  maxTotalTokens: z.number().int().positive().optional(),
@@ -35,8 +35,15 @@ const HEADING_ALIASES = {
35
35
  assumptions: new Set(["假设", "assumptions"]),
36
36
  };
37
37
  const OBJECTIVE_MAX_CHARS = 1200;
38
- /** Product Requirement V3/V4 style embedded AC heading, e.g. AC-FE-001 / AC-BE-12 / AC-001. */
39
- const EMBEDDED_AC_HEADING = /^(AC(?:[-_](?:FE|BE|UI|API))?[-_]?\d+(?:[-_]\d+)?)\s*[::]?\s*(.*)$/iu;
38
+ /**
39
+ * Product Requirement V3/V4 style embedded AC heading, e.g. AC-FE-001 /
40
+ * AC-BE-12 / AC-001 — and catalog-qualified feature segments such as
41
+ * AC-FE-INS-001 / AC-FE-CHAT-005 (uppercase letter/digit segments).
42
+ * Catalog ids embedded in bold (“**AC-FE-INS-001**:…”) must bind as-is;
43
+ * falling through to a minted AC-00N historically produced dual ids and
44
+ * acceptanceCoverage.missing noise (2026-08-23 fe-test loop finding 5).
45
+ */
46
+ const EMBEDDED_AC_HEADING = /^(AC(?:[-_](?:FE|BE|UI|API))?[-_]?[A-Z0-9]+(?:[-_][A-Z0-9]+)*)\s*[::]?\s*(.*)$/u;
40
47
  function stripFencedAndComments(text) {
41
48
  // Remove fenced code blocks and HTML comments so headings inside them are ignored.
42
49
  return text
@@ -52,12 +59,25 @@ function normalizeAcceptanceId(raw) {
52
59
  .replace(/^(AC)(FE|BE|UI|API)(?=-|\d)/u, "$1-$2");
53
60
  }
54
61
  function parseEmbeddedAcHeading(headingText) {
55
- const normalized = headingText.trim().replace(/^(?:\d+(?:\.\d+)*[..、]?\s*)+/u, "");
62
+ const normalized = headingText
63
+ .trim()
64
+ // Strip leading section numbers ("2.1、" style) and bold/emphasis
65
+ // wrappers ("**AC-FE-INS-001**") so embedded catalog ids bind.
66
+ .replace(/^(?:\d+(?:\.\d+)*[..、]?\s*)+/u, "")
67
+ .replace(/^\*\*|\*\*$/g, "")
68
+ .replace(/^__|__$/g, "")
69
+ .trim();
56
70
  const match = EMBEDDED_AC_HEADING.exec(normalized);
57
71
  if (!match)
58
72
  return null;
59
73
  const id = normalizeAcceptanceId(match[1]);
60
- const text = match[2]?.trim() || id;
74
+ // Bold-wrapped ids leave their closing marker inside the captured text
75
+ // ("**AC-FE-INS-001**:xxx" captures "**:xxx"); strip emphasis remnants
76
+ // and leading separators so the bound text is clean prose.
77
+ const text = (match[2] ?? "")
78
+ .replace(/^[*_]+\s*/, "")
79
+ .replace(/^\s*[::]\s*/, "")
80
+ .trim() || id;
61
81
  return { id, text };
62
82
  }
63
83
  function normalizeHeadingText(raw) {
@@ -1,6 +1,13 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { open, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ /** 新建模型时,未显式提供的字段使用的默认值(仅含 6 个定义参数)。 */
5
+ const DEFAULT_MODEL_FIELDS = {
6
+ contextWindow: 32000,
7
+ maxTokens: 8192,
8
+ reasoning: false,
9
+ input: ["text"],
10
+ };
4
11
  const revision = (raw) => createHash("sha256").update(raw).digest("hex");
5
12
  export async function readModelsConfig(agentDir) {
6
13
  let raw = "{}";
@@ -29,6 +36,18 @@ export async function readModelsConfig(agentDir) {
29
36
  return {
30
37
  id: String(item.id ?? ""),
31
38
  name: typeof item.name === "string" ? item.name : undefined,
39
+ contextWindow: typeof item.contextWindow === "number"
40
+ ? item.contextWindow
41
+ : undefined,
42
+ maxTokens: typeof item.maxTokens === "number"
43
+ ? item.maxTokens
44
+ : undefined,
45
+ input: Array.isArray(item.input)
46
+ ? item.input.filter((entry) => typeof entry === "string")
47
+ : undefined,
48
+ reasoning: typeof item.reasoning === "boolean"
49
+ ? item.reasoning
50
+ : undefined,
32
51
  };
33
52
  })
34
53
  .filter((model) => model.id)
@@ -81,19 +100,25 @@ export async function patchModelsConfig(agentDir, input) {
81
100
  code: "INVALID_INPUT",
82
101
  status: 400,
83
102
  });
103
+ const fields = validateModelFields(input.model);
104
+ const existingIndex = models.findIndex((model) => model.id === input.model.id);
105
+ const merged = {
106
+ // id/name 在前;新建模型用默认字段补齐(保证定义完整),更新保留已有值。
107
+ id: input.model.id,
108
+ ...(fields.name !== undefined ? { name: fields.name } : {}),
109
+ ...(existingIndex >= 0 ? models[existingIndex] : DEFAULT_MODEL_FIELDS),
110
+ ...fields,
111
+ };
84
112
  provider.models =
85
113
  input.action === "remove-model"
86
114
  ? models.filter((model) => model.id !== input.model.id)
87
- : [
88
- ...models.filter((model) => model.id !== input.model.id),
89
- {
90
- ...(models.find((model) => model.id === input.model.id) ?? {}),
91
- id: input.model.id,
92
- ...(input.model.name !== undefined
93
- ? { name: input.model.name }
94
- : {}),
95
- },
96
- ];
115
+ : existingIndex >= 0
116
+ ? [
117
+ ...models.slice(0, existingIndex),
118
+ merged,
119
+ ...models.slice(existingIndex + 1),
120
+ ]
121
+ : [...models, merged];
97
122
  }
98
123
  const body = `${JSON.stringify(parsed, null, 2)}\n`;
99
124
  const tmp = path.join(agentDir, `.models.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
@@ -108,6 +133,52 @@ export async function patchModelsConfig(agentDir, input) {
108
133
  await rename(tmp, file);
109
134
  return readModelsConfig(agentDir);
110
135
  }
136
+ /** Whitelist + validate model fields carried by a client upsert. Never accept
137
+ * arbitrary keys; only known numeric/boolean/string-array shape fields. */
138
+ function validateModelFields(model) {
139
+ const fields = {};
140
+ if (model.name !== undefined)
141
+ fields.name = String(model.name);
142
+ if (model.contextWindow !== undefined) {
143
+ if (typeof model.contextWindow !== "number" || model.contextWindow < 0)
144
+ throw invalid("contextWindow must be a non-negative number");
145
+ fields.contextWindow = model.contextWindow;
146
+ }
147
+ if (model.maxTokens !== undefined) {
148
+ if (typeof model.maxTokens !== "number" || model.maxTokens < 0)
149
+ throw invalid("maxTokens must be a non-negative number");
150
+ fields.maxTokens = model.maxTokens;
151
+ }
152
+ if (model.reasoning !== undefined) {
153
+ if (typeof model.reasoning !== "boolean")
154
+ throw invalid("reasoning must be a boolean");
155
+ fields.reasoning = model.reasoning;
156
+ }
157
+ if (model.input !== undefined) {
158
+ if (!Array.isArray(model.input) ||
159
+ !model.input.every((item) => typeof item === "string"))
160
+ throw invalid("input must be an array of strings");
161
+ fields.input = model.input.map((item) => String(item));
162
+ }
163
+ if (model.cost !== undefined) {
164
+ if (!model.cost || typeof model.cost !== "object")
165
+ throw invalid("cost must be an object");
166
+ const cost = {};
167
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"]) {
168
+ const value = model.cost[key];
169
+ if (value !== undefined) {
170
+ if (typeof value !== "number" || value < 0)
171
+ throw invalid(`cost.${key} must be a non-negative number`);
172
+ cost[key] = value;
173
+ }
174
+ }
175
+ fields.cost = cost;
176
+ }
177
+ return fields;
178
+ }
179
+ function invalid(message) {
180
+ return Object.assign(new Error(message), { code: "INVALID_INPUT", status: 400 });
181
+ }
111
182
  /** Max bytes of a package.json we are willing to read (read-only, bounded). */
112
183
  export const MAX_PACKAGE_JSON_BYTES = 64 * 1024;
113
184
  /** Max directory levels walked upward from an owned resolved resource path. */