@tea-agent/loop-agent 0.24.10 → 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 (72) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/executors/dag-pi-executor.js +74 -13
  3. package/dist/executors/pi-executor.js +25 -7
  4. package/dist/executors/pi-prompt-transport.js +198 -0
  5. package/dist/executors/pi-sdk-executor.js +1 -1
  6. package/dist/executors/process-tree.js +33 -0
  7. package/dist/executors/shell-executor.js +239 -47
  8. package/dist/executors/shell-verification.js +4 -2
  9. package/dist/infrastructure/harness/task-store.js +31 -0
  10. package/dist/shared/operator/capabilities.js +6 -0
  11. package/dist/verification/maven/cache.js +142 -0
  12. package/dist/verification/maven/index.js +120 -0
  13. package/dist/verification/maven/plan-commands.js +421 -0
  14. package/dist/verification/maven/pom-static.js +136 -0
  15. package/dist/verification/maven/scope-resolve.js +153 -0
  16. package/dist/verification/maven/stale.js +130 -0
  17. package/dist/verification/maven/types.js +23 -0
  18. package/dist/verification/maven/workspace-graph.js +322 -0
  19. package/dist/worker/cli.js +177 -7
  20. package/dist/worker/delivery/final-verification.js +12 -0
  21. package/dist/worker/feature/advance.js +301 -0
  22. package/dist/worker/feature/doctor.js +223 -0
  23. package/dist/worker/feature/next-action.js +11 -3
  24. package/dist/worker/feature/scaffold.js +798 -0
  25. package/dist/workflows/dag/backend-test-markdown-workflow.js +127 -0
  26. package/dist/workflows/dag/failure-category.js +5 -1
  27. package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
  28. package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
  29. package/dist/workflows/dag/init-hybrid.js +208 -19
  30. package/dist/workflows/dag/reconcile-run.js +24 -0
  31. package/dist/workflows/dag/types.js +44 -0
  32. package/dist/workflows/dag/validate.js +16 -0
  33. package/docs/architecture/dag-execution.md +5 -1
  34. package/docs/architecture/runtime-boundaries.md +11 -1
  35. package/docs/architecture/worker-and-feature.md +13 -0
  36. package/docs/init-surface.manifest.json +3 -0
  37. package/docs/templates/agent-dag.schema.json +9 -0
  38. package/docs/templates/backend-test-dag.json +3 -3
  39. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  40. package/docs/templates/product-line/README.md +17 -1
  41. package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
  42. package/docs/templates/product-line/scaffold-samples/README.md +36 -0
  43. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
  44. package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
  45. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
  46. package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
  47. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
  48. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
  49. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
  50. package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
  51. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
  52. package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
  53. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
  54. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
  55. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
  56. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
  57. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
  58. package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
  59. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
  60. package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
  61. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
  62. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
  63. package/examples/l5-report-coms-process-definition.html +322 -0
  64. package/package.json +1 -1
  65. package/skills/agent-worker/references/agent-worker-operator.md +84 -0
  66. package/skills/frontend-bounded-implement/SKILL.md +53 -0
  67. package/skills/frontend-implementation/SKILL.md +3 -7
  68. package/skills/frontend-implementation/references/node-contracts.md +3 -3
  69. package/skills/loop-agent/SKILL.md +8 -8
  70. package/skills/loop-agent/references/command-reference.md +14 -2
  71. package/skills/loop-agent/references/harness-policy.md +22 -0
  72. package/skills/loop-agent/references/task-workflow.md +5 -0
