@tea-agent/loop-agent 0.27.1 → 0.28.0

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 (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/application/task-lifecycle/observe.js +5 -0
  3. package/dist/application/task-lifecycle/plan-transitions.js +7 -2
  4. package/dist/cli/program.js +1 -1
  5. package/dist/commands/client-recovery.js +439 -20
  6. package/dist/commands/init.js +42 -6
  7. package/dist/executors/dag-pi-executor.js +143 -38
  8. package/dist/executors/pi-playwright-cli-tool.js +955 -0
  9. package/dist/executors/pi-sdk-executor.js +56 -0
  10. package/dist/executors/playwright-cli-launcher.js +63 -0
  11. package/dist/executors/shell-executor.js +128 -0
  12. package/dist/shared/playwright-cli-command-policy.js +41 -0
  13. package/dist/worker/observability/read-model.js +66 -8
  14. package/dist/worker/observe/static/dag-model.js +85 -13
  15. package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
  16. package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
  17. package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
  18. package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
  19. package/dist/workflows/dag/init-hybrid.js +116 -30
  20. package/dist/workflows/dag/lifecycle.js +33 -2
  21. package/dist/workflows/dag/node-execution.js +11 -5
  22. package/dist/workflows/dag/output-protocol.js +25 -83
  23. package/dist/workflows/dag/report.js +9 -2
  24. package/dist/workflows/dag/rerun-run.js +62 -3
  25. package/dist/workflows/dag/run-store.js +6 -1
  26. package/dist/workflows/dag/runner.js +15 -3
  27. package/dist/workflows/dag/types.js +27 -0
  28. package/dist/workflows/dag/validate.js +121 -1
  29. package/docs/architecture/runtime-boundaries.md +13 -11
  30. package/docs/init-surface.manifest.json +6 -2
  31. package/docs/templates/README.md +9 -1
  32. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
  33. package/docs/templates/frontend-test-dag.json +55 -15
  34. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
  35. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  36. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  37. package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
  38. package/harness.json +1 -1
  39. package/package.json +1 -1
  40. package/skills/loop-agent/SKILL.md +1 -1
  41. package/skills/loop-agent/references/command-reference.md +18 -6
  42. package/skills/playwright-cli/SKILL.md +69 -402
  43. package/skills/playwright-cli/references/tracing.md +3 -137
  44. package/skills/playwright-cli/references/video-recording.md +3 -141
  45. package/skills/playwright-cli-case-generator/SKILL.md +53 -46
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { writeDagRunJsonArtifact } from "../../../infrastructure/harness/artifact-store.js";
4
4
  import { executeDagNode, } from "../node-execution.js";
5
5
  import { writeRunSpec } from "../run-store.js";
6
+ import { assertValidMaterializedDagTask } from "../validate.js";
6
7
  import { freshNodeRecord, parseJsonFromText, renderDynamicPatternList, renderDynamicTemplate, resolveItemsFromSelector, sha256Json, } from "./shared.js";
7
8
  export function buildExpandedChildTask(input) {
8
9
  const { parent, expansion, item, index, nodeId } = input;
@@ -20,6 +21,7 @@ export function buildExpandedChildTask(input) {
20
21
  role: child.role,
21
22
  skills: child.skills,
22
23
  toolProfile: child.toolProfile,
24
+ commandPolicy: child.commandPolicy,
23
25
  writePolicy: child.writePolicy,
24
26
  allowedPaths: renderDynamicPatternList(child.allowedPaths, item, index, expansion.itemName) ?? [],
25
27
  forbiddenPaths: renderDynamicPatternList(child.forbiddenPaths, item, index, expansion.itemName) ?? [],
@@ -98,14 +100,11 @@ async function materializeBlockedCaseEvidence(input) {
98
100
  const caseId = resolveCaseIdFromItem(input.item);
99
101
  try {
100
102
  const existing = JSON.parse(await readFile(resultPath, "utf-8"));
101
- if (existing.caseId === caseId &&
102
- (existing.status === "passed" ||
103
- existing.status === "failed" ||
104
- existing.status === "blocked"))
103
+ if (existing.caseId === caseId && existing.status === "blocked")
105
104
  return;
106
105
  }
107
106
  catch {
108
- // Missing or malformed evidence is replaced only for a child that never finished a business result.
107
+ // Missing or malformed evidence is replaced by controller-owned terminal evidence.
109
108
  }
110
109
  await writeFile(path.join(workspace, "execution.md"), `# ${caseId}\n\nStatus: blocked\n\nReason: ${input.reason}\n`, "utf-8");
111
110
  await writeFile(resultPath, `${JSON.stringify({
@@ -130,14 +129,11 @@ async function materializeFailedCaseEvidence(input) {
130
129
  const caseId = resolveCaseIdFromItem(input.item);
131
130
  try {
132
131
  const existing = JSON.parse(await readFile(resultPath, "utf-8"));
133
- if (existing.caseId === caseId &&
134
- (existing.status === "passed" ||
135
- existing.status === "failed" ||
136
- existing.status === "blocked"))
132
+ if (existing.caseId === caseId && existing.status === "failed")
137
133
  return;
138
134
  }
139
135
  catch {
140
- // Missing or malformed evidence is replaced only when the child never finished a business result.
136
+ // Missing or malformed evidence is replaced by controller-owned terminal evidence.
141
137
  }
142
138
  await writeFile(path.join(workspace, "execution.md"), `# ${caseId}\n\nStatus: failed\n\nReason: ${input.reason}\n`, "utf-8");
143
139
  await writeFile(resultPath, `${JSON.stringify({
@@ -170,6 +166,9 @@ export async function executeDynamicMapExpansion(input) {
170
166
  index,
171
167
  nodeId,
172
168
  }));
169
+ for (const child of children) {
170
+ assertValidMaterializedDagTask(child);
171
+ }
173
172
  const writeSetConflicts = collectDynamicChildWriteSetConflicts(children);
174
173
  if (writeSetConflicts.length > 0) {
175
174
  throw new Error(`dynamic map_agent ${input.task.id} expanded overlapping writeSets: ${writeSetConflicts.join("; ")}`);
@@ -356,9 +355,9 @@ export async function executeDynamicMapExpansion(input) {
356
355
  throw new Error(`failed to materialize case outcome evidence for ${nodeId}: ${error instanceof Error ? error.message : String(error)}`);
357
356
  }
358
357
  caseOutcomeNotes.push({ nodeId, reason, status: outcomeStatus });
359
- record.status = "FINISHED";
360
- record.stderr = `${outcomeStatus}: ${reason}`;
361
- record.failureCategory = "success";
358
+ // `tolerateChildFailures` changes only the map barrier's business
359
+ // aggregation. The child terminal state and failure category remain
360
+ // executor-owned facts for downstream authority checks.
362
361
  }
363
362
  }
364
363
  const budgetBlockedIds = new Set(blockedChildren.map((entry) => entry.nodeId));
@@ -380,6 +379,7 @@ export async function executeDynamicMapExpansion(input) {
380
379
  item: items[index],
381
380
  workspaceRef: workspaceRefs[index],
382
381
  status: input.state.nodes[nodeId]?.status,
382
+ failureCategory: input.state.nodes[nodeId]?.failureCategory,
383
383
  stdout: input.state.nodes[nodeId]?.stdout,
384
384
  output: parseJsonFromText(input.state.nodes[nodeId]?.stdout),
385
385
  assistantText: input.state.nodes[nodeId]?.assistantText,
@@ -1,5 +1,19 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { isPlaywrightCliCommand } from "../../shared/playwright-cli-command-policy.js";
4
+ const COMMAND_FENCE_LANGUAGE = /^(?:|bash|sh|shell|zsh|fish|console|terminal|command|cmd|powershell|pwsh|javascript|js|typescript|ts|python|py)$/i;
5
+ const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
6
+ const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
7
+ const EXPLICIT_COMMAND = /^(?:(?:shell|terminal)(?:\s+command)?|command|run command|execute command)\s*:\s*(.+)$/i;
8
+ const BLOCKED_REASON_PREFIX = /^(?:(?:blocked|forbidden|reject(?:ed)?|disallow(?:ed)?|prohibited)(?:\s+reason)?\s*(?::|\bbecause\b)|(?:do not|must not|never)\s+(?:run|execute|use)\b)/i;
9
+ const IMPERATIVE_COMMAND = /^(?:(?:run|execute|use)(?:\s+(?:the\s+)?(?:(?:shell|terminal)\s+)?command)?|in\s+(?:the\s+)?(?:shell|terminal|console)\s*,?\s*(?:run|execute|use))\s*:?\s+(.+)$/i;
10
+ const INLINE_CODE_STEP = /^`([^`\r\n]+)`[.!?]?$/;
11
+ const AUTOMATION_EXECUTABLE = /^(?:playwright-cli\b|playwright\b|@playwright\/test\b|npx\b|npm\b|pnpm\b|yarn\b|bunx?\b|node(?:js)?\b|python(?:3)?\b|bash\b|sh\b|zsh\b|fish\b|powershell\b|pwsh\b|cmd(?:\.exe)?\b|cypress\b|selenium\b|webdriverio\b|chromedriver\b|google-chrome\b|chrome\b|firefox\b|curl\b|wget\b)/i;
12
+ const SHELL_WRAPPED_EXECUTABLE = /^(?:sudo\s+|env(?:\s+[A-Za-z_][A-Za-z0-9_]*=[^\s]+)*\s+|(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\/)[^\s]+)/i;
13
+ const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)/;
14
+ const ENV_WRAPPER = /^env(?:\s+(?:-[A-Za-z]+|--[A-Za-z][A-Za-z-]*))*\s+/i;
15
+ const SHELL_BUILTIN_WRAPPER = /^(?:command|exec|sudo)\s+/i;
16
+ const PLAYWRIGHT_API = /^(?:from\s+playwright(?:\.[A-Za-z_][A-Za-z0-9_]*)*\s+import\b|import\s+playwright(?:\.[A-Za-z_][A-Za-z0-9_]*)*(?:\s+as\s+[A-Za-z_][A-Za-z0-9_]*)?\b|import\s+.+\s+from\s+["'](?:playwright|@playwright\/test)["']|(?:const|let|var)\s+.+\brequire\s*\(\s*["'](?:playwright|@playwright\/test)["']\s*\)|require\s*\(\s*["'](?:playwright|@playwright\/test)["']\s*\)|(?:sync_playwright|async_playwright|chromium|firefox|webkit)\.(?:launch|connect)\b)/i;
3
17
  async function exists(file) {
4
18
  try {
5
19
  return (await stat(file)).isFile();
@@ -8,6 +22,180 @@ async function exists(file) {
8
22
  return false;
9
23
  }
10
24
  }
25
+ function normalizeCommand(value) {
26
+ const trimmed = value.trim().replace(/^[$>]\s*/, "");
27
+ const wrapped = trimmed.match(/^`([^`]+)`$/);
28
+ return (wrapped?.[1] ?? trimmed).trim();
29
+ }
30
+ function isExecutableFence(language) {
31
+ return COMMAND_FENCE_LANGUAGE.test(language.trim());
32
+ }
33
+ function isBlockedReason(value) {
34
+ return BLOCKED_REASON_PREFIX.test(value.trim());
35
+ }
36
+ function stripLeadingCommandWrappers(command) {
37
+ let remaining = command.trim();
38
+ let consumed = true;
39
+ while (remaining && consumed) {
40
+ consumed = false;
41
+ const assignment = remaining.match(ENV_ASSIGNMENT);
42
+ if (assignment) {
43
+ remaining = remaining.slice(assignment[0].length).trimStart();
44
+ consumed = true;
45
+ continue;
46
+ }
47
+ const env = remaining.match(ENV_WRAPPER);
48
+ if (env) {
49
+ remaining = remaining.slice(env[0].length).trimStart();
50
+ consumed = true;
51
+ continue;
52
+ }
53
+ const wrapper = remaining.match(SHELL_BUILTIN_WRAPPER);
54
+ if (wrapper) {
55
+ remaining = remaining.slice(wrapper[0].length).trimStart();
56
+ consumed = true;
57
+ }
58
+ }
59
+ return remaining;
60
+ }
61
+ function isCommandLikeExecutable(command) {
62
+ const executable = stripLeadingCommandWrappers(command);
63
+ return AUTOMATION_EXECUTABLE.test(executable) || SHELL_WRAPPED_EXECUTABLE.test(executable) || PLAYWRIGHT_API.test(executable);
64
+ }
65
+ function executableListStep(value) {
66
+ const trimmed = value.trim();
67
+ const explicit = trimmed.match(EXPLICIT_COMMAND);
68
+ if (explicit)
69
+ return isBlockedReason(explicit[1]) ? null : normalizeCommand(explicit[1]);
70
+ const imperative = trimmed.match(IMPERATIVE_COMMAND);
71
+ if (imperative)
72
+ return normalizeCommand(imperative[1]);
73
+ const inlineCode = trimmed.match(INLINE_CODE_STEP);
74
+ if (inlineCode)
75
+ return normalizeCommand(inlineCode[1]);
76
+ const prompted = /^[$>]\s*\S/.test(trimmed);
77
+ const normalized = normalizeCommand(trimmed);
78
+ if (prompted || isCommandLikeExecutable(normalized))
79
+ return normalized;
80
+ return null;
81
+ }
82
+ function hasShellControl(command) {
83
+ let quote = null;
84
+ for (let index = 0; index < command.length; index += 1) {
85
+ const character = command[index];
86
+ if (character === "\\" && quote !== "'") {
87
+ index += 1;
88
+ continue;
89
+ }
90
+ if (quote !== null) {
91
+ if (character === quote)
92
+ quote = null;
93
+ else if (quote === '"' && (character === "`" || (character === "$" && command[index + 1] === "(")))
94
+ return true;
95
+ continue;
96
+ }
97
+ if (character === "'" || character === '"') {
98
+ quote = character;
99
+ continue;
100
+ }
101
+ if (character === "#" && (index === 0 || /\s/.test(command[index - 1])))
102
+ break;
103
+ if (character === "`" || character === ";" || character === "|" || character === "&" || character === "<" || character === ">")
104
+ return true;
105
+ if (character === "$" && command[index + 1] === "(")
106
+ return true;
107
+ }
108
+ return false;
109
+ }
110
+ /**
111
+ * Return only Markdown lines that are presented as commands. Prose can mention
112
+ * forbidden commands as a blocked reason without becoming an executable step.
113
+ */
114
+ function extractExecutableInstructions(markdown) {
115
+ const instructions = [];
116
+ let fenceLanguage = null;
117
+ for (const [index, line] of markdown.split(/\r?\n/).entries()) {
118
+ const lineNumber = index + 1;
119
+ const fence = line.match(/^\s*```([^`]*)$/);
120
+ if (fence) {
121
+ fenceLanguage = fenceLanguage === null ? fence[1].trim() : null;
122
+ continue;
123
+ }
124
+ const trimmed = line.trim();
125
+ if (!trimmed)
126
+ continue;
127
+ if (fenceLanguage !== null) {
128
+ if (!isExecutableFence(fenceLanguage) || /^(?:#|\/\/)/.test(trimmed))
129
+ continue;
130
+ instructions.push({ command: normalizeCommand(trimmed), lineNumber });
131
+ continue;
132
+ }
133
+ const listed = line.match(LIST_ITEM);
134
+ if (listed) {
135
+ if (isBlockedReason(listed[1]))
136
+ continue;
137
+ const command = executableListStep(listed[1]);
138
+ if (command !== null)
139
+ instructions.push({ command, lineNumber });
140
+ continue;
141
+ }
142
+ const indented = line.match(INDENTED_CODE);
143
+ if (indented) {
144
+ if (isBlockedReason(indented[1]))
145
+ continue;
146
+ const explicitIndented = indented[1].match(EXPLICIT_COMMAND);
147
+ if (explicitIndented && isBlockedReason(explicitIndented[1]))
148
+ continue;
149
+ instructions.push({ command: executableListStep(indented[1]) ?? normalizeCommand(indented[1]), lineNumber });
150
+ continue;
151
+ }
152
+ const explicit = trimmed.match(EXPLICIT_COMMAND);
153
+ if (explicit) {
154
+ if (isBlockedReason(explicit[1]))
155
+ continue;
156
+ instructions.push({ command: normalizeCommand(explicit[1]), lineNumber });
157
+ continue;
158
+ }
159
+ const command = executableListStep(trimmed);
160
+ if (command !== null)
161
+ instructions.push({ command, lineNumber });
162
+ }
163
+ return instructions;
164
+ }
165
+ function commandGateIssue(input) {
166
+ const commandMatch = input.instruction.command.match(/^playwright-cli\s+([^\s`]+)/i);
167
+ if (commandMatch) {
168
+ const command = commandMatch[1].toLowerCase();
169
+ if (isPlaywrightCliCommand(command) && !hasShellControl(input.instruction.command))
170
+ return null;
171
+ if (isPlaywrightCliCommand(command)) {
172
+ return {
173
+ ruleId: "alternative-executable-command",
174
+ caseId: input.caseId,
175
+ casePath: input.casePath,
176
+ lineNumber: input.instruction.lineNumber,
177
+ command: input.instruction.command,
178
+ detail: "shell control or additional executable fragments are not allowed after playwright-cli commands",
179
+ };
180
+ }
181
+ return {
182
+ ruleId: "playwright-cli-command-not-allowed",
183
+ caseId: input.caseId,
184
+ casePath: input.casePath,
185
+ lineNumber: input.instruction.lineNumber,
186
+ command: input.instruction.command,
187
+ detail: `playwright-cli command is not allowlisted: ${command}`,
188
+ };
189
+ }
190
+ return {
191
+ ruleId: "alternative-executable-command",
192
+ caseId: input.caseId,
193
+ casePath: input.casePath,
194
+ lineNumber: input.instruction.lineNumber,
195
+ command: input.instruction.command,
196
+ detail: "only allowlisted playwright-cli commands may appear in executable case instructions",
197
+ };
198
+ }
11
199
  export async function validateFrontendCaseChecklist(input) {
12
200
  const root = path.join(input.workspaceRoot, "testcase/frontend/cases");
13
201
  const draft = path.join(root, "manifest.draft.json");
@@ -36,34 +224,39 @@ export async function validateFrontendCaseChecklist(input) {
36
224
  const absolute = path.resolve(input.workspaceRoot, casePath);
37
225
  const relative = path.relative(input.workspaceRoot, absolute);
38
226
  if (!casePath || relative.startsWith("..") || path.isAbsolute(relative) || !(await exists(absolute))) {
39
- issues.push({ ruleId: "case-file-missing", caseId: id, detail: casePath || "missing casePath" });
227
+ issues.push({ ruleId: "case-file-missing", caseId: id, casePath: casePath || undefined, detail: casePath || "missing casePath" });
40
228
  continue;
41
229
  }
42
230
  if (expectedPath && casePath.replaceAll("\\", "/") !== expectedPath)
43
- issues.push({ ruleId: "case-path-mismatch", caseId: id, detail: `${casePath} must equal ${expectedPath}` });
231
+ issues.push({ ruleId: "case-path-mismatch", caseId: id, casePath, detail: `${casePath} must equal ${expectedPath}` });
44
232
  const body = await readFile(absolute, "utf8");
45
233
  if (!openRe.test(body))
46
- issues.push({ ruleId: "open-prefix", caseId: id, detail: "missing playwright-cli open --browser=chrome --headed <absolute-url>" });
234
+ issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome --headed <absolute-url>" });
47
235
  const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headed\s+(https?:\/\/\S+)/i);
48
236
  if (match) {
49
237
  try {
50
238
  const url = new URL(match[1].replace(/[)\]},.\"'`]+$/, ""));
51
239
  if (productionHostRe.test(url.hostname))
52
- issues.push({ ruleId: "production-url", caseId: id, detail: match[1] });
240
+ issues.push({ ruleId: "production-url", caseId: id, casePath, detail: match[1] });
53
241
  }
54
242
  catch {
55
- issues.push({ ruleId: "production-url", caseId: id, detail: match[1] });
243
+ issues.push({ ruleId: "production-url", caseId: id, casePath, detail: match[1] });
56
244
  }
57
245
  }
246
+ for (const instruction of extractExecutableInstructions(body)) {
247
+ const issue = commandGateIssue({ caseId: id, casePath, instruction });
248
+ if (issue)
249
+ issues.push(issue);
250
+ }
58
251
  if (!Array.isArray(item?.acIds) || item.acIds.length === 0) {
59
- issues.push({ ruleId: "ac-mapping", caseId: id, detail: "acIds required" });
252
+ issues.push({ ruleId: "ac-mapping", caseId: id, casePath, detail: "acIds required" });
60
253
  }
61
254
  else {
62
255
  for (const ac of item.acIds) {
63
256
  if (typeof ac !== "string" || !acIdRe.test(ac))
64
- issues.push({ ruleId: "ac-id-shape", caseId: id, detail: String(ac) });
257
+ issues.push({ ruleId: "ac-id-shape", caseId: id, casePath, detail: String(ac) });
65
258
  else if (declaredAc.size > 0 && !declaredAc.has(ac))
66
- issues.push({ ruleId: "unknown-ac", caseId: id, detail: `${ac} not in sourceBinding` });
259
+ issues.push({ ruleId: "unknown-ac", caseId: id, casePath, detail: `${ac} not in sourceBinding` });
67
260
  }
68
261
  }
69
262
  }
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lstat, readFile, realpath, stat } from "node:fs/promises";
2
+ import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
+ import { readPlaywrightCliReceipts, summarizePlaywrightCliReceipts, } from "../../executors/pi-playwright-cli-tool.js";
6
7
  import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
7
8
  export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
8
9
  const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
@@ -180,6 +181,38 @@ export async function validateFrontendCaseEvidence(input) {
180
181
  }
181
182
  return { cases: manifest.cases.length, issues, hardFail };
182
183
  }
184
+ export function evaluatePassedCaseBrowserReceipts(summary, caseId) {
185
+ if (!summary || summary.caseIds.size !== 1 || !summary.caseIds.has(caseId)) {
186
+ return { ok: false, reason: "browser-command-evidence-missing" };
187
+ }
188
+ if (!summary.hasOrderedPassedReceiptChain) {
189
+ return { ok: false, reason: "browser-command-evidence-missing" };
190
+ }
191
+ return { ok: true };
192
+ }
193
+ async function loadCaseBrowserReceiptSummary(input) {
194
+ try {
195
+ const entries = await readdir(input.runDir, { withFileTypes: true });
196
+ // A passed chain must belong to exactly one browser child stream. Never
197
+ // stitch receipts across DAG nodes or accept ambiguous duplicate authority.
198
+ const authorizingStreams = [];
199
+ for (const entry of entries) {
200
+ if (!entry.isDirectory())
201
+ continue;
202
+ const nodeReceipts = (await readPlaywrightCliReceipts(input.runDir, entry.name))
203
+ .filter((item) => item.caseId === input.caseId);
204
+ const summary = summarizePlaywrightCliReceipts(nodeReceipts);
205
+ if (summary.hasOrderedPassedReceiptChain)
206
+ authorizingStreams.push(summary);
207
+ }
208
+ return authorizingStreams.length === 1
209
+ ? authorizingStreams[0]
210
+ : summarizePlaywrightCliReceipts([]);
211
+ }
212
+ catch {
213
+ return summarizePlaywrightCliReceipts([]);
214
+ }
215
+ }
183
216
  function sha256(content) {
184
217
  return createHash("sha256").update(content).digest("hex");
185
218
  }
@@ -296,7 +329,7 @@ export async function materializeFrontendTestResult(input) {
296
329
  advisoryFindings.push({ ruleId: "missing-or-invalid-case-result", caseId: item.caseId, detail: "case-result.json is missing or invalid" });
297
330
  }
298
331
  const parsedStatus = caseStatusSchema.safeParse(resultRaw.status);
299
- const status = parsedStatus.success ? parsedStatus.data : "blocked";
332
+ let status = parsedStatus.success ? parsedStatus.data : "blocked";
300
333
  if (resultRaw.caseId !== undefined && resultRaw.caseId !== item.caseId)
301
334
  advisoryFindings.push({ ruleId: "case-result-identity", caseId: item.caseId, detail: "case-result caseId does not match manifest" });
302
335
  if (status === "blocked" && resultRaw.blockedReason !== undefined && (typeof resultRaw.blockedReason !== "string" || !resultRaw.blockedReason.trim()))
@@ -318,8 +351,24 @@ export async function materializeFrontendTestResult(input) {
318
351
  }
319
352
  if (status === "passed" && !evidence.some((entry) => /\.(png|jpg|jpeg|webp|zip|har|webm|mp4|ya?ml|md)$/i.test(entry.path)))
320
353
  advisoryFindings.push({ ruleId: "passed-without-browser-evidence", caseId: item.caseId, detail: "passed case has no browser evidence" });
354
+ let blockedReason = status === "blocked" && typeof resultRaw.blockedReason === "string" && resultRaw.blockedReason.trim() ? resultRaw.blockedReason.trim() : undefined;
355
+ if (status === "passed") {
356
+ const receiptSummary = await loadCaseBrowserReceiptSummary({
357
+ runDir: input.runDir,
358
+ caseId: item.caseId,
359
+ });
360
+ const receiptGate = evaluatePassedCaseBrowserReceipts(receiptSummary, item.caseId);
361
+ if (!receiptGate.ok) {
362
+ status = "blocked";
363
+ blockedReason = receiptGate.reason ?? "browser-command-evidence-missing";
364
+ advisoryFindings.push({
365
+ ruleId: "browser-command-evidence-missing",
366
+ caseId: item.caseId,
367
+ detail: "passed case lacks controller-owned open + interaction/assertion + cleanup receipts",
368
+ });
369
+ }
370
+ }
321
371
  const caseContent = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
322
- const blockedReason = status === "blocked" && typeof resultRaw.blockedReason === "string" && resultRaw.blockedReason.trim() ? resultRaw.blockedReason.trim() : undefined;
323
372
  const explicitAnalysis = typeof resultRaw.errorAnalysis === "string" && resultRaw.errorAnalysis.trim()
324
373
  ? resultRaw.errorAnalysis.trim()
325
374
  : typeof resultRaw.errorSummary === "string" && resultRaw.errorSummary.trim()