@tea-agent/loop-agent 0.29.0 → 0.29.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.29.2] - 2026-08-07
6
+
7
+ ### 重点更新
8
+
9
+ - 优化后端测试生成 writer 的续写重试机制,直接内嵌具体目标路径以恢复输出,不再依赖读取受限路径
10
+ - 修复三处完整性门禁(Completeness Gate)误报,避免将正确的测试生成误判为残缺写集
11
+
12
+ ### 改进
13
+
14
+ - 后端测试生成 writer(N2 MD / N5 pytest)在输出截断或残缺写集后,续写重试直接内嵌具体目标路径清单,不再依赖读取 `.harness/**` 路径
15
+ - 即使遇到结果为空或输出失败的异常,也会评估完整性门禁,将可恢复的部分写入升格为不完整写集,避免重试空转
16
+
17
+ ### 修复
18
+
19
+ - 修复 README 正文提及的编号式文件名被误判为缺失模块的问题
20
+ - 修复覆盖率矩阵(Coverage Matrix)表头识别被覆盖率范围(Coverage Scope)数据行遮蔽的问题
21
+ - 修复合法多行 `def` 的 pytest 脚本被误判为截断的问题(真正截断现仍由括号配平计数准确捕获)
22
+
23
+ ## [0.29.1] - 2026-08-07
24
+
25
+ ### 重点更新
26
+
27
+ - 前端测试默认执行流程大幅精简,收敛至约 11 个静态节点,合并了多项检查与报告步骤
28
+ - 前端测试浏览器默认改为无头(headless)模式,减少环境干扰
29
+ - 优化证据缺失处理逻辑,非路径逃逸的异常不再强制阻断报告生成
30
+ - 修复文档说明被误判为可执行命令的问题,提升执行稳定性
31
+
32
+ ### 新增
33
+
34
+ - 新增前端测试标准场景物化节点,支持执行后针对阻塞或缺失证据进行最多两轮有界重跑
35
+ - 新增前端测试标准场景规范模板(frontend-test-standard-scenarios.v1.json)
36
+ - 前端测试独占节点支持 tools-only 写保护策略,在保留工具级写沙箱的同时跳过 Git 基线的硬失败限制
37
+
38
+ ### 改进
39
+
40
+ - 前端测试默认 DAG 结构精简至约 11 个静态节点:合并双重重跑、检查清单与清单文件、证据与结果、L5 与 HTML 报告
41
+ - 前端测试主 HTML 报告不再依赖回顾(retrospect)节点,回顾功能改为可选开启
42
+ - 权威清单文件(manifest.json)仅记录规范化后的测试用例,并在未配置重试时省略相关静态节点
43
+ - 强化运营 HTML 报告的状态过滤功能,并优化失败与阻塞原因的展示
44
+
45
+ ### 修复
46
+
47
+ - 修复前端测试检查清单将不可用或阻塞状态的说明文字误判为非法可执行命令的问题
48
+ - 修复前端测试收尾阶段因证据缺失或格式错误导致强制失败的问题,现仅对路径逃逸强制报错,其余降级为建议性提示
49
+ - 修复前端测试合并后清单文件物化异常的问题,确保仅写入规范化用例并恢复负向测试用例
50
+ - 回退误改的全局默认 Pi 模型(DEFAULT_PI_MODEL),将其恢复为 glm-5.2,模型路由统一交由 harness 复杂度矩阵处理
51
+
5
52
  ## [0.29.0] - 2026-08-07
6
53
 
7
54
  ### 重点更新
