@tea-agent/loop-agent 0.25.3 → 0.25.5

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 (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/application/dag/args.js +21 -1
  4. package/dist/application/dag/run-dag.js +1 -0
  5. package/dist/cli/command-definitions.js +1 -1
  6. package/dist/cli/program.js +82 -63
  7. package/dist/commands/client-recovery.js +209 -62
  8. package/dist/commands/init.js +206 -82
  9. package/dist/commands/run-dag-progress.js +109 -0
  10. package/dist/commands/run-dag.js +16 -5
  11. package/dist/executors/dag-pi-executor.js +80 -15
  12. package/dist/executors/model-routing.js +1 -1
  13. package/dist/executors/shell-executor.js +159 -0
  14. package/dist/executors/shell-write-guard.js +21 -7
  15. package/dist/worker/console/repo-fingerprint.js +7 -1
  16. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
  17. package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
  18. package/dist/workflows/dag/backend-test-markdown-workflow.js +306 -30
  19. package/dist/workflows/dag/backend-test-result-contract.js +35 -9
  20. package/dist/workflows/dag/convergence/controller.js +134 -9
  21. package/dist/workflows/dag/frontend-test-case-checklist.js +71 -0
  22. package/dist/workflows/dag/frontend-test-html-report.js +77 -0
  23. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  24. package/dist/workflows/dag/frontend-test-result-contract.js +44 -1
  25. package/dist/workflows/dag/init-hybrid.js +267 -80
  26. package/dist/workflows/dag/node-execution.js +64 -11
  27. package/dist/workflows/dag/prompt.js +118 -4
  28. package/dist/workflows/dag/retry-policy.js +5 -4
  29. package/dist/workflows/dag/scheduler.js +32 -5
  30. package/dist/workflows/dag/types.js +10 -3
  31. package/dist/workflows/dag/validate.js +6 -3
  32. package/docs/architecture/dag-execution.md +7 -4
  33. package/docs/architecture/runtime-boundaries.md +1 -1
  34. package/docs/templates/agent-dag.base.json +1 -1
  35. package/docs/templates/agent-dag.final-verification.json +1 -1
  36. package/docs/templates/agent-dag.schema.json +6 -0
  37. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  38. package/docs/templates/backend-test-dag.json +40 -13
  39. package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
  40. package/docs/templates/frontend-test-dag.json +62 -5
  41. package/docs/templates/hybrid-dag.json +1 -1
  42. package/examples/decision-gate-agent-dag.json +1 -1
  43. package/examples/example-dag.json +1 -1
  44. package/examples/hybrid-loop-agent-dag.json +1 -1
  45. package/harness.json +3 -2
  46. package/package.json +1 -1
  47. package/skills/loop-agent/references/command-reference.md +2 -1
  48. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  49. package/skills/loop-agent/references/model-routing.md +1 -1
@@ -0,0 +1,109 @@
1
+ export const DEFAULT_RUN_DAG_PROGRESS_INTERVAL_MS = 30_000;
2
+ function formatDuration(durationMs) {
3
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000));
4
+ const hours = Math.floor(totalSeconds / 3_600);
5
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
6
+ const seconds = totalSeconds % 60;
7
+ if (hours > 0)
8
+ return `${hours}h${String(minutes).padStart(2, "0")}m`;
9
+ if (minutes > 0)
10
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
11
+ return `${seconds}s`;
12
+ }
13
+ function countStatuses(state) {
14
+ const counts = { finished: 0, running: 0, pending: 0, error: 0, skipped: 0 };
15
+ for (const node of Object.values(state.nodes)) {
16
+ if (node.status === "FINISHED")
17
+ counts.finished += 1;
18
+ else if (node.status === "RUNNING")
19
+ counts.running += 1;
20
+ else if (node.status === "ERROR")
21
+ counts.error += 1;
22
+ else if (node.status === "SKIPPED")
23
+ counts.skipped += 1;
24
+ else
25
+ counts.pending += 1;
26
+ }
27
+ return `finished=${counts.finished} running=${counts.running} pending=${counts.pending} error=${counts.error} skipped=${counts.skipped}`;
28
+ }
29
+ function nodeElapsedMs(node, now) {
30
+ const startedAt = node.startedAt ? Date.parse(node.startedAt) : Number.NaN;
31
+ return Number.isFinite(startedAt) ? Math.max(0, now - startedAt) : 0;
32
+ }
33
+ function activeNodeSummary(state, now) {
34
+ const running = Object.values(state.nodes).filter((node) => node.status === "RUNNING");
35
+ if (running.length === 0)
36
+ return "nodes=none";
37
+ return running
38
+ .map((node) => {
39
+ const details = [
40
+ `${node.id}(${formatDuration(nodeElapsedMs(node, now))}`,
41
+ node.livenessStatus ? `liveness=${node.livenessStatus}` : undefined,
42
+ node.currentAttempt ? `attempt=${node.currentAttempt}` : undefined,
43
+ ]
44
+ .filter(Boolean)
45
+ .join(" ");
46
+ return `${details})`;
47
+ })
48
+ .join(",");
49
+ }
50
+ /**
51
+ * Human-readable CLI progress written to stderr. The final run-dag JSON remains
52
+ * the only stdout payload, so machine callers can continue parsing stdout.
53
+ */
54
+ export function createRunDagProgressObserver(options = {}) {
55
+ const intervalMs = options.intervalMs ?? DEFAULT_RUN_DAG_PROGRESS_INTERVAL_MS;
56
+ const write = options.write ?? ((text) => process.stderr.write(text));
57
+ const now = options.now ?? Date.now;
58
+ let latestState;
59
+ let timer;
60
+ const emit = (message) => {
61
+ try {
62
+ write(`[run-dag] ${message}\n`);
63
+ }
64
+ catch {
65
+ // Progress is a derived CLI view and must never decide DAG execution.
66
+ }
67
+ };
68
+ const stop = () => {
69
+ if (timer)
70
+ clearInterval(timer);
71
+ timer = undefined;
72
+ };
73
+ const start = () => {
74
+ if (timer || intervalMs <= 0)
75
+ return;
76
+ timer = setInterval(() => {
77
+ if (!latestState)
78
+ return;
79
+ emit(`heartbeat run=${latestState.runId} ${countStatuses(latestState)} ${activeNodeSummary(latestState, now())}`);
80
+ }, intervalMs);
81
+ timer.unref?.();
82
+ };
83
+ const observer = {
84
+ onRunStart: (state) => {
85
+ latestState = state;
86
+ emit(`started run=${state.runId} nodes=${Object.keys(state.nodes).length} title=${JSON.stringify(state.title)}`);
87
+ start();
88
+ },
89
+ onNodeStart: (nodeId, state) => {
90
+ latestState = state;
91
+ const node = state.nodes[nodeId];
92
+ emit(`node-started run=${state.runId} node=${nodeId} executor=${node?.executor ?? "unknown"} ${countStatuses(state)}`);
93
+ },
94
+ onNodeOutput: (_nodeId, _chunk, state) => {
95
+ latestState = state;
96
+ },
97
+ onNodeFinish: (nodeId, state) => {
98
+ latestState = state;
99
+ const node = state.nodes[nodeId];
100
+ emit(`node-finished run=${state.runId} node=${nodeId} status=${node?.status ?? "unknown"} duration=${formatDuration(node?.durationMs ?? 0)} ${countStatuses(state)}`);
101
+ },
102
+ onRunFinish: (state) => {
103
+ latestState = state;
104
+ stop();
105
+ emit(`finished run=${state.runId} status=${state.status} ${countStatuses(state)}`);
106
+ },
107
+ };
108
+ return { observer, dispose: stop };
109
+ }
@@ -1,11 +1,22 @@
1
1
  import { parseRunDagArgs } from "../application/dag/args.js";
