@tea-agent/loop-agent 0.16.15 → 0.16.16

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.
package/CHANGELOG.md CHANGED
@@ -4,11 +4,15 @@
4
4
 
5
5
  ### 改进
6
6
 
7
+ - 前端浏览器测试 DAG 的复盘报告现写入 `testcase/frontend/reports/**`,不再要求 `docs/test-reports/**` 权限;任务执行约束可以安全禁止整个 `docs/**`,同时仍保留可审计的测试资产。
8
+ - `frontend-test` 结果链新增 run-owned `frontend-test-result-v1`、单次用例修订与终审门禁;只有所有浏览器用例和 AC 覆盖通过且结果合同明确为 real/pass 时,Worker 才会投影真实集成。
9
+
7
10
  - 后端测试 DAG 从 38 个收敛为 24 个真实顶层节点;使用 fail-closed `runIf` 和复合 Shell capability 减少调度,同时保留双合同、Manifest、语义评审、JUnit、Result、分类、修复安全、追踪和最终 outcome 证据。三条可选修订/修复分支仍各最多执行一次。
8
11
  - Backend Test Analysis、Case Manifest、Semantic Review 与 Classification 的严格 JSON 契约进一步对齐,生成和修订节点明确字段白名单、`sourceBinding` 与 evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
9
12
 
10
13
  ### 修复
11
14
 
15
+ - `frontend-test` 的 case review 现在是 fail-closed browser gate:只有 `VERDICT: pass` 才能物化 manifest 并启动动态 browser map;生成的 case 固定使用默认 browser session,要求每个子场景的 fixture/UI reset 和 fresh snapshot,并由确定性节点校验每个 case 的 `execution.md`、`case-result.json`、`caseId`、`status`、`evidencePaths` 及 blocked `blockedReason`。
12
16
  - 前端实现计划/修订节点会注入当前包内权威 `frontend-implementation-contract-v1` Schema 与固定 source binding,避免模型猜测字段导致契约门禁失败。
13
17
  - 前端 Mock 策略节点的 canonical 输出会把首条 `MOCK_STRATEGY:` 协议行提升为第一行,避免解释性前言触发 `first-non-empty` 门禁误判。
14
18
  - 前端规范回退目录统一为本地 `openspec/`,DAG 能力发现、提示词、Skill 与验证证据检查不再查找大小写不一致的旧目录名。
@@ -18,6 +22,12 @@
18
22
  - Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
19
23
  - 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为 initial/final Result、repair eligibility、traceability 与 Observe 投影保留结构化运行证据。
20
24
 
25
+ ## [0.16.16] - 2026-07-20
26
+
27
+ ### 修复
28
+
29
+ - 动态 DAG `itemsFrom` / JSON 选择器从 shell 节点 stdout 解析时,可跳过命令回显行并提取末尾 JSON 对象,避免 frontend-test `execute-frontend-cases-map` 在 materialize 门成功后仍因 `$ node -e ...` 前缀失败。
30
+
21
31
  ## [0.16.15] - 2026-07-20
22
32
 
23
33
  ### 修复
@@ -7,6 +7,7 @@ import { truncateOutput } from "../shared/output-truncation.js";
7
7
  import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdictGateShellCommand, } from "./shell-presets.js";
8
8
  import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
9
9
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
10
+ import { materializeFrontendTestResult, } from "../workflows/dag/frontend-test-result-contract.js";
10
11
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
11
12
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
12
13
  import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
@@ -544,6 +545,15 @@ export async function executeDagShellNode(input, meta) {
544
545
  sourceBinding: meta.spec.sourceBinding,
545
546
  });
546
547
  break;
548
+ case "frontend-test-result-v1":
549
+ artifact = await materializeFrontendTestResult({
550
+ runDir: meta.runDir,
551
+ workspaceRoot: input.cwd,
552
+ artifactName: gate.artifactName,
553
+ outputDir: gate.outputDir,
554
+ sourceBinding: meta.spec.sourceBinding,
555
+ });
556
+ break;
547
557
  default:
548
558
  throw new Error(`unsupported jsonArtifactGate.schemaId: ${String(gate.schemaId)}`);
549
559
  }