@@ -1,9 +1,10 @@
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";
@@ -18,13 +19,15 @@ import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } f
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
21
  import { materializeBackendTestResultFromPytestHtml, 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";
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,64 +437,104 @@ 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 mappedScriptDiagLines = mappedScripts
389
- .map((script) => `echo " - ${script.replaceAll('"', '\\"')}" >> "\${DIAG_FILE}"`)
390
- .join("; ");
391
- // Diagnostic-first single shell: keep pytest exactly once, but always
392
- // emit STEP markers, mapped targets, interpreter identity, exit code
393
- // and html presence so empty nonzero-exit failures remain auditable.
394
- 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',
395
481
  'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
396
482
  'DIAG_FILE="${HARNESS_DAG_RUN_DIR}/reports/backend-test-execute-diagnostics.md"',
397
- [
398
- 'echo "# Backend Test Execute Diagnostics" > "${DIAG_FILE}"',
399
- 'echo >> "${DIAG_FILE}"',
400
- 'echo "- startedAt: $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" >> "${DIAG_FILE}"',
401
- 'echo "- pwd: $(pwd)" >> "${DIAG_FILE}"',
402
- 'echo "- HARNESS_DAG_RUN_DIR: ${HARNESS_DAG_RUN_DIR}" >> "${DIAG_FILE}"',
403
- 'echo "- shell: ${BASH:-bash} (${BASH_VERSION:-unknown})" >> "${DIAG_FILE}"',
404
- `echo "- mappedScriptCount: ${mappedScripts.length}" >> "\${DIAG_FILE}"`,
405
- 'echo "- mappedScripts:" >> "${DIAG_FILE}"',
406
- ].join("; "),
407
- mappedScriptDiagLines || 'echo " - <none>" >> "${DIAG_FILE}"',
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"',
408
492
  'echo "STEP=resolve-python"',
409
493
  'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
410
- [
411
- 'echo "- command_v_python: $(command -v python || true)" >> "${DIAG_FILE}"',
412
- 'echo "- command_v_python3: $(command -v python3 || true)" >> "${DIAG_FILE}"',
413
- 'echo "- PYTHON_BIN: ${PYTHON_BIN:-<empty>}" >> "${DIAG_FILE}"',
414
- ].join("; "),
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}"',
415
497
  'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" | tee -a "${DIAG_FILE}" >&2; exit 127; fi',
416
498
  'echo "STEP=python-identity"',
417
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',
418
506
  'echo "STEP=pytest-version"',
419
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',
420
515
  'echo "STEP=pytest-run"',
421
516
  `echo "- pytestCommand: python -m pytest ${mappedScripts.join(" ")} -v -p no:cacheprovider --html=reports/backend-test.html --self-contained-html" >> "\${DIAG_FILE}"`,