2
2
  import { runDagUseCase } from "../application/dag/run-dag.js";
3
+ import { createRunDagProgressObserver } from "./run-dag-progress.js";
3
4
  export { parseRunDagArgs };
4
5
  export async function runRunDag(repoRoot, rawArgs) {
5
6
  const parsed = parseRunDagArgs(rawArgs, repoRoot);
6
- const result = await runDagUseCase({
7
- repoRoot,
8
- ...parsed,
9
- });
10
- console.log(JSON.stringify(result, null, 2));
7
+ const { quiet, progressIntervalMs, ...runInput } = parsed;
8
+ const progress = quiet || parsed.dryRun || parsed.initOnly
9
+ ? undefined
10
+ : createRunDagProgressObserver({ intervalMs: progressIntervalMs });
11
+ try {
12
+ const result = await runDagUseCase({
13
+ repoRoot,
14
+ ...runInput,
15
+ observer: progress?.observer,
16
+ });
17
+ console.log(JSON.stringify(result, null, 2));
18
+ }
19
+ finally {
20
+ progress?.dispose();
21
+ }
11
22
  }
@@ -4,6 +4,7 @@ import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastruct
4
4
  import { executePiStep, } from "./pi-executor.js";
5
5
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
6
6
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
7
+ import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
7
8
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
8
9
  export const DAG_PI_WRITE_TOOLS = [
9
10
  "read",
@@ -15,6 +16,7 @@ export const DAG_PI_WRITE_TOOLS = [
15
16
  "ls",
16
17
  ];
17
18
  export const DEFAULT_DAG_PI_PROVIDER = "wizard-local";
19
+ const WRITER_OUTCOME_PROTOCOL_LINE = "IMPLEMENTATION_OUTCOME:";
18
20
  export const DAG_PI_MODEL_PROVIDERS = {
19
21
  "gpt-5.3-codex-spark": "wizard-local",
20
22
  "gpt-5.5": "wizard-local",
@@ -266,9 +268,14 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
266
268
  beforeStatus = await readGitStatusPorcelain(input.cwd);
267
269
  beforePathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, snapshotGitStatusPorcelain(beforeStatus));
268
270
  }
