@tea-agent/loop-agent 0.28.13 → 0.29.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 (48) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +73 -15
  3. package/dist/commands/client-recovery.js +56 -1
  4. package/dist/commands/init-upgrade.js +186 -21
  5. package/dist/commands/init.js +1 -1
  6. package/dist/executors/dag-pi-executor.js +52 -3
  7. package/dist/executors/pi-playwright-cli-tool.js +14 -8
  8. package/dist/executors/shell-executor.js +288 -0
  9. package/dist/task/config-types.js +21 -0
  10. package/dist/worker/console/app-data.js +132 -11
  11. package/dist/worker/console/chat/pi-runtime.js +24 -42
  12. package/dist/worker/console/chat/resource-loader.js +11 -20
  13. package/dist/worker/console/chat/routes.js +7 -8
  14. package/dist/worker/console/chat/runtime-context.js +1 -1
  15. package/dist/worker/console/chat/tools.js +67 -54
  16. package/dist/worker/console/operation-runner.js +15 -1
  17. package/dist/worker/console/operation-store.js +70 -49
  18. package/dist/worker/console/operator-actions.js +57 -1
  19. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
  20. package/dist/worker/console/static/favicon.svg +37 -0
  21. package/dist/worker/console/static/index.html +2 -1
  22. package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
  23. package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
  24. package/dist/workflows/dag/frontend-test-case-checklist.js +94 -15
  25. package/dist/workflows/dag/frontend-test-case-manifest.js +104 -0
  26. package/dist/workflows/dag/frontend-test-html-report.js +106 -24
  27. package/dist/workflows/dag/frontend-test-result-contract.js +3 -0
  28. package/dist/workflows/dag/init-hybrid.js +187 -108
  29. package/dist/workflows/dag/node-execution.js +31 -2
  30. package/dist/workflows/dag/retry-policy.js +55 -18
  31. package/dist/workflows/dag/types.js +41 -0
  32. package/dist/workflows/dag/validate.js +42 -4
  33. package/docs/operations/README.md +1 -1
  34. package/docs/templates/README.md +2 -1
  35. package/docs/templates/agent-dag.schema.json +9 -4
  36. package/docs/templates/backend-test-dag.json +36 -11
  37. package/docs/templates/frontend-test-case-checklist.md +1 -1
  38. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +9 -1
  39. package/docs/templates/frontend-test-dag.json +125 -267
  40. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -1
  41. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  42. package/docs/templates/frontend-test-standard-scenarios.v1.json +114 -0
  43. package/docs/templates/init-managed-agents.md +1 -1
  44. package/harness.json +2 -2
  45. package/package.json +2 -1
  46. package/skills/playwright-cli/SKILL.md +1 -1
  47. package/skills/playwright-cli-case-generator/SKILL.md +1 -1
  48. package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
@@ -8,6 +8,8 @@ import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND
8
8
  import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
9
9
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
10
10
  import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
