@tea-agent/loop-agent 0.28.1-beta.1 → 0.28.2-beta.1

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,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.28.1] - 2026-08-05
6
+
7
+ ### 改进
8
+
9
+ - cut npm test wall clock to ~264s and archive finished plans
10
+ - avoid routing maintenance tasks to frontend dag
11
+ - make openspec evidence non-blocking
12
+ - disable frontend lint generation
13
+ - tolerate prose before frontend contract json
14
+ - fix frontend topology and verdict tests
15
+ - reduce llm protocol validation failures
16
+ - fix frontend prewrite contract normalization
17
+
18
+ ### 修复
19
+
20
+ - stabilize identity-sensitive tests
21
+ - fail closed on backend writer no-op
22
+ - validate frontend contract before writing
23
+ - add structured frontend contract node
24
+ - harden verification and JSON parsing
25
+ - accept inline pass verdict
26
+ - normalize empty model fields
27
+
5
28
  ## [0.28.0] - 2026-08-05
6
29
 
7
30
  ### 重点更新
@@ -141,7 +141,14 @@ export function buildDagPiUserMessage(task, persona, step) {
141
141
  const writePolicy = task.writePolicy ?? "read-only (default)";
142
142
  if (isDagPiWriteTask(task)) {
143
143
  const outcomeInstruction = task.writerOutcomePolicy
144
- ? "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked. Use changed only after producing a non-empty bounded diff; use already-satisfied only when the contract is already met and no file changed; use blocked when implementation cannot proceed."
144
+ ? [
145
+ "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked. Use changed only after producing a non-empty bounded diff; use already-satisfied only when the contract is already met and no file changed; use blocked when implementation cannot proceed.",
146
+ task.writerOutcomePolicy.requireChangedFiles
147
+ ? "This generation node requires a non-empty bounded diff; already-satisfied cannot complete it successfully."
148
+ : undefined,
149
+ ]
150
+ .filter((value) => Boolean(value))
151
+ .join(" ")
145
152
  : undefined;
146
153
  return [
147
154
  `You are executing hybrid DAG node "${task.id}" (role=${role}, piStep=${step}, writePolicy=${writePolicy}).`,
@@ -635,7 +642,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
635
642
  "writer outcome validation failed: actual diff is unavailable";
636
643
  }
637
644
  else {
638
- const outcomeValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles);
645
+ const outcomeValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles, {
646
+ requireChangedFiles: input.task.writerOutcomePolicy.requireChangedFiles === true,
647
+ });
639
648
  if (!outcomeValidation.ok) {
640
649
  writerOutcomeViolation = outcomeValidation.reason;
641
650
  }
@@ -678,22 +687,41 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
678
687
  durationMs: mapped.durationMs || Date.now() - started,
679
688
  };
680
689
  }
