@tea-agent/loop-agent 0.20.1-beta.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/application/dag/args.js +29 -0
  3. package/dist/application/dag/run-dag.js +3 -1
  4. package/dist/cli/command-definitions.js +15 -1
  5. package/dist/cli/program.js +11 -1
  6. package/dist/commands/dag-rerun-task.js +19 -0
  7. package/dist/commands/dag-rerun.js +111 -0
  8. package/dist/commands/init.js +7 -0
  9. package/dist/executors/dag-pi-executor.js +24 -0
  10. package/dist/executors/pi-executor.js +111 -36
  11. package/dist/executors/pi-sdk-executor.js +105 -29
  12. package/dist/executors/shell-executor.js +54 -11
  13. package/dist/shared/operator/capabilities.js +54 -0
  14. package/dist/worker/console/index.js +1 -1
  15. package/dist/worker/console/inspect-split.js +82 -0
  16. package/dist/worker/console/operation-runner.js +3 -1
  17. package/dist/worker/console/operation-store.js +1 -0
  18. package/dist/worker/console/operator-actions.js +153 -2
  19. package/dist/worker/console/operator-user-error.js +10 -0
  20. package/dist/worker/console/pi-readiness.js +4 -0
  21. package/dist/worker/console/recovery-cta.js +116 -5
  22. package/dist/worker/console/recovery-selection.js +107 -0
  23. package/dist/worker/console/resolve-dag-run-for-task.js +50 -11
  24. package/dist/worker/console/routes.js +20 -0
  25. package/dist/worker/console/sibling-controller.js +12 -7
  26. package/dist/worker/console/static/assets/index-CUDke82y.js +18 -0
  27. package/dist/worker/console/static/assets/index-wSEksVSO.css +1 -0
  28. package/dist/worker/console/static/index.html +2 -2
  29. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  30. package/dist/worker/observability/read-model.js +67 -1
  31. package/dist/worker/observe/static/constants.js +5 -0
  32. package/dist/worker/observe/static/format-pool.js +22 -3
  33. package/dist/worker/observe/static/index.html +1 -1
  34. package/dist/worker/observe/static/styles.css +32 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  36. package/dist/worker/run-task/run-task.js +23 -6
  37. package/dist/workflows/dag/backend-test-markdown-workflow.js +291 -97
  38. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  39. package/dist/workflows/dag/frontend-test-result-contract.js +64 -0
  40. package/dist/workflows/dag/init-hybrid.js +117 -64
  41. package/dist/workflows/dag/lifecycle.js +60 -4
  42. package/dist/workflows/dag/liveness-policy.js +250 -0
  43. package/dist/workflows/dag/node-execution.js +89 -6
  44. package/dist/workflows/dag/output-protocol.js +76 -0
  45. package/dist/workflows/dag/rerun-plan.js +611 -0
  46. package/dist/workflows/dag/rerun-run.js +497 -0
  47. package/dist/workflows/dag/rerun-task.js +284 -0
  48. package/dist/workflows/dag/retry-policy.js +20 -1
  49. package/dist/workflows/dag/runner.js +71 -1
  50. package/dist/workflows/dag/skill-snapshot.js +22 -3
  51. package/dist/workflows/dag/types.js +12 -0
  52. package/dist/workflows/dag/validate.js +11 -0
  53. package/dist/workflows/dag/workspace-checkpoint.js +163 -0
  54. package/docs/README.md +5 -5
  55. package/docs/architecture/dag-execution.md +11 -0
  56. package/docs/architecture/facts-and-state.md +1 -0
  57. package/docs/architecture/worker-and-feature.md +10 -0
  58. package/docs/templates/agent-dag.schema.json +17 -2
  59. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  60. package/docs/templates/backend-test-dag.json +15 -15
  61. package/docs/templates/frontend-test-case-checklist.md +16 -1
  62. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +26 -2
  63. package/docs/templates/frontend-test-dag.json +65 -6
  64. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +4 -1
  65. package/harness.json +1 -1
  66. package/package.json +1 -1
  67. package/skills/loop-agent/references/command-reference.md +5 -0
  68. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  69. package/skills/playwright-cli-case-generator/SKILL.md +35 -7
  70. package/dist/worker/console/static/assets/index-3vsjZJHq.js +0 -16
  71. package/dist/worker/console/static/assets/index-i1wV4LrY.css +0 -1
@@ -185,3 +185,67 @@ export function buildFrontendTestOutcomeGateShellSnippet(options) {
185
185
  `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}"`,
186
186
  ].join("; ");
187
187
  }
188
+ /**
189
+ * Shared frontend-test evidence gate for map children + node 7.
190
+ * - Hard fail only on manifest/path integrity (unsafe evidenceDir / escape).
191
+ * - Missing or malformed case-result/execution is healed to status=blocked
192
+ * with blockedReason=invalid-evidence-shape so result materialize + retrospect can run.
193
+ */
194
+ export function buildFrontendCaseEvidenceValidateShellSnippet() {
195
+ const body = [
196
+ "const fs=require('fs'),path=require('path');",
197
+ "const manifestPath='testcase/frontend/cases/manifest.json';",
198
+ "if(!fs.existsSync(manifestPath))throw new Error('missing '+manifestPath);",
199
+ "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
200
+ "if(!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');",
201
+ "const statuses=new Set(['passed','failed','blocked']);",
202
+ "const issues=[];",
203
+ "let hardFail=false;",
204
+ "let evidenceContentCount=0;",
205
+ "function hasEvidenceContent(dir){if(!fs.existsSync(dir))return false;const pending=[dir];while(pending.length){const current=pending.pop();for(const entry of fs.readdirSync(current,{withFileTypes:true})){const candidate=path.join(current,entry.name);if(entry.isDirectory()){pending.push(candidate);}else if(entry.isFile()&&fs.statSync(candidate).size>0){return true;}}}return false;}",
206
+ "function writeBlocked(dir,caseId,reason,detail){",
207
+ " fs.mkdirSync(dir,{recursive:true});",
208
+ " const execution=path.join(dir,'execution.md');",
209
+ " const note='# '+caseId+'\\n\\nStatus: blocked\\n\\nReason: '+reason+(detail?('\\n\\nDetail: '+detail):'')+'\\n';",
210
+ " fs.writeFileSync(execution,note);",
211
+ " fs.writeFileSync(path.join(dir,'case-result.json'),JSON.stringify({caseId:caseId,status:'blocked',blockedReason:reason,evidencePaths:['execution.md']},null,2)+'\\n');",
212
+ "}",
213
+ "function isSafeRel(p){return typeof p==='string'&&p.length>0&&!path.isAbsolute(p)&&!p.includes('..');}",
214
+ "for(const c of manifest.cases){",
215
+ " const id=c&&typeof c.caseId==='string'?c.caseId:'?';",
216
+ " const dir=c&&c.evidenceDir;",
217
+ " const prefix='testcase/frontend/evidence/'+id;",
218
+ " if(!c||typeof c.caseId!=='string'||typeof dir!=='string'||!isSafeRel(dir)||!(dir===prefix||dir.startsWith(prefix+'/'))){",
219
+ " hardFail=true; issues.push({ruleId:'unsafe-evidence-dir',caseId:id,detail:String(dir)}); continue;",
220
+ " }",
221
+ " const execution=path.join(dir,'execution.md');",
222
+ " const resultPath=path.join(dir,'case-result.json');",
223
+ " if(hasEvidenceContent(dir))evidenceContentCount++;",
224
+ " let healReason=null; let healDetail=null;",
225
+ " if(!fs.existsSync(execution)){healReason='invalid-evidence-shape';healDetail='missing execution.md';}",
226
+ " if(!fs.existsSync(resultPath)){healReason='invalid-evidence-shape';healDetail=(healDetail?healDetail+'; ':'')+'missing case-result.json';}",
227
+ " let result=null;",
228
+ " if(!healReason){",
229
+ " try{result=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch(e){healReason='invalid-evidence-shape';healDetail='invalid JSON case-result.json';}",
230
+ " }",
231
+ " if(!healReason){",
232
+ " if(!result||result.caseId!==id||!statuses.has(result.status)||!Array.isArray(result.evidencePaths)||result.evidencePaths.some(p=>!isSafeRel(p))){",
233
+ " healReason='invalid-evidence-shape';healDetail='caseId/status/evidencePaths contract';",
234
+ " } else if(result.status==='passed'&&(result.evidencePaths.length<1||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4|md)$/i.test(p)))){",
235
+ " healReason='invalid-evidence-shape';healDetail='passed requires browser evidence path';",
236
+ " } else if(result.status==='blocked'&&(typeof result.blockedReason!=='string'||!result.blockedReason.trim())){",
237
+ " healReason='invalid-evidence-shape';healDetail='blocked requires blockedReason';",
238
+ " }",
239
+ " }",
240
+ " if(healReason){",
241
+ " writeBlocked(dir,id,healReason,healDetail);",
242
+ " issues.push({ruleId:healReason,caseId:id,detail:healDetail,healed:true});",
243
+ " }",
244
+ "}",
245
+ "if(hardFail){console.error('frontend-test evidence hard-fail: '+JSON.stringify(issues)); process.exit(1);}",
246
+ "if(evidenceContentCount===0){console.error('frontend-test evidence hard-fail: no frontend case evidence content'); process.exit(1);}",
247
+ "const healed=issues.filter(i=>i.healed).length;",
248
+ "console.log('frontend case evidence validation ok cases='+manifest.cases.length+' healed='+healed+(issues.length?(' issues='+JSON.stringify(issues)):''));",
249
+ ].join("");
250
+ return ["node -e", JSON.stringify(body)].join(" ");
251
+ }
@@ -7,7 +7,8 @@ import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1,
7
7
  import { pathMatchesPattern } from "../../shared/git-progress.js";