269
- catch {
270
- beforeStatus = undefined;
271
- beforePathFingerprints = undefined;
271
+ catch (error) {
272
+ return {
273
+ ok: false,
274
+ stdout: "",
275
+ stderr: `writer Git baseline unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
276
+ failureCategory: "write-guard",
277
+ durationMs: Date.now() - started,
278
+ };
272
279
  }
273
280
  }
274
281
  const reportActivity = input.reportActivity;
@@ -319,7 +326,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
319
326
  persona,
320
327
  step,
321
328
  });
322
- const mapped = mapPiResultToDagNodeResult(result, input.task.firstProtocolLine);
329
+ const mapped = mapPiResultToDagNodeResult(result, input.task.writerOutcomePolicy
330
+ ? WRITER_OUTCOME_PROTOCOL_LINE
331
+ : input.task.firstProtocolLine);
323
332
  if (!isWriteTask) {
324
333
  return mapped;
325
334
  }
@@ -399,38 +408,94 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
399
408
  };
400
409
  }
401
410
  export function validateWriterImplementationOutcome(text, changedFiles) {
402
- const firstLine = text
403
- .split(/\r?\n/)
404
- .map((line) => line.trim())
405
- .find(Boolean);
406
- const match = firstLine?.match(/^IMPLEMENTATION_OUTCOME: (changed|already-satisfied|blocked)$/);
407
- if (!match) {
411
+ const parsed = parseWriterImplementationOutcome(text);
412
+ const diagnostics = writerOutcomeDiagnostics(text, parsed, changedFiles.length);
413
+ if (parsed.kind !== "valid") {
408
414
  return {
409
415
  ok: false,
410
- reason: "writer outcome validation failed: missing or invalid first line; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked",
416
+ reason: `writer outcome validation failed: ${parsed.kind} outcome; ${diagnostics}; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked`,
411
417
  };
412
418
  }
413
- const outcome = match[1];
419
+ const outcome = parsed.outcome;
414
420
  if (outcome === "blocked") {
415
421
  return {
416
422
  ok: false,
417
- reason: "writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully",
423
+ reason: `writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully; ${diagnostics}`,
418
424
  };
419
425
  }
420
426
  if (outcome === "changed" && changedFiles.length === 0) {
421
427
  return {
422
428
  ok: false,
423
- reason: "writer outcome validation failed: changed outcome has an empty diff",
429
+ reason: `writer outcome validation failed: changed outcome has an empty diff; ${diagnostics}`,
424
430
  };
425
431
  }
426
432
  if (outcome === "already-satisfied" && changedFiles.length > 0) {
427
433
  return {
428
434
  ok: false,
429
- reason: "writer outcome validation failed: already-satisfied outcome has a non-empty diff",
435
+ reason: `writer outcome validation failed: already-satisfied outcome has a non-empty diff; ${diagnostics}`,
430
436
  };
431
437
  }
432
438
  return { ok: true, outcome };
433
439
  }
440
+ function parseWriterImplementationOutcome(text) {
441
+ const lines = text.split(/\r?\n/);
442
+ const candidates = [];
443
+ for (const [lineIndex, line] of lines.entries()) {
444
+ const nextLine = lines[lineIndex + 1];
445
+ const normalized = normalizeProtocolLine(line, WRITER_OUTCOME_PROTOCOL_LINE, nextLine);
446
+ if (normalized === undefined)
447
+ continue;
448
+ const value = normalized
449
+ .slice(WRITER_OUTCOME_PROTOCOL_LINE.length)
450
+ .trim();
451
+ const outcome = isWriterImplementationOutcome(value)
452
+ ? value
453
+ : undefined;
454
+ candidates.push({
455
+ lineIndex,
456
+ value,
457
+ outcome,
458
+ });
459
+ }
460
+ if (candidates.length === 0) {
461
+ return { kind: "missing", candidates };
462
+ }
463
+ if (candidates.some((candidate) => candidate.outcome === undefined)) {
464
+ return { kind: "unknown", candidates };
465
+ }
466
+ const outcomes = new Set(candidates.map((candidate) => candidate.outcome));
467
+ if (outcomes.size !== 1) {
468
+ return { kind: "conflicting", candidates };
469
+ }
470
+ return {
471
+ kind: "valid",
472
+ outcome: candidates[0].outcome,
473
+ candidates,
474
+ };
475
+ }
476
+ function isWriterImplementationOutcome(value) {
477
+ return (value === "changed" ||
478
+ value === "already-satisfied" ||
479
+ value === "blocked");
480
+ }
481
+ function writerOutcomeDiagnostics(text, parsed, changedFiles) {
482
+ const firstNonEmpty = text
483
+ .split(/\r?\n/)
484
+ .map((line) => line.trim())
485
+ .find(Boolean) ?? "(empty)";
486
+ const candidateSummary = parsed.candidates.length === 0
487
+ ? "none"
488
+ : parsed.candidates
489
+ .slice(0, 4)
490
+ .map((candidate) => candidate.outcome
491
+ ? `${candidate.outcome}@line${candidate.lineIndex + 1}`
492
+ : `unknown(${boundedWriterDiagnostic(candidate.value)})@line${candidate.lineIndex + 1}`)
493
+ .join(",");
494
+ return `firstNonEmpty=${boundedWriterDiagnostic(firstNonEmpty)}; candidates=${candidateSummary}; changedFiles=${changedFiles}`;
495
+ }
496
+ function boundedWriterDiagnostic(value) {
497
+ return JSON.stringify(truncateUtf8Preview(redactSecrets(value), 160));
498
+ }
434
499
  export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
435
500
  const assistantText = canonicalizeProtocolFirstLine(result.assistantText, firstProtocolLine);
436
501
  return {
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_DAG_EXECUTOR_MODELS, } from '../workflows/dag/types.js';
2
2
  export const DEFAULT_DAG_MODELS = {
3
3
  HIGH: "gpt-5.5",
4
- MED: "glm-5.2",
4
+ MED: "gpt-5.5",
5
5
  LOW: "gpt-5.3-codex-spark",
6
6
  };
7
7
  /**
@@ -10,6 +10,9 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
10
10
  import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
11
11
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
12
12
  import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
13
+ import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
14
+ import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
15
+ import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
13
16
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
14
17
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
15
18
  import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
@@ -18,6 +21,7 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
18
21
  import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
19
22
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
20
23
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
24
+ import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
21
25
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
22
26
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
23
27
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
@@ -370,6 +374,14 @@ async function readRequiredRunReport(reportsDir, filename) {
370
374
  throw new Error(`missing required upstream report: reports/${filename}`);
371
375
  }
372
376
  }
377
+ async function readAdvisoryRunReport(reportsDir, filename, title) {
378
+ try {
379
+ return await readFile(path.join(reportsDir, filename), "utf8");
380
+ }
381
+ catch {
382
+ return `# ${title}\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Missing advisory upstream report: reports/${filename}\n`;
383
+ }
384
+ }
373
385
  async function executeBackendTestPipeline(input, meta) {
374
386
  const pipeline = input.task.shell?.backendTestPipeline;
375
387
  const started = Date.now();
@@ -421,6 +433,48 @@ async function executeBackendTestPipeline(input, meta) {
421
433
  durationMs: Date.now() - started,
422
434
  };
423
435
  }
436
+ const sourceBinding = meta.spec.sourceBinding;
437
+ if (!sourceBinding) {
438
+ const coverage = "# Backend Test Case Coverage Analysis\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Coverage analysis requires spec.sourceBinding; node 4 and node 7 fail closed on the same task/source binding contract.\n";
439
+ const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage);
440
+ outputs.push(`caseCoverage=${coveragePath}`, coverage);
441
+ return {
442
+ ok: false,
443
+ stdout: outputs.join("\n\n"),
444
+ stderr: "backend-test markdown-cases requires spec.sourceBinding",
445
+ failureCategory: "invalid-output",
446
+ durationMs: Date.now() - started,
447
+ };
448
+ }
449
+ const normalizedBinding = {
450
+ taskId: sourceBinding.taskId,
451
+ requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ?? "source/requirement.md",
452
+ requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ?? "0".repeat(64),
453
+ referencePaths: sourceBinding.sources.filter((s) => s.kind === "reference").map((s) => s.path),
454
+ requirementIds: sourceBinding.requirementIds,
455
+ };
456
+ try {
457
+ const coverage = await analyzeBackendTestCaseCoverage({ workspaceRoot: input.cwd, sourceBinding: normalizedBinding });
458
+ const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage.markdown);
459
+ const contractsDir = path.join(meta.runDir, "contracts");
460
+ await mkdir(contractsDir, { recursive: true });
461
+ const factsPath = path.join(contractsDir, "backend-test-case-coverage-facts.json");
462
+ await writeFile(factsPath, JSON.stringify(coverage.facts, null, 2), "utf8");
463
+ outputs.push(`caseCoverage=${coveragePath}`, `caseCoverageFacts=${factsPath}`, coverage.markdown);
464
+ }
465
+ catch (error) {
466
+ const message = error instanceof Error ? error.message : String(error);
467
+ const coverage = `# Backend Test Case Coverage Analysis\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Coverage analysis crashed and is treated as an infrastructure failure: ${message}\n`;
468
+ const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage);
469
+ outputs.push(`caseCoverage=${coveragePath}`, coverage);
470
+ return {
471
+ ok: false,
472
+ stdout: outputs.join("\n\n"),
473
+ stderr: `backend-test coverage analysis crashed: ${message}`,
474
+ failureCategory: "invalid-output",
475
+ durationMs: Date.now() - started,
476
+ };
477
+ }
424
478
  }
