@tea-agent/loop-agent 0.25.4 → 0.25.6

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 (40) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +60 -0
  3. package/dist/commands/client-recovery.js +209 -62
  4. package/dist/commands/init.js +68 -129
  5. package/dist/executors/dag-pi-executor.js +80 -15
  6. package/dist/executors/model-routing.js +1 -1
  7. package/dist/executors/shell-executor.js +127 -0
  8. package/dist/executors/shell-write-guard.js +21 -7
  9. package/dist/worker/console/repo-fingerprint.js +7 -1
  10. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +1281 -0
  11. package/dist/workflows/dag/backend-test-case-manifest.js +59 -1
  12. package/dist/workflows/dag/backend-test-markdown-workflow.js +236 -16
  13. package/dist/workflows/dag/convergence/controller.js +134 -9
  14. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  15. package/dist/workflows/dag/init-hybrid.js +270 -80
  16. package/dist/workflows/dag/node-execution.js +64 -11
  17. package/dist/workflows/dag/prompt.js +118 -4
  18. package/dist/workflows/dag/retry-policy.js +5 -4
  19. package/dist/workflows/dag/scheduler.js +32 -5
  20. package/dist/workflows/dag/types.js +7 -4
  21. package/dist/workflows/dag/validate.js +3 -2
  22. package/docs/architecture/dag-execution.md +7 -4
  23. package/docs/architecture/runtime-boundaries.md +1 -1
  24. package/docs/init-surface.manifest.json +3 -1
  25. package/docs/templates/README.md +1 -0
  26. package/docs/templates/agent-dag.base.json +1 -1
  27. package/docs/templates/agent-dag.final-verification.json +1 -1
  28. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  29. package/docs/templates/backend-test-dag.json +41 -14
  30. package/docs/templates/frontend-test-dag.json +32 -2
  31. package/docs/templates/hybrid-dag.json +1 -1
  32. package/docs/templates/init-managed-agents.md +137 -0
  33. package/examples/decision-gate-agent-dag.json +1 -1
  34. package/examples/example-dag.json +1 -1
  35. package/examples/hybrid-loop-agent-dag.json +1 -1
  36. package/harness.json +1 -1
  37. package/package.json +1 -1
  38. package/skills/loop-agent/references/command-reference.md +5 -4
  39. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  40. package/skills/loop-agent/references/model-routing.md +1 -1
@@ -4,7 +4,6 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { fileURLToPath } from "node:url";
7
- import { copyDir } from "../shared/copy-dir.js";
8
7
  import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
9
8
  import { loadHarnessManifest } from "../governance/harness.js";
10
9
  import { OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, applyPiRetryMerge, buildOpenCodeTransientRetryPluginSource, inspectPiRetryConfig, parseClientRecoveryMode, runClientRecovery, } from "./client-recovery.js";
@@ -119,6 +118,12 @@ const COMPAT_PROMPTS = {
119
118
  ].join("\n"),
120
119
  };
121
120
  const GOVERNANCE_ROOT_TOKEN = "__LOOP_AGENT_GOVERNANCE_ROOT__";
