@tea-agent/loop-agent 0.24.11-beta.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +28 -5
  2. package/dist/commands/init.js +16 -2
  3. package/dist/executors/dag-pi-executor.js +74 -13
  4. package/dist/executors/pi-executor.js +25 -7
  5. package/dist/executors/pi-prompt-transport.js +198 -0
  6. package/dist/executors/pi-sdk-executor.js +1 -1
  7. package/dist/executors/process-tree.js +33 -0
  8. package/dist/executors/shell-executor.js +332 -24
  9. package/dist/executors/shell-verification.js +4 -2
  10. package/dist/infrastructure/harness/task-store.js +31 -0
  11. package/dist/shared/operator/capabilities.js +6 -0
  12. package/dist/verification/maven/cache.js +142 -0
  13. package/dist/verification/maven/index.js +120 -0
  14. package/dist/verification/maven/plan-commands.js +421 -0
  15. package/dist/verification/maven/pom-static.js +136 -0
  16. package/dist/verification/maven/scope-resolve.js +153 -0
  17. package/dist/verification/maven/stale.js +130 -0
  18. package/dist/verification/maven/types.js +23 -0
  19. package/dist/verification/maven/workspace-graph.js +322 -0
  20. package/dist/worker/cli.js +236 -21
  21. package/dist/worker/delivery/final-verification.js +12 -0
  22. package/dist/worker/delivery/git-transaction.js +32 -7
  23. package/dist/worker/delivery/package.js +9 -5
  24. package/dist/worker/delivery/verification-bundle.js +26 -5
  25. package/dist/worker/feature/advance.js +301 -0
  26. package/dist/worker/feature/doctor.js +223 -0
  27. package/dist/worker/feature/next-action.js +11 -3
  28. package/dist/worker/feature/scaffold.js +798 -0
  29. package/dist/worker/observe/dag-run-artifacts.js +90 -0
  30. package/dist/worker/observe/node-input.js +444 -0
  31. package/dist/worker/observe/routes.js +17 -0
  32. package/dist/worker/observe/static/api.js +9 -0
  33. package/dist/worker/observe/static/constants.js +9 -0
  34. package/dist/worker/observe/static/state.js +14 -0
  35. package/dist/worker/observe/static/styles.css +74 -0
  36. package/dist/worker/observe/static/views/dag-inspector.js +371 -15
  37. package/dist/worker/outcomes/projector.js +5 -1
  38. package/dist/worker/runner/run-ready.js +41 -1
  39. package/dist/workflows/dag/backend-test-markdown-workflow.js +202 -9
  40. package/dist/workflows/dag/backend-test-result-contract.js +103 -0
  41. package/dist/workflows/dag/failure-category.js +5 -1
  42. package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
  43. package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
  44. package/dist/workflows/dag/frontend-test-result-contract.js +106 -41
  45. package/dist/workflows/dag/init-hybrid.js +220 -32
  46. package/dist/workflows/dag/node-execution.js +3 -2
  47. package/dist/workflows/dag/reconcile-run.js +24 -0
  48. package/dist/workflows/dag/types.js +46 -0
  49. package/dist/workflows/dag/validate.js +19 -2
  50. package/docs/architecture/dag-execution.md +5 -1
  51. package/docs/architecture/runtime-boundaries.md +11 -1
  52. package/docs/architecture/worker-and-feature.md +2 -0
  53. package/docs/templates/backend-test-dag.json +3 -3
  54. package/docs/templates/product-line/README.md +17 -1
  55. package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
  56. package/docs/templates/product-line/scaffold-samples/README.md +36 -0
  57. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
  58. package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
  59. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
  60. package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
  61. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
  62. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
  63. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
  64. package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
  65. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
  66. package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
  67. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
  68. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
  69. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
  70. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
  71. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
  72. package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
  73. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
  74. package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
  75. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
  76. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
  77. package/examples/l5-report-coms-process-definition.html +322 -0
  78. package/package.json +1 -1
  79. package/skills/agent-worker/references/agent-worker-operator.md +84 -0
  80. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  81. package/skills/loop-agent/SKILL.md +8 -8
  82. package/skills/loop-agent/references/command-reference.md +14 -2
  83. package/skills/loop-agent/references/harness-policy.md +22 -0
  84. package/skills/loop-agent/references/task-workflow.md +5 -0