@@ -2217,7 +2217,7 @@ function buildTargetFeatureWorkflow(input) {
2217
2217
  '- `taskKind: "backend-test"` selects the dedicated backend test DAG. Its Pi nodes analyze requirements, generate and review backend cases, generate pytest, and retrospect on results; shell gate/execution nodes enforce the review verdict and run the target project\'s pytest. The backend test templates (`backend-test-dag.json` and the `backend-test-dag.*.prompt.md` files) ship inside the loop-agent package as static references and are projected to target projects under the governance `templates/` directory.',
2218
2218
  '- `taskKind: "knowledge-sync"` selects the Feature-scoped test-knowledge write-back DAG (collect → draft → validate → apply → pointer). Bind `featureId` in `task.json` (or hardConstraints / requirement text). It writes only under `features/<featureId>/…` after final verification evidence exists.',
2219
2219
  '- `taskKind: "knowledge-graph-bootstrap"` selects the business knowledge-graph bootstrap DAG (preflight → inventory → propose → validate → review → gate → promote → materialize). AI writes only `knowledge/bootstrap/staging/**`; promote is merge-new-only.',
2220
- '- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headed <base-url>`; the generic playwright-cli skill is unchanged.',
2220
+ '- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headless <base-url>`; the generic playwright-cli skill is unchanged.',
2221
2221
  "- Only eligible read-only Pi nodes (planner, scout, reviewer, verifier, closeout with no write-capable tool profile) receive the conservative automatic retry policy. Supervisor, implementer, writer, docs-only, dynamic, shell, static, and decision-gate nodes are not retried automatically. Eligible nodes cannot write repository files; the controller only records immutable attempt evidence under `.harness/dag-runs/<state>/<run-id>/<node-id>/attempt-<n>.json`.",
2222
2222
  "",
2223
2223
  "Use the package-backed public knowledge CLI for graph operations. Do not require target projects to run package-only kb runtime scripts:",
@@ -340,7 +340,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
340
340
  }
341
341
  let beforeStatus;
342
342
  let beforePathFingerprints;
343
- if (isWriteTask) {
343
+ /** tools-only: keep SDK path sandbox; skip git baseline + post-diff write-guard hard fail. */
344
+ const skipGitWriteGuard = isWriteTask && input.task.writeGuardPolicy === "tools-only";
345
+ if (isWriteTask && !skipGitWriteGuard) {
344
346
  try {
345
347
  beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
346
348
  const beforeSnapshot = snapshotGitStatusPorcelain(beforeStatus);
@@ -683,9 +685,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
683
685
  });
684
686
  }
685
687
  let completenessFailure;
686
- if (mapped.ok &&
687
- writeGuardOk &&
688
- !writerOutcomeViolation &&
688
+ if (writeGuardOk &&
689
689
  isBackendTestCompletenessRetryCandidate(input.task)) {
690
690
  try {
691
691
  const progress = input.task.id === "generate-backend-pytest-pi"
@@ -699,12 +699,25 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
699
699
  });
700
700
  if (progress.status !== "PASS") {
701
701
  const classified = classifyBackendTestWriterCompletenessFailure(progress);
702
- completenessFailure = {
703
- failureCategory: classified.recoverable
704
- ? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
705
- : "invalid-output",
706
- detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
707
- };
702
+ // A recoverable partial write set (missing/broken files) always wins
703
+ // over empty-output / invalid-output / writer-empty-diff: it carries
704
+ // the concrete target paths the continuation attempt needs.
705
+ if (classified.recoverable) {
706
+ completenessFailure = {
707
+ failureCategory: INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
708
+ detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
709
+ };
710
+ }
711
+ else if (mapped.ok && !writerOutcomeViolation) {
712
+ // Non-recoverable completeness issue only overrides a clean
713
+ // successful path; on an already-failed attempt, keep the
714
+ // executor's original category so retries reflect the real
715
+ // cause (empty-output / invalid-output / writer-empty-diff).
716
+ completenessFailure = {
717
+ failureCategory: "invalid-output",
718
+ detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
719
+ };
720
+ }
708
721
  }
709
722
  }
710
723
  catch (error) {
@@ -744,7 +757,21 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
744
757
  ? WRITER_EMPTY_DIFF_RETRY_CATEGORY
745
758
  : "invalid-output"
746
759
  : "write-guard"
747
- : mapped.failureCategory,
760
+ : // When the attempt already failed with a writer-style category
761
+ // (empty-output / invalid-output / writer-empty-diff) but the workspace
762
+ // shows a recoverable partial write set, prefer incomplete-write-set so
763
+ // the continuation is retryable and carries the embedded target paths.
764
+ // Provider/transport failures (quota, auth, network, timeout,
765
+ // rate-limit, unavailable) keep their original category so a provider
766
+ // outage is never masked as a recoverable local write problem.
767
+ writeGuardOk &&
768
+ completenessFailure?.failureCategory ===
769
+ INCOMPLETE_WRITE_SET_RETRY_CATEGORY &&
770
+ (mapped.failureCategory === "empty-output" ||
771
+ mapped.failureCategory === "invalid-output" ||
772
+ mapped.failureCategory === WRITER_EMPTY_DIFF_RETRY_CATEGORY)
773
+ ? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
774
+ : mapped.failureCategory,
748
775
  durationMs: mapped.durationMs || Date.now() - started,
749
776
  };
