@tea-agent/loop-agent 0.16.24 → 0.16.25

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
@@ -12,6 +12,10 @@
12
12
  - Backend Test Analysis、Case Manifest、Semantic Review 与 Classification 的严格 JSON 契约进一步对齐,生成节点明确字段白名单、`sourceBinding` 与 evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
13
13
  - 新增升级后 init surface notifier:普通安全仓库命令结束后,当目标项目存在有效 `.harness/init-surface.json` 且 `controllerVersion` 与当前包版本不同时自动检测。surface 缺失/损坏、源仓库、未初始化目录与版本一致时直接跳过;`checkInitUpdate().ok` 时仅在无活跃运行时静默刷新 state;非 TTY 只向 stderr 输出 `loop-agent init reconcile --repo-root ...` 提示且不写入目标;TTY 且无 human decisions、无活跃 DAG/Worker 时可经明确 `y/yes` 同意后应用 deterministic safe actions;只有 model merge 时直接输出有边界指引,不询问“应用 0 个动作”。notifier 遵循全局 `--repo-root`、不写 stdout、不改变原命令退出码,CI、禁用环境变量、help/version、`--json`/`--markdown`、`init`、`run-dag`、`dag`、`loop`、`delegate`、`pi-prompt`、`cursor-prompt` 均跳过。
14
14
  - 新增统一命令 `loop-agent init reconcile --repo-root <target>`:surface 缺失返回 `needs-baseline` 且零写入,存在 human decisions 返回 `needs-human-decision` 且零写入,活跃 DAG/Worker 或 Worker 状态无法确认时返回 `blocked-active-runtime` 且零写入;其余情况复用现有 `applyInitUpdate({ applySafe: true })` 并复查返回 `clean`/`needs-model-merge`/`needs-safe-update`,支持 `--json`/`--markdown`。
15
+ - 测试报告新增 L-5 指标消费与 Python coverage.py JSON、Java JaCoCo XML 的统一 Code Coverage v1 报告入口,覆盖通过率、AC/自动化覆盖率、稳定性、失败原因、缺陷、风险和回归建议;缺失覆盖率证据保持 `unavailable`,不改变 backend-test outcome gate。
16
+ - 现有 backend-test DAG 在 retrospective 之后新增独立的 `l5-metrics-pi` 指标节点;它基于前序测试报告和 run-owned 证据输出 `L-5 ready/not-ready`,不创建独立 L-5 DAG,也不改变 Result v1 outcome gate。
17
+ - 后端测试 DAG 从 38 个收敛为 24 个真实顶层节点;使用 fail-closed `runIf` 和复合 Shell capability 减少调度,同时保留双合同、Manifest、语义评审、JUnit、Result、分类、修复安全、追踪和最终 outcome 证据。三条可选修订/修复分支仍各最多执行一次。
18
+ - Backend Test Analysis、Case Manifest、Semantic Review 与 Classification 的严格 JSON 契约进一步对齐,生成和修订节点明确字段白名单、`sourceBinding` 与 evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
15
19
 
16
20
  ### 修复
17
21
 
@@ -35,6 +39,13 @@
35
39
  - Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
36
40
  - 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为唯一 JUnit/initial Result、canonical Result、traceability 与 Observe 投影保留结构化运行证据。
37
41
 
42
+ ## [0.16.25] - 2026-07-22
43
+
44
+ ### 修复
45
+
46
+ - Backend-test pytest preflight 与执行体改为 `&&` fail-closed:preflight 失败不再因 `;` 继续跑 pytest(I27 曾出现 preflight 报 `WELCOME_BASE_URL`/testRoot 错配仍执行用例)。
47
+ - 本地 `npm start` / `node server.js` 类 `managed-command` 在 materialize 时降级为 `in-process`,清空 requiredEnv、seed server fixture,并把 scout `testRoot` 收敛到冻结根 `testcase/`;generate-pytest 提示要求 fixture 内启动服务,不再依赖 clean-env 注入 base URL。
48
+
38
49
  ## [0.16.24] - 2026-07-21
39
50
 
40
51
  ### 改进
@@ -3,6 +3,7 @@ import { runDocsArchive } from "../commands/docs-archive.js";
3
3
  import { runDocsAudit } from "../commands/docs-audit.js";
4
4
  import { runEval } from "../commands/eval.js";
5
5
  import { runCoverageAudit } from "../commands/coverage-audit.js";
6
+ import { runCoverageReport } from "../commands/coverage-report.js";
6
7
  import { runExamples } from "../commands/examples.js";
7
8
  import { runCloseout } from "../commands/closeout.js";
8
9
  import { runHandoffCheck } from "../commands/handoff-check.js";
@@ -101,6 +102,7 @@ const PLAN_SUBCOMMANDS = ["list", "create", "complete", "check"];
101
102
  const SPINE_SUBCOMMANDS = ["audit"];
102
103
  const HANDOFF_SUBCOMMANDS = ["check", "coverage"];
103
104
  const HANDOFF_USAGE = "handoff <check|coverage> [taskId]";
105
+ const COVERAGE_SUBCOMMANDS = ["report"];
104
106
  const REFERENCE_SUBCOMMANDS = ["index"];
105
107
  const STUDY_SUBCOMMANDS = ["init"];
106
108
  const WORKTREE_SUBCOMMANDS = ["create", "list", "remove"];
@@ -378,6 +380,17 @@ export const COMMAND_DEFINITIONS = [
378
380
  throw new Error(`usage: ${HANDOFF_USAGE}`);
379
381
  },
380
382
  },
