@tea-agent/loop-agent 0.33.3 → 0.33.5

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.
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
+ import { isPriorityOnlyBackendTestModuleStem } from "./backend-test-module-stem.js";
5
6
  /**
6
7
  * Maps a backend-test generation writer task id to its writer-progress role.
7
8
  * Returns undefined for nodes that are not completeness-gated generators.
@@ -96,31 +97,26 @@ function hasMarkdownTable(section, headerNeedle) {
96
97
  * phantom missing modules when the model discusses forbidden filenames
97
98
  * ("do not create 1.md / 9.md") inside the README body.
98
99
  */
99
- function looksLikeValidModuleStem(raw) {
100
+ function moduleStemInvalidReason(raw) {
100
101
  if (!raw)
101
- return false;
102
+ return "invalid-syntax";
102
103
  const stem = normalizeBackendTestModuleStem(raw);
104
+ if (isPriorityOnlyBackendTestModuleStem(stem))
105
+ return "priority-only-module-stem";
103
106
  if (stem.toLowerCase() === "readme")
104
- return false;
107
+ return "reserved-module-stem";
105
108
  if (!/^[a-z][a-z0-9_]*$/.test(stem))
106
- return false;
109
+ return "invalid-syntax";
107
110
  if (/^(?:be|tp|ac|req|br)[_-]/i.test(stem))
108
- return false;
109
- return true;
111
+ return "case-like-module-stem";
112
+ return undefined;
110
113
  }
111
- /**
112
- * Exported for reuse by the manifest-shell node that materializes the
113
- * module list JSON consumed by the map_agent expansion. Kept deterministic
114
- * (same extractor as the completeness gate) so the shard set always matches
115
- * the README index the gate trusts.
116
- */
117
- export function extractModuleStemsFromReadme(readme) {
118
- const stems = [];
119
- // Only trust testcase/md/<stem>.md mentions that appear inside markdown
120
- // table rows (`| ... testcase/md/x.md ... |`) or as canonical relative
121
- // links (`[label](./x.md)`). Free-form prose mentions such as a recovery
122
- // note listing `testcase/md/1.md` must NOT be treated as authoritative
123
- // module references, otherwise the gate invents phantom missing modules.
114
+ function looksLikeValidModuleStem(raw) {
115
+ return moduleStemInvalidReason(raw) === undefined;
116
+ }
117
+ /** Inspect only authoritative README module-index candidates. */
118
+ export function inspectModuleStemsFromReadme(readme) {
119
+ const candidates = [];
124
120
  const tableRowLines = readme
125
121
  .replaceAll("\r\n", "\n")
126
122
  .replaceAll("\r", "\n")
@@ -128,19 +124,42 @@ export function extractModuleStemsFromReadme(readme) {
128
124
  .filter((line) => line.includes("|"));
129
125
  for (const line of tableRowLines) {
130
126
  for (const match of line.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
131
- if (looksLikeValidModuleStem(match[1]))
132
- stems.push(match[1]);
127
+ candidates.push(match[1]);
133
128
  }
134
129
  for (const match of line.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
135
- if (looksLikeValidModuleStem(match[1]))
136
- stems.push(match[1]);
130
+ // This compatibility shape can also capture a numeric count column.
131
+ // Keep valid stems and the priority-only defect we diagnose, while
132
+ // ignoring non-stem incidental cells such as `1`.
133
+ if (looksLikeValidModuleStem(match[1]) || isPriorityOnlyBackendTestModuleStem(match[1])) {
134
+ candidates.push(match[1]);
135
+ }
137
136
  }
138
137
  }
139
138
  for (const match of readme.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
140
- if (looksLikeValidModuleStem(match[1]))
141
- stems.push(match[1]);
139
+ candidates.push(match[1]);
142
140
  }
143
- return orderedUnique(stems.map((stem) => normalizeBackendTestModuleStem(stem)));
141
+ const uniqueCandidates = orderedUnique(candidates);
142
+ const invalid = uniqueCandidates.flatMap((raw) => {
143
+ const reasonCode = moduleStemInvalidReason(raw);
144
+ return reasonCode
145
+ ? [{ raw, normalized: normalizeBackendTestModuleStem(raw), reasonCode }]
146
+ : [];
147
+ });
148
+ return {
149
+ candidates: uniqueCandidates,
150
+ validStems: orderedUnique(uniqueCandidates
151
+ .filter(looksLikeValidModuleStem)
152
+ .map((stem) => normalizeBackendTestModuleStem(stem))),
153
+ invalid,
154
+ };
155
+ }
156
+ /**
157
+ * Exported for reuse by callers that only need the trusted shard set.
158
+ * Completeness gates must use inspectModuleStemsFromReadme so invalid
159
+ * authoritative candidates cannot be silently dropped.
160
+ */
161
+ export function extractModuleStemsFromReadme(readme) {
162
+ return inspectModuleStemsFromReadme(readme).validStems;
144
163
  }
145
164
  async function listMarkdownModules(workspaceRoot) {
146
165
  const root = path.join(workspaceRoot, "testcase", "md");
@@ -242,12 +261,80 @@ function moduleStructuralDetail(result) {
242
261
  return "module file is structurally complete";
243
262
  return `module file is empty or has structural issues: ${result.reasons.join("; ")}`;
244
263
  }
264
+ function pythonStructureOutsideStrings(source) {
265
+ const input = source.replace(/\r\n/g, "\n");
266
+ let output = "";
267
+ let index = 0;
268
+ let quote;
269
+ let triple = false;
270
+ let comment = false;
271
+ while (index < input.length) {
272
+ const char = input[index];
273
+ if (comment) {
274
+ if (char === "\n") {
275
+ comment = false;
276
+ output += "\n";
277
+ }
278
+ else
279
+ output += " ";
280
+ index += 1;
281
+ continue;
282
+ }
283
+ if (quote) {
284
+ if (char === "\\") {
285
+ output += " ";
286
+ if (index + 1 < input.length)
287
+ output += input[index + 1] === "\n" ? "\n" : " ";
288
+ index += 2;
289
+ continue;
290
+ }
291
+ if (triple && input.slice(index, index + 3) === quote.repeat(3)) {
292
+ output += " ";
293
+ index += 3;
294
+ quote = undefined;
295
+ triple = false;
296
+ continue;
297
+ }
298
+ if (!triple && char === quote) {
299
+ output += " ";
300
+ index += 1;
301
+ quote = undefined;
302
+ continue;
303
+ }
304
+ if (!triple && char === "\n")
305
+ return { text: output, stringsClosed: false };
306
+ output += char === "\n" ? "\n" : " ";
307
+ index += 1;
308
+ continue;
309
+ }
310
+ if (char === "#") {
311
+ comment = true;
312
+ output += " ";
313
+ index += 1;
314
+ continue;
315
+ }
316
+ if (char === "'" || char === '"') {
317
+ quote = char;
318
+ triple = input.slice(index, index + 3) === char.repeat(3);
319
+ output += triple ? " " : " ";
320
+ index += triple ? 3 : 1;
321
+ continue;
322
+ }
323
+ output += char;
324
+ index += 1;
325
+ }
326
+ return { text: output, stringsClosed: quote === undefined };
327
+ }
245
328
  function pythonParseable(source) {
246
- const text = source.replace(/\r\n/g, "\n");
247
- if (!text.trim())
329
+ const normalized = source.replace(/\r\n/g, "\n");
330
+ if (!normalized.trim())
248
331
  return false;
249
- if (/^\s*(?:def|class|import|from)\b/m.test(text) === false)
332
+ if (/^\s*(?:def|class|import|from)\b/m.test(normalized) === false)
333
+ return false;
334
+ const structural = pythonStructureOutsideStrings(normalized);
335
+ if (!structural.stringsClosed)
250
336
  return false;
337
+ const text = structural.text;
251
338
  const openParens = (text.match(/\(/g) ?? []).length;
252
339
  const closeParens = (text.match(/\)/g) ?? []).length;
253
340
  const openBrackets = (text.match(/\[/g) ?? []).length;
@@ -260,15 +347,6 @@ function pythonParseable(source) {
260
347
  return false;
261
348
  if (openBraces !== closeBraces)
262
349
  return false;
263
- if (/("""|''')[\s\S]*$/.test(text)) {
264
- const triples = text.match(/("""|''')/g) ?? [];
265
- if (triples.length % 2 !== 0)
266
- return false;
267
- }
268
- // The balanced-bracket counts above already catch a genuinely truncated
269
- // file (an unclosed def/call leaves unbalanced parens). The previous
270
- // per-line "def ... ( ... $" heuristic was a false-positive source for
271
- // legal multi-line definitions such as `def f(\n x,\n):` — removed.
272
350
  return true;
273
351
  }
274
352
  /**
@@ -318,12 +396,7 @@ function looksLikeValidPythonModule(source) {
318
396
  return false;
319
397
  if (/^\s*(?:def|class|import|from)\b/m.test(text) === false)
320
398
  return false;
321
- if (/("""|''')[\s\S]*$/.test(text)) {
322
- const triples = text.match(/("""|''')/g) ?? [];
323
- if (triples.length % 2 !== 0)
324
- return false;
325
- }
326
- return true;
399
+ return pythonStructureOutsideStrings(text).stringsClosed;
327
400
  }
328
401
  export function buildOutputLimitRecoveryPrompt(input) {
329
402
  return buildOutputLimitRecoverySection(input);
@@ -842,7 +915,19 @@ export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
842
915
  // The README must advertise a non-empty module index so the downstream
843
916
  // manifest shell has stems to shard; the indexed module files themselves
844
917
  // are produced by map_agent children and are NOT required here.
845
- const indexedStems = extractModuleStemsFromReadme(readme);
918
+ const stemInspection = inspectModuleStemsFromReadme(readme);
919
+ const indexedStems = stemInspection.validStems;
920
+ for (const invalid of stemInspection.invalid) {
921
+ if (!brokenPaths.includes("testcase/md/README.md")) {
922
+ brokenPaths.push("testcase/md/README.md");
923
+ }
924
+ issues.push({
925
+ code: "T5",
926
+ path: "testcase/md/README.md",
927
+ detail: `${invalid.reasonCode}: ${invalid.normalized}; use a stable lowercase business resource/domain stem`,
928
+ recoverable: true,
929
+ });
930
+ }
846
931
  if (indexedStems.length === 0) {
847
932
  if (!brokenPaths.includes("testcase/md/README.md")) {
848
933
  brokenPaths.push("testcase/md/README.md");
@@ -3508,7 +3508,8 @@ const readme=fs.existsSync('testcase/md/README.md')?fs.readFileSync('testcase/md
3508
3508
  const norm=s=>String(s).toLowerCase().replace(/[^a-z0-9]+/g,'_').replace(/^_+|_+$/g,'').replace(/_+/g,'_');
3509
3509
  const bt=String.fromCharCode(96);
3510
3510
  const stripBackticks=s=>s.split(bt).join('');
3511
- const valid=raw=>{const st=norm(raw);if(st==='readme')return false;if(!/^[a-z][a-z0-9_]*$/.test(st))return false;if(/^(?:be|tp|ac|req|br)[_-]/i.test(st))return false;return true;};
3511
+ const invalidReason=raw=>{const st=norm(raw);if(/^p[0-2]$/.test(st))return 'priority-only-module-stem';if(st==='readme')return 'reserved-module-stem';if(!/^[a-z][a-z0-9_]*$/.test(st))return 'invalid-syntax';if(/^(?:be|tp|ac|req|br)[_-]/i.test(st))return 'case-like-module-stem';return null;};
3512
+ const valid=raw=>invalidReason(raw)===null;
3512
3513
  const rxMdPath=/testcase\\/md\\/([A-Za-z0-9_.-]+)\\.md/g;
3513
3514
  const rxTableRow=/\\|\\s*([A-Za-z0-9_.-]+)\\s*\\|\\s*testcase\\/test_/g;
3514
3515
  const rxRelLink=/\\[[^\\]]+\\]\\(\\.\\/([A-Za-z0-9_.-]+)\\.md\\)/g;
@@ -3516,12 +3517,14 @@ const raw=[];
3516
3517
  const lines=readme.replace(/\\r\\n/g,'\\n').replace(/\\r/g,'\\n').split('\\n').filter(l=>l.includes('|'));
3517
3518
  for(const line of lines){
3518
3519
  const bare=stripBackticks(line);
3519
- for(const m of bare.matchAll(rxMdPath)){if(valid(m[1]))raw.push(m[1]);}
3520
- for(const m of bare.matchAll(rxTableRow)){if(valid(m[1]))raw.push(m[1]);}
3520
+ for(const m of bare.matchAll(rxMdPath)){raw.push(m[1]);}
3521
+ for(const m of bare.matchAll(rxTableRow)){if(valid(m[1])||invalidReason(m[1])==='priority-only-module-stem')raw.push(m[1]);}
3521
3522
  }
3522
- for(const m of readme.matchAll(rxRelLink)){if(valid(m[1]))raw.push(m[1]);}
3523
+ for(const m of readme.matchAll(rxRelLink)){raw.push(m[1]);}
3524
+ const invalid=[];for(const r of raw){const reason=invalidReason(r);if(reason)invalid.push({stem:norm(r),reason});}
3525
+ if(invalid.length){for(const item of invalid)process.stderr.write(item.reason+': '+item.stem+'; use a stable business resource/domain stem\\n');process.exit(2);}
3523
3526
  const seen=new Set();const modules=[];
3524
- for(const r of raw){const st=norm(r);if(!seen.has(st)){seen.add(st);modules.push({stem:st});}}
3527
+ for(const r of raw){const st=norm(r);if(valid(r)&&!seen.has(st)){seen.add(st);modules.push({stem:st});}}
3525
3528
  process.stdout.write(JSON.stringify({modules}));
3526
3529
  `;
3527
3530
  const encoded = Buffer.from(script, "utf8").toString("base64");
@@ -3599,7 +3602,7 @@ async function buildBackendTestHybridDag(sources) {
3599
3602
  "Each Rule Key must appear in exactly one Matrix row. Preserve each AC/REQ/BR Rule Key as one row; if one product rule spans multiple dimensions, use a concise composite Dimension in that single row instead of duplicating the key. Derive OpenAPI Rule Keys exactly as the deterministic analyzer does: operation token is `<HTTP-METHOD>-<PATH>` with braces removed and every non-alphanumeric run replaced by a hyphen, uppercase (for example POST `/api/resource-notes` → `POST-API-RESOURCE-NOTES`); response statuses use `API-<OPERATION>-RESPONSE-STATUS`; body/parameter fields use `API-<OPERATION>-<FIELD>-REQUIRED|ENUM|MIN-LENGTH|MAX-LENGTH|MINIMUM|MAXIMUM|PATTERN|FORMAT`. Do not invent aliases such as API-CREATE-FIELDS when a deterministic key applies.",
3600
3603
  "Coverage priority is strict inside the declared scope: P0 product requirements/task hard constraints always remain in scope; P1 exhaustively supplements documented operations, fields, business rules, statuses and errors only for Affected Operations; P2 adds bounded protocol robustness only when it is relevant to the change and does not invent product behavior. Coverage percentages describe the declared affected scope, never whole-API completeness unless every operation is explicitly listed. Conflicts or undefined expectations must stay visible as GAP/CONFLICT with precise source pointers, never guessed.",
3601
3604
  "For uniqueness/lifecycle rules cover absent, active-existing, deleted-existing, create-delete-recreate, restore-then-recreate and documented scope/case-normalization states. For every enum cover every valid value plus bounded invalid equivalence classes (unknown, case variant, whitespace, empty, null/missing and wrong types as applicable). For every length/number rule cover min-1, min, nominal, max and max+1. For format rules cover each allowed class separately plus a valid mixed value, and representative forbidden classes including uppercase, internal/leading/trailing whitespace, tab/newline, unsupported punctuation, slash, emoji or control characters when the source contract supports that expectation.",
3602
- "Mandatory module index: include a `## Module Index` table in README that lists every planned module as a canonical relative link of the exact form `[label](./<stem>.md)` plus a `testcase/md/<stem>.md` path cell, so a downstream deterministic manifest can parse the module list. Group by stable business resource/domain, not by CRUD operation: one resource's list/detail/create/update/delete cases belong in one module such as `resource_notes`; split only when a single module would exceed the per-child 16K output budget, and keep the total module count at the smallest safe value. Name each module file with a stable lowercase business stem such as `health` or `resource_notes`. Do not use Case-ID-like module filenames such as `BE-HEALTH.md` or `BE-NOTES.md`. The relative link target MUST equal the on-disk filename stem the sharded writer will create. For every automatable case, `自动化映射` must name exactly `testcase/test_<module>.py`, where <module> is that Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `testcase/md/health.md` → `testcase/test_health.py`; `testcase/md/resource_notes.md` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.",
3605
+ "Mandatory module index: include a `## Module Index` table in README that lists every planned module as a canonical relative link of the exact form `[label](./<stem>.md)` plus a `testcase/md/<stem>.md` path cell, so a downstream deterministic manifest can parse the module list. Group by stable business resource/domain, not by CRUD operation: one resource's list/detail/create/update/delete cases belong in one module such as `resource_notes`; split only when a single module would exceed the per-child 16K output budget, and keep the total module count at the smallest safe value. Name each module file with a stable lowercase business stem such as `health` or `resource_notes`. Do not use priority-only stems `p0`, `p1` or `p2`; Priority belongs only in the Coverage Matrix and never defines module files. Do not use Case-ID-like module filenames such as `BE-HEALTH.md` or `BE-NOTES.md`. The relative link target MUST equal the on-disk filename stem the sharded writer will create. For every automatable case, `自动化映射` must name exactly `testcase/test_<module>.py`, where <module> is that Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `testcase/md/health.md` → `testcase/test_health.py`; `testcase/md/resource_notes.md` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.",
3603
3606
  "Before finalizing README, calculate the predicted collected-item count as `sum(max(1, number of variant Test Points in each Case))`. If the task declares an item budget, the prediction must not exceed it. Reduce excess only by removing duplicate execution and converting same-request checkpoints to assertions; never drop required rules, boundaries, enums, operation-specific inputs, or business states. Record the prediction in README. Use only environment-supported fixtures/targets/isolation, record evidence gaps in Chinese, and do not emit JSON, pytest, or execute commands.",
3604
3607
  intake.boundedSourceContext,
3605
3608
  "## Authoritative reference index",
@@ -3670,7 +3673,7 @@ async function buildBackendTestHybridDag(sources) {
3670
3673
  "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed after the module file has been written, or IMPLEMENTATION_OUTCOME: blocked when precise missing evidence prevents safe generation. already-satisfied is not valid for this node.",
3671
3674
  "Write human-readable content in Simplified Chinese by default. Keep English only for machine-readable IDs and technical literals such as Case/AC/REQ/BR IDs, HTTP methods, paths, field names, enum values, commands, filenames, code symbols and exact source citations.",
3672
3675
  'Write the module {{item.stem}} as readable case cards covering every in-scope rule/Test Point the README Coverage Matrix assigns to this module. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>`. `<NNN>` is exactly three zero-padded digits (`001`, `002`, ...), never two digits (`01`), a bare number, or an alphabetic suffix such as `011A`. Every case must include `### 覆盖规则`, `### 测试点`, `### 场景类型`, `### 前置条件`, `### 操作步骤`, `### 预期结果`, and `### 自动化映射` Do not group cases under "## 测试类 ..." (or any h2 grouping) headings that force Cases down to h3; each Case must be a direct h2 (`##`), and its seven sections must be h3 (`###`) children of that Case. If you need to convey a pytest class, state it inside the Case\'s `### 自动化映射` instead. Forbidden: `## 测试类 X` then `### BE-PD-001` and `### 覆盖规则` at the same h3 level. Required: `## BE-PD-001` then `### 覆盖规则`.; `覆盖规则` and `测试点` must reference exact Matrix Rule Keys/Test Points. Add `测试目的`, `验收标准`, `需求依据`, and `测试数据` for readable evidence. The `验收标准` section must list the exact applicable `AC-...` IDs, and every explicit task AC must appear in at least one Case. Every automatable case explicitly names its target pytest script and exactly one primary symbol so traceability scans only that script/symbol.',
3673
- "Name this module file with the stable lowercase business stem `{{item.stem}}` (filename `testcase/md/{{item.stem}}.md`). Do not use Case-ID-like module filenames. For every automatable case, `自动化映射` must name exactly `testcase/test_{{item.stem}}.py`, where the module stem is this Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `health` → `testcase/test_health.py`; `resource_notes` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.",
3676
+ "Name this module file with the stable lowercase business stem `{{item.stem}}` (filename `testcase/md/{{item.stem}}.md`). Priority-only stems `p0`, `p1` and `p2` are forbidden and must never produce `p0.md` or `test_p0.py`. Do not use Case-ID-like module filenames. For every automatable case, `自动化映射` must name exactly `testcase/test_{{item.stem}}.py`, where the module stem is this Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `health` → `testcase/test_health.py`; `resource_notes` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.",
3674
3677
  "Every Case must keep at least one numbered executable line under `### 操作步骤`; a compact variant/result table may follow but must not replace the numbered action anchor. Keep numbered/bulleted independently assertable results under `### 预期结果`. The exact `### 操作步骤` and `### 预期结果` headings must remain present for every Case, including compact/table-based Cases; never compress later Cases by dropping required headings. Every result must name the observable HTTP status, response field/value, state transition or membership condition, never vague wording such as ‘符合预期’.",
3675
3678
  "In every `自动化映射`, use exactly these machine-readable list labels: `脚本`, `primary symbol`, `变体测试点`, `场景断言测试点`, `横切证据测试点`. Each Test Point from `### 测试点` must appear in exactly one binding list, and every Test Point named in any binding list must also be declared in that Case's `### 测试点`; write `无` for an empty list. A variant Test Point is atomic: one exact endpoint/input/precondition/outcome row equals one exact pytest item and one exact TP ID. If a parameter table has five rows, declare five distinct variant TP IDs in Markdown; never declare one family TP and append row suffixes only in pytest. Classify as `variant` only when endpoint, request input, precondition business state, or expected outcome genuinely changes and therefore needs an independent pytest parameter item. Classify CRUD checkpoints, status/body/header/schema assertions and multiple checks over the same response/journey as `assertion`; classify shared HTTP logging/redaction/truncation evidence as `cross-cutting`. Never create a Test Point merely to parameterize a checkpoint. Every non-cross-cutting TP ID is owned by exactly one Case; when the same response/schema/error assertion is needed in different Cases, use distinct Case-specific TP IDs instead of reusing one assertion TP across Cases. Keep the script path identical to the module one-to-one path and declare exactly one primary symbol named with the canonical Case prefix, for example `BE-RN-003` → `test_BE_RN_003_<description>`; non-Case-prefixed primary symbols are forbidden because parameterized item association must remain deterministic. For redaction scenarios, list sensitive header/field key names only. Never write any header-name-and-value pair, credential placeholder, fake token, anti-example, or other secret-shaped literal in Markdown; state only that a test-only value is supplied at runtime and omitted. Put implementation-only restrictions in a concise `<details>` block rather than dominating the main case flow. Use only environment-supported fixtures/targets/isolation, record evidence gaps in Chinese, and do not emit JSON, pytest, or execute commands.",
3676
3679
  intake.boundedSourceContext,
@@ -3939,143 +3942,9 @@ async function buildBackendTestHybridDag(sources) {
3939
3942
  assertValidDagSpec(spec);
3940
3943
  return spec;
3941
3944
  }
3942
- // Explicit allowlist of controller-owned reference filenames that may declare
3943
- // a non-production browser URL. Hash-bound task sources are the only authority;
3944
- // model-authored RAG/case/evidence files are never consulted.
3945
- const FRONTEND_BASE_URL_DOCUMENT_ALLOWLIST = [
3946
- /(?:^|[\\/])config\.md$/i,
3947
- /(?:^|[\\/])environment\.md$/i,
3948
- /(?:^|[\\/])env\.md$/i,
3949
- /(?:^|[\\/])urls?\.md$/i,
3950
- /(?:^|[\\/])references\.md$/i,
3951
- /(?:^|[\\/])task-reference\.md$/i,
3952
- /(?:^|[\\/])task-source\.md$/i,
3953
- ];
3954
- // Explicit allowlist of URL keys. The semantic name drives a deterministic
3955
- // priority so two conflicting same-priority URLs fail closed instead of being
3956
- // silently picked.
3957
- const FRONTEND_BASE_URL_KEY_ALLOWLIST = [
3958
- // The primary frontend browser entry point is the strongest signal.
3959
- { name: "baseurl", priority: 0 },
3960
- { name: "base url", priority: 0 },
3961
- { name: "targeturl", priority: 0 },
3962
- { name: "target url", priority: 0 },
3963
- { name: "frontendbaseurl", priority: 1 },
3964
- { name: "frontend base url", priority: 1 },
3965
- { name: "frontendurl", priority: 1 },
3966
- { name: "frontend url", priority: 1 },
3967
- { name: "loginurl", priority: 2 },
3968
- { name: "login url", priority: 2 },
3969
- ];
3970
- const DEFAULT_FRONTEND_BASE_URL = "http://localhost:5173/";
3971
- const DEFAULT_FRONTEND_BASE_URL_SOURCE = "default-localhost-5173";
3972
- function isAllowedFrontendBaseUrlDocument(documentPath) {
3973
- return FRONTEND_BASE_URL_DOCUMENT_ALLOWLIST.some((pattern) => pattern.test(documentPath));
3974
- }
3975
- function normalizeFrontendBaseUrlKey(raw) {
3976
- // Normalize separators: kebab/snake/space variants collapse to a comparable
3977
- // token. Lowercase so casing differences do not bypass the allowlist.
3978
- return raw
3979
- .toLowerCase()
3980
- .replace(/[_\-]+/g, " ")
3981
- .replace(/\s+/g, " ")
3982
- .trim();
3983
- }
3984
- /**
3985
- * Resolve the browser origin only from controller-owned task source bytes.
3986
- * Model-authored RAG/case/evidence files are intentionally excluded.
3987
- *
3988
- * Safety contract:
3989
- * - Only hash-bound controller reference documents on the filename allowlist
3990
- * may declare a URL.
3991
- * - Only the explicit key allowlist (baseUrl / targetUrl / loginUrl + common
3992
- * case/separator variants) is recognized.
3993
- * - Candidates are ranked deterministically by key priority then by document
3994
- * order; equal-priority conflicting URLs fail closed.
3995
- * - The resolved URL must be non-production http(s) without credentials, query,
3996
- * or fragment. Only when zero candidates exist does the localhost default
3997
- * apply.
3998
- */
3999
- export function resolveControllerFrontendBaseUrl(sources) {
4000
- const allowedKeyByNormalized = new Map(FRONTEND_BASE_URL_KEY_ALLOWLIST.map((variant) => [
4001
- variant.name,
4002
- variant.priority,
4003
- ]));
4004
- const candidates = [];
4005
- const documents = sources.referenceDocuments ?? [];
4006
- documents.forEach((document, documentIndex) => {
4007
- if (!isAllowedFrontendBaseUrlDocument(document.path))
4008
- return;
4009
- const source = toDagSourcePath(sources, document.path);
4010
- // Only accept `key: value` / `key = value` lines. A colon or equals
4011
- // sign after a known key bounds the URL; free-form prose is ignored.
4012
- const keyPattern = /^[ ]*(?:#>*)?[ ]*([A-Za-z][A-Za-z0-9 _\-]*?)[ ]*[:=][ ]*["'`]?((?:https?:)?\/\/[\S]+?)(?:["'`])?[ ]*(?:#.*)?$/gm;
4013
- let match;
4014
- while ((match = keyPattern.exec(document.markdown)) !== null) {
4015
- const rawKey = match[1].trim();
4016
- const normalizedKey = normalizeFrontendBaseUrlKey(rawKey);
4017
- const priority = allowedKeyByNormalized.get(normalizedKey);
4018
- if (priority === undefined)
4019
- continue;
4020
- let rawUrl = match[2].replace(/[)\]},.;]+$/, "");
4021
- if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(rawUrl)) {
4022
- rawUrl = `https://${rawUrl.replace(/^\/\/+/, "")}`;
4023
- }
4024
- candidates.push({
4025
- url: rawUrl,
4026
- source,
4027
- keyName: normalizedKey,
4028
- priority,
4029
- documentIndex,
4030
- });
4031
- }
4032
- });
4033
- if (candidates.length === 0) {
4034
- return {
4035
- baseUrl: DEFAULT_FRONTEND_BASE_URL,
4036
- baseUrlSource: DEFAULT_FRONTEND_BASE_URL_SOURCE,
4037
- };
4038
- }
4039
- // Deterministic ranking: lowest priority number first, then earliest
4040
- // document, then earliest occurrence in that document.
4041
- candidates.sort((left, right) => {
4042
- if (left.priority !== right.priority) {
4043
- return left.priority - right.priority;
4044
- }
4045
- if (left.documentIndex !== right.documentIndex) {
4046
- return left.documentIndex - right.documentIndex;
4047
- }
4048
- return 0;
4049
- });
4050
- const bestPriority = candidates[0].priority;
4051
- const bestPriorityCandidates = candidates.filter((candidate) => candidate.priority === bestPriority);
4052
- // Fail closed: two distinct URLs at the same priority level are ambiguous.
4053
- const distinctUrls = new Set(bestPriorityCandidates.map((candidate) => candidate.url));
4054
- if (distinctUrls.size > 1) {
4055
- const conflictingSources = bestPriorityCandidates
4056
- .map((candidate) => `${candidate.source}:${candidate.keyName}`)
4057
- .join(", ");
4058
- throw new Error(`frontend-test controller baseUrl is ambiguous (${conflictingSources})`);
4059
- }
4060
- const selected = bestPriorityCandidates[0];
4061
- const baseUrlSource = selected.source;
4062
- let parsed;
4063
- try {
4064
- parsed = new URL(selected.url);
4065
- }
4066
- catch {
4067
- throw new Error(`frontend-test controller baseUrl is invalid (${baseUrlSource})`);
4068
- }
4069
- if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
4070
- parsed.username ||
4071
- parsed.password ||
4072
- parsed.search ||
4073
- parsed.hash ||
4074
- /(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
4075
- throw new Error(`frontend-test controller baseUrl is unsafe (${baseUrlSource})`);
4076
- }
4077
- return { baseUrl: parsed.toString(), baseUrlSource };
4078
- }
3945
+ // ---------------------------------------------------------------------------
3946
+ // Frontend browser-test RAG DAG template
3947
+ // ---------------------------------------------------------------------------
4079
3948
  function buildFrontendTestHybridDag(sources) {
4080
3949
  const rawFrontendTest = sources.taskConfig.frontendTest;
4081
3950
  const config = {
@@ -4087,8 +3956,9 @@ function buildFrontendTestHybridDag(sources) {
4087
3956
  strictOutcomeGate: rawFrontendTest?.strictOutcomeGate === true,
4088
3957
  maxRerunAttempts: (() => {
4089
3958
  const raw = rawFrontendTest?.maxRerunAttempts;
4090
- if (raw === undefined || raw === null || Number.isNaN(Number(raw)))
4091
- return 2;
3959
+ if (raw === undefined || raw === null || Number.isNaN(Number(raw))) {
3960
+ return rawFrontendTest?.reviewMode === "blocking" ? 2 : 1;
3961
+ }
4092
3962
  return Math.min(4, Math.max(0, Math.trunc(Number(raw))));
4093
3963
  })(),
4094
3964
  reports: {
@@ -4101,7 +3971,7 @@ function buildFrontendTestHybridDag(sources) {
4101
3971
  const reviewMode = config.reviewMode;
4102
3972
  const blockingReview = reviewMode === "blocking";
4103
3973
  const strictOutcomeGate = config.strictOutcomeGate;
4104
- const maxRerunAttempts = config.maxRerunAttempts ?? 2;
3974
+ const maxRerunAttempts = config.maxRerunAttempts;
4105
3975
  const enableRetrospect = config.reports?.retrospect === true;
4106
3976
  const enableL5Report = config.reports?.l5 !== false;
4107
3977
  const hasFrontendTestWriteScope = sources.taskConfig.allowedPaths.some((pattern) => pattern === "testcase/frontend/**" ||
@@ -4111,7 +3981,6 @@ function buildFrontendTestHybridDag(sources) {
4111
3981
  throw new Error('frontend-test requires task.json allowedPaths to include "testcase/frontend/**" (or an explicit containing glob).');
4112
3982
  }
4113
3983
  const forbidden = commonForbiddenPaths(sources);
4114
- const controllerFrontend = resolveControllerFrontendBaseUrl(sources);
4115
3984
  const ragWriteSet = ["testcase/frontend/rag/**"];
4116
3985
  const caseDraftWriteSet = [
4117
3986
  "testcase/frontend/cases/FE-*.md",
@@ -4237,7 +4106,7 @@ function buildFrontendTestHybridDag(sources) {
4237
4106
  allowedPaths: [],
4238
4107
  forbiddenPaths: forbidden,
4239
4108
  outputContract: "Deterministic browser-tool preflight: SDK-only custom-tool capability + controller verified playwright-cli launcher + --help contract + frozen controller origin. Fail closed with playwright-cli-unavailable | playwright-cli-contract-incompatible | browser-command-capability-unavailable before any frontend-test Pi node.",
4240
- subtask_prompt: `Verify CODE_AGENT_PI_BACKEND permits SDK, the Pi SDK structured custom-tool surface is available, the controller-resolved playwright-cli launcher and --help list open/close/find/snapshot/click, and the frozen origin is ${controllerFrontend.baseUrl} from ${controllerFrontend.baseUrlSource}. Do not install packages. Do not start a browser session. On failure exit non-zero so retrieve/generate/map never run (zero frontend-test Pi calls).`,
4109
+ subtask_prompt: "Verify CODE_AGENT_PI_BACKEND permits SDK, the Pi SDK structured custom-tool surface is available, and the controller-resolved playwright-cli launcher and --help list open/close/find/snapshot/click. Do not resolve or freeze the target URL in this node. Do not install packages. Do not start a browser session. On failure exit non-zero so retrieve/generate/map never run (zero frontend-test Pi calls).",
4241
4110
  shell: {
4242
4111
  commands: [],
4243
4112
  frontendBrowserToolPreflight: {},
@@ -4280,12 +4149,12 @@ function buildFrontendTestHybridDag(sources) {
4280
4149
  writeSet: ragWriteSet,
4281
4150
  allowedPaths: [...commonReadOnlyPaths(sources), ...ragWriteSet],
4282
4151
  forbiddenPaths: forbidden,
4283
- outputContract: "Write short testcase/frontend/rag/context.md and coverage-map.md that copy the controller-frozen baseUrl/baseUrlSource verbatim, set environmentProbe=pending, and record capability notes.",
4152
+ outputContract: "Write short testcase/frontend/rag/context.md and coverage-map.md with a model-resolved non-production baseUrl/baseUrlSource from task source config, environmentProbe=pending, and capability notes.",
4284
4153
  subtask_prompt: [
4285
4154
  "Build the frontend test RAG package (keep it short).",
4286
4155
  "Read task source, routes/components/API or Mock facts, and execution contract. Write only testcase/frontend/rag/context.md and coverage-map.md.",
4287
4156
  "Prefer fixed fields: baseUrl, baseUrlSource, environmentProbe, AC table, capability matrix (backend real/mock, pagination data, HTTP observation, error injection), risks, forbidden hosts. Do not paste large implementation dumps.",
4288
- `Controller-frozen origin (required, not model-selectable): write exactly \`baseUrl: ${controllerFrontend.baseUrl}\` and \`baseUrlSource: ${controllerFrontend.baseUrlSource}\`. Do not derive, replace, or override the origin from model reasoning or other repository text. Write \`environmentProbe: pending\`. Include exact start prefix: playwright-cli open --browser=chrome ${controllerFrontend.baseUrl}.`,
4157
+ "Base URL resolution (required): read the bound task source and config/environment references. Prefer TARGET_URL/targetUrl as the concrete tested entry, then BASE_URL/baseUrl or FRONTEND_BASE_URL/frontendUrl, then LOGIN_URL/loginUrl; accept common casing and underscore/kebab/space variants. Choose one absolute non-production http(s) URL, never API-only or production hosts. If no usable URL exists, use http://localhost:5173. Write `baseUrl: <url>`, `baseUrlSource: <bound source path>|default-localhost-5173`, and `environmentProbe: pending`. Include exact start prefix: playwright-cli open --browser=chrome <resolved-base-url> with the concrete selected URL.",
4289
4158
  buildSourceContextBlock(sources),
4290
4159
  ].join("\n\n"),
4291
4160
  },
@@ -4300,12 +4169,12 @@ function buildFrontendTestHybridDag(sources) {
4300
4169
  allowedPaths: [...ragWriteSet],
4301
4170
  forbiddenPaths: forbidden,
4302
4171
  outputContract: "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable (node ERROR so generate/map do not run).",
4303
- subtask_prompt: `Probe only the controller-frozen baseUrl ${controllerFrontend.baseUrl} from ${controllerFrontend.baseUrlSource}; never parse or accept an origin from context.md. Use curl HEAD then GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. 4xx/5xx/DNS/timeout/connection refused/TLS => blockedReason frontend-base-url-unreachable. Missing curl => blockedReason curl-unavailable. Do not start the app. Fixture/reset remain soft guidance.`,
4172
+ subtask_prompt: "Parse the resolved absolute baseUrl from testcase/frontend/rag/context.md, reject production/non-http(s)/credential/query/fragment URLs, then probe it with curl HEAD and GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. 4xx/5xx/DNS/timeout/connection refused/TLS => blockedReason frontend-base-url-unreachable. Missing curl => blockedReason curl-unavailable. Do not start the app. Fixture/reset remain soft guidance.",
4304
4173
  shell: {
4305
4174
  commands: [
4306
4175
  [
4307
4176
  "node -e",
4308
- JSON.stringify(`const fs=require('fs');const {spawnSync}=require('child_process');const p='testcase/frontend/rag/context.md';const probePath='testcase/frontend/rag/environment-probe.json';function writeProbe(obj){try{fs.mkdirSync('testcase/frontend/rag',{recursive:true});fs.writeFileSync(probePath,JSON.stringify(obj,null,2)+'\\n');let ctx=fs.existsSync(p)?fs.readFileSync(p,'utf8'):'';const line='environmentProbe: '+obj.status+(obj.blockedReason?(' ('+obj.blockedReason+')'):'');if(/environmentProbe\\s*[:=]/i.test(ctx)){ctx=ctx.replace(/environmentProbe\\s*[:=]\\s*.*/i,line);}else{ctx=ctx.trimEnd()+'\\n\\n'+line+'\\n';}fs.writeFileSync(p,ctx);}catch(e){console.error('probe-write-failed',e&&e.message||e);}}function redactUrl(u){try{const x=new URL(u);x.username='';x.password='';if(x.search){x.search='';}return x.toString();}catch(_){return String(u).replace(/\\/\\/[^@\\s]+@/g,'//');}}function failBlocked(reason,extra){const payload=Object.assign({status:'unreachable',blockedReason:reason,baseUrlRedacted:extra&&extra.baseUrlRedacted||null,httpStatus:extra&&extra.httpStatus||null,method:extra&&extra.method||null,curlExit:extra&&extra.curlExit||null,errorClass:extra&&extra.errorClass||null},extra||{});writeProbe(payload);console.error('frontend-test preflight blocked: '+JSON.stringify({blockedReason:reason,baseUrl:payload.baseUrlRedacted,httpStatus:payload.httpStatus,errorClass:payload.errorClass}));throw new Error('frontend-test preflight blocked: '+reason);}if(!fs.existsSync(p))throw new Error('missing '+p);const baseUrl=${JSON.stringify(controllerFrontend.baseUrl)};const baseUrlSource=${JSON.stringify(controllerFrontend.baseUrlSource)};const safe=redactUrl(baseUrl);const curlCheck=spawnSync('curl',['--version'],{encoding:'utf8'});if(curlCheck.error||curlCheck.status!==0){failBlocked('curl-unavailable',{baseUrlRedacted:safe,errorClass:'curl-missing'});}function probe(method){const args=['-sS','-o','/dev/null','-w','%{http_code}','--connect-timeout','3','--max-time','8','-X',method,'-L','--max-redirs','3','--http1.1','--proto-redir','=http,https',safe];const r=spawnSync('curl',args,{encoding:'utf8'});return r;}let used='HEAD';let r=probe('HEAD');let code=String(r.stdout||'').trim();let statusNum=parseInt(code,10);const headRejected=r.status!==0||!statusNum||statusNum===405||statusNum===501;if(headRejected){used='GET';r=probe('GET');code=String(r.stdout||'').trim();statusNum=parseInt(code,10);}const ok=statusNum>=200&&statusNum<400;if(!ok){const errClass=r.error?'spawn-error':(r.status!==0?'curl-exit-'+r.status:('http-'+statusNum));failBlocked('frontend-base-url-unreachable',{baseUrlRedacted:safe,httpStatus:statusNum||null,method:used,curlExit:r.status,errorClass:errClass});}writeProbe({status:'reachable',blockedReason:null,baseUrl:safe,baseUrlRedacted:safe,baseUrlSource,httpStatus:statusNum,method:used,curlExit:r.status});console.log('frontend-test-execution-v1 validated controllerBaseUrl='+safe+' source='+baseUrlSource+' probe=reachable method='+used+' httpStatus='+statusNum);`),
4177
+ JSON.stringify(`const fs=require('fs');const {spawnSync}=require('child_process');const p='testcase/frontend/rag/context.md';const probePath='testcase/frontend/rag/environment-probe.json';function writeProbe(obj){try{fs.mkdirSync('testcase/frontend/rag',{recursive:true});fs.writeFileSync(probePath,JSON.stringify(obj,null,2)+'\\n');let ctx=fs.existsSync(p)?fs.readFileSync(p,'utf8'):'';const line='environmentProbe: '+obj.status+(obj.blockedReason?(' ('+obj.blockedReason+')'):'');if(/environmentProbe\\s*[:=]/i.test(ctx)){ctx=ctx.replace(/environmentProbe\\s*[:=]\\s*.*/i,line);}else{ctx=ctx.trimEnd()+'\\n\\n'+line+'\\n';}fs.writeFileSync(p,ctx);}catch(e){console.error('probe-write-failed',e&&e.message||e);}}function redactUrl(u){try{const x=new URL(u);x.username='';x.password='';if(x.search){x.search='';}return x.toString();}catch(_){return String(u).replace(/\\/\\/[^@\\s]+@/g,'//');}}function failBlocked(reason,extra){const payload=Object.assign({status:'unreachable',blockedReason:reason,baseUrlRedacted:extra&&extra.baseUrlRedacted||null,httpStatus:extra&&extra.httpStatus||null,method:extra&&extra.method||null,curlExit:extra&&extra.curlExit||null,errorClass:extra&&extra.errorClass||null},extra||{});writeProbe(payload);console.error('frontend-test preflight blocked: '+JSON.stringify({blockedReason:reason,baseUrl:payload.baseUrlRedacted,httpStatus:payload.httpStatus,errorClass:payload.errorClass}));throw new Error('frontend-test preflight blocked: '+reason);}if(!fs.existsSync(p))throw new Error('missing '+p);const s=fs.readFileSync(p,'utf8');const patterns=[/baseUrl\\s*[:=]\\s*[\"'\\x60]?((?:https?):\\/\\/[^\\s\"'\\x60<>]+)/i,/base[-_ ]url\\s*[:=]\\s*[\"'\\x60]?((?:https?):\\/\\/[^\\s\"'\\x60<>]+)/i,/playwright-cli open --browser=chrome\\s+((?:https?):\\/\\/[^\\s\"'\\x60<>]+)/i,/(https?:\\/\\/(?:localhost|127\\.0\\.0\\.1)[^\\s)\\}\\],\"']*)/i];let baseUrl=null;for(const re of patterns){const m=s.match(re);if(m){baseUrl=m[1];break;}}if(!baseUrl)throw new Error('frontend-test preflight missing absolute baseUrl from context.md');baseUrl=baseUrl.replace(/[)\\}\\],.\"'\\x60]+$/,'');const sourceMatch=s.match(/baseUrlSource\\s*[:=]\\s*([^\\r\\n]+)/i);const baseUrlSource=sourceMatch?sourceMatch[1].trim():'context.md';let parsed;try{parsed=new URL(baseUrl);}catch(_){throw new Error('baseUrl must be absolute http(s): '+baseUrl);}if((parsed.protocol!=='http:'&&parsed.protocol!=='https:')||parsed.username||parsed.password||parsed.search||parsed.hash)throw new Error('unsafe baseUrl from context.md: '+redactUrl(baseUrl));if(/(?:^|\\.)(?:www\\.)?[^.]*(?:prod|production)/i.test(parsed.hostname))throw new Error('production URL forbidden: '+redactUrl(baseUrl));baseUrl=parsed.toString();const safe=redactUrl(baseUrl);const curlCheck=spawnSync('curl',['--version'],{encoding:'utf8'});if(curlCheck.error||curlCheck.status!==0){failBlocked('curl-unavailable',{baseUrlRedacted:safe,errorClass:'curl-missing'});}function probe(method){const args=['-sS','-o','/dev/null','-w','%{http_code}','--connect-timeout','3','--max-time','8','-X',method,'-L','--max-redirs','3','--http1.1','--proto-redir','=http,https',safe];const r=spawnSync('curl',args,{encoding:'utf8'});return r;}let used='HEAD';let r=probe('HEAD');let code=String(r.stdout||'').trim();let statusNum=parseInt(code,10);const headRejected=r.status!==0||!statusNum||statusNum===405||statusNum===501;if(headRejected){used='GET';r=probe('GET');code=String(r.stdout||'').trim();statusNum=parseInt(code,10);}const ok=statusNum>=200&&statusNum<400;if(!ok){const errClass=r.error?'spawn-error':(r.status!==0?'curl-exit-'+r.status:('http-'+statusNum));failBlocked('frontend-base-url-unreachable',{baseUrlRedacted:safe,httpStatus:statusNum||null,method:used,curlExit:r.status,errorClass:errClass});}writeProbe({status:'reachable',blockedReason:null,baseUrl:safe,baseUrlRedacted:safe,baseUrlSource,httpStatus:statusNum,method:used,curlExit:r.status});console.log('frontend-test-execution-v1 validated contextBaseUrl='+safe+' source='+baseUrlSource+' probe=reachable method='+used+' httpStatus='+statusNum);`),
4309
4178
  ].join(" "),
4310
4179
  ],
4311
4180
  cwd: ".",
@@ -4338,7 +4207,7 @@ function buildFrontendTestHybridDag(sources) {
4338
4207
  "dimensions: core|boundary|flow|backend only.",
4339
4208
  "Prefer a small smoke suite (default max roughly 4-8 cases unless task frontendTest.maxCasesPerBatch is higher). Never invent unavailable API fields or credentials. Do not create pytest or Playwright source.",
4340
4209
  "HARD playwright-cli-only: every browser step must use repo skill playwright-cli declared commands only. Forbidden: bare `playwright`, `npx playwright`, `playwright test`, `@playwright/test`, Node Playwright API, or generating Playwright/Pytest source. No fallback when playwright-cli is unavailable - case must instruct blocked evidence playwright-cli-unavailable.",
4341
- `Use only the controller-frozen baseUrl ${controllerFrontend.baseUrl} from ${controllerFrontend.baseUrlSource}; context.md may reference it but cannot establish or override it. Every browser start command must be exactly: playwright-cli open --browser=chrome ${controllerFrontend.baseUrl}. Never leave a base-url placeholder. Use default browser session only; never write -s=<case-id>.`,
4210
+ "Copy the resolved absolute baseUrl from testcase/frontend/rag/context.md after the environment probe has marked it reachable. Every browser start command must be exactly: playwright-cli open --browser=chrome <resolved-base-url-from-context.md> with that concrete URL. Never leave a base-url placeholder. Use default browser session only; never write -s=<case-id>.",
4342
4211
  "HARD dynamic refs: executable playwright-cli lines must never contain an angle-bracket token such as <fresh-ref> or descriptive <...> placeholder. Use only shell-safe documentation placeholders `eX`, `eY`, ...; each means the real `eNN` ref parsed from the immediately preceding latest `snapshot`. Write a fresh snapshot before every element reference. eX/eY are never literal structured-tool arguments; a later snapshot invalidates prior refs, so never reuse stale refs.",
4343
4212
  "HARD file-output argv: use canonical `--filename` only. Screenshot uses `playwright-cli screenshot --filename final.png` (a real ref may precede the flag); PDF uses `playwright-cli pdf --filename final.pdf`; snapshot without filename is response-only and a snapshot file uses `playwright-cli snapshot --filename snapshot.txt`. Never generate `playwright-cli screenshot <path>`, use `--path`, `--output`, or `--file`, or pass an output path as a positional target.",
4344
4213
  "Each case must be independently reproducible with fixture/reset, UI reset, snapshot-before-ref, evidence write point under testcase/frontend/evidence/<case-id>/. If the isolated environment is unavailable, require writing blocked evidence before any browser command.",
@@ -4487,7 +4356,7 @@ function buildFrontendTestHybridDag(sources) {
4487
4356
  subtaskPromptTemplate: [
4488
4357
  "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session; do not use /new). playwright-cli-only: never bare Playwright CLI/API/test runner; no fallback.",
4489
4358
  "Use the structured playwright_cli tool for every browser action. Do not request or search for bash. Do not execute raw shell commands. Translate each playwright-cli line in the case Markdown into one playwright_cli tool call ({command, args?, timeoutSeconds?}).",
4490
- `1) The controller-frozen baseUrl is ${controllerFrontend.baseUrl} from ${controllerFrontend.baseUrlSource}; model-authored context/case text cannot establish or override it. 2) Start browser ONLY via playwright_cli command=open with args [--browser=chrome, ${controllerFrontend.baseUrl}] (default session only; no -s=). 3) Dynamic refs: eX/eY in case Markdown are documentation placeholders, never tool args. Immediately before every structured playwright_cli call that references an element, parse the current real eNN from the immediately preceding latest snapshot and pass only that real eNN; never send literal \`eX\`/\`eY\`. A new snapshot invalidates prior refs, so never reuse stale refs. File outputs are canonical: screenshot args [--filename, final.png] (or [e5, --filename, final.png] for a real target), PDF args [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or any output path as a positional target. Follow case steps with snapshot before element refs using only playwright_cli. A passed case requires this same child receipt order: successful open → successful find → controller post-execution cleanup. Only successful find is a meaningful assertion; snapshot, goto, screenshot, request/console, click/fill and other ordinary interactions cannot establish passed authority. 4) Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence (blockedReason playwright-cli-unavailable | frontend-base-url-unreachable); never invent CLI-unavailable solely because bash is absent. 5) For U/D: enforce current-user ownership / create-or-mock-or-blocked; never mutate other users' data.`,
4359
+ "1) Read the concrete baseUrl from testcase/frontend/rag/context.md; it has already passed the environment shell safety/reachability gate. Start browser ONLY via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>] (default session only; no -s=). 2) Dynamic refs: eX/eY in case Markdown are documentation placeholders, never tool args. Immediately before every structured playwright_cli call that references an element, parse the current real eNN from the immediately preceding latest snapshot and pass only that real eNN; never send literal `eX`/`eY`. A new snapshot invalidates prior refs, so never reuse stale refs. File outputs are canonical: screenshot args [--filename, final.png] (or [e5, --filename, final.png] for a real target), PDF args [--filename, final.pdf], and snapshot writes a file only with [--filename, snapshot.txt]; snapshot without filename is response-only. Never use --path, --output, --file, or any output path as a positional target. Follow case steps with snapshot before element refs using only playwright_cli. A passed case requires this same child receipt order: successful open → successful find → controller post-execution cleanup. Only successful find is a meaningful assertion; snapshot, goto, screenshot, request/console, click/fill and other ordinary interactions cannot establish passed authority. 3) Only when preflight or playwright_cli tool explicitly fails may you write blocked evidence (blockedReason playwright-cli-unavailable | frontend-base-url-unreachable); never invent CLI-unavailable solely because bash is absent. 4) For U/D: enforce current-user ownership / create-or-mock-or-blocked; never mutate other users' data.",
4491
4360
  "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId={{case.caseId}}, status passed|failed|blocked, evidencePaths (relative under evidenceDir). blocked needs non-empty blockedReason. After writing, self-check the same contract; if self-check fails, rewrite both files as status=blocked blockedReason=invalid-evidence-shape (never leave missing/malformed evidence).",
4492
4361
  "Business failed/blocked is a recorded result, not a node failure. Close browser via playwright_cli command=close. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
4493
4362
  ].join("\n\n"),
@@ -4510,8 +4379,8 @@ function buildFrontendTestHybridDag(sources) {
4510
4379
  "const manifestPath='testcase/frontend/cases/manifest.json';",
4511
4380
  "if(!fs.existsSync(manifestPath)){process.stdout.write(JSON.stringify({cases:[]}));process.exit(0);}",
4512
4381
  "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));const cases=[];",
4513
- "for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(!r.status){missing=true;reason='missing-result-files';}}catch(_){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||missing)&&attempt<" + String(round) + ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}",
4514
- `fs.mkdirSync('testcase/frontend/evidence',{recursive:true});fs.writeFileSync('testcase/frontend/evidence/${candidateArtifact}',JSON.stringify({schemaVersion:1,cases},null,2)+'\n');process.stdout.write(JSON.stringify({cases}));`,
4382
+ "for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(!r.status){missing=true;reason='missing-result-files';}}catch(_){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||missing)&&attempt<" + String(round) + ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}",
4383
+ `fs.mkdirSync('testcase/frontend/evidence',{recursive:true});fs.writeFileSync('testcase/frontend/evidence/${candidateArtifact}',JSON.stringify({schemaVersion:1,cases},null,2)+'\\n');process.stdout.write(JSON.stringify({cases}));`,
4515
4384
  ].join("");
4516
4385
  tasks.push({
4517
4386
  id: selectorId,
@@ -4577,7 +4446,7 @@ function buildFrontendTestHybridDag(sources) {
4577
4446
  subtaskPromptTemplate: [
4578
4447
  "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite the authoritative execution.md and case-result.json; its final status replaces the earlier case result.",
4579
4448
  "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use structured playwright_cli only; headless open.",
4580
- `Start via playwright_cli command=open with args [--browser=chrome, ${controllerFrontend.baseUrl}]. Passed requires open → find → cleanup receipts.`,
4449
+ "Read the concrete baseUrl from testcase/frontend/rag/context.md and start via playwright_cli command=open with args [--browser=chrome, <that-concrete-baseUrl>]. Passed requires open → find → cleanup receipts.",
4581
4450
  "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId, status, evidencePaths, rerunAttempt={{case.rerunAttempt}}. Write fixed sections `### 执行摘要` and `### 实际执行步骤` to execution.md when available.",
4582
4451
  ].join("\n\n"),
4583
4452
  },
@@ -4673,12 +4542,12 @@ function buildFrontendTestHybridDag(sources) {
4673
4542
  "frontend-test-dag generates Markdown cases and browser evidence only; it must not generate pytest or Playwright test source code.",
4674
4543
  "Each browser case runs serially in a fresh Pi execution boundary. Persist its evidence before starting the next case.",
4675
4544
  "Use only the declared isolated test environment. Production URLs, real credentials, and unauthorized data are blocked.",
4676
- `Browser startup for generated cases must be playwright-cli open --browser=chrome ${controllerFrontend.baseUrl}; this origin is frozen by the controller from ${controllerFrontend.baseUrlSource}, and model-authored files cannot establish or override it; generated operations stay in the default browser session and must not use unverified named-session flags.`,
4545
+ "Browser startup for generated cases must use the concrete non-production baseUrl written by retrieve-frontend-test-context-pi and validated reachable by materialize-frontend-test-execution-shell; generated operations stay in the default browser session and must not use unverified named-session flags.",
4677
4546
  "Browser-tool preflight runs before any frontend-test Pi node; cli-only rollback, missing/incompatible Pi SDK custom-tool capability, missing verified playwright-cli launcher, or incompatible --help fails with zero Pi calls.",
4678
4547
  "Browser case children use commandPolicy capability-allowlist playwright-cli and structured playwright_cli tool; playwright-cli stays capability-gated while ordinary writers have bash.",
4679
4548
  "Passed cases require same-child ordered controller receipts: successful open → successful find → successful post-execution cleanup. Pre-start cleanup, snapshot/goto/screenshot/request/console and ordinary interactions are insufficient; missing or unordered receipts convert to blocked (browser-command-evidence-missing).",
4680
4549
  "playwright-cli-only: generators and executors may call only skill-declared playwright-cli commands; bare playwright / npx playwright / @playwright/test / Playwright source are forbidden with no native Playwright fallback.",
4681
- "Environment preflight must curl-probe the frozen non-production baseUrl before generate; unreachable or curl-unavailable ends preflight as blocked (frontend-base-url-unreachable|curl-unavailable) so generate/map do not run.",
4550
+ "Environment preflight must parse the retrieve-authored context.md baseUrl, reject unsafe/non-production violations, and curl-probe it before generate; unreachable or curl-unavailable ends preflight as blocked (frontend-base-url-unreachable|curl-unavailable) so generate/map do not run.",
4682
4551
  "U/D cases must prove current-user data ownership or create cleanable current-user data or authorized Mock; otherwise blocked (current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable) without cross-user mutation.",
4683
4552
  "Token settings are post-case stop thresholds, never a hard provider token cap. Unstarted cases after a threshold are blocked: token-budget-exhausted.",
4684
4553
  "Pipeline acceptance for frontend-test is frontend-test-result-v1 plus the main report under testcase/frontend/reports/frontend-test-report.html; case pass rate and outcome=passed are quality signals; retrospect is opt-in (frontendTest.reports.retrospect).",