11
+ import { isWriterEmptyDiffRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
12
+ import { assessBackendTestMdWriterCompleteness, assessBackendTestPytestWriterCompleteness, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
11
13
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
12
14
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
13
15
  /** Bounded writer tools: no unrestricted bash; edit/write are policy-wrapped via SDK customTools. */
@@ -338,7 +340,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
338
340
  }
339
341
  let beforeStatus;
340
342
  let beforePathFingerprints;
341
- if (isWriteTask) {
343
+ /** tools-only: keep SDK path sandbox; skip git baseline + post-diff write-guard hard fail. */
344
+ const skipGitWriteGuard = isWriteTask && input.task.writeGuardPolicy === "tools-only";
345
+ if (isWriteTask && !skipGitWriteGuard) {
342
346
  try {
343
347
  beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
344
348
  const beforeSnapshot = snapshotGitStatusPorcelain(beforeStatus);
@@ -680,7 +684,39 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
680
684
  afterStatus: changeManifestAfterStatus ?? "",
681
685
  });
682
686
  }
683
- if (writeGuardOk && !writerOutcomeViolation) {
687
+ let completenessFailure;
688
+ if (mapped.ok &&
689
+ writeGuardOk &&
690
+ !writerOutcomeViolation &&
691
+ isBackendTestCompletenessRetryCandidate(input.task)) {
692
+ try {
693
+ const progress = input.task.id === "generate-backend-pytest-pi"
694
+ ? await assessBackendTestPytestWriterCompleteness(input.cwd)
695
+ : await assessBackendTestMdWriterCompleteness(input.cwd);
696
+ await writeBackendTestWriterProgressArtifacts({
697
+ runDir: meta.runDir,
698
+ progress,
699
+ attempt: input.attempt ?? 1,
700
+ maxAttempts: input.task.retryPolicy?.maxAttempts ?? 3,
701
+ });
702
+ if (progress.status !== "PASS") {
703
+ const classified = classifyBackendTestWriterCompletenessFailure(progress);
704
+ completenessFailure = {
705
+ failureCategory: classified.recoverable
706
+ ? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
707
+ : "invalid-output",
708
+ detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
709
+ };
710
+ }
711
+ }
712
+ catch (error) {
713
+ completenessFailure = {
714
+ failureCategory: "invalid-output",
715
+ detail: `backend-test completeness gate crashed: ${error instanceof Error ? error.message : String(error)}`,
716
+ };
717
+ }
718
+ }
719
+ if (writeGuardOk && !writerOutcomeViolation && !completenessFailure) {
684
720
  return mapped;
685
721
  }
686
722
  const stderrParts = [mapped.stderr];
@@ -690,6 +726,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
690
726
  if (writerOutcomeViolation) {
691
727
  stderrParts.push(writerOutcomeViolation);
692
728
  }
729
+ if (completenessFailure) {
730
+ stderrParts.push(completenessFailure.detail);
731
+ }
693
732
  if (meta.writeGuardAttribution === "best-effort") {
694
733
  stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
695
734
  }
@@ -699,7 +738,13 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
699
738
  stderr: stderrParts.filter(Boolean).join("\n\n"),
700
739
  failureCategory: mapped.ok
701
740
  ? writeGuardOk
702
- ? "invalid-output"
741
+ ? completenessFailure
742
+ ? completenessFailure.failureCategory
743
+ : changeManifestChangedFiles?.length === 0 &&
744
+ isWriterEmptyDiffRetryCandidate(input.task) &&
745
+ isChangedWriterImplementationOutcome(mapped.assistantText || mapped.stdout)
746
+ ? WRITER_EMPTY_DIFF_RETRY_CATEGORY
747
+ : "invalid-output"
703
748
  : "write-guard"
704
749
  : mapped.failureCategory,
705
750
  durationMs: mapped.durationMs || Date.now() - started,
@@ -776,6 +821,10 @@ function parseWriterImplementationOutcome(text) {
776
821
  function isWriterImplementationOutcome(value) {
777
822
  return (value === "changed" || value === "already-satisfied" || value === "blocked");
778
823
  }
824
+ function isChangedWriterImplementationOutcome(text) {
825
+ const parsed = parseWriterImplementationOutcome(text);
826
+ return parsed.kind === "valid" && parsed.outcome === "changed";
827
+ }
779
828
  function writerOutcomeDiagnostics(text, parsed, changedFiles) {
780
829
  const firstNonEmpty = text
781
830
  .split(/\r?\n/)
@@ -197,15 +197,21 @@ function validateOpenArgs(args, baseUrl) {
197
197
  for (const arg of args) {
198
198
  assertNoControlMeta(arg);
199
199
  assertNoSessionFlag(arg);
200
- if (arg === "--browser=chrome" || arg === "--headed") {
200
+ if (arg === "--browser=chrome" || arg === "--headless") {
201
201
  flags.add(arg);
202
202
  normalized.push(arg);
203
203
  continue;
204
204
  }
205
+ // Accept legacy --headed but normalize to headless for frontend-test CI/dogfood.
206
+ if (arg === "--headed") {
207
+ flags.add("--headless");
208
+ if (!normalized.includes("--headless"))
209
+ normalized.push("--headless");
210
+ continue;
211
+ }
205
212
  if (arg.startsWith("--browser=") ||
206
- arg === "--browser" ||
207
- arg === "--headless") {
208
- throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headed only");
213
+ arg === "--browser") {
214
+ throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headless only");
209
215
  }
210
216
  if (/^[a-z][a-z0-9+.-]*:/i.test(arg) || arg.startsWith("http")) {
211
217
  url = arg;
@@ -213,12 +219,12 @@ function validateOpenArgs(args, baseUrl) {
213
219
  }
214
220
  throw new PlaywrightCliPolicyError("open-args", `unsupported open argument: ${arg}`);
215
221
  }
216
- if (!flags.has("--browser=chrome") || !flags.has("--headed")) {
222
+ if (!flags.has("--browser=chrome") || !flags.has("--headless")) {
217
223
  // controller injects required flags when missing from model args
218
224
  if (!flags.has("--browser=chrome"))
219
225
  normalized.unshift("--browser=chrome");
220
- if (!flags.has("--headed"))
221
- normalized.push("--headed");
226
+ if (!flags.has("--headless"))
227
+ normalized.push("--headless");
222
228
  }
223
229
  if (!url) {
224
230
  throw new PlaywrightCliPolicyError("open-url-required", "open requires an absolute http(s) URL");
@@ -509,7 +515,7 @@ export function preparePlaywrightCliArgv(input, ctx) {
509
515
  catch {
510
516
  throw new PlaywrightCliPolicyError("goto-url-invalid", "goto requires a URL relative to the controller baseUrl");
511
517
  }
512
- const openLike = validateOpenArgs(["--browser=chrome", "--headed", target], ctx.baseUrl);
518
+ const openLike = validateOpenArgs(["--browser=chrome", "--headless", target], ctx.baseUrl);
513
519
  return openLike[openLike.length - 1];
514
520
  });
515
521
  if (args.length !== 1) {
@@ -18,6 +18,7 @@ import { materializeFrontendImplementationContract } from "../workflows/dag/fron
18
18
  import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
19
19
  import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
20
20
  import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
21
+ import { materializeFrontendTestCaseManifest } from "../workflows/dag/frontend-test-case-manifest.js";
21
22
  import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
22
23
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
23
24
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
@@ -30,6 +31,7 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
30
31
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
31
32
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
32
33
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
34
+ import { applyDeterministicScenarioParamRepairs, assessBackendScenarioParamConsistency, classifyBackendTestFailureWithScenarioParam, readBackendScenarioParamFacts, renderBackendTestFailureAnalysis, writeBackendScenarioParamArtifacts, writeScenarioParamRepairAudit, } from "../workflows/dag/backend-test-scenario-param.js";
33
35
  import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
34
36
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
35
37
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
@@ -768,6 +770,77 @@ async function executeBackendTestPipeline(input, meta) {
768
770
  durationMs: Date.now() - started,
769
771
  };
770
772
  }
773
+ // Scenario-param consistency (P1): assess -> deterministic repair <=1 -> reassess final.
774
+ try {
775
+ const initialAssessment = await assessBackendScenarioParamConsistency({
776
+ workspaceRoot: input.cwd,
777
+ phase: "initial",
778
+ repairAttempt: 0,
779
+ strictScenarioParamGate: Boolean((meta.spec.globalConstraints ?? []).some((item) => /strictScenarioParamGate\s*=\s*true/i.test(item))),
780
+ });
781
+ const initialArtifacts = await writeBackendScenarioParamArtifacts({
782
+ runDir: meta.runDir,
783
+ facts: initialAssessment.facts,
784
+ markdown: initialAssessment.markdown,
785
+ });
786
+ outputs.push(`scenarioParamInitial=${initialArtifacts.reportPath}`, `scenarioParamInitialFacts=${initialArtifacts.factsPath}`);
787
+ let finalFacts = initialAssessment.facts;
788
+ let finalMarkdown = initialAssessment.markdown;
789
+ if (initialAssessment.facts.repairEligible) {
790
+ const repair = await applyDeterministicScenarioParamRepairs({
791
+ workspaceRoot: input.cwd,
792
+ facts: initialAssessment.facts,
793
+ });
794
+ const auditPath = await writeScenarioParamRepairAudit({
795
+ runDir: meta.runDir,
796
+ audit: repair.audit,
797
+ changedFiles: repair.changedFiles,
798
+ });
799
+ outputs.push(`scenarioParamRepairAudit=${auditPath}`, `scenarioParamRepaired=${repair.repaired.join(",") || "(none)"}`);
800
+ const reassessment = await assessBackendScenarioParamConsistency({
801
+ workspaceRoot: input.cwd,
802
+ phase: "final",
803
+ repairAttempt: 1,
804
+ strictScenarioParamGate: initialAssessment.facts.strictScenarioParamGate,
805
+ });
806
+ finalFacts = reassessment.facts;
807
+ finalMarkdown = reassessment.markdown;
808
+ }
809
+ else {
810
+ finalFacts = {
811
+ ...initialAssessment.facts,
812
+ phase: "final",
813
+ repairAttempt: 0,
814
+ repairEligible: false,
815
+ };
816
+ finalMarkdown = initialAssessment.markdown.replace("Phase: initial", "Phase: final");
817
+ }
818
+ const finalArtifacts = await writeBackendScenarioParamArtifacts({
819
+ runDir: meta.runDir,
820
+ facts: finalFacts,
821
+ markdown: finalMarkdown,
822
+ });
823
+ outputs.push(`scenarioParamFinal=${finalArtifacts.reportPath}`, `scenarioParamFinalFacts=${finalArtifacts.factsPath}`, `scenarioParamMismatch=${finalFacts.summary.mismatchCount}`, finalMarkdown);
824
+ if (finalFacts.strictScenarioParamGate &&
825
+ finalFacts.summary.mismatchCount > 0) {
826
+ return {
827
+ ok: false,
828
+ stdout: outputs.join("\n\n"),
829
+ stderr: "backend-test strict scenario-param gate blocked residual MISMATCH before execute",
830
+ failureCategory: "invalid-output",
831
+ durationMs: Date.now() - started,
832
+ };
833
+ }
834
+ }
835
+ catch (error) {
836
+ const message = error instanceof Error ? error.message : String(error);
837
+ const report = "# Backend Test Scenario-Param Consistency\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Scenario-param analysis crashed: " +
838
+ message +
839
+ "\n";
840
+ const reportPath = await writeRunReport(meta.runDir, "backend-test-scenario-param-consistency.md", report);
841
+ outputs.push(`scenarioParam=${reportPath}`, report);
842
+ // Advisory by default: do not fail the node on scenario-param infrastructure errors.
843
+ }
771
844
  }
772
845
  else if (pipeline === "markdown-manifest") {
773
846
  const sourceBinding = meta.spec.sourceBinding;
@@ -989,6 +1062,69 @@ async function executeBackendTestPipeline(input, meta) {
989
1062
  });
990
1063
  const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
991
1064
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
1065
+ // Structured failure analysis report (P2); emitted even when there are zero failures.
1066
+ let scenarioParamFinalStatus = "UNAVAILABLE";
1067
+ let scenarioParamRepairAttempt = 0;
1068
+ const scenarioParamStatusByToken = new Map();
1069
+ try {
1070
+ const scenarioFacts = await readBackendScenarioParamFacts(path.join(meta.runDir, "contracts", "backend-test-scenario-param-consistency-facts.json"));
1071
+ scenarioParamFinalStatus =
1072
+ scenarioFacts.summary.mismatchCount > 0 ? "FAIL" : "PASS";
1073
+ scenarioParamRepairAttempt = scenarioFacts.repairAttempt;
1074
+ for (const entry of scenarioFacts.entries) {
1075
+ scenarioParamStatusByToken.set(entry.caseId, entry.status);
1076
+ scenarioParamStatusByToken.set(entry.tpId, entry.status);
1077
+ }
1078
+ }
1079
+ catch {
1080
+ // optional facts
1081
+ }
1082
+ const failureCases = parsed.cases.filter((item) => item.status === "failure" || item.status === "error");
1083
+ const failureAnalysisMarkdown = renderBackendTestFailureAnalysis({
1084
+ generatedAt: new Date().toISOString(),
1085
+ htmlReportPath: "reports/backend-test.html",
1086
+ total: parsed.tests,
1087
+ passed: parsed.passed,
1088
+ failed: parsed.failed + parsed.errors,
1089
+ durationLabel: `${parsed.durationMs ?? 0}ms`,
1090
+ environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8").catch(() => "unavailable"),
1091
+ scenarioParamFinalStatus,
1092
+ repairAttempt: scenarioParamRepairAttempt,
1093
+ failures: failureCases.map((result) => {
1094
+ const caseMatch = result.name.match(/\bBE-[A-Z0-9_-]+-\d{2,3}\b/);
1095
+ const tpMatch = result.name.match(/\bTP-[A-Z0-9-]+\b/);
1096
+ const caseId = caseMatch?.[0] ?? result.name;
1097
+ const scenarioParamStatus = (tpMatch && scenarioParamStatusByToken.get(tpMatch[0])) ||
1098
+ scenarioParamStatusByToken.get(caseId);
1099
+ const evidence = [result.message, result.details]
1100
+ .filter(Boolean)
1101
+ .join("\n");
1102
+ const statusMatch = /assert\s+(\d{3})\s*==\s*(\d{3})/i.exec(evidence) ||
1103
+ /expected[^\d]*(\d{3})[\s\S]{0,40}actual[^\d]*(\d{3})/i.exec(evidence);
1104
+ return {
1105
+ name: result.name,
1106
+ caseId,
1107
+ scenario: cases.find((item) => item.id === caseId)?.scenario ??
1108
+ cases.find((item) => item.id === caseId)?.title ??
1109
+ result.name,
1110
+ expectedCode: statusMatch?.[2] ?? statusMatch?.[1],
1111
+ actualCode: statusMatch?.[1] ?? statusMatch?.[2],
1112
+ message: result.message || result.status,
1113
+ scriptPath: result.classname,
1114
+ durationLabel: result.durationMs !== undefined
1115
+ ? `${result.durationMs}ms`
1116
+ : undefined,
1117
+ scenarioParamStatus,
1118
+ classification: classifyBackendTestFailureWithScenarioParam({
1119
+ message: result.message || "",
1120
+ details: result.details,
1121
+ scenarioParamStatus,
1122
+ }),
1123
+ };
1124
+ }),
1125
+ });
1126
+ const failureAnalysisPath = await writeRunReport(meta.runDir, "backend-test-failure-analysis.md", failureAnalysisMarkdown);
1127
+ outputs.push(`failureAnalysis=${failureAnalysisPath}`);
992
1128
  // Deterministic L-5 dashboard: machine-computed metrics (not Pi-generated).
993
1129
  // manifest is produced by an upstream finalize node; tolerate its absence
994
1130
  // so a minimal DAG without manifest still gets a degraded dashboard.
@@ -1691,6 +1827,149 @@ async function executeFrontendTestEvidenceValidation(input) {
1691
1827
  };
1692
1828
  }
1693
1829
  }
1830
+ async function executeFrontendTestCaseManifest(input, meta) {
1831
+ const started = Date.now();
1832
+ try {
1833
+ const gate = input.task.shell?.frontendTestCaseManifest ?? {};
1834
+ const declaredAcIds = gate.declaredAcIds ??
1835
+ (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
1836
+ const checklist = await validateFrontendCaseChecklist({
1837
+ workspaceRoot: input.cwd,
1838
+ declaredAcIds,
1839
+ });
1840
+ if (checklist.issues.length) {
1841
+ return {
1842
+ ok: false,
1843
+ stdout: "",
1844
+ stderr: `frontend-test checklist blocked: ${JSON.stringify(checklist.issues)}`,
1845
+ failureCategory: "invalid-output",
1846
+ durationMs: Date.now() - started,
1847
+ };
1848
+ }
1849
+ const materialized = await materializeFrontendTestCaseManifest({
1850
+ workspaceRoot: input.cwd,
1851
+ maxCases: gate.maxCases,
1852
+ declaredAcIds,
1853
+ });
1854
+ return {
1855
+ ok: true,
1856
+ stdout: `${JSON.stringify({ cases: materialized.cases })}
1857
+ `,
1858
+ stderr: "",
1859
+ failureCategory: "success",
1860
+ durationMs: Date.now() - started,
1861
+ };
1862
+ }
1863
+ catch (error) {
1864
+ return {
1865
+ ok: false,
1866
+ stdout: "",
1867
+ stderr: error instanceof Error ? error.message : String(error),
1868
+ failureCategory: "invalid-output",
1869
+ durationMs: Date.now() - started,
1870
+ };
1871
+ }
1872
+ }
1873
+ /** Path-escape / cross-case evidence errors must hard-fail finalize (same class as validate.hardFail). */
1874
+ function isFrontendTestEvidencePathHardError(error) {
1875
+ const message = error instanceof Error ? error.message : String(error);
1876
+ return /unsafe evidence|escapes (?:evidence root|its allowed root)|evidence realpath|cross-case/i.test(message);
1877
+ }
1878
+ /**
1879
+ * Lean finalize: evidence validate + result materialize in one shell.
1880
+ * - Case-level missing/malformed evidence is advisory (issues / advisoryFindings);
1881
+ * the node still succeeds so reports can render incomplete/failed outcomes.
1882
+ * - Path escape / unsafe evidence roots hard-fail the node.
1883
+ * - Controller/contract errors (missing sourceBinding, empty manifest) still fail closed.
1884
+ */
1885
+ async function executeFrontendTestResultFinalize(input, meta) {
1886
+ const started = Date.now();
1887
+ let advisoryStdout = "";
1888
+ try {
1889
+ const evidence = await validateFrontendCaseEvidence({
1890
+ workspaceRoot: input.cwd,
1891
+ });
1892
+ advisoryStdout = `frontend case evidence validation cases=${evidence.cases} findings=${evidence.issues.length}${evidence.issues.length ? ` issues=${JSON.stringify(evidence.issues)}` : ""}`;
1893
+ // Advisory findings must not block materialize; only hardFail (path escape) does.
1894
+ if (evidence.hardFail) {
1895
+ return {
1896
+ ok: false,
1897
+ stdout: advisoryStdout,
1898
+ stderr: `frontend-test evidence hard-fail: ${JSON.stringify(evidence.issues)}`,
1899
+ failureCategory: "nonzero-exit",
1900
+ durationMs: Date.now() - started,
1901
+ };
1902
+ }
1903
+ const artifact = await materializeFrontendTestResult({
1904
+ runDir: meta.runDir,
1905
+ workspaceRoot: input.cwd,
1906
+ artifactName: "frontend-test-result.json",
1907
+ outputDir: "contracts",
1908
+ sourceBinding: meta.spec.sourceBinding,
1909
+ });
1910
+ return {
1911
+ ok: true,
1912
+ stdout: `${advisoryStdout}\nStructured artifact: ${artifact.path}\nSchema: ${artifact.schemaId}\nSHA-256: ${artifact.sha256}`,
1913
+ stderr: "",
1914
+ failureCategory: "success",
1915
+ durationMs: Date.now() - started,
1916
+ };
1917
+ }
1918
+ catch (error) {
1919
+ const detail = error instanceof Error ? error.message : String(error);
1920
+ if (isFrontendTestEvidencePathHardError(error)) {
1921
+ return {
1922
+ ok: false,
1923
+ stdout: advisoryStdout,
1924
+ stderr: `frontend-test evidence hard-fail: ${detail}`,
1925
+ failureCategory: "nonzero-exit",
1926
+ durationMs: Date.now() - started,
1927
+ };
1928
+ }
1929
+ return {
1930
+ ok: false,
1931
+ stdout: advisoryStdout,
1932
+ stderr: detail,
1933
+ failureCategory: "invalid-output",
1934
+ durationMs: Date.now() - started,
1935
+ };
1936
+ }
1937
+ }
1938
+ async function executeFrontendTestReports(input, meta) {
1939
+ const started = Date.now();
1940
+ try {
1941
+ const writeL5 = input.task.shell?.frontendTestReports?.l5 !== false;
1942
+ const lines = [];
1943
+ if (writeL5) {
1944
+ const l5 = await renderFrontendTestL5Report({
1945
+ workspaceRoot: input.cwd,
1946
+ runDir: meta.runDir,
1947
+ });
1948
+ lines.push(`Frontend L-5 report: ${l5.htmlPath}\nMarkdown: ${l5.markdownPath}\nStatus: ${l5.metrics.status}`);
1949
+ }
1950
+ const report = await renderFrontendTestHtmlReport({
1951
+ workspaceRoot: input.cwd,
1952
+ runDir: meta.runDir,
1953
+ });
1954
+ lines.push(`Frontend test report: ${report.htmlPath}\nMarkdown: ${report.markdownPath}\nOutcome: ${report.outcome}\nCases: ${report.caseCount}`);
1955
+ return {
1956
+ ok: true,
1957
+ stdout: lines.join("\n"),
1958
+ stderr: "",
1959
+ failureCategory: "success",
1960
+ durationMs: Date.now() - started,
1961
+ };
1962
+ }
1963
+ catch (error) {
1964
+ return {
1965
+ ok: false,
1966
+ stdout: "",
1967
+ stderr: error instanceof Error ? error.message : String(error),
1968
+ failureCategory: "invalid-output",
1969
+ durationMs: Date.now() - started,
1970
+ };
1971
+ }
1972
+ }
1694
1973
  async function executeFrontendLintBaseline(input, meta) {
1695
1974
  const started = Date.now();
1696
1975
  const shell = input.task.shell;
@@ -1927,15 +2206,24 @@ export async function executeDagShellNode(input, meta) {
1927
2206
  if (shell?.frontendTestCaseChecklist) {
1928
2207
  return executeFrontendTestCaseChecklist(input, meta);
1929
2208
  }
2209
+ if (shell?.frontendTestCaseManifest) {
2210
+ return executeFrontendTestCaseManifest(input, meta);
2211
+ }
1930
2212
  if (shell?.frontendTestEvidenceValidation) {
1931
2213
  return executeFrontendTestEvidenceValidation(input);
1932
2214
  }
2215
+ if (shell?.frontendTestResultFinalize) {
2216
+ return executeFrontendTestResultFinalize(input, meta);
2217
+ }
1933
2218
  if (shell?.frontendTestL5Report) {
1934
2219
  return executeFrontendTestL5Report(input, meta);
1935
2220
  }
1936
2221
  if (shell?.frontendTestHtmlReport) {
1937
2222
  return executeFrontendTestHtmlReport(input, meta);
1938
2223
  }
2224
+ if (shell?.frontendTestReports) {
2225
+ return executeFrontendTestReports(input, meta);
2226
+ }
1939
2227
  if (shell?.backendTestPipeline) {
1940
2228
  return executeBackendTestPipelineWithWriteGuard(input, meta);
1941
2229
  }
@@ -83,6 +83,27 @@ export const frontendTestConfigSchema = z.object({
83
83
  * Default false: pipeline success is result materialize + retrospect report, not full green.
84
84
  */
85
85
  strictOutcomeGate: z.boolean().optional(),
86
+ /**
87
+ * Maximum rerun attempts for blocked or missing-result frontend cases.
88
+ * Defaults to 2; the static graph always carries a single rerun select+map
89
+ * pair and bounds candidates within that pair via this value.
90
+ */
91
+ maxRerunAttempts: z.number().int().min(0).max(4).optional(),
92
+ /**
93
+ * Report chain toggles for the lean frontend-test DAG.
94
+ * - retrospect: opt-in Pi retrospective node (default off). Pipeline success
95
+ * is frontend-test-result-v1 + main HTML/MD report, not the retrospect.
96
+ * - l5: write the deterministic L-5 dashboard from the merged reports node
97
+ * (default true; the merged reports node always renders the main
98
+ * HTML/MD report regardless of this flag).
99
+ */
100
+ reports: z
101
+ .object({
102
+ retrospect: z.boolean().optional(),
103
+ l5: z.boolean().optional(),
104
+ })
105
+ .strict()
106
+ .optional(),
86
107
  });
87
108
  export const convergenceConfigSchema = z.object({
88
109
  enabled: z.boolean().optional().default(false),