383
+ {
384
+ name: "coverage",
385
+ adapter: "required",
386
+ tier: "operator",
387
+ intent: "Normalize Python coverage.py JSON or Java JaCoCo XML for test reports.",
388
+ usage: "coverage report --language python|java --input <path> [options]",
389
+ subcommands: [...COVERAGE_SUBCOMMANDS],
390
+ handler: async ({ repoRoot, subcommand, rest }) => {
391
+ await runCoverageReport(repoRoot, [subcommand, ...rest].filter(Boolean));
392
+ },
393
+ },
381
394
  {
382
395
  name: "goal",
383
396
  adapter: "required",
@@ -5,6 +5,7 @@ import { readPackageVersion } from "../shared/package-metadata.js";
5
5
  import { resolveAdapter } from "../adapters/index.js";
6
6
  import { runCloseout } from "../commands/closeout.js";
7
7
  import { runCoverageAudit } from "../commands/coverage-audit.js";
8
+ import { runCoverageReport } from "../commands/coverage-report.js";
8
9
  import { parseCursorPromptArgs, printCursorPromptUsage, runCursorPrompt, } from "../commands/cursor-prompt.js";
9
10
  import { runDagApprove } from "../commands/dag-approve.js";
10
11
  import { runDagFinalVerification } from "../commands/dag-final-verification.js";
@@ -205,6 +206,9 @@ async function runCommanderAction(ctx, command, subcommand, rest) {
205
206
  return;
206
207
  }
207
208
  throw new Error("usage: handoff <check|coverage> [taskId]");
209
+ case "coverage":
210
+ await runCoverageReport(ctx.repoRoot, compactArgs([subcommand, ...rest]));
211
+ return;
208
212
  case "goal":
209
213
  await runGoal(ctx.repoRoot, subcommand, rest);
210
214
  return;
@@ -0,0 +1,50 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { formatCoverageMarkdown, readCoverageArtifact } from "../workflows/dag/backend-test-coverage-contract.js";
4
+ function value(args, flag) {
5
+ const index = args.findIndex((arg) => arg === flag || arg.startsWith(`${flag}=`));
6
+ if (index < 0)
7
+ return undefined;
8
+ const token = args[index];
9
+ return token.startsWith(`${flag}=`) ? token.slice(flag.length + 1) : args[index + 1];
10
+ }
11
+ function values(args, flag) {
12
+ return args.flatMap((arg, index) => {
13
+ if (arg === flag)
14
+ return args[index + 1] ? [args[index + 1]] : [];
15
+ return arg.startsWith(`${flag}=`) ? [arg.slice(flag.length + 1)] : [];
16
+ });
17
+ }
18
+ export async function runCoverageReport(repoRoot, args) {
19
+ if (args[0] !== "report" || args.includes("--help")) {
20
+ console.log("usage: coverage report --language python|java --input <coverage.json|jacoco.xml> [--output <path>] [--json|--markdown] --requirement-id <AC-id> --source-scope <path[,path]> [--commit <sha>] [--expected-sha256 <sha256>]");
21
+ return;
22
+ }
23
+ const input = value(args, "--input");
24
+ const language = value(args, "--language");
25
+ if (!input || (language !== "python" && language !== "java"))
26
+ throw new Error("coverage report requires --language python|java and --input");
27
+ const expectedSha = value(args, "--expected-sha256");
28
+ const sourcePaths = (value(args, "--source-scope") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
29
+ const contract = await readCoverageArtifact(path.resolve(repoRoot, input), {
30
+ sourceScope: { requirementIds: values(args, "--requirement-id"), paths: sourcePaths },
31
+ commitSha: value(args, "--commit") ?? null,
32
+ toolVersion: value(args, "--tool-version") ?? null,
33
+ artifactPath: input,
34
+ });
35
+ if ((language === "python" && contract.language !== "python") || (language === "java" && contract.language !== "java"))
36
+ throw new Error("coverage artifact format does not match --language");
37
+ if (expectedSha && expectedSha !== contract.artifact.sha256)
38
+ throw new Error(`artifact SHA-256 mismatch: expected ${expectedSha}, got ${contract.artifact.sha256}`);
39
+ const markdown = args.includes("--markdown");
40
+ const output = value(args, "--output");
41
+ const rendered = markdown ? formatCoverageMarkdown(contract) : `${JSON.stringify(contract, null, 2)}\n`;
42
+ if (output) {
43
+ const outputPath = path.resolve(repoRoot, output);
44
+ await mkdir(path.dirname(outputPath), { recursive: true });
45
+ await writeFile(outputPath, rendered, "utf8");
46
+ }
47
+ else {
48
+ process.stdout.write(rendered);
49
+ }
50
+ }
@@ -0,0 +1,202 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ export const CODE_COVERAGE_SCHEMA_ID = "code-coverage-v1";
6
+ const relativePath = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
7
+ !value.includes("\\") &&
8
+ !value.split("/").some((part) => part === "" || part === "." || part === ".."), "path must be a relative POSIX path");
9
+ export const coverageMetricSchema = z.object({
10
+ status: z.enum(["available", "unavailable"]),
11
+ covered: z.number().int().min(0).nullable(),
12
+ total: z.number().int().min(0).nullable(),
13
+ ratio: z.number().min(0).max(1).nullable(),
14
+ reason: z.string().min(1).nullable(),
15
+ }).strict();
16
+ export const codeCoverageContractSchema = z.object({
17
+ schemaVersion: z.literal(1),
18
+ schemaId: z.literal(CODE_COVERAGE_SCHEMA_ID),
19
+ language: z.enum(["python", "java"]),
20
+ format: z.enum(["coverage.py-json", "jacoco-xml"]),
21
+ tool: z.string().min(1),
22
+ toolVersion: z.string().min(1).nullable(),
23
+ sourceScope: z.object({
24
+ requirementIds: z.array(z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/)),
25
+ paths: z.array(relativePath),
26
+ }).strict(),
27
+ commitSha: z.string().regex(/^[a-f0-9]{7,64}$/).nullable(),
28
+ metrics: z.object({
29
+ line: coverageMetricSchema,
30
+ branch: coverageMetricSchema,
31
+ function: coverageMetricSchema,
32
+ }).strict(),
33
+ artifact: z.object({
34
+ path: relativePath,
35
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
36
+ }).strict(),
37
+ missingData: z.array(z.string().min(1)),
38
+ }).strict();
39
+ function metric(covered, total, reason) {
40
+ if (covered === null || total === null || total === 0) {
41
+ return { status: "unavailable", covered, total, ratio: null, reason: reason ?? (total === 0 ? "denominator-is-zero" : "metric-missing") };
42
+ }
43
+ return { status: "available", covered, total, ratio: covered / total, reason: null };
44
+ }
45
+ function numberValue(value) {
46
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
47
+ }
48
+ function safeArtifactPath(value) {
49
+ const normalized = value.replaceAll(path.sep, "/");
50
+ if (path.posix.isAbsolute(normalized) || normalized.split("/").some((part) => part === "" || part === "." || part === "..")) {
51
+ throw new Error("artifact path must be a safe relative POSIX path");
52
+ }
53
+ return normalized;
54
+ }
55
+ function scopeMissing(scope) {
56
+ return scope.requirementIds.length === 0 ? ["source-scope-requirement-id-missing"] : [];
57
+ }
58
+ function baseContract(options, common) {
59
+ return {
60
+ schemaVersion: 1,
61
+ schemaId: CODE_COVERAGE_SCHEMA_ID,
62
+ ...common,
63
+ sourceScope: {
64
+ requirementIds: [...options.sourceScope.requirementIds],
65
+ paths: [...options.sourceScope.paths],
66
+ },
67
+ commitSha: options.commitSha,
68
+ artifact: { path: safeArtifactPath(options.artifactPath), sha256: options.artifactSha256 },
69
+ };
70
+ }
71
+ function parsePythonTotals(input, sourceScope) {
72
+ if (!input || typeof input !== "object")
73
+ throw new Error("coverage.py JSON must be an object");
74
+ const root = input;
75
+ const totals = root.totals;
76
+ if (!totals || typeof totals !== "object")
77
+ throw new Error("coverage.py JSON missing totals");
78
+ const files = root.files;
79
+ const scopedFiles = sourceScope.paths.length > 0 && files
80
+ ? Object.entries(files).filter(([file]) => sourceScope.paths.includes(file.replaceAll("\\", "/")))
81
+ : [];
82
+ const usesScopedFiles = sourceScope.paths.length > 0;
83
+ const values = usesScopedFiles ? scopedFiles.map(([, value]) => value) : null;
84
+ const get = (key) => {
85
+ if (values) {
86
+ let sum = 0;
87
+ for (const file of values) {
88
+ const data = file && typeof file === "object" ? file : {};
89
+ const value = numberValue(data[key]);
90
+ if (value !== null)
91
+ sum += value;
92
+ }
93
+ return sum;
94
+ }
95
+ if (usesScopedFiles)
96
+ return null;
97
+ return numberValue(totals[key]);
98
+ };
99
+ return {
100
+ line: metric(get("covered_lines"), get("num_statements"), null),
101
+ branch: metric(get("covered_branches"), get("num_branches"), "coverage.py branch data missing"),
102
+ function: { status: "unavailable", covered: null, total: null, ratio: null, reason: "coverage.py JSON has no function coverage" },
103
+ };
104
+ }
105
+ function parseJacocoCounters(xml) {
106
+ const report = xml.match(/<report\b[^>]*>([\s\S]*?)<\/report>/i)?.[1];
107
+ if (report === undefined)
108
+ throw new Error("JaCoCo XML missing report root");
109
+ const reportLevel = report.split(/<(?:group|package)\b/i)[0] ?? "";
110
+ const counters = new Map();
111
+ for (const match of reportLevel.matchAll(/<counter\s+[^>]*type="([A-Z]+)"[^>]*missed="(\d+)"[^>]*covered="(\d+)"[^>]*\/>/gi)) {
112
+ const missed = Number(match[2]);
113
+ const covered = Number(match[3]);
114
+ counters.set(match[1].toUpperCase(), { covered, total: covered + missed });
115
+ }
116
+ return counters;
117
+ }
118
+ export function parseCoveragePyJson(input, options) {
119
+ const root = input;
120
+ const totals = parsePythonTotals(input, options.sourceScope);
121
+ const missingData = [...scopeMissing(options.sourceScope)];
122
+ const files = input && typeof input === "object" ? input.files : undefined;
123
+ if (options.sourceScope.paths.length > 0 && (!files || !options.sourceScope.paths.some((file) => Object.hasOwn(files, file))))
124
+ missingData.push("source-scope-not-found-in-artifact");
125
+ if (totals.branch.status === "unavailable")
126
+ missingData.push("branch-coverage-unavailable");
127
+ missingData.push("function-coverage-unavailable");
128
+ return codeCoverageContractSchema.parse({
129
+ ...baseContract(options, {
130
+ language: "python",
131
+ format: "coverage.py-json",
132
+ tool: "coverage.py",
133
+ toolVersion: typeof root?.meta?.version === "string" ? root.meta.version : options.toolVersion ?? null,
134
+ }),
135
+ metrics: totals,
136
+ missingData,
137
+ });
138
+ }
139
+ export function parseJacocoXml(xml, options) {
140
+ if (!/<report\b/i.test(xml))
141
+ throw new Error("JaCoCo XML missing report root");
142
+ const counters = parseJacocoCounters(xml);
143
+ const line = counters.get("LINE");
144
+ const branch = counters.get("BRANCH");
145
+ const method = counters.get("METHOD");
146
+ const missingData = [...scopeMissing(options.sourceScope)];
147
+ if (!line)
148
+ missingData.push("line-coverage-unavailable");
149
+ if (!branch)
150
+ missingData.push("branch-coverage-unavailable");
151
+ if (!method)
152
+ missingData.push("method-coverage-unavailable");
153
+ return codeCoverageContractSchema.parse({
154
+ ...baseContract(options, {
155
+ language: "java",
156
+ format: "jacoco-xml",
157
+ tool: "JaCoCo",
158
+ toolVersion: options.toolVersion ?? null,
159
+ }),
160
+ metrics: {
161
+ line: line ? metric(line.covered, line.total, null) : metric(null, null, "JaCoCo LINE counter missing"),
162
+ branch: branch ? metric(branch.covered, branch.total, null) : metric(null, null, "JaCoCo BRANCH counter missing"),
163
+ function: method ? metric(method.covered, method.total, null) : metric(null, null, "JaCoCo METHOD counter missing"),
164
+ },
165
+ missingData,
166
+ });
167
+ }
168
+ export async function readCoverageArtifact(inputPath, options) {
169
+ const raw = await readFile(inputPath, "utf8");
170
+ const artifactSha256 = createHash("sha256").update(raw).digest("hex");
171
+ const artifactPath = options.artifactPath ?? path.basename(inputPath);
172
+ const parseOptions = { ...options, artifactPath, artifactSha256 };
173
+ if (options.sourceScope.paths.some((value) => value.includes("..")))
174
+ throw new Error("unsafe source scope path");
175
+ if (inputPath.toLowerCase().endsWith(".xml"))
176
+ return parseJacocoXml(raw, parseOptions);
177
+ return parseCoveragePyJson(JSON.parse(raw), parseOptions);
178
+ }
179
+ export function formatCoverageMarkdown(contract) {
180
+ const row = (name, value) => `| ${name} | ${value.covered ?? "—"} | ${value.total ?? "—"} | ${value.ratio === null ? "unavailable" : `${(value.ratio * 100).toFixed(2)}%`} | ${value.status} | ${value.reason ?? "—"} |`;
181
+ return [
182
+ "# Code Coverage Report",
183
+ "",
184
+ `- Schema: ${contract.schemaId} v${contract.schemaVersion}`,
185
+ `- Language: ${contract.language}`,
186
+ `- Tool: ${contract.tool}${contract.toolVersion ? ` ${contract.toolVersion}` : ""}`,
187
+ `- Commit: ${contract.commitSha ?? "unavailable"}`,
188
+ `- Requirement IDs: ${contract.sourceScope.requirementIds.join(", ") || "unavailable"}`,
189
+ `- Source scope: ${contract.sourceScope.paths.join(", ") || "unavailable"}`,
190
+ `- Artifact: ${contract.artifact.path}`,
191
+ `- Artifact SHA-256: ${contract.artifact.sha256}`,
192
+ "",
193
+ "| Metric | Covered | Total | Ratio | Status | Reason |",
194
+ "|---|---:|---:|---:|---|---|",
195
+ row("Line", contract.metrics.line),
196
+ row("Branch", contract.metrics.branch),
197
+ row("Function/Method", contract.metrics.function),
198
+ "",
199
+ `Missing data: ${contract.missingData.length ? contract.missingData.join(", ") : "none"}`,
200
+ "",
201
+ ].join("\n");
202
+ }
@@ -334,6 +334,41 @@ function asRecord(value) {
334
334
  * Prefer exact schema payloads; otherwise map common discovery shapes onto the
335
335
  * pytest-centric runtime contract without inventing secrets or managed commands.
336
336
  */
337
+ /** True when managed start is a local node/npm process the adapter cannot host. */
338
+ export function isLocalManagedServerStart(start) {
339
+ if (!start)
340
+ return false;
341
+ const normalized = start.trim().toLowerCase();
342
+ if (!normalized)
343
+ return false;
344
+ return (/\bnpm(\s+run)?\s+start\b/.test(normalized) ||
345
+ /\bnode\s+server(\.js)?\b/.test(normalized) ||
346
+ /\bnode\s+\.\/?server(\.js)?\b/.test(normalized) ||
347
+ /\bbash\s+scripts\/fe-test-server\.sh\b/.test(normalized) ||
348
+ normalized === "node server.js" ||
349
+ normalized.includes("server.js"));
350
+ }
351
+ function seedLocalServerFixture(fixtures, managedStart) {
352
+ if (Array.isArray(fixtures) && fixtures.length > 0) {
353
+ return fixtures.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)));
354
+ }
355
+ if (managedStart.includes("server") || managedStart.includes("npm")) {
356
+ return [
357
+ {
358
+ name: "server-bootstrap",
359
+ sourcePath: "server.js",
360
+ kind: "server-bootstrap",
361
+ },
362
+ ];
363
+ }
364
+ return [
365
+ {
366
+ name: "pytest-test-root",
367
+ sourcePath: BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT,
368
+ kind: "test-root",
369
+ },
370
+ ];
371
+ }
337
372
  /** Drop empty managedCommand strings; omit block when not managed-command. */
338
373
  export function sanitizeBackendTestExecutionInput(value) {
339
374
  const record = asRecord(value);
@@ -359,6 +394,51 @@ export function sanitizeBackendTestExecutionInput(value) {
359
394
  next.managedCommand = cleaned;
360
395
  }
361
396
  }