@@ -1,14 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { writeDagNodeTextArtifact } from "../infrastructure/harness/artifact-store.js";
6
6
  import { truncateOutput } from "../shared/output-truncation.js";
7
+ import { processTreeSpawnOptions, terminateProcessTree } from "./process-tree.js";
7
8
  import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdictGateShellCommand, } from "./shell-presets.js";
8
9
  import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
9
10
  import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
10
11
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
11
- import { materializeFrontendTestResult, } from "../workflows/dag/frontend-test-result-contract.js";
12
+ import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
12
13
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
13
14
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
14
15
  import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
@@ -17,14 +18,16 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
17
18
  import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
18
19
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
19
20
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
20
- import { materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
21
- import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
21
+ import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
22
+ import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
23
+ import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
22
24
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
23
25
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
24
26
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
25
27
  import { buildShellProcessEnv } from "./shell-verification.js";
26
28
  import { readRunState } from "../workflows/dag/run-store.js";
27
29
  import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
30
+ import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
28
31
  const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
29
32
  const SUMMARY_STDOUT_MAX = 4_000;
30
33
  const SUMMARY_STDERR_MAX = 2_000;
@@ -46,6 +49,54 @@ function resolveBashExecutable() {
46
49
  envCandidate ??
47
50
  "bash");
48
51
  }