@@ -124,12 +124,13 @@ class BackendTestAdapter {
124
124
  class FrontendTestAdapter {
125
125
  workflow = "frontend-test";
126
126
  project(input) {
127
- const real = input.reportDecision.succeeded;
127
+ const structured = structuredArtifactsFromReport(input.dagReportRun);
128
+ const result = structured.find((artifact) => artifact.kind === "frontend-test-result" && artifact.schemaId === "frontend-test-result-v1");
129
+ // The projector validates the bound hash on disk. Missing result evidence is
130
+ // intentionally not upgraded from DAG completion to real integration.
131
+ const real = input.reportDecision.succeeded && Boolean(result);
128
132
  return {
129
- artifacts: [
130
- ...canonicalArtifacts(input),
131
- ...structuredArtifactsFromReport(input.dagReportRun),
132
- ],
133
+ artifacts: [...canonicalArtifacts(input), ...structured],
133
134
  acceptanceCoverage: [...input.acceptanceRefs],
134
135
  integrationStatus: { mock: false, real },
135
136
  };
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import { sha256File } from "../pool/run-store.js";
4
4
  import { taskOutcomeEnvelopeV1Schema } from "./types.js";
5
+ import { frontendTestResultContractSchema } from "../../workflows/dag/frontend-test-result-contract.js";
5
6
  import { getOutcomeAdapter } from "./adapters.js";
6
7
  import { isPathAllowedForTask, mergeOutcomeArtifacts, projectDeclaredPathArtifacts, } from "./declared-artifacts.js";
7
8
  /**
@@ -117,6 +118,26 @@ export async function projectOutcome(input) {
117
118
  ...(artifact.schemaId ? { schemaId: artifact.schemaId } : {}),
118
119
  });
119
120
  }
121
+ // A frontend-test success is meaningful only when its finalizer produced a
122
+ // hash-verified real/pass result. This check deliberately happens after the
123
+ // generic rehash so report metadata cannot forge integration evidence.
124
+ if (workflow === "frontend-test") {
125
+ const results = validatedArtifacts.filter((artifact) => artifact.kind === "frontend-test-result" &&
126
+ artifact.schemaId === "frontend-test-result-v1");
127
+ if (results.length !== 1) {
128
+ return contractError("frontend-test requires exactly one hash-bound frontend-test-result-v1 artifact", ["frontend-test-result-v1"]);
129
+ }
130
+ try {
131
+ const resultArtifact = results[0];
132
+ const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(path.resolve(input.repoRoot, resultArtifact.path), "utf-8")));
133
+ if (result.outcome !== "passed" || result.integrationMode !== "real") {
134
+ return contractError(`frontend-test result is not a real pass: outcome=${result.outcome} integrationMode=${result.integrationMode}`);
135
+ }
136
+ }
137
+ catch (error) {
138
+ return contractError(`invalid frontend-test-result-v1 artifact: ${error instanceof Error ? error.message : String(error)}`);
139
+ }
140
+ }
120
141
  // 5. Shell verification from report decision (exit-zero = report succeeded).
121
142
  const shellVerification = {
122
143
  exitZero: input.reportDecision.succeeded,
@@ -1,4 +1,4 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { writeDagRunJsonArtifact } from "../../../infrastructure/harness/artifact-store.js";
4
4
  import { executeDagNode, } from "../node-execution.js";
@@ -81,6 +81,28 @@ export function resolveRunLocalPath(runDir, ref, label) {
81
81
  }
82
82
  return resolved;
83
83
  }
84
+ async function materializeTokenBudgetBlockedEvidence(input) {
85
+ if (!input.workspaceRef)
86
+ return;
87
+ const workspace = resolveRunLocalPath(input.cwd, input.workspaceRef, "workspaceRef");
88
+ await mkdir(workspace, { recursive: true });
89
+ const resultPath = path.join(workspace, "case-result.json");
90
+ const caseId = typeof input.item === "object" && input.item !== null &&
91
+ typeof input.item.caseId === "string"
92
+ ? input.item.caseId
93
+ : String(input.item);
94
+ try {
95
+ const existing = JSON.parse(await readFile(resultPath, "utf-8"));
96
+ if (existing.caseId === caseId &&
97
+ (existing.status === "passed" || existing.status === "failed" || existing.status === "blocked"))
98
+ return;
99
+ }
100
+ catch {
101
+ // Missing or malformed evidence is replaced only for a child that never started.
102
+ }
103
+ await writeFile(path.join(workspace, "execution.md"), `# ${caseId}\n\nStatus: blocked\n\nReason: token-budget-exhausted\n`, "utf-8");
104
+ await writeFile(resultPath, `${JSON.stringify({ caseId, status: "blocked", blockedReason: "token-budget-exhausted", evidencePaths: ["execution.md"] }, null, 2)}\n`, "utf-8");
105
+ }
84
106
  export async function executeDynamicMapExpansion(input) {
85
107
  const started = Date.now();
86
108
  const items = resolveItemsFromSelector(input.expansion.itemsFrom, input.state);
@@ -195,6 +217,16 @@ export async function executeDynamicMapExpansion(input) {
195
217
  }
196
218
  continue;
197
219
  }
220
+ try {
221
+ await materializeTokenBudgetBlockedEvidence({
222
+ cwd: input.cwd,
223
+ workspaceRef: workspaceRefs[children.indexOf(remaining)],
224
+ item: items[children.indexOf(remaining)],
225
+ });
226
+ }
227
+ catch (error) {
228
+ throw new Error(`failed to materialize token-budget blocked evidence for ${remaining.id}: ${error instanceof Error ? error.message : String(error)}`);
229
+ }
198
230
  record.status = "SKIPPED";
199
231
  record.stderr = "blocked: token-budget-exhausted";
200
232
  if (!blockedChildren.some((entry) => entry.nodeId === remaining.id)) {
@@ -21,12 +21,10 @@ export function getNodeOutputAsJson(state, nodeId) {
21
21
  if (!raw) {
22
22
  throw new Error(`itemsFrom upstream node "${nodeId}" has no JSON output`);
23
23
  }
24
- try {
25
- return JSON.parse(raw);
26
- }
27
- catch (error) {
28
- throw new Error(`itemsFrom upstream node "${nodeId}" output is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
29
- }
24
+ const parsed = parseJsonFromText(raw);
25
+ if (parsed !== undefined)
26
+ return parsed;
27
+ throw new Error(`itemsFrom upstream node "${nodeId}" output is not valid JSON: ${raw.slice(0, 120)}`);
30
28
  }
31
29
  /** Normalize a line the same way verdict gates do (strip whole-line Markdown emphasis). */
32
30
  export function normalizeVerdictCandidateLine(value) {
@@ -147,10 +145,29 @@ export function parseConditionLiteral(raw) {
147
145
  return Number.isFinite(numeric) ? numeric : trimmed;
148
146
  }
149
147
  export function parseJsonFromText(value) {
150
- if (!value?.trim())
148
+ const raw = value?.trim();
149
+ if (!raw)
151
150
  return undefined;
151
+ // Pure JSON first.
152
+ try {
153
+ return JSON.parse(raw);
154
+ }
155
+ catch {
156
+ // continue
157
+ }
158
+ // Shell executor stdout prefixes each command with `$ <command>`. Dynamic
159
+ // selectors must consume exactly one trailing JSON payload after those echoes.
160
+ // Reject multi-payload or mixed prose to keep map expansion fail-closed.
161
+ const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
162
+ if (lines.length < 2)
163
+ return undefined;
164
+ const payload = lines.at(-1)?.trim();
165
+ const prefix = lines.slice(0, -1);
166
+ if (!payload || prefix.some((line) => !line.startsWith("$ "))) {
167
+ return undefined;
168
+ }
152
169
  try {
153
- return JSON.parse(value);
170
+ return JSON.parse(payload);
154
171
  }
155
172
  catch {
156
173
  return undefined;
@@ -0,0 +1,165 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
+ export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
7
+ const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
8
+ !path.win32.isAbsolute(value) &&
9
+ !value.includes("\\") &&
10
+ value.split("/").every((part) => part.length > 0 && part !== "." && part !== ".."), "must be a safe repo-relative POSIX path");
11
+ const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
12
+ const caseStatusSchema = z.enum(["passed", "failed", "blocked"]);
13
+ export const frontendTestResultContractSchema = z.object({
14
+ schemaVersion: z.literal(1),
15
+ sourceBinding: z.object({
16
+ taskId: z.string().min(1),
17
+ sources: z.array(z.object({
18
+ kind: z.enum(["requirement", "constraint", "reference"]),
19
+ path: safeRelativePathSchema,
20
+ sha256: sha256Schema,
21
+ }).strict()).min(1),
22
+ requirementIds: z.array(z.string().min(1)),
23
+ }).strict(),
24
+ manifest: z.object({
25
+ path: safeRelativePathSchema,
26
+ sha256: sha256Schema,
27
+ }).strict(),
28
+ cases: z.array(z.object({
29
+ caseId: z.string().min(1),
30
+ acIds: z.array(z.string().min(1)).min(1),
31
+ status: caseStatusSchema,
32
+ evidence: z.array(z.object({ path: safeRelativePathSchema, sha256: sha256Schema }).strict()),
33
+ blockedReason: z.string().min(1).optional(),
34
+ }).strict()).min(1),
35
+ totals: z.object({
36
+ cases: z.number().int().positive(),
37
+ passed: z.number().int().min(0),
38
+ failed: z.number().int().min(0),
39
+ blocked: z.number().int().min(0),
40
+ }).strict(),
41
+ acceptanceCoverage: z.object({
42
+ covered: z.array(z.string().min(1)),
43
+ missing: z.array(z.string().min(1)),
44
+ }).strict(),
45
+ integrationMode: z.enum(["real", "none"]),
46
+ outcome: z.enum(["passed", "failed", "incomplete"]),
47
+ }).strict().superRefine((value, ctx) => {
48
+ if (value.totals.cases !== value.cases.length) {
49
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["totals", "cases"], message: "totals.cases must equal cases length" });
50
+ }
51
+ for (const status of ["passed", "failed", "blocked"]) {
52
+ const actual = value.cases.filter((item) => item.status === status).length;
53
+ if (value.totals[status] !== actual) {
54
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["totals", status], message: `totals.${status} must equal case status count` });
55
+ }
56
+ }
57
+ if (value.outcome === "passed" && (value.totals.failed > 0 || value.totals.blocked > 0 || value.acceptanceCoverage.missing.length > 0 || value.integrationMode !== "real")) {
58
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["outcome"], message: "passed requires all cases passed, complete AC coverage, and real integration" });
59
+ }
60
+ if (value.integrationMode === "real" && value.outcome !== "passed") {
61
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["integrationMode"], message: "real integration requires passed outcome" });
62
+ }
63
+ });
64
+ function sha256(content) {
65
+ return createHash("sha256").update(content).digest("hex");
66
+ }
67
+ function assertInside(root, candidate, label) {
68
+ const relative = path.relative(root, candidate);
69
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
70
+ throw new Error(`${label} escapes its allowed root`);
71
+ }
72
+ }
73
+ async function readEvidenceFile(repoRoot, evidenceRoot, relative) {
74
+ if (!safeRelativePathSchema.safeParse(relative).success) {
75
+ throw new Error(`unsafe evidence path: ${relative}`);
76
+ }
77
+ const absolute = path.resolve(evidenceRoot, relative);
78
+ assertInside(evidenceRoot, absolute, "evidence path");
79
+ const resolved = await realpath(absolute);
80
+ assertInside(repoRoot, resolved, "evidence realpath");
81
+ const info = await lstat(resolved);
82
+ if (!info.isFile())
83
+ throw new Error(`evidence is not a file: ${relative}`);
84
+ return { path: relative, sha256: sha256(await readFile(resolved)) };
85
+ }
86
+ export async function materializeFrontendTestResult(input) {
87
+ if (!/^[a-z0-9][a-z0-9._-]*\.json$/.test(input.artifactName) || !/^[a-z0-9][a-z0-9._-]*$/.test(input.outputDir)) {
88
+ throw new Error("unsafe structured artifact path");
89
+ }
90
+ const sourceBinding = input.sourceBinding;
91
+ if (!sourceBinding?.taskId || !sourceBinding.sources?.length) {
92
+ throw new Error("frontend-test result requires frozen sourceBinding");
93
+ }
94
+ const manifestPath = "testcase/frontend/cases/manifest.json";
95
+ const manifestAbsolute = path.resolve(input.workspaceRoot, manifestPath);
96
+ const manifest = JSON.parse(await readFile(manifestAbsolute, "utf-8"));
97
+ if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.cases) || manifest.cases.length === 0) {
98
+ throw new Error("invalid or empty frontend case manifest");
99
+ }
100
+ const requiredIds = new Set(sourceBinding.requirementIds ?? []);
101
+ const covered = new Set();
102
+ const cases = [];
103
+ for (const raw of manifest.cases) {
104
+ const item = raw;
105
+ if (!item || typeof item.caseId !== "string" || !Array.isArray(item.acIds) || typeof item.evidenceDir !== "string") {
106
+ throw new Error("invalid frontend manifest case");
107
+ }
108
+ if (!item.evidenceDir.startsWith(`testcase/frontend/evidence/${item.caseId}/`)) {
109
+ throw new Error(`unsafe evidenceDir for ${item.caseId}`);
110
+ }
111
+ for (const acId of item.acIds) {
112
+ if (requiredIds.size > 0 && !requiredIds.has(acId))
113
+ throw new Error(`unknown AC in manifest: ${acId}`);
114
+ covered.add(acId);
115
+ }
116
+ const evidenceRoot = path.resolve(input.workspaceRoot, item.evidenceDir);
117
+ assertInside(input.workspaceRoot, evidenceRoot, "evidenceDir");
118
+ const resultRaw = JSON.parse(await readFile(path.join(evidenceRoot, "case-result.json"), "utf-8"));
119
+ const status = caseStatusSchema.parse(resultRaw.status);
120
+ if (resultRaw.caseId !== item.caseId || !Array.isArray(resultRaw.evidencePaths))
121
+ throw new Error(`invalid result identity for ${item.caseId}`);
122
+ if (status === "blocked" && (typeof resultRaw.blockedReason !== "string" || !resultRaw.blockedReason.trim()))
123
+ throw new Error(`blocked result requires reason for ${item.caseId}`);
124
+ const evidence = [await readEvidenceFile(input.workspaceRoot, evidenceRoot, "execution.md")];
125
+ for (const evidencePath of resultRaw.evidencePaths) {
126
+ if (typeof evidencePath !== "string" || evidencePath === "execution.md")
127
+ continue;
128
+ evidence.push(await readEvidenceFile(input.workspaceRoot, evidenceRoot, evidencePath));
129
+ }
130
+ if (status === "passed" && !evidence.some((entry) => /\.(png|jpg|jpeg|webp|zip|har|webm|mp4|ya?ml)$/i.test(entry.path))) {
131
+ throw new Error(`passed case requires browser evidence: ${item.caseId}`);
132
+ }
133
+ cases.push({ caseId: item.caseId, acIds: item.acIds, status, evidence, ...(status === "blocked" ? { blockedReason: resultRaw.blockedReason } : {}) });
134
+ }
135
+ const missing = [...requiredIds].filter((id) => !covered.has(id));
136
+ const totals = {
137
+ cases: cases.length,
138
+ passed: cases.filter((item) => item.status === "passed").length,
139
+ failed: cases.filter((item) => item.status === "failed").length,
140
+ blocked: cases.filter((item) => item.status === "blocked").length,
141
+ };
142
+ const outcome = totals.failed > 0 ? "failed" : totals.blocked > 0 || missing.length > 0 ? "incomplete" : "passed";
143
+ const result = frontendTestResultContractSchema.parse({
144
+ schemaVersion: 1,
145
+ sourceBinding,
146
+ manifest: { path: manifestPath, sha256: sha256(await readFile(manifestAbsolute)) },
147
+ cases,
148
+ totals,
149
+ acceptanceCoverage: { covered: [...covered].sort(), missing: missing.sort() },
150
+ integrationMode: outcome === "passed" ? "real" : "none",
151
+ outcome,
152
+ });
153
+ const relativePath = path.posix.join(input.outputDir, input.artifactName);
154
+ const artifactPath = await writeDagRunJsonArtifact(input.runDir, relativePath, result);
155
+ return { path: artifactPath, sha256: sha256(`${JSON.stringify(result, null, 2)}\n`), schemaId: FRONTEND_TEST_RESULT_SCHEMA_ID };
156
+ }
157
+ export function buildFrontendTestOutcomeGateShellSnippet(options) {
158
+ const resultRelativePath = options?.resultRelativePath ?? "contracts/frontend-test-result.json";
159
+ return [
160
+ 'test -n "${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for frontend-test outcome gate" >&2; exit 2; }',
161
+ `RESULT="\${HARNESS_DAG_RUN_DIR}/${resultRelativePath}"`,
162
+ 'test -f "${RESULT}" || { echo "missing frontend-test result: ${RESULT}" >&2; exit 2; }',
163
+ `node -e 'const fs=require("fs");const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const ok=r.outcome==="passed"&&r.integrationMode==="real"&&Number(r.totals?.failed||0)===0&&Number(r.totals?.blocked||0)===0&&Array.isArray(r.acceptanceCoverage?.missing)&&r.acceptanceCoverage.missing.length===0;console.log("frontend-test outcome="+r.outcome+" integrationMode="+r.integrationMode);if(!ok)process.exit(1);' "\${RESULT}"`,
164
+ ].join("; ");
165
+ }
@@ -20,6 +20,7 @@ import { normalizeTaskRequirementText, resolveTaskDagTemplateSelection, } from "
20
20
  import { BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT, buildBackendTestExecutionPreflightShellSnippet, } from "./backend-test-execution-contract.js";
