@tea-agent/loop-agent 0.20.0 → 0.20.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 (62) hide show
  1. package/CHANGELOG.md +31 -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/executors/shell-executor.js +1 -0
  9. package/dist/shared/operator/capabilities.js +54 -0
  10. package/dist/worker/console/index.js +1 -1
  11. package/dist/worker/console/inspect-split.js +82 -0
  12. package/dist/worker/console/operation-runner.js +3 -1
  13. package/dist/worker/console/operation-store.js +1 -0
  14. package/dist/worker/console/operator-actions.js +153 -2
  15. package/dist/worker/console/operator-user-error.js +10 -0
  16. package/dist/worker/console/pi-readiness.js +4 -0
  17. package/dist/worker/console/recovery-cta.js +116 -5
  18. package/dist/worker/console/recovery-selection.js +107 -0
  19. package/dist/worker/console/resolve-dag-run-for-task.js +50 -11
  20. package/dist/worker/console/routes.js +20 -0
  21. package/dist/worker/console/sibling-controller.js +12 -7
  22. package/dist/worker/console/static/assets/index-CUDke82y.js +18 -0
  23. package/dist/worker/console/static/assets/index-wSEksVSO.css +1 -0
  24. package/dist/worker/console/static/index.html +2 -2
  25. package/dist/worker/observability/read-model.js +60 -0
  26. package/dist/worker/observe/spec-evidence.js +33 -0
  27. package/dist/worker/observe/static/index.html +1 -1
  28. package/dist/worker/observe/static/views/dag-inspector.js +67 -4
  29. package/dist/worker/run-task/run-task.js +7 -0
  30. package/dist/workflows/dag/frontend-prewrite-gate.js +97 -2
  31. package/dist/workflows/dag/frontend-project-capability.js +6 -2
  32. package/dist/workflows/dag/frontend-test-result-contract.js +64 -0
  33. package/dist/workflows/dag/init-hybrid.js +91 -48
  34. package/dist/workflows/dag/node-execution.js +40 -6
  35. package/dist/workflows/dag/output-protocol.js +76 -0
  36. package/dist/workflows/dag/rerun-plan.js +611 -0
  37. package/dist/workflows/dag/rerun-run.js +497 -0
  38. package/dist/workflows/dag/rerun-task.js +284 -0
  39. package/dist/workflows/dag/retry-policy.js +20 -1
  40. package/dist/workflows/dag/runner.js +50 -0
  41. package/dist/workflows/dag/skill-snapshot.js +22 -3
  42. package/dist/workflows/dag/types.js +10 -0
  43. package/dist/workflows/dag/validate.js +11 -0
  44. package/dist/workflows/dag/workspace-checkpoint.js +163 -0
  45. package/docs/README.md +1 -0
  46. package/docs/templates/agent-dag.schema.json +17 -2
  47. package/docs/templates/frontend-test-case-checklist.md +16 -1
  48. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +26 -2
  49. package/docs/templates/frontend-test-dag.json +65 -6
  50. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +4 -1
  51. package/package.json +1 -1
  52. package/skills/frontend-design-review/SKILL.md +5 -3
  53. package/skills/frontend-design-review/references/review-checklist.md +3 -2
  54. package/skills/frontend-implementation/references/design-spec.md +16 -8
  55. package/skills/frontend-review/SKILL.md +7 -1
  56. package/skills/frontend-review/references/review-findings.md +5 -1
  57. package/skills/frontend-verification/SKILL.md +5 -3
  58. package/skills/frontend-verification/references/verification-checklist.md +3 -2
  59. package/skills/loop-agent/references/command-reference.md +3 -0
  60. package/skills/playwright-cli-case-generator/SKILL.md +35 -7
  61. package/dist/worker/console/static/assets/index-3vsjZJHq.js +0 -16
  62. package/dist/worker/console/static/assets/index-i1wV4LrY.css +0 -1
@@ -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";
@@ -2041,6 +2042,7 @@ function buildFrontendHybridDagFromTask(sources) {
2041
2042
  : ["native", "browser-intercept", "request-adapter", "not-needed"],
2042
2043
  artifactName: "frontend-implementation-contract.json",
2043
2044
  outputDir: "contracts",
2045
+ openspecCandidatePaths: sources.frontendProjectCapability?.designEvidence.normativePaths ?? [],
2044
2046
  },