397
+ // Adapter always executes `python -m pytest testcase/`. Scout-chosen relative roots such as
398
+ // tests/api/** are product sample trees, not the frozen automation root. Absolute / ..
399
+ // paths stay untouched so schema materialize remains fail-closed.
400
+ if (typeof next.testRoot === "string" && next.testRoot.trim()) {
401
+ const rawRoot = next.testRoot.trim();
402
+ const normalizedRoot = rawRoot.replace(/\/+$/, "");
403
+ const looksUnsafe = normalizedRoot.startsWith("/") ||
404
+ normalizedRoot.includes("..") ||
405
+ normalizedRoot.includes("\\") ||
406
+ /^[A-Za-z]:/.test(normalizedRoot);
407
+ if (!looksUnsafe &&
408
+ normalizedRoot !== BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT) {
409
+ const gaps = Array.isArray(next.evidenceGaps)
410
+ ? [...next.evidenceGaps]
411
+ : [];
412
+ gaps.push({
413
+ description: `scout testRoot=${normalizedRoot} remapped to frozen adapter root ${BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT}`,
414
+ sourceRef: "adapter:backend-test-execution",
415
+ });
416
+ next.evidenceGaps = gaps;
417
+ next.testRoot = BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT;
418
+ }
419
+ }
420
+ // Local node/npm managed-command is not hosted by the clean-env pytest shell.
421
+ // Demote to in-process so generate-pytest must bootstrap the server inside fixtures
422
+ // instead of requiring host-injected base URL env vars that clean env cannot provide.
423
+ const managedStart = asRecord(next.managedCommand) &&
424
+ typeof asRecord(next.managedCommand).start === "string"
425
+ ? String(asRecord(next.managedCommand).start)
426
+ : "";
427
+ if (next.targetMode === "managed-command" &&
428
+ isLocalManagedServerStart(managedStart)) {
429
+ next.targetMode = "in-process";
430
+ next.requiredEnvNames = [];
431
+ next.existingFixtures = seedLocalServerFixture(Array.isArray(next.existingFixtures) ? next.existingFixtures : null, managedStart);
432
+ const gaps = Array.isArray(next.evidenceGaps)
433
+ ? [...next.evidenceGaps]
434
+ : [];
435
+ gaps.push({
436
+ description: "local managed-command demoted to in-process: clean-env pytest shell does not start npm/node servers or inject base URL env; tests must bootstrap via fixtures",
437
+ sourceRef: asRecord(next.managedCommand)?.sourceRef ||
438
+ "adapter:backend-test-execution",
439
+ });
440
+ next.evidenceGaps = gaps;
441
+ }
362
442
  // Near-schema scouts sometimes emit in-process with existingFixtures: [].
363
443
  // Only rewrite near-schema payloads here; free-form envelopes keep empty/missing
364
444
  // fixtures so coerceBackendTestExecutionInput can map discoveredFixtures first.