121
+ const PROJECT_NAME_TOKEN = "__LOOP_AGENT_PROJECT_NAME__";
122
+ /** Package-shipped render source for AGENTS.md managed block; not projected to target repos. */
123
+ const MANAGED_AGENTS_TEMPLATE_PATH = "docs/templates/init-managed-agents.md";
124
+ /** Files under package assets that init must not copy into target projects. */
125
+ const PACKAGE_ONLY_SURFACE_FILES = new Set([MANAGED_AGENTS_TEMPLATE_PATH]);
126
+ let managedAgentsTemplateCache;
122
127
  const INIT_CHECK_ENGINEERING_STRUCTURE_SH = `#!/usr/bin/env bash
123
128
  set -euo pipefail
124
129
 
@@ -798,130 +803,28 @@ function mergeRecord(base, patch) {
798
803
  ...patch,
799
804
  };
800
805
  }
801
- function buildManagedAgentsBlock(input) {
802
- return [
803
- MANAGED_BLOCK_START,
804
- "## loop-agent 治理",
805
- "",
806
- `本仓库已初始化为 \`${input.projectName}\` loop-agent harness 项目。`,
807
- "",
808
- "### 默认立场",
809
- "",
810
- "- 仓库是记录系统:决策、契约、计划、验证、报告和交接应进入可追踪文件。",
811
- "- 一次只推进一个有边界、可验证的工作块;实现前先搜索现有代码、文档、脚本和测试。",
812
- "- Shell 验证是完成依据;模型建议不能替代命令证据。",
813
- "- 保留无关的用户改动,不要回退自己没有做的修改。",
814
- "- 委托模型写入前,必须把写入边界写成结构化 `task.json.allowedPaths` / `task.json.forbiddenPaths`,再审查生成 DAG 的 writer `writeSet`;不要只依赖 `source/执行约束.md` 的自然语言约束。",
815
- "- 本仓库对 openCode 等主会话的定位是 **Compatibility / Operator Assist**:主会话编排 CLI 与只读诊断,**不是**默认实现 agent。",
816
- "",
817
- "### 主会话硬约束(Compatibility / Operator Assist)",
818
- "",
819
- "主会话(含 openCode、Cursor Chat、其他宿主 agent)= **operator-only**;skills 与本文件是纪律文档,**不能**替代 `task.json` / DAG `writeSet` / runtime 执法。",
820
- "",
821
- "| 类别 | 规则 |",
822
- "|---|---|",
823
- "| **允许** | 已发布 `loop-agent` / `agent-worker` CLI;只读 status/doctor/report/inspect/observe;准备 `source/*` 与 `task.json` 边界;human gate;shell 验证与 handoff。 |",
824
- "| **禁止** | 绕过 CLI 用宿主 Edit/Write/ApplyPatch 直接改业务实现;CLI/DAG 失败后「救火改文件」;用聊天自述代替 shell 验证。 |",
825
- "| **失败时** | `dag doctor` / `dag report` / `dag reconcile-run`(及适用 worker reconcile);修正任务源/`task.json`/DAG 后经 CLI 重跑。 |",
826
- "| **实现写入** | 业务代码 **只** 经受治理 DAG writer(`implement-pi` / `repair-pi`)经 `dag run-task` / `run-dag`。 |",
827
- "",
828
- "**永远不要**:`loop-agent` / `agent-worker` 失败 ⇒ 主会话直接改仓库实现。",
829
- "",
830
- "### 自然语言入口路由",
831
- "",
832
- "| 用户表达 | 入口 | 执行动作 |",
833
- "|---|---|---|",
834
- "| loop-agent 初始化 / loop agent 初始化 / loop agent初始化 / 初始化 loop-agent | 初始化 | 完成确定性初始化闭环 |",
835
- "| 初始化更新校验 / 检查初始化更新 / loop-agent 初始化更新校验 / loop agent初始化更新校验 | 更新校验 | 只读报告,不写入 |",
836
- "| 初始化对齐 / 升级后对齐 / reconcile 初始化 / loop-agent 初始化对齐 | 升级对齐 | 自动应用确定性安全动作;活跃 DAG/Worker 或人工决策时零写入 |",
837
- "| 初始化安全更新 / 应用初始化更新 / loop-agent 初始化安全更新 / loop agent初始化安全更新 | 安全更新 | 先 check-update,再只执行确定性安全动作 |",
838
- "| loop-agent 帮我完成 / 帮我实现 / 帮我修复 / 帮我开发 <需求>;使用 loop-agent 完成 <X>;按 loop-agent 流程处理 <X> | 通用需求实现 | 先建任务源与 `task.json.allowedPaths` / `forbiddenPaths`,再 `new-task` → `dag run-task` → `dag validate` → `run-dag`;主会话不得直接修改业务实现 |",
839
- "",
840
- "**更新校验(只读)**:只读执行下面命令;**不得自动**执行 `apply-safe` 或模型合并。",
841
- "",
842
- "```bash",
843
- "loop-agent init check-update --repo-root . --markdown",
844
- "```",
845
- "",
846
- "**升级对齐**:`loop-agent init reconcile --repo-root .`;存在活跃 DAG/Worker、human decisions 时必须零写入。",
847
- "",
848
- "**安全更新**:先 check-update,再 `apply-safe`;surface 缺失才 `--bootstrap-surface`。完成后复查 `init doctor` / `inspect` / `docs audit` 与目标治理检查。",
849
- "",
850
- "```bash",
851
- "loop-agent init check-update --repo-root . --markdown",
852
- "loop-agent init update --repo-root . --apply-safe",
853
- "```",
854
- "",
855
- "### 初始化闭环",
856
- "",
857
- `当用户要求初始化 loop-agent(“初始化 loop-agent”“loop agent 初始化”“loop agent初始化”“loop-agent 初始化”),或发现 \`README.md\` / \`${input.governanceRoot}/verification-matrix.md\` 仍停留在通用初始化状态时,模型应在同一轮工作中完成初始化闭环,不要把补全文档和验证矩阵留给用户交互。`,
858
- "",
859
- "1. 运行 `loop-agent init instructions --repo-root .`。",
860
- "2. 运行 `loop-agent init --repo-root . --profile full --merge`。",
861
- `3. 基于真实项目文件补全根 \`README.md\`、\`${input.governanceRoot}/verification-matrix.md\`,必要时适配 \`scripts/ci-tests.sh\`。`,
862
- "4. 复查 `init doctor` / `inspect` / `docs audit` 与 quick verification。",
863
- "",
864
- "信息不足时写明尚未识别到的事实;不要留下泛化占位符。",
865
- "",
866
- "### 文档收敛",
867
- "",
868
- "完成实现和验证后,必须检查 `README.md`、`AGENTS.md`、`CHANGELOG.md`(如果目标项目维护)、治理文档、skills references、初始化模板和脚本说明是否仍与实际行为一致。只更新与本次变更相关的内容;如果决定不更新,应在交接里写明理由。",
869
- "",
870
- "### 开始顺序",
871
- "",
872
- `pwd → \`README.md\` → \`harness.json\` → \`${input.governanceRoot}/README.md\` → 实现类再读 principles/feature-workflow/verification-matrix;测试纪律读 harness-methodology-*;\`git status\`;最小基线验证。`,
873
- "",
874
- "### Agent DAG 路径",
875
- "",
876
- "```bash",
877
- 'loop-agent new-task <task-id> "任务标题"',
878
- "# write .harness/tasks/<task-id>/source/需求.md",
879
- "# write .harness/tasks/<task-id>/source/执行约束.md",
880
- "loop-agent dag run-task <task-id> --profile auto --strict-models",
881
- "loop-agent dag validate --dag .harness/tasks/<task-id>/dag.json --strict-models --strict-governance",
882
- "loop-agent run-dag --dag .harness/tasks/<task-id>/dag.json --cwd .",
883
- "```",
884
- "",
885
- "`source/需求.md` 与 `source/执行约束.md` 必需;写入前同步 `task.json.allowedPaths` / `task.json.forbiddenPaths` 并审查 writer `writeSet`。",
886
- "",
887
- "凡是影响项目公共契约、执行入口、交付流水线、自动化/治理、数据模型、安全或权限模型、跨模块行为、用户可见工作流的改动,都必须在编辑实现文件前先创建任务、写好两个 source 文件、生成 DAG,并审查 DAG/writeSet。",
888
- "",
889
- "### 任务类型路由(taskKind)",
890
- "",
891
- '- 用户明确提出后端测试、接口/API 测试、pytest,或语境明确为后端的自动化测试时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `"backend-test"`,不得保留默认 `standard`。',
892
- "- `backend-test` 是 `taskKind`,不是 `--profile` 的可选值;运行 `dag run-task` 时继续使用 `--profile auto`。",
893
- "- 仅出现“自动化测试”且无法判断前后端时,先阅读任务源与目标项目技术栈再决定,禁止无条件路由到 `backend-test`。",
894
- '- 用户提示词明确是前端实现需求(例如前端页面、UI、组件或交互开发)时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `"frontend-implementation"`,不得保留默认 `standard`。',
895
- "- `frontend-implementation` 是 `taskKind`,不是 `--profile` 的可选值;运行 `dag run-task` 时继续使用 `--profile auto`,也可按需显式选择 `minimal` / `standard` / `reviewed` / `supervised`,不要把业务模板名当作 profile。",
896
- '- 前端自动化测试(浏览器/UI 自动化、Playwright、E2E)继续使用 `taskKind: "frontend-test"`,不得设置为 `frontend-implementation`。',
897
- "",
898
- "### 运行看板(只读)",
899
- "",
900
- "```bash",
901
- "agent-worker console # 默认 repo=当前目录,port=8790",
902
- "agent-worker console serve --repo . --port 8790 # 兼容入口,等价于上面裸入口",
903
- "```",
904
- "",
905
- "浏览器打开 `http://127.0.0.1:8790/`;检视面为 `http://127.0.0.1:8790/inspect/`。默认绑定本机 `127.0.0.1`;不要直接暴露到公开网络。端口被占用时不会自动更换,请用 `--port <port>` 显式指定。`agent-worker observe serve` 仅为兼容入口。",
906
- "",
907
- "### DAG 诊断与收口",
908
- "",
909
- "用 `loop-agent dag report --run-id <run-id> --markdown` 读 facts;失败/paused 用 `loop-agent dag doctor --run-id <run-id> --markdown`;生命周期对齐用 `loop-agent dag reconcile-run`。失败 run 用 `dag closeout-draft` 生成 failure handoff,不要写成成功 closeout。",
910
- "",
911
- "恢复:doctor/report → classify → reconcile/replan → CLI 重跑 → shell verify。**禁止**把主会话直接 Edit 业务代码当作恢复手段。",
912
- "",
913
- "### 运行态与验证",
914
- "",
915
- "- `.harness/tasks/`、`.harness/dag-runs/`、`.harness/runs/` 保存运行事实;已完成事实只读。",
916
- "- `.agents/skills/` 为本地 skills;缺失时可回退 npm 包内置。",
917
- `- 验证命令选择:\`${input.governanceRoot}/verification-matrix.md\`。常用:\`bash scripts/check-repo.sh\`、\`bash scripts/ci.sh\`、\`loop-agent inspect\`、\`loop-agent doctor\`、\`loop-agent docs audit\`。`,
918
- "- `scripts/ci-tests.sh` 必须反映目标项目真实工具链。Windows 脚本用 Git Bash;仓库引用用 `/`。",
919
- "",
920
- "### 交接",
921
- "",
922
- `交接写清变更、原因、验证证据、影响面、风险与下一步;长期结论进入 \`${input.governanceRoot}/progress\`、reports、exec-plans、decisions。`,
923
- MANAGED_BLOCK_END,
924
- ].join("\n");
806
+ async function loadManagedAgentsTemplate(assetRoot) {
807
+ if (managedAgentsTemplateCache !== undefined)
808
+ return managedAgentsTemplateCache;
809
+ const templatePath = path.join(assetRoot, MANAGED_AGENTS_TEMPLATE_PATH);
810
+ const raw = await readFile(templatePath, "utf-8");
811
+ // Prefer a START marker on its own line so header notes mentioning the token are ignored.
812
+ const lines = raw.split(/\r?\n/);
813
+ const startLine = lines.findIndex((line) => line.trim() === MANAGED_BLOCK_START);
814
+ const endLine = startLine >= 0
815
+ ? lines.findIndex((line, index) => index > startLine && line.trim() === MANAGED_BLOCK_END)
816
+ : -1;
817
+ if (startLine < 0 || endLine < 0) {
818
+ throw new Error(`managed agents template missing LOOP_AGENT_INIT markers: ${MANAGED_AGENTS_TEMPLATE_PATH}`);
819
+ }
820
+ managedAgentsTemplateCache = lines.slice(startLine, endLine + 1).join("\n");
821
+ return managedAgentsTemplateCache;
822
+ }
823
+ async function buildManagedAgentsBlock(input) {
824
+ const template = await loadManagedAgentsTemplate(input.assetRoot);
825
+ return template
826
+ .replaceAll(PROJECT_NAME_TOKEN, input.projectName)
827
+ .replaceAll(GOVERNANCE_ROOT_TOKEN, input.governanceRoot);
925
828
  }