425
479
  else if (pipeline === "markdown-traceability") {
426
480
  let report;
@@ -432,6 +486,60 @@ async function executeBackendTestPipeline(input, meta) {
432
486
  }
433
487
  const reportPath = await writeRunReport(meta.runDir, "backend-test-traceability.md", report);
434
488
  outputs.push(`traceability=${reportPath}`, report);
489
+ const sourceBinding = meta.spec.sourceBinding;
490
+ if (!sourceBinding) {
491
+ const correspondence = "# Backend Test Markdown → pytest Correspondence\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Correspondence analysis requires spec.sourceBinding; nodes 4, 6 and 7 fail closed on the same task/source binding contract.\n";
492
+ const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence);
493
+ outputs.push(`correspondence=${correspondencePath}`, correspondence);
494
+ return {
495
+ ok: false,
496
+ stdout: outputs.join("\n\n"),
497
+ stderr: "backend-test markdown-traceability requires spec.sourceBinding",
498
+ failureCategory: "invalid-output",
499
+ durationMs: Date.now() - started,
500
+ };
501
+ }
502
+ try {
503
+ const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({ workspaceRoot: input.cwd, taskId: sourceBinding.taskId });
504
+ const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence.markdown);
505
+ const contractsDir = path.join(meta.runDir, "contracts");
506
+ await mkdir(contractsDir, { recursive: true });
507
+ const factsPath = path.join(contractsDir, "backend-test-markdown-pytest-correspondence-facts.json");
508
+ await writeFile(factsPath, JSON.stringify(correspondence.facts, null, 2), "utf8");
509
+ outputs.push(`correspondence=${correspondencePath}`, `correspondenceFacts=${factsPath}`, correspondence.markdown);
510
+ }
511
+ catch (error) {
512
+ const message = error instanceof Error ? error.message : String(error);
513
+ const correspondence = `# Backend Test Markdown → pytest Correspondence\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Correspondence analysis crashed and is treated as an infrastructure failure: ${message}\n`;
514
+ const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence);
515
+ outputs.push(`correspondence=${correspondencePath}`, correspondence);
516
+ return {
517
+ ok: false,
518
+ stdout: outputs.join("\n\n"),
519
+ stderr: `backend-test correspondence analysis crashed: ${message}`,
520
+ failureCategory: "invalid-output",
521
+ durationMs: Date.now() - started,
522
+ };
523
+ }
524
+ }
525
+ else if (pipeline === "markdown-manifest") {
526
+ const sourceBinding = meta.spec.sourceBinding;
527
+ if (!sourceBinding)
528
+ throw new Error("backend-test markdown-manifest requires spec.sourceBinding");
529
+ const manifest = await materializeBackendTestCaseManifestFromFacts({
530
+ runDir: meta.runDir,
531
+ workspaceRoot: input.cwd,
532
+ sourceBinding: {
533
+ taskId: sourceBinding.taskId,
534
+ requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ?? "source/requirement.md",
535
+ requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ?? "0".repeat(64),
536
+ referencePaths: sourceBinding.sources.filter((s) => s.kind === "reference").map((s) => s.path),
537
+ requirementIds: sourceBinding.requirementIds,
538
+ },
539
+ });
540
+ const manifestPath = path.join(meta.runDir, "contracts", "backend-test-case-manifest.json");
541
+ const summary = manifest.coverageSummary;
542
+ outputs.push(`manifest=${manifestPath}`, `materializationStatus=${manifest.materializationStatus ?? "available"}`, `coverageSummary.explicitAcCount=${summary?.explicitAcCount ?? "unavailable"}`, `coverageSummary.coveredAcCount=${summary?.coveredAcCount ?? "unavailable"}`, `coverageSummary.caseCount=${summary?.caseCount ?? "unavailable"}`, `coverageSummary.generatedCount=${summary?.generatedCount ?? "unavailable"}`, `ruleCoverageSummary.ruleCount=${manifest.ruleCoverageSummary?.ruleCount ?? "unavailable"}`, `correspondenceSummary.exactCorrespondenceCount=${manifest.correspondenceSummary?.exactCorrespondenceCount ?? "unavailable"}`);
435
543
  }
