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

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 (85) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/build-stamp.json +2 -2
  3. package/dist/executors/pi-playwright-cli-tool.js +43 -11
  4. package/dist/shared/playwright-cli-command-policy.js +15 -0
  5. package/dist/task/source-prepare/parse-intent.js +24 -4
  6. package/dist/worker/console/dag-execution-receipt.js +33 -0
  7. package/dist/worker/console/operator-user-error.js +1 -1
  8. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CiGOV5eG.js → abnfDiagram-N423BO3Z-gauhD9kD.js} +1 -1
  9. package/dist/worker/console/static/assets/{arc-Ds9Gyfd8.js → arc-pm3c2Rsl.js} +1 -1
  10. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C6V6uC98.js → architectureDiagram-T3A2C74G-D_JAg3wH.js} +1 -1
  11. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DezF7Ol9.js → blockDiagram-VBNYF7ZC-Dk7-ex52.js} +1 -1
  12. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BCWk3gCT.js → c4Diagram-5PPSVZJV-Uval9oRI.js} +1 -1
  13. package/dist/worker/console/static/assets/channel-CVrnUdZl.js +1 -0
  14. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DbYr-Cxy.js → chunk-2GRJ4B5K-BSYjHs-v.js} +1 -1
  15. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-XSlusW4G.js → chunk-2Q5K7J3B-KGUuBXI8.js} +1 -1
  16. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BtcKAj9i.js → chunk-5RXB4S5H-JqAtxD9N.js} +1 -1
  17. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BF57lhXj.js → chunk-5VM5RSS4-D6EJOVQK.js} +1 -1
  18. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DJIlR25o.js → chunk-6Q2QTUOP-B1JriSjm.js} +1 -1
  19. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DklNc7dm.js → chunk-GF5L2VYU-D62b96Jd.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-fZNtEdQZ.js → chunk-JWPE2WC7-BsNKEC_r.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BBgLqV4h.js → chunk-KBJHAD2P-B56v6-ZP.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Dl9e5n_J.js → chunk-RYQCIY6F-D2KPnd8K.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-B0JK51NT.js → chunk-XXDRQBXY-kzKSCaaL.js} +1 -1
  24. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-aX91WTya.js +1 -0
  25. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-aX91WTya.js +1 -0
  26. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-Ch-ajv4O.js → cose-bilkent-JH36ORCC-C6N6KNJd.js} +1 -1
  27. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-xn_NxcfB.js → cynefin-VYW2F7L2-DiyymT2D.js} +1 -1
  28. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DRD0fyga.js → cynefinDiagram-MW4NZA55-BeKAXvf3.js} +1 -1
  29. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-COtnKp4B.js → dagre-VZM6K2ZE-TkIdXfPF.js} +1 -1
  30. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BQp_CAqG.js → diagram-7IWD3JNH-BFmSG3ks.js} +1 -1
  31. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-x0y-vNgq.js → diagram-B4RE2ZJO-DqpJT2Fs.js} +1 -1
  32. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-N2ezwL31.js → diagram-LBJQPF4R-r5ATN43t.js} +1 -1
  33. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-CSaxRH6Q.js → diagram-Q27KOJAE-DEcSCMuR.js} +1 -1
  34. package/dist/worker/console/static/assets/{diagram-UB23O5K3-6Ro7Kpbd.js → diagram-UB23O5K3-VOvYRLVc.js} +1 -1
  35. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CrIIQEAI.js → ebnfDiagram-BXEA7PRR-D7f4Cb1u.js} +1 -1
  36. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DivKcq8g.js → erDiagram-JOGREHBK-ziUf3U0G.js} +1 -1
  37. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BXrfDo8y.js → flowDiagram-UKHOOZJN-BoRdQ-1y.js} +1 -1
  38. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-LTTQmMRJ.js → ganttDiagram-PKOTCBZU-B1-GncNm.js} +1 -1
  39. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DP3o_5qd.js → gitGraphDiagram-DS77QQ5N-CTW-ph8W.js} +1 -1
  40. package/dist/worker/console/static/assets/{index-C2jmYBfu.js → index-DEbyrEfW.js} +2 -2
  41. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-wVfSY1WH.js → infoDiagram-6WML65LV-BFA337tZ.js} +1 -1
  42. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BRKfFL0I.js → ishikawaDiagram-WSZJBQD7-BtEujgRR.js} +1 -1
  43. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DEml3jLb.js → journeyDiagram-NVQOT4AX-C8ZwD2Jp.js} +1 -1
  44. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DTzBmOqN.js → kanban-definition-27J2QSJJ-Ck-z8B11.js} +1 -1
  45. package/dist/worker/console/static/assets/{linear-Cpy1d873.js → linear-CAyXjQjN.js} +1 -1
  46. package/dist/worker/console/static/assets/{mermaid.core-C1cSavOy.js → mermaid.core-CcWKn6tS.js} +5 -5
  47. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BEBSKvYM.js → mindmap-definition-FAOFIHXS-C2NuDO7E.js} +1 -1
  48. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BrLs4Oac.js → pegDiagram-VL7TDLO6-BFub3PDz.js} +1 -1
  49. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-B47AZcLa.js → pieDiagram-7S7Q4E2Y-BJx5THZ5.js} +1 -1
  50. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-s0dVeYR2.js → quadrantDiagram-CIZ2JOQS-DhalkDWV.js} +1 -1
  51. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-lW8aJ7Q6.js → railroadDiagram-AXF67PYL-CVcBM36o.js} +1 -1
  52. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BcHJPf6Q.js → requirementDiagram-LRYGKXZP-D_vy4n0w.js} +1 -1
  53. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DdegpUUf.js → sankeyDiagram-W5VNT64P-DGYdJL1D.js} +1 -1
  54. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BxFWqNv8.js → sequenceDiagram-SI44F4Z6-C1xJFg3k.js} +1 -1
  55. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-cOdUDPbR.js → sizeCapture-X5ZJPWSS-D_Fc43df.js} +1 -1
  56. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-DpxlYl8P.js → stateDiagram-OKZ733FA-BhGbIjbu.js} +1 -1
  57. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DUt1lyFL.js +1 -0
  58. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DQOfurE0.js → swimlanes-SLNWSIFB-DVkNcO-V.js} +2 -2
  59. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BM6SGr7j.js +8 -0
  60. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CIp3tC6V.js → timeline-definition-Z64GVDOM-BQU9ufV0.js} +1 -1
  61. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-C-adIPKn.js → vennDiagram-T6HMQDX7-BCQhRmYB.js} +1 -1
  62. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CiYrar9M.js → wardleyDiagram-T6FBY63Y-DWerOyHO.js} +1 -1
  63. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-WHrdTV0-.js → xychartDiagram-ELKLHX3M-DxLeebQU.js} +1 -1
  64. package/dist/worker/console/static/index.html +1 -1
  65. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  66. package/dist/worker/observe/static/views/failures.js +5 -2
  67. package/dist/workflows/dag/frontend-repair.js +14 -0
  68. package/dist/workflows/dag/frontend-risk.js +15 -2
  69. package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
  70. package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
  71. package/dist/workflows/dag/frontend-test-result-contract.js +151 -11
  72. package/dist/workflows/dag/frontend-verification-trace.js +29 -4
  73. package/dist/workflows/dag/init-hybrid.js +90 -14
  74. package/harness.json +2 -2
  75. package/package.json +1 -1
  76. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  77. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  78. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  79. package/skills/playwright-cli/SKILL.md +22 -0
  80. package/skills/playwright-cli-case-generator/SKILL.md +20 -1
  81. package/dist/worker/console/static/assets/channel-DMvu1nmi.js +0 -1
  82. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DVzM-02m.js +0 -1
  83. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DVzM-02m.js +0 -1
  84. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BwecGAkM.js +0 -1
  85. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D5WW_LbN.js +0 -8
