@tea-agent/loop-agent 0.27.1 → 0.28.1-beta.1

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 (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/application/task-lifecycle/observe.js +5 -0
  3. package/dist/application/task-lifecycle/plan-transitions.js +7 -2
  4. package/dist/cli/program.js +1 -1
  5. package/dist/commands/client-recovery.js +439 -20
  6. package/dist/commands/init.js +42 -6
  7. package/dist/executors/dag-pi-executor.js +161 -60
  8. package/dist/executors/pi-playwright-cli-tool.js +955 -0
  9. package/dist/executors/pi-sdk-executor.js +56 -0
  10. package/dist/executors/playwright-cli-launcher.js +63 -0
  11. package/dist/executors/shell-executor.js +128 -0
  12. package/dist/shared/playwright-cli-command-policy.js +41 -0
  13. package/dist/task/task-demand-routing.js +1 -15
  14. package/dist/worker/observability/read-model.js +66 -8
  15. package/dist/worker/observe/static/dag-model.js +85 -13
  16. package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
  17. package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
  18. package/dist/workflows/dag/frontend-implementation-contract.js +124 -6
  19. package/dist/workflows/dag/frontend-prewrite-gate.js +22 -8
  20. package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
  21. package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
  22. package/dist/workflows/dag/init-hybrid.js +181 -68
  23. package/dist/workflows/dag/lifecycle.js +33 -2
  24. package/dist/workflows/dag/node-execution.js +11 -5
  25. package/dist/workflows/dag/output-protocol.js +48 -83
  26. package/dist/workflows/dag/report.js +9 -2
  27. package/dist/workflows/dag/rerun-run.js +62 -3
  28. package/dist/workflows/dag/run-store.js +6 -1
  29. package/dist/workflows/dag/runner.js +15 -3
  30. package/dist/workflows/dag/types.js +27 -0
  31. package/dist/workflows/dag/validate.js +121 -1
  32. package/docs/architecture/runtime-boundaries.md +13 -11
  33. package/docs/init-surface.manifest.json +6 -2
  34. package/docs/templates/README.md +9 -1
  35. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  36. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
  37. package/docs/templates/frontend-test-dag.json +55 -15
  38. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
  39. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  40. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  41. package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
  42. package/harness.json +1 -1
  43. package/package.json +1 -1
  44. package/skills/loop-agent/SKILL.md +1 -1
  45. package/skills/loop-agent/references/command-reference.md +18 -6
  46. package/skills/playwright-cli/SKILL.md +69 -402
  47. package/skills/playwright-cli/references/tracing.md +3 -137
  48. package/skills/playwright-cli/references/video-recording.md +3 -141
  49. package/skills/playwright-cli-case-generator/SKILL.md +53 -46
@@ -1,6 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { access, readdir, readFile, realpath } from "node:fs/promises";
3
- import { existsSync, readFileSync } from "node:fs";
4
3
  import path from "node:path";
5
4
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
6
5
  import { assertValidDagSpec } from "./validate.js";
@@ -734,15 +733,16 @@ function resolveImplementPaths(taskConfig, options = {}) {
734
733
  return normalized === "docs" || normalized.startsWith("docs/");
735
734
  });