21
21
  import { buildBackendTestEffectiveResultSelectorShellSnippet, buildBackendTestRepairEligibilityShellSnippet, buildBackendTestRepairSafetyShellSnippet, } from "./backend-test-repair-contract.js";
22
22
  import { buildBackendTestOutcomeGateShellSnippet } from "./backend-test-result-contract.js";
23
+ import { buildFrontendTestOutcomeGateShellSnippet } from "./frontend-test-result-contract.js";
23
24
  import { classifyFrontendRisk, } from "./frontend-risk.js";
24
25
  import { discoverFrontendProjectCapability, } from "./frontend-project-capability.js";
25
26
  import { FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, loadFrontendImplementationContractJsonSchema, } from "./frontend-implementation-contract.js";
@@ -3660,11 +3661,8 @@ function buildFrontendTestHybridDag(sources) {
3660
3661
  const hasFrontendTestWriteScope = sources.taskConfig.allowedPaths.some((pattern) => pattern === "testcase/frontend/**" ||
3661
3662
  pattern === "testcase/**" ||
3662
3663
  pattern === "**");
3663
- const hasReportWriteScope = sources.taskConfig.allowedPaths.some((pattern) => pattern === "docs/test-reports/**" ||
3664
- pattern === "docs/**" ||
3665
- pattern === "**");
3666
- if (!hasFrontendTestWriteScope || !hasReportWriteScope) {
3667
- throw new Error('frontend-test requires task.json allowedPaths to include both "testcase/frontend/**" and "docs/test-reports/**" (or explicit containing globs).');
3664
+ if (!hasFrontendTestWriteScope) {
3665
+ throw new Error('frontend-test requires task.json allowedPaths to include "testcase/frontend/**" (or an explicit containing glob).');
3668
3666
  }
3669
3667
  const forbidden = commonForbiddenPaths(sources);
3670
3668
  const ragWriteSet = ["testcase/frontend/rag/**"];
@@ -3675,7 +3673,7 @@ function buildFrontendTestHybridDag(sources) {
3675
3673
  JSON.stringify([
3676
3674
  "const fs=require('fs'),path=require('path');",
3677
3675
  "const file='testcase/frontend/cases/manifest.json'; if(!fs.existsSync(file)) throw new Error('missing '+file);",
3678
- "const manifest=JSON.parse(fs.readFileSync(file,'utf8')); if(manifest.schemaVersion!==1||!Array.isArray(manifest.cases)) throw new Error('invalid frontend case manifest');",
3676
+ "const manifest=JSON.parse(fs.readFileSync(file,'utf8')); if(manifest.schemaVersion!==1||!Array.isArray(manifest.cases)||manifest.cases.length===0) throw new Error('invalid or empty frontend case manifest');",
3679
3677
  "const dims=new Set(['core','boundary','flow','backend']);",
3680
3678
  "const seen=new Set(); const seenCasePath=new Set(); const seenEvidenceDir=new Set();",
3681
3679
  "for(const c of manifest.cases){",
@@ -3685,14 +3683,27 @@ function buildFrontendTestHybridDag(sources) {
3685
3683
  " if(!Array.isArray(c.acIds)||c.acIds.length===0||c.acIds.some(a=>typeof a!=='string'||!a.trim())) throw new Error('invalid acIds');",
3686
3684
  " for(const k of ['casePath','evidenceDir']){ const v=c[k]; if(typeof v!=='string'||path.isAbsolute(v)||v.includes('..')) throw new Error('unsafe '+k); }",
3687
3685
  " if(c.casePath!=='testcase/frontend/cases/'+c.caseId+'.md') throw new Error('casePath must match caseId');",
3688
- // Accept evidenceDir as case root or nested path under that root.
3686
+ // Accept evidenceDir as the case root or a nested path under that root.
3689
3687
  " { const prefix='testcase/frontend/evidence/'+c.caseId; if(!(c.evidenceDir===prefix||c.evidenceDir.startsWith(prefix+'/'))) throw new Error('case path escapes frontend test roots'); }",
3688
+ " if(!fs.existsSync(c.casePath)) throw new Error('missing case file '+c.casePath);",
3690
3689
  " if(seenCasePath.has(c.casePath)) throw new Error('duplicate casePath'); seenCasePath.add(c.casePath);",
3691
3690
  " if(seenEvidenceDir.has(c.evidenceDir)) throw new Error('duplicate evidenceDir'); seenEvidenceDir.add(c.evidenceDir);",
3692
3691
  "}",
3693
3692
  "process.stdout.write(JSON.stringify({cases:manifest.cases}));",
3694
3693
  ].join("")),
3695
3694
  ].join(" ");
3695
+ const evidenceValidation = [
3696
+ "node -e",
3697
+ JSON.stringify([
3698
+ "const fs=require('fs'),path=require('path');",
3699
+ "const manifestPath='testcase/frontend/cases/manifest.json';if(!fs.existsSync(manifestPath))throw new Error('missing '+manifestPath);",
3700
+ "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));if(!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');",
3701
+ "const statuses=new Set(['passed','failed','blocked']);let failed=false;",
3702
+ "for(const c of manifest.cases){const dir=c&&c.evidenceDir;if(!c||typeof c.caseId!=='string'||typeof dir!=='string'||path.isAbsolute(dir)||dir.includes('..')||!dir.startsWith('testcase/frontend/evidence/'+c.caseId+'/')){console.error('invalid case evidence target');failed=true;continue;}const execution=path.join(dir,'execution.md'),resultPath=path.join(dir,'case-result.json');if(!fs.existsSync(execution)){console.error(c.caseId+': missing '+execution);failed=true;}if(!fs.existsSync(resultPath)){console.error(c.caseId+': missing '+resultPath);failed=true;continue;}let result;try{result=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch{console.error(c.caseId+': invalid JSON '+resultPath);failed=true;continue;}if(!result||result.caseId!==c.caseId||!statuses.has(result.status)||!Array.isArray(result.evidencePaths)||result.evidencePaths.some(p=>typeof p!=='string'||path.isAbsolute(p)||p.includes('..'))){console.error(c.caseId+': result must have matching caseId, passed|failed|blocked status, and safe evidencePaths array');failed=true;continue;}if(result.status==='passed'&&(result.evidencePaths.length<2||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4)$/i.test(p)))){console.error(c.caseId+': passed result requires browser evidence');failed=true;}if(result.status==='blocked'&&(typeof result.blockedReason!=='string'||!result.blockedReason.trim())){console.error(c.caseId+': blocked result requires blockedReason');failed=true;}}",
3703
+ "if(failed)process.exit(1);console.log('frontend case evidence validation ok cases='+manifest.cases.length);",
3704
+ ].join("")),
3705
+ ].join(" ");
3706
+ const frontendTestOutcomeGate = buildFrontendTestOutcomeGateShellSnippet();
3696
3707
  const spec = {
3697
3708
  version: 3,
3698
3709
  title: `Frontend test DAG: ${sources.taskConfig.title}`,
@@ -3706,7 +3717,8 @@ function buildFrontendTestHybridDag(sources) {
3706
3717
  "frontend-test-dag generates Markdown cases and browser evidence only; it must not generate pytest or Playwright test source code.",
3707
3718
  "Each browser case runs serially in a fresh Pi execution boundary. Persist its evidence before starting the next case.",
3708
3719
  "Use only the declared isolated test environment. Production URLs, real credentials, and unauthorized data are blocked.",
3709
- "Browser startup for generated cases must be playwright-cli open --browser=chrome --headed <base-url>.",
3720
+ "Browser startup for generated cases must be playwright-cli open --browser=chrome --headed <base-url>; generated operations stay in the default browser session and must not use unverified named-session flags.",
3721
+ "A frontend case review must emit VERDICT: pass before manifest materialization; request-revision blocks browser execution.",
3710
3722
  "Token settings are post-case stop thresholds, never a hard provider token cap. Unstarted cases after a threshold are blocked: token-budget-exhausted.",
3711
3723
  ],
3712
3724
  defaults: {
@@ -3748,8 +3760,21 @@ function buildFrontendTestHybridDag(sources) {
3748
3760
  ].join("\n\n"),
3749
3761
  },
3750
3762
  {
3751
- id: "generate-frontend-functional-cases-pi",
3763
+ id: "materialize-frontend-test-execution-shell",
3752
3764
  depends_on: ["retrieve-frontend-test-context-pi"],
3765
+ role: "verifier",
3766
+ executor: "shell",
3767
+ complexity: "LOW",
3768
+ writePolicy: "read-only",
3769
+ allowedPaths: [...ragWriteSet],
3770
+ forbiddenPaths: forbidden,
3771
+ outputContract: "Fail-closed preflight for an isolated non-production frontend test execution contract.",
3772
+ subtask_prompt: "Validate that RAG context declares a non-production base URL/env name, fixture/reset isolation, browser startup, and no credential values.",
3773
+ shell: { commands: [["node -e", JSON.stringify("const fs=require('fs');const p='testcase/frontend/rag/context.md';if(!fs.existsSync(p))throw new Error('missing '+p);const s=fs.readFileSync(p,'utf8');const required=[['base URL',/base[- ]url/i],['fixture',/fixture/i],['reset',/reset|重置/i],['playwright-cli open --browser=chrome --headed',/playwright-cli open --browser=chrome --headed/i]];for(const [label,pattern] of required)if(!pattern.test(s))throw new Error('frontend-test execution contract missing '+label);if(/https?:\\/\\/(?:www\\.)?[^\\s]*(?:prod|production)/i.test(s))throw new Error('production URL forbidden');console.log('frontend-test-execution-v1 validated')")].join(" ")], cwd: ".", timeoutMs: 60000 },
3774
+ },
3775
+ {
3776
+ id: "generate-frontend-functional-cases-pi",
3777
+ depends_on: ["materialize-frontend-test-execution-shell"],
3753
3778
  role: "implementer",
3754
3779
  executor: "pi",
3755
3780
  toolProfile: "write",
@@ -3763,8 +3788,8 @@ function buildFrontendTestHybridDag(sources) {
3763
3788
  "Use skill playwright-cli-case-generator.",
3764
3789
  "Read only testcase/frontend/rag/context.md, testcase/frontend/rag/coverage-map.md, and existing testcase/frontend/cases/. Write only testcase/frontend/cases/**.",
3765
3790
  "Generate Markdown cases, index.md and manifest.json (schemaVersion 1; cases[] with caseId, casePath, dimension, acIds, evidenceDir). IDs use FE-<FEATURE>-<NNN>-<dimension>; dimensions core|boundary|flow|backend.",
3766
- "Never infer API fields, constraints, SLA, credentials, or unrecorded test data. Do not create pytest or Playwright source. Every browser start command is: playwright-cli open --browser=chrome --headed <base-url>.",
3767
- "Each case must be independent, declare its session/preconditions/data cleanup, UI assertions, evidence paths under testcase/frontend/evidence/<case-id>/, and mark unsafe/missing dependencies blocked.",
3791
+ "Never infer API fields, constraints, SLA, credentials, or unrecorded test data. Do not create pytest or Playwright source. Every browser start command is: playwright-cli open --browser=chrome --headed <base-url>. Use the same default browser session for every subsequent command; never write -s=<case-id> or assume named-session binding.",
3792
+ "Each case must be independently reproducible: for every executable sub-scenario state fixture/reset, UI reset, a fresh snapshot before references are used, exact evidence write point, preconditions/data cleanup, UI assertions, and evidence paths under testcase/frontend/evidence/<case-id>/. If the isolated environment is unavailable, require writing blocked evidence before any browser command.",
3768
3793
  ].join("\n\n"),
3769
3794
  },
3770
3795
  {
@@ -3776,19 +3801,72 @@ function buildFrontendTestHybridDag(sources) {
3776
3801
  writePolicy: "read-only",
3777
3802
  allowedPaths: [...ragWriteSet, ...casesWriteSet],
3778
3803
  forbiddenPaths: forbidden,
3779
- outputContract: "First line VERDICT: pass or VERDICT: request-revision, followed by AC-to-case coverage and execution risk findings; no writes.",
3780
- subtask_prompt: "Review only the RAG package and frontend Markdown cases. Verify traceability, independent execution, safe data/environment handling, manifest correctness, and evidence requirements. The verdict is advisory and does not block case execution.",
3804
+ outputContract: "First line VERDICT: pass or VERDICT: request-revision, followed by AC-to-case coverage and execution risk findings; no writes. request-revision blocks manifest materialization.",
3805
+ subtask_prompt: "Review only the RAG package and frontend Markdown cases. Verify traceability, independent execution, safe data/environment handling, manifest correctness, session consistency, fixture/UI reset and fresh snapshot steps, and evidence requirements. Any Important or Critical finding requires VERDICT: request-revision. Browser execution is blocked unless this review passes.",
3781
3806
  },
3782
3807
  {
3783
- id: "materialize-frontend-case-manifest-shell",
3808
+ id: "revise-frontend-cases-pi",
3784
3809
  depends_on: ["review-frontend-cases-pi"],
3810
+ runIf: "$.nodes['review-frontend-cases-pi'].firstVerdictLine == 'VERDICT: request-revision'",
3811
+ role: "implementer",
3812
+ executor: "pi",
3813
+ toolProfile: "write",
3814
+ complexity: "HIGH",
3815
+ writePolicy: "exclusive",
3816
+ writeSet: casesWriteSet,
3817
+ allowedPaths: [...ragWriteSet, ...casesWriteSet],
3818
+ forbiddenPaths: forbidden,
3819
+ outputContract: "Apply the one permitted frontend case revision under testcase/frontend/cases/** only; no browser execution or evidence writes.",
3820
+ subtask_prompt: "This is the only permitted case revision. Read the first review findings and the RAG package. Revise only testcase/frontend/cases/**, preserve traceable AC mappings, and do not execute a browser or write evidence.",
3821
+ },
3822
+ {
3823
+ id: "review-frontend-cases-final-pi",
3824
+ depends_on: ["revise-frontend-cases-pi"],
3825
+ runIf: "$.nodes['review-frontend-cases-pi'].firstVerdictLine == 'VERDICT: request-revision'",
3826
+ role: "reviewer",
3827
+ executor: "pi",
3828
+ complexity: "HIGH",
3829
+ writePolicy: "read-only",
3830
+ allowedPaths: [...ragWriteSet, ...casesWriteSet],
3831
+ forbiddenPaths: forbidden,
3832
+ outputContract: "First line VERDICT: pass or VERDICT: request-revision after the single allowed case revision; no writes.",
3833
+ subtask_prompt: "Perform the final frontend case review after the sole permitted revision. Apply the same traceability, isolation, manifest, reset, session, snapshot, and evidence checks. First verdict line must be exact; any Important or Critical finding requires request-revision. Do not write files.",
3834
+ },
3835
+ {
3836
+ id: "final-frontend-case-review-gate-shell",
3837
+ depends_on: ["review-frontend-cases-pi", "review-frontend-cases-final-pi"],
3838
+ dependsPolicy: "all-or-condition-skip",
3839
+ role: "verifier",
3840
+ executor: "shell",
3841
+ complexity: "LOW",
3842
+ writePolicy: "read-only",
3843
+ allowedPaths: [...ragWriteSet, ...casesWriteSet],
3844
+ forbiddenPaths: forbidden,
3845
+ outputContract: "Pass-only effective frontend case review gate; final review takes precedence when the revision branch ran.",
3846
+ subtask_prompt: "Authorize manifest materialization only after the effective frontend case review passes.",
3847
+ shell: {
3848
+ commands: [],
3849
+ verdictGate: {
3850
+ fromNodeId: "review-frontend-cases-final-pi",
3851
+ fallbackFromNodeIds: ["review-frontend-cases-pi"],
3852
+ accept: ["VERDICT: pass"],
3853
+ label: "effective frontend case review",
3854
+ lineMode: "first-verdict-line",
3855
+ },
3856
+ cwd: ".",
3857
+ timeoutMs: 60000,
3858
+ },
3859
+ },
3860
+ {
3861
+ id: "materialize-frontend-case-manifest-shell",
3862
+ depends_on: ["final-frontend-case-review-gate-shell"],
3785
3863
  role: "verifier",
3786
3864
  executor: "shell",
3787
3865
  complexity: "LOW",
3788
3866
  writePolicy: "read-only",
3789
3867
  allowedPaths: casesWriteSet,
3790
3868
  forbiddenPaths: forbidden,
3791
- outputContract: "stdout is exactly JSON { cases: [...] } after deterministic frontend manifest validation.",
3869
+ outputContract: "Validated frontend manifest payload { cases: [...] }; shell command echo is permitted only as the prefix before exactly one final JSON line.",
3792
3870
  subtask_prompt: "Validate and materialize the generated frontend case manifest.",
3793
3871
  shell: { commands: [manifestValidation], cwd: ".", timeoutMs: 120000 },
3794
3872
  },
@@ -3812,6 +3890,7 @@ function buildFrontendTestHybridDag(sources) {
3812
3890
  maxItems: config.maxCasesPerBatch,
3813
3891
  maxExpandedNodes: config.maxCasesPerBatch,
3814
3892
  childIdPrefix: "execute-frontend-case",
3893
+ workspaceTemplate: "{{case.evidenceDir}}",
3815
3894
  tokenBudget: {
3816
3895
  maxTokensPerCase: config.maxTokensPerCase,
3817
3896
  maxTotalTokens: config.maxTotalTokens,
@@ -3835,15 +3914,67 @@ function buildFrontendTestHybridDag(sources) {
3835
3914
  subtaskPromptTemplate: [
3836
3915
  "Execute exactly case {{case.caseId}} from {{case.casePath}} using playwright-cli and webapp-testing. This is a fresh Pi session; do not use /new.",
3837
3916
  "Use only the declared isolated test environment. If CLI/browser/base URL/credentials/fixture isolation is missing, record blocked rather than installing tools or guessing.",
3838
- "Use playwright-cli open --browser=chrome --headed <base-url>. Persist execution.md, case-result.json, screenshots/trace/video/logs under {{case.evidenceDir}} before returning.",
3917
+ "Use exactly this browser start command prefix: playwright-cli open --browser=chrome --headed <base-url>. Do not put session flags before open. Every later playwright-cli command must use that same default browser session; must not use -s=<case-id>, -s=, or any named-session flag because no session binding is verified.",
3918
+ "For every sub-scenario, record fixture/reset, UI reset, and a fresh snapshot before using element references. If the isolated environment is missing, write blocked evidence before any browser command; do not open or connect to a browser.",
3919
+ "Before returning, always persist {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json. case-result.json must be JSON with matching caseId, status as passed, failed, or blocked, and evidencePaths array; a blocked result must include non-empty blockedReason and must never imply pass.",
3920
+ "Run a local deterministic validation before returning: node -e \"const fs=require('fs');const p='{{case.evidenceDir}}';const r=JSON.parse(fs.readFileSync(p+'case-result.json','utf8'));if(!fs.existsSync(p+'execution.md')||r.caseId!=='{{case.caseId}}'||!['passed','failed','blocked'].includes(r.status)||!Array.isArray(r.evidencePaths)||(r.status==='blocked'&&!(typeof r.blockedReason==='string'&&r.blockedReason.trim())))process.exit(1)\". Persist screenshots/trace/video/logs when actually available. execution.md must record the executed or blocked steps, base URL safety decision, fixture/reset and request-observation availability, and evidence file list.",
3839
3921
  "A business failed or blocked case is a recorded result, not a node failure. Close the session and return only compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
3840
3922
  ].join("\n\n"),
3841
3923
  },
3842
3924
  },
3843
3925
  },
3844
3926
  {
3845
- id: "review-frontend-execution-pi",
3927
+ id: "validate-frontend-case-evidence-shell",
3846
3928
  depends_on: ["execute-frontend-cases-map"],
3929
+ role: "verifier",
3930
+ executor: "shell",
3931
+ complexity: "LOW",
3932
+ writePolicy: "read-only",
3933
+ allowedPaths: ["testcase/frontend/cases/**", `${evidenceRoot}/**`],
3934
+ forbiddenPaths: forbidden,
3935
+ outputContract: "Deterministic validation that every manifest case has execution.md and valid matching case-result.json; blocked results require blockedReason.",
3936
+ subtask_prompt: "Validate all frontend case evidence before evidence review; fail closed on missing or malformed records.",
3937
+ shell: { commands: [evidenceValidation], cwd: ".", timeoutMs: 120000 },
3938
+ },
3939
+ {
3940
+ id: "materialize-frontend-test-result-shell",
3941
+ depends_on: ["validate-frontend-case-evidence-shell"],
3942
+ role: "verifier",
3943
+ executor: "shell",
3944
+ complexity: "LOW",
3945
+ writePolicy: "read-only",
3946
+ allowedPaths: ["testcase/frontend/cases/**", `${evidenceRoot}/**`],
3947
+ forbiddenPaths: forbidden,
3948
+ outputContract: "Run-owned hash-bound frontend-test-result-v1 derived only from the manifest and validated case evidence.",
3949
+ subtask_prompt: "Materialize the authoritative frontend-test-result-v1. Do not use Pi prose or retrospective output as input.",
3950
+ shell: {
3951
+ commands: [],
3952
+ jsonArtifactGate: {
3953
+ fromNodeId: "validate-frontend-case-evidence-shell",
3954
+ schemaId: "frontend-test-result-v1",
3955
+ artifactName: "frontend-test-result.json",
3956
+ outputDir: "contracts",
3957
+ },
3958
+ cwd: ".",
3959
+ timeoutMs: 120000,
3960
+ },
3961
+ },
3962
+ {
3963
+ id: "frontend-test-result-outcome-gate-shell",
3964
+ depends_on: ["materialize-frontend-test-result-shell"],
3965
+ role: "verifier",
3966
+ executor: "shell",
3967
+ complexity: "LOW",
3968
+ writePolicy: "read-only",
3969
+ allowedPaths: [],
3970
+ forbiddenPaths: forbidden,
3971
+ outputContract: "Pass only when the run-owned frontend-test-result-v1 records outcome=passed and integrationMode=real.",
3972
+ subtask_prompt: "Gate the authoritative frontend-test result before Pi review and retrospective.",
3973
+ shell: { commands: [frontendTestOutcomeGate], cwd: ".", timeoutMs: 60000 },
3974
+ },
3975
+ {
3976
+ id: "review-frontend-execution-pi",
3977
+ depends_on: ["frontend-test-result-outcome-gate-shell"],
3847
3978
  role: "reviewer",
3848
3979
  executor: "pi",
3849
3980
  complexity: "HIGH",
@@ -3861,11 +3992,11 @@ function buildFrontendTestHybridDag(sources) {
3861
3992
  toolProfile: "write",
3862
3993
  complexity: "MED",
3863
3994
  writePolicy: "exclusive",
3864
- writeSet: ["docs/test-reports/**"],
3865
- allowedPaths: ["testcase/frontend/**", "docs/test-reports/**"],
3995
+ writeSet: ["testcase/frontend/reports/**"],
3996
+ allowedPaths: ["testcase/frontend/**"],
3866
3997
  forbiddenPaths: forbidden,
3867
- outputContract: "Write frontend-test-retrospect-<date>.md with coverage, pass/fail/blocked, risks, findings, and A/B/C/D rating.",
3868
- subtask_prompt: "Write the frontend test retrospective under docs/test-reports/. Summarize coverage, passed/failed/blocked cases (including token-budget-exhausted), review findings, browser anomalies, residual risks, and A/B/C/D rating. Blocked cases never count as passed.",
3998
+ outputContract: "Write frontend-test retrospective under testcase/frontend/reports/frontend-test-retrospect-<date>.md with coverage, pass/fail/blocked, risks, findings, and A/B/C/D rating.",
3999
+ subtask_prompt: "Write the frontend test retrospective under testcase/frontend/reports/. Summarize coverage, passed/failed/blocked cases (including token-budget-exhausted), review findings, browser anomalies, residual risks, and A/B/C/D rating. Blocked cases never count as passed. Do not write docs/**.",
3869
4000
  },
3870
4001
  ],
3871
4002
  };
@@ -91,6 +91,7 @@ export const dagJsonArtifactSchemaIdSchema = z.enum([
91
91
  "backend-test-semantic-review-v1",
92
92
  "backend-test-case-manifest-v1",
93
93
  "frontend-implementation-contract-v1",
94
+ "frontend-test-result-v1",
94
95
  ]);
95
96
  export const dagJsonArtifactGateSchema = z.object({
96
97
  fromNodeId: z.string().regex(/^[a-z][a-z0-9-]*$/),
@@ -2,4 +2,21 @@
2
2
 
3
3
  Use `playwright-cli-case-generator`. Read only `testcase/frontend/rag/context.md`, `coverage-map.md`, and existing `testcase/frontend/cases/`; write only that cases directory. Produce Markdown cases, `index.md`, and schema-version-1 `manifest.json`. Do not generate pytest or Playwright source code.
4
4
 
5
- Each case is independently executable and includes AC mapping, preconditions, session, cleanup, UI assertions, and isolated evidence paths. Use dimensions `core`, `boundary`, `flow`, or `backend`. Never guess API fields, constraints, SLA, credentials, or unrecorded data. The open command is exactly `playwright-cli open --browser=chrome --headed <base-url>`.
5
+ Each case is independently executable and includes AC mapping, preconditions, cleanup, UI assertions, and its isolated evidence path. Use dimensions `core`, `boundary`, `flow`, or `backend`. Never guess API fields, constraints, SLA, credentials, or unrecorded data.
6
+
7
+ Every case must use this exact browser-start command prefix:
8
+
9
+ ```text
10
+ playwright-cli open --browser=chrome --headed <base-url>
11
+ ```
12
+
13
+ Do not put a session flag before `open`. Every later Playwright CLI command must stay in that same default browser session: do **not** emit `-s=<case-id>`, `-s=...`, or assume an undocumented named-session binding.
14
+
15
+ For every executable sub-scenario, state the fixture/reset operation, UI reset operation, a fresh snapshot before using element references, and the exact evidence write point. If the isolated environment is unavailable, require writing blocked evidence before any browser command; do not open or connect to a browser.
16
+
17
+ Each case must require the executor to persist, even when blocked:
18
+
19
+ - `testcase/frontend/evidence/<case-id>/execution.md`
20
+ - `testcase/frontend/evidence/<case-id>/case-result.json`
21
+
22
+ `case-result.json` must be valid JSON containing the matching `caseId`, `status` (`passed`, `failed`, or `blocked`), and an `evidencePaths` array. A blocked result must include a non-empty `blockedReason`, such as `isolated-test-environment-unavailable` or `token-budget-exhausted`, and must never claim or imply a pass. `execution.md` records attempted or blocked steps, base-URL safety decision, fixture/reset and request-observation availability, timestamps, and the evidence-file list. Screenshots, snapshots, traces, videos, and logs are required only when actually available and must stay under the same case evidence directory.
@@ -2,22 +2,412 @@
2
2
  "$schema": "./agent-dag.schema.json",
3
3
  "version": 3,
4
4
  "title": "Frontend test RAG DAG template",
5
- "runtimeContract": { "schemaVersion": 1, "agentRuntime": "pi-only", "repairWriterProtocol": "explicit-node-v1" },
5
+ "runtimeContract": {
6
+ "schemaVersion": 1,
7
+ "agentRuntime": "pi-only",
8
+ "repairWriterProtocol": "explicit-node-v1"
9
+ },
6
10
  "objective": "Build a frontend test RAG package, generate Markdown cases, execute each case serially through playwright-cli, and retain browser evidence.",
7
11
  "globalConstraints": [
8
12
  "Do not generate pytest or Playwright source code.",
9
13
  "Only use declared isolated test environments; production URLs and real credentials are blocked.",
10
- "Every generated browser start command is playwright-cli open --browser=chrome --headed <base-url>.",
14
+ "Every generated browser start command is playwright-cli open --browser=chrome --headed <base-url>; subsequent commands stay in that default session and must not use unverified named-session flags.",
15
+ "A frontend case review must emit VERDICT: pass before manifest materialization; request-revision blocks browser execution.",
11
16
  "Case children execute serially. Persist each case result, logs and browser evidence before the next child starts.",
12
17
  "A token threshold is a post-case stop check, not a model hard token cap; unstarted cases must be recorded as blocked: token-budget-exhausted."
13
18
  ],
14
19
  "tasks": [
15
- { "id": "retrieve-frontend-test-context-pi", "depends_on": [], "executor": "pi", "role": "planner", "toolProfile": "write", "complexity": "HIGH", "writePolicy": "exclusive", "writeSet": ["testcase/frontend/rag/**"], "allowedPaths": ["REPLACE/WITH/SOURCE/PATH/**", "testcase/frontend/rag/**"], "forbiddenPaths": [".harness/**", "artifacts/**"], "outputContract": "RAG context.md and coverage-map.md.", "subtask_prompt_markdown": "./frontend-test-dag.retrieve-context.prompt.md" },
16
- { "id": "generate-frontend-functional-cases-pi", "depends_on": ["retrieve-frontend-test-context-pi"], "executor": "pi", "role": "implementer", "toolProfile": "write", "complexity": "HIGH", "writePolicy": "exclusive", "writeSet": ["testcase/frontend/cases/**"], "allowedPaths": ["testcase/frontend/rag/**", "testcase/frontend/cases/**"], "forbiddenPaths": [".harness/**", "artifacts/**"], "outputContract": "Markdown cases, index.md and manifest.json schemaVersion 1; no test source code.", "subtask_prompt_markdown": "./frontend-test-dag.generate-cases.prompt.md" },
17
- { "id": "review-frontend-cases-pi", "depends_on": ["generate-frontend-functional-cases-pi"], "executor": "pi", "role": "reviewer", "complexity": "HIGH", "writePolicy": "read-only", "allowedPaths": ["testcase/frontend/rag/**", "testcase/frontend/cases/**"], "forbiddenPaths": [".harness/**", "artifacts/**"], "outputContract": "First line VERDICT: pass or VERDICT: request-revision; no writes.", "subtask_prompt_markdown": "./frontend-test-dag.review-cases.prompt.md" },
18
- { "id": "materialize-frontend-case-manifest-shell", "depends_on": ["review-frontend-cases-pi"], "executor": "shell", "role": "verifier", "complexity": "LOW", "writePolicy": "read-only", "allowedPaths": ["testcase/frontend/cases/**"], "forbiddenPaths": [".harness/**", "artifacts/**"], "outputContract": "stdout exactly JSON { cases: [...] } after manifest validation.", "subtask_prompt": "Validate the generated frontend case manifest." },
19
- { "id": "execute-frontend-cases-map", "depends_on": ["materialize-frontend-case-manifest-shell"], "executor": "static", "role": "verifier", "complexity": "LOW", "writePolicy": "none", "allowedPaths": [], "forbiddenPaths": [".harness/**", "artifacts/**"], "outputContract": "Serial aggregate of browser case results.", "subtask_prompt": "Expand the validated manifest into serial browser case children.", "static": { "resultMarkdown": "Frontend case map expansion barrier." }, "dynamicExpansion": { "type": "map_agent", "workflowNodeId": "execute-frontend-cases-map", "itemsFrom": "$.nodes['materialize-frontend-case-manifest-shell'].output.cases", "itemName": "case", "maxItems": 20, "maxExpandedNodes": 20, "childIdPrefix": "execute-frontend-case", "tokenBudget": { "maxTokensPerCase": 20000, "maxTotalTokens": 200000 }, "childTask": { "executor": "pi", "role": "verifier", "skills": ["playwright-cli", "webapp-testing"], "toolProfile": "write", "complexity": "MED", "subtaskPromptTemplate": "Execute {{case.caseId}} from {{case.casePath}} in a fresh Pi session. Use playwright-cli open --browser=chrome --headed <base-url>. Persist result, logs and screenshots/trace/video under {{case.evidenceDir}}; return compact JSON only.", "outputContract": "Compact JSON <=1200 chars.", "writePolicy": "exclusive", "allowedPaths": ["testcase/frontend/cases/{{case.caseId}}.md", "testcase/frontend/rag/context.md", "testcase/frontend/rag/coverage-map.md", "testcase/frontend/evidence/{{case.caseId}}/**"], "forbiddenPaths": [".harness/**", "artifacts/**"], "writeSet": ["testcase/frontend/evidence/{{case.caseId}}/**"] } } },
20
- { "id": "review-frontend-execution-pi", "depends_on": ["execute-frontend-cases-map"], "executor": "pi", "role": "reviewer", "complexity": "HIGH", "writePolicy": "read-only", "allowedPaths": ["testcase/frontend/**"], "forbiddenPaths": [".harness/**", "artifacts/**"], "outputContract": "AC-to-case-to-browser-evidence review.", "subtask_prompt_markdown": "./frontend-test-dag.review-execution.prompt.md" },
21
- { "id": "frontend-test-retrospect-pi", "depends_on": ["review-frontend-execution-pi"], "executor": "pi", "role": "closeout", "toolProfile": "write", "complexity": "MED", "writePolicy": "exclusive", "writeSet": ["docs/test-reports/**"], "allowedPaths": ["testcase/frontend/**", "docs/test-reports/**"], "forbiddenPaths": [".harness/**", "artifacts/**"], "outputContract": "Frontend test retrospective with A/B/C/D rating.", "subtask_prompt_markdown": "./frontend-test-dag.retrospect.prompt.md" }
20
+ {
21
+ "id": "retrieve-frontend-test-context-pi",
22
+ "depends_on": [],
23
+ "executor": "pi",
24
+ "role": "planner",
25
+ "toolProfile": "write",
26
+ "complexity": "HIGH",
27
+ "writePolicy": "exclusive",
28
+ "writeSet": [
29
+ "testcase/frontend/rag/**"
30
+ ],
31
+ "allowedPaths": [
32
+ "REPLACE/WITH/SOURCE/PATH/**",
33
+ "testcase/frontend/rag/**"
34
+ ],
35
+ "forbiddenPaths": [
36
+ ".harness/**",
37
+ "artifacts/**"
38
+ ],
39
+ "outputContract": "RAG context.md and coverage-map.md.",
40
+ "subtask_prompt_markdown": "./frontend-test-dag.retrieve-context.prompt.md"
41
+ },
42
+ {
43
+ "id": "generate-frontend-functional-cases-pi",
44
+ "depends_on": [
45
+ "retrieve-frontend-test-context-pi"
46
+ ],
47
+ "executor": "pi",
48
+ "role": "implementer",
49
+ "toolProfile": "write",
50
+ "complexity": "HIGH",
51
+ "writePolicy": "exclusive",
52
+ "writeSet": [
53
+ "testcase/frontend/cases/**"
54
+ ],
55
+ "allowedPaths": [
56
+ "testcase/frontend/rag/**",
57
+ "testcase/frontend/cases/**"
58
+ ],
59
+ "forbiddenPaths": [
60
+ ".harness/**",
61
+ "artifacts/**"
62
+ ],
63
+ "outputContract": "Markdown cases, index.md and manifest.json schemaVersion 1; no test source code.",
64
+ "subtask_prompt_markdown": "./frontend-test-dag.generate-cases.prompt.md"
65
+ },
66
+ {
67
+ "id": "review-frontend-cases-pi",
68
+ "depends_on": [
69
+ "generate-frontend-functional-cases-pi"
70
+ ],
71
+ "executor": "pi",
72
+ "role": "reviewer",
73
+ "complexity": "HIGH",
74
+ "writePolicy": "read-only",
75
+ "allowedPaths": [
76
+ "testcase/frontend/rag/**",
77
+ "testcase/frontend/cases/**"
78
+ ],
79
+ "forbiddenPaths": [
80
+ ".harness/**",
81
+ "artifacts/**"
82
+ ],
83
+ "outputContract": "First line VERDICT: pass or VERDICT: request-revision; request-revision blocks manifest materialization; no writes.",
84
+ "subtask_prompt_markdown": "./frontend-test-dag.review-cases.prompt.md"
85
+ },
86
+ {
87
+ "id": "review-frontend-cases-gate-shell",
88
+ "depends_on": [
89
+ "review-frontend-cases-pi"
90
+ ],
91
+ "executor": "shell",
92
+ "role": "verifier",
93
+ "complexity": "LOW",
94
+ "writePolicy": "read-only",
95
+ "allowedPaths": [
96
+ "testcase/frontend/rag/**",
97
+ "testcase/frontend/cases/**"
98
+ ],
99
+ "forbiddenPaths": [
100
+ ".harness/**",
101
+ "artifacts/**"
102
+ ],
103
+ "outputContract": "Deterministic gate: block manifest materialization and browser execution unless review-frontend-cases-pi emits VERDICT: pass.",
104
+ "subtask_prompt": "Enforce the frontend case review verdict before manifest materialization.",
105
+ "shell": {
106
+ "commands": [],
107
+ "verdictGate": {
108
+ "fromNodeId": "review-frontend-cases-pi",
109
+ "accept": [
110
+ "VERDICT: pass"
111
+ ],
112
+ "label": "frontend case review",
113
+ "lineMode": "first-verdict-line"
114
+ },
115
+ "cwd": ".",
116
+ "timeoutMs": 60000
117
+ }
118
+ },
119
+ {
120
+ "id": "revise-frontend-cases-pi",
121
+ "depends_on": [
122
+ "review-frontend-cases-pi"
123
+ ],
124
+ "runIf": "$.nodes['review-frontend-cases-pi'].firstVerdictLine == 'VERDICT: request-revision'",
125
+ "executor": "pi",
126
+ "role": "implementer",
127
+ "toolProfile": "write",
128
+ "complexity": "HIGH",
129
+ "writePolicy": "exclusive",
130
+ "writeSet": [
131
+ "testcase/frontend/cases/**"
132
+ ],
133
+ "allowedPaths": [
134
+ "testcase/frontend/rag/**",
135
+ "testcase/frontend/cases/**"
136
+ ],
137
+ "forbiddenPaths": [
138
+ ".harness/**",
139
+ "artifacts/**"
140
+ ],
141
+ "outputContract": "Apply the one permitted frontend case revision; no browser execution or evidence writes.",
142
+ "subtask_prompt": "Apply the sole allowed case revision from the first review. Change only testcase/frontend/cases/**; preserve AC traceability. Do not execute browsers or write evidence."
143
+ },
144
+ {
145
+ "id": "review-frontend-cases-final-pi",
146
+ "depends_on": [
147
+ "revise-frontend-cases-pi"
148
+ ],
149
+ "runIf": "$.nodes['review-frontend-cases-pi'].firstVerdictLine == 'VERDICT: request-revision'",
150
+ "executor": "pi",
151
+ "role": "reviewer",
152
+ "complexity": "HIGH",
153
+ "writePolicy": "read-only",
154
+ "allowedPaths": [
155
+ "testcase/frontend/rag/**",
156
+ "testcase/frontend/cases/**"
157
+ ],
158
+ "forbiddenPaths": [
159
+ ".harness/**",
160
+ "artifacts/**"
161
+ ],
162
+ "outputContract": "First line VERDICT: pass or VERDICT: request-revision after the single allowed revision; no writes.",
163
+ "subtask_prompt_markdown": "./frontend-test-dag.review-cases.prompt.md"
164
+ },
165
+ {
166
+ "id": "final-frontend-case-review-gate-shell",
167
+ "depends_on": [
168
+ "review-frontend-cases-pi",
169
+ "review-frontend-cases-final-pi"
170
+ ],
171
+ "executor": "shell",
172
+ "role": "verifier",
173
+ "complexity": "LOW",
174
+ "writePolicy": "read-only",
175
+ "allowedPaths": [
176
+ "testcase/frontend/rag/**",
177
+ "testcase/frontend/cases/**"
178
+ ],
179
+ "forbiddenPaths": [
180
+ ".harness/**",
181
+ "artifacts/**"
182
+ ],
183
+ "outputContract": "Pass-only effective case review gate; final review takes precedence when revision ran.",
184
+ "subtask_prompt": "Enforce the frontend case review verdict before manifest materialization.",
185
+ "shell": {
186
+ "commands": [],
187
+ "verdictGate": {
188
+ "fromNodeId": "review-frontend-cases-final-pi",
189
+ "fallbackFromNodeIds": [
190
+ "review-frontend-cases-pi"
191
+ ],
192
+ "accept": [
193
+ "VERDICT: pass"
194
+ ],
195
+ "label": "effective frontend case review",
196
+ "lineMode": "first-verdict-line"
197
+ },
198
+ "cwd": ".",
199
+ "timeoutMs": 60000
200
+ },
201
+ "dependsPolicy": "all-or-condition-skip"
202
+ },
203
+ {
204
+ "id": "materialize-frontend-case-manifest-shell",
205
+ "depends_on": [
206
+ "final-frontend-case-review-gate-shell"
207
+ ],
208
+ "executor": "shell",
209
+ "role": "verifier",
210
+ "complexity": "LOW",
211
+ "writePolicy": "read-only",
212
+ "allowedPaths": [
213
+ "testcase/frontend/cases/**"
214
+ ],
215
+ "forbiddenPaths": [
216
+ ".harness/**",
217
+ "artifacts/**"
218
+ ],
219
+ "outputContract": "Validated frontend manifest payload { cases: [...] }; shell command echo is permitted only as the prefix before exactly one final JSON line.",
220
+ "subtask_prompt": "Validate and materialize the generated frontend case manifest.",
221
+ "shell": {
222
+ "commands": [
223
+ "node -e \"const fs=require('fs'),path=require('path');const file='testcase/frontend/cases/manifest.json';if(!fs.existsSync(file))throw new Error('missing '+file);const manifest=JSON.parse(fs.readFileSync(file,'utf8'));if(manifest.schemaVersion!==1||!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');const dims=new Set(['core','boundary','flow','backend']);const ids=new Set(),paths=new Set(),evidence=new Set();for(const c of manifest.cases){if(!c||typeof c.caseId!=='string'||!/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId)||ids.has(c.caseId))throw new Error('invalid or duplicate caseId');ids.add(c.caseId);if(!dims.has(c.dimension))throw new Error('invalid dimension');if(!Array.isArray(c.acIds)||!c.acIds.length||c.acIds.some(a=>typeof a!=='string'||!a.trim()))throw new Error('invalid acIds');for(const key of ['casePath','evidenceDir']){const value=c[key];if(typeof value!=='string'||path.isAbsolute(value)||value.includes('..'))throw new Error('unsafe '+key);}if(c.casePath!=='testcase/frontend/cases/'+c.caseId+'.md')throw new Error('casePath must match caseId');if(!c.evidenceDir.startsWith('testcase/frontend/evidence/'+c.caseId+'/'))throw new Error('case path escapes frontend test roots');if(paths.has(c.casePath))throw new Error('duplicate casePath');paths.add(c.casePath);if(evidence.has(c.evidenceDir))throw new Error('duplicate evidenceDir');evidence.add(c.evidenceDir);}process.stdout.write(JSON.stringify({cases:manifest.cases}));\""
224
+ ],
225
+ "cwd": ".",
226
+ "timeoutMs": 120000
227
+ }
228
+ },
229
+ {
230
+ "id": "execute-frontend-cases-map",
231
+ "depends_on": [
232
+ "materialize-frontend-case-manifest-shell"
233
+ ],
234
+ "executor": "static",
235
+ "role": "verifier",
236
+ "complexity": "LOW",
237
+ "writePolicy": "none",
238
+ "allowedPaths": [],
239
+ "forbiddenPaths": [
240
+ ".harness/**",
241
+ "artifacts/**"
242
+ ],
243
+ "outputContract": "Serial aggregate of browser case results.",
244
+ "subtask_prompt": "Expand the validated manifest into serial browser case children.",
245
+ "static": {
246
+ "resultMarkdown": "Frontend case map expansion barrier."
247
+ },
248
+ "dynamicExpansion": {
249
+ "type": "map_agent",
250
+ "workflowNodeId": "execute-frontend-cases-map",
251
+ "itemsFrom": "$.nodes['materialize-frontend-case-manifest-shell'].output.cases",
252
+ "itemName": "case",
253
+ "maxItems": 20,
254
+ "maxExpandedNodes": 20,
255
+ "childIdPrefix": "execute-frontend-case",
256
+ "tokenBudget": {
257
+ "maxTokensPerCase": 20000,
258
+ "maxTotalTokens": 200000
259
+ },
260
+ "childTask": {
261
+ "executor": "pi",
262
+ "role": "verifier",
263
+ "skills": [
264
+ "playwright-cli",
265
+ "webapp-testing"
266
+ ],
267
+ "toolProfile": "write",
268
+ "complexity": "MED",
269
+ "subtaskPromptTemplate": "Execute {{case.caseId}} from {{case.casePath}} in a fresh Pi session. Use exactly this browser start command prefix: playwright-cli open --browser=chrome --headed <base-url>. Do not put session flags before open. Every later playwright-cli command must use that same default browser session; must not use -s=<case-id>, -s=, or any named-session flag because no session binding is verified. For every sub-scenario, record fixture/reset, UI reset, and a fresh snapshot before using element references. If the isolated environment is missing, write blocked evidence before any browser command; do not open or connect to a browser. Before returning, always persist {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json. case-result.json must be JSON with matching caseId, status as passed, failed, or blocked, and evidencePaths array; a blocked result must include non-empty blockedReason and must never imply pass. Run a local deterministic validation before returning: node -e \"const fs=require('fs');const p='{{case.evidenceDir}}';const r=JSON.parse(fs.readFileSync(p+'case-result.json','utf8'));if(!fs.existsSync(p+'execution.md')||r.caseId!=='{{case.caseId}}'||!['passed','failed','blocked'].includes(r.status)||(r.status==='blocked'&&!(typeof r.blockedReason==='string'&&r.blockedReason.trim())))process.exit(1)\". Persist screenshots/trace/video/logs when actually available. execution.md must record the executed or blocked steps, base URL safety decision, fixture/reset and request-observation availability, and evidence file list. A business failed or blocked case is a recorded result, not a node failure. Close the session and return only compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
270
+ "outputContract": "Compact JSON <=1200 chars.",
271
+ "writePolicy": "exclusive",
272
+ "allowedPaths": [
273
+ "testcase/frontend/cases/{{case.caseId}}.md",
274
+ "testcase/frontend/rag/context.md",
275
+ "testcase/frontend/rag/coverage-map.md",
276
+ "testcase/frontend/evidence/{{case.caseId}}/**"
277
+ ],
278
+ "forbiddenPaths": [
279
+ ".harness/**",
280
+ "artifacts/**"
281
+ ],
282
+ "writeSet": [
283
+ "testcase/frontend/evidence/{{case.caseId}}/**"
284
+ ]
285
+ }
286
+ }
287
+ },
288
+ {
289
+ "id": "validate-frontend-case-evidence-shell",
290
+ "depends_on": [
291
+ "execute-frontend-cases-map"
292
+ ],
293
+ "executor": "shell",
294
+ "role": "verifier",
295
+ "complexity": "LOW",
296
+ "writePolicy": "read-only",
297
+ "allowedPaths": [
298
+ "testcase/frontend/cases/**",
299
+ "testcase/frontend/evidence/**"
300
+ ],
301
+ "forbiddenPaths": [
302
+ ".harness/**",
303
+ "artifacts/**"
304
+ ],
305
+ "outputContract": "Deterministic validation that every manifest case has execution.md and valid matching case-result.json; blocked results require blockedReason.",
306
+ "subtask_prompt": "Validate all frontend case evidence before evidence review; fail closed on missing or malformed records.",
307
+ "shell": {
308
+ "commands": [
309
+ "node -e \"const fs=require('fs'),path=require('path');const file='testcase/frontend/cases/manifest.json';if(!fs.existsSync(file))throw new Error('missing '+file);const manifest=JSON.parse(fs.readFileSync(file,'utf8'));const statuses=new Set(['passed','failed','blocked']);let failed=false;for(const c of manifest.cases||[]){const dir=c&&c.evidenceDir;const execution=path.join(dir||'','execution.md'),resultPath=path.join(dir||'','case-result.json');if(!c||typeof c.caseId!=='string'||typeof dir!=='string'||path.isAbsolute(dir)||dir.includes('..')||!fs.existsSync(execution)||!fs.existsSync(resultPath)){failed=true;continue;}let r;try{r=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch{failed=true;continue;}if(!r||r.caseId!==c.caseId||!statuses.has(r.status)||!Array.isArray(r.evidencePaths)||(r.status==='blocked'&&!(typeof r.blockedReason==='string'&&r.blockedReason.trim())))failed=true;}if(failed)process.exit(1);\""
310
+ ],
311
+ "cwd": ".",
312
+ "timeoutMs": 120000
313
+ }
314
+ },
315
+ {
316
+ "id": "finalize-frontend-test-result-shell",
317
+ "depends_on": [
318
+ "validate-frontend-case-evidence-shell"
319
+ ],
320
+ "executor": "shell",
321
+ "role": "verifier",
322
+ "complexity": "LOW",
323
+ "writePolicy": "read-only",
324
+ "allowedPaths": [
325
+ "testcase/frontend/cases/**",
326
+ "testcase/frontend/evidence/**"
327
+ ],
328
+ "forbiddenPaths": [
329
+ ".harness/**",
330
+ "artifacts/**"
331
+ ],
332
+ "outputContract": "Run-owned hash-bound frontend-test-result-v1 from manifest and validated evidence.",
333
+ "subtask_prompt": "Materialize the authoritative frontend-test-result-v1; never consume Pi prose.",
334
+ "shell": {
335
+ "commands": [],
336
+ "jsonArtifactGate": {
337
+ "fromNodeId": "validate-frontend-case-evidence-shell",
338
+ "schemaId": "frontend-test-result-v1",
339
+ "artifactName": "frontend-test-result.json",
340
+ "outputDir": "contracts"
341
+ },
342
+ "cwd": ".",
343
+ "timeoutMs": 120000
344
+ }
345
+ },
346
+ {
347
+ "id": "frontend-outcome-gate-shell",
348
+ "depends_on": [
349
+ "finalize-frontend-test-result-shell"
350
+ ],
351
+ "executor": "shell",
352
+ "role": "verifier",
353
+ "complexity": "LOW",
354
+ "writePolicy": "read-only",
355
+ "allowedPaths": [],
356
+ "forbiddenPaths": [
357
+ ".harness/**",
358
+ "artifacts/**"
359
+ ],
360
+ "outputContract": "Pass only for a real passed frontend-test-result-v1.",
361
+ "subtask_prompt": "Fail closed unless the final frontend test result records a real pass.",
362
+ "shell": {
363
+ "commands": [
364
+ "test -n \"${HARNESS_DAG_RUN_DIR:-}\" || { echo \"missing HARNESS_DAG_RUN_DIR for frontend-test outcome gate\" >&2; exit 2; }; RESULT=\"${HARNESS_DAG_RUN_DIR}/contracts/frontend-test-result.json\"; test -f \"${RESULT}\" || { echo \"missing frontend-test result: ${RESULT}\" >&2; exit 2; }; node -e 'const fs=require(\"fs\");const r=JSON.parse(fs.readFileSync(process.argv[1],\"utf8\"));const ok=r.outcome===\"passed\"&&r.integrationMode===\"real\"&&Number(r.totals?.failed||0)===0&&Number(r.totals?.blocked||0)===0&&Array.isArray(r.acceptanceCoverage?.missing)&&r.acceptanceCoverage.missing.length===0;if(!ok)process.exit(1);' \"${RESULT}\""
365
+ ],
366
+ "cwd": ".",
367
+ "timeoutMs": 60000
368
+ }
369
+ },
370
+ {
371
+ "id": "review-frontend-execution-pi",
372
+ "depends_on": [
373
+ "frontend-outcome-gate-shell"
374
+ ],
375
+ "executor": "pi",
376
+ "role": "reviewer",
377
+ "complexity": "HIGH",
378
+ "writePolicy": "read-only",
379
+ "allowedPaths": [
380
+ "testcase/frontend/**"
381
+ ],
382
+ "forbiddenPaths": [
383
+ ".harness/**",
384
+ "artifacts/**"
385
+ ],
386
+ "outputContract": "AC-to-case-to-browser-evidence review.",
387
+ "subtask_prompt_markdown": "./frontend-test-dag.review-execution.prompt.md"
388
+ },
389
+ {
390
+ "id": "frontend-test-retrospect-pi",
391
+ "depends_on": [
392
+ "review-frontend-execution-pi"
393
+ ],
394
+ "executor": "pi",
395
+ "role": "closeout",
396
+ "toolProfile": "write",
397
+ "complexity": "MED",
398
+ "writePolicy": "exclusive",
399
+ "writeSet": [
400
+ "testcase/frontend/reports/**"
401
+ ],
402
+ "allowedPaths": [
403
+ "testcase/frontend/**"
404
+ ],
405
+ "forbiddenPaths": [
406
+ ".harness/**",
407
+ "artifacts/**"
408
+ ],
409
+ "outputContract": "Write testcase/frontend/reports/frontend-test-retrospect-<date>.md with coverage, pass/fail/blocked, risks, findings, and A/B/C/D rating.",
410
+ "subtask_prompt": "Write the frontend test retrospective under testcase/frontend/reports/. Summarize coverage, passed/failed/blocked cases (including token-budget-exhausted), review findings, browser anomalies, residual risks, and A/B/C/D rating. Blocked cases never count as passed. Do not write docs/**."
411
+ }
22
412
  ]
23
413
  }
@@ -1,3 +1,3 @@
1
1
  # Frontend test retrospective
2
2
 
3
- Write a dated report under `docs/test-reports/` covering case coverage, passed/failed/blocked results, review findings, browser anomalies, residual risks, and an A/B/C/D maturity rating. Blocked cases never count as passed.
3
+ Write a dated report under `testcase/frontend/reports/` covering case coverage, passed/failed/blocked results, review findings, browser anomalies, residual risks, and an A/B/C/D maturity rating. Cite the deterministic case-evidence validation outcome. Blocked cases never count as passed; a missing or malformed `execution.md` / `case-result.json` is a verification gap, not a pass. Do not write under `docs/**`.
@@ -1,3 +1,5 @@
1
1
  # Review frontend cases
2
2
 
3
- Read the RAG files and Markdown cases only. First line must be `VERDICT: pass` or `VERDICT: request-revision`. Report AC coverage, case independence, evidence completeness, unsafe environment/data dependencies, and manifest issues. The verdict is advisory and does not block browser execution.
3
+ Read the RAG files and Markdown cases only. First line must be `VERDICT: pass` or `VERDICT: request-revision`. Report AC coverage, case independence, evidence completeness, unsafe environment/data dependencies, and manifest issues. This verdict is a deterministic safety gate: `request-revision` blocks manifest materialization and browser execution.
4
+
5
+ Every case must retain the exact browser-start command prefix `playwright-cli open --browser=chrome --headed <base-url>`; session flags must not precede `open`, and subsequent commands must remain in its default session without `-s=` or assumed named-session binding. Verify every executable sub-scenario specifies fixture/reset, UI reset, fresh snapshot before element refs, and an evidence write point. Verify each case requires both `execution.md` and `case-result.json` under its own evidence directory. The JSON result must contain matching `caseId`, `status` (`passed`, `failed`, or `blocked`) and `evidencePaths`; blocked cases must name a non-empty `blockedReason` and cannot count as passed.
@@ -1,3 +1,3 @@
1
1
  # Review frontend execution evidence
2
2
 
3
- Review AC → case → browser-evidence traceability. A passed case needs assertions and screenshot or equivalent browser evidence. Failed and blocked cases need an explicit cause. Treat `token-budget-exhausted` as blocked; do not substitute model conclusions or static checks for browser evidence.
3
+ Review AC → case → browser-evidence traceability only after deterministic evidence validation. Each manifest case must have `execution.md` and a valid `case-result.json` with matching `caseId`, `status`, and `evidencePaths`; `blocked` requires a non-empty `blockedReason`. A passed case additionally needs assertions and screenshot or equivalent browser evidence. Failed and blocked cases need an explicit cause. Treat `token-budget-exhausted` as blocked; do not substitute model conclusions or static checks for browser evidence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.16.15",
3
+ "version": "0.16.16",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",