@@ -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
  }
@@ -34,6 +34,7 @@ import { buildFrontendTestOutcomeGateShellSnippet } from "./frontend-test-result
34
34
  import { classifyFrontendRisk, } from "./frontend-risk.js";
35
35
  import { discoverFrontendProjectCapability, } from "./frontend-project-capability.js";
36
36
  import { buildFrontendImplementationContractSkeleton, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID, loadFrontendImplementationContractJsonSchema, } from "./frontend-implementation-contract.js";
37
+ import { FRONTEND_NO_VERIFICATION_MARKER_TEXT } from "./frontend-verification-trace.js";
37
38
  import { serializeDagTaskSourcePath } from "../../task/dag-source-paths.js";
38
39
  const REQUIREMENT_FILE = "需求.md";
39
40
  const CONSTRAINT_FILE = "执行约束.md";
@@ -1281,11 +1282,15 @@ function deriveParallelScoutPaths(taskConfig) {
1281
1282
  : allowed,
1282
1283
  };
1283
1284
  }
1285
+ const FRONTEND_NO_STATIC_VERIFICATION_MARKER = `node -e "console.log('${FRONTEND_NO_VERIFICATION_MARKER_TEXT}; static/behavior verification not-run')"`;
1284
1286
  /**
1285
1287
  * Resolve frontend verification fallbacks from the target project's own
1286
1288
  * package scripts. The DAG builder is also used by unit fixtures without a
1287
- * package.json, so those fixtures retain the historical generic fallback.
1288
- * Real projects never inherit loop-agent's commands when package.json exists.
1289
+ * repoRoot, so those fixtures retain the historical generic fallback. A real
1290
+ * project (repoRoot set) without a readable package.json has no scripts to
1291
+ * invoke; the generic fallback would only inject commands guaranteed to fail
1292
+ * at verify time, so such projects fall through to the tsc probe and may end
1293
+ * up with no fallback commands plus a generation-time advisory.
1289
1294
  */
