@tea-agent/loop-agent 0.26.1 → 0.26.3

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 (37) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/application/dag/generate-task-dag.js +33 -0
  3. package/dist/commands/task-source-prepare.js +6 -0
  4. package/dist/executors/dag-pi-executor.js +156 -9
  5. package/dist/executors/shell-executor.js +111 -0
  6. package/dist/executors/shell-presets.js +12 -4
  7. package/dist/executors/shell-write-guard.js +145 -12
  8. package/dist/task/config-types.js +6 -0
  9. package/dist/task/contract/constants.js +1 -0
  10. package/dist/task/contract/project.js +8 -0
  11. package/dist/task/contract/schema.js +1 -0
  12. package/dist/task/frontend-preflight.js +131 -0
  13. package/dist/task/runtime.js +2 -4
  14. package/dist/task/source-prepare/build-draft.js +9 -0
  15. package/dist/task/source-prepare/completeness.js +1 -1
  16. package/dist/worker/observability/read-model.js +134 -0
  17. package/dist/worker/observe/static/state.js +61 -0
  18. package/dist/worker/observe/static/styles.css +8 -0
  19. package/dist/worker/observe/static/views/dag-graph.js +107 -31
  20. package/dist/worker/observe/static/views/dag-inspector.js +374 -157
  21. package/dist/worker/observe/static/views/dag.js +4 -11
  22. package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
  23. package/dist/workflows/dag/convergence/controller.js +110 -21
  24. package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
  25. package/dist/workflows/dag/frontend-repair.js +29 -29
  26. package/dist/workflows/dag/frontend-review-context.js +7 -1
  27. package/dist/workflows/dag/frontend-verification-trace.js +26 -5
  28. package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
  29. package/dist/workflows/dag/governance-profile.js +1 -1
  30. package/dist/workflows/dag/init-hybrid.js +115 -39
  31. package/dist/workflows/dag/output-protocol.js +180 -7
  32. package/dist/workflows/dag/runner.js +141 -52
  33. package/dist/workflows/dag/types.js +5 -1
  34. package/dist/workflows/dag/validate.js +3 -2
  35. package/docs/templates/backend-test-dag.json +100 -8
  36. package/package.json +1 -1
  37. package/skills/loop-agent/references/hybrid-dag.md +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.26.3] - 2026-08-02
6
+
7
+ ### 重点更新
8
+
9
+ - DAG 无害工具副产物自动恢复
10
+ - 提升前端验证命令的精确度与失败分类
11
+ - 隔离测试固件与运行环境标记
12
+
13
+ ### 新增
14
+
15
+ - DAG 无害工具副产物自动恢复:自动清理 Git Bash 误用 `> nul` / `2> nul` 在仓库根目录生成的普通非 symlink untracked `nul` 文件,避免其干扰 Git 状态复查与变更清单生成
16
+
17
+ ### 改进
18
+
19
+ - 精确限定前端验证命令的作用域,提升验证执行的准确性
20
+ - 更精准地分类前端验证失败原因,避免模糊报错
21
+ - 将运行器标记与测试固件进行安全隔离,防止测试环境干扰
22
+
23
+ ### 修复
24
+
25
+ - 修复配置验证目标错误处理符号引用的问题
26
+ - 修复 DAG 执行中无害的根目录 nul 副产物导致流程异常的问题
27
+
28
+ ## [0.26.2] - 2026-08-02
29
+
30
+ ### 重点更新
31
+
32
+ - 后端测试在执行前新增有界的 pytest 收集修复机制,安全且精准地自动修复生成测试中的不一致问题
33
+ - 全面加固 DAG 审查协议与前端契约边界,提升执行稳定性
34
+ - 修复 Observe 看板轮询刷新时的交互状态保留问题,优化使用体验
35
+
36
+ ### 新增
37
+
38
+ - 后端测试在业务 pytest 执行前新增有界的收集(collection)修复机制:仅对生成的测试目录中可唯一归因的语法、导入、模块或参数化不一致进行最多一次自动修复,且严格禁止修改生产代码或放宽断言
39
+
40
+ ### 改进
41
+
42
+ - 加固 DAG 审查协议与追踪门禁,提升执行稳定性
43
+ - 加固前端 DAG 契约边界
44
+ - 稳定 Linux 环境下的测试收集固件(fixture)
45
+ - 对齐受监督的 DAG 收尾拓扑结构
46
+
47
+ ### 修复
48
+
49
+ - 修复 Observe 看板在轮询刷新时破坏 DAG 图视口 DOM 的问题,确保拖动与滚动状态不被销毁
50
+ - 修复节点检查器在内容未变时重建内容的问题,现支持异步标签页安全恢复滚动位置
51
+
5
52
  ## [0.26.1] - 2026-08-02