736
735
  if (repoRoot && mayNeedDocIndex) {
737
- const merged = mergeDocumentIndexCompanions({
736
+ const closure = mergeDocumentIndexCompanions({
738
737
  repoRoot,
739
738
  paths: allowed,
740
739
  forbiddenPaths: forbidden,
741
740
  });
742
- return {
743
- allowedPaths: merged.paths,
744
- writeSet: [...merged.paths],
745
- };
741
+ const explicitAllowedPaths = new Set(allowed.map((entry) => entry.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")));
742
+ const missingCompanions = closure.companions.filter((companion) => !explicitAllowedPaths.has(companion));
743
+ if (missingCompanions.length > 0) {
744
+ throw new Error(`document-index-closure: catalog companion paths must be explicitly authorized in task allowedPaths: ${missingCompanions.map((companion) => `"${companion}"`).join(", ")}. Add each missing path with task advance --allowed-path <path>; required command(s): ${missingCompanions.map((companion) => `task advance --allowed-path ${companion}`).join("; ")}`);
745
+ }
746
746
  }
747
747
  return {
748
748
  allowedPaths: allowed,
@@ -1079,40 +1079,14 @@ function isFrontendLintVerifyCommand(command) {
1079
1079
  const text = `${command.label}\n${command.args.join(" ")}`;
1080
1080
  return /\b(?:lint|eslint)\b/i.test(text);
1081
1081
  }
1082
- function isManagedCiWrapperVerifyCommand(command) {
1083
- const text = `${command.label}\n${command.args.join(" ")}`.replaceAll("\\", "/");
1084
- return /\bscripts\/ci(?:-tests)?\.sh\b/.test(text);
1085
- }
1086
- function repoHasNpmScript(repoRoot, scriptName) {
1087
- if (!repoRoot)
1088
- return false;
1089
- const packagePath = path.join(repoRoot, "package.json");
1090
- if (!existsSync(packagePath))
1091
- return false;
1092
- try {
1093
- const decoded = JSON.parse(readFileSync(packagePath, "utf8"));
1094
- return typeof decoded.scripts?.[scriptName] === "string";
1095
- }
1096
- catch {
1097
- return false;
1098
- }
1099
- }
1100
1082
  function partitionFrontendStaticVerifyCommands(input) {
1101
1083
  const commands = input.commands ?? [];
1102
- const lintCommands = commands.filter(isFrontendLintVerifyCommand);
1084
+ // Frontend DAGs deliberately never generate lint verification. Existing
1085
+ // project lint debt is allowed to remain outside this workflow, and an
1086
+ // explicitly mentioned lint command must not reintroduce the lint gate.
1103
1087
  const staticCommands = commands.filter((command) => !isFrontendLintVerifyCommand(command));
1104
- if (lintCommands.length === 0 &&
1105
- commands.some(isManagedCiWrapperVerifyCommand) &&
1106
- repoHasNpmScript(input.repoRoot, "lint")) {
1107
- lintCommands.push({
1108
- args: ["npm", "run", "lint"],
1109
- cwd: input.repoRoot,
1110
- label: "npm run lint",
1111
- });
1112
- }
1113
1088
  return {
1114
1089
  lint: {
1115
- ...(lintCommands.length > 0 ? { commands: lintCommands } : {}),
1116
1090
  commandSource: input.commandSource,
1117
1091
  },
1118
1092
  static: {
@@ -1211,7 +1185,6 @@ async function discoverFrontendFallbackVerifyCommands(repoRoot) {
1211
1185
  const staticCommands = firstExisting([
1212
1186
  "typecheck",
1213
1187
  "check-types",
1214
- "lint",
1215
1188
  "check",
1216
1189
  "build",
1217
1190
  ]);
@@ -1688,6 +1661,7 @@ export function buildStandardHybridDagFromTask(sources) {
1688
1661
  writeSet: implementPaths.writeSet,
1689
1662
  allowedPaths: implementPaths.allowedPaths,
1690
1663
  forbiddenPaths,
1664
+ writerOutcomePolicy: { type: "implementation-outcome-v1" },
1691
1665
  subtask_prompt: [
1692
1666
  "Implement the approved plan with minimal focused changes.",
1693
1667
  "Stay within writeSet. Do not write root artifacts/** unless artifacts paths are explicitly declared in writeSet.",
@@ -2455,9 +2429,62 @@ async function buildFrontendHybridDagFromTask(sources) {
2455
2429
  sourceContext,
2456
2430
  ].join("\n\n"),
2457
2431
  },
2432
+ {
2433
+ id: "frontend-contract-json-pi",
2434
+ depends_on: [
2435
+ "frontend-plan-revision-pi",
2436
+ "frontend-plan-pi",
2437
+ "frontend-final-design-review-pi",
2438
+ "frontend-design-review-pi",
2439
+ ],
2440
+ dependsPolicy: "all-or-condition-skip",
2441
+ role: "planner",
2442
+ executor: "pi",
2443
+ complexity: "MED",
2444
+ writePolicy: "read-only",
2445
+ outputMode: "structured-required",
2446
+ retryPolicy: STRUCTURED_REQUIRED_PI_RETRY_POLICY,
2447
+ allowedPaths: readOnlyPaths,
2448
+ forbiddenPaths,
2449
+ skills: FRONTEND_IMPLEMENTATION_SKILLS,
2450
+ outputContract: "Return exactly one JSON object conforming to frontend-implementation-contract-v1. No Markdown, prose, comments, or code fences.",
2451
+ subtask_prompt: [
2452
+ "Convert the effective reviewed frontend plan into the canonical frontend-implementation-contract-v1 JSON.",
2453
+ "Use frontend-plan-revision-pi when it is FINISHED; otherwise use frontend-plan-pi. Confirm the effective design review passed before producing the contract.",
2454
+ "Return only the JSON object. Do not wrap it in Markdown or a code fence. Do not add explanatory text.",
2455
+ "Preserve all requirement expectedOutcome, interaction trigger/expectedBehavior, target files, verification targets, Mock/API decisions, and Real Integration Gap from the effective plan.",
2456
+ frontendContractSchemaBlock,
2457
+ sourceContext,
2458
+ ].join("\n\n"),
2459
+ },
2460
+ {
2461
+ id: "frontend-contract-json-validate-shell",
2462
+ depends_on: ["frontend-contract-json-pi"],
2463
+ role: "verifier",
2464
+ executor: "shell",
2465
+ complexity: "LOW",
2466
+ writePolicy: "read-only",
2467
+ allowedPaths: readOnlyPaths,
2468
+ forbiddenPaths,
2469
+ outputContract: "Validated frontend implementation contract artifact with schema ID and SHA-256.",
2470
+ subtask_prompt: "Materialize and validate the structured frontend contract before prewrite authorization.",
2471
+ shell: {
2472
+ commands: [],
2473
+ jsonArtifactGate: {
2474
+ fromNodeId: "frontend-contract-json-pi",
2475
+ schemaId: "frontend-implementation-contract-v1",
2476
+ artifactName: "frontend-implementation-contract.json",
2477
+ outputDir: "contracts",
2478
+ },
2479
+ cwd: ".",
2480
+ timeoutMs: 60000,
2481
+ },
2482
+ },
2458
2483
  {
2459
2484
  id: "frontend-prewrite-gate-shell",
2460
2485
  depends_on: [
2486
+ "frontend-contract-json-pi",
2487
+ "frontend-contract-json-validate-shell",
2461
2488
  "frontend-final-design-review-pi",
2462
2489
  "frontend-design-review-pi",
2463
2490
  "frontend-plan-revision-pi",
@@ -2476,8 +2503,8 @@ async function buildFrontendHybridDagFromTask(sources) {
2476
2503
  commands: [],
2477
2504
  frontendPrewriteGate: {
2478
2505
  schemaVersion: 1,
2479
- planFromNodeId: "frontend-plan-revision-pi",
2480
- planFallbackFromNodeIds: ["frontend-plan-pi"],
2506
+ planFromNodeId: "frontend-contract-json-pi",
2507
+ planFallbackFromNodeIds: ["frontend-plan-revision-pi", "frontend-plan-pi"],
2481
2508
  reviewFromNodeId: "frontend-final-design-review-pi",
2482
2509
  reviewFallbackFromNodeIds: ["frontend-design-review-pi"],
2483
2510
  requiredRequirementIds: requirementIds,
@@ -3755,9 +3782,48 @@ async function buildBackendTestHybridDag(sources) {
3755
3782
  assertValidDagSpec(spec);
3756
3783
  return spec;
3757
3784
  }
3758
- // ---------------------------------------------------------------------------
3759
- // Frontend browser-test RAG DAG template
3760
- // ---------------------------------------------------------------------------
3785
+ /**
3786
+ * Resolve the browser origin only from controller-owned task source bytes.
3787
+ * Model-authored RAG/case/evidence files are intentionally excluded.
3788
+ */
3789
+ export function resolveControllerFrontendBaseUrl(sources) {
3790
+ const candidates = (sources.referenceDocuments ?? [])
3791
+ .filter((document) => /(?:^|[\\/])config\.md$/i.test(document.path))
3792
+ .map((document) => ({
3793
+ markdown: document.markdown,
3794
+ source: toDagSourcePath(sources, document.path),
3795
+ }));
3796
+ let rawBaseUrl = "http://localhost:5173";
3797
+ let baseUrlSource = "default-localhost-5173";
3798
+ for (const candidate of candidates) {
3799
+ const match = candidate.markdown.match(/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i);
3800
+ if (!match?.[1]) {
3801
+ if (/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]/i.test(candidate.markdown)) {
3802
+ throw new Error(`frontend-test controller baseUrl is invalid (${candidate.source})`);
3803
+ }
3804
+ continue;
3805
+ }
3806
+ rawBaseUrl = match[1].replace(/[)\]},.;]+$/, "");
3807
+ baseUrlSource = candidate.source;
3808
+ break;
3809
+ }
3810
+ let parsed;
3811
+ try {
3812
+ parsed = new URL(rawBaseUrl);
3813
+ }
3814
+ catch {
3815
+ throw new Error(`frontend-test controller baseUrl is invalid (${baseUrlSource})`);
3816
+ }
3817
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
3818
+ parsed.username ||
3819
+ parsed.password ||
3820
+ parsed.search ||
3821
+ parsed.hash ||
3822
+ /(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
3823
+ throw new Error(`frontend-test controller baseUrl is unsafe (${baseUrlSource})`);
3824
+ }
3825
+ return { baseUrl: parsed.toString(), baseUrlSource };
3826
+ }
3761
3827
  function buildFrontendTestHybridDag(sources) {
3762
3828
  const rawFrontendTest = sources.taskConfig.frontendTest;
3763
3829
  const config = {
@@ -3780,7 +3846,14 @@ function buildFrontendTestHybridDag(sources) {
3780
3846
  throw new Error('frontend-test requires task.json allowedPaths to include "testcase/frontend/**" (or an explicit containing glob).');
3781
3847
  }
3782
3848
  const forbidden = commonForbiddenPaths(sources);
3849
+ const controllerFrontend = resolveControllerFrontendBaseUrl(sources);
3783
3850
  const ragWriteSet = ["testcase/frontend/rag/**"];
3851
+ const caseDraftWriteSet = [
3852
+ "testcase/frontend/cases/FE-*.md",
3853
+ "testcase/frontend/cases/index.md",
3854
+ "testcase/frontend/cases/manifest.draft.json",
3855
+ ];
3856
+ // The shell materializer alone owns the final manifest boundary.
3784
3857
  const casesWriteSet = ["testcase/frontend/cases/**"];
3785
3858
  const evidenceRoot = "testcase/frontend/evidence";
3786
3859
  const declaredAcIdsLiteral = JSON.stringify(declaredAcIds);
@@ -3831,7 +3904,7 @@ function buildFrontendTestHybridDag(sources) {
3831
3904
  // such as \\s/\\d and interprets Markdown backticks before Node sees them.
3832
3905
  const checklistScriptBase64 = Buffer.from(checklistScript, "utf8").toString("base64");
3833
3906
  const checklistValidation = [
3834
- "node -e \"eval(Buffer.from(process.argv[1],'base64').toString('utf8'))\"",
3907
+ "node -e \"require('node:vm').runInThisContext(Buffer.from(process.argv[1],'base64').toString('utf8'))\"",
3835
3908
  checklistScriptBase64,
3836
3909
  ].join(" ");
3837
3910
  const manifestValidation = [
@@ -3890,8 +3963,26 @@ function buildFrontendTestHybridDag(sources) {
3890
3963
  ].join(" ");
3891
3964
  const tasks = [
3892
3965
  {
3893
- id: "retrieve-frontend-test-context-pi",
3966
+ id: "preflight-frontend-browser-tool-shell",
3894
3967
  depends_on: [],
3968
+ role: "verifier",
3969
+ executor: "shell",
3970
+ complexity: "LOW",
3971
+ writePolicy: "read-only",
3972
+ allowedPaths: [],
3973
+ forbiddenPaths: forbidden,
3974
+ 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.",
3975
+ 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).`,
3976
+ shell: {
3977
+ commands: [],
3978
+ frontendBrowserToolPreflight: {},
3979
+ cwd: ".",
3980
+ timeoutMs: 60000,
3981
+ },
3982
+ },
3983
+ {
3984
+ id: "retrieve-frontend-test-context-pi",
3985
+ depends_on: ["preflight-frontend-browser-tool-shell"],
3895
3986
  role: "planner",
3896
3987
  executor: "pi",
3897
3988
  toolProfile: "write",
@@ -3900,12 +3991,12 @@ function buildFrontendTestHybridDag(sources) {
3900
3991
  writeSet: ragWriteSet,
3901
3992
  allowedPaths: [...commonReadOnlyPaths(sources), ...ragWriteSet],
3902
3993
  forbiddenPaths: forbidden,
3903
- outputContract: "Write short testcase/frontend/rag/context.md and coverage-map.md with machine-readable baseUrl, baseUrlSource, environmentProbe=pending, and capability notes.",
3994
+ 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.",
3904
3995
  subtask_prompt: [
3905
3996
  "Build the frontend test RAG package (keep it short).",
3906
3997
  "Read task source, routes/components/API or Mock facts, and execution contract. Write only testcase/frontend/rag/context.md and coverage-map.md.",
3907
3998
  "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.",
3908
- "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>.",
3999
+ `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 --headed ${controllerFrontend.baseUrl}.`,
3909
4000
  buildSourceContextBlock(sources),
3910
4001
  ].join("\n\n"),
3911
4002
  },
@@ -3920,12 +4011,12 @@ function buildFrontendTestHybridDag(sources) {
3920
4011
  allowedPaths: [...ragWriteSet],
3921
4012
  forbiddenPaths: forbidden,
3922
4013
  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).",
3923
- 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.",
4014
+ 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.`,
3924
4015
  shell: {
3925
4016
  commands: [
3926
4017
  [
3927
4018
  "node -e",
3928
- 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(/[)\\}\\],.\\\"'\\x60]+$/,'');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);"),
4019
+ 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);`),
3929
4020
  ].join(" "),
3930
4021
  ],
3931
4022
  cwd: ".",
@@ -3940,13 +4031,13 @@ function buildFrontendTestHybridDag(sources) {
3940
4031
  toolProfile: "write",
3941
4032
  complexity: "HIGH",
3942
4033
  writePolicy: "exclusive",
3943
- writeSet: casesWriteSet,
3944
- allowedPaths: [...ragWriteSet, ...casesWriteSet],
4034
+ writeSet: caseDraftWriteSet,
4035
+ allowedPaths: [...ragWriteSet, ...caseDraftWriteSet],
3945
4036
  forbiddenPaths: forbidden,
3946
- outputContract: "Write executable Markdown frontend cases, index.md, and manifest.draft.json schemaVersion 1; no test source code.",
4037
+ outputContract: "Write executable Markdown frontend cases, index.md, and manifest.draft.json schemaVersion 1 only; the exclusive shell materializer promotes the validated draft to manifest.json. Do not write manifest.json or test source code.",
3947
4038
  subtask_prompt: [
3948
4039
  "Use skill playwright-cli-case-generator.",
3949
- "Read only testcase/frontend/rag/context.md, testcase/frontend/rag/coverage-map.md, and existing testcase/frontend/cases/. Write only testcase/frontend/cases/**.",
4040
+ "Read only testcase/frontend/rag/context.md, testcase/frontend/rag/coverage-map.md, and the draft case paths testcase/frontend/cases/FE-*.md, testcase/frontend/cases/index.md, and testcase/frontend/cases/manifest.draft.json. Write only those same draft paths. Do not write testcase/frontend/cases/manifest.json.",
3950
4041
  "Generate Markdown cases, index.md and manifest.draft.json (schemaVersion 1; cases[] with caseId, casePath, dimension, acIds, evidenceDir).",
3951
4042
  "HARD ID CONTRACT (do not confuse these):",
3952
4043
  "- caseId / filename MUST be FE-<FEATURE>-<NNN>-<dimension> (example FE-LOGIN-001-core). NEVER use AC-FE-* as caseId or filename.",
@@ -3956,7 +4047,9 @@ function buildFrontendTestHybridDag(sources) {
3956
4047
  "dimensions: core|boundary|flow|backend only.",
3957
4048
  "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.",
3958
4049
  "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.",
3959
- "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>.",
4050
+ `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 --headed ${controllerFrontend.baseUrl}. Never leave a base-url placeholder. Use default browser session only; never write -s=<case-id>.`,
4051
+ "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.",
4052
+ "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.",
3960
4053
  "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.",
3961
4054
  "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.",
3962
4055
  ].join("\n\n"),
@@ -3983,11 +4076,11 @@ function buildFrontendTestHybridDag(sources) {
3983
4076
  toolProfile: "write",
3984
4077
  complexity: "HIGH",
3985
4078
  writePolicy: "exclusive",
3986
- writeSet: casesWriteSet,
3987
- allowedPaths: [...ragWriteSet, ...casesWriteSet],
4079
+ writeSet: caseDraftWriteSet,
4080
+ allowedPaths: [...ragWriteSet, ...caseDraftWriteSet],
3988
4081
  forbiddenPaths: forbidden,
3989
- outputContract: "Apply the one permitted frontend case revision under testcase/frontend/cases/** only; no browser execution or evidence writes.",
3990
- subtask_prompt: "This is the only permitted case revision. Read the first review findings and the RAG package. Revise only testcase/frontend/cases/**, preserve traceable AC mappings, and do not execute a browser or write evidence. HARD: Never delete case files; only edit in place or add missing cases. Preserve the full planned suite, index.md, and manifest.draft.json.",
4082
+ outputContract: "Apply the one permitted frontend case revision to FE-*.md, index.md, and manifest.draft.json only; the exclusive shell materializer remains the sole writer of manifest.json. No browser execution or evidence writes.",
4083
+ subtask_prompt: "This is the only permitted case revision. Read the first review findings and the RAG package. Revise only testcase/frontend/cases/FE-*.md, testcase/frontend/cases/index.md, and testcase/frontend/cases/manifest.draft.json; preserve traceable AC mappings. Preserve the dynamic-ref contract: executable playwright-cli lines use only eX/eY-style shell-safe documentation placeholders, never <...>; each placeholder is resolved from the immediately preceding latest snapshot and stale refs are not reused. Do not write testcase/frontend/cases/manifest.json, execute a browser, or write evidence. HARD: Never delete case files; only edit in place or add missing cases. Preserve the full planned suite, index.md, and manifest.draft.json.",
3991
4084
  }, {
3992
4085
  id: "review-frontend-cases-final-pi",
3993
4086
  depends_on: ["revise-frontend-cases-pi"],
@@ -4041,8 +4134,8 @@ function buildFrontendTestHybridDag(sources) {
4041
4134
  writePolicy: "read-only",
4042
4135
  allowedPaths: [...ragWriteSet, ...casesWriteSet],
4043
4136
  forbiddenPaths: forbidden,
4044
- outputContract: "Mechanical checklist: manifest/case paths, Case ID, non-production playwright-cli open prefix, and AC mapping; alternative executable tool commands are not inspected or blocked.",
4045
- subtask_prompt: "Scan generated cases/manifest for structural and safety rules only. Strongly recommend playwright-cli for browser execution, but do not inspect or reject alternative executable tool commands and do not use free-form LLM verdicts.",
4137
+ outputContract: "Mechanical checklist: manifest/case paths, Case ID, non-production playwright-cli open prefix, AC mapping, and executable command gate; rejects alternative executable instructions and non-allowlisted playwright-cli commands with ruleId-tagged location evidence.",
4138
+ subtask_prompt: "Inspect and reject alternative executable instructions in fenced command code, list/indented steps, and explicit shell/terminal command lines. Only allowlisted playwright-cli command instructions may pass; ordinary prose and explicit blocked reasons may describe prohibitions. Run the native deterministic checklist without spawning Bash, PowerShell, or node -e and do not use free-form LLM verdicts.",
4046
4139
  shell: {
4047
4140
  commands: [],
4048
4141
  frontendTestCaseChecklist: {},
@@ -4093,6 +4186,10 @@ function buildFrontendTestHybridDag(sources) {
4093
4186
  role: "implementer",
4094
4187
  skills: ["playwright-cli"],
4095
4188
  toolProfile: "write",
4189
+ commandPolicy: {
4190
+ mode: "capability-allowlist",
4191
+ capabilities: ["playwright-cli"],
4192
+ },
4096
4193
  complexity: "MED",
4097
4194
  writePolicy: "exclusive",
4098
4195
  allowedPaths: [
@@ -4103,12 +4200,13 @@ function buildFrontendTestHybridDag(sources) {
4103
4200
  ],
4104
4201
  forbiddenPaths: forbidden,
4105
4202
  writeSet: [`${evidenceRoot}/{{case.caseId}}/**`],
4106
- outputContract: "Compact JSON <=1200 characters with case status, evidence paths, error summary, and tokens.",
4203
+ outputContract: "Compact JSON <=1200 characters with case status, evidence paths, error summary, and tokens. Browser actions must use structured playwright_cli tool. Passed authority requires same-child ordered controller receipts: successful open → successful find → successful post-execution cleanup.",
4107
4204
  subtaskPromptTemplate: [
4108
4205
  "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.",
4109
- "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.",
4206
+ "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?}).",
4207
+ `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, --headed, ${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.`,
4110
4208
  "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).",
4111
- "Business failed/blocked is a recorded result, not a node failure. Close browser. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
4209
+ "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}.",
4112
4210
  ].join("\n\n"),
4113
4211
  },
4114
4212
  },
@@ -4236,7 +4334,10 @@ function buildFrontendTestHybridDag(sources) {
4236
4334
  "frontend-test-dag generates Markdown cases and browser evidence only; it must not generate pytest or Playwright test source code.",
4237
4335
  "Each browser case runs serially in a fresh Pi execution boundary. Persist its evidence before starting the next case.",
4238
4336
  "Use only the declared isolated test environment. Production URLs, real credentials, and unauthorized data are blocked.",
4239
- "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.",
4337
+ `Browser startup for generated cases must be playwright-cli open --browser=chrome --headed ${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.`,
4338
+ "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.",
4339
+ "Browser case children use commandPolicy capability-allowlist playwright-cli and structured playwright_cli tool; ordinary writers remain without Bash.",
4340
+ "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).",
4240
4341
  "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.",
4241
4342
  "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.",
4242
4343
  "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.",
@@ -5287,7 +5388,7 @@ async function buildHybridDagForTemplate(sources, template) {
5287
5388
  else if (template === "review-gated-dag")
5288
5389
  spec = buildReviewGatedHybridDag(standard, sources);
5289
5390
  else
5290
- spec = buildSupervisedHybridDag(standard, sources);
5391
+ spec = await buildSupervisedHybridDag(standard, sources);
5291
5392
  }
5292
5393
  applyProjectGovernanceReview(spec, template, sources);
5293
5394
  // New generate path always emits DagSpec v4 + bindings.
@@ -5473,8 +5574,18 @@ function buildReviewGateNode(sources) {
5473
5574
  }
5474
5575
  function enableProjectGovernanceOnNode(task) {
5475
5576
  task.governanceStandardReview = true;
5476
- task.outputProtocol = REVIEW_VERDICT_OUTPUT_PROTOCOL;
5477
5577
  task.retryPolicy = PROTOCOL_AWARE_PI_RETRY_POLICY;
5578
+ if (task.outputProtocol?.type === "json-review-verdict") {
5579
+ const governanceInstruction = 'mandatory governance violations must use verdict "request-revision" with at least one finding; never emit verdict "pass" when any mandatory governance violation or Critical/Important finding remains.';
5580
+ if (!(task.outputContract ?? "").includes("mandatory governance violations")) {
5581
+ task.outputContract = `${task.outputContract ?? ""} Unresolved ${governanceInstruction} No file writes.`;
5582
+ }
5583
+ if (!task.subtask_prompt.includes(governanceInstruction)) {
5584
+ task.subtask_prompt = `${task.subtask_prompt}\n\n${governanceInstruction}`;
5585
+ }
5586
+ return;
5587
+ }
5588
+ task.outputProtocol = REVIEW_VERDICT_OUTPUT_PROTOCOL;
5478
5589
  task.outputContract =
5479
5590
  "Plain Markdown whose first non-empty line is exactly VERDICT: pass or VERDICT: request-revision; unresolved mandatory governance violations force request-revision. No file writes.";
5480
5591
  const protocolInstruction = "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.";
@@ -5721,10 +5832,12 @@ function buildWriteSetGateNode(sources) {
5721
5832
  },
5722
5833
  };
5723
5834
  }
5724
- function buildSoftVerifyNode(sources) {
5835
+ async function buildSoftVerifyNode(sources) {
5725
5836
  const implementId = implementationNodeId();
5726
5837
  const strategy = resolveDagVerifyStrategy(sources.taskConfig, "1");
5727
- const fallbackCommands = ["npm run typecheck"];
5838
+ const fallbackCommands = sources.repoRoot
5839
+ ? (await discoverFrontendFallbackVerifyCommands(sources.repoRoot)).staticCommands
5840
+ : ["npm run typecheck"];
5728
5841
  const focusedIntermediate = sources.verifyCommands?.intermediate.filter((command) => !isFullSuiteVerifyCommand(command));
5729
5842
  const plannedIntermediate = applyMavenVerificationPlanning({
5730
5843
  repoRoot: sources.repoRoot,
@@ -5966,7 +6079,7 @@ function resolveSupervisedConvergence(taskConfig) {
5966
6079
  chainNodeIds: [...SUPERVISED_CONVERGENCE_CHAIN_NODE_IDS],
5967
6080
  };
5968
6081
  }
5969
- function buildSupervisedHybridDag(standard, sources) {
6082
+ async function buildSupervisedHybridDag(standard, sources) {
5970
6083
  const contract = getTaskOrThrow(standard, "contract-pi");
5971
6084
  const scoutSrc = getTaskOrThrow(standard, "scout-src");
5972
6085
  const scoutTests = getTaskOrThrow(standard, "scout-tests");
@@ -6018,7 +6131,7 @@ function buildSupervisedHybridDag(standard, sources) {
6018
6131
  "final-write-set-audit-format-repair-pi",
6019
6132
  ],
6020
6133
  }),
6021
- buildSoftVerifyNode(sources),
6134
+ await buildSoftVerifyNode(sources),
6022
6135
  buildProcessSupervisorNode(sources),
6023
6136
  buildProcessGateNode(sources),
6024
6137
  buildRepairNode(sources),
@@ -5,13 +5,31 @@ import { writeJsonAtomic, } from "../../infrastructure/harness/atomic-write.js";
5
5
  import { parseDagSpec } from "./types.js";
6
6
  import { normalizeDagFailureCategory, } from "./failure-category.js";
7
7
  import { dagProductLineFailureCategoryValues, routeDagFailure, } from "./failure-routing.js";
8
- const DAG_LIFECYCLE_SCAN_ORDER = [
8
+ /** Resume/operator scan order for real execution lifecycles. */
9
+ const DAG_EXECUTION_LIFECYCLE_SCAN_ORDER = [
9
10
  "paused",
10
11
  "active",
11
12
  "completed",
12
13
  ];
14
+ /** Full disk scan including non-execution audit lifecycles (R-A2). */
15
+ const DAG_LIFECYCLE_SCAN_ORDER = [
16
+ ...DAG_EXECUTION_LIFECYCLE_SCAN_ORDER,
17
+ "dry-run",
18
+ "init-only",
19
+ ];
13
20
  const PAUSED_APPROVAL_FLOW_HINT = "Use dag approve / dag reject, then dag resume when approved.";
14
21
  export const DAG_RUNS_DIR = path.join(".harness", "dag-runs");
22
+ /** Non-execution audit lifecycles (never resume targets). */
23
+ export const DAG_NON_EXECUTION_LIFECYCLES = ["dry-run", "init-only"];
24
+ export function isDagNonExecutionLifecycle(lifecycle) {
25
+ return (lifecycle === "dry-run" ||
26
+ lifecycle === "init-only");
27
+ }
28
+ export function isDagExecutionLifecycle(lifecycle) {
29
+ return (lifecycle === "active" ||
30
+ lifecycle === "completed" ||
31
+ lifecycle === "paused");
32
+ }
15
33
  export function getDagRunDir(cwd, lifecycle, runId) {
16
34
  return path.join(cwd, DAG_RUNS_DIR, lifecycle, runId);
17
35
  }
@@ -36,7 +54,8 @@ export async function readDagRunSpec(runDir) {
36
54
  return parseDagSpec(raw);
37
55
  }
38
56
  export async function locateDagRun(cwd, runId) {
39
- for (const lifecycle of ["paused", "active", "completed"]) {
57
+ // Prefer real execution lifecycles for resume/status; audit dirs last.
58
+ for (const lifecycle of DAG_LIFECYCLE_SCAN_ORDER) {
40
59
  const runDir = getDagRunDir(cwd, lifecycle, runId);
41
60
  if (await dagRunDirExists(runDir)) {
42
61
  return { lifecycle, runDir };
@@ -233,6 +252,10 @@ export function deriveDagRunEffectiveStatus(input) {
233
252
  if (input.lifecycle === "completed") {
234
253
  return mapTerminalDagRunEffectiveStatus(input.state.status);
235
254
  }
255
+ // dry-run / init-only are audit shells; surface pending without pretending execute.
256
+ if (isDagNonExecutionLifecycle(input.lifecycle)) {
257
+ return input.state.status === "pending" ? "pending" : "unknown";
258
+ }
236
259
  if (input.state.status === "pending")
237
260
  return "pending";
238
261
  if (isTerminalDagRunStatus(input.state.status)) {
@@ -254,6 +277,14 @@ export function deriveDagRunEffectiveStatus(input) {
254
277
  }
255
278
  export function assessDagRunRecoveryEligibility(input) {
256
279
  const reasons = [];
280
+ if (isDagNonExecutionLifecycle(input.lifecycle)) {
281
+ return {
282
+ canResume: false,
283
+ canReconcile: false,
284
+ allowedActions: [],
285
+ reasons: ["non-execution-lifecycle"],
286
+ };
287
+ }
257
288
  const canResume = input.lifecycle === "active" &&
258
289
  input.state.status === "running" &&
259
290
  Boolean(input.state.humanDecisionNodeId) &&
@@ -9,7 +9,7 @@ import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./p
9
9
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
10
10
  import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
11
11
  import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
12
- import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, validateOutputProtocol, } from "./output-protocol.js";
12
+ import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
13
13
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
14
14
  import { buildProjectGovernanceContext, readCompletedWriterChangeManifests, writeProjectGovernanceContext, } from "./project-governance-context.js";
15
15
  import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
@@ -83,8 +83,15 @@ export async function buildNodePromptWithResolvedSkillInstructions(spec, task, u
83
83
  resolvedSkills: skillInstructionMetadata(resolvedSkillInstructions),
84
84
  };
85
85
  }
86
+ export function canonicalNodeOutput(node) {
87
+ const assistantText = node?.assistantText ?? "";
88
+ return assistantText.trim().length > 0 ? assistantText : (node?.stdout ?? "");
89
+ }
86
90
  export function parseProcessVerdict(node) {
87
- const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
91
+ const text = canonicalNodeOutput(node);
92
+ const jsonVerdict = parseJsonReviewVerdict(text);
93
+ if (jsonVerdict.ok)
94
+ return jsonVerdict.verdict;
88
95
  for (const line of text.split("\n")) {
89
96
  const trimmed = line.trim();
90
97
  if (trimmed === "VERDICT: pass")
@@ -138,8 +145,7 @@ function assertRepairArtifactVerdictMatchesSupervisor(input) {
138
145
  }
139
146
  }
140
147
  function parseSupervisorRepairArtifact(node) {
141
- const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
142
- return parseRepairArtifactFromText(text);
148
+ return parseRepairArtifactFromText(canonicalNodeOutput(node));
143
149
  }
144
150
  function validateRepairArtifactGateBeforeShell(input) {
145
151
  const gate = input.task.shell?.repairArtifactGate;
@@ -450,7 +456,7 @@ export async function executeDagNode(input) {
450
456
  // R0: executor ok=true still fails closed when outputProtocol is violated.
451
457
  // Valid semantic results (e.g. VERDICT: request-revision) pass validation.
452
458
  if (result.ok && task.outputProtocol) {
453
- const protocolText = `${result.assistantText ?? ""}\n${result.stdout ?? ""}`;
459
+ const protocolText = canonicalNodeOutput(result);
454
460
  const protocolCheck = validateOutputProtocol(task.outputProtocol, protocolText);
455
461
  if (!protocolCheck.ok) {
456
462
  result = {