436
544
  else if (pipeline === "markdown-execute-html") {
437
545
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
@@ -599,14 +707,18 @@ async function executeBackendTestPipeline(input, meta) {
599
707
  });
600
708
  const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
601
709
  const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
710
+ const caseCoverageSummary = await readAdvisoryRunReport(reportsDir, "backend-test-case-coverage-analysis.md", "Backend Test Case Coverage Analysis");
602
711
  const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
712
+ const correspondenceSummary = await readAdvisoryRunReport(reportsDir, "backend-test-markdown-pytest-correspondence.md", "Backend Test Markdown → pytest Correspondence");
603
713
  const htmlContent = renderBackendTestHtml({
604
714
  title: meta.spec.title,
605
715
  parsed,
606
716
  cases,
607
717
  environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8"),
608
718
  caseValidationSummary,
719
+ caseCoverageSummary,
609
720
  traceabilitySummary,
721
+ correspondenceSummary,
610
722
  });
611
723
  const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
612
724
  const facts = renderBackendTestFacts({
@@ -616,7 +728,9 @@ async function executeBackendTestPipeline(input, meta) {
616
728
  htmlRelativePath: "reports/backend-test.html",
617
729
  htmlContent,
618
730
  caseValidationSummary,
731
+ caseCoverageSummary,
619
732
  traceabilitySummary,
733
+ correspondenceSummary,
620
734
  });
621
735
  const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
622
736
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
@@ -679,7 +793,9 @@ async function executeBackendTestPipeline(input, meta) {
679
793
  parsed,
680
794
  metrics: l5Metrics,
681
795
  caseValidationSummary,
796
+ caseCoverageSummary,
682
797
  traceabilitySummary,
798
+ correspondenceSummary,
683
799
  failures: failureSummaries.length > 0 ? failureSummaries : undefined,
684
800
  });