422
- // Run pytest exactly once and emit the native pytest-html
423
- // self-contained report. The HTML/facts renderers read per-case
424
- // captured stdout (HTTP_REQUEST/HTTP_RESPONSE) directly from the
425
- // pytest-html data-jsonblob island, so a JUnit XML report is no
426
- // longer generated.
427
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`,
428
518
  "STATUS=$?",
429
519
  'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
430
- [
431
- 'echo "- pytestExitCode: ${STATUS}" >> "${DIAG_FILE}"',
432
- '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',
433
- 'echo "- finishedAt: $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" >> "${DIAG_FILE}"',
434
- ].join("; "),
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}"',
435
523
  'echo "STEP=pytest-finished status=${STATUS}"',
436
- // exit 0 (all pass) or 1 (assertion failures) with a valid
437
- // pytest-html report are reportable; collection errors / crashes
438
- // (exit >= 2) or a missing report surface as a pipeline error below.
439
524
  'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" ]; then exit 0; fi',
440
525
  'echo "STEP=pipeline-failed status=${STATUS} html=$([ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.html" ] && echo present || echo missing)" >&2',
441
526
  'exit "${STATUS}"',
442
527
  ].join("; ");
443
- 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);
444
535
  if (!results.every((result) => result.ok)) {
445
- const failure = results.find((result) => !result.ok);
536
+ const failureIndex = results.findIndex((result) => !result.ok);
537
+ const failure = results[failureIndex];
446
538
  const combinedStdout = results.map((result) => result.stdout).join("\n");
447
539
  let diagnosticsText = "";
448
540
  try {
@@ -451,11 +543,18 @@ async function executeBackendTestPipeline(input, meta) {
451
543
  catch {
452
544
  diagnosticsText = "";
453
545
  }
546
+ const failedStep = failureIndex >= 0 && failureIndex < commandPlan.length
547
+ ? commandPlan[failureIndex]
548
+ : "unknown";
454
549
  const stderrSummary = [
455
550
  "backend-test markdown-execute-html shell failed",
456
551
  `failureCategory=${failure.failureCategory}`,
457
552
  `exitCode=${failure.exitCode ?? "null"}`,
458
553
  `durationMs=${failure.durationMs}`,
554
+ `failedCommandIndex=${failureIndex + 1}`,
555
+ `failedStep=${failedStep}`,
556
+ `commandPlanCount=${pipelineCommands.length}`,
557
+ `preSpawnDiagnostics=${preSpawnOk ? "written" : "failed"}`,
459
558
  `mappedScripts=${mappedScripts.join(",") || "<none>"}`,
460
559
  `shellStdoutEmpty=${combinedStdout.trim().length === 0}`,
461
560
  `shellStderrEmpty=${failure.stderr.trim().length === 0}`,
@@ -465,7 +564,7 @@ async function executeBackendTestPipeline(input, meta) {
465
564
  failure.stderrArtifactPath
466
565
  ? `commandStderrArtifact=${failure.stderrArtifactPath}`
467
566
  : undefined,
468
- "diagnostics=reports/backend-test-execute-diagnostics.md",
567
+ `diagnostics=${BACKEND_TEST_EXECUTE_DIAGNOSTICS_REL}`,
469
568
  failure.stderr.trim() || "(shell stderr empty)",
470
569
  diagnosticsText.trim()
471
570
  ? `--- diagnostics ---\n${diagnosticsText.trim()}`
@@ -484,8 +583,9 @@ async function executeBackendTestPipeline(input, meta) {
484
583
  const reportsDir = path.join(meta.runDir, "reports");
485
584
  const pytestHtmlContent = await readFile(path.join(reportsDir, "backend-test.html"), "utf8");
486
585
  const pytestExitCode = Number.parseInt((await readFile(path.join(reportsDir, "backend-test-pytest-exit.txt"), "utf8")).trim(), 10);
487
- if (![0, 1].includes(pytestExitCode))
586
+ if (![0, 1].includes(pytestExitCode)) {
488
587
  throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
588
+ }
489
589
  const parsed = parsePytestHtmlReport(pytestHtmlContent);
490
590
  // Bind Result v1 from the native pytest-html report BEFORE overwriting with the
491
591
  // styled renderer (which drops the data-jsonblob island).
@@ -507,11 +607,52 @@ async function executeBackendTestPipeline(input, meta) {
507
607
  traceabilitySummary,
508
608
  });
509
609
  const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
510
- 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
+ });
511
619
  const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
512
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);
513
654
  const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
514
- outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `result=${resultArtifact.path}`, facts);
655
+ outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `l5-dashboard=${l5Path}`, `result=${resultArtifact.path}`, facts);
515
656
  }
516
657
  else if (pipeline === "contracts") {
517
658
  const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
@@ -1376,10 +1517,61 @@ export async function executeDagShellNode(input, meta) {
1376
1517
  if (!shell || commands.length === 0) {
1377
1518
  throw new Error(`shell task ${input.task.id} requires shell.preset, shell.verdictGate, and/or non-empty shell.commands`);
1378
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
+ }
1379
1572
  const cwd = resolveShellCwd(input.cwd, shell.cwd);
1380
1573
  const timeoutMs = shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
1381
1574
  const results = [];
1382
- const started = Date.now();
1383
1575
  let beforeStatus;
1384
1576
  try {
1385
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" },
@@ -0,0 +1,142 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { MAVEN_CACHE_SCHEMA_VERSION, MAVEN_PLANNER_VERSION, } from "./types.js";
4
+ import { buildMavenWorkspaceGraph } from "./workspace-graph.js";
5
+ const CACHE_FILE_NAME = "maven-workspace-graph.json";
6
+ /** Directory names ignored during discovery and lightweight entry-list checks. */
7
+ const SKIP_DIR_NAMES = new Set([
8
+ ".git",
9
+ ".harness",
10
+ "node_modules",
11
+ "target",
12
+ "dist",
13
+ "build",
14
+ ".idea",
15
+ ".vscode",
16
+ "coverage",
17
+ ".turbo",
18
+ ".next",
19
+ ]);
20
+ export function defaultMavenCacheDir(repoRoot) {
21
+ return path.join(repoRoot, ".harness", "cache");
22
+ }
23
+ export function mavenGraphCachePath(cacheDir) {
24
+ return path.join(cacheDir, CACHE_FILE_NAME);
25
+ }
26
+ function isValidCachedGraph(value) {
27
+ if (!value || typeof value !== "object")
28
+ return false;
29
+ const graph = value;
30
+ return (graph.schemaVersion === MAVEN_CACHE_SCHEMA_VERSION &&
31
+ graph.plannerVersion === MAVEN_PLANNER_VERSION &&
32
+ typeof graph.fingerprint === "string" &&
33
+ Array.isArray(graph.pomPaths) &&
34
+ Array.isArray(graph.nodes));
35
+ }
36
+ /**
37
+ * Lightweight freshness probe: only stat previously recorded directories and
38
+ * file metadata. Does NOT re-walk the whole tree or re-parse POMs.
39
+ * Returns true when the cached graph can be reused as-is.
40
+ */
41
+ export function isCachedGraphStillFresh(repoRoot, graph) {
42
+ if (graph.schemaVersion !== MAVEN_CACHE_SCHEMA_VERSION ||
43
+ graph.plannerVersion !== MAVEN_PLANNER_VERSION) {
44
+ return false;
45
+ }
46
+ const manifest = graph.manifest;
47
+ if (!manifest) {
48
+ // Old cache shape without manifest cannot prove lightweight freshness.
49
+ return false;
50
+ }
51
+ // Directory entry lists: any add/remove invalidates.
52
+ for (const dir of manifest.directories) {
53
+ const absDir = dir.path === "" ? repoRoot : path.join(repoRoot, ...dir.path.split("/"));
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(absDir, { withFileTypes: true })
57
+ .filter((e) => {
58
+ if (SKIP_DIR_NAMES.has(e.name))
59
+ return false;
60
+ try {
61
+ return !e.isSymbolicLink();
62
+ }
63
+ catch {
64
+ return true;
65
+ }
66
+ })
67
+ .map((e) => e.name)
68
+ .sort();
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ if (entries.length !== dir.entryNames.length)
74
+ return false;
75
+ for (let i = 0; i < entries.length; i += 1) {
76
+ if (entries[i] !== dir.entryNames[i])
77
+ return false;
78
+ }
79
+ }
80
+ // File metadata: size/mtime change triggers rebuild (content re-hash on rebuild).
81
+ for (const file of manifest.files) {
82
+ const abs = path.join(repoRoot, ...file.path.split("/"));
83
+ try {
84
+ const lst = lstatSync(abs);
85
+ if (lst.isSymbolicLink())
86
+ return false;
87
+ const st = statSync(abs);
88
+ if (st.size !== file.size)
89
+ return false;
90
+ if (st.mtimeMs !== file.mtimeMs)
91
+ return false;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ return true;
98
+ }
99
+ /**
100
+ * Load cached graph when schema/planner versions match and lightweight
101
+ * directory/file metadata is unchanged. Full parse only on miss/stale.
102
+ */
103
+ export function loadOrBuildMavenWorkspaceGraph(input) {
104
+ const cacheDir = input.cacheDir ?? defaultMavenCacheDir(input.repoRoot);
105
+ const cachePath = mavenGraphCachePath(cacheDir);
106
+ if (existsSync(cachePath)) {
107
+ try {
108
+ const raw = JSON.parse(readFileSync(cachePath, "utf8"));
109
+ if (isValidCachedGraph(raw) && isCachedGraphStillFresh(input.repoRoot, raw)) {
110
+ return raw;
111
+ }
112
+ }
113
+ catch {
114
+ // Fall through to rebuild.
115
+ }
116
+ }
117
+ const graph = buildMavenWorkspaceGraph(input.repoRoot);
118
+ writeGraphCache(cacheDir, graph);
119
+ return graph;
120
+ }
121
+ export function writeGraphCache(cacheDir, graph) {
122
+ mkdirSync(cacheDir, { recursive: true });
123
+ const payload = `${JSON.stringify(graph, null, 2)}\n`;
124
+ // Guard: never persist absolute host paths.
125
+ if (/[A-Za-z]:[\\/]/.test(payload) || payload.includes("\\\\")) {
126
+ // Absolute Windows paths are forbidden; re-check using graph fields only.
127
+ const serialized = JSON.stringify({
128
+ schemaVersion: graph.schemaVersion,
129
+ plannerVersion: graph.plannerVersion,
130
+ fingerprint: graph.fingerprint,
131
+ pomPaths: graph.pomPaths,
132
+ wrapperPaths: graph.wrapperPaths,
133
+ nodes: graph.nodes,
134
+ reactors: graph.reactors,
135
+ reactorRootByPomPath: graph.reactorRootByPomPath,
136
+ manifest: graph.manifest,
137
+ });
138
+ writeFileSync(mavenGraphCachePath(cacheDir), `${serialized}\n`, "utf8");
139
+ return;
140
+ }
141
+ writeFileSync(mavenGraphCachePath(cacheDir), payload, "utf8");
142
+ }