681
- export function validateWriterImplementationOutcome(text, changedFiles) {
690
+ export function validateWriterImplementationOutcome(text, changedFiles, options) {
682
691
  const parsed = parseWriterImplementationOutcome(text);
683
692
  const diagnostics = writerOutcomeDiagnostics(text, parsed, changedFiles.length);
684
- // The workspace diff is authoritative. The model outcome is advisory and
685
- // may be missing, malformed, or inconsistent without blocking a completed
686
- // writer. An explicit blocked signal remains a hard failure.
687
- if (parsed.candidates.some((candidate) => candidate.outcome === "blocked")) {
693
+ if (parsed.kind !== "valid") {
688
694
  return {
689
695
  ok: false,
690
- reason: `writer outcome validation failed: explicit blocked outcome; ${diagnostics}`,
696
+ reason: `writer outcome validation failed: ${parsed.kind} outcome; ${diagnostics}; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked`,
691
697
  };
692
698
  }
693
- return {
694
- ok: true,
695
- outcome: changedFiles.length > 0 ? "changed" : "already-satisfied",
696
- };
699
+ const outcome = parsed.outcome;
700
+ if (outcome === "blocked") {
701
+ return {
702
+ ok: false,
703
+ reason: `writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully; ${diagnostics}`,
704
+ };
705
+ }
706
+ if (outcome === "changed" && changedFiles.length === 0) {
707
+ return {
708
+ ok: false,
709
+ reason: `writer outcome validation failed: changed outcome has an empty diff; ${diagnostics}`,
710
+ };
711
+ }
712
+ if (outcome === "already-satisfied" && changedFiles.length > 0) {
713
+ return {
714
+ ok: false,
715
+ reason: `writer outcome validation failed: already-satisfied outcome has a non-empty diff; ${diagnostics}`,
716
+ };
717
+ }
718
+ if (options?.requireChangedFiles === true && changedFiles.length === 0) {
719
+ return {
720
+ ok: false,
721
+ reason: `writer outcome validation failed: node requires a non-empty diff; ${diagnostics}`,
722
+ };
723
+ }
724
+ return { ok: true, outcome };
697
725
  }
698
726
  function parseWriterImplementationOutcome(text) {
699
727
  const lines = text.split(/\r?\n/);
@@ -261,7 +261,7 @@ export function extractAssistantTextFromPiJson(stdout) {
261
261
  const event = JSON.parse(trimmed);
262
262
  parsedEvents += 1;
263
263
  const candidate = extractAssistantTextFromEvent(event);
264
- if (candidate)
264
+ if (candidate !== undefined)
265
265
  assistantText = candidate;
266
266
  }
267
267
  catch {
@@ -331,23 +331,24 @@ function readNumericUsageField(record, keys) {
331
331
  return null;
332
332
  }
333
333
  function extractAssistantTextFromEvent(event) {
334
- if (event.type === "turn_end" && isRecord(event.message)) {
335
- return extractAssistantTextFromMessage(event.message);
336
- }
337
- if (event.type === "message_end" && isRecord(event.message)) {
334
+ if ((event.type === "turn_end" || event.type === "message_end") &&
335
+ isRecord(event.message) &&
336
+ event.message.role === "assistant") {
338
337
  return extractAssistantTextFromMessage(event.message);
339
338
  }
340
339
  if (event.type === "agent_end" && Array.isArray(event.messages)) {
341
340
  for (let i = event.messages.length - 1; i >= 0; i -= 1) {
342
341
  const message = event.messages[i];
343
- if (isRecord(message)) {
344
- const text = extractAssistantTextFromMessage(message);
345
- if (text)
346
- return text;
342
+ if (isRecord(message) && message.role === "assistant") {
343
+ // The terminal assistant message is authoritative even when it only
344
+ // contains thinking/tool content. Do not walk farther back and reuse a
345
+ // stale progress sentence as the final node output.
346
+ return extractAssistantTextFromMessage(message);
347
347
  }
348
348
  }
349
+ return "";
349
350
  }
350
- return "";
351
+ return undefined;
351
352
  }
352
353
  function extractAssistantTextFromMessage(message) {
353
354
  if (message.role !== "assistant" || !Array.isArray(message.content))
@@ -472,7 +473,7 @@ export class PiJsonlStreamCollector {
472
473
  }
473
474
  consumeEvent(event) {
474
475
  const candidate = extractAssistantTextFromEvent(event);
475
- if (candidate) {
476
+ if (candidate !== undefined) {
476
477
  if (candidate.length > MAX_ASSISTANT_TEXT_CHARS) {
477
478
  this.outputTooLarge = true;
478
479
  this.assistantText = candidate.slice(0, MAX_ASSISTANT_TEXT_CHARS);
@@ -964,11 +965,11 @@ export function classifyPiFailure(input) {
964
965
  return "quota";
965
966
  if (/rate.?limit/.test(combined))
966
967
  return "rate-limit";
967
- if (/unauthorized|forbidden|invalid api key|authentication|auth failed/.test(combined))
968
+ if (/\bunauthorized\b|\bhttp\s*40[13]\b|invalid api key|authentication (?:failed|required|error)|auth(?:entication)? failed/.test(combined))
968
969
  return "auth";
969
970
  if (/unknown provider|unknown model|model.*unavailable|provider.*unavailable|\bunavailable\b|overloaded|capacity|temporarily unavailable/.test(combined))
970
971
  return "unavailable";
971
- if (/network|econnreset|etimedout|socket hang up|connection reset|dns|fetch failed/.test(combined))
972
+ if (/network|econnreset|etimedout|socket hang up|connection reset|connection error|request timed out|dns|fetch failed/.test(combined))
972
973
  return "network";
973
974
  if (input.exitCode === 0 && !input.assistantText.trim())
974
975
  return "empty-output";
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
- import { appendFile, lstat, mkdir, readFile, realpath, stat } from "node:fs/promises";
3
+ import { appendFile, lstat, mkdir, readFile, realpath, stat, } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { processTreeSpawnOptions, terminateProcessTree } from "./process-tree.js";
5
+ import { processTreeSpawnOptions, terminateProcessTree, } from "./process-tree.js";
6
6
  import { resolvePlaywrightCliLauncher } from "./playwright-cli-launcher.js";
7
7
  import { PLAYWRIGHT_CLI_ALLOWED_COMMANDS, isPlaywrightCliCommand, } from "../shared/playwright-cli-command-policy.js";
8
8
  export { PLAYWRIGHT_CLI_ALLOWED_COMMANDS, isPlaywrightCliCommand, };
@@ -55,10 +55,14 @@ function normalizePosix(value) {
55
55
  }
56
56
  function isSafeRelativePosix(value) {
57
57
  const normalized = normalizePosix(value);
58
- if (!normalized || path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
58
+ if (!normalized ||
59
+ path.posix.isAbsolute(normalized) ||
60
+ path.win32.isAbsolute(normalized)) {
59
61
  return false;
60
62
  }
61
- return normalized.split("/").every((part) => part.length > 0 && part !== "." && part !== "..");
63
+ return normalized
64
+ .split("/")
65
+ .every((part) => part.length > 0 && part !== "." && part !== "..");
62
66
  }
63
67
  export function redactPlaywrightCliArgs(command, args) {
64
68
  return args.map((arg) => {
@@ -168,7 +172,9 @@ function assertNoControlMeta(arg) {
168
172
  }
169
173
  }
170
174
  function assertNoSessionFlag(arg) {
171
- if (SESSION_FLAG.test(arg) || arg === "--session" || arg.startsWith("--session=")) {
175
+ if (SESSION_FLAG.test(arg) ||
176
+ arg === "--session" ||
177
+ arg.startsWith("--session=")) {
172
178
  throw new PlaywrightCliPolicyError("named-session-forbidden", "named browser sessions are forbidden; use the default session only");
173
179
  }
174
180
  }
@@ -176,10 +182,13 @@ function isProductionHost(hostname) {
176
182
  const host = hostname.toLowerCase();
177
183
  if (host === "localhost" || host === "127.0.0.1" || host === "::1")
178
184
  return false;
179
- if (host.endsWith(".local") || host.endsWith(".test") || host.endsWith(".localhost")) {
185
+ if (host.endsWith(".local") ||
186
+ host.endsWith(".test") ||
187
+ host.endsWith(".localhost")) {
180
188
  return false;
181
189
  }
182
- return /(?:^|\.)prod(?:uction)?(?:\.|$)/i.test(host) || /(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(host);
190
+ return (/(?:^|\.)prod(?:uction)?(?:\.|$)/i.test(host) ||
191
+ /(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(host));
183
192
  }
184
193
  function validateOpenArgs(args, baseUrl) {
185
194
  const flags = new Set();
@@ -193,7 +202,9 @@ function validateOpenArgs(args, baseUrl) {
193
202
  normalized.push(arg);
194
203
  continue;
195
204
  }
196
- if (arg.startsWith("--browser=") || arg === "--browser" || arg === "--headless") {
205
+ if (arg.startsWith("--browser=") ||
206
+ arg === "--browser" ||
207
+ arg === "--headless") {
197
208
  throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headed only");
198
209
  }
199
210
  if (/^[a-z][a-z0-9+.-]*:/i.test(arg) || arg.startsWith("http")) {
@@ -247,14 +258,17 @@ function validateOpenArgs(args, baseUrl) {
247
258
  function resolveEvidenceRelative(rawPath, ctx) {
248
259
  const evidenceDir = normalizePosix(ctx.evidenceDir).replace(/\/$/, "");
249
260
  const evidencePrefix = `testcase/frontend/evidence/${ctx.caseId}`;
250
- if (!(evidenceDir === evidencePrefix || evidenceDir.startsWith(`${evidencePrefix}/`))) {
261
+ if (!(evidenceDir === evidencePrefix ||
262
+ evidenceDir.startsWith(`${evidencePrefix}/`))) {
251
263
  throw new PlaywrightCliPolicyError("evidence-dir-invalid", `evidenceDir must be under ${evidencePrefix}`);
252
264
  }
253
265
  const candidate = normalizePosix(rawPath);
254
266
  if (!candidate || candidate.includes("\0")) {
255
267
  throw new PlaywrightCliPolicyError("path-empty", "empty output path");
256
268
  }
257
- if (path.win32.isAbsolute(candidate) || path.posix.isAbsolute(candidate) || /^[a-zA-Z]:\//.test(candidate)) {
269
+ if (path.win32.isAbsolute(candidate) ||
270
+ path.posix.isAbsolute(candidate) ||
271
+ /^[a-zA-Z]:\//.test(candidate)) {
258
272
  throw new PlaywrightCliPolicyError("path-absolute", "absolute output paths are forbidden");
259
273
  }
260
274
  if (candidate.split("/").includes("..")) {
@@ -267,7 +281,8 @@ function resolveEvidenceRelative(rawPath, ctx) {
267
281
  if (candidate.startsWith(`${evidenceDir}/`) || candidate === evidenceDir) {
268
282
  return candidate;
269
283
  }
270
- if (candidate.startsWith("testcase/frontend/evidence/") && !candidate.startsWith(`${evidencePrefix}/`)) {
284
+ if (candidate.startsWith("testcase/frontend/evidence/") &&
285
+ !candidate.startsWith(`${evidencePrefix}/`)) {
271
286
  throw new PlaywrightCliPolicyError("path-cross-case", "output path escapes current case evidenceDir");
272
287
  }
273
288
  throw new PlaywrightCliPolicyError("path-outside-evidence", `output path must stay under ${evidenceDir}`);
@@ -292,9 +307,7 @@ function resolveScreenshotFilename(rawFilename, ctx) {
292
307
  }
293
308
  }
294
309
  function isSafeBareScreenshotFilename(value) {
295
- return (!value.includes("/") &&
296
- !value.includes("\\") &&
297
- isSafeRelativePosix(value));
310
+ return (!value.includes("/") && !value.includes("\\") && isSafeRelativePosix(value));
298
311
  }
299
312
  function isPlaywrightCliElementRef(value) {
300
313
  return /^e[1-9]\d*$/.test(value);
@@ -390,7 +403,8 @@ function normalizePdfOrSnapshotArgs(command, args, ctx) {
390
403
  target = arg;
391
404
  }
392
405
  if (filename === undefined) {
393
- if (command === "snapshot" && (target === undefined || isPlaywrightCliElementRef(target))) {
406
+ if (command === "snapshot" &&
407
+ (target === undefined || isPlaywrightCliElementRef(target))) {
394
408
  return target ? [target] : [];
395
409
  }
396
410
  throw new PlaywrightCliPolicyError("output-filename-required", `${command} requires --filename <file>`);
@@ -432,7 +446,9 @@ function rewriteArgsForPaths(command, args, ctx) {
432
446
  i += 1;
433
447
  continue;
434
448
  }
435
- if (inputCommands.has(command) && !arg.startsWith("-") && /\.[a-z0-9]+$/i.test(arg)) {
449
+ if (inputCommands.has(command) &&
450
+ !arg.startsWith("-") &&
451
+ /\.[a-z0-9]+$/i.test(arg)) {
436
452
  assertInputPathAllowed(arg, ctx);
437
453
  rewritten.push(normalizePosix(arg));
438
454
  continue;
@@ -530,19 +546,17 @@ async function validatePlaywrightCliInputFiles(command, args, ctx) {
530
546
  if (!isSafeRelativePosix(candidate)) {
531
547
  throw new PlaywrightCliPolicyError("input-path-unsafe", "upload/drop input must be a safe path relative to inputRoot");
532
548
  }
549
+ // Resolve both sides with realpath before containment checks. macOS temp
550
+ // paths often enter via /var/... while realpath returns /private/var/...,
551
+ // which would otherwise false-positive as path escape.
533
552
  const lexical = path.resolve(ctx.repoRoot, candidate);
534
- const relative = path.relative(root, lexical);
535
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
536
- throw new PlaywrightCliPolicyError("input-path-outside", "upload/drop input escapes inputRoot");
537
- }
538
553
  const info = await lstat(lexical);
539
554
  if (!info.isFile() || info.isSymbolicLink()) {
540
555
  throw new PlaywrightCliPolicyError("input-not-regular-file", "upload/drop input must be a regular non-symlink file");
541
556
  }
542
557
  const resolved = await realpath(lexical);
543
- const resolvedRelative = path.relative(root, resolved);
544
- if (resolvedRelative.startsWith("..") || path.isAbsolute(resolvedRelative)) {
545
- throw new PlaywrightCliPolicyError("input-realpath-escape", "upload/drop input realpath escapes inputRoot");
558
+ if (!pathIsContained(root, resolved)) {
559
+ throw new PlaywrightCliPolicyError("input-path-outside", "upload/drop input escapes inputRoot");
546
560
  }
547
561
  if (!(await stat(resolved)).isFile()) {
548
562
  throw new PlaywrightCliPolicyError("input-not-regular-file", "upload/drop input must resolve to a regular file");
@@ -609,7 +623,10 @@ async function validatePlaywrightCliOutputPath(command, args, ctx) {
609
623
  throw outputRealpathUnsafe("output evidence directory must be an existing non-symlink directory");
610
624
  }
611
625
  }
612
- const relativeParts = path.relative(repoRoot, target).split(path.sep).filter(Boolean);
626
+ const relativeParts = path
627
+ .relative(repoRoot, target)
628
+ .split(path.sep)
629
+ .filter(Boolean);
613
630
  let current = repoRoot;
614
631
  let nearestExistingDirectory = repoRoot;
615
632
  let targetExists = false;
@@ -638,12 +655,13 @@ async function validatePlaywrightCliOutputPath(command, args, ctx) {
638
655
  let evidenceReal;
639
656
  let nearestExistingReal;
640
657
  try {
641
- [repoReal, caseEvidenceReal, evidenceReal, nearestExistingReal] = await Promise.all([
642
- realpath(repoRoot),
643
- realpath(caseEvidenceRoot),
644
- realpath(evidenceDir),
645
- realpath(nearestExistingDirectory),
646
- ]);
658
+ [repoReal, caseEvidenceReal, evidenceReal, nearestExistingReal] =
659
+ await Promise.all([
660
+ realpath(repoRoot),
661
+ realpath(caseEvidenceRoot),
662
+ realpath(evidenceDir),
663
+ realpath(nearestExistingDirectory),
664
+ ]);
647
665
  }
648
666
  catch {
649
667
  throw outputRealpathUnsafe("cannot resolve output containment realpath");
@@ -662,7 +680,8 @@ async function validatePlaywrightCliOutputPath(command, args, ctx) {
662
680
  catch {
663
681
  throw outputRealpathUnsafe("cannot resolve existing output target realpath");
664
682
  }
665
- if (!pathIsContained(evidenceReal, targetReal) || !pathIsContained(repoReal, targetReal)) {
683
+ if (!pathIsContained(evidenceReal, targetReal) ||
684
+ !pathIsContained(repoReal, targetReal)) {
666
685
  throw outputRealpathUnsafe("existing output target realpath escapes containment");
667
686
  }
668
687
  }
@@ -757,8 +776,9 @@ function nextSequence(nodeId) {
757
776
  export async function executePlaywrightCliCommand(input, ctx, signal) {
758
777
  const startedAt = new Date();
759
778
  const sequence = nextSequence(ctx.nodeId);
760
- const timeoutSeconds = Math.max(1, Math.min(600, Number(input.timeoutSeconds ?? ctx.defaultTimeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) ||
761
- DEFAULT_TIMEOUT_SECONDS));
779
+ const timeoutSeconds = Math.max(1, Math.min(600, Number(input.timeoutSeconds ??
780
+ ctx.defaultTimeoutSeconds ??
781
+ DEFAULT_TIMEOUT_SECONDS) || DEFAULT_TIMEOUT_SECONDS));
762
782
  let command = String(input.command ?? "");
763
783
  let args = [];
764
784
  let errorClass;
@@ -810,15 +830,35 @@ export async function executePlaywrightCliCommand(input, ctx, signal) {
810
830
  }
811
831
  }
812
832
  catch (error) {
813
- errorClass = error instanceof PlaywrightCliPolicyError ? error.code : "playwright-cli-unavailable";
833
+ errorClass =
834
+ error instanceof PlaywrightCliPolicyError
835
+ ? error.code
836
+ : "playwright-cli-unavailable";
814
837
  const finishedAt = new Date();
815
838
  const receipt = {
816
- schemaVersion: 1, caseId: ctx.caseId, sequence, command,
817
- argsRedacted: redactPlaywrightCliArgs(command, args), startedAt: startedAt.toISOString(), finishedAt: finishedAt.toISOString(),
818
- exitCode: null, durationMs: finishedAt.getTime() - startedAt.getTime(), outputSha256: createHash("sha256").update("").digest("hex"), toolVersion: ctx.toolVersion ?? "unknown", errorClass,
839
+ schemaVersion: 1,
840
+ caseId: ctx.caseId,
841
+ sequence,
842
+ command,
843
+ argsRedacted: redactPlaywrightCliArgs(command, args),
844
+ startedAt: startedAt.toISOString(),
845
+ finishedAt: finishedAt.toISOString(),
846
+ exitCode: null,
847
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
848
+ outputSha256: createHash("sha256").update("").digest("hex"),
849
+ toolVersion: ctx.toolVersion ?? "unknown",
850
+ errorClass,
819
851
  };
820
852
  await appendReceipt(ctx, receipt);
821
- return { ok: false, exitCode: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut: false, receipt, errorClass };
853
+ return {
854
+ ok: false,
855
+ exitCode: null,
856
+ stdout: "",
857
+ stderr: error instanceof Error ? error.message : String(error),
858
+ timedOut: false,
859
+ receipt,
860
+ errorClass,
861
+ };
822
862
  }
823
863
  const runner = ctx.runCommand ?? defaultPlaywrightCliCommandRunner;
824
864
  let runResult;
@@ -836,7 +876,13 @@ export async function executePlaywrightCliCommand(input, ctx, signal) {
836
876
  });
837
877
  }
838
878
  catch (error) {
839
- runResult = { exitCode: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut: false, signal: null };
879
+ runResult = {
880
+ exitCode: null,
881
+ stdout: "",
882
+ stderr: error instanceof Error ? error.message : String(error),
883
+ timedOut: false,
884
+ signal: null,
885
+ };
840
886
  }
841
887
  const finishedAt = new Date();
842
888
  const combined = `${runResult.stdout}\n${runResult.stderr}`;
@@ -903,8 +949,12 @@ export async function createPlaywrightCliTool(ctx) {
903
949
  command: Type.String({
904
950
  description: `Playwright CLI command name. Allowed: ${PLAYWRIGHT_CLI_ALLOWED_COMMANDS.join(", ")}`,
905
951
  }),
906
- args: Type.Optional(Type.Array(Type.String(), { description: "Positional/flag args (not a shell string)" })),
907
- timeoutSeconds: Type.Optional(Type.Number({ description: "Per-command timeout seconds (default 60, max 600)" })),
952
+ args: Type.Optional(Type.Array(Type.String(), {
953
+ description: "Positional/flag args (not a shell string)",
954
+ })),
955
+ timeoutSeconds: Type.Optional(Type.Number({
956
+ description: "Per-command timeout seconds (default 60, max 600)",
957
+ })),
908
958
  }, { additionalProperties: false });
909
959
  return defineTool({
910
960
  name: "playwright_cli",
@@ -570,6 +570,106 @@ function canonicalizeVerificationTargetAliases(value) {
570
570
  : record.requirements;
571
571
  return { ...record, requirements };
572
572
  }
573
+ /**
574
+ * Model-written evidence gaps are hypotheses, not authoritative coverage
575
+ * facts. If the plan contains a real verification target that explicitly
576
+ * names a requirement, the executor can prove that the requirement has a
577
+ * verification path even when the model forgot to wire the target into the
578
+ * requirement entry or conservatively marked its gap as blocking.
579
+ *
580
+ * Keep gaps blocking when that proof cannot be derived. This is deliberately
581
+ * narrow: it uses only sourceBinding requirement IDs and structurally present
582
+ * verification targets, and never invents a command, file, or target.
583
+ */
584
+ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
585
+ const record = asRecord(value);
586
+ if (!record)
587
+ return value;
588
+ const rawTargets = Array.isArray(record.verificationTargets)
589
+ ? record.verificationTargets
590
+ : [];
591
+ const targetIds = new Set(rawTargets
592
+ .map((item) => asRecord(item))
593
+ .filter((item) => Boolean(item))
594
+ .map((item) => asString(item.id))
595
+ .filter(Boolean));
596
+ const targetIdsByRequirement = new Map();
597
+ for (const item of rawTargets) {
598
+ const target = asRecord(item);
599
+ if (!target)
600
+ continue;
601
+ const targetId = asString(target.id);
602
+ const commandLabel = asString(target.commandLabel) || asString(target.command);
603
+ const file = asString(target.file);
604
+ // A target is usable evidence only when it has an identity, command and
605
+ // file. The strict schema will validate the final shape afterwards.
606
+ if (!targetId || !commandLabel || !file)
607
+ continue;
608
+ for (const requirementId of asStringArray(target.requirementIds)) {
609
+ const canonicalId = canonicalizeRequirementId(requirementId);
610
+ if (!canonicalBinding.requirementIds.includes(canonicalId))
611
+ continue;
612
+ const ids = targetIdsByRequirement.get(canonicalId) ?? [];
613
+ if (!ids.includes(targetId))
614
+ ids.push(targetId);
615
+ targetIdsByRequirement.set(canonicalId, ids);
616
+ }
617
+ }
618
+ const provenRequirementIds = new Set(targetIdsByRequirement.keys());
619
+ const requirements = Array.isArray(record.requirements)
620
+ ? record.requirements.map((item) => {
621
+ const requirement = asRecord(item);
622
+ if (!requirement)
623
+ return item;
624
+ const requirementId = canonicalizeRequirementId(asString(requirement.id));
625
+ const inferredTargetIds = targetIdsByRequirement.get(requirementId) ?? [];
626
+ const existingTargetIds = asStringArray(requirement.verificationTargetIds)
627
+ .filter((id) => targetIds.has(id));
628
+ const verificationTargetIds = [...new Set([
629
+ ...existingTargetIds,
630
+ ...inferredTargetIds,
631
+ ])];
632
+ const evidenceGap = asRecord(requirement.evidenceGap);
633
+ const hasProof = provenRequirementIds.has(requirementId);
634
+ return {
635
+ ...requirement,
636
+ ...(verificationTargetIds.length > 0 ? { verificationTargetIds } : {}),
637
+ ...(hasProof
638
+ ? (evidenceGap ? { evidenceGap: { ...evidenceGap, blocking: false } } : {})
639
+ : {
640
+ evidenceGap: {
641
+ requirementId,
642
+ description: `No executable verification target can be derived for ${requirementId}`,
643
+ blocking: true,
644
+ },
645
+ }),
646
+ };
647
+ })
648
+ : record.requirements;
649
+ const modelEvidenceGaps = Array.isArray(record.evidenceGaps)
650
+ ? record.evidenceGaps.map((item) => {
651
+ const gap = asRecord(item);
652
+ if (!gap)
653
+ return item;
654
+ const requirementId = canonicalizeRequirementId(asString(gap.requirementId));
655
+ // Model gaps are advisory. Blocking status is reconstructed below
656
+ // from the source binding and executable verification targets.
657
+ return { ...gap, blocking: false };
658
+ })
659
+ : [];
660
+ const derivedBlockingGaps = canonicalBinding.requirementIds
661
+ .filter((requirementId) => !provenRequirementIds.has(requirementId))
662
+ .map((requirementId) => ({
663
+ requirementId,
664
+ description: `No executable verification target can be derived for ${requirementId}`,
665
+ blocking: true,
666
+ }));
667
+ return {
668
+ ...record,
669
+ requirements,
670
+ evidenceGaps: [...modelEvidenceGaps, ...derivedBlockingGaps],
671
+ };
672
+ }
573
673
  function assertFrontendContractPathsSafe(value) {
574
674
  const record = asRecord(value);
575
675
  if (!record)
@@ -1069,10 +1169,10 @@ export async function materializeFrontendImplementationContract(input) {
1069
1169
  const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
1070
1170
  // There is exactly one post-security candidate. A fallback candidate would
1071
1171
  // allow malformed raw fields to bypass the boundary checks above.
1072
- const candidate = {
1172
+ const candidate = deriveFrontendVerificationCoverage({
1073
1173
  ...(asRecord(normalizedContract) ?? parsed),
1074
1174
  sourceBinding: canonicalBinding,
1075
- };
1175
+ }, canonicalBinding);
1076
1176
  const result = frontendImplementationContractSchema.safeParse(candidate);
1077
1177
  if (!result.success)
1078
1178
  throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
@@ -1,8 +1,7 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
- import { createHash } from "node:crypto";
3
2
  import path from "node:path";
4
3
  import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
- import { frontendImplementationContractSchema, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, materializeFrontendImplementationContract, assertFrontendSourceBindingFresh, } from "./frontend-implementation-contract.js";
4
+ import { frontendImplementationContractSchema, materializeFrontendImplementationContract, assertFrontendSourceBindingFresh, } from "./frontend-implementation-contract.js";
6
5
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
7
6
  import { pathMatchesPattern } from "../../shared/git-progress.js";
8
7
  async function selectNode(runDir, primary, fallbacks) {
@@ -37,7 +36,10 @@ async function readNodeText(runDir, nodeId) {
37
36
  return text;
38
37
  }
39
38
  function firstNonEmptyVerdictLine(text) {
40
- const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
39
+ const lines = text
40
+ .split(/\r?\n/)
41
+ .map((line) => line.trim())
42
+ .filter(Boolean);
41
43
  const normalize = (line) => {
42
44
  const emphasized = line.match(/^(?:`{1,3}|\*{1,3})\s*(VERDICT:[^`*]+?)\s*(?:`{1,3}|\*{1,3})$/);
43
45
  return (emphasized?.[1] ?? line).trim();
@@ -134,7 +136,10 @@ export async function runFrontendPrewriteGate(input) {
134
136
  if (input.config.requireSourceFreshness) {
135
137
  if (!input.sourceBinding)
136
138
  throw new Error("frontend prewrite gate requires sourceBinding for freshness check");
137
- await assertFrontendSourceBindingFresh({ workspaceRoot, binding: input.sourceBinding });
139
+ await assertFrontendSourceBindingFresh({
140
+ workspaceRoot,
141
+ binding: input.sourceBinding,
142
+ });
138
143
  }
139
144
  let hasGitMetadata = true;
140
145
  try {
@@ -147,7 +152,10 @@ export async function runFrontendPrewriteGate(input) {
147
152
  throw error;
148
153
  }
149
154
  if (hasGitMetadata)
150
- await captureFrontendWorktreeBaseline({ runDir: input.runDir, workspaceRoot });
155
+ await captureFrontendWorktreeBaseline({
156
+ runDir: input.runDir,
157
+ workspaceRoot,
158
+ });
151
159
  }
152
160
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
153
161
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
@@ -161,20 +169,16 @@ export async function runFrontendPrewriteGate(input) {
161
169
  if (missingIds.length > 0) {
162
170
  throw new Error(`frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`);
163
171
  }
164
- const artifactPath = path.join(input.runDir, input.config.outputDir, input.config.artifactName);
165
- const artifact = await stat(artifactPath)
166
- .then(async () => ({
167
- path: artifactPath,
168
- schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
169
- sha256: createHash("sha256").update(await readFile(artifactPath)).digest("hex"),
170
- }))
171
- .catch(() => materializeFrontendImplementationContract({
172
+ // Always rematerialize from the selected plan node. Reusing a previous
173
+ // contracts/*.json would accept stale contracts when a higher-priority
174
+ // FINISHED primary is present but invalid (see composite-shell invalid-primary).
175
+ const artifact = await materializeFrontendImplementationContract({
172
176
  runDir: input.runDir,
173
177
  fromNodeId: planNodeId,
174
178
  artifactName: input.config.artifactName,
175
179
  outputDir: input.config.outputDir,
176
180
  sourceBinding: input.sourceBinding,
177
- }));
181
+ });
178
182
  const raw = JSON.parse(await readFile(artifact.path, "utf8"));
179
183
  const contract = frontendImplementationContractSchema.parse(raw);
180
184
  if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
@@ -3588,8 +3588,14 @@ async function buildBackendTestHybridDag(sources) {
3588
3588
  writeSet: ["testcase/md/**"],
3589
3589
  allowedPaths: ["testcase/md/**"],
3590
3590
  forbiddenPaths: forbidden,
3591
+ writerOutcomePolicy: {
3592
+ type: "implementation-outcome-v1",
3593
+ requireChangedFiles: true,
3594
+ },
3591
3595
  outputContract: "Write a Chinese, human-readable testcase/md/README.md plus module Markdown case cards using BE-<MODULE>-<NNN>; keep machine IDs/literals exact and do not execute pytest or modify production code/config.",
3592
3596
  subtask_prompt: [
3597
+ "This is a required file-generation node. After reading the bounded inputs, immediately use write/edit tools to create testcase/md/README.md and the module Markdown files. Do not end after analysis or planning, and do not return before a non-empty bounded diff exists.",
3598
+ "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed after the required files have been written, or IMPLEMENTATION_OUTCOME: blocked when precise missing evidence prevents safe generation. already-satisfied is not valid for this node.",
3593
3599
  "Read the upstream environment report. Generate a Markdown-first backend test strategy and cases under testcase/md/**.",
3594
3600
  "Write human-readable content in Simplified Chinese by default. Keep English only for machine-readable IDs and technical literals such as Case/AC/REQ/BR IDs, HTTP methods, paths, field names, enum values, commands, filenames, code symbols and exact source citations.",
3595
3601
  "Create testcase/md/README.md as the concise entry page: test objective, target/environment, isolation/cleanup, module summary and a linked case index table with Case ID, Chinese case name, scenario type, endpoint and expected status/result. Avoid repeating every case body in README.",
@@ -537,6 +537,8 @@ export const dagDecisionGateSchema = z.object({
537
537
  export const dagWriterOutcomePolicySchema = z
538
538
  .object({
539
539
  type: z.literal("implementation-outcome-v1"),
540
+ /** Generation writers may forbid already-satisfied/no-diff completion. */
541
+ requireChangedFiles: z.boolean().optional(),
540
542
  })
541
543
  .strict();
542
544
  export const dagConvergenceSpecSchema = z
@@ -533,7 +533,11 @@
533
533
  "required": ["type"],
534
534
  "description": "Fail-closed outcome/diff consistency protocol for bounded Pi writers. The first non-empty assistant line must be IMPLEMENTATION_OUTCOME: changed, already-satisfied, or blocked.",
535
535
  "properties": {
536
- "type": { "const": "implementation-outcome-v1" }
536
+ "type": { "const": "implementation-outcome-v1" },
537
+ "requireChangedFiles": {
538
+ "type": "boolean",
539
+ "description": "When true, already-satisfied and every empty run-attributed diff fail closed. Use for generation nodes whose output contract requires fresh files."
540
+ }
537
541
  }
538
542
  },
539
543
  "allowedPaths": {
@@ -125,7 +125,11 @@
125
125
  "artifacts/**"
126
126
  ],
127
127
  "outputContract": "Write a Chinese, human-readable testcase/md/README.md plus module Markdown case cards using BE-<MODULE>-<NNN>; keep machine IDs/literals exact and do not execute pytest or modify production code/config.",
128
- "subtask_prompt": "Read the upstream environment report. Generate a Markdown-first backend test strategy and cases under testcase/md/**.\n\nWrite human-readable content in Simplified Chinese by default. Keep English only for machine-readable IDs and technical literals such as Case/AC/REQ/BR IDs, HTTP methods, paths, field names, enum values, commands, filenames, code symbols and exact source citations.\n\nCreate testcase/md/README.md as the concise entry page: test objective, target/environment, isolation/cleanup, module summary and a linked case index table with Case ID, Chinese case name, scenario type, endpoint and expected status/result. Avoid repeating every case body in README.\n\nBefore the Coverage Matrix, write a mandatory machine-readable `## Coverage Scope` section in README using exactly `| Field | Value |`, immediately followed by the separator row `|---|---|`, and these six unique rows: `Change Classification`, `Coverage Policy`, `Affected Operations`, `Affected Rule Keys`, `Regression Floor`, `Scope Evidence`. Classify from authoritative task/reference evidence, not merely whether a route already exists. Use only these pairs: `new-operation` → `full-contract`; `contract-change` → `affected-contract-full`; `behavior-change` → `affected-behavior-full`; `bugfix` → `reproduction-plus-neighbors`; `implementation-optimization` → `change-focused-plus-regression-floor`. List affected operations exactly as `METHOD /path`, stable rule keys separated by semicolons, and precise source pointers as Scope Evidence.\n\nCoverage depth follows the declared change scope. For `new-operation`, fully cover every documented status, request/response field rule, requiredness, enum, boundary, format, auth and business state of each affected new operation, but do not re-test unrelated existing operations. For contract/behavior changes, fully cover the changed contract or behavior and its directly affected operations. For bugfix, cover exact reproduction, adjacent boundary/equivalence cases and a normal path. For `implementation-optimization`, cover explicit ACs, deterministic affected operations and a minimum regression floor; do not exhaustively regenerate unrelated POST/PUT/GET/DELETE rules. Every non-new classification must include `main-success-path` and `unchanged-response-shape`; contract changes also include `changed-contract-boundaries`, behavior changes `affected-state-transition`, and bugfixes `defect-reproduction` plus `adjacent-boundary`. Inspect shared validator/helper/DTO/query builder evidence and expand Affected Operations when the same changed path can affect them; unresolved impact stays visible as GAP/CONFLICT.\n\nBefore writing cases, build the mandatory machine-readable Coverage Matrix inside `testcase/md/README.md` itself. Its section heading line must be exactly `## Coverage Matrix` with no numeric prefix/suffix; never place the canonical Matrix only in a module file. Use this exact header: `| Rule Key | Priority | Source | Endpoint/Field | Dimension | Rule | Required Test Points | Case IDs | Status |`. Every data row must contain exactly 9 pipe-delimited cells and must never omit `Dimension`; use concise dimensions such as requirement, operation, response-status, requiredness, enum, boundary, format, business-state or error. Use only P0/P1/P2 and COVERED/PARTIAL/GAP/CONFLICT. Use stable `TP-<UPPERCASE-HYPHENATED-ID>` test points separated by semicolons.\n\nEach Rule Key must appear in exactly one Matrix row. Preserve each AC/REQ/BR Rule Key as one row; if one product rule spans multiple dimensions, use a concise composite Dimension in that single row instead of duplicating the key. Derive OpenAPI Rule Keys exactly as the deterministic analyzer does: operation token is `<HTTP-METHOD>-<PATH>` with braces removed and every non-alphanumeric run replaced by a hyphen, uppercase (for example POST `/api/resource-notes` → `POST-API-RESOURCE-NOTES`); response statuses use `API-<OPERATION>-RESPONSE-STATUS`; body/parameter fields use `API-<OPERATION>-<FIELD>-REQUIRED|ENUM|MIN-LENGTH|MAX-LENGTH|MINIMUM|MAXIMUM|PATTERN|FORMAT`. Do not invent aliases such as API-CREATE-FIELDS when a deterministic key applies.\n\nCoverage priority is strict inside the declared scope: P0 product requirements/task hard constraints always remain in scope; P1 exhaustively supplements documented operations, fields, business rules, statuses and errors only for Affected Operations; P2 adds bounded protocol robustness only when it is relevant to the change and does not invent product behavior. Coverage percentages describe the declared affected scope, never whole-API completeness unless every operation is explicitly listed. Conflicts or undefined expectations must stay visible as GAP/CONFLICT with precise source pointers, never guessed.\n\nFor uniqueness/lifecycle rules cover absent, active-existing, deleted-existing, create-delete-recreate, restore-then-recreate and documented scope/case-normalization states. For every enum cover every valid value plus bounded invalid equivalence classes (unknown, case variant, whitespace, empty, null/missing and wrong types as applicable). For every length/number rule cover min-1, min, nominal, max and max+1. For format rules cover each allowed class separately plus a valid mixed value, and representative forbidden classes including uppercase, internal/leading/trailing whitespace, tab/newline, unsupported punctuation, slash, emoji or control characters when the source contract supports that expectation.\n\nWrite each module as readable case cards. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>`. `<NNN>` is exactly three zero-padded digits (`001`, `002`, ...), never two digits (`01`), a bare number, or an alphabetic suffix such as `011A`. Every case must include `### 覆盖规则`, `### 测试点`, `### 场景类型`, `### 前置条件`, `### 操作步骤`, `### 预期结果`, and `### 自动化映射`; `覆盖规则` and `测试点` must reference exact Matrix Rule Keys/Test Points. Add `测试目的`, `验收标准`, `需求依据`, and `测试数据` for readable evidence. The `验收标准` section must list the exact applicable `AC-...` IDs, and every explicit task AC must appear in at least one Case. Every automatable case explicitly names its target pytest script and exactly one primary symbol so traceability scans only that script/symbol.\n\nName each module file with a stable lowercase business stem such as `testcase/md/health.md` or `testcase/md/resource_notes.md`. Do not use Case-ID-like module filenames such as `BE-HEALTH.md` or `BE-NOTES.md`. For every automatable case, `自动化映射` must name exactly `testcase/test_<module>.py`, where <module> is that Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `testcase/md/health.md` → `testcase/test_health.py`; `testcase/md/resource_notes.md` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.\n\nEvery Case must keep at least one numbered executable line under `### 操作步骤`; a compact variant/result table may follow but must not replace the numbered action anchor. Keep numbered/bulleted independently assertable results under `### 预期结果`. The exact `### 操作步骤` and `### 预期结果` headings must remain present for every Case, including compact/table-based Cases; never compress later Cases by dropping required headings. Every result must name the observable HTTP status, response field/value, state transition or membership condition, never vague wording such as ‘符合预期’.\n\nIn every `自动化映射`, use exactly these machine-readable list labels: `脚本`, `primary symbol`, `变体测试点`, `场景断言测试点`, `横切证据测试点`. Each Test Point from `### 测试点` must appear in exactly one binding list, and every Test Point named in any binding list must also be declared in that Case's `### 测试点`; write `无` for an empty list. A variant Test Point is atomic: one exact endpoint/input/precondition/outcome row equals one exact pytest item and one exact TP ID. If a parameter table has five rows, declare five distinct variant TP IDs in Markdown; never declare one family TP and append row suffixes only in pytest. Classify as `variant` only when endpoint, request input, precondition business state, or expected outcome genuinely changes and therefore needs an independent pytest parameter item. Classify CRUD checkpoints, status/body/header/schema assertions and multiple checks over the same response/journey as `assertion`; classify shared HTTP logging/redaction/truncation evidence as `cross-cutting`. Never create a Test Point merely to parameterize a checkpoint. Every non-cross-cutting TP ID is owned by exactly one Case; when the same response/schema/error assertion is needed in different Cases, use distinct Case-specific TP IDs instead of reusing one assertion TP across Cases. Keep the script path identical to the module one-to-one path and declare exactly one primary symbol named with the canonical Case prefix, for example `BE-RN-003` → `test_BE_RN_003_<description>`; non-Case-prefixed primary symbols are forbidden because parameterized item association must remain deterministic. For redaction scenarios, list sensitive header/field key names only. Never write any header-name-and-value pair, credential placeholder, fake token, anti-example, or other secret-shaped literal in Markdown; state only that a test-only value is supplied at runtime and omitted. Put implementation-only restrictions in a concise `<details>` block rather than dominating the main case flow. Before finalizing Markdown, calculate the predicted collected-item count as `sum(max(1, number of variant Test Points in each Case))`. If the task declares an item budget, the prediction must not exceed it. Reduce excess only by removing duplicate execution and converting same-request checkpoints to assertions; never drop required rules, boundaries, enums, operation-specific inputs, or business states. Record the prediction in README. Use only environment-supported fixtures/targets/isolation, record evidence gaps in Chinese, and do not emit JSON, pytest, or execute commands.\n\n## Derived task contract: 需求.md\n\n# Backend test\n- AC-001 proof\n\n## Authoritative reference index\n\n[]\n\nFor each index entry, use `readPath` for Pi read-tool calls and copy `path` exactly into Markdown Source References. Bound files under .harness/tasks/<taskId>/source/** are read-only inputs: reading them is allowed even though writing .harness/** is forbidden. Never resolve `path` relative to the repository root, search for substitutes, or fall back to docs/** when a bound read fails.\n\nRead only precise indexed references needed for AC/API/field/rule evidence; references remain authoritative over derived text."
128
+ "subtask_prompt": "This is a required file-generation node. After reading the bounded inputs, immediately use write/edit tools to create testcase/md/README.md and the module Markdown files. Do not end after analysis or planning, and do not return before a non-empty bounded diff exists.\n\nThe first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed after the required files have been written, or IMPLEMENTATION_OUTCOME: blocked when precise missing evidence prevents safe generation. already-satisfied is not valid for this node.\n\nRead the upstream environment report. Generate a Markdown-first backend test strategy and cases under testcase/md/**.\n\nWrite human-readable content in Simplified Chinese by default. Keep English only for machine-readable IDs and technical literals such as Case/AC/REQ/BR IDs, HTTP methods, paths, field names, enum values, commands, filenames, code symbols and exact source citations.\n\nCreate testcase/md/README.md as the concise entry page: test objective, target/environment, isolation/cleanup, module summary and a linked case index table with Case ID, Chinese case name, scenario type, endpoint and expected status/result. Avoid repeating every case body in README.\n\nBefore the Coverage Matrix, write a mandatory machine-readable `## Coverage Scope` section in README using exactly `| Field | Value |`, immediately followed by the separator row `|---|---|`, and these six unique rows: `Change Classification`, `Coverage Policy`, `Affected Operations`, `Affected Rule Keys`, `Regression Floor`, `Scope Evidence`. Classify from authoritative task/reference evidence, not merely whether a route already exists. Use only these pairs: `new-operation` → `full-contract`; `contract-change` → `affected-contract-full`; `behavior-change` → `affected-behavior-full`; `bugfix` → `reproduction-plus-neighbors`; `implementation-optimization` → `change-focused-plus-regression-floor`. List affected operations exactly as `METHOD /path`, stable rule keys separated by semicolons, and precise source pointers as Scope Evidence.\n\nCoverage depth follows the declared change scope. For `new-operation`, fully cover every documented status, request/response field rule, requiredness, enum, boundary, format, auth and business state of each affected new operation, but do not re-test unrelated existing operations. For contract/behavior changes, fully cover the changed contract or behavior and its directly affected operations. For bugfix, cover exact reproduction, adjacent boundary/equivalence cases and a normal path. For `implementation-optimization`, cover explicit ACs, deterministic affected operations and a minimum regression floor; do not exhaustively regenerate unrelated POST/PUT/GET/DELETE rules. Every non-new classification must include `main-success-path` and `unchanged-response-shape`; contract changes also include `changed-contract-boundaries`, behavior changes `affected-state-transition`, and bugfixes `defect-reproduction` plus `adjacent-boundary`. Inspect shared validator/helper/DTO/query builder evidence and expand Affected Operations when the same changed path can affect them; unresolved impact stays visible as GAP/CONFLICT.\n\nBefore writing cases, build the mandatory machine-readable Coverage Matrix inside `testcase/md/README.md` itself. Its section heading line must be exactly `## Coverage Matrix` with no numeric prefix/suffix; never place the canonical Matrix only in a module file. Use this exact header: `| Rule Key | Priority | Source | Endpoint/Field | Dimension | Rule | Required Test Points | Case IDs | Status |`. Every data row must contain exactly 9 pipe-delimited cells and must never omit `Dimension`; use concise dimensions such as requirement, operation, response-status, requiredness, enum, boundary, format, business-state or error. Use only P0/P1/P2 and COVERED/PARTIAL/GAP/CONFLICT. Use stable `TP-<UPPERCASE-HYPHENATED-ID>` test points separated by semicolons.\n\nEach Rule Key must appear in exactly one Matrix row. Preserve each AC/REQ/BR Rule Key as one row; if one product rule spans multiple dimensions, use a concise composite Dimension in that single row instead of duplicating the key. Derive OpenAPI Rule Keys exactly as the deterministic analyzer does: operation token is `<HTTP-METHOD>-<PATH>` with braces removed and every non-alphanumeric run replaced by a hyphen, uppercase (for example POST `/api/resource-notes` → `POST-API-RESOURCE-NOTES`); response statuses use `API-<OPERATION>-RESPONSE-STATUS`; body/parameter fields use `API-<OPERATION>-<FIELD>-REQUIRED|ENUM|MIN-LENGTH|MAX-LENGTH|MINIMUM|MAXIMUM|PATTERN|FORMAT`. Do not invent aliases such as API-CREATE-FIELDS when a deterministic key applies.\n\nCoverage priority is strict inside the declared scope: P0 product requirements/task hard constraints always remain in scope; P1 exhaustively supplements documented operations, fields, business rules, statuses and errors only for Affected Operations; P2 adds bounded protocol robustness only when it is relevant to the change and does not invent product behavior. Coverage percentages describe the declared affected scope, never whole-API completeness unless every operation is explicitly listed. Conflicts or undefined expectations must stay visible as GAP/CONFLICT with precise source pointers, never guessed.\n\nFor uniqueness/lifecycle rules cover absent, active-existing, deleted-existing, create-delete-recreate, restore-then-recreate and documented scope/case-normalization states. For every enum cover every valid value plus bounded invalid equivalence classes (unknown, case variant, whitespace, empty, null/missing and wrong types as applicable). For every length/number rule cover min-1, min, nominal, max and max+1. For format rules cover each allowed class separately plus a valid mixed value, and representative forbidden classes including uppercase, internal/leading/trailing whitespace, tab/newline, unsupported punctuation, slash, emoji or control characters when the source contract supports that expectation.\n\nWrite each module as readable case cards. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>`. `<NNN>` is exactly three zero-padded digits (`001`, `002`, ...), never two digits (`01`), a bare number, or an alphabetic suffix such as `011A`. Every case must include `### 覆盖规则`, `### 测试点`, `### 场景类型`, `### 前置条件`, `### 操作步骤`, `### 预期结果`, and `### 自动化映射`; `覆盖规则` and `测试点` must reference exact Matrix Rule Keys/Test Points. Add `测试目的`, `验收标准`, `需求依据`, and `测试数据` for readable evidence. The `验收标准` section must list the exact applicable `AC-...` IDs, and every explicit task AC must appear in at least one Case. Every automatable case explicitly names its target pytest script and exactly one primary symbol so traceability scans only that script/symbol.\n\nName each module file with a stable lowercase business stem such as `testcase/md/health.md` or `testcase/md/resource_notes.md`. Do not use Case-ID-like module filenames such as `BE-HEALTH.md` or `BE-NOTES.md`. For every automatable case, `自动化映射` must name exactly `testcase/test_<module>.py`, where <module> is that Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `testcase/md/health.md` → `testcase/test_health.py`; `testcase/md/resource_notes.md` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.\n\nEvery Case must keep at least one numbered executable line under `### 操作步骤`; a compact variant/result table may follow but must not replace the numbered action anchor. Keep numbered/bulleted independently assertable results under `### 预期结果`. The exact `### 操作步骤` and `### 预期结果` headings must remain present for every Case, including compact/table-based Cases; never compress later Cases by dropping required headings. Every result must name the observable HTTP status, response field/value, state transition or membership condition, never vague wording such as ‘符合预期’.\n\nIn every `自动化映射`, use exactly these machine-readable list labels: `脚本`, `primary symbol`, `变体测试点`, `场景断言测试点`, `横切证据测试点`. Each Test Point from `### 测试点` must appear in exactly one binding list, and every Test Point named in any binding list must also be declared in that Case's `### 测试点`; write `无` for an empty list. A variant Test Point is atomic: one exact endpoint/input/precondition/outcome row equals one exact pytest item and one exact TP ID. If a parameter table has five rows, declare five distinct variant TP IDs in Markdown; never declare one family TP and append row suffixes only in pytest. Classify as `variant` only when endpoint, request input, precondition business state, or expected outcome genuinely changes and therefore needs an independent pytest parameter item. Classify CRUD checkpoints, status/body/header/schema assertions and multiple checks over the same response/journey as `assertion`; classify shared HTTP logging/redaction/truncation evidence as `cross-cutting`. Never create a Test Point merely to parameterize a checkpoint. Every non-cross-cutting TP ID is owned by exactly one Case; when the same response/schema/error assertion is needed in different Cases, use distinct Case-specific TP IDs instead of reusing one assertion TP across Cases. Keep the script path identical to the module one-to-one path and declare exactly one primary symbol named with the canonical Case prefix, for example `BE-RN-003` → `test_BE_RN_003_<description>`; non-Case-prefixed primary symbols are forbidden because parameterized item association must remain deterministic. For redaction scenarios, list sensitive header/field key names only. Never write any header-name-and-value pair, credential placeholder, fake token, anti-example, or other secret-shaped literal in Markdown; state only that a test-only value is supplied at runtime and omitted. Put implementation-only restrictions in a concise `<details>` block rather than dominating the main case flow. Before finalizing Markdown, calculate the predicted collected-item count as `sum(max(1, number of variant Test Points in each Case))`. If the task declares an item budget, the prediction must not exceed it. Reduce excess only by removing duplicate execution and converting same-request checkpoints to assertions; never drop required rules, boundaries, enums, operation-specific inputs, or business states. Record the prediction in README. Use only environment-supported fixtures/targets/isolation, record evidence gaps in Chinese, and do not emit JSON, pytest, or execute commands.\n\n## Derived task contract: 需求.md\n\n# Backend test\n- AC-001 proof\n\n## Authoritative reference index\n\n[]\n\nFor each index entry, use `readPath` for Pi read-tool calls and copy `path` exactly into Markdown Source References. Bound files under .harness/tasks/<taskId>/source/** are read-only inputs: reading them is allowed even though writing .harness/** is forbidden. Never resolve `path` relative to the repository root, search for substitutes, or fall back to docs/** when a bound read fails.\n\nRead only precise indexed references needed for AC/API/field/rule evidence; references remain authoritative over derived text.",
129
+ "writerOutcomePolicy": {
130
+ "type": "implementation-outcome-v1",
131
+ "requireChangedFiles": true
132
+ }
129
133
  },
130
134
  {
131
135
  "id": "review-and-revise-backend-md-cases-pi",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.28.1-beta.1",
3
+ "version": "0.28.2-beta.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",