@@ -369,23 +449,7 @@ export function sanitizeBackendTestExecutionInput(value) {
369
449
  if (nearSchema &&
370
450
  (next.targetMode === "in-process" || next.targetMode === undefined) &&
371
451
  (!fixtures || fixtures.length === 0)) {
372
- const managedStart = asRecord(next.managedCommand) &&
373
- typeof asRecord(next.managedCommand).start === "string"
374
- ? String(asRecord(next.managedCommand).start)
375
- : "";
376
- next.existingFixtures = [
377
- managedStart.includes("server")
378
- ? {
379
- name: "server-bootstrap",
380
- sourcePath: "server.js",
381
- kind: "server-bootstrap",
382
- }
383
- : {
384
- name: "pytest-test-root",
385
- sourcePath: BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT,
386
- kind: "test-root",
387
- },
388
- ];
452
+ next.existingFixtures = seedLocalServerFixture(fixtures, managedStart);
389
453
  if (next.targetMode === undefined)
390
454
  next.targetMode = "in-process";
391
455
  }
@@ -631,10 +695,12 @@ export function buildBackendTestExecutionPreflightShellSnippet(options) {
631
695
  `console.log("backend-test preflight ok: framework=pytest testRoot="+testRoot+" targetMode="+contract.targetMode);'`,
632
696
  ' "${CONTRACT}"',
633
697
  ].join("");
698
+ // Fail-closed: join with && so a preflight exit 2 never continues into pytest.
699
+ // Historical ";" chains recorded I27-style false progresses (env missing + pytest ran).
634
700
  return [
635
701
  'test -n "${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for backend pytest preflight" >&2; exit 2; }',
636
702
  `CONTRACT="\${HARNESS_DAG_RUN_DIR}/${contractRelativePath}"`,
637
703
  'test -f "${CONTRACT}" || { echo "missing backend-test execution contract: ${CONTRACT}" >&2; exit 2; }',
638
704
  nodePreflight,
639
- ].join("; ");
705
+ ].join(" && ");
640
706
  }
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ export const STABILITY_EVIDENCE_SCHEMA_ID = "stability-evidence-v1";
3
+ const runRefSchema = z.object({
4
+ runId: z.string().min(1),
5
+ suiteId: z.string().min(1),
6
+ version: z.string().min(1),
7
+ commitSha: z.string().regex(/^[a-f0-9]{7,64}$/),
8
+ success: z.boolean(),
9
+ resultRef: z.string().min(1),
10
+ }).strict();
11
+ export const stabilityEvidenceSchema = z.object({
12
+ schemaVersion: z.literal(1),
13
+ schemaId: z.literal(STABILITY_EVIDENCE_SCHEMA_ID),
14
+ suiteId: z.string().min(1),
15
+ version: z.string().min(1),
16
+ commitSha: z.string().regex(/^[a-f0-9]{7,64}$/),
17
+ runs: z.array(runRefSchema),
18
+ recordedRuns: z.number().int().min(0),
19
+ successfulRuns: z.number().int().min(0),
20
+ failedRuns: z.number().int().min(0),
21
+ ratio: z.number().min(0).max(1).nullable(),
22
+ minimumRuns: z.literal(5),
23
+ status: z.enum(["available", "unavailable"]),
24
+ reason: z.string().min(1).nullable(),
25
+ }).strict().superRefine((value, ctx) => {
26
+ if (value.recordedRuns !== value.runs.length)
27
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "recordedRuns must equal runs.length", path: ["recordedRuns"] });
28
+ if (value.successfulRuns !== value.runs.filter((run) => run.success).length)
29
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "successfulRuns must match run facts", path: ["successfulRuns"] });
30
+ if (value.failedRuns !== value.runs.filter((run) => !run.success).length)
31
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "failedRuns must match run facts", path: ["failedRuns"] });
32
+ for (const [index, run] of value.runs.entries()) {
33
+ if (run.suiteId !== value.suiteId || run.version !== value.version || run.commitSha !== value.commitSha) {
34
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "all runs must bind to the same suite/version/commit", path: ["runs", index] });
35
+ }
36
+ }
37
+ if (value.recordedRuns >= value.minimumRuns && value.ratio === null)
38
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ratio required when minimum sample is met", path: ["ratio"] });
39
+ });
40
+ export function buildStabilityEvidence(input) {
41
+ const recordedRuns = input.runs.length;
42
+ const successfulRuns = input.runs.filter((run) => run.success).length;
43
+ const failedRuns = recordedRuns - successfulRuns;
44
+ const status = recordedRuns >= 5 ? "available" : "unavailable";
45
+ return stabilityEvidenceSchema.parse({
46
+ schemaVersion: 1,
47
+ schemaId: STABILITY_EVIDENCE_SCHEMA_ID,
48
+ ...input,
49
+ recordedRuns,
50
+ successfulRuns,
51
+ failedRuns,
52
+ ratio: recordedRuns >= 5 ? successfulRuns / recordedRuns : null,
53
+ minimumRuns: 5,
54
+ status,
55
+ reason: recordedRuns >= 5 ? null : "minimum-sample-size-not-met",
56
+ });
57
+ }
@@ -2912,6 +2912,8 @@ function buildGenerateBackendPytestNode(sources) {
2912
2912
  "- Validated Backend Test Analysis v1 under the current run contracts/ (analysis gate).",
2913
2913
  "- Validated Backend Test Execution Contract v1 under contracts/backend-test-execution.json (execution gate).",
2914
2914
  "Use only fixture names, env NAMES, testRoot, targetMode, and field/API facts already present in those contracts or reviewed cases. Do not invent production credentials or secret values.",
2915
+ "When targetMode is in-process (including demoted local npm/node managed servers): bootstrap the service inside function-scoped pytest fixtures under testcase/** — e.g. subprocess node server.js / startWelcomeServer with PORT=0 — and never require host-injected base URL env vars (clean-env shell will not provide WELCOME_BASE_URL / API_BASE_URL).",
2916
+ "Do not depend on requiredEnvNames being present at process start for in-process mode; if the contract still lists an env name, the fixture must set it or start the server without that env.",
2915
2917
  "",
2916
2918
  "## Output Steps (do in order):",
2917
2919
  "1. First, output a brief summary: how many files, how many test functions planned",
@@ -3062,8 +3064,9 @@ function buildExecuteBackendPytestNode(sources, options = {}) {
3062
3064
  const reportStem = options.reportStem ?? "backend-test";
3063
3065
  const reportName = `${reportStem}-junit.xml`;
3064
3066
  const exitName = `${reportStem}-pytest-exit.txt`;
3065
- const pytestCommand = [
3066
- preflightCommand,
3067
+ // Preflight snippet is already fail-closed (&&). Only the pytest body may use
3068
+ // ";" so STATUS capture still runs after non-zero pytest exits.
3069
+ const pytestBody = [
3067
3070
  `REPORT="\${HARNESS_DAG_RUN_DIR}/reports/${reportName}"`,
3068
3071
  `EXIT_FILE="\${HARNESS_DAG_RUN_DIR}/reports/${exitName}"`,
3069
3072
  'mkdir -p "$(dirname "${REPORT}")"',
@@ -3075,6 +3078,7 @@ function buildExecuteBackendPytestNode(sources, options = {}) {
3075
3078
  'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${REPORT}" ]; then exit 0; fi',
3076
3079
  'exit "${STATUS}"',
3077
3080
  ].join("; ");
3081
+ const pytestCommand = `${preflightCommand} && { ${pytestBody}; }`;
3078
3082
  return {
3079
3083
  id: nodeId,
3080
3084
  depends_on: options.dependsOn ?? [
@@ -3168,21 +3172,24 @@ function buildTestRetrospectNode(sources) {
3168
3172
  "## Stats authority (deterministic only):",
3169
3173
  "- Pass rate, failed/error/skipped counts, and failure list MUST come from contracts/backend-test-result.json only.",
3170
3174
  "- AC coverage ratio / case counts MUST come from contracts/backend-test-case-manifest.json coverageSummary (or gate-derived fields). Do NOT invent coverage %.",
3175
+ "- Automation coverage MUST use coverageSummary.generatedCount / coverageSummary.caseCount. If either field is missing, write unavailable; do not estimate.",
3176
+ "- Code coverage MUST come only from the validated contracts/code-coverage-v1.json artifact generated by coverage.py/pytest-cov or JaCoCo. Show line, branch, function/method, covered, total, ratio, threshold, status, source scope, requirement IDs, tool, commit, and artifact hash.",
3177
+ "- Stability MUST come from independent Stability Evidence: use successfulRuns / recordedRuns, same suite/version, and require n≥5; a single run is unavailable.",
3171
3178
  "- Use classify-backend-test-result-pi JSON as interpretive evidence only.",
3172
3179
  "- NEVER rewrite a failed result as passed. Outcome gate (not this report) is authoritative for task success.",
3173
3180
  "",
3174
3181
  "## Report Structure:",
3175
3182
  "1. Maturity Rating with rationale",
3176
- "2. Test Coverage Summary (manifest coverageSummary + Result v1 pass rate)",
3177
- "3. Review Findings and resolution status",
3178
- "4. Failed Test Analysis (if any) + classification category",
3179
- "5. Recommendations for improvement",
3183
+ "2. Test Coverage Summary (Result v1 pass rate, AC coverage, automation coverage, code coverage, and stability evidence)",
3184
+ "3. Failed Test Analysis (failure/error details, category, confidence, evidence, and owner direction)",
3185
+ "4. Defects (local Bug ledger in the same report directory; unavailable when absent)",
3186
+ "5. Risks (Critical/High/Medium/Low, impact, controls, residual risk, treatment; Critical risks block L-5, High risks do not automatically block)",
3187
+ "6. Regression Recommendations (immediate, related, periodic, deferred; every item links to failure/risk/AC/case IDs)",
3188
+ "7. L-5 conclusion with blocking items",
3180
3189
  "",
3181
3190
  "## Rating Criteria:",
3182
- "- A: coverageSummary.acCoverageRatio=1 + 100% pytest pass + no Critical findings",
3183
- "- B: acCoverageRatio≥0.8 + ≥90% pass + Low findings only",
3184
- "- C: acCoverageRatio≥0.6 + ≥70% pass + no Critical findings",
3185
- "- D: below C thresholds",
3191
+ "- L-5 ready requires pass rate=100%, AC coverage=100%, automation coverage≥90%, stability≥95% with n≥5, line coverage≥80%, branch coverage≥70%, skipped=0, and no blocking Critical risk.",
3192
+ "- Any required metric fail or unavailable means L-5 not-ready. Function/method coverage is displayed but not a gate. Preserve the existing A/B/C/D single-run rating separately.",
3186
3193
  "",
3187
3194
  "## Constraints:",
3188
3195
  canWriteReport
@@ -3197,7 +3204,7 @@ function buildBackendTestOutcomeGateNode(sources) {
3197
3204
  const gateCommand = buildBackendTestOutcomeGateShellSnippet();
3198
3205
  return {
3199
3206
  id: "backend-test-outcome-gate-shell",
3200
- depends_on: ["test-retrospect-pi"],
3207
+ depends_on: ["l5-metrics-pi"],
3201
3208
  role: "verifier",
3202
3209
  executor: "shell",
3203
3210
  complexity: "LOW",
@@ -3220,6 +3227,30 @@ function buildBackendTestOutcomeGateNode(sources) {
3220
3227
  },
3221
3228
  };
3222
3229
  }
3230
+ function buildL5MetricsNode(sources) {
3231
+ return {
3232
+ id: "l5-metrics-pi",
3233
+ depends_on: ["test-retrospect-pi"],
3234
+ role: "reviewer",
3235
+ executor: "pi",
3236
+ complexity: "MED",
3237
+ writePolicy: "read-only",
3238
+ allowedPaths: commonReadOnlyPaths(sources),
3239
+ forbiddenPaths: commonForbiddenPaths(sources),
3240
+ outputContract: "Exactly one JSON object with status=ready|not-ready, metrics, and blockingItems; no file writes.",
3241
+ subtask_prompt: [
3242
+ "You are the independent L-5 metrics node at the end of the existing backend-test DAG.",
3243
+ "The direct upstream test-retrospect-pi output is the primary report to assess. Read it together with the run-owned Result v1, Case Manifest v1, Code Coverage v1, and Stability Evidence artifacts when present.",
3244
+ "Do not create a new DAG, rewrite the retrospective report, change test outcome, or modify any repository file.",
3245
+ "Return exactly one JSON object and no surrounding prose.",
3246
+ "Required shape: {\"status\":\"ready\"|\"not-ready\",\"metrics\":{\"passRate\":metric,\"acCoverage\":metric,\"automationCoverage\":metric,\"stability\":metric,\"lineCoverage\":metric,\"branchCoverage\":metric,\"skipped\":metric,\"criticalRisks\":metric},\"blockingItems\":[string]}.",
3247
+ "Each metric must contain numerator, denominator, ratio, threshold, status=pass|fail|unavailable, and reason (null only when passed).",
3248
+ "Use only explicit evidence. Missing or invalid required evidence is unavailable, never zero or an estimate.",
3249
+ "L-5 ready requires pass rate=100%, AC coverage=100%, automation coverage>=90%, stability>=95% with n>=5, line coverage>=80%, branch coverage>=70%, skipped=0, and zero blocking Critical risks.",
3250
+ "Function/method coverage is display-only and does not gate L-5. Preserve the distinction between L-5 maturity and the Result v1 outcome gate.",
3251
+ ].join("\n\n"),
3252
+ };
3253
+ }
3223
3254
  const BACKEND_TEST_DEFAULTS = {
3224
3255
  ...HYBRID_DEFAULTS,
3225
3256
  writePolicy: "read-only",
@@ -3237,7 +3268,7 @@ function buildBackendTestHybridDag(sources) {
3237
3268
  const globalConstraints = [
3238
3269
  ...taskConfig.hardConstraints,
3239
3270
  ...STANDARD_GLOBAL_CONSTRAINTS,
3240
- "backend-test-dag uses exactly 15 real top-level tasks and executes pytest exactly once.",
3271
+ "backend-test-dag uses exactly 16 real top-level tasks and executes pytest exactly once.",
3241
3272
  "Case and semantic request-revision verdicts fail at deterministic gates; no in-run revision or repair writer is authorized.",
3242
3273
  "Analysis, execution, manifest, semantic review, single-run result, classification, canonical result, retrospective and outcome evidence remain run-owned and fail-closed.",
3243
3274
  "Functional test case IDs must use BE-<MODULE>-<NNN> format.",
@@ -3301,8 +3332,10 @@ function buildBackendTestHybridDag(sources) {
3301
3332
  const retrospect = buildTestRetrospectNode(sources);
3302
3333
  retrospect.depends_on = [context.id];
3303
3334
  retrospect.subtask_prompt = retrospect.subtask_prompt.replaceAll("select-effective-backend-test-result-shell", context.id);
3335
+ const l5Metrics = buildL5MetricsNode(sources);
3336
+ l5Metrics.depends_on = [retrospect.id];
3304
3337
  const outcome = buildBackendTestOutcomeGateNode(sources);
3305
- const tasks = [analyze, contracts, generateCases, manifest, reviewCases, caseGate, generatePytest, semanticReview, semanticMaterialize, semanticGate, execute, classify, context, retrospect, outcome];
3338
+ const tasks = [analyze, contracts, generateCases, manifest, reviewCases, caseGate, generatePytest, semanticReview, semanticMaterialize, semanticGate, execute, classify, context, retrospect, l5Metrics, outcome];
3306
3339
  const spec = { version: 3, title: `Backend test DAG: ${taskConfig.title}`, runtimeContract: GENERATED_DAG_RUNTIME_CONTRACT, outputLanguage: sources.outputLanguage ?? DEFAULT_DAG_OUTPUT_LANGUAGE, objective: extractObjective(sources.requirementMarkdown, taskConfig.title), successCriteria: extractSuccessCriteria(sources.requirementMarkdown, sources.taskId), globalConstraints, defaults: { ...BACKEND_TEST_DEFAULTS, contextProfile: taskConfig.contextProfile }, skillsByRole: BACKEND_TEST_SKILLS_BY_ROLE, executorModels: sources.executorModelMatrix ?? DEFAULT_DAG_EXECUTOR_MODELS, tasks };
3307
3340
  applyDefaultReadOnlyRetryPolicy(spec);
3308
3341
  parseDagSpec(spec);
@@ -0,0 +1,36 @@
1
+ function unavailable(reason, numerator = null, denominator = null, threshold = null) {
2
+ return { numerator, denominator, ratio: null, threshold, status: "unavailable", reason };
3
+ }
4
+ function ratioMetric(numerator, denominator, threshold, label) {
5
+ if (denominator === 0)
6
+ return unavailable(`${label}-denominator-is-zero`, numerator, denominator, threshold);
7
+ const ratio = numerator / denominator;
8
+ return { numerator, denominator, ratio, threshold, status: ratio >= threshold ? "pass" : "fail", reason: ratio >= threshold ? null : `${label}-below-threshold` };
9
+ }
10
+ export function computeL5ReportMetrics(input) {
11
+ const executed = input.result.passed + input.result.failed + input.result.error;
12
+ const metrics = {
13
+ passRate: ratioMetric(input.result.passed, executed, 1, "pass-rate"),
14
+ acCoverage: input.manifest.coverageSummary
15
+ ? input.manifest.coverageSummary.explicitAcCount === 0
16
+ ? { numerator: 0, denominator: 0, ratio: 1, threshold: 1, status: "pass", reason: null }
17
+ : ratioMetric(input.manifest.coverageSummary.coveredAcCount, input.manifest.coverageSummary.explicitAcCount, 1, "ac-coverage")
18
+ : unavailable("case-manifest-coverage-missing", null, null, 1),
19
+ automationCoverage: input.manifest.coverageSummary
20
+ ? ratioMetric(input.manifest.coverageSummary.generatedCount, input.manifest.coverageSummary.caseCount, 0.9, "automation-coverage")
21
+ : unavailable("case-manifest-coverage-missing", null, null, 0.9),
22
+ stability: input.stability?.status === "available" && input.stability.ratio !== null
23
+ ? { numerator: input.stability.successfulRuns, denominator: input.stability.recordedRuns, ratio: input.stability.ratio, threshold: 0.95, status: input.stability.ratio >= 0.95 ? "pass" : "fail", reason: input.stability.ratio >= 0.95 ? null : "stability-below-threshold" }
24
+ : unavailable(input.stability?.reason ?? "stability-evidence-missing", input.stability?.successfulRuns ?? null, input.stability?.recordedRuns ?? null, 0.95),
25
+ lineCoverage: input.coverage?.metrics.line.status === "available" && input.coverage.metrics.line.ratio !== null
26
+ ? { numerator: input.coverage.metrics.line.covered, denominator: input.coverage.metrics.line.total, ratio: input.coverage.metrics.line.ratio, threshold: 0.8, status: input.coverage.metrics.line.ratio >= 0.8 ? "pass" : "fail", reason: input.coverage.metrics.line.ratio >= 0.8 ? null : "line-coverage-below-threshold" }
27
+ : unavailable(input.coverage?.metrics.line.reason ?? "line-coverage-missing", input.coverage?.metrics.line.covered ?? null, input.coverage?.metrics.line.total ?? null, 0.8),
28
+ branchCoverage: input.coverage?.metrics.branch.status === "available" && input.coverage.metrics.branch.ratio !== null
29
+ ? { numerator: input.coverage.metrics.branch.covered, denominator: input.coverage.metrics.branch.total, ratio: input.coverage.metrics.branch.ratio, threshold: 0.7, status: input.coverage.metrics.branch.ratio >= 0.7 ? "pass" : "fail", reason: input.coverage.metrics.branch.ratio >= 0.7 ? null : "branch-coverage-below-threshold" }
30
+ : unavailable(input.coverage?.metrics.branch.reason ?? "branch-coverage-missing", input.coverage?.metrics.branch.covered ?? null, input.coverage?.metrics.branch.total ?? null, 0.7),
31
+ skipped: { numerator: input.result.skipped, denominator: input.result.skipped, ratio: input.result.skipped === 0 ? 1 : 0, threshold: 1, status: input.result.skipped === 0 ? "pass" : "fail", reason: input.result.skipped === 0 ? null : "skipped-tests-present" },
32
+ criticalRisks: { numerator: input.criticalRiskCount, denominator: input.criticalRiskCount, ratio: input.criticalRiskCount === 0 ? 1 : 0, threshold: 1, status: input.criticalRiskCount === 0 ? "pass" : "fail", reason: input.criticalRiskCount === 0 ? null : "critical-risk-present" },
33
+ };
34
+ const blockingItems = Object.entries(metrics).filter(([, value]) => value.status !== "pass").map(([name, value]) => `${name}:${value.reason ?? value.status}`);
35
+ return { status: blockingItems.length === 0 ? "ready" : "not-ready", metrics, blockingItems };
36
+ }
@@ -35,10 +35,12 @@
35
35
  | 云 Task Pool / SQL / Orchestrator | 规划 / 未实现 | 同上(第 2 月原始设计已调整为本地 Feature 闭环) |
36
36
  | 多仓库平台 | 规划 / 未实现 | 同上 |
37
37
  | 组织级服务 | 规划 / 未实现 | 同上 |
38
- | Web Console(远端) | 规划 / 未实现 | 同上 |
38
+ | Web Console(远端) | 规划 / 未实现;指多用户/远程编排平台,不等同于 `docs/design/local-operator-console-from-pi-web.md` 的单机 loopback Operator Console | 同上 |
39
39
  | Dynamic Workflow runtime limits 强执法、更广 profile | 设计输入 | `ai_workspace/loop-agent/design/dynamic-workflow-dag-engine-roadmap.md`(未勾选 phase) |
40
40
  | Loop 与 Dynamic Workflow 更深的双向集成、稳定化与自动恢复 | 设计输入 | 同上;当前已有基础 `workflow` action,不应误写为完全缺失 |
41
41
 
42
+ > 本地 Loop Operator Console 已在 `docs/design/local-operator-console-from-pi-web.md` 作为独立设计输入:单仓库、loopback、随 `@tea-agent/loop-agent` 同包发布、canonical mutation 只经 sibling CLI;它不是本表中的远端 Web Console,也不能把远端多租户/云编排需求偷渡进本地 MVP。
43
+
42
44
  > 注意:`ai_workspace/loop-agent/design/dynamic-workflow-dag-engine-roadmap.md` 是 2026-07-04 历史叙述;文中凡把 Cursor 写成受治理 executor 或 `loop` 的 `cursor-fix` 动作,均为**历史叙述**,现状以 Pi-only + 显式 `cursor-prompt` sidecar 为准。
43
45
 
44
46
  ## 已收敛为 archive / 历史基线(非未来)
@@ -45,6 +45,8 @@ Your job is to convert reviewed test cases under `testcase/md/` into pytest auto
45
45
 
46
46
  Do NOT re-read source documents for free-form analysis. Use only reviewed cases and the validated contracts. Use only fixture/env/testRoot facts already present in the execution contract; never invent production credentials or secret values.
47
47
 
48
+ When `targetMode` is `in-process` (including demoted local npm/node managed servers): bootstrap the service inside function-scoped pytest fixtures under `testcase/**` (for example subprocess `node server.js` / `startWelcomeServer` with `PORT=0`). Never require host-injected base URL env vars such as `WELCOME_BASE_URL` / `API_BASE_URL` — the clean-env pytest shell will not provide them.
49
+
48
50
  ### Conversion Rules
49
51
 
50
52
  #### File Naming
@@ -28,7 +28,7 @@
28
28
  "Root artifacts/ is reserved for explicit exclusive write nodes, not read-only scout/reviewer output",
29
29
  "exclusive implementer nodes must use narrow, concrete writeSet paths; never keep ** or repo root",
30
30
  "Replace REPLACE/WITH/NARROW/IMPLEMENT/PATHS/** with concrete paths before executing the implementation writer",
31
- "backend-test-dag uses exactly 15 real top-level tasks and executes pytest exactly once.",
31
+ "backend-test-dag uses exactly 16 real top-level tasks and executes pytest exactly once.",
32
32
  "Case and semantic request-revision verdicts fail at deterministic gates; no in-run revision or repair writer is authorized.",
33
33
  "Analysis, execution, manifest, semantic review, single-run result, classification, canonical result, retrospective and outcome evidence remain run-owned and fail-closed.",
34
34
  "Functional test case IDs must use BE-<MODULE>-<NNN> format.",
@@ -280,7 +280,7 @@
280
280
  ".harness/dag-runs/**",
281
281
  "artifacts/**"
282
282
  ],
283
- "subtask_prompt": "Convert the reviewed test cases under testcase/md/ into pytest automation code.\n\n\n\n## Inputs (MUST use validated contracts):\n\n- Reviewed cases under testcase/md/ (after review-backend-cases-gate-shell).\n\n- Validated Backend Test Analysis v1 under the current run contracts/ (analysis gate).\n\n- Validated Backend Test Execution Contract v1 under contracts/backend-test-execution.json (execution gate).\n\nUse only fixture names, env NAMES, testRoot, targetMode, and field/API facts already present in those contracts or reviewed cases. Do not invent production credentials or secret values.\n\n\n\n## Output Steps (do in order):\n\n1. First, output a brief summary: how many files, how many test functions planned\n\n2. Then write each test file under testcase/\n\n\n\n## Format Rules:\n\n- File prefix: test_<module>.py\n\n- Function name: test_BE_<MODULE>_<NNN>_<description>\n\n- Docstring first line: BE-<MODULE>-<NNN>: <Case Title>\n\n- 1:1 mapping: each functional case → one pytest function\n\n\n\n## Implementation Rules:\n\n- Use assert statements, not unittest assertions\n\n- Use @pytest.mark.parametrize for boundary cases when the case defines edge values\n\n- Use markers: @pytest.mark.positive, @pytest.mark.negative, @pytest.mark.boundary\n\n\n\n## Test Data Preparation Rules (MUST follow):\n\n\n\n### When Setup is Needed\n\nSetup phase is REQUIRED only when test cases need pre-existing data:\n\n- Query/Read APIs: need data to exist before querying\n\n- Update/Delete APIs: need data to exist before modifying\n\n- State transition tests: need data in specific state\n\n\n\nSetup phase is NOT needed for:\n\n- Create APIs: testing the creation itself\n\n- Validation tests: testing input validation with invalid data\n\n\n\n### Data Setup Strategy\n\nWhen setup is needed:\n\n1. Prefer function-scoped fixtures for isolation; use module/session scope only when cases explicitly share immutable fixtures\n\n2. Prefer API-based setup from the upstream analyze-inputs-pi API list and reviewed cases\n\n3. If a required helper/factory is missing, create NEW files only under testcase/**/helpers/** or testcase/**/factories/**\n\n\n\n### Data Construction Priority\n\n1. API-first: construct data via documented APIs from analyze-inputs-pi / reviewed cases\n\n2. Reuse existing conftest fixtures when present (read-only)\n\n3. Direct DB writes are LAST RESORT and only if conftest already exposes a safe test DB fixture with rollback/isolation\n\n4. If neither API nor safe DB fixture exists, skip the case with an explicit gap note — do NOT invent production DB credentials or write live data\n\n\n\n### API Data Construction\n\n- Prefer the analyze-inputs-pi API Endpoints section and reviewed cases for method/path/fields\n\n- Chain API calls only when cases document multi-step preconditions\n\n- Store created resource IDs in fixtures for reuse\n\n- Do NOT broadly search host route/controller trees for secrets, .env, private keys, or production configs\n\n- Read host API definitions only when needed to resolve a field name already referenced by reviewed cases; stay out of credential/config paths\n\n\n\n### Database Data Construction (restricted)\n\n- Allowed only via existing conftest test-DB fixtures with transaction rollback or equivalent isolation\n\n- Never hardcode connection strings, passwords, tokens, or cloud credentials\n\n- Never target production/shared non-test databases\n\n- If isolation is unclear, report the gap instead of writing DB rows\n\n\n\n## Assertion Rules (MUST follow):\n\n\n\n### Positive Path\n\nMUST assert ALL of the following:\n\n1. HTTP status code: as defined in API spec (e.g. 200, 201)\n\n2. Response structure: key fields exist in response body\n\n3. Specific values: each field equals expected value from test case\n\n4. Data type: each field is correct type\n\n\n\n### Negative Path\n\nMUST assert ALL of the following:\n\n1. HTTP status code: as defined in API spec (e.g. 400, 404, 500)\n\n2. Error code field: field name from API spec (e.g. code, error_code, errcode, ret)\n\n3. Error message field: field name from API spec (e.g. message, msg, errmsg, error)\n\n\n\n### Field Name Resolution\n\nField names MUST come from the upstream analyze-inputs-pi output (API Endpoints section) or reviewed cases, NOT guessed. For example:\n\n- If API spec defines {\"ret\": 0, \"msg\": \"success\"}, assert response.json()['ret'] and response.json()['msg']\n\n- If API spec defines {\"code\": 4001, \"message\": \"error\"}, assert response.json()['code'] and response.json()['message']\n\n\n\n## Conditional Implementation (include ONLY if test cases exist):\n\n- Authentication tests: implement ONLY if testcase/md/ contains auth-related cases\n\n- Timeout tests: implement ONLY if testcase/md/ contains timeout-related cases\n\n- Boundary tests: implement ONLY when cases define value ranges, length limits, or format constraints\n\n- Use @pytest.mark.auth for auth tests, @pytest.mark.timeout for timeout tests\n\n- If no such cases exist, do NOT add these tests\n\n\n\n## Constraints:\n\n- Only create NEW files under writeSet: testcase/**/test_*.py, testcase/**/helpers/**, testcase/**/factories/**\n\n- Do NOT modify existing framework files (conftest.py, pytest.ini, pyproject.toml, setup.cfg, __init__.py)\n\n- If a test filename exists, add suffix: test_order.py → test_order_01.py\n\n- Do NOT re-read source documents — use reviewed cases under testcase/md/ and upstream analyze-inputs-pi output only\n\n- Read existing conftest.py/pytest.ini to understand conventions, but do NOT modify them\n\n- Do NOT execute pytest/python -m pytest or npm test in this node; the single execution is owned by the dedicated shell node. Local smoke runs create __pycache__/.pytest_cache and are unnecessary here."
283
+ "subtask_prompt": "Convert the reviewed test cases under testcase/md/ into pytest automation code.\n\n\n\n## Inputs (MUST use validated contracts):\n\n- Reviewed cases under testcase/md/ (after review-backend-cases-gate-shell).\n\n- Validated Backend Test Analysis v1 under the current run contracts/ (analysis gate).\n\n- Validated Backend Test Execution Contract v1 under contracts/backend-test-execution.json (execution gate).\n\nUse only fixture names, env NAMES, testRoot, targetMode, and field/API facts already present in those contracts or reviewed cases. Do not invent production credentials or secret values.\n\nWhen targetMode is in-process (including demoted local npm/node managed servers): bootstrap the service inside function-scoped pytest fixtures under testcase/** — e.g. subprocess node server.js / startWelcomeServer with PORT=0 — and never require host-injected base URL env vars (clean-env shell will not provide WELCOME_BASE_URL / API_BASE_URL).\n\nDo not depend on requiredEnvNames being present at process start for in-process mode; if the contract still lists an env name, the fixture must set it or start the server without that env.\n\n\n\n## Output Steps (do in order):\n\n1. First, output a brief summary: how many files, how many test functions planned\n\n2. Then write each test file under testcase/\n\n\n\n## Format Rules:\n\n- File prefix: test_<module>.py\n\n- Function name: test_BE_<MODULE>_<NNN>_<description>\n\n- Docstring first line: BE-<MODULE>-<NNN>: <Case Title>\n\n- 1:1 mapping: each functional case → one pytest function\n\n\n\n## Implementation Rules:\n\n- Use assert statements, not unittest assertions\n\n- Use @pytest.mark.parametrize for boundary cases when the case defines edge values\n\n- Use markers: @pytest.mark.positive, @pytest.mark.negative, @pytest.mark.boundary\n\n\n\n## Test Data Preparation Rules (MUST follow):\n\n\n\n### When Setup is Needed\n\nSetup phase is REQUIRED only when test cases need pre-existing data:\n\n- Query/Read APIs: need data to exist before querying\n\n- Update/Delete APIs: need data to exist before modifying\n\n- State transition tests: need data in specific state\n\n\n\nSetup phase is NOT needed for:\n\n- Create APIs: testing the creation itself\n\n- Validation tests: testing input validation with invalid data\n\n\n\n### Data Setup Strategy\n\nWhen setup is needed:\n\n1. Prefer function-scoped fixtures for isolation; use module/session scope only when cases explicitly share immutable fixtures\n\n2. Prefer API-based setup from the upstream analyze-inputs-pi API list and reviewed cases\n\n3. If a required helper/factory is missing, create NEW files only under testcase/**/helpers/** or testcase/**/factories/**\n\n\n\n### Data Construction Priority\n\n1. API-first: construct data via documented APIs from analyze-inputs-pi / reviewed cases\n\n2. Reuse existing conftest fixtures when present (read-only)\n\n3. Direct DB writes are LAST RESORT and only if conftest already exposes a safe test DB fixture with rollback/isolation\n\n4. If neither API nor safe DB fixture exists, skip the case with an explicit gap note — do NOT invent production DB credentials or write live data\n\n\n\n### API Data Construction\n\n- Prefer the analyze-inputs-pi API Endpoints section and reviewed cases for method/path/fields\n\n- Chain API calls only when cases document multi-step preconditions\n\n- Store created resource IDs in fixtures for reuse\n\n- Do NOT broadly search host route/controller trees for secrets, .env, private keys, or production configs\n\n- Read host API definitions only when needed to resolve a field name already referenced by reviewed cases; stay out of credential/config paths\n\n\n\n### Database Data Construction (restricted)\n\n- Allowed only via existing conftest test-DB fixtures with transaction rollback or equivalent isolation\n\n- Never hardcode connection strings, passwords, tokens, or cloud credentials\n\n- Never target production/shared non-test databases\n\n- If isolation is unclear, report the gap instead of writing DB rows\n\n\n\n## Assertion Rules (MUST follow):\n\n\n\n### Positive Path\n\nMUST assert ALL of the following:\n\n1. HTTP status code: as defined in API spec (e.g. 200, 201)\n\n2. Response structure: key fields exist in response body\n\n3. Specific values: each field equals expected value from test case\n\n4. Data type: each field is correct type\n\n\n\n### Negative Path\n\nMUST assert ALL of the following:\n\n1. HTTP status code: as defined in API spec (e.g. 400, 404, 500)\n\n2. Error code field: field name from API spec (e.g. code, error_code, errcode, ret)\n\n3. Error message field: field name from API spec (e.g. message, msg, errmsg, error)\n\n\n\n### Field Name Resolution\n\nField names MUST come from the upstream analyze-inputs-pi output (API Endpoints section) or reviewed cases, NOT guessed. For example:\n\n- If API spec defines {\"ret\": 0, \"msg\": \"success\"}, assert response.json()['ret'] and response.json()['msg']\n\n- If API spec defines {\"code\": 4001, \"message\": \"error\"}, assert response.json()['code'] and response.json()['message']\n\n\n\n## Conditional Implementation (include ONLY if test cases exist):\n\n- Authentication tests: implement ONLY if testcase/md/ contains auth-related cases\n\n- Timeout tests: implement ONLY if testcase/md/ contains timeout-related cases\n\n- Boundary tests: implement ONLY when cases define value ranges, length limits, or format constraints\n\n- Use @pytest.mark.auth for auth tests, @pytest.mark.timeout for timeout tests\n\n- If no such cases exist, do NOT add these tests\n\n\n\n## Constraints:\n\n- Only create NEW files under writeSet: testcase/**/test_*.py, testcase/**/helpers/**, testcase/**/factories/**\n\n- Do NOT modify existing framework files (conftest.py, pytest.ini, pyproject.toml, setup.cfg, __init__.py)\n\n- If a test filename exists, add suffix: test_order.py → test_order_01.py\n\n- Do NOT re-read source documents — use reviewed cases under testcase/md/ and upstream analyze-inputs-pi output only\n\n- Read existing conftest.py/pytest.ini to understand conventions, but do NOT modify them\n\n- Do NOT execute pytest/python -m pytest or npm test in this node; the single execution is owned by the dedicated shell node. Local smoke runs create __pycache__/.pytest_cache and are unnecessary here."
284
284
  },
285
285
  {
286
286
  "id": "review-generated-backend-pytest-pi",
@@ -395,7 +395,7 @@
395
395
  "subtask_prompt": "Run pytest for the backend test suite; write JUnit + pytestExitCode evidence only under the current HARNESS_DAG_RUN_DIR/reports/.",
396
396
  "shell": {
397
397
  "commands": [
398
- "test -n \"${HARNESS_DAG_RUN_DIR:-}\" || { echo \"missing HARNESS_DAG_RUN_DIR for backend pytest preflight\" >&2; exit 2; }; CONTRACT=\"${HARNESS_DAG_RUN_DIR}/contracts/backend-test-execution.json\"; test -f \"${CONTRACT}\" || { echo \"missing backend-test execution contract: ${CONTRACT}\" >&2; exit 2; }; node -e 'const fs=require(\"fs\");const path=require(\"path\");const contractPath=process.argv[1];const contract=JSON.parse(fs.readFileSync(contractPath,\"utf8\"));const expected=\"testcase\";const errors=[];if(contract.framework!==\"pytest\") errors.push(\"framework must be pytest\");const testRoot=String(contract.testRoot||\"\");if(!testRoot||testRoot.includes(\"..\")||path.isAbsolute(testRoot)) errors.push(\"unsafe testRoot\");if(testRoot.replace(/\\/+$/,\"\")!==expected.replace(/\\/+$/,\"\")) errors.push(\"testRoot mismatch vs frozen command: \"+testRoot+\" !== \"+expected);const rootAbs=path.resolve(process.cwd(),testRoot);if(!fs.existsSync(rootAbs)) errors.push(\"testRoot does not exist: \"+testRoot);if(contract.targetMode===\"in-process\"&&!(Array.isArray(contract.existingFixtures)&&contract.existingFixtures.length)) errors.push(\"in-process requires existingFixtures\");for (const name of (contract.requiredEnvNames||[])) { if(!process.env[name]) errors.push(\"required env missing: \"+name); }if(contract.targetMode===\"external-running-service\"){ const n=contract.baseUrlEnvName; if(!n||!process.env[n]) errors.push(\"external base URL env missing: \"+String(n||\"<empty>\")); }if(contract.targetMode===\"managed-command\" && !(contract.managedCommand&&contract.managedCommand.sourceRef)) errors.push(\"managed-command requires sourceRef evidence\");if(errors.length){ console.error(errors.join(\"; \")); process.exit(2);} console.log(\"backend-test preflight ok: framework=pytest testRoot=\"+testRoot+\" targetMode=\"+contract.targetMode);' \"${CONTRACT}\"; REPORT=\"${HARNESS_DAG_RUN_DIR}/reports/backend-test-initial-junit.xml\"; EXIT_FILE=\"${HARNESS_DAG_RUN_DIR}/reports/backend-test-initial-pytest-exit.txt\"; mkdir -p \"$(dirname \"${REPORT}\")\"; PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest testcase/ -v -p no:cacheprovider --junitxml=\"${REPORT}\"; STATUS=$?; printf \"%s\" \"${STATUS}\" > \"${EXIT_FILE}\"; printf \"JUnit report: %s\\n\" \"${REPORT}\"; printf \"pytestExitCode=%s\\n\" \"${STATUS}\"; if { [ \"${STATUS}\" -eq 0 ] || [ \"${STATUS}\" -eq 1 ]; } && [ -s \"${REPORT}\" ]; then exit 0; fi; exit \"${STATUS}\""
398
+ "test -n \"${HARNESS_DAG_RUN_DIR:-}\" || { echo \"missing HARNESS_DAG_RUN_DIR for backend pytest preflight\" >&2; exit 2; } && CONTRACT=\"${HARNESS_DAG_RUN_DIR}/contracts/backend-test-execution.json\" && test -f \"${CONTRACT}\" || { echo \"missing backend-test execution contract: ${CONTRACT}\" >&2; exit 2; } && node -e 'const fs=require(\"fs\");const path=require(\"path\");const contractPath=process.argv[1];const contract=JSON.parse(fs.readFileSync(contractPath,\"utf8\"));const expected=\"testcase\";const errors=[];if(contract.framework!==\"pytest\") errors.push(\"framework must be pytest\");const testRoot=String(contract.testRoot||\"\");if(!testRoot||testRoot.includes(\"..\")||path.isAbsolute(testRoot)) errors.push(\"unsafe testRoot\");if(testRoot.replace(/\\/+$/,\"\")!==expected.replace(/\\/+$/,\"\")) errors.push(\"testRoot mismatch vs frozen command: \"+testRoot+\" !== \"+expected);const rootAbs=path.resolve(process.cwd(),testRoot);if(!fs.existsSync(rootAbs)) errors.push(\"testRoot does not exist: \"+testRoot);if(contract.targetMode===\"in-process\"&&!(Array.isArray(contract.existingFixtures)&&contract.existingFixtures.length)) errors.push(\"in-process requires existingFixtures\");for (const name of (contract.requiredEnvNames||[])) { if(!process.env[name]) errors.push(\"required env missing: \"+name); }if(contract.targetMode===\"external-running-service\"){ const n=contract.baseUrlEnvName; if(!n||!process.env[n]) errors.push(\"external base URL env missing: \"+String(n||\"<empty>\")); }if(contract.targetMode===\"managed-command\" && !(contract.managedCommand&&contract.managedCommand.sourceRef)) errors.push(\"managed-command requires sourceRef evidence\");if(errors.length){ console.error(errors.join(\"; \")); process.exit(2);} console.log(\"backend-test preflight ok: framework=pytest testRoot=\"+testRoot+\" targetMode=\"+contract.targetMode);' \"${CONTRACT}\" && { REPORT=\"${HARNESS_DAG_RUN_DIR}/reports/backend-test-initial-junit.xml\"; EXIT_FILE=\"${HARNESS_DAG_RUN_DIR}/reports/backend-test-initial-pytest-exit.txt\"; mkdir -p \"$(dirname \"${REPORT}\")\"; PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest testcase/ -v -p no:cacheprovider --junitxml=\"${REPORT}\"; STATUS=$?; printf \"%s\" \"${STATUS}\" > \"${EXIT_FILE}\"; printf \"JUnit report: %s\\n\" \"${REPORT}\"; printf \"pytestExitCode=%s\\n\" \"${STATUS}\"; if { [ \"${STATUS}\" -eq 0 ] || [ \"${STATUS}\" -eq 1 ]; } && [ -s \"${REPORT}\" ]; then exit 0; fi; exit \"${STATUS}\"; }"
399
399
  ],
400
400
  "envAllowlist": [],
401
401
  "verifyEvidence": {
@@ -404,7 +404,7 @@
404
404
  "commandSource": "inline",
405
405
  "commandCount": 1,
406
406
  "commandLabels": [
407
- "test -n \"${HARNESS_DAG_RUN_DIR:-}\" || { echo \"missing HARNESS_DAG_RUN_DIR for backend pytest preflight\" >&2; exit 2; }; CONTRACT=\"${HARNESS_DAG_RUN_DIR}/contracts/backend-test-execution.json\"; test -f \"${CONTRACT}\" || { echo \"missing backend-test execution contract: ${CONTRACT}\" >&2; exit 2; }; node -e 'const fs=require(\"fs\");const path=require(\"path\");const contractPath=process.argv[1];const contract=JSON.parse(fs.readFileSync(contractPath,\"utf8\"));const expected=\"testcase\";const errors=[];if(contract.framework!==\"pytest\") errors.push(\"framework must be pytest\");const testRoot=String(contract.testRoot||\"\");if(!testRoot||testRoot.includes(\"..\")||path.isAbsolute(testRoot)) errors.push(\"unsafe testRoot\");if(testRoot.replace(/\\/+$/,\"\")!==expected.replace(/\\/+$/,\"\")) errors.push(\"testRoot mismatch vs frozen command: \"+testRoot+\" !== \"+expected);const rootAbs=path.resolve(process.cwd(),testRoot);if(!fs.existsSync(rootAbs)) errors.push(\"testRoot does not exist: \"+testRoot);if(contract.targetMode===\"in-process\"&&!(Array.isArray(contract.existingFixtures)&&contract.existingFixtures.length)) errors.push(\"in-process requires existingFixtures\");for (const name of (contract.requiredEnvNames||[])) { if(!process.env[name]) errors.push(\"required env missing: \"+name); }if(contract.targetMode===\"external-running-service\"){ const n=contract.baseUrlEnvName; if(!n||!process.env[n]) errors.push(\"external base URL env missing: \"+String(n||\"<empty>\")); }if(contract.targetMode===\"managed-command\" && !(contract.managedCommand&&contract.managedCommand.sourceRef)) errors.push(\"managed-command requires sourceRef evidence\");if(errors.length){ console.error(errors.join(\"; \")); process.exit(2);} console.log(\"backend-test preflight ok: framework=pytest testRoot=\"+testRoot+\" targetMode=\"+contract.targetMode);' \"${CONTRACT}\"; REPORT=\"${HARNESS_DAG_RUN_DIR}/reports/backend-test-initial-junit.xml\"; EXIT_FILE=\"${HARNESS_DAG_RUN_DIR}/reports/backend-test-initial-pytest-exit.txt\"; mkdir -p \"$(dirname \"${REPORT}\")\"; PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest testcase/ -v -p no:cacheprovider --junitxml=\"${REPORT}\"; STATUS=$?; printf \"%s\" \"${STATUS}\" > \"${EXIT_FILE}\"; printf \"JUnit report: %s\\n\" \"${REPORT}\"; printf \"pytestExitCode=%s\\n\" \"${STATUS}\"; if { [ \"${STATUS}\" -eq 0 ] || [ \"${STATUS}\" -eq 1 ]; } && [ -s \"${REPORT}\" ]; then exit 0; fi; exit \"${STATUS}\""
407
+ "test -n \"${HARNESS_DAG_RUN_DIR:-}\" || { echo \"missing HARNESS_DAG_RUN_DIR for backend pytest preflight\" >&2; exit 2; } && CONTRACT=\"${HARNESS_DAG_RUN_DIR}/contracts/backend-test-execution.json\" && test -f \"${CONTRACT}\" || { echo \"missing backend-test execution contract: ${CONTRACT}\" >&2; exit 2; } && node -e 'const fs=require(\"fs\");const path=require(\"path\");const contractPath=process.argv[1];const contract=JSON.parse(fs.readFileSync(contractPath,\"utf8\"));const expected=\"testcase\";const errors=[];if(contract.framework!==\"pytest\") errors.push(\"framework must be pytest\");const testRoot=String(contract.testRoot||\"\");if(!testRoot||testRoot.includes(\"..\")||path.isAbsolute(testRoot)) errors.push(\"unsafe testRoot\");if(testRoot.replace(/\\/+$/,\"\")!==expected.replace(/\\/+$/,\"\")) errors.push(\"testRoot mismatch vs frozen command: \"+testRoot+\" !== \"+expected);const rootAbs=path.resolve(process.cwd(),testRoot);if(!fs.existsSync(rootAbs)) errors.push(\"testRoot does not exist: \"+testRoot);if(contract.targetMode===\"in-process\"&&!(Array.isArray(contract.existingFixtures)&&contract.existingFixtures.length)) errors.push(\"in-process requires existingFixtures\");for (const name of (contract.requiredEnvNames||[])) { if(!process.env[name]) errors.push(\"required env missing: \"+name); }if(contract.targetMode===\"external-running-service\"){ const n=contract.baseUrlEnvName; if(!n||!process.env[n]) errors.push(\"external base URL env missing: \"+String(n||\"<empty>\")); }if(contract.targetMode===\"managed-command\" && !(contract.managedCommand&&contract.managedCommand.sourceRef)) errors.push(\"managed-command requires sourceRef evidence\");if(errors.length){ console.error(errors.join(\"; \")); process.exit(2);} console.log(\"backend-test preflight ok: framework=pytest testRoot=\"+testRoot+\" targetMode=\"+contract.targetMode);' \"${CONTRACT}\" && { REPORT=\"${HARNESS_DAG_RUN_DIR}/reports/backend-test-initial-junit.xml\"; EXIT_FILE=\"${HARNESS_DAG_RUN_DIR}/reports/backend-test-initial-pytest-exit.txt\"; mkdir -p \"$(dirname \"${REPORT}\")\"; PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest testcase/ -v -p no:cacheprovider --junitxml=\"${REPORT}\"; STATUS=$?; printf \"%s\" \"${STATUS}\" > \"${EXIT_FILE}\"; printf \"JUnit report: %s\\n\" \"${REPORT}\"; printf \"pytestExitCode=%s\\n\" \"${STATUS}\"; if { [ \"${STATUS}\" -eq 0 ] || [ \"${STATUS}\" -eq 1 ]; } && [ -s \"${REPORT}\" ]; then exit 0; fi; exit \"${STATUS}\"; }"
408
408
  ],
409
409
  "finalFullRequired": true
410
410
  },
@@ -496,13 +496,46 @@
496
496
  "artifacts/**"
497
497
  ],
498
498
  "outputContract": "Maturity rating in assistant output plus a report written under docs/test-reports/**.",
499
- "subtask_prompt": "Read the complete JSON from direct upstream materialize-classification-and-result-context-shell and generate a test retrospective report.\n\nThat JSON contains result, manifest (including coverageSummary), and classification. Treat those fields as authoritative; do not rely on pointer/hash summaries.\n\n\n\n## Output Steps (do in order):\n\n1. First, output the maturity rating on the first line: Rating: A/B/C/D\n\n2. Then write the full report under docs/test-reports/\n\n\n\n## Stats authority (deterministic only):\n\n- Pass rate, failed/error/skipped counts, and failure list MUST come from contracts/backend-test-result.json only.\n\n- AC coverage ratio / case counts MUST come from contracts/backend-test-case-manifest.json coverageSummary (or gate-derived fields). Do NOT invent coverage %.\n\n- Use classify-backend-test-result-pi JSON as interpretive evidence only.\n\n- NEVER rewrite a failed result as passed. Outcome gate (not this report) is authoritative for task success.\n\n\n\n## Report Structure:\n\n1. Maturity Rating with rationale\n\n2. Test Coverage Summary (manifest coverageSummary + Result v1 pass rate)\n\n3. Review Findings and resolution status\n\n4. Failed Test Analysis (if any) + classification category\n\n5. Recommendations for improvement\n\n\n\n## Rating Criteria:\n\n- A: coverageSummary.acCoverageRatio=1 + 100% pytest pass + no Critical findings\n\n- B: acCoverageRatio≥0.8 + ≥90% pass + Low findings only\n\n- C: acCoverageRatio≥0.6 + ≥70% pass + no Critical findings\n\n- D: below C thresholds\n\n\n\n## Constraints:\n\n- Stay within writeSet: docs/test-reports/**\n\n- Do NOT re-read source documents — use upstream outputs only\n\n- Do not write root artifacts/**"
499
+ "subtask_prompt": "Read the complete JSON from direct upstream materialize-classification-and-result-context-shell and generate a test retrospective report.\n\nThat JSON contains result, manifest (including coverageSummary), and classification. Treat those fields as authoritative; do not rely on pointer/hash summaries.\n\n\n\n## Output Steps (do in order):\n\n1. First, output the maturity rating on the first line: Rating: A/B/C/D\n\n2. Then write the full report under docs/test-reports/\n\n\n\n## Stats authority (deterministic only):\n\n- Pass rate, failed/error/skipped counts, and failure list MUST come from contracts/backend-test-result.json only.\n\n- AC coverage ratio / case counts MUST come from contracts/backend-test-case-manifest.json coverageSummary (or gate-derived fields). Do NOT invent coverage %.\n\n- Automation coverage MUST use coverageSummary.generatedCount / coverageSummary.caseCount. If either field is missing, write unavailable; do not estimate.\n\n- Code coverage MUST come only from the validated contracts/code-coverage-v1.json artifact generated by coverage.py/pytest-cov or JaCoCo. Show line, branch, function/method, covered, total, ratio, threshold, status, source scope, requirement IDs, tool, commit, and artifact hash.\n\n- Stability MUST come from independent Stability Evidence: use successfulRuns / recordedRuns, same suite/version, and require n≥5; a single run is unavailable.\n\n- Use classify-backend-test-result-pi JSON as interpretive evidence only.\n\n- NEVER rewrite a failed result as passed. Outcome gate (not this report) is authoritative for task success.\n\n\n\n## Report Structure:\n\n1. Maturity Rating with rationale\n\n2. Test Coverage Summary (Result v1 pass rate, AC coverage, automation coverage, code coverage, and stability evidence)\n\n3. Failed Test Analysis (failure/error details, category, confidence, evidence, and owner direction)\n\n4. Defects (local Bug ledger in the same report directory; unavailable when absent)\n\n5. Risks (Critical/High/Medium/Low, impact, controls, residual risk, treatment; Critical risks block L-5, High risks do not automatically block)\n\n6. Regression Recommendations (immediate, related, periodic, deferred; every item links to failure/risk/AC/case IDs)\n\n7. L-5 conclusion with blocking items\n\n\n\n## Rating Criteria:\n\n- L-5 ready requires pass rate=100%, AC coverage=100%, automation coverage≥90%, stability≥95% with n≥5, line coverage≥80%, branch coverage≥70%, skipped=0, and no blocking Critical risk.\n\n- Any required metric fail or unavailable means L-5 not-ready. Function/method coverage is displayed but not a gate. Preserve the existing A/B/C/D single-run rating separately.\n\n\n\n## Constraints:\n\n- Stay within writeSet: docs/test-reports/**\n\n- Do NOT re-read source documents — use upstream outputs only\n\n- Do not write root artifacts/**"
500
500
  },
501
501
  {
502
- "id": "backend-test-outcome-gate-shell",
502
+ "id": "l5-metrics-pi",
503
503
  "depends_on": [
504
504
  "test-retrospect-pi"
505
505
  ],
506
+ "role": "reviewer",
507
+ "executor": "pi",
508
+ "complexity": "MED",
509
+ "writePolicy": "read-only",
510
+ "allowedPaths": [
511
+ "testcase/**",
512
+ "docs/test-reports/**"
513
+ ],
514
+ "forbiddenPaths": [
515
+ ".harness/**",
516
+ ".harness/dag-runs/**",
517
+ "artifacts/**"
518
+ ],
519
+ "outputContract": "Exactly one JSON object with status=ready|not-ready, metrics, and blockingItems; no file writes.",
520
+ "subtask_prompt": "You are the independent L-5 metrics node at the end of the existing backend-test DAG.\n\nThe direct upstream test-retrospect-pi output is the primary report to assess. Read it together with the run-owned Result v1, Case Manifest v1, Code Coverage v1, and Stability Evidence artifacts when present.\n\nDo not create a new DAG, rewrite the retrospective report, change test outcome, or modify any repository file.\n\nReturn exactly one JSON object and no surrounding prose.\n\nRequired shape: {\"status\":\"ready\"|\"not-ready\",\"metrics\":{\"passRate\":metric,\"acCoverage\":metric,\"automationCoverage\":metric,\"stability\":metric,\"lineCoverage\":metric,\"branchCoverage\":metric,\"skipped\":metric,\"criticalRisks\":metric},\"blockingItems\":[string]}.\n\nEach metric must contain numerator, denominator, ratio, threshold, status=pass|fail|unavailable, and reason (null only when passed).\n\nUse only explicit evidence. Missing or invalid required evidence is unavailable, never zero or an estimate.\n\nL-5 ready requires pass rate=100%, AC coverage=100%, automation coverage>=90%, stability>=95% with n>=5, line coverage>=80%, branch coverage>=70%, skipped=0, and zero blocking Critical risks.\n\nFunction/method coverage is display-only and does not gate L-5. Preserve the distinction between L-5 maturity and the Result v1 outcome gate.",
521
+ "retryPolicy": {
522
+ "maxAttempts": 3,
523
+ "backoff": "exponential",
524
+ "initialDelayMs": 2000,
525
+ "maxDelayMs": 30000,
526
+ "retryCategories": [
527
+ "timeout",
528
+ "network",
529
+ "rate-limit",
530
+ "unavailable"
531
+ ]
532
+ }
533
+ },
534
+ {
535
+ "id": "backend-test-outcome-gate-shell",
536
+ "depends_on": [
537
+ "l5-metrics-pi"
538
+ ],
506
539
  "role": "verifier",
507
540
  "executor": "shell",
508
541
  "complexity": "LOW",
@@ -46,7 +46,9 @@ This node runs on **both pass and assertion-fail** paths (after parse + classify
46
46
  Use `coverageSummary.acCoverageRatio`, `coveredAcCount`, `explicitAcCount`, case counts only from this artifact.
47
47
  3. **Classification** — `classify-backend-test-result-pi` JSON (`category`, `confidence`, `evidence`). Interpretive only; does not override outcome.
48
48
  4. **Review report** — `review-backend-cases-pi` output (VERDICT, findings, coverage assessment).
49
- 5. Optional secondary: execute stdout markers / JUnit path (do not re-parse logs for counts when Result v1 exists).
49
+ 5. **Code Coverage v1** optional validated `contracts/code-coverage-v1.json`, generated by coverage.py/pytest-cov or JaCoCo. Never infer it from Result v1.
50
+ 6. **Stability Evidence** — optional independent contract for repeated runs of the same suite and version.
51
+ 7. Optional secondary: execute stdout markers / JUnit path (do not re-parse logs for counts when Result v1 exists).
50
52
 
51
53
  Do NOT re-read source documents. Use upstream outputs only.
52
54
 
@@ -54,6 +56,9 @@ Do NOT re-read source documents. Use upstream outputs only.
54
56
 
55
57
  - Pass rate = `passed / (passed + failed + error)` when denominator > 0 (skipped excluded from denominator unless Result documents otherwise) — **Result v1 only**.
56
58
  - AC coverage = `coverageSummary.acCoverageRatio` from Case Manifest v1 only (do **not** recompute or invent percentages).
59
+ - Automation coverage = `coverageSummary.generatedCount / coverageSummary.caseCount`; if either field is missing, output `unavailable`.
60
+ - Code coverage = Code Coverage v1 only. Display line (gate ≥80%), branch (gate ≥70%), and function/method (display only), each with covered, total, ratio, status, reason, source scope, requirement IDs, tool/version, commit and artifact SHA-256. Missing artifacts or line/branch metrics are `unavailable` and block L-5.
61
+ - Stability = Stability Evidence `successfulRuns / recordedRuns`, same suite and version, minimum `n≥5`; one run is `unavailable` and cannot prove `FlakyTest`.
57
62
  - Failed case table rows must match `failures[]` from Result v1.
58
63
  - If Result v1 `outcome` is not `passed`, the retrospective **must not** claim overall success.
59
64
 
@@ -74,6 +79,7 @@ Do NOT re-read source documents. Use upstream outputs only.
74
79
  - If Result v1 shows >30% failed+error among executed tests, cap at **D** regardless of coverage.
75
80
  - Skipped tests count as "not covered" for pass rate but not as failures.
76
81
  - Collection/command/report errors → cap at **D** and record classification (not ProductBug by default).
82
+ - The downstream `l5-metrics-pi` node owns the separate L-5 conclusion. This retrospective must provide the authoritative evidence and risks, but must not replace the downstream L-5 JSON conclusion.
77
83
 
78
84
  ### Report Structure
79
85
 
@@ -95,6 +101,15 @@ Write the report as a Markdown file named `backend-test-retrospect-<date>.md` un
95
101
  | Total acceptance criteria | N |
96
102
  | Covered by test cases | N (X%) |
97
103
  | Total functional test cases | N |
104
+ | Automated cases / total cases | N / N (X%) or unavailable |
105
+ | Code line coverage | N / N (X%), threshold ≥80%, status |
106
+ | Code branch coverage | N / N (X%), threshold ≥70%, status |
107
+ | Code function/method coverage | N / N (X%) or unavailable |
108
+ | Stability | successfulRuns / recordedRuns (X%), n, or unavailable |
109
+
110
+ Code coverage must include language, tool/version, requirement IDs, source scope, commit and artifact SHA-256.
111
+
112
+ The downstream `l5-metrics-pi` node consumes this report and the run-owned contracts to calculate the independent L-5 conclusion.
98
113
 
99
114
  ## 2. Automation Results (from Result v1)
100
115
 
@@ -112,21 +127,35 @@ Write the report as a Markdown file named `backend-test-retrospect-<date>.md` un
112
127
 
113
128
  ### Failed Test Analysis
114
129
 
115
- | Test Case / Function | Message (truncated) | Classification |
116
- |----------------------|-------------------|----------------|
117
- | ... | ... | ... |
130
+ | Test Case / Function | Kind | Classification | Confidence | Evidence | Owner direction |
131
+ |----------------------|------|----------------|------------|----------|----------------|
132
+ | ... | ... | ... | ... | ... | ... |
133
+
134
+ ## 3. Defects
135
+
136
+ Use the local Bug ledger in the same directory as the report. Include defect ID, title, AC/case, severity, priority, status, reproduction, evidence, category, owner, fix version and regression status. If absent, write `缺陷登记:unavailable`.
137
+
138
+ ## 4. Risks
139
+
140
+ | Risk ID | Level | Impact | Likelihood | Current control | Residual risk | Treatment |
141
+ |---------|-------|--------|------------|-----------------|---------------|-----------|
142
+ | ... | Critical/High/Medium/Low | ... | ... | ... | ... | ... |
143
+
144
+ ## 5. Regression Recommendations
145
+
146
+ Every recommendation must reference a failure/risk/AC/case ID and include target suite, priority and verification command.
118
147
 
119
- ## 3. Review Findings
148
+ ## 6. Review Findings
120
149
 
121
150
  | Severity | Finding | Status |
122
151
  |----------|---------|--------|
123
152
  | … | … | … |
124
153
 
125
- ## 4. Maturity Rating Rationale
154
+ ## 7. Maturity Rating Rationale
126
155
 
127
156
  Explain which threshold was met or missed.
128
157
 
129
- ## 5. Recommendations
158
+ ## 8. Recommendations
130
159
 
131
160
  - Actionable items for the next iteration.
132
161
  - Do not propose changing production code solely to greenwash tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.16.24",
3
+ "version": "0.16.25",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -374,6 +374,7 @@ loop-agent plan complete <plan-id> --summary "<summary>"
374
374
  loop-agent plan check
375
375
  loop-agent handoff check [task-id]
376
376
  loop-agent handoff coverage <task-id> [--json|--markdown]
377
+ loop-agent coverage report --language python|java --input <coverage.json|jacoco.xml> --requirement-id <AC-id> --source-scope <path[,path]> [--output <path>] [--json|--markdown]
377
378
  ```
378
379
 
379
380
  - `docs audit`:扫描文档腐化风险,如 active/completed 漂移、失效链接、host-gap closeout