6
53
 
7
54
  ### 新增
@@ -12,6 +12,9 @@ import { validateDagUseCase } from "./validate-dag.js";
12
12
  import { runDagUseCase } from "./run-dag.js";
13
13
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
14
14
  import { writeTaskConfig } from "../../infrastructure/harness/task-store.js";
15
+ import { runFrontendPreflight } from "../../task/frontend-preflight.js";
16
+ import { observeTaskContract } from "../../task/contract/observe.js";
17
+ import { adoptTaskContract } from "../../task/contract/adopt.js";
15
18
  const PLACEHOLDER_WRITESET_MARKER = "REPLACE/WITH";
16
19
  function buildValidateInput(repoRoot, dagPath, parsed) {
17
20
  return {
@@ -243,6 +246,36 @@ export async function generateTaskDagUseCase(input) {
243
246
  // before any expensive DAG generation or execution. Empty/consistent
244
247
  // repos stay compatible so the default DAG flow is unblocked.
245
248
  await assertExecPlanIndexConsistent(repoRoot);
249
+ const preflightTaskConfig = await loadTaskConfig(repoRoot, parsed.taskId);
250
+ const frontendScope = await runFrontendPreflight({
251
+ repoRoot,
252
+ taskId: parsed.taskId,
253
+ taskConfig: preflightTaskConfig,
254
+ });
255
+ if (frontendScope) {
256
+ await writeTaskConfig(repoRoot, parsed.taskId, {
257
+ ...preflightTaskConfig,
258
+ allowedPaths: [
259
+ ...new Set([
260
+ frontendScope.entrypoint,
261
+ ...frontendScope.implementationPaths,
262
+ ...frontendScope.testPaths,
263
+ ]),
264
+ ],
265
+ });
266
+ const state = await observeTaskContract({ repoRoot, taskId: parsed.taskId });
267
+ if (!state.ref || !state.observedCanonicalHash) {
268
+ throw new Error("frontend preflight could not reconcile the managed Task Contract");
269
+ }
270
+ await adoptTaskContract({
271
+ repoRoot,
272
+ taskId: parsed.taskId,
273
+ expectedRevision: state.ref.revision,
274
+ expectedObservedHash: state.observedCanonicalHash,
275
+ requestId: `frontend-preflight-${parsed.taskId}-${randomUUID()}`,
276
+ requestPayloadSha256: randomUUID().replaceAll("-", "").padEnd(64, "0"),
277
+ });
278
+ }
246
279
  const candidateResult = await initHybridDagFromTask(repoRoot, parsed.taskId, {
247
280
  outputPath: parsed.outputPath,
248
281
  template: "standard-dag",
@@ -18,6 +18,7 @@ const USAGE = `usage:
18
18
  [--task-kind <TaskKind>]
19
19
  [--feature-id <string>]
20
20
  [--allowed-path <glob>]...
21
+ [--allowed-root <glob>]...
21
22
  [--forbidden-path <glob>]...
22
23
  [--no-default-forbidden-paths]
23
24
  [--invariant <text>]...
@@ -113,6 +114,10 @@ export function parseTaskSourcePrepareArgs(args) {
113
114
  options.allowedPaths = pushList(options.allowedPaths, next());
114
115
  continue;
115
116
  }
117
+ if (token === "--allowed-root") {
118
+ options.allowedRoots = pushList(options.allowedRoots, next());
119
+ continue;
120
+ }
116
121
  if (token === "--forbidden-path") {
117
122
  options.forbiddenPaths = pushList(options.forbiddenPaths, next());
118
123
  continue;
@@ -264,6 +269,7 @@ function buildFlags(options) {
264
269
  taskKind: options.taskKind,
265
270
  featureId: options.featureId,
266
271
  allowedPaths: options.allowedPaths,
272
+ allowedRoots: options.allowedRoots,
267
273
  forbiddenPaths: options.forbiddenPaths,
268
274
  noDefaultForbiddenPaths: options.noDefaultForbiddenPaths,
269
275
  invariants: options.invariants,
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
4
4
  import { executePiStep, } from "./pi-executor.js";
5
5
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
6
- import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
6
+ import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
7
7
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
8
8
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
9
9
  export const DAG_PI_WRITE_TOOLS = [
@@ -259,6 +259,7 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
259
259
  }
260
260
  const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
261
261
  readGitStatusPorcelain,
262
+ recoverRootNulArtifact,
262
263
  };
263
264
  export async function executeDagPiNode(input, meta, piStepFn = executePiStep, writeGuardDependencies = DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES) {
264
265
  const started = Date.now();
@@ -360,10 +361,105 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
360
361
  let changeManifestAfterStatus;
361
362
  let changeManifestChangedFiles;
362
363
  if (beforeStatus !== undefined) {
364
+ let recoveryEvidence;
365
+ let removalPendingRecheck;
363
366
  try {
364
- const afterStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-after" });
365
- const afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
366
- const afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
367
+ let afterStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-after" });
368
+ let afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
369
+ let afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
370
+ // Benign tool artifact recovery: a new untracked bounded regular file
371
+ // literally named `nul` at the repository root is removed with
372
+ // the Node file API, then git status is recaptured so the guard,
373
+ // outcome validation and change manifest only ever see the
374
+ // sanitized diff. Every non-matching candidate stays fail-closed.
375
+ if (afterSnapshot.has("nul")) {
376
+ const recoverRootNulArtifactFn = writeGuardDependencies.recoverRootNulArtifact ??
377
+ recoverRootNulArtifact;
378
+ const recovery = await recoverRootNulArtifactFn({
379
+ rootCwd: input.cwd,
380
+ beforeSnapshot: snapshotGitStatusPorcelain(beforeStatus),
381
+ afterSnapshot,
382
+ afterPathFingerprints,
383
+ candidateAuthorized: validateShellWriteGuardFromDiff({
384
+ changedFiles: ["nul"],
385
+ task: input.task,
386
+ concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
387
+ }).ok,
388
+ });
389
+ const beforeStatusSha256 = createHash("sha256")
390
+ .update(beforeStatus)
391
+ .digest("hex");
392
+ const afterStatusSha256 = createHash("sha256")
393
+ .update(afterStatus)
394
+ .digest("hex");
395
+ if (recovery.action === "removed") {
396
+ const evidenceBase = {
397
+ candidatePath: "nul",
398
+ reason: recovery.reason,
399
+ action: "recovered",
400
+ result: "removed",
401
+ observedSizeBytes: recovery.observedSizeBytes,
402
+ removedAt: recovery.removedAt,
403
+ beforeStatusSha256,
404
+ afterStatusSha256,
405
+ };
406
+ removalPendingRecheck = evidenceBase;
407
+ const recheckedStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, {
408
+ phase: "pi-writer-after-recheck",
409
+ });
410
+ const recheckedSnapshot = snapshotGitStatusPorcelain(recheckedStatus);
411
+ afterStatus = recheckedStatus;
412
+ afterSnapshot = recheckedSnapshot;
413
+ afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, recheckedSnapshot);
414
+ recoveryEvidence = {
415
+ ...evidenceBase,
416
+ recheck: {
417
+ statusSha256: createHash("sha256")
418
+ .update(recheckedStatus)
419
+ .digest("hex"),
420
+ nulStillPresent: recheckedSnapshot.has("nul"),
421
+ },
422
+ };
423
+ removalPendingRecheck = undefined;
424
+ }
425
+ else if (recovery.action === "skipped") {
426
+ recoveryEvidence = {
427
+ candidatePath: "nul",
428
+ reason: recovery.reason,
429
+ action: "skipped",
430
+ result: "kept",
431
+ ...(recovery.observedSizeBytes === undefined
432
+ ? {}
433
+ : { observedSizeBytes: recovery.observedSizeBytes }),
434
+ beforeStatusSha256,
435
+ afterStatusSha256,
436
+ recheck: {
437
+ statusSha256: afterStatusSha256,
438
+ nulStillPresent: true,
439
+ },
440
+ };
441
+ }
442
+ else {
443
+ recoveryEvidence = {
444
+ candidatePath: "nul",
445
+ reason: recovery.reason,
446
+ action: "failed",
447
+ result: "removal-failed",
448
+ observedSizeBytes: recovery.observedSizeBytes,
449
+ errorDetail: recovery.errorDetail,
450
+ beforeStatusSha256,
451
+ afterStatusSha256,
452
+ recheck: {
453
+ statusSha256: afterStatusSha256,
454
+ nulStillPresent: true,
455
+ },
456
+ };
457
+ writeGuardOk = false;
458
+ writeGuardViolations = [
459
+ `nul artifact removal failed: ${recovery.errorDetail}`,
460
+ ];
461
+ }
462
+ }
367
463
  const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
368
464
  changeManifestAfterStatus = afterStatus;
369
465
  changeManifestChangedFiles = changedFiles;
@@ -372,16 +468,35 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
372
468
  task: input.task,
373
469
  concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
374
470
  });
375
- writeGuardOk = guard.ok;
376
- writeGuardViolations = guard.violations;
471
+ writeGuardOk = writeGuardOk && guard.ok;
472
+ writeGuardViolations = [...writeGuardViolations, ...guard.violations];
377
473
  }
378
474
  catch (error) {
379
475
  await persistGitWriteGuardDiagnostics(meta.runDir, input.task.id, error);
476
+ if (removalPendingRecheck) {
477
+ // The artifact was already deleted but the recheck read failed:
478
+ // record the recovery facts with the recheck failure so the
479
+ // evidence trail stays complete while the node remains failed.
480
+ recoveryEvidence = {
481
+ ...removalPendingRecheck,
482
+ recheck: {
483
+ failed: true,
484
+ errorDetail: boundedRecoveryErrorDetail(error),
485
+ },
486
+ };
487
+ }
380
488
  writeGuardOk = false;
381
489
  writeGuardViolations = [
382
490
  `git status unavailable: ${error instanceof Error ? error.message : String(error)}`,
383
491
  ];
384
492
  }
493
+ if (recoveryEvidence) {
494
+ await persistBenignToolArtifactRecovery({
495
+ runDir: meta.runDir,
496
+ nodeId: input.task.id,
497
+ evidence: recoveryEvidence,
498
+ });
499
+ }
385
500
  }
386
501
  let writerOutcomeViolation;
387
502
  if (mapped.ok && input.task.writerOutcomePolicy) {
@@ -624,9 +739,7 @@ async function persistWriterGitBaseline(input) {
624
739
  nodeId: input.nodeId,
625
740
  phase: "pi-writer-before",
626
741
  capturedAt: new Date().toISOString(),
627
- statusSha256: createHash("sha256")
628
- .update(input.beforeStatus)
629
- .digest("hex"),
742
+ statusSha256: createHash("sha256").update(input.beforeStatus).digest("hex"),
630
743
  dirtyPaths: [...input.beforeSnapshot.keys()].sort(),
631
744
  pathFingerprintsSha256: createHash("sha256")
632
745
  .update(JSON.stringify(fingerprintEntries))
@@ -639,6 +752,40 @@ async function persistGitWriteGuardDiagnostics(runDir, nodeId, error) {
639
752
  return;
640
753
  await writeDagNodeJsonArtifact(runDir, nodeId, "git-write-guard-diagnostics.json", error.diagnostics);
641
754
  }
755
+ function boundedRecoveryErrorDetail(error) {
756
+ const detail = error instanceof Error ? error.message : String(error);
757
+ return detail.length <= 1000
758
+ ? detail
759
+ : `${detail.slice(0, 1000)}...[truncated]`;
760
+ }
761
+ /**
762
+ * Persist bounded run-owned evidence for a recognized repository-root `nul`
763
+ * candidate: candidate path, reason, action, result and recheck facts plus
764
+ * status hashes. Never records environment variables or secrets.
765
+ */
766
+ async function persistBenignToolArtifactRecovery(input) {
767
+ const artifact = {
768
+ schemaVersion: 1,
769
+ nodeId: input.nodeId,
770
+ candidatePath: input.evidence.candidatePath,
771
+ reason: input.evidence.reason,
772
+ action: input.evidence.action,
773
+ result: input.evidence.result,
774
+ ...(input.evidence.observedSizeBytes === undefined
775
+ ? {}
776
+ : { observedSizeBytes: input.evidence.observedSizeBytes }),
777
+ ...(input.evidence.errorDetail
778
+ ? { errorDetail: input.evidence.errorDetail }
779
+ : {}),
780
+ ...(input.evidence.removedAt
781
+ ? { removedAt: input.evidence.removedAt }
782
+ : {}),
783
+ beforeStatusSha256: input.evidence.beforeStatusSha256,
784
+ afterStatusSha256: input.evidence.afterStatusSha256,
785
+ recheck: input.evidence.recheck,
786
+ };
787
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "benign-tool-artifact-recovery.json", artifact);
788
+ }
642
789
  /**
643
790
  * Validate the writer's observed diff against its declared write boundary.
644
791
  * Inlined mirror of runPostRunWriteGuard that reuses the already-computed diff
@@ -24,6 +24,7 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
24
24
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
25
25
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
26
26
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
27
+ import { assessBackendPytestCollection, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
27
28
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
28
29
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
29
30
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -476,6 +477,114 @@ async function executeBackendTestPipeline(input, meta) {
476
477
  };
477
478
  }
478
479
  }
480
+ else if (pipeline === "markdown-collection-assess") {
481
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
482
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
483
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
484
+ const targets = mappedScripts.map(shellQuote).join(" ");
485
+ const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
486
+ const [result] = await executePipelineCommands(input, meta, [command]);
487
+ if (!result)
488
+ throw new Error("backend pytest collection command did not produce a result");
489
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
490
+ return {
491
+ ok: false,
492
+ stdout: result.stdout,
493
+ stderr: result.stderr || "backend pytest collection could not start",
494
+ failureCategory: result.failureCategory,
495
+ durationMs: Date.now() - started,
496
+ };
497
+ }
498
+ const facts = assessBackendPytestCollection({
499
+ phase: "initial",
500
+ mappedScripts,
501
+ inventory,
502
+ exitCode: result.exitCode ?? 2,
503
+ stdout: result.stdout,
504
+ stderr: result.stderr,
505
+ });
506
+ const artifacts = await writeBackendPytestCollectionArtifacts({
507
+ runDir: meta.runDir,
508
+ stem: "initial",
509
+ facts,
510
+ });
511
+ outputs.push(`collectionFacts=${artifacts.factsPath}`, `collectionReport=${artifacts.reportPath}`);
512
+ return {
513
+ ok: true,
514
+ stdout: JSON.stringify({
515
+ status: facts.status,
516
+ repairEligible: facts.repairEligible,
517
+ pytestExitCode: facts.pytestExitCode,
518
+ collectedItemCount: facts.collectedItemCount,
519
+ factsPath: "contracts/backend-test-pytest-collection-initial.json",
520
+ }),
521
+ stderr: "",
522
+ durationMs: Date.now() - started,
523
+ };
524
+ }
525
+ else if (pipeline === "markdown-collection-effective") {
526
+ const initial = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-initial.json"));
527
+ let effective;
528
+ if (initial.status === "PASS") {
529
+ effective = await materializeEffectiveBackendPytestCollection({
530
+ workspaceRoot: input.cwd,
531
+ initial,
532
+ });
533
+ }
534
+ else if (initial.status === "REPAIRABLE") {
535
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
536
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
537
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
538
+ const targets = mappedScripts.map(shellQuote).join(" ");
539
+ const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
540
+ const [result] = await executePipelineCommands(input, meta, [command]);
541
+ if (!result)
542
+ throw new Error("backend pytest final collection command did not produce a result");
543
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
544
+ return {
545
+ ok: false,
546
+ stdout: result.stdout,
547
+ stderr: result.stderr || "backend pytest final collection could not start",
548
+ failureCategory: result.failureCategory,
549
+ durationMs: Date.now() - started,
550
+ };
551
+ }
552
+ const finalFacts = assessBackendPytestCollection({
553
+ phase: "final",
554
+ mappedScripts,
555
+ inventory,
556
+ exitCode: result.exitCode ?? 2,
557
+ stdout: result.stdout,
558
+ stderr: result.stderr,
559
+ });
560
+ effective = await materializeEffectiveBackendPytestCollection({
561
+ workspaceRoot: input.cwd,
562
+ initial,
563
+ final: finalFacts,
564
+ });
565
+ }
566
+ else {
567
+ throw new Error("backend pytest collection is blocked and does not authorize repair");
568
+ }
569
+ const artifacts = await writeBackendPytestCollectionArtifacts({
570
+ runDir: meta.runDir,
571
+ stem: "effective",
572
+ facts: effective,
573
+ });
574
+ return {
575
+ ok: true,
576
+ stdout: JSON.stringify({
577
+ status: effective.status,
578
+ collectionSource: effective.collectionSource,
579
+ repairAttempt: effective.repairAttempt,
580
+ collectedItemCount: effective.collectedItemCount,
581
+ factsPath: "contracts/backend-test-pytest-collection-effective.json",
582
+ reportPath: artifacts.reportPath,
583
+ }),
584
+ stderr: "",
585
+ durationMs: Date.now() - started,
586
+ };
587
+ }
479
588
  else if (pipeline === "markdown-traceability") {
480
589
  let report;
481
590
  try {
@@ -542,6 +651,8 @@ async function executeBackendTestPipeline(input, meta) {
542
651
  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"}`, `correspondenceSummary.primarySymbolCount=${manifest.correspondenceSummary?.primarySymbolCount ?? "unavailable"}`, `correspondenceSummary.testPoints=${manifest.correspondenceSummary?.mappedTestPointCount ?? "unavailable"}/${manifest.correspondenceSummary?.testPointCount ?? "unavailable"}`, `correspondenceSummary.variantTestPointCount=${manifest.correspondenceSummary?.variantTestPointCount ?? "unavailable"}`, `correspondenceSummary.assertionTestPointCount=${manifest.correspondenceSummary?.assertionTestPointCount ?? "unavailable"}`, `correspondenceSummary.crossCuttingTestPointCount=${manifest.correspondenceSummary?.crossCuttingTestPointCount ?? "unavailable"}`, `correspondenceSummary.unclassifiedTestPointCount=${manifest.correspondenceSummary?.unclassifiedTestPointCount ?? "unavailable"}`, `correspondenceSummary.duplicateBindingTestPointCount=${manifest.correspondenceSummary?.duplicateBindingTestPointCount ?? "unavailable"}`);
543
652
  }
544
653
  else if (pipeline === "markdown-execute-html") {
654
+ const effectiveCollection = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-effective.json"));
655
+ await assertBackendPytestCollectionFresh(input.cwd, effectiveCollection);
545
656
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
546
657
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
547
658
  const pytestTargets = mappedScripts.map(shellQuote).join(" ");
@@ -15,7 +15,10 @@ function escapeShellSingleQuoted(value) {
15
15
  /**
16
16
  * Build a single shell command that reads `$HARNESS_DAG_RUN_DIR/<fromNodeId>.json`
17
17
  * (injected by the shell executor from runtime meta), parses a single candidate
18
- * verdict line from `assistantText ?? stdout`, and accepts only exact verdict values.
18
+ * verdict from `assistantText ?? stdout`, and accepts only exact verdict values.
19
+ * Sources:
20
+ * - verdict-line: line-based legacy VERDICT protocol
21
+ * - json-review-verdict: structured review JSON with verdict: pass/request-revision
19
22
  * Modes:
20
23
  * - first-non-empty: first non-empty line
21
24
  * - first-verdict-line: first line matching /^VERDICT:/ after trim and optional whole-line Markdown emphasis normalization
@@ -25,6 +28,7 @@ function escapeShellSingleQuoted(value) {
25
28
  */
26
29
  export function buildVerdictGateShellCommand(gate) {
27
30
  const gateLabel = gate.label ?? `${gate.fromNodeId} verdict`;
31
+ const source = gate.source ?? "verdict-line";
28
32
  const lineMode = gate.lineMode ?? "first-non-empty";
29
33
  const candidateNodeIds = [
30
34
  gate.fromNodeId,
@@ -32,8 +36,10 @@ export function buildVerdictGateShellCommand(gate) {
32
36
  ];
33
37
  const config = {
34
38
  label: gateLabel,
39
+ source,
35
40
  lineMode,
36
41
  accept: gate.accept,
42
+ routingAccept: gate.routingAccept ?? [],
37
43
  candidateNodeIds,
38
44
  };
39
45
  const configLiteral = escapeShellSingleQuoted(JSON.stringify(config));
@@ -48,10 +54,12 @@ export function buildVerdictGateShellCommand(gate) {
48
54
  'if(!file){console.error("missing "+cfg.label+" JSON output (tried: "+cfg.candidateNodeIds.join(", ")+")");process.exit(1);}',
49
55
  'const raw=JSON.parse(fs.readFileSync(file,"utf8"));',
50
56
  'const text=String(raw.assistantText ?? raw.stdout ?? "");',
57
+ 'const extractJson=(value)=>{const text=String(value).trim();const fenced=[...text.matchAll(/```(?:json)?\\s*([\\s\\S]*?)\\s*```/gi)];const source=fenced.length===1?String(fenced[0][1]).trim():text;for(let start=0;start<source.length;start+=1){if(source[start]!=="{")continue;let depth=0,inString=false,escaped=false;for(let i=start;i<source.length;i+=1){const ch=source[i];if(inString){if(escaped){escaped=false;}else if(ch==="\\\\"){escaped=true;}else if(ch===String.fromCharCode(34)){inString=false;}continue;}if(ch===String.fromCharCode(34)){inString=true;continue;}if(ch==="{"){depth+=1;}else if(ch===String.fromCharCode(125)){depth-=1;if(depth===0){const candidate=source.slice(start,i+1);try{const parsed=JSON.parse(candidate);if(parsed&&typeof parsed==="object"&&!Array.isArray(parsed))return candidate;}catch{}break;}}}}return "";};',
58
+ 'let first="";',
59
+ 'if(cfg.source==="json-review-verdict"){const jsonText=extractJson(text);if(!jsonText){console.error(cfg.label+" gate blocked: missing JSON review verdict");process.exit(1);}let parsed;try{parsed=JSON.parse(jsonText);}catch(error){console.error(cfg.label+" gate blocked: invalid JSON review verdict: "+error.message);process.exit(1);}first=String(parsed&&parsed.verdict||"");}',
51
60
  'const normalize=(value)=>{const trimmed=String(value).trim();const emphasized=trimmed.match(/^\\*{1,3}\\s*(VERDICT:[^*]+?)\\s*\\*{1,3}$/);return (emphasized?emphasized[1]:trimmed).trim();};',
52
- 'const lines=text.split(/\\r?\\n/).map(normalize);',
53
- 'const first=cfg.lineMode==="first-verdict-line"? (lines.find((value)=>/^VERDICT:/.test(value))??"") : (lines.find((value)=>value.length>0)??"");',
54
- 'if(!cfg.accept.includes(first)){console.error(cfg.label+" gate blocked: "+(first||"missing VERDICT line"));process.exit(1);}',
61
+ 'if(cfg.source!=="json-review-verdict"){const lines=text.split(/\\r?\\n/).map(normalize);first=cfg.lineMode==="first-verdict-line"? (lines.find((value)=>/^VERDICT:/.test(value))??"") : (lines.find((value)=>value.length>0)??"");}',
62
+ 'const routing=Array.isArray(cfg.routingAccept)?cfg.routingAccept:[];if(!cfg.accept.includes(first)&&!routing.includes(first)){console.error(cfg.label+" gate blocked: "+(first|| (cfg.source==="json-review-verdict"?"missing JSON verdict":"missing VERDICT line")));process.exit(1);}',
55
63
  'process.stdout.write(first+"\\n");',
56
64
  ].join("");
57
65
  return `node -e '${escapeShellSingleQuoted(program)}' '${configLiteral}'`;