750
777
  }
@@ -197,15 +197,21 @@ function validateOpenArgs(args, baseUrl) {
197
197
  for (const arg of args) {
198
198
  assertNoControlMeta(arg);
199
199
  assertNoSessionFlag(arg);
200
- if (arg === "--browser=chrome" || arg === "--headed") {
200
+ if (arg === "--browser=chrome" || arg === "--headless") {
201
201
  flags.add(arg);
202
202
  normalized.push(arg);
203
203
  continue;
204
204
  }
205
+ // Accept legacy --headed but normalize to headless for frontend-test CI/dogfood.
206
+ if (arg === "--headed") {
207
+ flags.add("--headless");
208
+ if (!normalized.includes("--headless"))
209
+ normalized.push("--headless");
210
+ continue;
211
+ }
205
212
  if (arg.startsWith("--browser=") ||
206
- arg === "--browser" ||
207
- arg === "--headless") {
208
- throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headed only");
213
+ arg === "--browser") {
214
+ throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headless only");
209
215
  }
210
216
  if (/^[a-z][a-z0-9+.-]*:/i.test(arg) || arg.startsWith("http")) {
211
217
  url = arg;
@@ -213,12 +219,12 @@ function validateOpenArgs(args, baseUrl) {
213
219
  }
214
220
  throw new PlaywrightCliPolicyError("open-args", `unsupported open argument: ${arg}`);
215
221
  }
216
- if (!flags.has("--browser=chrome") || !flags.has("--headed")) {
222
+ if (!flags.has("--browser=chrome") || !flags.has("--headless")) {
217
223
  // controller injects required flags when missing from model args
218
224
  if (!flags.has("--browser=chrome"))
219
225
  normalized.unshift("--browser=chrome");
220
- if (!flags.has("--headed"))
221
- normalized.push("--headed");
226
+ if (!flags.has("--headless"))
227
+ normalized.push("--headless");
222
228
  }
223
229
  if (!url) {
224
230
  throw new PlaywrightCliPolicyError("open-url-required", "open requires an absolute http(s) URL");
@@ -509,7 +515,7 @@ export function preparePlaywrightCliArgv(input, ctx) {
509
515
  catch {
510
516
  throw new PlaywrightCliPolicyError("goto-url-invalid", "goto requires a URL relative to the controller baseUrl");
511
517
  }
512
- const openLike = validateOpenArgs(["--browser=chrome", "--headed", target], ctx.baseUrl);
518
+ const openLike = validateOpenArgs(["--browser=chrome", "--headless", target], ctx.baseUrl);
513
519
  return openLike[openLike.length - 1];
514
520
  });
515
521
  if (args.length !== 1) {
@@ -18,6 +18,7 @@ import { materializeFrontendImplementationContract } from "../workflows/dag/fron
18
18
  import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
19
19
  import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
20
20
  import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
21
+ import { materializeFrontendTestCaseManifest } from "../workflows/dag/frontend-test-case-manifest.js";
21
22
  import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
22
23
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
23
24
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
@@ -1826,6 +1827,149 @@ async function executeFrontendTestEvidenceValidation(input) {
1826
1827
  };
1827
1828
  }
1828
1829
  }
1830
+ async function executeFrontendTestCaseManifest(input, meta) {
1831
+ const started = Date.now();
1832
+ try {
1833
+ const gate = input.task.shell?.frontendTestCaseManifest ?? {};
1834
+ const declaredAcIds = gate.declaredAcIds ??
1835
+ (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
1836
+ const checklist = await validateFrontendCaseChecklist({
1837
+ workspaceRoot: input.cwd,
1838
+ declaredAcIds,
1839
+ });
1840
+ if (checklist.issues.length) {
1841
+ return {
1842
+ ok: false,
1843
+ stdout: "",
1844
+ stderr: `frontend-test checklist blocked: ${JSON.stringify(checklist.issues)}`,
1845
+ failureCategory: "invalid-output",
1846
+ durationMs: Date.now() - started,
1847
+ };
1848
+ }
1849
+ const materialized = await materializeFrontendTestCaseManifest({
1850
+ workspaceRoot: input.cwd,
1851
+ maxCases: gate.maxCases,
1852
+ declaredAcIds,
1853
+ });
1854
+ return {
1855
+ ok: true,
1856
+ stdout: `${JSON.stringify({ cases: materialized.cases })}
1857
+ `,
1858
+ stderr: "",
1859
+ failureCategory: "success",
1860
+ durationMs: Date.now() - started,
1861
+ };
1862
+ }
1863
+ catch (error) {
1864
+ return {
1865
+ ok: false,
1866
+ stdout: "",
1867
+ stderr: error instanceof Error ? error.message : String(error),
1868
+ failureCategory: "invalid-output",
1869
+ durationMs: Date.now() - started,
1870
+ };
1871
+ }
1872
+ }
1873
+ /** Path-escape / cross-case evidence errors must hard-fail finalize (same class as validate.hardFail). */
1874
+ function isFrontendTestEvidencePathHardError(error) {
1875
+ const message = error instanceof Error ? error.message : String(error);
1876
+ return /unsafe evidence|escapes (?:evidence root|its allowed root)|evidence realpath|cross-case/i.test(message);
1877
+ }
1878
+ /**
1879
+ * Lean finalize: evidence validate + result materialize in one shell.
1880
+ * - Case-level missing/malformed evidence is advisory (issues / advisoryFindings);
1881
+ * the node still succeeds so reports can render incomplete/failed outcomes.
1882
+ * - Path escape / unsafe evidence roots hard-fail the node.
1883
+ * - Controller/contract errors (missing sourceBinding, empty manifest) still fail closed.
1884
+ */
1885
+ async function executeFrontendTestResultFinalize(input, meta) {
1886
+ const started = Date.now();
1887
+ let advisoryStdout = "";
1888
+ try {
1889
+ const evidence = await validateFrontendCaseEvidence({
1890
+ workspaceRoot: input.cwd,
1891
+ });
1892
+ advisoryStdout = `frontend case evidence validation cases=${evidence.cases} findings=${evidence.issues.length}${evidence.issues.length ? ` issues=${JSON.stringify(evidence.issues)}` : ""}`;
1893
+ // Advisory findings must not block materialize; only hardFail (path escape) does.
1894
+ if (evidence.hardFail) {
1895
+ return {
1896
+ ok: false,
1897
+ stdout: advisoryStdout,
1898
+ stderr: `frontend-test evidence hard-fail: ${JSON.stringify(evidence.issues)}`,
1899
+ failureCategory: "nonzero-exit",
1900
+ durationMs: Date.now() - started,
1901
+ };
1902
+ }
1903
+ const artifact = await materializeFrontendTestResult({
1904
+ runDir: meta.runDir,
1905
+ workspaceRoot: input.cwd,
1906
+ artifactName: "frontend-test-result.json",
1907
+ outputDir: "contracts",
1908
+ sourceBinding: meta.spec.sourceBinding,
1909
+ });
1910
+ return {
1911
+ ok: true,
1912
+ stdout: `${advisoryStdout}\nStructured artifact: ${artifact.path}\nSchema: ${artifact.schemaId}\nSHA-256: ${artifact.sha256}`,
1913
+ stderr: "",
1914
+ failureCategory: "success",
1915
+ durationMs: Date.now() - started,
1916
+ };
1917
+ }
1918
+ catch (error) {
1919
+ const detail = error instanceof Error ? error.message : String(error);
1920
+ if (isFrontendTestEvidencePathHardError(error)) {
1921
+ return {
1922
+ ok: false,
1923
+ stdout: advisoryStdout,
1924
+ stderr: `frontend-test evidence hard-fail: ${detail}`,
1925
+ failureCategory: "nonzero-exit",
1926
+ durationMs: Date.now() - started,
1927
+ };
1928
+ }
1929
+ return {
1930
+ ok: false,
1931
+ stdout: advisoryStdout,
1932
+ stderr: detail,
1933
+ failureCategory: "invalid-output",
1934
+ durationMs: Date.now() - started,
1935
+ };
1936
+ }
1937
+ }
1938
+ async function executeFrontendTestReports(input, meta) {
1939
+ const started = Date.now();
1940
+ try {
1941
+ const writeL5 = input.task.shell?.frontendTestReports?.l5 !== false;
1942
+ const lines = [];
1943
+ if (writeL5) {
1944
+ const l5 = await renderFrontendTestL5Report({
1945
+ workspaceRoot: input.cwd,
1946
+ runDir: meta.runDir,
1947
+ });
1948
+ lines.push(`Frontend L-5 report: ${l5.htmlPath}\nMarkdown: ${l5.markdownPath}\nStatus: ${l5.metrics.status}`);
1949
+ }
1950
+ const report = await renderFrontendTestHtmlReport({
1951
+ workspaceRoot: input.cwd,
1952
+ runDir: meta.runDir,
1953
+ });
1954
+ lines.push(`Frontend test report: ${report.htmlPath}\nMarkdown: ${report.markdownPath}\nOutcome: ${report.outcome}\nCases: ${report.caseCount}`);
1955
+ return {
1956
+ ok: true,
1957
+ stdout: lines.join("\n"),
1958
+ stderr: "",
1959
+ failureCategory: "success",
1960
+ durationMs: Date.now() - started,
1961
+ };
1962
+ }
1963
+ catch (error) {
1964
+ return {
1965
+ ok: false,
1966
+ stdout: "",
1967
+ stderr: error instanceof Error ? error.message : String(error),
1968
+ failureCategory: "invalid-output",
1969
+ durationMs: Date.now() - started,
1970
+ };
1971
+ }
1972
+ }
1829
1973
  async function executeFrontendLintBaseline(input, meta) {
1830
1974
  const started = Date.now();
1831
1975
  const shell = input.task.shell;
@@ -2062,15 +2206,24 @@ export async function executeDagShellNode(input, meta) {
2062
2206
  if (shell?.frontendTestCaseChecklist) {
2063
2207
  return executeFrontendTestCaseChecklist(input, meta);
2064
2208
  }
2209
+ if (shell?.frontendTestCaseManifest) {
2210
+ return executeFrontendTestCaseManifest(input, meta);
2211
+ }
2065
2212
  if (shell?.frontendTestEvidenceValidation) {
2066
2213
  return executeFrontendTestEvidenceValidation(input);
2067
2214
  }
2215
+ if (shell?.frontendTestResultFinalize) {
2216
+ return executeFrontendTestResultFinalize(input, meta);
2217
+ }
2068
2218
  if (shell?.frontendTestL5Report) {
2069
2219
  return executeFrontendTestL5Report(input, meta);
2070
2220
  }
2071
2221
  if (shell?.frontendTestHtmlReport) {
2072
2222
  return executeFrontendTestHtmlReport(input, meta);
2073
2223
  }
2224
+ if (shell?.frontendTestReports) {
2225
+ return executeFrontendTestReports(input, meta);
2226
+ }
2074
2227
  if (shell?.backendTestPipeline) {
2075
2228
  return executeBackendTestPipelineWithWriteGuard(input, meta);
2076
2229
  }
@@ -83,6 +83,27 @@ export const frontendTestConfigSchema = z.object({
83
83
  * Default false: pipeline success is result materialize + retrospect report, not full green.
84
84
  */
85
85
  strictOutcomeGate: z.boolean().optional(),
86
+ /**
87
+ * Maximum rerun attempts for blocked or missing-result frontend cases.
88
+ * Defaults to 2; the static graph always carries a single rerun select+map
89
+ * pair and bounds candidates within that pair via this value.
90
+ */
91
+ maxRerunAttempts: z.number().int().min(0).max(4).optional(),
92
+ /**
93
+ * Report chain toggles for the lean frontend-test DAG.
94
+ * - retrospect: opt-in Pi retrospective node (default off). Pipeline success
95
+ * is frontend-test-result-v1 + main HTML/MD report, not the retrospect.
96
+ * - l5: write the deterministic L-5 dashboard from the merged reports node
97
+ * (default true; the merged reports node always renders the main
98
+ * HTML/MD report regardless of this flag).
99
+ */
100
+ reports: z
101
+ .object({
102
+ retrospect: z.boolean().optional(),
103
+ l5: z.boolean().optional(),
104
+ })
105
+ .strict()
106
+ .optional(),
86
107
  });
87
108
  export const convergenceConfigSchema = z.object({
88
109
  enabled: z.boolean().optional().default(false),
@@ -2,6 +2,17 @@ import { createHash } from "node:crypto";
2
2
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
+ /**
6
+ * Maps a backend-test generation writer task id to its writer-progress role.
7
+ * Returns undefined for nodes that are not completeness-gated generators.
8
+ */
9
+ export function backendTestWriterProgressRoleForTask(taskId) {
10
+ if (taskId === "generate-backend-md-cases-pi")
11
+ return "md-generate";
12
+ if (taskId === "generate-backend-pytest-pi")
13
+ return "pytest-generate";
14
+ return undefined;
15
+ }
5
16
  import { expectedBackendTestPytestScriptForMarkdownModule, normalizeBackendTestModuleStem, } from "./backend-test-markdown-workflow.js";
6
17
  export const BACKEND_TEST_WRITER_PROGRESS_SCHEMA_ID = "backend-test-writer-progress-v1";
7
18
  export const BACKEND_TEST_OUTPUT_LIMIT_RECOVERY_REPORT = "backend-test-output-limit-recovery.md";
@@ -42,7 +53,21 @@ function hasMarkdownTable(section, headerNeedle) {
42
53
  .replaceAll("\r\n", "\n")
43
54
  .replaceAll("\r", "\n")
44
55
  .split("\n");
45
- const headerIndex = lines.findIndex((line) => line.toLowerCase().includes(headerNeedle.toLowerCase()));
56
+ // A markdown table header row must itself be a pipe-delimited row (start
57
+ // and end with `|`) AND be immediately followed by a separator row
58
+ // (`|---|`). Find the first row that satisfies both and contains the
59
+ // needle, so a Coverage Scope data cell like `| Affected Rule Keys |`
60
+ // (which mentions the needle but is not a header) does not shadow the
61
+ // real Coverage Matrix header further down.
62
+ const headerIndex = lines.findIndex((line, index) => {
63
+ const trimmed = line.trim();
64
+ const next = (lines[index + 1] ?? "").trim();
65
+ return (trimmed.startsWith("|") &&
66
+ trimmed.endsWith("|") &&
67
+ /^\|[-: |]+$/.test(next) &&
68
+ /-{3,}/.test(next) &&
69
+ line.toLowerCase().includes(headerNeedle.toLowerCase()));
70
+ });
46
71
  if (headerIndex < 0)
47
72
  return false;
48
73
  const header = lines[headerIndex] ?? "";
@@ -51,19 +76,50 @@ function hasMarkdownTable(section, headerNeedle) {
51
76
  /-{3,}/.test(separator) &&
52
77
  separator.includes("|"));
53
78
  }
79
+ /**
80
+ * A README module stem must be a stable lowercase business stem such as
81
+ * `health` or `resource_notes`. Reject tokens that are purely numeric, look
82
+ * like Case IDs, or were clearly lifted from prose examples (e.g. `1`,
83
+ * `9`, `BE-RN-001`). This prevents the completeness gate from inventing
84
+ * phantom missing modules when the model discusses forbidden filenames
85
+ * ("do not create 1.md / 9.md") inside the README body.
86
+ */
87
+ function looksLikeValidModuleStem(raw) {
88
+ if (!raw)
89
+ return false;
90
+ const stem = normalizeBackendTestModuleStem(raw);
91
+ if (stem.toLowerCase() === "readme")
92
+ return false;
93
+ if (!/^[a-z][a-z0-9_]*$/.test(stem))
94
+ return false;
95
+ if (/^(?:be|tp|ac|req|br)[_-]/i.test(stem))
96
+ return false;
97
+ return true;
98
+ }
54
99
  function extractModuleStemsFromReadme(readme) {
55
100
  const stems = [];
56
- for (const match of readme.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
57
- const stem = match[1];
58
- if (stem && stem.toLowerCase() !== "readme")
59
- stems.push(stem);
60
- }
61
- for (const match of readme.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
62
- if (match[1])
63
- stems.push(match[1]);
101
+ // Only trust testcase/md/<stem>.md mentions that appear inside markdown
102
+ // table rows (`| ... testcase/md/x.md ... |`) or as canonical relative
103
+ // links (`[label](./x.md)`). Free-form prose mentions such as a recovery
104
+ // note listing `testcase/md/1.md` must NOT be treated as authoritative
105
+ // module references, otherwise the gate invents phantom missing modules.
106
+ const tableRowLines = readme
107
+ .replaceAll("\r\n", "\n")
108
+ .replaceAll("\r", "\n")
109
+ .split("\n")
110
+ .filter((line) => line.includes("|"));
111
+ for (const line of tableRowLines) {
112
+ for (const match of line.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
113
+ if (looksLikeValidModuleStem(match[1]))
114
+ stems.push(match[1]);
115
+ }
116
+ for (const match of line.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
117
+ if (looksLikeValidModuleStem(match[1]))
118
+ stems.push(match[1]);
119
+ }
64
120
  }
65
121
  for (const match of readme.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
66
- if (match[1] && match[1].toLowerCase() !== "readme")
122
+ if (looksLikeValidModuleStem(match[1]))
67
123
  stems.push(match[1]);
68
124
  }
69
125
  return orderedUnique(stems.map((stem) => normalizeBackendTestModuleStem(stem)));
@@ -118,13 +174,21 @@ function pythonParseable(source) {
118
174
  if (triples.length % 2 !== 0)
119
175
  return false;
120
176
  }
121
- if (/\bdef\s+\w+\s*\([^)]*$/m.test(text))
122
- return false;
123
- if (/\bpytest\.param\s*\([^)]*$/m.test(text))
124
- return false;
177
+ // The balanced-bracket counts above already catch a genuinely truncated
178
+ // file (an unclosed def/call leaves unbalanced parens). The previous
179
+ // per-line "def ... ( ... $" heuristic was a false-positive source for
180
+ // legal multi-line definitions such as `def f(\n x,\n):` — removed.
125
181
  return true;
126
182
  }
127
183
  export function buildOutputLimitRecoveryPrompt(input) {
184
+ return buildOutputLimitRecoverySection(input);
185
+ }
186
+ /**
187
+ * Build an inline `<retry_instruction>` block embedding the exact target
188
+ * paths. Used by {@link buildAttemptPrompt} so a continuation attempt does
189
+ * not depend on reading `.harness/**` (which is forbidden for Pi writers).
190
+ */
191
+ export function buildOutputLimitRecoverySection(input) {
128
192
  const paths = input.targetPaths.length > 0
129
193
  ? input.targetPaths.map((item) => ` - ${item}`).join("\n")
130
194
  : " - (none)";
@@ -144,6 +208,26 @@ export function buildOutputLimitRecoveryPrompt(input) {
144
208
  "</retry_instruction>",
145
209
  ].join("\n");
146
210
  }
211
+ /**
212
+ * Load the most recent writer-progress facts from the run dir for a given
213
+ * generation writer task id, so the next attempt's prompt can embed concrete
214
+ * target paths instead of asking the model to read a forbidden `.harness/**`
215
+ * path. Returns undefined when no progress facts exist yet (first retry of
216
+ * a session, or a non-completeness failure).
217
+ */
218
+ export async function loadBackendTestWriterProgressForRetry(runDir, taskId) {
219
+ const role = backendTestWriterProgressRoleForTask(taskId);
220
+ if (!role)
221
+ return undefined;
222
+ const factsPath = path.join(runDir, "contracts", `backend-test-writer-progress-${role}.json`);
223
+ try {
224
+ const raw = await readFile(factsPath, "utf8");
225
+ return progressSchema.parse(JSON.parse(raw));
226
+ }
227
+ catch {
228
+ return undefined;
229
+ }
230
+ }
147
231
  export async function assessBackendTestMdWriterCompleteness(workspaceRoot) {
148
232
  const issues = [];
149
233
  const expectedPaths = ["testcase/md/README.md"];