@tea-agent/loop-agent 0.28.0 → 0.28.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 +23 -0
- package/dist/executors/dag-pi-executor.js +27 -3
- package/dist/executors/pi-executor.js +14 -13
- package/dist/executors/pi-playwright-cli-tool.js +91 -41
- package/dist/task/task-demand-routing.js +1 -15
- package/dist/workflows/dag/frontend-implementation-contract.js +124 -6
- package/dist/workflows/dag/frontend-prewrite-gate.js +26 -8
- package/dist/workflows/dag/init-hybrid.js +71 -38
- package/dist/workflows/dag/output-protocol.js +23 -0
- package/dist/workflows/dag/types.js +2 -0
- package/docs/templates/agent-dag.schema.json +5 -1
- package/docs/templates/backend-test-dag.json +5 -1
- package/docs/templates/frontend-implementation-contract.schema.json +2 -2
- package/package.json +1 -1
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
|
-
?
|
|
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,7 +687,7 @@ 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
693
|
if (parsed.kind !== "valid") {
|
|
@@ -706,6 +715,12 @@ export function validateWriterImplementationOutcome(text, changedFiles) {
|
|
|
706
715
|
reason: `writer outcome validation failed: already-satisfied outcome has a non-empty diff; ${diagnostics}`,
|
|
707
716
|
};
|
|
708
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
|
+
}
|
|
709
724
|
return { ok: true, outcome };
|
|
710
725
|
}
|
|
711
726
|
function parseWriterImplementationOutcome(text) {
|
|
@@ -837,6 +852,15 @@ function normalizeProtocolLine(line, firstProtocolLine, nextLine) {
|
|
|
837
852
|
const value = normalizedProtocolValue(direct[1] ?? "");
|
|
838
853
|
return value ? `${firstProtocolLine} ${value}` : undefined;
|
|
839
854
|
}
|
|
855
|
+
// Writers sometimes place a short delivery sentence before the protocol
|
|
856
|
+
// line. Accept the protocol token when it appears inline, while preserving
|
|
857
|
+
// the candidate value so template repetitions still fail as unknown or
|
|
858
|
+
// conflicting outcomes.
|
|
859
|
+
const inline = normalizedLine.match(new RegExp(`${labelPattern}\\s*[::]\\s*(.+)$`, "i"));
|
|
860
|
+
if (inline) {
|
|
861
|
+
const value = normalizedProtocolValue(inline[1] ?? "");
|
|
862
|
+
return value ? `${firstProtocolLine} ${value}` : undefined;
|
|
863
|
+
}
|
|
840
864
|
const splitValue = normalizedLine.match(new RegExp(`^${labelPattern}\\s*[::]?\\s*$`, "i"));
|
|
841
865
|
if (splitValue && nextLine !== undefined) {
|
|
842
866
|
const value = normalizedProtocolValue(nextLine);
|
|
@@ -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"
|
|
335
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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 (
|
|
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 ||
|
|
58
|
+
if (!normalized ||
|
|
59
|
+
path.posix.isAbsolute(normalized) ||
|
|
60
|
+
path.win32.isAbsolute(normalized)) {
|
|
59
61
|
return false;
|
|
60
62
|
}
|
|
61
|
-
return normalized
|
|
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) ||
|
|
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") ||
|
|
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) ||
|
|
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=") ||
|
|
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 ||
|
|
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) ||
|
|
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/") &&
|
|
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" &&
|
|
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) &&
|
|
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
|
-
|
|
544
|
-
|
|
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
|
|
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] =
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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) ||
|
|
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 ??
|
|
761
|
-
|
|
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 =
|
|
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,
|
|
817
|
-
|
|
818
|
-
|
|
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 {
|
|
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 = {
|
|
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(), {
|
|
907
|
-
|
|
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",
|
|
@@ -334,24 +334,10 @@ export function classifyTaskDemand(input) {
|
|
|
334
334
|
});
|
|
335
335
|
}
|
|
336
336
|
const backendDelivery = titleSignals.backendDelivery || requirementSignals.backendDelivery;
|
|
337
|
-
const frontendProjectDefaultImplementation = hasStrongFrontendProjectEvidence &&
|
|
338
|
-
frontendPath &&
|
|
339
|
-
!hasBackendTaskType &&
|
|
340
|
-
!backendDelivery &&
|
|
341
|
-
!frontendNegated &&
|
|
342
|
-
!allowedPathsOnlyCoverNonProductArtifacts;
|
|
343
|
-
if (frontendProjectDefaultImplementation) {
|
|
344
|
-
addSignal(signals, {
|
|
345
|
-
id: "frontend-project-default-implementation",
|
|
346
|
-
source: "project",
|
|
347
|
-
message: "frontend project evidence selects the frontend implementation workflow by default",
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
337
|
const frontendDelivery = titleSignals.frontendDelivery ||
|
|
351
338
|
requirementSignals.frontendDelivery ||
|
|
352
339
|
pathSupportedFrontendDelivery ||
|
|
353
|
-
projectSupportedFrontendDelivery
|
|
354
|
-
frontendProjectDefaultImplementation;
|
|
340
|
+
projectSupportedFrontendDelivery;
|
|
355
341
|
if (allowedPathsOnlyCoverNonProductArtifacts && frontendDelivery) {
|
|
356
342
|
blockers.push("non-product-allowed-paths");
|
|
357
343
|
}
|
|
@@ -155,7 +155,7 @@ export const frontendImplementationContractSchema = z
|
|
|
155
155
|
"not-needed",
|
|
156
156
|
]),
|
|
157
157
|
productionDefaultOff: z.literal(true),
|
|
158
|
-
activation: z.string().min(1),
|
|
158
|
+
activation: z.preprocess((value) => (value === "" || value === null ? "explicit activation boundary" : value), z.string().min(1)),
|
|
159
159
|
endpoints: z.array(z
|
|
160
160
|
.object({
|
|
161
161
|
method: z.enum([
|
|
@@ -168,7 +168,10 @@ export const frontendImplementationContractSchema = z
|
|
|
168
168
|
"OPTIONS",
|
|
169
169
|
]),
|
|
170
170
|
path: z.string().startsWith("/"),
|
|
171
|
-
fixture
|
|
171
|
+
// Models occasionally emit an empty fixture when Mock is
|
|
172
|
+
// intentionally not needed. Treat it like an omitted optional
|
|
173
|
+
// field; active Mock strategies still fail the refinement below.
|
|
174
|
+
fixture: z.preprocess((value) => (value === "" || value === null ? undefined : value), safePath.optional()),
|
|
172
175
|
consumer: safePath.optional(),
|
|
173
176
|
})
|
|
174
177
|
.strict()),
|
|
@@ -187,7 +190,7 @@ export const frontendImplementationContractSchema = z
|
|
|
187
190
|
type: z.enum(["static", "unit", "component", "integration", "mock"]),
|
|
188
191
|
commandLabel: z.string().min(1),
|
|
189
192
|
file: safePath,
|
|
190
|
-
symbol: z.string().min(1).optional(),
|
|
193
|
+
symbol: z.preprocess((value) => (value === "" || value === null ? undefined : value), z.string().min(1).optional()),
|
|
191
194
|
requirementIds: z.array(id),
|
|
192
195
|
uiStates: z.array(z.string().min(1)),
|
|
193
196
|
})
|
|
@@ -343,12 +346,127 @@ function secretIssues(value, at = "$", issues = []) {
|
|
|
343
346
|
}
|
|
344
347
|
export function extractFrontendImplementationJson(text) {
|
|
345
348
|
const trimmed = text.trim();
|
|
349
|
+
const parse = (source) => {
|
|
350
|
+
try {
|
|
351
|
+
return JSON.parse(source);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
// Models sometimes put ordinary ASCII quotes inside a JSON string
|
|
355
|
+
// (for example: `reason: "支持..."`). Repair only quotes that are
|
|
356
|
+
// clearly not structural: a closing quote is followed by JSON
|
|
357
|
+
// punctuation, while an embedded quote is followed by content.
|
|
358
|
+
let repaired = "";
|
|
359
|
+
let inString = false;
|
|
360
|
+
let escaped = false;
|
|
361
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
362
|
+
const character = source[index];
|
|
363
|
+
if (character !== '"') {
|
|
364
|
+
repaired += character;
|
|
365
|
+
if (inString && character === "\\" && !escaped)
|
|
366
|
+
escaped = true;
|
|
367
|
+
else
|
|
368
|
+
escaped = false;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (escaped) {
|
|
372
|
+
repaired += character;
|
|
373
|
+
escaped = false;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (!inString) {
|
|
377
|
+
inString = true;
|
|
378
|
+
repaired += character;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const next = source.slice(index + 1).trimStart()[0];
|
|
382
|
+
if ([",", "}", "]", ":"].includes(next ?? "") || source.slice(index + 1).trim() === "") {
|
|
383
|
+
inString = false;
|
|
384
|
+
repaired += character;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
repaired += "\\\"";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
return JSON.parse(repaired);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const isContract = (candidate) => {
|
|
399
|
+
const record = asRecord(candidate);
|
|
400
|
+
return (record?.schemaVersion === 1 &&
|
|
401
|
+
asRecord(record.targets) !== null &&
|
|
402
|
+
Array.isArray(record.requirements) &&
|
|
403
|
+
Array.isArray(record.verificationTargets));
|
|
404
|
+
};
|
|
346
405
|
if (trimmed.startsWith("{") && trimmed.endsWith("}"))
|
|
347
|
-
return
|
|
406
|
+
return parse(trimmed);
|
|
407
|
+
const balancedObjects = [];
|
|
408
|
+
for (let start = 0; start < trimmed.length; start += 1) {
|
|
409
|
+
if (trimmed[start] !== "{")
|
|
410
|
+
continue;
|
|
411
|
+
let depth = 0;
|
|
412
|
+
let inString = false;
|
|
413
|
+
let escaped = false;
|
|
414
|
+
for (let index = start; index < trimmed.length; index += 1) {
|
|
415
|
+
const character = trimmed[index];
|
|
416
|
+
if (inString) {
|
|
417
|
+
if (escaped)
|
|
418
|
+
escaped = false;
|
|
419
|
+
else if (character === "\\")
|
|
420
|
+
escaped = true;
|
|
421
|
+
else if (character === '"')
|
|
422
|
+
inString = false;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (character === '"')
|
|
426
|
+
inString = true;
|
|
427
|
+
else if (character === "{")
|
|
428
|
+
depth += 1;
|
|
429
|
+
else if (character === "}" && --depth === 0) {
|
|
430
|
+
try {
|
|
431
|
+
const candidate = parse(trimmed.slice(start, index + 1));
|
|
432
|
+
if (candidate && typeof candidate === "object" && !Array.isArray(candidate))
|
|
433
|
+
balancedObjects.push(candidate);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// Continue scanning for a later complete JSON object.
|
|
437
|
+
}
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const balancedContracts = balancedObjects.filter(isContract);
|
|
443
|
+
if (balancedContracts.length === 1)
|
|
444
|
+
return balancedContracts[0];
|
|
445
|
+
if (balancedObjects.length === 1)
|
|
446
|
+
return balancedObjects[0];
|
|
348
447
|
const blocks = [...trimmed.matchAll(/```json\s*\n([\s\S]*?)\n```/gi)];
|
|
349
|
-
if (blocks.length
|
|
448
|
+
if (blocks.length === 0)
|
|
350
449
|
throw new Error("output must contain exactly one fenced json object");
|
|
351
|
-
|
|
450
|
+
const candidates = [];
|
|
451
|
+
for (const block of blocks) {
|
|
452
|
+
try {
|
|
453
|
+
const candidate = parse(block[1]);
|
|
454
|
+
if (candidate && typeof candidate === "object" && !Array.isArray(candidate))
|
|
455
|
+
candidates.push(candidate);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// Ignore incomplete model scratch blocks. A later complete contract
|
|
459
|
+
// block may still be deterministically recoverable.
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const contractCandidates = candidates.filter(isContract);
|
|
463
|
+
if (contractCandidates.length === 1)
|
|
464
|
+
return contractCandidates[0];
|
|
465
|
+
if (candidates.length === 1)
|
|
466
|
+
return candidates[0];
|
|
467
|
+
if (candidates.length === 0)
|
|
468
|
+
throw new Error("output must contain exactly one valid fenced json object (found 0)");
|
|
469
|
+
throw new Error(`output must contain exactly one valid frontend contract json object (found ${contractCandidates.length || candidates.length})`);
|
|
352
470
|
}
|
|
353
471
|
/**
|
|
354
472
|
* Build the authoritative frontend-implementation-contract sourceBinding from
|
|
@@ -36,7 +36,10 @@ async function readNodeText(runDir, nodeId) {
|
|
|
36
36
|
return text;
|
|
37
37
|
}
|
|
38
38
|
function firstNonEmptyVerdictLine(text) {
|
|
39
|
-
const lines = text
|
|
39
|
+
const lines = text
|
|
40
|
+
.split(/\r?\n/)
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter(Boolean);
|
|
40
43
|
const normalize = (line) => {
|
|
41
44
|
const emphasized = line.match(/^(?:`{1,3}|\*{1,3})\s*(VERDICT:[^`*]+?)\s*(?:`{1,3}|\*{1,3})$/);
|
|
42
45
|
return (emphasized?.[1] ?? line).trim();
|
|
@@ -46,7 +49,12 @@ function firstNonEmptyVerdictLine(text) {
|
|
|
46
49
|
// The prompt requires VERDICT to be the first non-empty line, but model
|
|
47
50
|
// output can still prepend a summary. Keep the protocol strict in the
|
|
48
51
|
// prompt while making the deterministic gate resilient to that drift.
|
|
49
|
-
|
|
52
|
+
const verdictLine = normalizedLines.find((line) => /^VERDICT:/.test(line)) ?? first;
|
|
53
|
+
if (/^VERDICT:\s*pass(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
|
|
54
|
+
return "VERDICT: pass";
|
|
55
|
+
if (/^VERDICT:\s*request-revision(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
|
|
56
|
+
return "VERDICT: request-revision";
|
|
57
|
+
return verdictLine;
|
|
50
58
|
}
|
|
51
59
|
function eventArgs(event) {
|
|
52
60
|
return event.args ?? event.toolInput ?? event.input ?? {};
|
|
@@ -128,7 +136,10 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
128
136
|
if (input.config.requireSourceFreshness) {
|
|
129
137
|
if (!input.sourceBinding)
|
|
130
138
|
throw new Error("frontend prewrite gate requires sourceBinding for freshness check");
|
|
131
|
-
await assertFrontendSourceBindingFresh({
|
|
139
|
+
await assertFrontendSourceBindingFresh({
|
|
140
|
+
workspaceRoot,
|
|
141
|
+
binding: input.sourceBinding,
|
|
142
|
+
});
|
|
132
143
|
}
|
|
133
144
|
let hasGitMetadata = true;
|
|
134
145
|
try {
|
|
@@ -141,7 +152,10 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
141
152
|
throw error;
|
|
142
153
|
}
|
|
143
154
|
if (hasGitMetadata)
|
|
144
|
-
await captureFrontendWorktreeBaseline({
|
|
155
|
+
await captureFrontendWorktreeBaseline({
|
|
156
|
+
runDir: input.runDir,
|
|
157
|
+
workspaceRoot,
|
|
158
|
+
});
|
|
145
159
|
}
|
|
146
160
|
const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
|
|
147
161
|
const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
|
|
@@ -155,6 +169,9 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
155
169
|
if (missingIds.length > 0) {
|
|
156
170
|
throw new Error(`frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`);
|
|
157
171
|
}
|
|
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).
|
|
158
175
|
const artifact = await materializeFrontendImplementationContract({
|
|
159
176
|
runDir: input.runDir,
|
|
160
177
|
fromNodeId: planNodeId,
|
|
@@ -183,10 +200,11 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
183
200
|
reviewNodeId,
|
|
184
201
|
repoRoot: workspaceRoot ?? process.cwd(),
|
|
185
202
|
});
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
203
|
+
// OpenSpec is advisory design evidence, not an authorization boundary.
|
|
204
|
+
// Model read behavior is nondeterministic, and a missed read must not block
|
|
205
|
+
// a contract/schema/write-set-authorized implementation. Preserve any
|
|
206
|
+
// successful evidence for review context, while allowing unavailable
|
|
207
|
+
// evidence to be reported downstream.
|
|
190
208
|
return {
|
|
191
209
|
ok: true,
|
|
192
210
|
planNodeId,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { access, readdir, readFile, realpath } from "node:fs/promises";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
6
5
|
import { assertValidDagSpec } from "./validate.js";
|
|
@@ -1080,40 +1079,14 @@ function isFrontendLintVerifyCommand(command) {
|
|
|
1080
1079
|
const text = `${command.label}\n${command.args.join(" ")}`;
|
|
1081
1080
|
return /\b(?:lint|eslint)\b/i.test(text);
|
|
1082
1081
|
}
|
|
1083
|
-
function isManagedCiWrapperVerifyCommand(command) {
|
|
1084
|
-
const text = `${command.label}\n${command.args.join(" ")}`.replaceAll("\\", "/");
|
|
1085
|
-
return /\bscripts\/ci(?:-tests)?\.sh\b/.test(text);
|
|
1086
|
-
}
|
|
1087
|
-
function repoHasNpmScript(repoRoot, scriptName) {
|
|
1088
|
-
if (!repoRoot)
|
|
1089
|
-
return false;
|
|
1090
|
-
const packagePath = path.join(repoRoot, "package.json");
|
|
1091
|
-
if (!existsSync(packagePath))
|
|
1092
|
-
return false;
|
|
1093
|
-
try {
|
|
1094
|
-
const decoded = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
1095
|
-
return typeof decoded.scripts?.[scriptName] === "string";
|
|
1096
|
-
}
|
|
1097
|
-
catch {
|
|
1098
|
-
return false;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
1082
|
function partitionFrontendStaticVerifyCommands(input) {
|
|
1102
1083
|
const commands = input.commands ?? [];
|
|
1103
|
-
|
|
1084
|
+
// Frontend DAGs deliberately never generate lint verification. Existing
|
|
1085
|
+
// project lint debt is allowed to remain outside this workflow, and an
|
|
1086
|
+
// explicitly mentioned lint command must not reintroduce the lint gate.
|
|
1104
1087
|
const staticCommands = commands.filter((command) => !isFrontendLintVerifyCommand(command));
|
|
1105
|
-
if (lintCommands.length === 0 &&
|
|
1106
|
-
commands.some(isManagedCiWrapperVerifyCommand) &&
|
|
1107
|
-
repoHasNpmScript(input.repoRoot, "lint")) {
|
|
1108
|
-
lintCommands.push({
|
|
1109
|
-
args: ["npm", "run", "lint"],
|
|
1110
|
-
cwd: input.repoRoot,
|
|
1111
|
-
label: "npm run lint",
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
1088
|
return {
|
|
1115
1089
|
lint: {
|
|
1116
|
-
...(lintCommands.length > 0 ? { commands: lintCommands } : {}),
|
|
1117
1090
|
commandSource: input.commandSource,
|
|
1118
1091
|
},
|
|
1119
1092
|
static: {
|
|
@@ -1212,7 +1185,6 @@ async function discoverFrontendFallbackVerifyCommands(repoRoot) {
|
|
|
1212
1185
|
const staticCommands = firstExisting([
|
|
1213
1186
|
"typecheck",
|
|
1214
1187
|
"check-types",
|
|
1215
|
-
"lint",
|
|
1216
1188
|
"check",
|
|
1217
1189
|
"build",
|
|
1218
1190
|
]);
|
|
@@ -2457,9 +2429,62 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2457
2429
|
sourceContext,
|
|
2458
2430
|
].join("\n\n"),
|
|
2459
2431
|
},
|
|
2432
|
+
{
|
|
2433
|
+
id: "frontend-contract-json-pi",
|
|
2434
|
+
depends_on: [
|
|
2435
|
+
"frontend-plan-revision-pi",
|
|
2436
|
+
"frontend-plan-pi",
|
|
2437
|
+
"frontend-final-design-review-pi",
|
|
2438
|
+
"frontend-design-review-pi",
|
|
2439
|
+
],
|
|
2440
|
+
dependsPolicy: "all-or-condition-skip",
|
|
2441
|
+
role: "planner",
|
|
2442
|
+
executor: "pi",
|
|
2443
|
+
complexity: "MED",
|
|
2444
|
+
writePolicy: "read-only",
|
|
2445
|
+
outputMode: "structured-required",
|
|
2446
|
+
retryPolicy: STRUCTURED_REQUIRED_PI_RETRY_POLICY,
|
|
2447
|
+
allowedPaths: readOnlyPaths,
|
|
2448
|
+
forbiddenPaths,
|
|
2449
|
+
skills: FRONTEND_IMPLEMENTATION_SKILLS,
|
|
2450
|
+
outputContract: "Return exactly one JSON object conforming to frontend-implementation-contract-v1. No Markdown, prose, comments, or code fences.",
|
|
2451
|
+
subtask_prompt: [
|
|
2452
|
+
"Convert the effective reviewed frontend plan into the canonical frontend-implementation-contract-v1 JSON.",
|
|
2453
|
+
"Use frontend-plan-revision-pi when it is FINISHED; otherwise use frontend-plan-pi. Confirm the effective design review passed before producing the contract.",
|
|
2454
|
+
"Return only the JSON object. Do not wrap it in Markdown or a code fence. Do not add explanatory text.",
|
|
2455
|
+
"Preserve all requirement expectedOutcome, interaction trigger/expectedBehavior, target files, verification targets, Mock/API decisions, and Real Integration Gap from the effective plan.",
|
|
2456
|
+
frontendContractSchemaBlock,
|
|
2457
|
+
sourceContext,
|
|
2458
|
+
].join("\n\n"),
|
|
2459
|
+
},
|
|
2460
|
+
{
|
|
2461
|
+
id: "frontend-contract-json-validate-shell",
|
|
2462
|
+
depends_on: ["frontend-contract-json-pi"],
|
|
2463
|
+
role: "verifier",
|
|
2464
|
+
executor: "shell",
|
|
2465
|
+
complexity: "LOW",
|
|
2466
|
+
writePolicy: "read-only",
|
|
2467
|
+
allowedPaths: readOnlyPaths,
|
|
2468
|
+
forbiddenPaths,
|
|
2469
|
+
outputContract: "Validated frontend implementation contract artifact with schema ID and SHA-256.",
|
|
2470
|
+
subtask_prompt: "Materialize and validate the structured frontend contract before prewrite authorization.",
|
|
2471
|
+
shell: {
|
|
2472
|
+
commands: [],
|
|
2473
|
+
jsonArtifactGate: {
|
|
2474
|
+
fromNodeId: "frontend-contract-json-pi",
|
|
2475
|
+
schemaId: "frontend-implementation-contract-v1",
|
|
2476
|
+
artifactName: "frontend-implementation-contract.json",
|
|
2477
|
+
outputDir: "contracts",
|
|
2478
|
+
},
|
|
2479
|
+
cwd: ".",
|
|
2480
|
+
timeoutMs: 60000,
|
|
2481
|
+
},
|
|
2482
|
+
},
|
|
2460
2483
|
{
|
|
2461
2484
|
id: "frontend-prewrite-gate-shell",
|
|
2462
2485
|
depends_on: [
|
|
2486
|
+
"frontend-contract-json-pi",
|
|
2487
|
+
"frontend-contract-json-validate-shell",
|
|
2463
2488
|
"frontend-final-design-review-pi",
|
|
2464
2489
|
"frontend-design-review-pi",
|
|
2465
2490
|
"frontend-plan-revision-pi",
|
|
@@ -2478,8 +2503,8 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2478
2503
|
commands: [],
|
|
2479
2504
|
frontendPrewriteGate: {
|
|
2480
2505
|
schemaVersion: 1,
|
|
2481
|
-
planFromNodeId: "frontend-
|
|
2482
|
-
planFallbackFromNodeIds: ["frontend-plan-pi"],
|
|
2506
|
+
planFromNodeId: "frontend-contract-json-pi",
|
|
2507
|
+
planFallbackFromNodeIds: ["frontend-plan-revision-pi", "frontend-plan-pi"],
|
|
2483
2508
|
reviewFromNodeId: "frontend-final-design-review-pi",
|
|
2484
2509
|
reviewFallbackFromNodeIds: ["frontend-design-review-pi"],
|
|
2485
2510
|
requiredRequirementIds: requirementIds,
|
|
@@ -3563,8 +3588,14 @@ async function buildBackendTestHybridDag(sources) {
|
|
|
3563
3588
|
writeSet: ["testcase/md/**"],
|
|
3564
3589
|
allowedPaths: ["testcase/md/**"],
|
|
3565
3590
|
forbiddenPaths: forbidden,
|
|
3591
|
+
writerOutcomePolicy: {
|
|
3592
|
+
type: "implementation-outcome-v1",
|
|
3593
|
+
requireChangedFiles: true,
|
|
3594
|
+
},
|
|
3566
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.",
|
|
3567
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.",
|
|
3568
3599
|
"Read the upstream environment report. Generate a Markdown-first backend test strategy and cases under testcase/md/**.",
|
|
3569
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.",
|
|
3570
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.",
|
|
@@ -5363,7 +5394,7 @@ async function buildHybridDagForTemplate(sources, template) {
|
|
|
5363
5394
|
else if (template === "review-gated-dag")
|
|
5364
5395
|
spec = buildReviewGatedHybridDag(standard, sources);
|
|
5365
5396
|
else
|
|
5366
|
-
spec = buildSupervisedHybridDag(standard, sources);
|
|
5397
|
+
spec = await buildSupervisedHybridDag(standard, sources);
|
|
5367
5398
|
}
|
|
5368
5399
|
applyProjectGovernanceReview(spec, template, sources);
|
|
5369
5400
|
// New generate path always emits DagSpec v4 + bindings.
|
|
@@ -5807,10 +5838,12 @@ function buildWriteSetGateNode(sources) {
|
|
|
5807
5838
|
},
|
|
5808
5839
|
};
|
|
5809
5840
|
}
|
|
5810
|
-
function buildSoftVerifyNode(sources) {
|
|
5841
|
+
async function buildSoftVerifyNode(sources) {
|
|
5811
5842
|
const implementId = implementationNodeId();
|
|
5812
5843
|
const strategy = resolveDagVerifyStrategy(sources.taskConfig, "1");
|
|
5813
|
-
const fallbackCommands =
|
|
5844
|
+
const fallbackCommands = sources.repoRoot
|
|
5845
|
+
? (await discoverFrontendFallbackVerifyCommands(sources.repoRoot)).staticCommands
|
|
5846
|
+
: ["npm run typecheck"];
|
|
5814
5847
|
const focusedIntermediate = sources.verifyCommands?.intermediate.filter((command) => !isFullSuiteVerifyCommand(command));
|
|
5815
5848
|
const plannedIntermediate = applyMavenVerificationPlanning({
|
|
5816
5849
|
repoRoot: sources.repoRoot,
|
|
@@ -6052,7 +6085,7 @@ function resolveSupervisedConvergence(taskConfig) {
|
|
|
6052
6085
|
chainNodeIds: [...SUPERVISED_CONVERGENCE_CHAIN_NODE_IDS],
|
|
6053
6086
|
};
|
|
6054
6087
|
}
|
|
6055
|
-
function buildSupervisedHybridDag(standard, sources) {
|
|
6088
|
+
async function buildSupervisedHybridDag(standard, sources) {
|
|
6056
6089
|
const contract = getTaskOrThrow(standard, "contract-pi");
|
|
6057
6090
|
const scoutSrc = getTaskOrThrow(standard, "scout-src");
|
|
6058
6091
|
const scoutTests = getTaskOrThrow(standard, "scout-tests");
|
|
@@ -6104,7 +6137,7 @@ function buildSupervisedHybridDag(standard, sources) {
|
|
|
6104
6137
|
"final-write-set-audit-format-repair-pi",
|
|
6105
6138
|
],
|
|
6106
6139
|
}),
|
|
6107
|
-
buildSoftVerifyNode(sources),
|
|
6140
|
+
await buildSoftVerifyNode(sources),
|
|
6108
6141
|
buildProcessSupervisorNode(sources),
|
|
6109
6142
|
buildProcessGateNode(sources),
|
|
6110
6143
|
buildRepairNode(sources),
|
|
@@ -148,6 +148,29 @@ export function validateOutputProtocol(protocol, text) {
|
|
|
148
148
|
reason: `missing first non-empty line; expected one of: ${protocol.validLines.map((l) => JSON.stringify(l)).join(" or ")}`,
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
|
+
const isReviewVerdict = protocol.validLines.length === 2 && protocol.validLines.includes("VERDICT: pass") && protocol.validLines.includes("VERDICT: request-revision");
|
|
152
|
+
// Models frequently prepend a short explanation despite the protocol
|
|
153
|
+
// instruction. Recover only when there is exactly one unambiguous protocol
|
|
154
|
+
// line; conflicting or repeated verdicts remain fail-closed.
|
|
155
|
+
const candidates = isReviewVerdict ? text
|
|
156
|
+
.split("\n")
|
|
157
|
+
.map((line) => normalizeVerdictCandidateLine(line.trim()))
|
|
158
|
+
.filter((line) => protocol.validLines.includes(line)) : [];
|
|
159
|
+
const uniqueCandidates = [...new Set(candidates)];
|
|
160
|
+
if (uniqueCandidates.length > 1) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
failureCategory: "protocol-invalid",
|
|
164
|
+
reason: `conflicting protocol lines found: ${uniqueCandidates.map((line) => JSON.stringify(line)).join(" and ")}`,
|
|
165
|
+
firstNonEmptyLine: first,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (protocol.validLines.includes(first)) {
|
|
169
|
+
return { ok: true, matchedLine: first };
|
|
170
|
+
}
|
|
171
|
+
if (uniqueCandidates.length === 1) {
|
|
172
|
+
return { ok: true, matchedLine: uniqueCandidates[0] };
|
|
173
|
+
}
|
|
151
174
|
if (!protocol.validLines.includes(first)) {
|
|
152
175
|
return {
|
|
153
176
|
ok: false,
|
|
@@ -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",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"requirements": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "expectedOutcome", "implementationTargets", "verificationTargetIds"], "properties": { "id": { "$ref": "#/$defs/requirementId" }, "expectedOutcome": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "evidenceGap": { "$ref": "#/$defs/gap" } } } },
|
|
14
14
|
"uiStates": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "applicable"], "properties": { "name": { "type": "string", "minLength": 1 }, "applicable": { "type": "boolean" }, "expectedBehavior": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "notApplicableReason": { "type": "string", "minLength": 1 } }, "allOf": [{ "if": { "properties": { "applicable": { "const": true } }, "required": ["applicable"] }, "then": { "required": ["expectedBehavior", "implementationTargets", "verificationTargetIds"], "properties": { "implementationTargets": { "minItems": 1 }, "verificationTargetIds": { "minItems": 1 } } } }, { "if": { "properties": { "applicable": { "const": false } }, "required": ["applicable"] }, "then": { "required": ["notApplicableReason"] } }] } },
|
|
15
15
|
"interactions": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "trigger", "expectedBehavior", "implementationTargets", "verificationTargetIds"], "properties": { "name": { "type": "string", "minLength": 1 }, "trigger": { "type": "string", "minLength": 1 }, "expectedBehavior": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string" } } } } },
|
|
16
|
-
"mockApi": { "type": "object", "additionalProperties": false, "required": ["strategy", "productionDefaultOff", "activation", "endpoints"], "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter", "not-needed"] }, "productionDefaultOff": { "const": true }, "activation": { "type": "string", "minLength": 1 }, "endpoints": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["method", "path"], "properties": { "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] }, "path": { "type": "string", "pattern": "^/" }, "fixture": { "$ref": "#/$defs/path" }, "consumer": { "$ref": "#/$defs/path" } } } } } },
|
|
16
|
+
"mockApi": { "type": "object", "additionalProperties": false, "required": ["strategy", "productionDefaultOff", "activation", "endpoints"], "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter", "not-needed"] }, "productionDefaultOff": { "const": true }, "activation": { "type": ["string", "null"], "minLength": 1 }, "endpoints": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["method", "path"], "properties": { "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] }, "path": { "type": "string", "pattern": "^/" }, "fixture": { "anyOf": [{ "$ref": "#/$defs/path" }, { "type": "null" }] }, "consumer": { "anyOf": [{ "$ref": "#/$defs/path" }, { "type": "null" }] } } } } } },
|
|
17
17
|
"designEvidence": { "type": "object", "additionalProperties": false, "required": ["source", "paths", "conflicts"], "properties": { "source": { "type": "string", "minLength": 1 }, "paths": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "conflicts": { "type": "array", "items": { "type": "string", "minLength": 1 } } } },
|
|
18
|
-
"verificationTargets": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "type", "commandLabel", "file", "requirementIds", "uiStates"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": ["static", "unit", "component", "integration", "mock"] }, "commandLabel": { "type": "string", "minLength": 1 }, "file": { "$ref": "#/$defs/path" }, "symbol": { "type": "string", "minLength": 1 }, "requirementIds": { "type": "array", "items": { "$ref": "#/$defs/requirementId" } }, "uiStates": { "type": "array", "items": { "type": "string", "minLength": 1 } } } } },
|
|
18
|
+
"verificationTargets": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "type", "commandLabel", "file", "requirementIds", "uiStates"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": ["static", "unit", "component", "integration", "mock"] }, "commandLabel": { "type": "string", "minLength": 1 }, "file": { "$ref": "#/$defs/path" }, "symbol": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, "requirementIds": { "type": "array", "items": { "$ref": "#/$defs/requirementId" } }, "uiStates": { "type": "array", "items": { "type": "string", "minLength": 1 } } } } },
|
|
19
19
|
"evidenceGaps": { "type": "array", "items": { "$ref": "#/$defs/gap" } }
|
|
20
20
|
},
|
|
21
21
|
"allOf": [{ "if": { "properties": { "mockApi": { "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter"] } } } } }, "then": { "properties": { "mockApi": { "properties": { "endpoints": { "minItems": 1, "items": { "required": ["method", "path", "fixture", "consumer"] } } } } } } }],
|