8
8
  import { BASELINE_FORBIDDEN_PATHS } from "./governance-constants.js";
9
9
  import { buildDecisionEnvelopePromptContract } from "./decision-envelope.js";
10
- import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, STRUCTURED_REQUIRED_PI_RETRY_POLICY, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
10
+ import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRUCTURED_REQUIRED_PI_RETRY_POLICY, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
11
+ import { REVIEW_VERDICT_OUTPUT_PROTOCOL } from "./output-protocol.js";
11
12
  import { resolveAdapter } from "../../adapters/index.js";
12
13
  import { loadHarnessManifest } from "../../governance/harness.js";
13
14
  import { buildAuthoritySurfaceAuditNode, buildAuthoritySurfaceGateNode, resolveAuthoritySurfaceAudit, } from "./authority-surface.js";
@@ -22,7 +23,7 @@ import { normalizeTaskRequirementText, resolveTaskDagTemplateSelection, } from "
22
23
  import { BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT, buildBackendTestExecutionPreflightShellSnippet, } from "./backend-test-execution-contract.js";
23
24
  import { buildBackendTestOutcomeGateShellSnippet } from "./backend-test-result-contract.js";
24
25
  import { buildBackendTestIntakeContext } from "./backend-test-intake-context.js";
25
- import { buildFrontendTestOutcomeGateShellSnippet } from "./frontend-test-result-contract.js";
26
+ import { buildFrontendCaseEvidenceValidateShellSnippet, buildFrontendTestOutcomeGateShellSnippet, } from "./frontend-test-result-contract.js";
26
27
  import { classifyFrontendRisk, } from "./frontend-risk.js";
27
28
  import { discoverFrontendProjectCapability, } from "./frontend-project-capability.js";
28
29
  import { FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, loadFrontendImplementationContractJsonSchema, } from "./frontend-implementation-contract.js";
@@ -2800,12 +2801,18 @@ function buildBackendTestSemanticReviewNode(sources, options = {}) {
2800
2801
  }