685
801
  const l5Path = await writeRunReport(meta.runDir, "backend-test-l5-dashboard.html", l5Html);
@@ -1143,6 +1259,40 @@ async function executeFrontendVerificationBundle(input, meta) {
1143
1259
  };
1144
1260
  }
1145
1261
  }
1262
+ async function executeFrontendTestCaseChecklist(input, meta) {
1263
+ const started = Date.now();
1264
+ try {
1265
+ const declaredAcIds = (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
1266
+ const result = await validateFrontendCaseChecklist({ workspaceRoot: input.cwd, declaredAcIds });
1267
+ if (result.issues.length) {
1268
+ return { ok: false, stdout: "", stderr: `frontend-test checklist blocked: ${JSON.stringify(result.issues)}`, failureCategory: "invalid-output", durationMs: Date.now() - started };
1269
+ }
1270
+ return { ok: true, stdout: `frontend-test checklist ok cases=${result.caseCount} source=${path.basename(result.manifestPath)}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1271
+ }
1272
+ catch (error) {
1273
+ return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1274
+ }
1275
+ }
1276
+ async function executeFrontendTestL5Report(input, meta) {
1277
+ const started = Date.now();
1278
+ try {
1279
+ const output = await renderFrontendTestL5Report({ workspaceRoot: input.cwd, runDir: meta.runDir });
1280
+ return { ok: true, stdout: `Frontend L-5 report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nStatus: ${output.metrics.status}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
1281
+ }
1282
+ catch (error) {
1283
+ return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1284
+ }
1285
+ }
1286
+ async function executeFrontendTestHtmlReport(input, meta) {
1287
+ const started = Date.now();
1288
+ try {
1289
+ const output = await renderFrontendTestHtmlReport({ workspaceRoot: input.cwd, runDir: meta.runDir });
1290
+ 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 };
1291
+ }
1292
+ catch (error) {
1293
+ return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1294
+ }
1295
+ }
1146
1296
  async function executeFrontendTestEvidenceValidation(input) {
1147
1297
  const started = Date.now();
1148
1298
  try {
@@ -1275,9 +1425,18 @@ export async function executeDagShellNode(input, meta) {
1275
1425
  return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1276
1426
  }
1277
1427
  }
1428
+ if (shell?.frontendTestCaseChecklist) {
1429
+ return executeFrontendTestCaseChecklist(input, meta);
1430
+ }
1278
1431
  if (shell?.frontendTestEvidenceValidation) {
1279
1432
  return executeFrontendTestEvidenceValidation(input);
1280
1433
  }
1434
+ if (shell?.frontendTestL5Report) {
1435
+ return executeFrontendTestL5Report(input, meta);
1436
+ }
1437
+ if (shell?.frontendTestHtmlReport) {
1438
+ return executeFrontendTestHtmlReport(input, meta);
1439
+ }
1281
1440
  if (shell?.backendTestPipeline) {
1282
1441
  return executeBackendTestPipelineWithWriteGuard(input, meta);
1283
1442
  }
@@ -165,8 +165,8 @@ export function validateShellWriteGuard(input) {
165
165
  return { ok: violations.length === 0, violations };
166
166
  }
167
167
  export async function readGitStatusPorcelain(cwd, options = {}) {
168
- const attempts = Math.max(1, options.attempts ?? 3);
169
- const retryDelayMs = Math.max(0, options.retryDelayMs ?? 100);
168
+ const attempts = Math.max(1, options.attempts ?? 5);
169
+ const retryDelayMs = Math.max(0, options.retryDelayMs ?? 150);
170
170
  let lastError;
171
171
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
172
172
  try {
@@ -179,14 +179,24 @@ export async function readGitStatusPorcelain(cwd, options = {}) {
179
179
  }
180
180
  }
181
181
  }
182
- throw lastError instanceof Error
183
- ? lastError
184
- : new Error(`git status failed after ${attempts} attempts`);
182
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
183
+ const exitCode = lastError instanceof Error &&
184
+ "exitCode" in lastError &&
185
+ typeof lastError.exitCode === "number"
186
+ ? String(lastError.exitCode)
187
+ : "unavailable";
188
+ const signal = lastError instanceof Error &&
189
+ "signal" in lastError &&
190
+ typeof lastError.signal === "string"
191
+ ? lastError.signal
192
+ : "unavailable";
193
+ throw new Error(`git status failed after ${attempts} attempts (exit code=${exitCode}, signal=${signal}, cwd=${path.resolve(cwd)}): ${detail}`, { cause: lastError });
185
194
  }
186
195
  function readGitStatusPorcelainOnce(cwd) {
187
196
  return new Promise((resolve, reject) => {
188
197
  const child = spawn("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
189
198
  cwd,
199
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
190
200
  stdio: ["ignore", "pipe", "pipe"],
191
201
  });
192
202
  let stdout = "";
@@ -200,12 +210,16 @@ function readGitStatusPorcelainOnce(cwd) {
200
210
  stderr += chunk;
201
211
  });
202
212
  child.on("error", reject);
203
- child.on("close", (code) => {
213
+ child.on("close", (code, signal) => {
204
214
  if (code === 0) {
205
215
  resolve(stdout);
206
216
  return;
207
217
  }
208
- reject(new Error(`git status failed: ${stderr.trim() || stdout.trim()}`));
218
+ const detail = stderr.trim() || stdout.trim() || "no stderr/stdout";
219
+ const error = new Error(`git status failed: ${detail}`);
220
+ error.exitCode = code;
221
+ error.signal = signal;
222
+ reject(error);
209
223
  });
210
224
  });
211
225
  }
@@ -7,7 +7,13 @@ import path from "node:path";
7
7
  * (except a bare root). No extra case-folding on macOS/Windows.
8
8
  */
9
9
  export function normalizeWorktreeRealpath(repoRoot) {
10
- let resolved = realpathSync(path.resolve(repoRoot));
10
+ // The legacy JS implementation preserves an input 8.3 path such as
11
+ // C:\\Users\\MICROS~1 on Windows. The native implementation expands the
12
+ // short and long aliases to the same physical path before hashing.
13
+ const absolute = path.resolve(repoRoot);
14
+ let resolved = process.platform === "win32"
15
+ ? realpathSync.native(absolute)
16
+ : realpathSync(absolute);
11
17
  if (/^[a-zA-Z]:/.test(resolved)) {
12
18
  resolved = resolved[0].toUpperCase() + resolved.slice(1);
13
19
  }