52
+ /** POSIX-style runDir for bash `${HARNESS_DAG_RUN_DIR}` expansions on Windows. */
53
+ function normalizeHarnessDagRunDir(runDir) {
54
+ return runDir.replaceAll(path.sep, "/");
55
+ }
56
+ const BACKEND_TEST_EXECUTE_DIAGNOSTICS_REL = "reports/backend-test-execute-diagnostics.md";
57
+ /**
58
+ * Write diagnostics *before* any bash spawn so Windows STATUS_DLL_INIT_FAILED
59
+ * (0xC0000142) / empty-shell-output failures still leave an auditable file.
60
+ * Shell steps append under `## shell-steps`; they must not overwrite this head.
61
+ */
62
+ async function writeBackendTestExecuteDiagnosticsPreSpawn(input) {
63
+ const harnessDagRunDir = normalizeHarnessDagRunDir(input.runDir);
64
+ if (!harnessDagRunDir.trim()) {
65
+ throw new Error("HARNESS_DAG_RUN_DIR would be empty; refusing backend-test execute (would write under /reports)");
66
+ }
67
+ const reportsDir = path.join(input.runDir, "reports");
68
+ await mkdir(reportsDir, { recursive: true });
69
+ const diagnosticsPath = path.join(reportsDir, "backend-test-execute-diagnostics.md");
70
+ const lines = [
71
+ "# Backend Test Execute Diagnostics",
72
+ "",
73
+ "## pre-spawn",
74
+ "",
75
+ `- phase: pre-spawn`,
76
+ `- startedAt: ${new Date().toISOString()}`,
77
+ `- cwd: ${input.cwd}`,
78
+ `- runId: ${input.runId}`,
79
+ `- runDir: ${input.runDir}`,
80
+ `- HARNESS_DAG_RUN_DIR: ${harnessDagRunDir}`,
81
+ `- bashExecutable: ${resolveBashExecutable()}`,
82
+ `- platform: ${process.platform}`,
83
+ `- mappedScriptCount: ${input.mappedScripts.length}`,
84
+ "- mappedScripts:",
85
+ ...(input.mappedScripts.length > 0
86
+ ? input.mappedScripts.map((script) => ` - ${script}`)
87
+ : [" - <none>"]),
88
+ `- commandPlanCount: ${input.commandPlan.length}`,
89
+ "- commandPlan:",
90
+ ...input.commandPlan.map((label, index) => ` - ${index + 1}. ${label}`),
91
+ "",
92
+ "## shell-steps",
93
+ "",
94
+ "_Shell commands append below. If this section stays empty, bash never ran successfully._",
95
+ "",
96
+ ];
97
+ await writeFile(diagnosticsPath, `${lines.join("\n")}\n`, "utf8");
98
+ return { diagnosticsPath, harnessDagRunDir };
99
+ }
49
100
  function isWithinRoot(root, candidate) {
50
101
  const relative = path.relative(root, candidate);
51
102
  return (relative === "" ||
@@ -114,6 +165,7 @@ export async function executeShellCommand(input) {
114
165
  const child = spawn(resolveBashExecutable(), ["-c", input.command], {
115
166
  cwd: input.cwd,
116
167
  env: buildShellProcessEnv(input.envAllowlist, injectedEnv),
168
+ ...processTreeSpawnOptions(),
117
169
  stdio: ["ignore", "pipe", "pipe"],
118
170
  });
119
171
  let stdout = createBoundedOutput();
@@ -187,10 +239,10 @@ export async function executeShellCommand(input) {
187
239
  if (input.timeoutMs > 0) {
188
240
  timeoutHandle = setTimeout(() => {
189
241
  timedOut = true;
190
- child.kill("SIGTERM");
242
+ terminateProcessTree(child, "SIGTERM");
191
243
  sigkillHandle = setTimeout(() => {
192
244
  if (child.exitCode === null) {
193
- child.kill("SIGKILL");
245
+ terminateProcessTree(child, "SIGKILL");
194
246
  }
195
247
  }, 5_000);
196
248
  }, input.timeoutMs);
@@ -385,35 +437,164 @@ async function executeBackendTestPipeline(input, meta) {
385
437
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
386
438
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
387
439
  const pytestTargets = mappedScripts.map(shellQuote).join(" ");
388
- const pytestCommand = [
440
+ // Split into short bash -c commands (aligned with markdown-environment).
441
+ // A single ultra-long compound command has been observed on Windows to
442
+ // exit 0xC0000142 (STATUS_DLL_INIT_FAILED) in ~40ms with empty stdout/
443
+ // stderr and no diagnostics file. Keep pytest exactly once.
444
+ const commandPlan = [
445
+ "shell-bootstrap",
446
+ "python-identity",
447
+ "pytest-version",
448
+ "pytest-run",
449
+ ];
450
+ let preSpawnOk = false;
451
+ try {
452
+ await writeBackendTestExecuteDiagnosticsPreSpawn({
453
+ runDir: meta.runDir,
454
+ runId: meta.runId,
455
+ cwd: input.cwd,
456
+ mappedScripts,
457
+ commandPlan: [...commandPlan],
458
+ });
459
+ preSpawnOk = true;
460
+ }
461
+ catch (error) {
462
+ const message = error instanceof Error ? error.message : String(error);
463
+ return {
464
+ ok: false,
465
+ stdout: "",
466
+ stderr: [
467
+ "backend-test markdown-execute-html shell failed",
468
+ "failureCategory=invalid-output",
469
+ `mappedScripts=${mappedScripts.join(",") || "<none>"}`,
470
+ `preSpawnDiagnostics=failed`,
471
+ `diagnostics=${BACKEND_TEST_EXECUTE_DIAGNOSTICS_REL}`,
472
+ message,
473
+ ].join("\n"),
474
+ failureCategory: "invalid-output",
475
+ durationMs: Date.now() - started,
476
+ };
477
+ }
478
+ // Append-only shell snippets; never overwrite the JS pre-spawn head.
479
+ const shellBootstrapCommand = [
480
+ 'if [ -z "${HARNESS_DAG_RUN_DIR:-}" ]; then echo "HARNESS_DAG_RUN_DIR is empty; refusing to write diagnostics under /reports" >&2; exit 2; fi',
389
481
  'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
482
+ 'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
483
+ 'echo "STEP=shell-bootstrap"',
484
+ 'echo "- shellBootstrapAt: $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" >> "${DIAG_FILE}"',
485
+ 'echo "- shellPwd: $(pwd)" >> "${DIAG_FILE}"',
486
+ 'echo "- shellHARNESS_DAG_RUN_DIR: ${HARNESS_DAG_RUN_DIR}" >> "${DIAG_FILE}"',
487
+ 'echo "- shell: ${BASH:-bash} (${BASH_VERSION:-unknown})" >> "${DIAG_FILE}"',
488
+ ].join("; ");
489
+ const pythonIdentityCommand = [
490
+ 'if [ -z "${HARNESS_DAG_RUN_DIR:-}" ]; then echo "HARNESS_DAG_RUN_DIR is empty" >&2; exit 2; fi',
491
+ 'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
492
+ 'echo "STEP=resolve-python"',
390
493
  'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
391
- 'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" >&2; exit 127; fi',
392
- // Run pytest exactly once and emit the native pytest-html
393
- // self-contained report. The HTML/facts renderers read per-case
394
- // captured stdout (HTTP_REQUEST/HTTP_RESPONSE) directly from the
395
- // pytest-html data-jsonblob island, so a JUnit XML report is no
396
- // longer generated.
397
- `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 "\${PYTHON_BIN}" -m pytest ${pytestTargets} -v -p no:cacheprovider --html="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" --self-contained-html`,
494
+ 'echo "- command_v_python: $(command -v python || true)" >> "${DIAG_FILE}"',
495
+ 'echo "- command_v_python3: $(command -v python3 || true)" >> "${DIAG_FILE}"',
496
+ 'echo "- PYTHON_BIN: ${PYTHON_BIN:-<empty>}" >> "${DIAG_FILE}"',
497
+ 'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
498
+ 'echo "STEP=python-identity"',
499
+ 'if ! PYTHONUNBUFFERED=1 "${PYTHON_BIN}" -c "import sys; print(sys.executable); print(sys.version.splitlines()[0])" >> "${DIAG_FILE}" 2>&1; then echo "STEP=python-identity-failed" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
500
+ ].join("; ");
501
+ const pytestVersionCommand = [
502
+ 'if [ -z "${HARNESS_DAG_RUN_DIR:-}" ]; then echo "HARNESS_DAG_RUN_DIR is empty" >&2; exit 2; fi',
503
+ 'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
504
+ 'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
505
+ 'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
506
+ 'echo "STEP=pytest-version"',
507
+ 'if ! PYTHONUNBUFFERED=1 "${PYTHON_BIN}" -m pytest --version >> "${DIAG_FILE}" 2>&1; then echo "STEP=pytest-version-failed" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
508
+ ].join("; ");
509
+ // Single pytest invocation; exit 0/1 + non-empty html are reportable.
510
+ const pytestRunCommand = [
511
+ 'if [ -z "${HARNESS_DAG_RUN_DIR:-}" ]; then echo "HARNESS_DAG_RUN_DIR is empty" >&2; exit 2; fi',
512
+ 'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
513
+ 'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
514
+ 'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
515
+ 'echo "STEP=pytest-run"',
516
+ `echo "- pytestCommand: python -m pytest ${mappedScripts.join(" ")} -v -p no:cacheprovider --html=reports/backend-test.html --self-contained-html" >> "\${DIAG_FILE}"`,
517
+ `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 "\${PYTHON_BIN}" -m pytest ${pytestTargets} -v -p no:cacheprovider --html="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" --self-contained-html`,
398
518
  "STATUS=$?",
399
519
  'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
400
- // exit 0 (all pass) or 1 (assertion failures) with a valid
401
- // pytest-html report are reportable; collection errors / crashes
402
- // (exit >= 2) or a missing report surface as a pipeline error below.
520
+ 'echo "- pytestExitCode: ${STATUS}" >> "${DIAG_FILE}"',
521
+ 'if [ -f "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" ]; then echo "- pytestHtml: present ($(wc -c < "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" | tr -d " ") bytes)" >> "${DIAG_FILE}"; else echo "- pytestHtml: missing" >> "${DIAG_FILE}"; fi',
522
+ 'echo "- finishedAt: $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" >> "${DIAG_FILE}"',
523
+ 'echo "STEP=pytest-finished status=${STATUS}"',
403
524
  'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" ]; then exit 0; fi',
525
+ 'echo "STEP=pipeline-failed status=${STATUS} html=$([ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" ] && echo present || echo missing)" >&2',
404
526
  'exit "${STATUS}"',
405
527
  ].join("; ");
406
- const results = await executePipelineCommands(input, meta, [pytestCommand]);
528
+ const pipelineCommands = [
529
+ shellBootstrapCommand,
530
+ pythonIdentityCommand,
531
+ pytestVersionCommand,
532
+ pytestRunCommand,
533
+ ];
534
+ const results = await executePipelineCommands(input, meta, pipelineCommands);
407
535
  if (!results.every((result) => result.ok)) {
408
- const failure = results.find((result) => !result.ok);
409
- return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: failure.stderr, failureCategory: failure.failureCategory, durationMs: Date.now() - started };
536
+ const failureIndex = results.findIndex((result) => !result.ok);
537
+ const failure = results[failureIndex];
538
+ const combinedStdout = results.map((result) => result.stdout).join("\n");
539
+ let diagnosticsText = "";
540
+ try {
541
+ diagnosticsText = await readFile(path.join(meta.runDir, "reports", "backend-test-execute-diagnostics.md"), "utf8");
542
+ }
543
+ catch {
544
+ diagnosticsText = "";
545
+ }
546
+ const failedStep = failureIndex >= 0 && failureIndex < commandPlan.length
547
+ ? commandPlan[failureIndex]
548
+ : "unknown";
549
+ const stderrSummary = [
550
+ "backend-test markdown-execute-html shell failed",
551
+ `failureCategory=${failure.failureCategory}`,
552
+ `exitCode=${failure.exitCode ?? "null"}`,
553
+ `durationMs=${failure.durationMs}`,
554
+ `failedCommandIndex=${failureIndex + 1}`,
555
+ `failedStep=${failedStep}`,
556
+ `commandPlanCount=${pipelineCommands.length}`,
557
+ `preSpawnDiagnostics=${preSpawnOk ? "written" : "failed"}`,
558
+ `mappedScripts=${mappedScripts.join(",") || "<none>"}`,
559
+ `shellStdoutEmpty=${combinedStdout.trim().length === 0}`,
560
+ `shellStderrEmpty=${failure.stderr.trim().length === 0}`,
561
+ failure.stdoutArtifactPath
562
+ ? `commandStdoutArtifact=${failure.stdoutArtifactPath}`
563
+ : undefined,
564
+ failure.stderrArtifactPath
565
+ ? `commandStderrArtifact=${failure.stderrArtifactPath}`
566
+ : undefined,
567
+ `diagnostics=${BACKEND_TEST_EXECUTE_DIAGNOSTICS_REL}`,
568
+ failure.stderr.trim() || "(shell stderr empty)",
569
+ diagnosticsText.trim()
570
+ ? `--- diagnostics ---\n${diagnosticsText.trim()}`
571
+ : "(diagnostics file missing or empty)",
572
+ ]
573
+ .filter((line) => Boolean(line))
574
+ .join("\n");
575
+ return {
576
+ ok: false,
577
+ stdout: combinedStdout,
578
+ stderr: stderrSummary,
579
+ failureCategory: failure.failureCategory,
580
+ durationMs: Date.now() - started,
581
+ };
410
582
  }
411
583
  const reportsDir = path.join(meta.runDir, "reports");
412
584
  const pytestHtmlContent = await readFile(path.join(reportsDir, "backend-test.html"), "utf8");
413
585
  const pytestExitCode = Number.parseInt((await readFile(path.join(reportsDir, "backend-test-pytest-exit.txt"), "utf8")).trim(), 10);
414
- if (![0, 1].includes(pytestExitCode))
586
+ if (![0, 1].includes(pytestExitCode)) {
415
587
  throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
588
+ }
416
589
  const parsed = parsePytestHtmlReport(pytestHtmlContent);
590
+ // Bind Result v1 from the native pytest-html report BEFORE overwriting with the
591
+ // styled renderer (which drops the data-jsonblob island).
592
+ const resultArtifact = await materializeBackendTestResultFromPytestHtml({
593
+ runDir: meta.runDir,
594
+ htmlRelativePath: "reports/backend-test.html",
595
+ htmlContent: pytestHtmlContent,
596
+ pytestExitCode,
597
+ });
417
598
  const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
418
599
  const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
419
600
  const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
@@ -426,11 +607,52 @@ async function executeBackendTestPipeline(input, meta) {
426
607
  traceabilitySummary,
427
608
  });
428
609
  const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
429
- const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, htmlRelativePath: "reports/backend-test.html", htmlContent, caseValidationSummary, traceabilitySummary });
610
+ const facts = renderBackendTestFacts({
611
+ parsed,
612
+ cases,
613
+ pytestExitCode,
614
+ htmlRelativePath: "reports/backend-test.html",
615
+ htmlContent,
616
+ caseValidationSummary,
617
+ traceabilitySummary,
618
+ });
430
619
  const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
431
620
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
621
+ // Deterministic L-5 dashboard: machine-computed metrics (not Pi-generated).
622
+ // manifest is produced by an upstream finalize node; tolerate its absence
623
+ // so a minimal DAG without manifest still gets a degraded dashboard.
624
+ let manifestForL5 = {};
625
+ try {
626
+ const manifestPath = path.join(meta.runDir, "contracts", "backend-test-case-manifest.json");
627
+ manifestForL5 = JSON.parse(await readFile(manifestPath, "utf8"));
628
+ }
629
+ catch {
630
+ // manifest missing: L5 AC/automation metrics degrade to unavailable.
631
+ }
632
+ const l5Metrics = computeL5ReportMetrics({
633
+ result: { passed: parsed.passed, failed: parsed.failed, error: parsed.errors, skipped: parsed.skipped },
634
+ manifest: manifestForL5,
635
+ coverage: null,
636
+ criticalRiskCount: parsed.failed + parsed.errors > 0 ? 1 : 0,
637
+ });
638
+ const failureSummaries = parsed.cases
639
+ .filter((c) => c.status !== "passed")
640
+ .map((c) => {
641
+ const id = c.name.match(/BE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}/)?.[0] ?? c.name;
642
+ return { caseId: id, title: c.name, message: c.message ?? c.status, script: `${c.classname.split("::")[0] ?? c.classname}` };
643
+ });
644
+ const l5Html = renderBackendTestL5Dashboard({
645
+ title: meta.spec.title,
646
+ objective: meta.spec.objective,
647
+ parsed,
648
+ metrics: l5Metrics,
649
+ caseValidationSummary,
650
+ traceabilitySummary,
651
+ failures: failureSummaries.length > 0 ? failureSummaries : undefined,
652
+ });
653
+ const l5Path = await writeRunReport(meta.runDir, "backend-test-l5-dashboard.html", l5Html);
432
654
  const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
433
- outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, facts);
655
+ outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `l5-dashboard=${l5Path}`, `result=${resultArtifact.path}`, facts);
434
656
  }