926
829
  function mergeManagedBlock(existing, block) {
927
830
  const start = existing.indexOf(MANAGED_BLOCK_START);
@@ -1137,11 +1040,39 @@ async function copyFileIfMissing(input) {
1137
1040
  async function copyDirMerge(input) {
1138
1041
  const source = path.join(input.assetRoot, input.sourceRelativePath);
1139
1042
  const target = path.join(input.repoRoot, input.targetRelativePath);
1140
- await copyDir(source, target);
1043
+ const sourcePrefix = `${input.sourceRelativePath.replaceAll(path.sep, "/").replace(/\/$/, "")}/`;
1044
+ await copyDirSkippingPackageOnly({
1045
+ source,
1046
+ target,
1047
+ packageRelativePrefix: sourcePrefix,
1048
+ });
1141
1049
  input.written.push(input.targetRelativePath.endsWith("/")
1142
1050
  ? input.targetRelativePath
1143
1051
  : `${input.targetRelativePath}/`);
1144
1052
  }
1053
+ /** Like copyDir, but skips package-only assets that must not land in target projects. */
1054
+ async function copyDirSkippingPackageOnly(input) {
1055
+ await mkdir(input.target, { recursive: true });
1056
+ const entries = await readdir(input.source, { withFileTypes: true });
1057
+ for (const entry of entries) {
1058
+ const srcPath = path.join(input.source, entry.name);
1059
+ const destPath = path.join(input.target, entry.name);
1060
+ const packageRelative = `${input.packageRelativePrefix}${entry.name}`;
1061
+ if (entry.isDirectory()) {
1062
+ await copyDirSkippingPackageOnly({
1063
+ source: srcPath,
1064
+ target: destPath,
1065
+ packageRelativePrefix: `${packageRelative}/`,
1066
+ });
1067
+ continue;
1068
+ }
1069
+ if (PACKAGE_ONLY_SURFACE_FILES.has(packageRelative))
1070
+ continue;
1071
+ if (entry.isFile() || entry.isSymbolicLink()) {
1072
+ await copyFile(srcPath, destPath);
1073
+ }
1074
+ }
1075
+ }
1145
1076
  async function ensureHarnessDirs(repoRoot, written) {
1146
1077
  const dirs = [
1147
1078
  ".harness/prompts",
@@ -1289,7 +1220,10 @@ async function discoverCopiedSurfaceFiles(assetRoot) {
1289
1220
  continue;
1290
1221
  const files = await listRelativeFiles(rootPath);
1291
1222
  for (const relativePath of files) {
1292
- discovered.push(`${prefix}${relativePath}`);
1223
+ const manifestPath = `${prefix}${relativePath}`;
1224
+ if (PACKAGE_ONLY_SURFACE_FILES.has(manifestPath))
1225
+ continue;
1226
+ discovered.push(manifestPath);
1293
1227
  }
1294
1228
  }
1295
1229
  return discovered.sort();
@@ -1335,7 +1269,8 @@ async function buildDesiredSurfaceContent(input) {
1335
1269
  }
1336
1270
  if (manifestPath === "AGENTS.md") {
1337
1271
  return {
1338
- content: buildManagedAgentsBlock({
1272
+ content: await buildManagedAgentsBlock({
1273
+ assetRoot: input.assetRoot,
1339
1274
  projectName: input.projectName,
1340
1275
  governanceRoot: input.governanceRoot,
1341
1276
  }),
@@ -2427,7 +2362,11 @@ export async function initializeLoopAgentProject(options) {
2427
2362
  await writeText({
2428
2363
  repoRoot,
2429
2364
  relativePath: "AGENTS.md",
2430
- content: mergeManagedBlock(existingAgents, buildManagedAgentsBlock({ projectName, governanceRoot })),
2365
+ content: mergeManagedBlock(existingAgents, await buildManagedAgentsBlock({
2366
+ assetRoot,
2367
+ projectName,
2368
+ governanceRoot,
2369
+ })),
2431
2370
  merge: true,
2432
2371
  written,
2433
2372
  skipped,
@@ -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,7 @@ 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";
13
14
  import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
14
15
  import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
15
16
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
@@ -20,6 +21,7 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
20
21
  import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
21
22
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
22
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";
23
25
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
24
26
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
25
27
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
@@ -372,6 +374,14 @@ async function readRequiredRunReport(reportsDir, filename) {
372
374
  throw new Error(`missing required upstream report: reports/${filename}`);
373
375
  }
374
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
+ }
375
385
  async function executeBackendTestPipeline(input, meta) {
376
386
  const pipeline = input.task.shell?.backendTestPipeline;
377
387
  const started = Date.now();
@@ -423,6 +433,48 @@ async function executeBackendTestPipeline(input, meta) {
423
433
  durationMs: Date.now() - started,
424
434
  };
425
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
+ }
426
478
  }
427
479
  else if (pipeline === "markdown-traceability") {
428
480
  let report;
@@ -434,6 +486,60 @@ async function executeBackendTestPipeline(input, meta) {
434
486
  }
435
487
  const reportPath = await writeRunReport(meta.runDir, "backend-test-traceability.md", report);
436
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"}`, `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"}`);
437
543
  }
438
544
  else if (pipeline === "markdown-execute-html") {
439
545
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
@@ -601,14 +707,18 @@ async function executeBackendTestPipeline(input, meta) {
601
707
  });
602
708
  const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
603
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");
604
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");
605
713
  const htmlContent = renderBackendTestHtml({
606
714
  title: meta.spec.title,
607
715
  parsed,
608
716
  cases,
609
717
  environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8"),
610
718
  caseValidationSummary,
719
+ caseCoverageSummary,
611
720
  traceabilitySummary,
721
+ correspondenceSummary,
612
722
  });
613
723
  const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
614
724
  const facts = renderBackendTestFacts({
@@ -618,7 +728,9 @@ async function executeBackendTestPipeline(input, meta) {
618
728
  htmlRelativePath: "reports/backend-test.html",
619
729
  htmlContent,
620
730
  caseValidationSummary,
731
+ caseCoverageSummary,
621
732
  traceabilitySummary,
733
+ correspondenceSummary,
622
734
  });
623
735
  const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
624
736
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
@@ -681,7 +793,9 @@ async function executeBackendTestPipeline(input, meta) {
681
793
  parsed,
682
794
  metrics: l5Metrics,
683
795
  caseValidationSummary,
796
+ caseCoverageSummary,
684
797
  traceabilitySummary,
798
+ correspondenceSummary,
685
799
  failures: failureSummaries.length > 0 ? failureSummaries : undefined,
686
800
  });
687
801
  const l5Path = await writeRunReport(meta.runDir, "backend-test-l5-dashboard.html", l5Html);
@@ -1159,6 +1273,16 @@ async function executeFrontendTestCaseChecklist(input, meta) {
1159
1273
  return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
1160
1274
  }
1161
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
+ }
1162
1286
  async function executeFrontendTestHtmlReport(input, meta) {
1163
1287
  const started = Date.now();
1164
1288
  try {
@@ -1307,6 +1431,9 @@ export async function executeDagShellNode(input, meta) {
1307
1431
  if (shell?.frontendTestEvidenceValidation) {
1308
1432
  return executeFrontendTestEvidenceValidation(input);
1309
1433
  }
1434
+ if (shell?.frontendTestL5Report) {
1435
+ return executeFrontendTestL5Report(input, meta);
1436
+ }
1310
1437
  if (shell?.frontendTestHtmlReport) {
1311
1438
  return executeFrontendTestHtmlReport(input, meta);
1312
1439
  }