2045
2047
  cwd: ".",
2046
2048
  timeoutMs: 60000,
@@ -3138,12 +3140,15 @@ async function buildBackendTestHybridDag(sources) {
3138
3140
  function buildFrontendTestHybridDag(sources) {
3139
3141
  const rawFrontendTest = sources.taskConfig.frontendTest;
3140
3142
  const config = {
3141
- maxCasesPerBatch: rawFrontendTest?.maxCasesPerBatch ?? 20,
3143
+ // Default 32: common FE suites cover ~24 AC with multi-dimension cases; 20 caused map maxExpandedNodes failures.
3144
+ maxCasesPerBatch: rawFrontendTest?.maxCasesPerBatch ?? 32,
3142
3145
  maxTokensPerCase: rawFrontendTest?.maxTokensPerCase,
3143
3146
  maxTotalTokens: rawFrontendTest?.maxTotalTokens,
3144
3147
  reviewMode: rawFrontendTest?.reviewMode ?? "off",
3145
3148
  strictOutcomeGate: rawFrontendTest?.strictOutcomeGate === true,
3146
3149
  };
3150
+ const declaredRequirementIds = buildDagSourceBinding(sources).requirementIds;
3151
+ const declaredAcIds = declaredRequirementIds.filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
3147
3152
  const reviewMode = config.reviewMode;
3148
3153
  const blockingReview = reviewMode === "blocking";
3149
3154
  const strictOutcomeGate = config.strictOutcomeGate;
@@ -3157,6 +3162,8 @@ function buildFrontendTestHybridDag(sources) {
3157
3162
  const ragWriteSet = ["testcase/frontend/rag/**"];
3158
3163
  const casesWriteSet = ["testcase/frontend/cases/**"];
3159
3164
  const evidenceRoot = "testcase/frontend/evidence";
3165
+ const declaredAcIdsLiteral = JSON.stringify(declaredAcIds);
3166
+ const maxCasesPerBatchLiteral = String(config.maxCasesPerBatch);
3160
3167
  const checklistValidation = [
3161
3168
  "node -e",
3162
3169
  JSON.stringify([
@@ -3168,20 +3175,36 @@ function buildFrontendTestHybridDag(sources) {
3168
3175
  "if(!manifestPath)throw new Error('checklist: missing manifest.draft.json or manifest.json');",
3169
3176
  "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
3170
3177
  "if(!Array.isArray(manifest.cases)||manifest.cases.length===0)throw new Error('checklist: empty cases');",
3178
+ `const declaredAc=new Set(${declaredAcIdsLiteral});`,
3171
3179
  "const issues=[];",
3172
3180
  "const openRe=/playwright-cli\\s+open\\s+--browser=chrome\\s+--headed\\s+https?:\\/\\/\\S+/i;",
3173
3181
  "const prodRe=/(?:^|\\/\\/)(?:www\\.)?[^\\s\\/]*(?:prod|production)/i;",
3174
3182
  "const codeRe=/\\b(pytest|playwright\\.test|@playwright\\/test)\\b/i;",
3183
+ "const barePwRe=/(?:^|[\\s\"'(])(?:npx\\s+playwright\\b|playwright\\s+test\\b|from\\s+['\"]@playwright\\/|require\\(['\"]@playwright\\/|import\\s+.*@playwright\\/|(?<![\\w-])playwright(?!-cli)\\b)/i;",
3184
+ "const caseIdRe=/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;",
3185
+ "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
3175
3186
  "for(const c of manifest.cases){",
3176
3187
  " const id=c&&c.caseId||'?';",
3188
+ " 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-*'});",
3189
+ " 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'});",
3177
3190
  " const casePath=typeof c.casePath==='string'?c.casePath:null;",
3178
3191
  " if(!casePath||!fs.existsSync(casePath)){issues.push({ruleId:'case-file-missing',caseId:id,detail:String(casePath)});continue;}",
3192
+ " 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'});",
3179
3193
  " const body=fs.readFileSync(casePath,'utf8');",
3180
3194
  " if(!openRe.test(body))issues.push({ruleId:'open-prefix',caseId:id,detail:'missing playwright-cli open --browser=chrome --headed <absolute-url>'});",
3181
3195
  " const m=body.match(/playwright-cli\\s+open\\s+--browser=chrome\\s+--headed\\s+(https?:\\/\\/\\S+)/i);",
3182
3196
  " if(m){const url=m[1].replace(/[)\\]},.\"']+$/,''); if(prodRe.test(url))issues.push({ruleId:'production-url',caseId:id,detail:url});}",
3183
3197
  " if(codeRe.test(body))issues.push({ruleId:'no-test-source',caseId:id,detail:'pytest/playwright test source forbidden'});",
3184
- " if(!Array.isArray(c.acIds)||c.acIds.length===0)issues.push({ruleId:'ac-mapping',caseId:id,detail:'acIds required'});",
3198
+ " 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'});",
3199
+ " 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)'});",
3200
+ " else {",
3201
+ " for(const ac of c.acIds){",
3202
+ " if(typeof ac!=='string'){issues.push({ruleId:'ac-id-shape',caseId:id,detail:String(ac)+' must look like AC-FE-001'});continue;}",
3203
+ " if(/^FE-/i.test(ac)){issues.push({ruleId:'ac-id-is-case',caseId:id,detail:ac+' looks like caseId; acIds must be AC-*'});continue;}",
3204
+ " if(!acIdRe.test(ac)){issues.push({ruleId:'ac-id-shape',caseId:id,detail:ac+' must look like AC-FE-001'});continue;}",
3205
+ " if(declaredAc.size>0&&!declaredAc.has(ac))issues.push({ruleId:'unknown-ac',caseId:id,detail:ac+' not in task sourceBinding.requirementIds'});",
3206
+ " }",
3207
+ " }",
3185
3208
  "}",
3186
3209
  "if(issues.length){console.error('frontend-test checklist blocked: '+JSON.stringify(issues)); process.exit(1);}",
3187
3210
  "console.log('frontend-test checklist ok cases='+manifest.cases.length+' source='+path.basename(manifestPath));",
@@ -3191,37 +3214,40 @@ function buildFrontendTestHybridDag(sources) {
3191
3214
  "node -e",
3192
3215
  JSON.stringify([
3193
3216
  "const fs=require('fs'),path=require('path');",
3194
- "const draft='testcase/frontend/cases/manifest.draft.json',file='testcase/frontend/cases/manifest.json'; if(!fs.existsSync(draft)) throw new Error('missing '+draft);",
3195
- "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');",
3217
+ "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):'')); }",
3218
+ "const draft='testcase/frontend/cases/manifest.draft.json',file='testcase/frontend/cases/manifest.json',tmp=file+'.tmp';",
3219
+ "if(!fs.existsSync(draft)) fail('draft-missing','missing '+draft);",
3220
+ "let manifest; try{manifest=JSON.parse(fs.readFileSync(draft,'utf8'));}catch(e){fail('draft-invalid-json',e&&e.message||e);}",
3221
+ "if(manifest.schemaVersion!==1) fail('draft-schema','schemaVersion must be 1');",
3222
+ "if(!Array.isArray(manifest.cases)||manifest.cases.length===0) fail('draft-empty-cases','cases must be a non-empty array');",
3223
+ `const maxCases=${maxCasesPerBatchLiteral};`,
3224
+ "if(manifest.cases.length>maxCases) fail('map-capacity-exceeded','cases='+manifest.cases.length+' exceeds maxCasesPerBatch/maxExpandedNodes='+maxCases+'; raise frontendTest.maxCasesPerBatch or shrink the suite');",
3196
3225
  "const dims=new Set(['core','boundary','flow','backend']);",
3226
+ `const declaredAc=new Set(${declaredAcIdsLiteral});`,
3197
3227
  "const seen=new Set(); const seenCasePath=new Set(); const seenEvidenceDir=new Set();",
3228
+ "const acIdRe=/^AC(?:-[A-Z0-9]+)+$/i;",
3198
3229
  "for(const c of manifest.cases){",
3199
- " 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');",
3230
+ " 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));",
3231
+ " if(/^AC-/i.test(c.caseId)) fail('case-id-is-ac','caseId must not be an acceptance id: '+c.caseId);",
3232
+ " if(seen.has(c.caseId)) fail('duplicate-case-id',c.caseId);",
3200
3233
  " seen.add(c.caseId);",
3201
- " if(typeof c.dimension!=='string'||!dims.has(c.dimension)) throw new Error('invalid dimension');",
3202
- " if(!Array.isArray(c.acIds)||c.acIds.length===0||c.acIds.some(a=>typeof a!=='string'||!a.trim())) throw new Error('invalid acIds');",
3203
- " for(const k of ['casePath','evidenceDir']){ const v=c[k]; if(typeof v!=='string'||path.isAbsolute(v)||v.includes('..')) throw new Error('unsafe '+k); }",
3204
- " if(c.casePath!=='testcase/frontend/cases/'+c.caseId+'.md') throw new Error('casePath must match caseId');",
3205
- " { const prefix='testcase/frontend/evidence/'+c.caseId; if(!(c.evidenceDir===prefix||c.evidenceDir.startsWith(prefix+'/'))) throw new Error('case path escapes frontend test roots'); }",
3206
- " if(!fs.existsSync(c.casePath)) throw new Error('missing case file '+c.casePath);",
3207
- " if(seenCasePath.has(c.casePath)) throw new Error('duplicate casePath'); seenCasePath.add(c.casePath);",
3208
- " if(seenEvidenceDir.has(c.evidenceDir)) throw new Error('duplicate evidenceDir'); seenEvidenceDir.add(c.evidenceDir);",
3234
+ " if(typeof c.dimension!=='string'||!dims.has(c.dimension)) fail('invalid-dimension',String(c.dimension));",
3235
+ " 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);",
3236
+ " 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'); }",
3237
+ " c.casePath='testcase/frontend/cases/'+c.caseId+'.md';",
3238
+ " c.evidenceDir='testcase/frontend/evidence/'+c.caseId+'/';",
3239
+ " 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)); }",
3240
+ " if(!fs.existsSync(c.casePath)) fail('case-file-missing','missing case file '+c.casePath+' (filename must equal caseId.md)');",
3241
+ " if(seenCasePath.has(c.casePath)) fail('duplicate-case-path',c.casePath); seenCasePath.add(c.casePath);",
3242
+ " if(seenEvidenceDir.has(c.evidenceDir)) fail('duplicate-evidence-dir',c.evidenceDir); seenEvidenceDir.add(c.evidenceDir);",
3209
3243
  "}",
3210
- "fs.writeFileSync(file,JSON.stringify(manifest,null,2)+'\\n'); fs.unlinkSync(draft);",
3244
+ "const payload=JSON.stringify(manifest,null,2)+'\\n';",
3245
+ "fs.writeFileSync(tmp,payload); fs.renameSync(tmp,file); try{fs.unlinkSync(draft);}catch(_){}",
3211
3246
  "process.stdout.write(JSON.stringify({cases:manifest.cases}));",
3212
3247
  ].join("")),
3213
3248
  ].join(" ");
3214
- const evidenceValidation = [
3215
- "node -e",
3216
- JSON.stringify([
3217
- "const fs=require('fs'),path=require('path');",
3218
- "const manifestPath='testcase/frontend/cases/manifest.json';if(!fs.existsSync(manifestPath))throw new Error('missing '+manifestPath);",
3219
- "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));if(!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');",
3220
- "const statuses=new Set(['passed','failed','blocked']);let failed=false;",
3221
- "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;}}",
3222
- "if(failed)process.exit(1);console.log('frontend case evidence validation ok cases='+manifest.cases.length);",
3223
- ].join("")),
3224
- ].join(" ");
3249
+ // Heal malformed/missing case evidence to blocked; only unsafe evidenceDir hard-fails.
3250
+ const evidenceValidation = buildFrontendCaseEvidenceValidateShellSnippet();
3225
3251
  const frontendTestOutcomeGate = buildFrontendTestOutcomeGateShellSnippet();
3226
3252
  const tasks = [
3227
3253
  {
@@ -3235,12 +3261,12 @@ function buildFrontendTestHybridDag(sources) {
3235
3261
  writeSet: ragWriteSet,
3236
3262
  allowedPaths: [...commonReadOnlyPaths(sources), ...ragWriteSet],
3237
3263
  forbiddenPaths: forbidden,
3238
- outputContract: "Write short testcase/frontend/rag/context.md and coverage-map.md with machine-readable baseUrl and capability notes.",
3264
+ outputContract: "Write short testcase/frontend/rag/context.md and coverage-map.md with machine-readable baseUrl, baseUrlSource, environmentProbe=pending, and capability notes.",
3239
3265
  subtask_prompt: [
3240
3266
  "Build the frontend test RAG package (keep it short).",
3241
3267
  "Read task source, routes/components/API or Mock facts, and execution contract. Write only testcase/frontend/rag/context.md and coverage-map.md.",
3242
- "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.",
3243
- "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>.",
3268
+ "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.",
3269
+ "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>.",
3244
3270
  buildSourceContextBlock(sources),
3245
3271
  ].join("\n\n"),
3246
3272
  },
@@ -3250,16 +3276,17 @@ function buildFrontendTestHybridDag(sources) {
3250
3276
  role: "verifier",
3251
3277
  executor: "shell",
3252
3278
  complexity: "LOW",
3253
- writePolicy: "read-only",
3279
+ writePolicy: "exclusive",
3280
+ writeSet: ragWriteSet,
3254
3281
  allowedPaths: [...ragWriteSet],
3255
3282
  forbiddenPaths: forbidden,
3256
- outputContract: "Fail-closed preflight: absolute non-production baseUrl required; fixture/reset not hard-gated.",
3257
- 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.",
3283
+ 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).",
3284
+ 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.",
3258
3285
  shell: {
3259
3286
  commands: [
3260
3287
  [
3261
3288
  "node -e",
3262
- 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);"),
3289
+ 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);"),
3263
3290
  ].join(" "),
3264
3291
  ],
3265
3292
  cwd: ".",
@@ -3281,10 +3308,18 @@ function buildFrontendTestHybridDag(sources) {
3281
3308
  subtask_prompt: [
3282
3309
  "Use skill playwright-cli-case-generator.",
3283
3310
  "Read only testcase/frontend/rag/context.md, testcase/frontend/rag/coverage-map.md, and existing testcase/frontend/cases/. Write only testcase/frontend/cases/**.",
3284
- "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.",
3285
- "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.",
3286
- "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>.",
3311
+ "Generate Markdown cases, index.md and manifest.draft.json (schemaVersion 1; cases[] with caseId, casePath, dimension, acIds, evidenceDir).",
3312
+ "HARD ID CONTRACT (do not confuse these):",
3313
+ "- caseId / filename MUST be FE-<FEATURE>-<NNN>-<dimension> (example FE-LOGIN-001-core). NEVER use AC-FE-* as caseId or filename.",
3314
+ "- 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.",
3315
+ "- 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.",
3316
+ `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)"}.`,
3317
+ "dimensions: core|boundary|flow|backend only.",
3318
+ "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.",
3319
+ "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.",
3320
+ "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>.",
3287
3321
  "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.",
3322
+ "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.",
3288
3323
  ].join("\n\n"),
3289
3324
  },
3290
3325
  ];
@@ -3367,7 +3402,7 @@ function buildFrontendTestHybridDag(sources) {
3367
3402
  writePolicy: "read-only",
3368
3403
  allowedPaths: [...ragWriteSet, ...casesWriteSet],
3369
3404
  forbiddenPaths: forbidden,
3370
- outputContract: "Mechanical checklist: open-prefix, non-prod absolute URL, acIds, no pytest/playwright test source; emit structured ruleId issues on failure.",
3405
+ outputContract: "Mechanical checklist: open-prefix, non-prod absolute URL, acIds, playwright-cli-only, no pytest/playwright test source; emit structured ruleId issues on failure.",
3371
3406
  subtask_prompt: "Scan generated cases/manifest against the shared blocking checklist. Do not use free-form LLM verdicts.",
3372
3407
  shell: { commands: [checklistValidation], cwd: ".", timeoutMs: 120000 },
3373
3408
  }, {
@@ -3380,7 +3415,7 @@ function buildFrontendTestHybridDag(sources) {
3380
3415
  writeSet: casesWriteSet,
3381
3416
  allowedPaths: casesWriteSet,
3382
3417
  forbiddenPaths: forbidden,
3383
- 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.",
3418
+ 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}.",
3384
3419
  subtask_prompt: "Validate manifest.draft.json and materialize manifest.json after the mechanical checklist (and optional blocking review) passes.",
3385
3420
  shell: { commands: [manifestValidation], cwd: ".", timeoutMs: 120000 },
3386
3421
  }, {
@@ -3426,9 +3461,9 @@ function buildFrontendTestHybridDag(sources) {
3426
3461
  writeSet: [`${evidenceRoot}/{{case.caseId}}/**`],
3427
3462
  outputContract: "Compact JSON <=1200 characters with case status, evidence paths, error summary, and tokens.",
3428
3463
  subtaskPromptTemplate: [
3429
- "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.",
3430
- "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.",
3431
- "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)\".",
3464
+ "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.",
3465
+ "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.",
3466
+ "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).",
3432
3467
  "Business failed/blocked is a recorded result, not a node failure. Close browser. Return compact JSON (<=1200 chars): {caseId,status,evidencePaths,errorSummary,tokens}.",
3433
3468
  ].join("\n\n"),
3434
3469
  },
@@ -3439,11 +3474,12 @@ function buildFrontendTestHybridDag(sources) {
3439
3474
  role: "verifier",
3440
3475
  executor: "shell",
3441
3476
  complexity: "LOW",
3442
- writePolicy: "read-only",
3477
+ writePolicy: "exclusive",
3478
+ writeSet: [`${evidenceRoot}/**`],
3443
3479
  allowedPaths: ["testcase/frontend/cases/**", `${evidenceRoot}/**`],
3444
3480
  forbiddenPaths: forbidden,
3445
- outputContract: "Deterministic validation that every manifest case has execution.md and valid matching case-result.json; blocked results require blockedReason.",
3446
- subtask_prompt: "Validate all frontend case evidence before result materialization; fail closed on missing or malformed records.",
3481
+ outputContract: "Deterministic evidence gate: heal missing/malformed case-result to blocked(invalid-evidence-shape); hard-fail only on unsafe evidenceDir. Does not block retrospect.",
3482
+ 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.",
3447
3483
  shell: { commands: [evidenceValidation], cwd: ".", timeoutMs: 120000 },
3448
3484
  }, {
3449
3485
  id: "materialize-frontend-test-result-shell",
@@ -3508,6 +3544,9 @@ function buildFrontendTestHybridDag(sources) {
3508
3544
  "Each browser case runs serially in a fresh Pi execution boundary. Persist its evidence before starting the next case.",
3509
3545
  "Use only the declared isolated test environment. Production URLs, real credentials, and unauthorized data are blocked.",
3510
3546
  "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.",
3547
+ "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.",
3548
+ "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.",
3549
+ "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.",
3511
3550
  "Token settings are post-case stop thresholds, never a hard provider token cap. Unstarted cases after a threshold are blocked: token-budget-exhausted.",
3512
3551
  "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.",
3513
3552
  blockingReview
@@ -4597,9 +4636,11 @@ function applyDefaultReadOnlyRetryPolicy(spec) {
4597
4636
  for (const task of spec.tasks) {
4598
4637
  if (task.retryPolicy !== undefined)
4599
4638
  continue;
4600
- if (isSafeReadOnlyPiRetryCandidate(task)) {
4601
- task.retryPolicy = DEFAULT_READ_ONLY_PI_RETRY_POLICY;
4602
- }
4639
+ if (!isSafeReadOnlyPiRetryCandidate(task))
4640
+ continue;
4641
+ task.retryPolicy = task.outputProtocol
4642
+ ? PROTOCOL_AWARE_PI_RETRY_POLICY
4643
+ : DEFAULT_READ_ONLY_PI_RETRY_POLICY;
4603
4644
  }
4604
4645
  }
4605
4646
  function getTaskOrThrow(spec, id) {
@@ -4633,6 +4674,7 @@ function buildReviewNode(sources) {
4633
4674
  allowedPaths: commonReadOnlyPaths(sources),
4634
4675
  forbiddenPaths: commonForbiddenPaths(sources),
4635
4676
  outputContract: "Plain Markdown whose first non-empty line is VERDICT: pass or VERDICT: request-revision; Critical/Important findings force request-revision. No file writes.",
4677
+ outputProtocol: REVIEW_VERDICT_OUTPUT_PROTOCOL,
4636
4678
  subtask_prompt: [
4637
4679
  "Review upstream implementation and verification evidence.",
4638
4680
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
@@ -4660,6 +4702,7 @@ function buildReviewVerdictRecoveryNode(sources) {
4660
4702
  allowedPaths: commonReadOnlyPaths(sources),
4661
4703
  forbiddenPaths: commonForbiddenPaths(sources),
4662
4704
  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.",
4705
+ outputProtocol: REVIEW_VERDICT_OUTPUT_PROTOCOL,
4663
4706
  subtask_prompt: [
4664
4707
  "Normalize the output format of review-pi; this is the single read-only format-recovery attempt for the review verdict protocol.",
4665
4708
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
@@ -8,6 +8,7 @@ import { resolveContextPolicy } from "./context-policy.js";
8
8
  import { buildDagNodePromptEnvelope } from "./prompt.js";
9
9
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
10
10
  import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
11
+ import { buildProtocolRetryInstruction, validateOutputProtocol, } from "./output-protocol.js";
11
12
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
12
13
  import { buildProjectGovernanceContext, readCompletedWriterChangeManifests, writeProjectGovernanceContext, } from "./project-governance-context.js";
13
14
  import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
@@ -25,9 +26,19 @@ export function buildNodePrompt(spec, task, upstream, options) {
25
26
  projectGovernanceContext: options?.projectGovernanceContext,
26
27
  });
27
28
  }
28
- function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory) {
29
- if (attemptNumber <= 1 ||
30
- task.outputMode !== "structured-required" ||
29
+ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason) {
30
+ if (attemptNumber <= 1)
31
+ return basePrompt;
32
+ if (previousFailureCategory === "protocol-invalid" &&
33
+ task.outputProtocol &&
34
+ previousProtocolReason) {
35
+ return [
36
+ basePrompt,
37
+ "",
38
+ buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
39
+ ].join("\n");
40
+ }
41
+ if (task.outputMode !== "structured-required" ||
31
42
  previousFailureCategory !== "output-too-large") {
32
43
  return basePrompt;
33
44
  }
@@ -301,6 +312,7 @@ export async function executeDagNode(input) {
301
312
  let totalBackoffMs = 0;
302
313
  let terminalResult;
303
314
  let previousFailureCategory;
315
+ let previousProtocolReason;
304
316
  for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
305
317
  const attemptStartedAt = new Date().toISOString();
306
318
  const attemptStarted = Date.now();
@@ -315,7 +327,7 @@ export async function executeDagNode(input) {
315
327
  task,
316
328
  cwd,
317
329
  model,
318
- prompt: buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory),
330
+ prompt: buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory, previousProtocolReason),
319
331
  });
320
332
  }
321
333
  catch (error) {
@@ -326,6 +338,26 @@ export async function executeDagNode(input) {
326
338
  durationMs: Date.now() - attemptStarted,
327
339
  };
328
340
  }
341
+ // R0: executor ok=true still fails closed when outputProtocol is violated.
342
+ // Valid semantic results (e.g. VERDICT: request-revision) pass validation.
343
+ if (result.ok && task.outputProtocol) {
344
+ const protocolText = `${result.assistantText ?? ""}\n${result.stdout ?? ""}`;
345
+ const protocolCheck = validateOutputProtocol(task.outputProtocol, protocolText);
346
+ if (!protocolCheck.ok) {
347
+ result = {
348
+ ...result,
349
+ ok: false,
350
+ failureCategory: protocolCheck.failureCategory,
351
+ stderr: [result.stderr, protocolCheck.reason]
352
+ .filter(Boolean)
353
+ .join("\n"),
354
+ };
355
+ previousProtocolReason = protocolCheck.reason;
356
+ }
357
+ else {
358
+ previousProtocolReason = undefined;
359
+ }
360
+ }
329
361
  const attemptFinishedAt = new Date().toISOString();
330
362
  const attemptRecord = {
331
363
  attempt: attemptNumber,
@@ -384,9 +416,11 @@ export async function executeDagNode(input) {
384
416
  break;
385
417
  const canRetry = retryPolicy !== undefined && attemptNumber < maxAttempts;
386
418
  const retryable = retryPolicy !== undefined &&
387
- isRetryablePiFailureCategory(result.failureCategory, {
419
+ (isRetryablePiFailureCategory(result.failureCategory, {
388
420
  retryCategories: retryPolicy.retryCategories,
389
- });
421
+ }) ||
422
+ (result.failureCategory === "protocol-invalid" &&
423
+ task.outputProtocol?.retryOnInvalid === true));
390
424
  if (!canRetry || !retryable)
391
425
  break;
392
426
  const delayMs = computeBackoffDelayMs(attemptNumber + 1, retryPolicy);
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Machine-readable output protocol for safe read-only Pi nodes.
4
+ *
5
+ * Phase 1 only supports first-line-enum (e.g. VERDICT lines). Structured JSON
6
+ * continues to use existing structured-required / deterministic gates.
7
+ */
8
+ export const dagOutputProtocolSchema = z
9
+ .object({
10
+ type: z.literal("first-line-enum"),
11
+ validLines: z.array(z.string().min(1)).min(1),
12
+ retryOnInvalid: z.boolean().default(true),
13
+ })
14
+ .strict();
15
+ /** Reviewer VERDICT protocol used by reviewed/supervised DAGs. */
16
+ export const REVIEW_VERDICT_OUTPUT_PROTOCOL = {
17
+ type: "first-line-enum",
18
+ validLines: ["VERDICT: pass", "VERDICT: request-revision"],
19
+ retryOnInvalid: true,
20
+ };
21
+ /**
22
+ * Extract the first non-empty line from assistant/stdout text.
23
+ */
24
+ export function firstNonEmptyLine(text) {
25
+ for (const line of text.split("\n")) {
26
+ const trimmed = line.trim();
27
+ if (trimmed)
28
+ return trimmed;
29
+ }
30
+ return undefined;
31
+ }
32
+ /**
33
+ * Validate node output against an explicit outputProtocol.
34
+ * Pure function — does not mutate run facts.
35
+ */
36
+ export function validateOutputProtocol(protocol, text) {
37
+ if (protocol.type !== "first-line-enum") {
38
+ return {
39
+ ok: false,
40
+ failureCategory: "protocol-invalid",
41
+ reason: `unsupported outputProtocol.type: ${protocol.type ?? "unknown"}`,
42
+ };
43
+ }
44
+ const first = firstNonEmptyLine(text);
45
+ if (!first) {
46
+ return {
47
+ ok: false,
48
+ failureCategory: "protocol-invalid",
49
+ reason: `missing first non-empty line; expected one of: ${protocol.validLines.map((l) => JSON.stringify(l)).join(" or ")}`,
50
+ };
51
+ }
52
+ if (!protocol.validLines.includes(first)) {
53
+ return {
54
+ ok: false,
55
+ failureCategory: "protocol-invalid",
56
+ reason: `first non-empty line ${JSON.stringify(first)} is not a valid protocol line; expected one of: ${protocol.validLines.map((l) => JSON.stringify(l)).join(" or ")}`,
57
+ firstNonEmptyLine: first,
58
+ };
59
+ }
60
+ return { ok: true, matchedLine: first };
61
+ }
62
+ /**
63
+ * Correction instruction appended on protocol-invalid retry attempts.
64
+ */
65
+ export function buildProtocolRetryInstruction(protocol, reason) {
66
+ const expected = protocol.validLines
67
+ .map((line) => JSON.stringify(line))
68
+ .join(" or ");
69
+ return [
70
+ "<retry_instruction>",
71
+ "Previous attempt violated the output protocol:",
72
+ reason,
73
+ `Return one valid protocol line before any explanation. Expected first non-empty line: ${expected}.`,
74
+ "</retry_instruction>",
75
+ ].join("\n");
76
+ }