435
657
  else if (pipeline === "contracts") {
436
658
  const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
@@ -889,6 +1111,38 @@ async function executeFrontendVerificationBundle(input, meta) {
889
1111
  };
890
1112
  }
891
1113
  }
1114
+ async function executeFrontendTestEvidenceValidation(input) {
1115
+ const started = Date.now();
1116
+ try {
1117
+ const result = await validateFrontendCaseEvidence({ workspaceRoot: input.cwd });
1118
+ const output = `frontend case evidence validation cases=${result.cases} findings=${result.issues.length}${result.issues.length ? ` issues=${JSON.stringify(result.issues)}` : ""}`;
1119
+ if (result.hardFail) {
1120
+ return {
1121
+ ok: false,
1122
+ stdout: "",
1123
+ stderr: `frontend-test evidence hard-fail: ${JSON.stringify(result.issues)}`,
1124
+ failureCategory: "nonzero-exit",
1125
+ durationMs: Date.now() - started,
1126
+ };
1127
+ }
1128
+ return {
1129
+ ok: true,
1130
+ stdout: output,
1131
+ stderr: "",
1132
+ failureCategory: "success",
1133
+ durationMs: Date.now() - started,
1134
+ };
1135
+ }
1136
+ catch (error) {
1137
+ return {
1138
+ ok: false,
1139
+ stdout: "",
1140
+ stderr: error instanceof Error ? error.message : String(error),
1141
+ failureCategory: "invalid-output",
1142
+ durationMs: Date.now() - started,
1143
+ };
1144
+ }
1145
+ }
892
1146
  async function executeFrontendLintBaseline(input, meta) {
893
1147
  const started = Date.now();
894
1148
  const shell = input.task.shell;
@@ -989,6 +1243,9 @@ export async function executeDagShellNode(input, meta) {
989
1243
  return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
990
1244
  }
991
1245
  }