2801
2802
  function collectBackendTestShellEnvAllowlist(sources) {
2802
2803
  const names = new Set();
2803
- for (const verify of sources.taskConfig.verifyCommands) {
2804
+ const collectAssignments = (text) => {
2804
2805
  const assignmentPattern = /(?:^|[\s;&|])([A-Z_][A-Z0-9_]*)\s*=/g;
2805
- for (const match of verify.command.matchAll(assignmentPattern)) {
2806
+ for (const match of text.matchAll(assignmentPattern)) {
2806
2807
  if (match[1])
2807
2808
  names.add(match[1]);
2808
2809
  }
2810
+ };
2811
+ for (const constraint of sources.taskConfig.hardConstraints) {
2812
+ collectAssignments(constraint);
2813
+ }
2814
+ for (const verify of sources.taskConfig.verifyCommands) {
2815
+ collectAssignments(verify.command);
2809
2816
  }
2810
2817
  return [...names].sort();
2811
2818
  }
@@ -3050,7 +3057,7 @@ async function buildBackendTestHybridDag(sources) {
3050
3057
  "Read the upstream environment report. Generate a Markdown-first backend test strategy and cases under testcase/md/**.",
3051
3058
  "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.",
3052
3059
  "Create testcase/md/README.md as the concise entry page: test objective, target/environment, isolation/cleanup, module summary and a linked case index table with Case ID, Chinese case name, scenario type, endpoint and expected status/result. Avoid repeating every case body in README.",
3053
- "Write each module as readable case cards. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>` and uses these Chinese headings: `### 测试目的`, `### 验收标准`, `### 需求依据`, `### 前置条件`, optional `### 测试数据`, `### 操作步骤`, `### 预期结果`, and `### 自动化映射`. API metadata may use a compact table under the case heading. The deterministic validator also accepts legacy English headings, but new output should use this Chinese presentation.",
3060
+ "Write each module as readable case cards. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>`. The only sections required by the deterministic validator are `### 前置条件`, `### 操作步骤`, and `### 预期结果` (legacy English aliases remain accepted). Add `测试目的`, `验收标准`, `需求依据`, `测试数据`, and `自动化映射` when useful for human readability; every automatable case should explicitly name its target pytest script under `自动化映射` so traceability can scan only that script.",
3054
3061
  "Place steps and their expected results in a compact readable table when that improves clarity; otherwise keep numbered executable steps and numbered/bulleted independently assertable results. Every result must name the observable HTTP status, response field/value, state transition or membership condition, never vague wording such as ‘符合预期’.",
3055
3062
  "In `自动化映射`, record the planned script path and pytest function name when known. 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.",
3056
3063
  intake.boundedSourceContext, "## Authoritative reference index", JSON.stringify(intake.referenceIndex, null, 2),
@@ -3064,7 +3071,7 @@ async function buildBackendTestHybridDag(sources) {
3064
3071
  writeSet: ["testcase/md/**"], allowedPaths: ["testcase/md/**"], forbiddenPaths: forbidden,
3065
3072
  outputContract: "Review source fidelity and directly revise only testcase/md/**; return concise Markdown, never JSON.",
3066
3073
  subtask_prompt: [
3067
- "Independently review generated Markdown cases against each case 需求依据 and environment evidence. Treat the files as human-facing test documentation: require a clear Chinese name and scenario/purpose, compact metadata, readable steps/results, and a concise automation mapping while preserving exact machine IDs and technical literals.",
3074
+ "Independently review generated Markdown cases against the task requirements and environment evidence. Treat the files as human-facing test documentation: require clear preconditions, executable steps and assertable expected results; improve names, purpose, metadata and automation mapping where useful while preserving exact machine IDs and technical literals.",
3068
3075
  "Check AC completeness/meaning, endpoint, fields/shape, status/error codes, rules, states, documented boundaries/auth, positive/negative coverage, executable steps and assertable results. Reject avoidable English prose, duplicated bilingual wording, repeated boilerplate, oversized unstructured sections, vague results such as ‘符合预期’, and missing script/function mapping where it can be derived.",
3069
3076
  "Correct testcase/md/** directly: add documented omissions, remove unsupported cases, fix mappings/expectations, merge duplicates, improve navigation/tables/Chinese wording, or record gaps in Chinese. Keep Case IDs, AC/REQ/BR IDs, HTTP methods, paths, fields, enum values, filenames, code symbols and source citations exact. The validator accepts Chinese and legacy English section aliases; retain or converge to the Chinese human-readable headings without losing structure.",
3070
3077
  "Read only precise referenced source paths plus requirement sections needed for uncovered ACs. Do not scan the repository, modify source/**, generate pytest, execute tests, or emit JSON.",
@@ -3072,7 +3079,7 @@ async function buildBackendTestHybridDag(sources) {
3072
3079
  "For each index entry, use `readPath` for Pi read-tool calls and keep `path` as the exact Markdown Source References citation. Bound files under .harness/tasks/<taskId>/source/** are read-only inputs: reading them is allowed even though writing .harness/** is forbidden. Never resolve `path` relative to the repository root, search for substitutes, or fall back to docs/** when a bound read fails.",
3073
3080
  ].join("\n\n"),
3074
3081
  };
3075
- const validateCases = shellNode("validate-backend-md-cases-shell", [reviewCases.id], "markdown-cases", "Fail closed on missing/duplicate IDs, sections, AC coverage, source references, executable steps, assertable results, placeholders or secret-shaped content.", "Run-owned reports/backend-md-case-validation.md proving final Markdown quality and safety.");
3082
+ const validateCases = shellNode("validate-backend-md-cases-shell", [reviewCases.id], "markdown-cases", "Record advisory findings for missing/duplicate IDs, missing core sections (preconditions, steps, expected results), AC coverage, executable steps, assertable results or placeholders. Do not validate source-reference existence. Keep quality findings advisory, but fail closed after writing the report when secret-shaped values are detected so downstream pytest/report nodes cannot consume them.", "Run-owned reports/backend-md-case-validation.md with PASS/FAIL advisory findings; downstream execution continues.");
3076
3083
  const generatePytest = {
3077
3084
  id: "generate-backend-pytest-pi", depends_on: [validateCases.id], role: "implementer",
3078
3085
  executor: "pi", toolProfile: "write", complexity: "HIGH", writePolicy: "exclusive",
@@ -3080,20 +3087,23 @@ async function buildBackendTestHybridDag(sources) {
3080
3087
  allowedPaths: Array.from(new Set([...ro, "testcase/**"])), forbiddenPaths: forbidden,
3081
3088
  outputContract: "Convert every final automatable Markdown case into pytest assets whose actual test function region contains the exact Case ID, preferably in the function name or docstring; no JSON and no pytest execution.",
3082
3089
  subtask_prompt: [
3083
- "Convert validated testcase/md/** to pytest using upstream environment and validation evidence plus only bounded pytest config/conftest.",
3084
- "Ensure every final Markdown Case ID appears in at least one real pytest test function or pytest test class method region, preferably as `test_BE_<MODULE>_<NNN>_<description>` and in that function/method docstring. Module-level functions and class-based pytest methods are both supported. Multiple test functions may cover one Case ID; assertions come only from 预期结果/Expected Results and setup comes only from 前置条件/测试数据/自动化映射 or their legacy English aliases.",
3090
+ "Convert testcase/md/** to pytest using upstream environment and advisory validation evidence plus only bounded pytest config/conftest. A FAIL advisory report does not authorize inventing missing behavior; use the final Markdown facts that are present.",
3091
+ "Ensure every final Markdown Case ID appears in at least one real pytest test function or pytest test class method region, preferably as `test_BE_<MODULE>_<NNN>_<description>` and in that function/method docstring. Module-level functions and class-based pytest methods are both supported. Multiple test functions may cover one Case ID; assertions come only from 预期结果/Expected Results and setup comes only from 前置条件 plus any optional 测试数据/自动化映射 or their legacy English aliases.",
3092
+ "Generate a reusable HTTP logging helper (or equivalent client wrapper) and call it for every interface request. The request log must include method, URL/path, and request parameters (query plus JSON/body/payload summary). The response log must include status code and response result (JSON/text/body summary), and both records must be visible in pytest stdout/stderr without changing assertions.",
3093
+ "Compare timestamps and other semantically equivalent protocol values by parsed meaning, not byte-for-byte serialization. In particular, normalize valid ISO-8601 instants before equality/order assertions so differences such as omitted trailing fractional seconds do not create TestBug failures; preserve exact-string assertions only when the Markdown explicitly requires representation equality.",
3094
+ "Before logging, recursively redact sensitive keys and header values including authorization, proxy-authorization, cookie, set-cookie, token, password, secret, api key and credentials. Never print full Authorization/Cookie values. Apply bounded truncation to serialized request and response bodies (with an explicit truncation marker) so large payloads cannot flood pytest or report artifacts.",
3085
3095
  "Do not read source/**, add cases, reassign ACs, modify conftest/config/production code, use skip/xfail, swallow assertions, execute pytest, or emit JSON. For best-effort cleanup, catch only the narrow transport exception actually raised by the selected HTTP client (for example `requests.RequestException` or `urllib.error.URLError`); never use bare `except`, `Exception`, or `BaseException` with `pass`.",
3086
3096
  ].join("\n\n"),
3087
3097
  };
3088
- const traceability = shellNode("backend-test-traceability-gate-shell", [generatePytest.id], "markdown-traceability", "Fail closed only when a real Markdown case heading has no associated pytest test function or class method. Accept exact Case IDs in the function/method name or its decorator/body/docstring region; report multiple mappings and extra automation Case IDs without blocking. Continue to reject skip/xfail or swallowed exceptions.", "Run-owned reports/backend-test-traceability.md proving every real Markdown Case ID is covered by at least one pytest test function.");
3098
+ const traceability = shellNode("backend-test-traceability-gate-shell", [generatePytest.id], "markdown-traceability", "Record advisory findings when a real Markdown case heading has no associated pytest test function or class method in the script explicitly mapped by that Markdown case, or when a mapped HTTP test script lacks request parameters logging, response result logging, recursive redaction or bounded truncation evidence. Accept exact Case IDs in the function/method name or its decorator/body/docstring region. Do not scan unrelated test_*.py files and do not block pytest execution.", "Run-owned reports/backend-test-traceability.md with PASS/FAIL advisory findings for Markdown Case to mapped pytest script/symbol coverage.");
3089
3099
  const pytestCommand = [
3090
3100
  'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
3091
- 'PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest testcase/ -v -p no:cacheprovider --junitxml="${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml"',
3092
- "STATUS=$?", 'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
3093
- 'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml" ]; then exit 0; fi',
3094
- 'exit "${STATUS}"',
3101
+ 'echo "pytest targets are resolved at runtime from final Markdown 自动化映射"',
3095
3102
  ].join("; ");
3096
- const execute = shellNode("execute-backend-pytest-and-html-report-shell", [traceability.id], "markdown-execute-html", "Execute pytest exactly once. Validate JUnit, render the primary self-contained Chinese HTML report from the same JUnit plus final Markdown case metadata without rerun, list every case with name/scenario/script/function/result/duration, and preserve failure summaries plus expandable technical details as facts.", "One pytest execution producing valid JUnit, self-contained HTML and reports/backend-test-facts.md; exit 0/1 with valid evidence continues.", [pytestCommand], 300000);
3103
+ const execute = shellNode("execute-backend-pytest-and-html-report-shell", [traceability.id], "markdown-execute-html", "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Validate JUnit, then render the primary self-contained Chinese HTML report from the same JUnit plus final Markdown case metadata without rerun. Keep 测试结论, quality status, failure overview, and a polished per-case result card with concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.", "One scoped pytest execution over Markdown-mapped scripts producing valid JUnit with per-case captured output, self-contained HTML and reports/backend-test-facts.md; exit 0/1 with valid evidence continues.", [pytestCommand], 300000);
3104
+ if (execute.shell) {
3105
+ execute.shell.envAllowlist = collectBackendTestShellEnvAllowlist(sources);
3106
+ }
3097
3107
  const canWriteReport = taskAllowsBackendTestReportWrite(sources);
3098
3108
  const report = {
3099
3109
  id: "backend-test-report-and-l5-pi", depends_on: [execute.id], role: "closeout", executor: "pi", complexity: "MED",
@@ -3103,7 +3113,9 @@ async function buildBackendTestHybridDag(sources) {
3103
3113
  forbiddenPaths: forbidden,
3104
3114
  outputContract: canWriteReport ? "Final Markdown report and L-5 conclusion under docs/test-reports/**; no JSON." : "Final Markdown report and L-5 conclusion in assistant output; no JSON or writes.",
3105
3115
  subtask_prompt: [
3106
- "Generate the final Markdown report from upstream facts and run-owned environment, case-validation, traceability, JUnit and HTML evidence. Do not emit JSON.",
3116
+ "Generate the final Markdown report from upstream facts and run-owned environment, advisory case-validation, advisory traceability, JUnit and HTML evidence. Do not emit JSON.",
3117
+ "Use this exact human-facing section order: 测试结论 → 执行概览 → 质量校验 → 失败分析 → 风险与建议 → 证据与 L-5. Put the decision and key numbers first, use compact tables/bullets, and keep headings concise. Do not paste entire upstream reports, duplicate per-case tables already present in facts, or repeat the same evidence in multiple sections; link to paths/hashes and quote only the findings needed for the conclusion.",
3118
+ "Always state the exact PASS/FAIL status and findings from nodes 4 and 6. Their FAIL status does not block pytest, but it must remain visible as a quality/traceability risk and must never be rewritten as PASS.",
3107
3119
  "Include environment, case quality/review, automation mapping, exact pytest facts, failure classification/analysis, risks, regression recommendations, evidence paths/hashes, coverage/stability availability, and L-5 READY/NOT READY.",
3108
3120
  "Never override Shell/JUnit facts. One run cannot prove FlakyTest. Missing coverage/stability is Unavailable. L-5 requires pass=100%, AC=100%, automation>=90%, stability>=95% n>=5, line>=80%, branch>=70%, skipped=0 and no blocking Critical risk.",
3109
3121
  canWriteReport ? "Write only under docs/test-reports/**." : "Keep the full report in assistant output.",
@@ -3117,9 +3129,9 @@ async function buildBackendTestHybridDag(sources) {
3117
3129
  successCriteria: extractSuccessCriteria(sources.requirementMarkdown, sources.taskId),
3118
3130
  globalConstraints: [
3119
3131
  ...taskConfig.hardConstraints, ...STANDARD_GLOBAL_CONSTRAINTS,
3120
- "backend-test-dag uses exactly 8 real top-level tasks and executes pytest exactly once.",
3132
+ "backend-test-dag uses exactly 8 real top-level tasks and executes pytest exactly once over only the safe scripts explicitly mapped by final Markdown cases.",
3121
3133
  "Model nodes produce Markdown and pytest assets, never backend-test business JSON envelopes.",
3122
- "Environment, Markdown validation, traceability, JUnit, HTML and execution facts are deterministic fail-closed evidence.",
3134
+ "Environment, advisory Markdown validation, advisory traceability, JUnit, HTML and execution facts are deterministic evidence. Nodes 4 and 6 record findings without blocking nodes 5, 7 or 8.",
3123
3135
  "Only Markdown case generation/review may read source facts; pytest generation must not read source/**.",
3124
3136
  "Functional case IDs use BE-<MODULE>-<NNN>; production code/config, skip/xfail, repair and rerun are forbidden.",
3125
3137
  ],
@@ -3139,12 +3151,15 @@ async function buildBackendTestHybridDag(sources) {
3139
3151
  function buildFrontendTestHybridDag(sources) {
3140
3152
  const rawFrontendTest = sources.taskConfig.frontendTest;
3141
3153
  const config = {
3142
- maxCasesPerBatch: rawFrontendTest?.maxCasesPerBatch ?? 20,
3154
+ // Default 32: common FE suites cover ~24 AC with multi-dimension cases; 20 caused map maxExpandedNodes failures.
3155
+ maxCasesPerBatch: rawFrontendTest?.maxCasesPerBatch ?? 32,
3143
3156
  maxTokensPerCase: rawFrontendTest?.maxTokensPerCase,
3144
3157
  maxTotalTokens: rawFrontendTest?.maxTotalTokens,
3145
3158
  reviewMode: rawFrontendTest?.reviewMode ?? "off",
3146
3159
  strictOutcomeGate: rawFrontendTest?.strictOutcomeGate === true,
3147
3160
  };
3161
+ const declaredRequirementIds = buildDagSourceBinding(sources).requirementIds;
3162
+ const declaredAcIds = declaredRequirementIds.filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
3148
3163
  const reviewMode = config.reviewMode;
3149
3164
  const blockingReview = reviewMode === "blocking";
3150
3165
  const strictOutcomeGate = config.strictOutcomeGate;
@@ -3158,6 +3173,8 @@ function buildFrontendTestHybridDag(sources) {
3158
3173
  const ragWriteSet = ["testcase/frontend/rag/**"];
3159
3174
  const casesWriteSet = ["testcase/frontend/cases/**"];
3160
3175
  const evidenceRoot = "testcase/frontend/evidence";
3176
+ const declaredAcIdsLiteral = JSON.stringify(declaredAcIds);
3177
+ const maxCasesPerBatchLiteral = String(config.maxCasesPerBatch);
3161
3178
  const checklistValidation = [
3162
3179
  "node -e",
3163
3180
  JSON.stringify([
@@ -3169,20 +3186,36 @@ function buildFrontendTestHybridDag(sources) {
3169
3186
  "if(!manifestPath)throw new Error('checklist: missing manifest.draft.json or manifest.json');",
3170
3187
  "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
3171
3188
  "if(!Array.isArray(manifest.cases)||manifest.cases.length===0)throw new Error('checklist: empty cases');",
3189
+ `const declaredAc=new Set(${declaredAcIdsLiteral});`,
3172
3190
  "const issues=[];",
3173
3191
  "const openRe=/playwright-cli\\s+open\\s+--browser=chrome\\s+--headed\\s+https?:\\/\\/\\S+/i;",
3174
3192
  "const prodRe=/(?:^|\\/\\/)(?:www\\.)?[^\\s\\/]*(?:prod|production)/i;",
3175
3193
  "const codeRe=/\\b(pytest|playwright\\.test|@playwright\\/test)\\b/i;",
3194
+ "const barePwRe=/(?:^|[\\s\"'(])(?:npx\\s+playwright\\b|playwright\\s+test\\b|from\\s+['\"]@playwright\\/|require\\(['\"]@playwright\\/|import\\s+.*@playwright\\/|(?<![\\w-])playwright(?!-cli)\\b)/i;",
3195
+ "const caseIdRe=/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;",
3196
+ "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
3176
3197
  "for(const c of manifest.cases){",
3177
3198
  " const id=c&&c.caseId||'?';",
3199
+ " if(typeof c.caseId!=='string'||!caseIdRe.test(c.caseId))issues.push({ruleId:'case-id-shape',caseId:id,detail:'caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*'});",
3200
+ " if(typeof c.caseId==='string'&&/^AC-/i.test(c.caseId))issues.push({ruleId:'case-id-is-ac',caseId:id,detail:'do not use acceptance id as caseId; put AC-FE-* only in acIds'});",
3178
3201
  " const casePath=typeof c.casePath==='string'?c.casePath:null;",
3179
3202
  " if(!casePath||!fs.existsSync(casePath)){issues.push({ruleId:'case-file-missing',caseId:id,detail:String(casePath)});continue;}",
3203
+ " if(typeof c.caseId==='string'&&casePath!=='testcase/frontend/cases/'+c.caseId+'.md')issues.push({ruleId:'case-path-mismatch',caseId:id,detail:casePath+' must equal testcase/frontend/cases/'+c.caseId+'.md'});",
3180
3204
  " const body=fs.readFileSync(casePath,'utf8');",
3181
3205
  " if(!openRe.test(body))issues.push({ruleId:'open-prefix',caseId:id,detail:'missing playwright-cli open --browser=chrome --headed <absolute-url>'});",
3182
3206
  " const m=body.match(/playwright-cli\\s+open\\s+--browser=chrome\\s+--headed\\s+(https?:\\/\\/\\S+)/i);",
3183
3207
  " if(m){const url=m[1].replace(/[)\\]},.\"']+$/,''); if(prodRe.test(url))issues.push({ruleId:'production-url',caseId:id,detail:url});}",
3184
3208
  " if(codeRe.test(body))issues.push({ruleId:'no-test-source',caseId:id,detail:'pytest/playwright test source forbidden'});",
3185
- " if(!Array.isArray(c.acIds)||c.acIds.length===0)issues.push({ruleId:'ac-mapping',caseId:id,detail:'acIds required'});",
3209
+ " if(barePwRe.test(body))issues.push({ruleId:'playwright-cli-only',caseId:id,detail:'only playwright-cli skill commands allowed; bare Playwright CLI/API/test runner forbidden'});",
3210
+ " if(!Array.isArray(c.acIds)||c.acIds.length===0)issues.push({ruleId:'ac-mapping',caseId:id,detail:'acIds required (AC-FE-* acceptance ids, not caseId)'});",
3211
+ " else {",
3212
+ " for(const ac of c.acIds){",
3213
+ " if(typeof ac!=='string'){issues.push({ruleId:'ac-id-shape',caseId:id,detail:String(ac)+' must look like AC-FE-001'});continue;}",
3214
+ " if(/^FE-/i.test(ac)){issues.push({ruleId:'ac-id-is-case',caseId:id,detail:ac+' looks like caseId; acIds must be AC-*'});continue;}",
3215
+ " if(!acIdRe.test(ac)){issues.push({ruleId:'ac-id-shape',caseId:id,detail:ac+' must look like AC-FE-001'});continue;}",
3216
+ " if(declaredAc.size>0&&!declaredAc.has(ac))issues.push({ruleId:'unknown-ac',caseId:id,detail:ac+' not in task sourceBinding.requirementIds'});",
3217
+ " }",
3218
+ " }",
3186
3219
  "}",
3187
3220
  "if(issues.length){console.error('frontend-test checklist blocked: '+JSON.stringify(issues)); process.exit(1);}",
3188
3221
  "console.log('frontend-test checklist ok cases='+manifest.cases.length+' source='+path.basename(manifestPath));",
@@ -3192,37 +3225,40 @@ function buildFrontendTestHybridDag(sources) {
3192
3225
  "node -e",
3193
3226
  JSON.stringify([
3194
3227
  "const fs=require('fs'),path=require('path');",
3195
- "const draft='testcase/frontend/cases/manifest.draft.json',file='testcase/frontend/cases/manifest.json'; if(!fs.existsSync(draft)) throw new Error('missing '+draft);",
3196
- "const manifest=JSON.parse(fs.readFileSync(draft,'utf8')); if(manifest.schemaVersion!==1||!Array.isArray(manifest.cases)||manifest.cases.length===0) throw new Error('invalid or empty frontend case manifest');",
3228
+ "function fail(ruleId,detail){ const msg=JSON.stringify({ruleId:ruleId,detail:String(detail||'')}); console.error('frontend-test manifest blocked: '+msg); throw new Error('frontend-test manifest blocked: '+ruleId+(detail?(': '+detail):'')); }",
3229
+ "const draft='testcase/frontend/cases/manifest.draft.json',file='testcase/frontend/cases/manifest.json',tmp=file+'.tmp';",
3230
+ "if(!fs.existsSync(draft)) fail('draft-missing','missing '+draft);",
3231
+ "let manifest; try{manifest=JSON.parse(fs.readFileSync(draft,'utf8'));}catch(e){fail('draft-invalid-json',e&&e.message||e);}",
3232
+ "if(manifest.schemaVersion!==1) fail('draft-schema','schemaVersion must be 1');",
3233
+ "if(!Array.isArray(manifest.cases)||manifest.cases.length===0) fail('draft-empty-cases','cases must be a non-empty array');",
3234
+ `const maxCases=${maxCasesPerBatchLiteral};`,
3235
+ "if(manifest.cases.length>maxCases) fail('map-capacity-exceeded','cases='+manifest.cases.length+' exceeds maxCasesPerBatch/maxExpandedNodes='+maxCases+'; raise frontendTest.maxCasesPerBatch or shrink the suite');",
3197
3236
  "const dims=new Set(['core','boundary','flow','backend']);",
3237
+ `const declaredAc=new Set(${declaredAcIdsLiteral});`,
3198
3238
  "const seen=new Set(); const seenCasePath=new Set(); const seenEvidenceDir=new Set();",
3239
+ "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
3199
3240
  "for(const c of manifest.cases){",
3200
- " if(!c||typeof c.caseId!=='string'||!/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId)||seen.has(c.caseId)) throw new Error('invalid or duplicate caseId');",
3241
+ " if(!c||typeof c.caseId!=='string'||!/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId)) fail('case-id-shape','caseId must be FE-*, never AC-FE-*: '+String(c&&c.caseId));",
3242
+ " if(/^AC-/i.test(c.caseId)) fail('case-id-is-ac','caseId must not be an acceptance id: '+c.caseId);",
3243
+ " if(seen.has(c.caseId)) fail('duplicate-case-id',c.caseId);",
3201
3244
  " seen.add(c.caseId);",
3202
- " if(typeof c.dimension!=='string'||!dims.has(c.dimension)) throw new Error('invalid dimension');",
3203
- " if(!Array.isArray(c.acIds)||c.acIds.length===0||c.acIds.some(a=>typeof a!=='string'||!a.trim())) throw new Error('invalid acIds');",
3204
- " for(const k of ['casePath','evidenceDir']){ const v=c[k]; if(typeof v!=='string'||path.isAbsolute(v)||v.includes('..')) throw new Error('unsafe '+k); }",
3205
- " if(c.casePath!=='testcase/frontend/cases/'+c.caseId+'.md') throw new Error('casePath must match caseId');",
3206
- " { const prefix='testcase/frontend/evidence/'+c.caseId; if(!(c.evidenceDir===prefix||c.evidenceDir.startsWith(prefix+'/'))) throw new Error('case path escapes frontend test roots'); }",
3207
- " if(!fs.existsSync(c.casePath)) throw new Error('missing case file '+c.casePath);",
3208
- " if(seenCasePath.has(c.casePath)) throw new Error('duplicate casePath'); seenCasePath.add(c.casePath);",
3209
- " if(seenEvidenceDir.has(c.evidenceDir)) throw new Error('duplicate evidenceDir'); seenEvidenceDir.add(c.evidenceDir);",
3245
+ " if(typeof c.dimension!=='string'||!dims.has(c.dimension)) fail('invalid-dimension',String(c.dimension));",
3246
+ " if(!Array.isArray(c.acIds)||c.acIds.length===0||c.acIds.some(a=>typeof a!=='string'||!a.trim())) fail('ac-mapping','invalid acIds for '+c.caseId);",
3247
+ " for(const ac of c.acIds){ if(!acIdRe.test(ac)) fail('ac-id-shape','acIds entry must be AC-* acceptance id, not caseId: '+ac); if(declaredAc.size>0&&!declaredAc.has(ac)) fail('unknown-ac',ac+' not in sourceBinding; repair generator input or AC list'); }",
3248
+ " c.casePath='testcase/frontend/cases/'+c.caseId+'.md';",
3249
+ " c.evidenceDir='testcase/frontend/evidence/'+c.caseId+'/';",
3250
+ " for(const k of ['casePath','evidenceDir']){ const v=c[k]; if(typeof v!=='string'||path.isAbsolute(v)||v.includes('..')) fail('unsafe-path',k+': '+String(v)); }",
3251
+ " if(!fs.existsSync(c.casePath)) fail('case-file-missing','missing case file '+c.casePath+' (filename must equal caseId.md)');",
3252
+ " if(seenCasePath.has(c.casePath)) fail('duplicate-case-path',c.casePath); seenCasePath.add(c.casePath);",
3253
+ " if(seenEvidenceDir.has(c.evidenceDir)) fail('duplicate-evidence-dir',c.evidenceDir); seenEvidenceDir.add(c.evidenceDir);",
3210
3254
  "}",
3211
- "fs.writeFileSync(file,JSON.stringify(manifest,null,2)+'\\n'); fs.unlinkSync(draft);",
3255
+ "const payload=JSON.stringify(manifest,null,2)+'\\n';",
3256
+ "fs.writeFileSync(tmp,payload); fs.renameSync(tmp,file); try{fs.unlinkSync(draft);}catch(_){}",
3212
3257
  "process.stdout.write(JSON.stringify({cases:manifest.cases}));",
3213
3258
  ].join("")),
3214
3259
  ].join(" ");
3215
- const evidenceValidation = [
3216
- "node -e",
3217
- JSON.stringify([
3218
- "const fs=require('fs'),path=require('path');",
3219
- "const manifestPath='testcase/frontend/cases/manifest.json';if(!fs.existsSync(manifestPath))throw new Error('missing '+manifestPath);",
3220
- "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));if(!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');",
3221
- "const statuses=new Set(['passed','failed','blocked']);let failed=false;",
3222
- "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==='testcase/frontend/evidence/'+c.caseId||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<1||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4|md)$/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;}}",
3223
- "if(failed)process.exit(1);console.log('frontend case evidence validation ok cases='+manifest.cases.length);",
3224
- ].join("")),
3225
- ].join(" ");
3260
+ // Heal malformed/missing case evidence to blocked; only unsafe evidenceDir hard-fails.
3261
+ const evidenceValidation = buildFrontendCaseEvidenceValidateShellSnippet();
3226
3262
  const frontendTestOutcomeGate = buildFrontendTestOutcomeGateShellSnippet();
3227
3263
  const tasks = [
3228
3264
  {
@@ -3236,12 +3272,12 @@ function buildFrontendTestHybridDag(sources) {
3236
3272
  writeSet: ragWriteSet,
3237
3273
  allowedPaths: [...commonReadOnlyPaths(sources), ...ragWriteSet],
3238
3274
  forbiddenPaths: forbidden,
3239
- outputContract: "Write short testcase/frontend/rag/context.md and coverage-map.md with machine-readable baseUrl and capability notes.",
3275
+ outputContract: "Write short testcase/frontend/rag/context.md and coverage-map.md with machine-readable baseUrl, baseUrlSource, environmentProbe=pending, and capability notes.",
3240
3276
  subtask_prompt: [
3241
3277
  "Build the frontend test RAG package (keep it short).",
3242
3278
  "Read task source, routes/components/API or Mock facts, and execution contract. Write only testcase/frontend/rag/context.md and coverage-map.md.",
3243
- "Prefer fixed fields: baseUrl, baseUrlSource, AC table, capability matrix (backend real/mock, pagination data, HTTP observation, error injection), risks, forbidden hosts. Do not paste large implementation dumps.",
3244
- "Base URL resolution (required): (1) Prefer absolute http(s) frontend URL from task source config.md. (2) Else default http://localhost:5173. (3) Never production hosts. (4) Write `baseUrl: <url>` and `baseUrlSource: config.md|<path>|default-localhost-5173`. (5) Include exact start prefix: playwright-cli open --browser=chrome --headed <resolved-base-url>.",
3279
+ "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.",
3280
+ "Base URL resolution (required): (1) Prefer absolute http(s) frontend URL from task source config.md. (2) Else default http://localhost:5173. (3) Never production hosts. (4) Write `baseUrl: <url>` and `baseUrlSource: config.md|<path>|default-localhost-5173`. (5) Write `environmentProbe: pending` (preflight shell updates to reachable|unreachable|curl-unavailable with structured reason). (6) Include exact start prefix: playwright-cli open --browser=chrome --headed <resolved-base-url>.",
3245
3281
  buildSourceContextBlock(sources),
3246
3282
  ].join("\n\n"),
3247
3283
  },
@@ -3251,16 +3287,17 @@ function buildFrontendTestHybridDag(sources) {
3251
3287
  role: "verifier",
3252
3288
  executor: "shell",
3253
3289
  complexity: "LOW",
3254
- writePolicy: "read-only",
3290
+ writePolicy: "exclusive",
3291
+ writeSet: ragWriteSet,
3255
3292
  allowedPaths: [...ragWriteSet],
3256
3293
  forbiddenPaths: forbidden,
3257
- outputContract: "Fail-closed preflight: absolute non-production baseUrl required; fixture/reset not hard-gated.",
3258
- subtask_prompt: "Hard-validate only an absolute non-production baseUrl in RAG context (from config.md or default http://localhost:5173). Fixture/reset and other isolation details are soft guidance for later nodes, not preflight failures.",
3294
+ 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).",
3295
+ subtask_prompt: "Parse frozen baseUrl from context.md (config.md preferred, else http://localhost:5173). Reject production / non-http(s). Probe with 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.",
3259
3296
  shell: {
3260
3297
  commands: [
3261
3298
  [
3262
3299
  "node -e",
3263
- 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 patterns=[ /baseUrl\\s*[:=]\\s*(https?:\\/\\/\\S+)/i, /base[- ]url\\s*[:=]\\s*(https?:\\/\\/\\S+)/i, /playwright-cli open --browser=chrome --headed\\s+(https?:\\/\\/\\S+)/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 (prefer config.md; default http://localhost:5173)'); baseUrl=baseUrl.replace(/[)\\]},.\"']+$/,''); if(!/^https?:\\/\\//i.test(baseUrl))throw new Error('baseUrl must be absolute http(s): '+baseUrl); if(/(?:^|\\/\\/)(?:www\\.)?[^\\s/]*(?:prod|production)/i.test(baseUrl))throw new Error('production URL forbidden: '+baseUrl); console.log('frontend-test-execution-v1 validated baseUrl='+baseUrl);"),
3300
+ 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*(https?:\\/\\/\\S+)/i,/base[- ]url\\s*[:=]\\s*(https?:\\/\\/\\S+)/i,/playwright-cli open --browser=chrome --headed\\s+(https?:\\/\\/\\S+)/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 (prefer config.md; default http://localhost:5173)');baseUrl=baseUrl.replace(/[)\\}\\],.\\\"']+$/,'');if(!/^https?:\\/\\//i.test(baseUrl))throw new Error('baseUrl must be absolute http(s): '+baseUrl);if(/(?:^|\\/\\/)(?:www\\.)?[^\\s\\/]*(?:prod|production)/i.test(baseUrl))throw new Error('production URL forbidden: '+baseUrl);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,baseUrlRedacted:safe,httpStatus:statusNum,method:used,curlExit:r.status});console.log('frontend-test-execution-v1 validated baseUrl='+safe+' probe=reachable method='+used+' httpStatus='+statusNum);"),
3264
3301
  ].join(" "),
3265
3302
  ],
3266
3303
  cwd: ".",
@@ -3282,10 +3319,18 @@ function buildFrontendTestHybridDag(sources) {
3282
3319
  subtask_prompt: [
3283
3320
  "Use skill playwright-cli-case-generator.",
3284
3321
  "Read only testcase/frontend/rag/context.md, testcase/frontend/rag/coverage-map.md, and existing testcase/frontend/cases/. Write only testcase/frontend/cases/**.",
3285
- "Generate Markdown cases, index.md and manifest.draft.json (schemaVersion 1; cases[] with caseId, casePath, dimension, acIds, evidenceDir). IDs use FE-<FEATURE>-<NNN>-<dimension>; dimensions core|boundary|flow|backend.",
3286
- "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.",
3287
- "Copy the resolved absolute baseUrl from context.md (baseUrl field; resolved from config.md or default http://localhost:5173). Every browser start command must be: playwright-cli open --browser=chrome --headed <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>.",
3322
+ "Generate Markdown cases, index.md and manifest.draft.json (schemaVersion 1; cases[] with caseId, casePath, dimension, acIds, evidenceDir).",
3323
+ "HARD ID CONTRACT (do not confuse these):",
3324
+ "- caseId / filename MUST be FE-<FEATURE>-<NNN>-<dimension> (example FE-LOGIN-001-core). NEVER use AC-FE-* as caseId or filename.",
3325
+ "- acIds MUST list acceptance criteria only: AC-FE-* / AC-* from the declared task list (example AC-FE-001). NEVER put FE-* case ids into acIds.",
3326
+ "- casePath MUST equal testcase/frontend/cases/<caseId>.md; evidenceDir MUST equal testcase/frontend/evidence/<caseId>/. Materialize will rewrite paths, but files must already use caseId filenames.",
3327
+ `Declared acceptance ids for this task (use only these in acIds when non-empty): ${declaredAcIds.length > 0 ? declaredAcIds.join(", ") : "(none extracted - still use AC-* shape, never FE-* case ids)"}.`,
3328
+ "dimensions: core|boundary|flow|backend only.",
3329
+ "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.",
3330
+ "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.",
3331
+ "Copy the resolved absolute baseUrl from context.md (baseUrl field; resolved from config.md or default http://localhost:5173). Every browser start command must be: playwright-cli open --browser=chrome --headed <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>.",
3288
3332
  "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.",
3333
+ "U/D data ownership (required for modify/delete): (1) Only mutate data whose ownership is proven by current login identity + observable UI/API owner fields - never by name/guessed id/list order alone. (2) If current user has no data, create tagged cleanable data in current-user context, then U/D, then cleanup+verify. (3) Else only task-authorized Mock, labeled as Mock (not real backend proof). (4) If ownership unverifiable and create/Mock unavailable: write blocked with blockedReason current-user-data-unavailable | data-ownership-unverifiable | safe-test-data-setup-unavailable - do not risk cross-user data. (5) Never touch other users, shared fixtures, production, or non-cleanable data.",
3289
3334
  ].join("\n\n"),
3290
3335
  },
3291
3336
  ];
@@ -3368,7 +3413,7 @@ function buildFrontendTestHybridDag(sources) {
3368
3413
  writePolicy: "read-only",
3369
3414
  allowedPaths: [...ragWriteSet, ...casesWriteSet],
3370
3415
  forbiddenPaths: forbidden,
3371
- outputContract: "Mechanical checklist: open-prefix, non-prod absolute URL, acIds, no pytest/playwright test source; emit structured ruleId issues on failure.",
3416
+ outputContract: "Mechanical checklist: open-prefix, non-prod absolute URL, acIds, playwright-cli-only, no pytest/playwright test source; emit structured ruleId issues on failure.",
3372
3417
  subtask_prompt: "Scan generated cases/manifest against the shared blocking checklist. Do not use free-form LLM verdicts.",
3373
3418
  shell: { commands: [checklistValidation], cwd: ".", timeoutMs: 120000 },
3374
3419
  }, {
@@ -3381,7 +3426,7 @@ function buildFrontendTestHybridDag(sources) {
3381
3426
  writeSet: casesWriteSet,
3382
3427
  allowedPaths: casesWriteSet,
3383
3428
  forbiddenPaths: forbidden,
3384
- outputContract: "Validated frontend manifest payload { cases: [...] }; atomically materialize testcase/frontend/cases/manifest.json from manifest.draft.json; shell output may echo only the prefix before exactly one final JSON line.",
3429
+ outputContract: "Validated frontend manifest payload { cases: [...] }; ruleId-tagged fail-closed validation; atomically materialize testcase/frontend/cases/manifest.json via temp+rename then delete draft; stdout is exactly one final JSON line {cases}.",
3385
3430
  subtask_prompt: "Validate manifest.draft.json and materialize manifest.json after the mechanical checklist (and optional blocking review) passes.",
3386
3431
  shell: { commands: [manifestValidation], cwd: ".", timeoutMs: 120000 },
3387
3432
  }, {
@@ -3427,9 +3472,9 @@ function buildFrontendTestHybridDag(sources) {
3427
3472
  writeSet: [`${evidenceRoot}/{{case.caseId}}/**`],
3428
3473
  outputContract: "Compact JSON <=1200 characters with case status, evidence paths, error summary, and tokens.",
3429
3474
  subtaskPromptTemplate: [
3430
- "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session; do not use /new). Prefer playwright-cli over prose review.",
3431
- "1) Read baseUrl from testcase/frontend/rag/context.md (config.md preferred, else http://localhost:5173). 2) Start browser: playwright-cli open --browser=chrome --headed <resolved-base-url> (default session only; no -s=). 3) Follow the case steps with snapshot before element refs. 4) If env/CLI/baseUrl is unavailable, write blocked evidence and do not open a browser.",
3432
- "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json (caseId, status passed|failed|blocked, evidencePaths; blocked needs blockedReason). Then validate: 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)\".",
3475
+ "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.",
3476
+ "1) Read baseUrl from testcase/frontend/rag/context.md (config.md preferred, else http://localhost:5173). 2) Start browser ONLY: playwright-cli open --browser=chrome --headed <resolved-base-url> (default session only; no -s=). 3) Follow case steps with snapshot before element refs using only playwright-cli skill commands. 4) If env/CLI/baseUrl/playwright-cli unavailable, write blocked evidence (blockedReason playwright-cli-unavailable | frontend-base-url-unreachable) and do not open a browser. 5) For U/D: enforce current-user ownership / create-or-mock-or-blocked; never mutate other users' data.",
3477
+ "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).",
3433
3478
  "Business failed/blocked is a recorded result, not a node failure. Close browser. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
3434
3479
  ].join("\n\n"),
3435
3480
  },
@@ -3440,11 +3485,12 @@ function buildFrontendTestHybridDag(sources) {
3440
3485
  role: "verifier",
3441
3486
  executor: "shell",
3442
3487
  complexity: "LOW",
3443
- writePolicy: "read-only",
3488
+ writePolicy: "exclusive",
3489
+ writeSet: [`${evidenceRoot}/**`],
3444
3490
  allowedPaths: ["testcase/frontend/cases/**", `${evidenceRoot}/**`],
3445
3491
  forbiddenPaths: forbidden,
3446
- outputContract: "Deterministic validation that every manifest case has execution.md and valid matching case-result.json; blocked results require blockedReason.",
3447
- subtask_prompt: "Validate all frontend case evidence before result materialization; fail closed on missing or malformed records.",
3492
+ outputContract: "Deterministic evidence gate: heal missing/malformed case-result to blocked(invalid-evidence-shape); hard-fail only on unsafe evidenceDir. Does not block retrospect.",
3493
+ subtask_prompt: "Validate frontend case evidence before result materialization. Prefer healing bad shapes to blocked so pipeline can still produce a report; only path-escape failures abort the node.",
3448
3494
  shell: { commands: [evidenceValidation], cwd: ".", timeoutMs: 120000 },
3449
3495
  }, {
3450
3496
  id: "materialize-frontend-test-result-shell",
@@ -3509,6 +3555,9 @@ function buildFrontendTestHybridDag(sources) {
3509
3555
  "Each browser case runs serially in a fresh Pi execution boundary. Persist its evidence before starting the next case.",
3510
3556
  "Use only the declared isolated test environment. Production URLs, real credentials, and unauthorized data are blocked.",
3511
3557
  "Browser startup for generated cases must be playwright-cli open --browser=chrome --headed <resolved-base-url>; resolve baseUrl from task source config.md when present, otherwise default http://localhost:5173; generated operations stay in the default browser session and must not use unverified named-session flags.",
3558
+ "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.",
3559
+ "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.",
3560
+ "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.",
3512
3561
  "Token settings are post-case stop thresholds, never a hard provider token cap. Unstarted cases after a threshold are blocked: token-budget-exhausted.",
3513
3562
  "Pipeline acceptance for frontend-test is the final retrospect report under testcase/frontend/reports/; case pass rate and outcome=passed are quality signals, not the default pipeline success condition.",
3514
3563
  blockingReview
@@ -4598,9 +4647,11 @@ function applyDefaultReadOnlyRetryPolicy(spec) {
4598
4647
  for (const task of spec.tasks) {
4599
4648
  if (task.retryPolicy !== undefined)
4600
4649
  continue;
4601
- if (isSafeReadOnlyPiRetryCandidate(task)) {
4602
- task.retryPolicy = DEFAULT_READ_ONLY_PI_RETRY_POLICY;
4603
- }
4650
+ if (!isSafeReadOnlyPiRetryCandidate(task))
4651
+ continue;
4652
+ task.retryPolicy = task.outputProtocol
4653
+ ? PROTOCOL_AWARE_PI_RETRY_POLICY
4654
+ : DEFAULT_READ_ONLY_PI_RETRY_POLICY;
4604
4655
  }
4605
4656
  }
4606
4657
  function getTaskOrThrow(spec, id) {
@@ -4634,6 +4685,7 @@ function buildReviewNode(sources) {
4634
4685
  allowedPaths: commonReadOnlyPaths(sources),
4635
4686
  forbiddenPaths: commonForbiddenPaths(sources),
4636
4687
  outputContract: "Plain Markdown whose first non-empty line is VERDICT: pass or VERDICT: request-revision; Critical/Important findings force request-revision. No file writes.",
4688
+ outputProtocol: REVIEW_VERDICT_OUTPUT_PROTOCOL,
4637
4689
  subtask_prompt: [
4638
4690
  "Review upstream implementation and verification evidence.",
4639
4691
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
@@ -4661,6 +4713,7 @@ function buildReviewVerdictRecoveryNode(sources) {
4661
4713
  allowedPaths: commonReadOnlyPaths(sources),
4662
4714
  forbiddenPaths: commonForbiddenPaths(sources),
4663
4715
  outputContract: "Plain Markdown whose first non-empty line is exactly VERDICT: pass or VERDICT: request-revision, followed by the original review findings without substantive changes. No file writes.",
4716
+ outputProtocol: REVIEW_VERDICT_OUTPUT_PROTOCOL,
4664
4717
  subtask_prompt: [
4665
4718
  "Normalize the output format of review-pi; this is the single read-only format-recovery attempt for the review verdict protocol.",
4666
4719
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",