1290
1295
  async function discoverFrontendFallbackVerifyCommands(repoRoot) {
1291
1296
  const genericFallback = {
@@ -1302,10 +1307,8 @@ async function discoverFrontendFallbackVerifyCommands(repoRoot) {
1302
1307
  }
1303
1308
  }
1304
1309
  catch {
1305
- return genericFallback;
1310
+ // No readable manifest: leave scripts unset and keep probing.
1306
1311
  }
1307
- if (!scripts)
1308
- return genericFallback;
1309
1312
  let packageManager = "npm";
1310
1313
  for (const [lockfile, manager] of [
1311
1314
  ["pnpm-lock.yaml", "pnpm"],
@@ -1382,19 +1385,66 @@ function toDagSourcePath(sources, absolutePath) {
1382
1385
  absolutePath,
1383
1386
  });
1384
1387
  }
1385
- function extractExplicitRequirementIds(...markdownInputs) {
1388
+ /**
1389
+ * Declaration-line shapes that assert a requirement id: list items
1390
+ * ("- AC-1: ..."), numbered/ordered acceptance entries ("1. AC-1 ...",
1391
+ * "1、AC-1"), table rows ("| AC-1 | ..."), and explicit key/value or
1392
+ * heading declarations ("AC-1:...", "id: AC-1"). Ids that merely appear
1393
+ * inside narrative prose (e.g. a fixture run id mentioned in background:
1394
+ * "夹具 run 2026-08-23-req-f03535aa 已存在") are incidental mentions, not
1395
+ * declared requirements, and must not pollute sourceBinding.requirementIds.
1396
+ */
1397
+ const REQUIREMENT_ID_DECLARATION_LINE = /^\s*(?:[-+*]\s+|\d+[.、))]\s*|\|\s*)?(?:[-A-Z0-9]+\s*[::]\s*)?(?:\*\*)?(?:REQ|BR|AC)-/i;
1398
+ function isRequirementIdDeclarationLine(line) {
1399
+ return REQUIREMENT_ID_DECLARATION_LINE.test(line);
1400
+ }
1401
+ function extractExplicitRequirementIds(requirementMarkdown, ...fallbackMarkdown) {
1386
1402
  const ids = [];
1387
1403
  const seen = new Set();
1388
- for (const markdown of markdownInputs) {
1404
+ const collect = (markdown, declarationsOnly) => {
1389
1405
  if (!markdown)
1390
- continue;
1391
- for (const match of markdown.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
1406
+ return;
1407
+ const source = declarationsOnly
1408
+ ? markdown
1409
+ .split(/\r?\n/)
1410
+ .filter((line) => isRequirementIdDeclarationLine(line))
1411
+ .join("\n")
1412
+ : markdown;
1413
+ for (const match of source.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
1392
1414
  const id = match[0].toUpperCase();
1393
1415
  if (!seen.has(id)) {
1394
1416
  seen.add(id);
1395
1417
  ids.push(id);
1396
1418
  }
1397
1419
  }
1420
+ };
1421
+ // The requirement document itself is human-authored acceptance prose:
1422
+ // only declaration lines declare requirements there. If the document has
1423
+ // no declaration-shaped lines at all (minimal free-form tasks like
1424
+ // "covers AC-001"), fall back to its full text so bindings never go
1425
+ // missing for unstructured input. References and constraints are bound
1426
+ // documents (acceptance yaml, analysis docs) whose ids are authoritative
1427
+ // wherever they appear — keep full scan for them.
1428
+ const declarationLines = requirementMarkdown
1429
+ .split(/\r?\n/)
1430
+ .filter((line) => isRequirementIdDeclarationLine(line));
1431
+ collect(requirementMarkdown, declarationLines.length > 0);
1432
+ for (const markdown of fallbackMarkdown) {
1433
+ if (markdown)
1434
+ collect(markdown, false);
1435
+ }
1436
+ return ids;
1437
+ }
1438
+ /** Extract ids from an explicit authoritative section (full scan inside it). */
1439
+ function extractSectionRequirementIds(sectionMarkdown) {
1440
+ const ids = [];
1441
+ const seen = new Set();
1442
+ for (const match of sectionMarkdown.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
1443
+ const id = match[0].toUpperCase();
1444
+ if (!seen.has(id)) {
1445
+ seen.add(id);
1446
+ ids.push(id);
1447
+ }
1398
1448
  }
1399
1449
  return ids;
1400
1450
  }
@@ -1408,7 +1458,7 @@ export function extractTaskScopedRequirementIds(requirementMarkdown, ...fallback
1408
1458
  // Without it, preserve legacy full-scan behavior (requirement + references).
1409
1459
  const sectionMatch = requirementMarkdown.match(/(?:^|\n)##\s*Acceptance References\s*\n([\s\S]*?)(?=\n##\s+|\n#\s+|$)/i);
1410
1460
  if (sectionMatch?.[1]) {
1411
- const fromSection = extractExplicitRequirementIds(sectionMatch[1]);
1461
+ const fromSection = extractSectionRequirementIds(sectionMatch[1]);
1412
1462
  if (fromSection.length > 0)
1413
1463
  return fromSection;
1414
1464
  }
@@ -2530,7 +2580,14 @@ async function buildFrontendHybridDagFromTask(sources) {
2530
2580
  return buildBlockedFrontendMockDag(frontendSources, readOnlyPaths, forbiddenPaths, globalConstraints, blockedReason);
2531
2581
  }
2532
2582
  const fallbackVerifyCommands = await discoverFrontendFallbackVerifyCommands(sources.repoRoot);
2533
- const staticFallbackCommands = fallbackVerifyCommands.staticCommands;
2583
+ // The verification bundle schema requires at least one static command. When
2584
+ // a real project exposes no usable verification command at all, run an
2585
+ // explicit no-op marker instead of a command that is guaranteed to fail:
2586
+ // the trace then records not-run honestly and the advisory asks the task
2587
+ // to declare verification commands and regenerate.
2588
+ const staticFallbackCommands = fallbackVerifyCommands.staticCommands.length > 0
2589
+ ? fallbackVerifyCommands.staticCommands
2590
+ : [FRONTEND_NO_STATIC_VERIFICATION_MARKER];
2534
2591
  const behaviorFallbackCommands = fallbackVerifyCommands.behaviorCommands;
2535
2592
  const parsedFrontendVerifyCommands = extractFrontendVerifyCommandsFromMarkdown({
2536
2593
  repoRoot: sources.repoRoot,
@@ -2629,6 +2686,11 @@ async function buildFrontendHybridDagFromTask(sources) {
2629
2686
  ...behaviorVerifyEvidence.commandLabels.map((command) => ` - ${JSON.stringify(command)}`),
2630
2687
  ].join("\n");
2631
2688
  const advisories = [];
2689
+ if (!hasDeclaredFrontendVerification &&
2690
+ fallbackVerifyCommands.staticCommands.length === 0 &&
2691
+ fallbackVerifyCommands.behaviorCommands.length === 0) {
2692
+ advisories.push("未发现可用的前端验证命令:目标项目没有可读取的 package.json scripts,也未探测到本地 TypeScript,verify 节点将没有静态/行为命令可执行。请在 task.json --verify 或执行约束.md 的验证约束中显式声明命令(例如 node --check src/app.js),然后重新生成 DAG。");
2693
+ }
2632
2694
  if (frontendSourceMentionsMock(frontendSources) &&
2633
2695
  frontendMockStrategyMustBeNotNeeded(frontendSources)) {
2634
2696
  advisories.push("auto 模式已将 Mock 策略收窄为 not-needed:任务源提到接口/API/Mock 需求,但仓库无确认 Mock 能力或无确定性 Mock 验证命令。若项目规范要求 Mock,请声明 frontendMock.verifyCommands 或 policy:required 后重新生成 DAG。");
@@ -4762,6 +4824,13 @@ function buildFrontendTestHybridDag(sources) {
4762
4824
  };
4763
4825
  const declaredRequirementIds = buildDagSourceBinding(sources).requirementIds;
4764
4826
  const declaredAcIds = declaredRequirementIds.filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
4827
+ // Optional project-local UI anchor ledger: only mention it in prompts when it
4828
+ // exists so ledger-less projects keep generating without noise.
4829
+ const hasUiAnchorsLedger = Boolean(sources.repoRoot &&
4830
+ existsSync(path.join(sources.repoRoot, "testcase/frontend/rag/ui-anchors.md")));
4831
+ const uiAnchorsLedgerInstruction = hasUiAnchorsLedger
4832
+ ? "Also read testcase/frontend/rag/ui-anchors.md (UI anchor ledger). Every passing find assertion in generated cases must quote a ledger row whose 状态 is 有效 for the matching page x state section; rows marked 不可断言/失效 must not be used as passing assertions. When an AC names a control absent from the ledger section for that state, emit a blocked case note (blockedReason unique-control-unavailable) instead of exploratory find steps, and follow ledger 备注 alternatives (split-node short literals) exactly."
4833
+ : "";
4765
4834
  const reviewMode = config.reviewMode;
4766
4835
  const blockingReview = reviewMode === "blocking";
4767
4836
  const strictOutcomeGate = config.strictOutcomeGate;
@@ -4802,6 +4871,8 @@ function buildFrontendTestHybridDag(sources) {
4802
4871
  "const caseIdRe=/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;",
4803
4872
  ,
4804
4873
  "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
4874
+ "const truncatedAcRe=/^AC-FE-[A-Z]+$/i;",
4875
+ "const truncatedUndeclaredAcRe=/^(?:AC-\\d{3}|AC-FE-[A-Z]+)$/i;",
4805
4876
  "for(const c of manifest.cases){",
4806
4877
  " const id=c&&c.caseId||'?';",
4807
4878
  " if(typeof c.caseId!=='string'||!caseIdRe.test(c.caseId))issues.push({ruleId:'case-id-shape',caseId:id,detail:'caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*'});",
@@ -4820,6 +4891,7 @@ function buildFrontendTestHybridDag(sources) {
4820
4891
  " if(typeof ac!=='string'){issues.push({ruleId:'ac-id-shape',caseId:id,detail:String(ac)+' must look like AC-FE-001'});continue;}",
4821
4892
  " if(/^FE-/i.test(ac)){issues.push({ruleId:'ac-id-is-case',caseId:id,detail:ac+' looks like caseId; acIds must be AC-*'});continue;}",
4822
4893
  " if(!acIdRe.test(ac)){issues.push({ruleId:'ac-id-shape',caseId:id,detail:ac+' must look like AC-FE-001'});continue;}",
4894
+ " if(truncatedAcRe.test(ac)||(declaredAc.size===0&&truncatedUndeclaredAcRe.test(ac))){issues.push({ruleId:'ac-id-truncated',caseId:id,detail:ac+' is a truncated acceptance id (missing feature prefix or sequence number)'});continue;}",
4823
4895
  " if(declaredAc.size>0&&!declaredAc.has(ac))issues.push({ruleId:'unknown-ac',caseId:id,detail:ac+' not in task sourceBinding.requirementIds'});",
4824
4896
  " }",
4825
4897
  " }",
@@ -4851,6 +4923,8 @@ function buildFrontendTestHybridDag(sources) {
4851
4923
  `const declaredAc=new Set(${declaredAcIdsLiteral});`,
4852
4924
  "const seen=new Set(); const seenCasePath=new Set(); const seenEvidenceDir=new Set();",
4853
4925
  "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
4926
+ "const truncatedAcRe=/^AC-FE-[A-Z]+$/i;",
4927
+ "const truncatedUndeclaredAcRe=/^(?:AC-\\d{3}|AC-FE-[A-Z]+)$/i;",
4854
4928
  "for(const c of manifest.cases){",
4855
4929
  " if(!c||typeof c.caseId!=='string'||!/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId)) fail('case-id-shape','caseId must be FE-*, never AC-FE-*: '+String(c&&c.caseId));",
4856
4930
  " if(/^AC-/i.test(c.caseId)) fail('case-id-is-ac','caseId must not be an acceptance id: '+c.caseId);",
@@ -4858,7 +4932,7 @@ function buildFrontendTestHybridDag(sources) {
4858
4932
  " seen.add(c.caseId);",
4859
4933
  " if(typeof c.dimension!=='string'||!dims.has(c.dimension)) fail('invalid-dimension',String(c.dimension));",
4860
4934
  " if(!Array.isArray(c.acIds)||c.acIds.length===0||c.acIds.some(a=>typeof a!=='string'||!a.trim())) fail('ac-mapping','invalid acIds for '+c.caseId);",
4861
- " for(const ac of c.acIds){ if(!acIdRe.test(ac)) fail('ac-id-shape','acIds entry must be AC-* acceptance id, not caseId: '+ac); if(declaredAc.size>0&&!declaredAc.has(ac)) fail('unknown-ac',ac+' not in sourceBinding; repair generator input or AC list'); }",
4935
+ " for(const ac of c.acIds){ if(!acIdRe.test(ac)) fail('ac-id-shape','acIds entry must be AC-* acceptance id, not caseId: '+ac); if(truncatedAcRe.test(ac)||(declaredAc.size===0&&truncatedUndeclaredAcRe.test(ac))) fail('ac-id-truncated','acIds entry is a truncated acceptance id (missing feature prefix or sequence number): '+ac); if(declaredAc.size>0&&!declaredAc.has(ac)) fail('unknown-ac',ac+' not in sourceBinding; repair generator input or AC list'); }",
4862
4936
  " c.casePath='testcase/frontend/cases/'+c.caseId+'.md';",
4863
4937
  " c.evidenceDir='testcase/frontend/evidence/'+c.caseId+'/';",
4864
4938
  " for(const k of ['casePath','evidenceDir']){ const v=c[k]; if(typeof v!=='string'||path.isAbsolute(v)||v.includes('..')) fail('unsafe-path',k+': '+String(v)); }",
@@ -4983,6 +5057,7 @@ function buildFrontendTestHybridDag(sources) {
4983
5057
  subtask_prompt: [
4984
5058
  "Use skill playwright-cli-case-generator.",
4985
5059
  "Read testcase/frontend/rag/standard-scenarios.v1.json and cover priority=must scenarios (or record GAP in coverage-map). Include ## 测试点 and ## 测试步骤 in each case.",
5060
+ ...(uiAnchorsLedgerInstruction ? [uiAnchorsLedgerInstruction] : []),
4986
5061
  "Read only testcase/frontend/rag/context.md, testcase/frontend/rag/coverage-map.md, and the draft case paths testcase/frontend/cases/FE-*.md, testcase/frontend/cases/index.md, and testcase/frontend/cases/manifest.draft.json. Write only those same draft paths. Do not write testcase/frontend/cases/manifest.json.",
4987
5062
  "Generate Markdown cases, index.md and manifest.draft.json (schemaVersion 1; cases[] with caseId, casePath, dimension, acIds, evidenceDir).",
4988
5063
  "HARD ID CONTRACT (do not confuse these):",
@@ -5142,7 +5217,7 @@ function buildFrontendTestHybridDag(sources) {
5142
5217
  subtaskPromptTemplate: [
5143
5218
  "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session; do not use /new). playwright-cli-only: never bare Playwright CLI/API/test runner; no fallback.",
5144
5219
  "Use the structured playwright_cli tool for every browser action. Do not request or search for bash. Do not execute raw shell commands. Translate each playwright-cli line in the case Markdown into one playwright_cli tool call ({command, args?, timeoutSeconds?}).",
5145
- "1) Read the concrete baseUrl from testcase/frontend/rag/context.md; it has already passed the environment shell safety/reachability gate. Start browser ONLY via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>] (default session only; no -s=). 2) Dynamic refs: eX/eY in case Markdown are documentation placeholders, never tool args. Immediately before every structured playwright_cli call that references an element, parse the current real eNN from the immediately preceding latest snapshot and pass only that real eNN; never send literal `eX`/`eY`. A new snapshot invalidates prior refs, so never reuse stale refs. File outputs are canonical: screenshot args [--filename, final.png] (or [e5, --filename, final.png] for a real target), PDF args [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or any output path as a positional target. Follow case steps with snapshot before element refs using only playwright_cli. A passed case requires this same child receipt order: successful open → successful find → controller post-execution cleanup. Only successful find is a meaningful assertion; snapshot, goto, screenshot, request/console, click/fill and other ordinary interactions cannot establish passed authority. 3) Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence (blockedReason playwright-cli-unavailable | frontend-base-url-unreachable); never invent CLI-unavailable solely because bash is absent. 4) For U/D: enforce current-user ownership / create-or-mock-or-blocked; never mutate other users' data.",
5220
+ "1) Read the concrete baseUrl from testcase/frontend/rag/context.md; it has already passed the environment shell safety/reachability gate. Start browser ONLY via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>] (default session only; no -s=). 2) Dynamic refs: eX/eY in case Markdown are documentation placeholders, never tool args. Immediately before every structured playwright_cli call that references an element, parse the current real eNN from the immediately preceding latest snapshot and pass only that real eNN; never send literal `eX`/`eY`. A new snapshot invalidates prior refs, so never reuse stale refs. File outputs are canonical: screenshot args [--filename, final.png] (or [e5, --filename, final.png] for a real target), PDF args [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or any output path as a positional target. Follow case steps with snapshot before element refs using only playwright_cli. A passed case requires this same child receipt order: successful open → successful find → controller post-execution cleanup. Only successful find is a meaningful assertion; snapshot, goto, screenshot, request/console, click/fill and other ordinary interactions cannot establish passed authority. 3) Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence (blockedReason playwright-cli-unavailable | frontend-base-url-unreachable); never invent CLI-unavailable solely because bash is absent. Unique-control early stop: when an AC names a specific control that is absent from the first post-navigation snapshot after reaching the required state (and no setup-required gate appeared), do ONE find with the case's literal as evidence; if it returns 0 matches, record status=blocked with blockedReason unique-control-unavailable citing that single find - do NOT re-explore with alternative literals, repeated snapshots, navigation detours, or rerun loops. 4) For U/D: enforce current-user ownership / create-or-mock-or-blocked; never mutate other users' data.",
5146
5221
  "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId={{case.caseId}}, status passed|failed|blocked, evidencePaths (relative under evidenceDir). blocked needs non-empty blockedReason. After writing, self-check the same contract; if self-check fails, rewrite both files as status=blocked blockedReason=invalid-evidence-shape (never leave missing/malformed evidence).",
5147
5222
  "Business failed/blocked is a recorded result, not a node failure. Close browser via playwright_cli command=close. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
5148
5223
  ].join("\n\n"),
@@ -5165,7 +5240,7 @@ function buildFrontendTestHybridDag(sources) {
5165
5240
  "const manifestPath='testcase/frontend/cases/manifest.json';",
5166
5241
  "if(!fs.existsSync(manifestPath)){process.stdout.write(JSON.stringify({cases:[]}));process.exit(0);}",
5167
5242
  "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));const cases=[];",
5168
- "for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(!r.status){missing=true;reason='missing-result-files';}}catch(_){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||missing)&&attempt<" + String(round) + ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}",
5243
+ "for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(r.status==='failed')reason='failed-retry';if(!r.status){missing=true;reason='missing-result-files';}}catch(_){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||reason==='failed-retry'||missing)&&attempt<" + String(round) + ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}",
5169
5244
  `fs.mkdirSync('testcase/frontend/evidence',{recursive:true});fs.writeFileSync('testcase/frontend/evidence/${candidateArtifact}',JSON.stringify({schemaVersion:1,cases},null,2)+'\\n');process.stdout.write(JSON.stringify({cases}));`,
5170
5245
  ].join("");
5171
5246
  tasks.push({
@@ -5233,6 +5308,7 @@ function buildFrontendTestHybridDag(sources) {
5233
5308
  "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite the authoritative execution.md and case-result.json; its final status replaces the earlier case result.",
5234
5309
  "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use structured playwright_cli only; headless open.",
5235
5310
  "Read the concrete baseUrl from testcase/frontend/rag/context.md and start via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>]. Passed requires open → find → cleanup receipts.",
5311
+ "Unique-control early stop: if the first post-navigation snapshot lacks the control an AC names (and no setup-required gate appeared), one find returning 0 matches is sufficient evidence - write status=blocked blockedReason unique-control-unavailable instead of exploratory retries.",
5236
5312
  "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId, status, evidencePaths, rerunAttempt={{case.rerunAttempt}}. Write fixed sections `### 执行摘要` and `### 实际执行步骤` to execution.md when available.",
5237
5313
  ].join("\n\n"),
5238
5314
  },
package/harness.json CHANGED
@@ -83,8 +83,8 @@
83
83
  "pi": {
84
84
  "description": "Pi 负责规划、评审、诊断;当 DAG toolProfile=write 时也可做有界写入。模型按复杂度三档配置,支持 provider/model 字符串或带 thinking 的对象;斜杠前为 Pi provider,后为 modelId,勿只写裸 modelId。",
85
85
  "LOW": "wizard-local/minimax-m3",
86
- "MED": "wizard-local/grok-4.6",
87
- "HIGH": "wizard-local/gpt-5.6-sol"
86
+ "MED": "wizard-local/minimax-m3",
87
+ "HIGH": "wizard-local/minimax-m3"
88
88
  }
89
89
  }
90
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.39.0-next.30",
3
+ "version": "0.39.0-next.31",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: fe-test-ui-scout
3
+ description: 在写 frontend-test Wave PRD/回归目录之前,对真实运行的被测页面做只读锚点侦察,产出并维护 testcase/frontend/rag/ui-anchors.md 锚点台账。用 playwright-cli snapshot/find 逐字面验证命中数与唯一性,防止 PRD 验收标准引用不存在或不唯一的文案。触发词:frontend-test、锚点、台账、ui-anchors、侦察、写 PRD 前、snapshot/find 校准、Wave PRD。
4
+ references:
5
+ - path: references/ledger-schema.md
6
+ required: true
7
+ - path: references/recon-protocol.md
8
+ required: true
9
+ ---
10
+
11
+ # FE-Test UI Scout(锚点侦察)
12
+
13
+ ## 定位
14
+
15
+ 写 Wave PRD / 校准回归目录 AC **之前**的前置侦察。产出锚点台账
16
+ `testcase/frontend/rag/ui-anchors.md`:每个「页面 × 状态」下真实可见、
17
+ `find` 可命中的字面清单。PRD 的每条 `find "..."` 断言必须能在台账找到对应行;
18
+ 台账没有的字面禁止写进验收标准。
19
+
20
+ 本 skill **不生成用例、不执行用例、不点任何写控件**。生成由
21
+ `playwright-cli-case-generator`(DAG generate 节点)负责,执行由
22
+ `playwright-cli`(DAG execute 节点)负责;本 skill 只在 DAG 之外的主会话使用。
23
+
24
+ ## 输入
25
+
26
+ - 被测 baseUrl(来自 `testcase/frontend/rag/context.md` 的 `environmentProbe=reachable` URL)
27
+ - 目标 Wave 的 AC 清单(回归目录中本 Wave 范围的行)
28
+ - 既有台账(增量更新,不整表重写)
29
+
30
+ ## 流程
31
+
32
+ 1. **读取范围**:从回归目录取本 Wave AC 的「页面 + 测试点 + 验收标准」,
33
+ 提取所有候选字面(引号内文案、aria-label、控件名)。
34
+ 2. **逐状态侦察**:对每个涉及页面/状态组合:
35
+ `playwright-cli open --browser=chrome <baseUrl>/#/<route>` → `snapshot` →
36
+ 逐候选 `find "<字面>"`,记录命中数与命中位置。
37
+ 3. **记录陷阱**:拆节点(`<kbd>`+`<span>`、文本+`<code>`)、同名多控件、
38
+ aria-label 与可见文本不一致、状态前置(no-session / has-session / empty、
39
+ setup-required)都要写进备注列。
40
+ 4. **增量写台账**:按 [references/ledger-schema.md](references/ledger-schema.md)
41
+ 的表结构与分节规则追加/修订行,保留既有行历史(用备注标注失效原因,
42
+ 不删行)。
43
+ 5. **报告**:侦察完成后输出小结(新增/修订/失效行数、命中率、给 PRD 的
44
+ 可用锚点清单与禁用清单)。
45
+
46
+ ## 硬规则
47
+
48
+ - 只读:不点提交/保存/发送类控件、不创建会话以外的任何状态(「新建空白
49
+ 对话」CTA 允许点击一次以进入 has-session 状态,属于台账明确的状态夹具)。
50
+ - 命中 0 或命中数 >1 且无法用状态/作用域区分的字面,标记 `不可断言`,
51
+ 禁止进入 PRD `find` 断言。
52
+ - 台账每行必须带验证时间与当时 baseUrl;页面改版后旧行标 `失效` 而非删除。
53
+ - 不猜测:没跑过 `find` 的字面不进台账。
54
+ - PRD 正文(含背景/夹具段)不得出现会被需求 id 扫描捕获的形态:大写或小写的 REQ/BR/AC 前缀 + 连字符 + hash/序号(如夹具任务 id 里的 req 段、或自示范用的「req 加连字符 hash」字面)。夹具引用只写日期 + 短 hash(如 f03535aa),规则说明文字用中文描述或代码块包裹仍可能被扫——一律避免连写。
55
+ - PRD 验收标准只用**目录 AC id**(如 AC-FE-INS-005)作条目 id;不要写本地序号前缀(如 AC-003 加粗目录 id 的双 id 形态)——双 id 会被同时提取,生成器只引用其一,另一个落入 acceptanceCoverage.missing。
56
+
57
+ ## 输出
58
+
59
+ - `testcase/frontend/rag/ui-anchors.md`(增量更新)
60
+ - 侦察小结(对话内输出,不落盘;结论回写 PRD 草稿的锚点引用)
61
+
62
+ ## References
63
+
64
+ - [references/ledger-schema.md](references/ledger-schema.md) — 台账表结构与分节规则
65
+ - [references/recon-protocol.md](references/recon-protocol.md) — 逐状态侦察操作协议与陷阱清单