1246
+ if (shell?.frontendTestEvidenceValidation) {
1247
+ return executeFrontendTestEvidenceValidation(input);
1248
+ }
992
1249
  if (shell?.backendTestPipeline) {
993
1250
  return executeBackendTestPipelineWithWriteGuard(input, meta);
994
1251
  }
@@ -1260,10 +1517,61 @@ export async function executeDagShellNode(input, meta) {
1260
1517
  if (!shell || commands.length === 0) {
1261
1518
  throw new Error(`shell task ${input.task.id} requires shell.preset, shell.verdictGate, and/or non-empty shell.commands`);
1262
1519
  }
1520
+ const started = Date.now();
1521
+ if (shell.mavenVerificationPlan) {
1522
+ try {
1523
+ assertMavenPlanFresh(input.cwd, shell.mavenVerificationPlan, { shellCommands: commands });
1524
+ }
1525
+ catch (error) {
1526
+ const message = error instanceof MavenPlanStaleError
1527
+ ? error.message
1528
+ : error instanceof Error
1529
+ ? error.message
1530
+ : String(error);
1531
+ const staleMessage = message.includes("verification-plan-stale")
1532
+ ? message
1533
+ : `verification-plan-stale: ${message}`;
1534
+ const staleResults = commands.map((command) => ({
1535
+ command,
1536
+ cwd: input.cwd,
1537
+ durationMs: 0,
1538
+ exitCode: null,
1539
+ failureCategory: "verification-plan-stale",
1540
+ ok: false,
1541
+ stderr: staleMessage,
1542
+ stderrBytes: Buffer.byteLength(staleMessage),
1543
+ stderrTruncated: false,
1544
+ stdout: "",
1545
+ stdoutBytes: 0,
1546
+ stdoutTruncated: false,
1547
+ timedOut: false,
1548
+ }));
1549
+ await writeDagNodeTextArtifact(meta.runDir, input.task.id, "result.summary.md", buildShellResultSummaryMarkdown({
1550
+ nodeId: input.task.id,
1551
+ runId: meta.runId,
1552
+ rootCwd: input.cwd,
1553
+ results: staleResults,
1554
+ }));
1555
+ return {
1556
+ ok: false,
1557
+ stdout: "",
1558
+ stderr: `${staleMessage}\nRegenerate/validate the DAG before running Maven verification.`,
1559
+ failureCategory: "verification-plan-stale",
1560
+ durationMs: Date.now() - started,
1561
+ ...{
1562
+ commandResults: staleResults.map((result) => ({
1563
+ ok: result.ok,
1564
+ exitCode: result.exitCode,
1565
+ failureCategory: result.failureCategory,
1566
+ command: result.command,
1567
+ })),
1568
+ },
1569
+ };
1570
+ }
1571
+ }
1263
1572
  const cwd = resolveShellCwd(input.cwd, shell.cwd);
1264
1573
  const timeoutMs = shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
1265
1574
  const results = [];
1266
- const started = Date.now();
1267
1575
  let beforeStatus;
1268
1576
  try {
1269
1577
  beforeStatus = await readGitStatusPorcelain(input.cwd);
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { processTreeSpawnOptions, terminateProcessTree } from "./process-tree.js";
2
3
  export const DEFAULT_VERIFY_TIMEOUT_MS = 1_800_000;
3
4
  const VERIFY_ENV_ALLOWLIST = [
4
5
  "HOME",
@@ -142,6 +143,7 @@ export async function executeCommand(command, defaultTimeoutMs = DEFAULT_VERIFY_
142
143
  const child = spawn(binary, args, {
143
144
  cwd: command.cwd,
144
145
  env: command.env ?? process.env,
146
+ ...processTreeSpawnOptions(),
145
147
  stdio: ["ignore", "pipe", "pipe"],
146
148
  });
147
149
  let stdout = "";
@@ -184,10 +186,10 @@ export async function executeCommand(command, defaultTimeoutMs = DEFAULT_VERIFY_
184
186
  if (timeoutMs > 0) {
185
187
  timeoutHandle = setTimeout(() => {
186
188
  timedOut = true;
187
- child.kill("SIGTERM");
189
+ terminateProcessTree(child, "SIGTERM");
188
190
  sigkillHandle = setTimeout(() => {
189
191
  if (child.exitCode === null) {
190
- child.kill("SIGKILL");
192
+ terminateProcessTree(child, "SIGKILL");
191
193
  }
192
194
  }, 5_000);
193
195
  }, timeoutMs);
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { taskConfigSchema } from "../../shared/types.js";
3
4
  import { appendJsonlLineAtomic, writeJsonAtomic, writeTextAtomic, } from "./atomic-write.js";
4
5
  function taskDir(repoRoot, taskId) {
5
6
  return path.join(repoRoot, ".harness", "tasks", taskId);
@@ -10,6 +11,27 @@ function taskFile(repoRoot, taskId, filename) {
10
11
  function taskWriteOptions(repoRoot) {
11
12
  return { repoRoot };
12
13
  }
14
+ function describeTaskConfigValidationError(error) {
15
+ if (error &&
16
+ typeof error === "object" &&
17
+ "issues" in error &&
18
+ Array.isArray(error.issues)) {
19
+ return error.issues
20
+ .map((issue) => {
21
+ if (!issue || typeof issue !== "object")
22
+ return String(issue);
23
+ const pathValue = "path" in issue && Array.isArray(issue.path)
24
+ ? issue.path.map(String).join(".")
25
+ : "";
26
+ const message = "message" in issue && typeof issue.message === "string"
27
+ ? issue.message
28
+ : String(issue);
29
+ return pathValue ? `${pathValue}: ${message}` : message;
30
+ })
31
+ .join("; ");
32
+ }
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
13
35
  export function resolveTaskContextFromDir(taskDir) {
14
36
  const normalized = path.resolve(taskDir);
15
37
  const taskId = path.basename(normalized);
@@ -37,6 +59,15 @@ export function resolveTaskStateContext(statePath) {
37
59
  return { repoRoot, taskId };
38
60
  }
39
61
  export async function writeTaskConfig(repoRoot, taskId, config) {
62
+ try {
63
+ taskConfigSchema.parse(config);
64
+ }
65
+ catch (error) {
66
+ throw new Error([
67
+ `invalid task config for ${taskId}: ${describeTaskConfigValidationError(error)}`,
68
+ "`referenceDocs` must use `{ path, name? }[]`; `verifyCommands` must use `{ label, command, timeoutMs? }[]`.",
69
+ ].join(" "));
70
+ }
40
71
  await writeJsonAtomic(taskFile(repoRoot, taskId, "task.json"), config, taskWriteOptions(repoRoot));
41
72
  }
42
73
  export async function writeWorkflowState(repoRoot, taskId, state) {
@@ -61,7 +61,10 @@ const OFFICIAL_ACTIONS = [
61
61
  ...["init", "status", "run", "record-round", "add-signal", "closeout"].map((leaf) => [`loop${leaf.replace(/(^|-)(.)/g, (_m, _d, c) => c.toUpperCase())}`, `loop-agent loop ${leaf}`, leaf === "status" ? "read" : "mutation", "advanced"]),
62
62
  ...["list", "inspect", "save", "run", "diff", "replay"].map((leaf) => [`workflow${leaf[0].toUpperCase()}${leaf.slice(1)}`, `loop-agent workflow ${leaf}`, ["list", "inspect", "diff"].includes(leaf) ? "read" : "mutation", "advanced"]),
63
63
  ["workerFeatureReview", "agent-worker feature review", "read", "model-callable"],
64
+ ["workerFeatureScaffold", "agent-worker feature scaffold", "mutation", "advanced"],
64
65
  ["workerFeatureRun", "agent-worker feature run", "long-running", "human-gated-required"],
66
+ ["workerFeatureAdvance", "agent-worker feature advance", "long-running", "human-gated-required"],
67
+ ["workerFeatureDoctor", "agent-worker feature doctor", "read", "model-callable"],
65
68
  ["workerFeatureVerifyFinal", "agent-worker feature verify-final", "long-running", "human-gated-required"],
66
69
  ["workerFeatureDelivery", "agent-worker feature delivery", "mutation", "human-gated-required"],
67
70
  ["workerFeatureCloseout", "agent-worker feature closeout", "mutation", "human-gated-required"],
@@ -734,7 +737,10 @@ export const OPERATOR_COMMAND_COVERAGE = Object.freeze([
734
737
  { command: "loop-agent stats", coverage: "model-callable", action: "stats", source: "loop-agent" },
735
738
  { command: "loop-agent stats context", coverage: "model-callable", action: "statsContext", source: "loop-agent" },
736
739
  { command: "agent-worker feature review", coverage: "model-callable", action: "workerFeatureReview", source: "agent-worker" },
740
+ { command: "agent-worker feature scaffold", coverage: "advanced", action: "workerFeatureScaffold", source: "agent-worker" },
737
741
  { command: "agent-worker feature run", coverage: "human-gated-required", action: "workerFeatureRun", source: "agent-worker" },
742
+ { command: "agent-worker feature advance", coverage: "human-gated-required", action: "workerFeatureAdvance", source: "agent-worker" },
743
+ { command: "agent-worker feature doctor", coverage: "model-callable", action: "workerFeatureDoctor", source: "agent-worker" },
738
744
  { command: "agent-worker feature verify-final", coverage: "human-gated-required", action: "workerFeatureVerifyFinal", source: "agent-worker" },
739
745
  { command: "agent-worker feature delivery", coverage: "human-gated-required", action: "workerFeatureDelivery", source: "agent-worker" },
740
746
  { command: "agent-worker feature closeout", coverage: "human-gated-required", action: "workerFeatureCloseout", source: "agent-worker" },