@tea-agent/loop-agent 0.26.3 → 0.26.5-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { writeDagNodeTextArtifact } from "../infrastructure/harness/artifact-store.js";
5
+ import { writeDagNodeJsonArtifact, writeDagNodeTextArtifact, } from "../infrastructure/harness/artifact-store.js";
6
+ import { captureWorkspaceCheckpoint } from "../workflows/dag/workspace-checkpoint.js";
6
7
  import { truncateOutput } from "../shared/output-truncation.js";
7
- import { processTreeSpawnOptions, terminateProcessTree } from "./process-tree.js";
8
+ import { processTreeSpawnOptions, terminateProcessTree, } from "./process-tree.js";
8
9
  import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdictGateShellCommand, } from "./shell-presets.js";
9
10
  import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
10
11
  import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
@@ -22,7 +23,7 @@ import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } f
22
23
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
23
24
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
24
25
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
25
- import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
26
+ import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
26
27
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
27
28
  import { assessBackendPytestCollection, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
28
29
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
@@ -33,6 +34,29 @@ import { buildShellProcessEnv } from "./shell-verification.js";
33
34
  import { readRunState } from "../workflows/dag/run-store.js";
34
35
  import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
35
36
  import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
37
+ /** In-memory same-run success cache. Never shared across runIds. */
38
+ const sameRunShellCommandCaches = new Map();
39
+ function shellCommandCacheKey(runId, nodeId) {
40
+ return `${runId}::${nodeId}`;
41
+ }
42
+ function buildShellCommandContractKey(input) {
43
+ return JSON.stringify({
44
+ commands: input.commands,
45
+ cwd: input.cwd,
46
+ timeoutMs: input.timeoutMs,
47
+ envAllowlist: input.envAllowlist ?? [],
48
+ failFast: input.failFast,
49
+ nonZeroExitPolicy: input.nonZeroExitPolicy ?? "fail",
50
+ });
51
+ }
52
+ export function clearSameRunShellCommandCachesForTests() {
53
+ sameRunShellCommandCaches.clear();
54
+ }
55
+ function shouldReuseSuccessfulShellCommands(shell) {
56
+ // AC-005 targets supervised hard verify; also allow any shell node that
57
+ // carries final verifyEvidence so intermediate gates stay fail-closed fresh.
58
+ return shell.verifyEvidence?.phase === "final";
59
+ }
36
60
  const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
37
61
  const SUMMARY_STDOUT_MAX = 4_000;
38
62
  const SUMMARY_STDERR_MAX = 2_000;
@@ -394,7 +418,13 @@ async function executeBackendTestPipeline(input, meta) {
394
418
  const results = await executePipelineCommands(input, meta);
395
419
  if (!results.every((result) => result.ok)) {
396
420
  const failure = results.find((result) => !result.ok);
397
- return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: failure.stderr, failureCategory: failure.failureCategory, durationMs: Date.now() - started };
421
+ return {
422
+ ok: false,
423
+ stdout: results.map((result) => result.stdout).join("\n"),
424
+ stderr: failure.stderr,
425
+ failureCategory: failure.failureCategory,
426
+ durationMs: Date.now() - started,
427
+ };
398
428
  }
399
429
  const environment = await inspectBackendTestEnvironment({
400
430
  workspaceRoot: input.cwd,
@@ -449,13 +479,20 @@ async function executeBackendTestPipeline(input, meta) {
449
479
  }
450
480
  const normalizedBinding = {
451
481
  taskId: sourceBinding.taskId,
452
- requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ?? "source/requirement.md",
453
- requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ?? "0".repeat(64),
454
- referencePaths: sourceBinding.sources.filter((s) => s.kind === "reference").map((s) => s.path),
482
+ requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ??
483
+ "source/requirement.md",
484
+ requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ??
485
+ "0".repeat(64),
486
+ referencePaths: sourceBinding.sources
487
+ .filter((s) => s.kind === "reference")
488
+ .map((s) => s.path),
455
489
  requirementIds: sourceBinding.requirementIds,
456
490
  };
457
491
  try {
458
- const coverage = await analyzeBackendTestCaseCoverage({ workspaceRoot: input.cwd, sourceBinding: normalizedBinding });
492
+ const coverage = await analyzeBackendTestCaseCoverage({
493
+ workspaceRoot: input.cwd,
494
+ sourceBinding: normalizedBinding,
495
+ });
459
496
  const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage.markdown);
460
497
  const contractsDir = path.join(meta.runDir, "contracts");
461
498
  await mkdir(contractsDir, { recursive: true });
@@ -544,7 +581,8 @@ async function executeBackendTestPipeline(input, meta) {
544
581
  return {
545
582
  ok: false,
546
583
  stdout: result.stdout,
547
- stderr: result.stderr || "backend pytest final collection could not start",
584
+ stderr: result.stderr ||
585
+ "backend pytest final collection could not start",
548
586
  failureCategory: result.failureCategory,
549
587
  durationMs: Date.now() - started,
550
588
  };
@@ -609,7 +647,10 @@ async function executeBackendTestPipeline(input, meta) {
609
647
  };
610
648
  }
611
649
  try {
612
- const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({ workspaceRoot: input.cwd, taskId: sourceBinding.taskId });
650
+ const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({
651
+ workspaceRoot: input.cwd,
652
+ taskId: sourceBinding.taskId,
653
+ });
613
654
  const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence.markdown);
614
655
  const contractsDir = path.join(meta.runDir, "contracts");
615
656
  await mkdir(contractsDir, { recursive: true });
@@ -640,9 +681,13 @@ async function executeBackendTestPipeline(input, meta) {
640
681
  workspaceRoot: input.cwd,
641
682
  sourceBinding: {
642
683
  taskId: sourceBinding.taskId,
643
- requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ?? "source/requirement.md",
644
- requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ?? "0".repeat(64),
645
- referencePaths: sourceBinding.sources.filter((s) => s.kind === "reference").map((s) => s.path),
684
+ requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ??
685
+ "source/requirement.md",
686
+ requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")
687
+ ?.sha256 ?? "0".repeat(64),
688
+ referencePaths: sourceBinding.sources
689
+ .filter((s) => s.kind === "reference")
690
+ .map((s) => s.path),
646
691
  requirementIds: sourceBinding.requirementIds,
647
692
  },
648
693
  });
@@ -754,7 +799,9 @@ async function executeBackendTestPipeline(input, meta) {
754
799
  if (!results.every((result) => result.ok)) {
755
800
  const failureIndex = results.findIndex((result) => !result.ok);
756
801
  const failure = results[failureIndex];
757
- const combinedStdout = results.map((result) => result.stdout).join("\n");
802
+ const combinedStdout = results
803
+ .map((result) => result.stdout)
804
+ .join("\n");
758
805
  let diagnosticsText = "";
759
806
  try {
760
807
  diagnosticsText = await readFile(path.join(meta.runDir, "reports", "backend-test-execute-diagnostics.md"), "utf8");
@@ -887,7 +934,12 @@ async function executeBackendTestPipeline(input, meta) {
887
934
  }
888
935
  }
889
936
  const l5Metrics = computeL5ReportMetrics({
890
- result: { passed: parsed.passed, failed: parsed.failed, error: parsed.errors, skipped: parsed.skipped },
937
+ result: {
938
+ passed: parsed.passed,
939
+ failed: parsed.failed,
940
+ error: parsed.errors,
941
+ skipped: parsed.skipped,
942
+ },
891
943
  manifest: manifestForL5,
892
944
  coverage: coverageContract,
893
945
  criticalRiskCount: parsed.failed + parsed.errors > 0 ? 1 : 0,
@@ -896,7 +948,12 @@ async function executeBackendTestPipeline(input, meta) {
896
948
  .filter((c) => c.status !== "passed")
897
949
  .map((c) => {
898
950
  const id = c.name.match(/BE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}/)?.[0] ?? c.name;
899
- return { caseId: id, title: c.name, message: c.message ?? c.status, script: `${c.classname.split("::")[0] ?? c.classname}` };
951
+ return {
952
+ caseId: id,
953
+ title: c.name,
954
+ message: c.message ?? c.status,
955
+ script: `${c.classname.split("::")[0] ?? c.classname}`,
956
+ };
900
957
  });
901
958
  const l5Html = renderBackendTestL5Dashboard({
902
959
  title: meta.spec.title,
@@ -969,7 +1026,7 @@ async function executeBackendTestPipeline(input, meta) {
969
1026
  if (!parsed ||
970
1027
  typeof parsed !== "object" ||
971
1028
  Array.isArray(parsed) ||
972
- !["verdict", "findings", "summary"].every((key) => Object.prototype.hasOwnProperty.call(parsed, key))) {
1029
+ !["verdict", "findings", "summary"].every((key) => Object.hasOwn(parsed, key))) {
973
1030
  return {
974
1031
  ok: false,
975
1032
  stdout: "",
@@ -1021,13 +1078,25 @@ async function executeBackendTestPipeline(input, meta) {
1021
1078
  });
1022
1079
  outputs.push(`semantic=${review.path}`, formatTraceabilityGateStdout(trace));
1023
1080
  const parsed = JSON.parse(await readFile(review.path, "utf8"));
1024
- return { ok: true, stdout: JSON.stringify(parsed), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1081
+ return {
1082
+ ok: true,
1083
+ stdout: JSON.stringify(parsed),
1084
+ stderr: "",
1085
+ failureCategory: "success",
1086
+ durationMs: Date.now() - started,
1087
+ };
1025
1088
  }
1026
1089
  else if (pipeline === "execute-parse-initial") {
1027
1090
  const results = await executePipelineCommands(input, meta);
1028
1091
  if (!results.every((result) => result.ok)) {
1029
1092
  const failure = results.find((result) => !result.ok);
1030
- return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: failure.stderr, failureCategory: failure.failureCategory, durationMs: Date.now() - started };
1093
+ return {
1094
+ ok: false,
1095
+ stdout: results.map((result) => result.stdout).join("\n"),
1096
+ stderr: failure.stderr,
1097
+ failureCategory: failure.failureCategory,
1098
+ durationMs: Date.now() - started,
1099
+ };
1031
1100
  }
1032
1101
  const artifact = await materializeBackendTestResultFromRunDir({
1033
1102
  runDir: meta.runDir,
@@ -1045,9 +1114,12 @@ async function executeBackendTestPipeline(input, meta) {
1045
1114
  artifactName: "backend-test-classification.json",
1046
1115
  outputDir: "contracts",
1047
1116
  });
1048
- const selected = await executePipelineCommands(input, meta, [buildBackendTestCanonicalResultFromInitialShellSnippet()]);
1117
+ const selected = await executePipelineCommands(input, meta, [
1118
+ buildBackendTestCanonicalResultFromInitialShellSnippet(),
1119
+ ]);
1049
1120
  if (!selected.every((result) => result.ok))
1050
- throw new Error(selected.find((result) => !result.ok)?.stderr || "canonical result materialization failed");
1121
+ throw new Error(selected.find((result) => !result.ok)?.stderr ||
1122
+ "canonical result materialization failed");
1051
1123
  const readNodeOutput = async (nodeId) => {
1052
1124
  const record = JSON.parse(await readFile(path.join(meta.runDir, `${nodeId}.json`), "utf8"));
1053
1125
  const text = record.assistantText?.trim() || record.stdout?.trim() || "";
@@ -1056,12 +1128,17 @@ async function executeBackendTestPipeline(input, meta) {
1056
1128
  return text;
1057
1129
  };
1058
1130
  const caseReview = await readNodeOutput("review-backend-cases-pi");
1059
- const reviewVerdicts = caseReview.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("VERDICT:"));
1060
- if (reviewVerdicts.length !== 1 || !["VERDICT: pass", "VERDICT: request-revision"].includes(reviewVerdicts[0])) {
1131
+ const reviewVerdicts = caseReview
1132
+ .split(/\r?\n/)
1133
+ .map((line) => line.trim())
1134
+ .filter((line) => line.startsWith("VERDICT:"));
1135
+ if (reviewVerdicts.length !== 1 ||
1136
+ !["VERDICT: pass", "VERDICT: request-revision"].includes(reviewVerdicts[0])) {
1061
1137
  throw new Error("invalid advisory case review evidence: expected exactly one auditable VERDICT line");
1062
1138
  }
1063
1139
  const traceability = await readNodeOutput("backend-test-traceability-gate-shell");
1064
- if (!traceability.includes("Traceability gate: pass") || !/Matched symbols:\s*\d+/.test(traceability)) {
1140
+ if (!traceability.includes("Traceability gate: pass") ||
1141
+ !/Matched symbols:\s*\d+/.test(traceability)) {
1065
1142
  throw new Error("invalid traceability evidence: deterministic pass summary is missing");
1066
1143
  }
1067
1144
  const contracts = path.join(meta.runDir, "contracts");
@@ -1070,7 +1147,10 @@ async function executeBackendTestPipeline(input, meta) {
1070
1147
  result: JSON.parse(await readFile(path.join(contracts, "backend-test-result.json"), "utf8")),
1071
1148
  manifest: JSON.parse(await readFile(path.join(contracts, "backend-test-case-manifest.json"), "utf8")),
1072
1149
  classification: JSON.parse(await readFile(path.join(contracts, "backend-test-classification.json"), "utf8")),
1073
- caseReview: { verdict: reviewVerdicts[0].slice("VERDICT: ".length), evidence: caseReview },
1150
+ caseReview: {
1151
+ verdict: reviewVerdicts[0].slice("VERDICT: ".length),
1152
+ evidence: caseReview,
1153
+ },
1074
1154
  traceability: { status: "pass", evidence: traceability },
1075
1155
  };
1076
1156
  outputs.push(`classification=${classification.path}`, ...selected.map((result) => result.stdout), JSON.stringify(context, null, 2));
@@ -1078,10 +1158,22 @@ async function executeBackendTestPipeline(input, meta) {
1078
1158
  else {
1079
1159
  throw new Error(`unsupported backend-test pipeline: ${pipeline}`);
1080
1160
  }
1081
- return { ok: true, stdout: outputs.filter(Boolean).join("\n"), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1161
+ return {
1162
+ ok: true,
1163
+ stdout: outputs.filter(Boolean).join("\n"),
1164
+ stderr: "",
1165
+ failureCategory: "success",
1166
+ durationMs: Date.now() - started,
1167
+ };
1082
1168
  }
1083
1169
  catch (error) {
1084
- return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1170
+ return {
1171
+ ok: false,
1172
+ stdout: "",
1173
+ stderr: error instanceof Error ? error.message : String(error),
1174
+ failureCategory: "invalid-output",
1175
+ durationMs: Date.now() - started,
1176
+ };
1085
1177
  }
1086
1178
  }
1087
1179
  async function executeBackendTestPipelineWithWriteGuard(input, meta) {
@@ -1192,9 +1284,21 @@ async function executeFrontendVerificationBundle(input, meta) {
1192
1284
  const lintBlocked = lintAssessment?.status === "failed" ||
1193
1285
  lintAssessment?.status === "unavailable";
1194
1286
  const groups = [
1195
- { name: "mock", commands: bundle.mockCommands, labels: bundle.mockEvidence?.commandLabels ?? [] },
1196
- { name: "static", commands: bundle.staticCommands, labels: bundle.staticEvidence.commandLabels },
1197
- { name: "behavior", commands: bundle.behaviorCommands, labels: bundle.behaviorEvidence.commandLabels },
1287
+ {
1288
+ name: "mock",
1289
+ commands: bundle.mockCommands,
1290
+ labels: bundle.mockEvidence?.commandLabels ?? [],
1291
+ },
1292
+ {
1293
+ name: "static",
1294
+ commands: bundle.staticCommands,
1295
+ labels: bundle.staticEvidence.commandLabels,
1296
+ },
1297
+ {
1298
+ name: "behavior",
1299
+ commands: bundle.behaviorCommands,
1300
+ labels: bundle.behaviorEvidence.commandLabels,
1301
+ },
1198
1302
  ];
1199
1303
  const successfulLabels = new Map();
1200
1304
  for (const group of lintBlocked ? [] : groups) {
@@ -1316,9 +1420,7 @@ async function executeFrontendVerificationBundle(input, meta) {
1316
1420
  const failureFacts = [];
1317
1421
  if (firstFailure || lintSyntheticFailure) {
1318
1422
  const failureStdout = firstFailure
1319
- ? [firstFailure.command, firstFailure.stdout]
1320
- .filter(Boolean)
1321
- .join("\n")
1423
+ ? [firstFailure.command, firstFailure.stdout].filter(Boolean).join("\n")
1322
1424
  : "";
1323
1425
  failureFacts.push({
1324
1426
  nodeId: input.task.id,
@@ -1374,40 +1476,93 @@ async function executeFrontendTestCaseChecklist(input, meta) {
1374
1476
  const started = Date.now();
1375
1477
  try {
1376
1478
  const declaredAcIds = (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
1377
- const result = await validateFrontendCaseChecklist({ workspaceRoot: input.cwd, declaredAcIds });
1479
+ const result = await validateFrontendCaseChecklist({
1480
+ workspaceRoot: input.cwd,
1481
+ declaredAcIds,
1482
+ });
1378
1483
  if (result.issues.length) {
1379
- return { ok: false, stdout: "", stderr: `frontend-test checklist blocked: ${JSON.stringify(result.issues)}`, failureCategory: "invalid-output", durationMs: Date.now() - started };
1484
+ return {
1485
+ ok: false,
1486
+ stdout: "",
1487
+ stderr: `frontend-test checklist blocked: ${JSON.stringify(result.issues)}`,
1488
+ failureCategory: "invalid-output",
1489
+ durationMs: Date.now() - started,
1490
+ };
1380
1491
  }
1381
- return { ok: true, stdout: `frontend-test checklist ok cases=${result.caseCount} source=${path.basename(result.manifestPath)}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1492
+ return {
1493
+ ok: true,
1494
+ stdout: `frontend-test checklist ok cases=${result.caseCount} source=${path.basename(result.manifestPath)}`,
1495
+ stderr: "",
1496
+ failureCategory: "success",
1497
+ durationMs: Date.now() - started,
1498
+ };
1382
1499
  }
1383
1500
  catch (error) {
1384
- return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1501
+ return {
1502
+ ok: false,
1503
+ stdout: "",
1504
+ stderr: error instanceof Error ? error.message : String(error),
1505
+ failureCategory: "invalid-output",
1506
+ durationMs: Date.now() - started,
1507
+ };
1385
1508
  }
1386
1509
  }
1387
1510
  async function executeFrontendTestL5Report(input, meta) {
1388
1511
  const started = Date.now();
1389
1512
  try {
1390
- const output = await renderFrontendTestL5Report({ workspaceRoot: input.cwd, runDir: meta.runDir });
1391
- return { ok: true, stdout: `Frontend L-5 report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nStatus: ${output.metrics.status}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1513
+ const output = await renderFrontendTestL5Report({
1514
+ workspaceRoot: input.cwd,
1515
+ runDir: meta.runDir,
1516
+ });
1517
+ return {
1518
+ ok: true,
1519
+ stdout: `Frontend L-5 report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nStatus: ${output.metrics.status}`,
1520
+ stderr: "",
1521
+ failureCategory: "success",
1522
+ durationMs: Date.now() - started,
1523
+ };
1392
1524
  }
1393
1525
  catch (error) {
1394
- return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1526
+ return {
1527
+ ok: false,
1528
+ stdout: "",
1529
+ stderr: error instanceof Error ? error.message : String(error),
1530
+ failureCategory: "invalid-output",
1531
+ durationMs: Date.now() - started,
1532
+ };
1395
1533
  }
1396
1534
  }
1397
1535
  async function executeFrontendTestHtmlReport(input, meta) {
1398
1536
  const started = Date.now();
1399
1537
  try {
1400
- const output = await renderFrontendTestHtmlReport({ workspaceRoot: input.cwd, runDir: meta.runDir });
1401
- return { ok: true, stdout: `Frontend test report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nOutcome: ${output.outcome}\nCases: ${output.caseCount}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1538
+ const output = await renderFrontendTestHtmlReport({
1539
+ workspaceRoot: input.cwd,
1540
+ runDir: meta.runDir,
1541
+ });
1542
+ return {
1543
+ ok: true,
1544
+ stdout: `Frontend test report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nOutcome: ${output.outcome}\nCases: ${output.caseCount}`,
1545
+ stderr: "",
1546
+ failureCategory: "success",
1547
+ durationMs: Date.now() - started,
1548
+ };
1402
1549
  }
1403
1550
  catch (error) {
1404
- return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1551
+ return {
1552
+ ok: false,
1553
+ stdout: "",
1554
+ stderr: error instanceof Error ? error.message : String(error),
1555
+ failureCategory: "invalid-output",
1556
+ durationMs: Date.now() - started,
1557
+ };
1405
1558
  }
1406
1559
  }
1407
1560
  async function executeFrontendTestEvidenceValidation(input) {
1408
1561
  const started = Date.now();
1409
1562
  try {
1410
- const result = await validateFrontendCaseEvidence({ workspaceRoot: input.cwd });
1563
+ const result = await validateFrontendCaseEvidence({
1564
+ workspaceRoot: input.cwd,
1565
+ });
1411
1566
  const output = `frontend case evidence validation cases=${result.cases} findings=${result.issues.length}${result.issues.length ? ` issues=${JSON.stringify(result.issues)}` : ""}`;
1412
1567
  if (result.hardFail) {
1413
1568
  return {
@@ -1510,10 +1665,22 @@ export async function executeDagShellNode(input, meta) {
1510
1665
  workspaceRoot: input.cwd,
1511
1666
  repoRoot: input.cwd,
1512
1667
  });
1513
- return { ok: true, stdout: formatFrontendPrewriteGateStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1668
+ return {
1669
+ ok: true,
1670
+ stdout: formatFrontendPrewriteGateStdout(result),
1671
+ stderr: "",
1672
+ failureCategory: "success",
1673
+ durationMs: Date.now() - started,
1674
+ };
1514
1675
  }
1515
1676
  catch (error) {
1516
- return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1677
+ return {
1678
+ ok: false,
1679
+ stdout: "",
1680
+ stderr: error instanceof Error ? error.message : String(error),
1681
+ failureCategory: "invalid-output",
1682
+ durationMs: Date.now() - started,
1683
+ };
1517
1684
  }
1518
1685
  }
1519
1686
  if (shell?.frontendLintBaseline) {
@@ -1530,10 +1697,22 @@ export async function executeDagShellNode(input, meta) {
1530
1697
  workspaceRoot: input.cwd,
1531
1698
  requireBaseline: shell.frontendReviewContext.requireBaseline,
1532
1699
  });
1533
- return { ok: true, stdout: formatFrontendReviewContextStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1700
+ return {
1701
+ ok: true,
1702
+ stdout: formatFrontendReviewContextStdout(result),
1703
+ stderr: "",
1704
+ failureCategory: "success",
1705
+ durationMs: Date.now() - started,
1706
+ };
1534
1707
  }
1535
1708
  catch (error) {
1536
- return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1709
+ return {
1710
+ ok: false,
1711
+ stdout: "",
1712
+ stderr: error instanceof Error ? error.message : String(error),
1713
+ failureCategory: "invalid-output",
1714
+ durationMs: Date.now() - started,
1715
+ };
1537
1716
  }
1538
1717
  }
1539
1718
  if (shell?.frontendTestCaseChecklist) {
@@ -1873,8 +2052,10 @@ export async function executeDagShellNode(input, meta) {
1873
2052
  }
1874
2053
  const cwd = resolveShellCwd(input.cwd, shell.cwd);
1875
2054
  const timeoutMs = shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
2055
+ const failFast = shell.failFast !== false;
1876
2056
  const results = [];
1877
2057
  let beforeStatus;
2058
+ let workspaceFingerprint;
1878
2059
  try {
1879
2060
  beforeStatus = await readGitStatusPorcelain(input.cwd);
1880
2061
  }
@@ -1882,8 +2063,78 @@ export async function executeDagShellNode(input, meta) {
1882
2063
  // Non-git cwd: skip write guard (documented limitation).
1883
2064
  beforeStatus = undefined;
1884
2065
  }
2066
+ const reuseEligible = shouldReuseSuccessfulShellCommands(shell);
2067
+ let commandContractKey;
2068
+ let cache;
2069
+ if (reuseEligible) {
2070
+ try {
2071
+ const governanceTaskId = meta.spec.sourceBinding?.taskId?.trim();
2072
+ const checkpoint = await captureWorkspaceCheckpoint(input.cwd, {
2073
+ ...(governanceTaskId ? { taskId: governanceTaskId } : {}),
2074
+ });
2075
+ workspaceFingerprint = checkpoint.fingerprint;
2076
+ }
2077
+ catch {
2078
+ // Fingerprint unavailable: do not create cache / do not reuse.
2079
+ workspaceFingerprint = undefined;
2080
+ }
2081
+ commandContractKey = buildShellCommandContractKey({
2082
+ commands,
2083
+ cwd,
2084
+ timeoutMs,
2085
+ envAllowlist: shell.envAllowlist,
2086
+ failFast,
2087
+ nonZeroExitPolicy: shell.nonZeroExitPolicy,
2088
+ });
2089
+ const cacheKey = shellCommandCacheKey(meta.runId, input.task.id);
2090
+ const existing = sameRunShellCommandCaches.get(cacheKey);
2091
+ if (existing &&
2092
+ existing.runId === meta.runId &&
2093
+ existing.nodeId === input.task.id &&
2094
+ existing.commandContractKey === commandContractKey &&
2095
+ workspaceFingerprint &&
2096
+ existing.workspaceFingerprint === workspaceFingerprint) {
2097
+ cache = existing;
2098
+ }
2099
+ else {
2100
+ sameRunShellCommandCaches.delete(cacheKey);
2101
+ if (workspaceFingerprint) {
2102
+ cache = {
2103
+ runId: meta.runId,
2104
+ nodeId: input.task.id,
2105
+ commandContractKey,
2106
+ workspaceFingerprint,
2107
+ byCommand: new Map(),
2108
+ };
2109
+ sameRunShellCommandCaches.set(cacheKey, cache);
2110
+ }
2111
+ }
2112
+ }
1885
2113
  for (const command of commands) {
1886
2114
  const commandNumber = results.length + 1;
2115
+ // Cache each contractual occurrence independently. A result written for an
2116
+ // earlier duplicate in this round must not satisfy a later occurrence.
2117
+ const commandCacheKey = `${commandNumber}:${command}`;
2118
+ const cached = cache?.byCommand.get(commandCacheKey);
2119
+ if (cached) {
2120
+ results.push({
2121
+ command,
2122
+ cwd: cached.cwd,
2123
+ durationMs: cached.durationMs,
2124
+ exitCode: cached.exitCode,
2125
+ failureCategory: cached.failureCategory,
2126
+ ok: true,
2127
+ stderr: cached.stderr,
2128
+ stderrBytes: cached.stderrBytes,
2129
+ stderrTruncated: cached.stderrTruncated,
2130
+ stdout: cached.stdout,
2131
+ stdoutBytes: cached.stdoutBytes,
2132
+ stdoutTruncated: cached.stdoutTruncated,
2133
+ timedOut: cached.timedOut,
2134
+ reused: true,
2135
+ });
2136
+ continue;
2137
+ }
1887
2138
  const result = await executeShellCommand({
1888
2139
  command,
1889
2140
  cwd,
@@ -1896,7 +2147,23 @@ export async function executeDagShellNode(input, meta) {
1896
2147
  },
1897
2148
  });
1898
2149
  results.push(result);
1899
- if (!result.ok)
2150
+ if (result.ok && cache) {
2151
+ cache.byCommand.set(commandCacheKey, {
2152
+ ok: true,
2153
+ exitCode: result.exitCode,
2154
+ failureCategory: "success",
2155
+ stdout: result.stdout,
2156
+ stderr: result.stderr,
2157
+ stdoutBytes: result.stdoutBytes,
2158
+ stderrBytes: result.stderrBytes,
2159
+ stdoutTruncated: result.stdoutTruncated,
2160
+ stderrTruncated: result.stderrTruncated,
2161
+ timedOut: result.timedOut,
2162
+ durationMs: result.durationMs,
2163
+ cwd: result.cwd,
2164
+ });
2165
+ }
2166
+ if (!result.ok && failFast)
1900
2167
  break;
1901
2168
  }
1902
2169
  let writeGuardViolations = [];
@@ -1918,17 +2185,49 @@ export async function executeDagShellNode(input, meta) {
1918
2185
  ];
1919
2186
  }
1920
2187
  }
2188
+ // Write-guard or workspace mutation invalidates same-run success reuse.
2189
+ if (!writeGuardOk && reuseEligible) {
2190
+ sameRunShellCommandCaches.delete(shellCommandCacheKey(meta.runId, input.task.id));
2191
+ }
1921
2192
  const commandsOk = results.every((result) => result.ok);
1922
2193
  const recordNonZero = shell.nonZeroExitPolicy === "record";
1923
2194
  const ok = (commandsOk || recordNonZero) && writeGuardOk;
1924
2195
  const firstFailure = results.find((result) => !result.ok);
1925
- await writeDagNodeTextArtifact(meta.runDir, input.task.id, "result.summary.md", buildShellResultSummaryMarkdown({
2196
+ const receipt = {
2197
+ schemaVersion: 1,
2198
+ runId: meta.runId,
2199
+ nodeId: input.task.id,
2200
+ workspaceFingerprint: workspaceFingerprint ?? null,
2201
+ failFast,
2202
+ commands: results.map((result) => ({
2203
+ command: result.command,
2204
+ ok: result.ok,
2205
+ exitCode: result.exitCode,
2206
+ failureCategory: result.failureCategory,
2207
+ reused: result.reused === true,
2208
+ })),
2209
+ };
2210
+ await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "shell-command-receipt.json", receipt);
2211
+ const summaryMarkdown = buildShellResultSummaryMarkdown({
1926
2212
  nodeId: input.task.id,
1927
2213
  runId: meta.runId,
1928
2214
  rootCwd: input.cwd,
1929
2215
  results,
1930
2216
  writeGuardViolations: writeGuardOk ? undefined : writeGuardViolations,
1931
- }));
2217
+ });
2218
+ const receiptMarkdown = [
2219
+ summaryMarkdown,
2220
+ "",
2221
+ "## Same-run reuse receipt",
2222
+ "",
2223
+ `| workspaceFingerprint | ${workspaceFingerprint ?? "(unavailable)"} |`,
2224
+ `| failFast | ${failFast} |`,
2225
+ "",
2226
+ "| # | ok | exitCode | reused | command |",
2227
+ "|---|----|----------|--------|---------|",
2228
+ ...results.map((result, index) => `| ${index + 1} | ${result.ok} | ${result.exitCode ?? "null"} | ${result.reused === true} | ${result.command.replace(/\|/g, "\\|")} |`),
2229
+ ].join("\n");
2230
+ await writeDagNodeTextArtifact(meta.runDir, input.task.id, "result.summary.md", receiptMarkdown);
1932
2231
  const aggregate = summarizeCommandResults(results);
1933
2232
  const stderrParts = [aggregate.stderr];
1934
2233
  if (!writeGuardOk) {
@@ -1946,13 +2245,17 @@ export async function executeDagShellNode(input, meta) {
1946
2245
  exitCode: result.exitCode,
1947
2246
  failureCategory: result.failureCategory,
1948
2247
  command: result.command,
2248
+ reused: result.reused === true,
1949
2249
  }));
1950
2250
  const recordPrefix = recordNonZero && !commandsOk
1951
2251
  ? `nonZeroExitPolicy=record exitCode=${firstFailure?.exitCode ?? "null"}\n`
1952
2252
  : "";
2253
+ const reuseNote = results.some((result) => result.reused)
2254
+ ? `same-run-reuse workspaceFingerprint=${workspaceFingerprint ?? "unknown"}\n`
2255
+ : "";
1953
2256
  return {
1954
2257
  ok,
1955
- stdout: `${recordPrefix}${aggregate.stdout}`,
2258
+ stdout: `${recordPrefix}${reuseNote}${aggregate.stdout}`,
1956
2259
  stderr: stderrParts.filter(Boolean).join("\n\n"),
1957
2260
  failureCategory,
1958
2261
  durationMs: Date.now() - started,