@tea-agent/loop-agent 0.38.0 → 0.39.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/dist/build-stamp.json +3 -3
- package/dist/infrastructure/harness/atomic-write.js +2 -1
- package/dist/shared/openspec-spec.js +76 -0
- package/dist/task/config-types.js +21 -0
- package/dist/task/frontend-project-capability.js +292 -38
- package/dist/workflows/dag/failure-routing.js +9 -4
- package/dist/workflows/dag/frontend-prewrite-gate.js +230 -17
- package/dist/workflows/dag/init-hybrid.js +127 -6
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +16 -6
- package/dist/workflows/dag/report.js +6 -0
- package/dist/workflows/dag/retry-policy.js +11 -0
- package/dist/workflows/dag/types.js +24 -5
- package/dist/workflows/dag/validate.js +7 -2
- package/docs/templates/frontend-design-contract.md +40 -14
- package/docs/templates/frontend-task-constraints.md +40 -13
- package/docs/templates/frontend-task-requirement.md +54 -20
- package/package.json +1 -1
- package/skills/frontend-implementation/references/node-contracts.md +12 -11
|
@@ -2,6 +2,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
|
|
5
|
+
import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
|
|
5
6
|
import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
|
|
6
7
|
import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
|
|
7
8
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
@@ -25,6 +26,11 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
|
|
|
25
26
|
"mock-strategy-no-verification-commands",
|
|
26
27
|
"target-outside-write-set",
|
|
27
28
|
"verification-target-outside-write-set",
|
|
29
|
+
"openspec-not-read",
|
|
30
|
+
"candidate-missing-drift",
|
|
31
|
+
"openspec-citation-block-unparseable",
|
|
32
|
+
"openspec-not-cited",
|
|
33
|
+
"openspec-citation-not-read",
|
|
28
34
|
]);
|
|
29
35
|
export const frontendPrewriteResultV1Schema = z
|
|
30
36
|
.object({
|
|
@@ -196,14 +202,9 @@ function isFailedToolResult(event) {
|
|
|
196
202
|
event.result?.error != null ||
|
|
197
203
|
event.result?.ok === false);
|
|
198
204
|
}
|
|
199
|
-
async function
|
|
200
|
-
const { runDir,
|
|
201
|
-
if (candidatePaths.length === 0)
|
|
202
|
-
return [];
|
|
205
|
+
async function collectOpenspecReadPaths(input) {
|
|
206
|
+
const { runDir, planNodeId, reviewNodeId, repoRoot } = input;
|
|
203
207
|
const matched = new Set();
|
|
204
|
-
const normalizedCandidates = new Set(candidatePaths
|
|
205
|
-
.map((candidate) => toRepoRelativePath(candidate, repoRoot))
|
|
206
|
-
.filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
|
|
207
208
|
for (const nodeId of [planNodeId, reviewNodeId]) {
|
|
208
209
|
const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
|
|
209
210
|
try {
|
|
@@ -237,7 +238,7 @@ async function checkOpenspecReadEvidence(input) {
|
|
|
237
238
|
: null;
|
|
238
239
|
if (relativePath &&
|
|
239
240
|
!isFailedToolResult(event) &&
|
|
240
|
-
|
|
241
|
+
isOpenspecSpecFilePath(relativePath)) {
|
|
241
242
|
matched.add(relativePath);
|
|
242
243
|
}
|
|
243
244
|
startedReads.delete(event.toolCallId);
|
|
@@ -252,6 +253,87 @@ async function checkOpenspecReadEvidence(input) {
|
|
|
252
253
|
}
|
|
253
254
|
return [...matched];
|
|
254
255
|
}
|
|
256
|
+
async function checkOpenspecReadEvidence(input) {
|
|
257
|
+
const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
|
|
258
|
+
if (candidatePaths.length === 0)
|
|
259
|
+
return [];
|
|
260
|
+
const allReadPaths = await collectOpenspecReadPaths({
|
|
261
|
+
runDir,
|
|
262
|
+
planNodeId,
|
|
263
|
+
reviewNodeId,
|
|
264
|
+
repoRoot,
|
|
265
|
+
});
|
|
266
|
+
const normalizedCandidates = new Set(candidatePaths
|
|
267
|
+
.map((candidate) => toRepoRelativePath(candidate, repoRoot))
|
|
268
|
+
.filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
|
|
269
|
+
return allReadPaths.filter((readPath) => normalizedCandidates.has(readPath));
|
|
270
|
+
}
|
|
271
|
+
function normalizeCitationPath(rawPath, repoRoot) {
|
|
272
|
+
let candidate = rawPath.trim();
|
|
273
|
+
const hashIndex = candidate.indexOf("#");
|
|
274
|
+
if (hashIndex >= 0)
|
|
275
|
+
candidate = candidate.slice(0, hashIndex);
|
|
276
|
+
candidate = candidate.replace(/\\/g, "/");
|
|
277
|
+
if (!candidate)
|
|
278
|
+
return null;
|
|
279
|
+
if (repoRoot) {
|
|
280
|
+
const relative = toRepoRelativePath(candidate, repoRoot);
|
|
281
|
+
return relative ? relative.replace(/\\/g, "/") : null;
|
|
282
|
+
}
|
|
283
|
+
return candidate;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Parses the deterministic openspec-citations fence from the effective plan
|
|
287
|
+
* output. Each non-empty line must be one JSON object {"path","section","line"}.
|
|
288
|
+
* Rows whose normalized path is not a supported openspec spec file are ignored
|
|
289
|
+
* (out of scope); rows that are invalid JSON or lack a string path mark the
|
|
290
|
+
* block unparseable (fail-closed when candidates are non-empty).
|
|
291
|
+
*/
|
|
292
|
+
export function parseOpenspecCitations(planText, repoRoot) {
|
|
293
|
+
const citations = [];
|
|
294
|
+
let blockPresent = false;
|
|
295
|
+
let unparseable = false;
|
|
296
|
+
const fenceRe = /```openspec-citations[ \t]*\r?\n([\s\S]*?)\r?\n```/g;
|
|
297
|
+
let match;
|
|
298
|
+
while ((match = fenceRe.exec(planText)) !== null) {
|
|
299
|
+
blockPresent = true;
|
|
300
|
+
const body = match[1] ?? "";
|
|
301
|
+
for (const rawLine of body.split(/\r?\n/)) {
|
|
302
|
+
const line = rawLine.trim();
|
|
303
|
+
if (!line)
|
|
304
|
+
continue;
|
|
305
|
+
let parsed;
|
|
306
|
+
try {
|
|
307
|
+
parsed = JSON.parse(line);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
unparseable = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
314
|
+
unparseable = true;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const rawPath = parsed.path;
|
|
318
|
+
if (typeof rawPath !== "string" || !rawPath.trim()) {
|
|
319
|
+
unparseable = true;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const normalized = normalizeCitationPath(rawPath, repoRoot);
|
|
323
|
+
if (!normalized || !isOpenspecSpecFilePath(normalized))
|
|
324
|
+
continue;
|
|
325
|
+
const section = typeof parsed.section === "string"
|
|
326
|
+
? parsed.section
|
|
327
|
+
: "";
|
|
328
|
+
const lineValue = parsed.line;
|
|
329
|
+
const citationLine = typeof lineValue === "number" && Number.isInteger(lineValue)
|
|
330
|
+
? lineValue
|
|
331
|
+
: null;
|
|
332
|
+
citations.push({ path: normalized, section, line: citationLine });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { blockPresent, unparseable, citations };
|
|
336
|
+
}
|
|
255
337
|
async function finalizePrewrite(input, pending) {
|
|
256
338
|
const sourceBindingSha256 = input.sourceBinding
|
|
257
339
|
? deterministicSha256(input.sourceBinding)
|
|
@@ -298,6 +380,7 @@ async function finalizePrewrite(input, pending) {
|
|
|
298
380
|
canonicalSha256: pending.canonicalSha256,
|
|
299
381
|
openspecReadPaths: pending.openspecReadPaths,
|
|
300
382
|
openspecCandidatePaths: pending.openspecCandidatePaths,
|
|
383
|
+
openspecPolicy: pending.openspecPolicy,
|
|
301
384
|
};
|
|
302
385
|
}
|
|
303
386
|
export async function runFrontendPrewriteGate(input) {
|
|
@@ -307,6 +390,7 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
307
390
|
const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
|
|
308
391
|
const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
|
|
309
392
|
const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
|
|
393
|
+
const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
|
|
310
394
|
const basePending = {
|
|
311
395
|
planNodeId,
|
|
312
396
|
reviewNodeId,
|
|
@@ -322,6 +406,7 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
322
406
|
classification: "accepted",
|
|
323
407
|
openspecReadPaths: [],
|
|
324
408
|
openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
|
|
409
|
+
openspecPolicy,
|
|
325
410
|
};
|
|
326
411
|
let planText;
|
|
327
412
|
try {
|
|
@@ -498,6 +583,134 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
498
583
|
});
|
|
499
584
|
}
|
|
500
585
|
}
|
|
586
|
+
// Effective plan/review must actually read the candidate openspec paths when
|
|
587
|
+
// any exist. This fail-closes BEFORE the canonical contract is materialized
|
|
588
|
+
// so a writer is never authorized on top of unread normative evidence.
|
|
589
|
+
const candidatePaths = input.config.openspecCandidatePaths ?? [];
|
|
590
|
+
const candidateRoot = workspaceRoot ?? process.cwd();
|
|
591
|
+
// Drift precheck: candidates are frozen at generation time. A candidate that
|
|
592
|
+
// no longer exists on disk was deleted/renamed after generation and is a
|
|
593
|
+
// generation-contract defect, not an "unread" failure.
|
|
594
|
+
const missingCandidates = [];
|
|
595
|
+
if (candidatePaths.length > 0) {
|
|
596
|
+
for (const candidate of candidatePaths) {
|
|
597
|
+
try {
|
|
598
|
+
await stat(path.join(candidateRoot, candidate));
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
missingCandidates.push(candidate);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (missingCandidates.length > 0) {
|
|
606
|
+
return finalizePrewrite(input, {
|
|
607
|
+
...basePending,
|
|
608
|
+
verdict,
|
|
609
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
610
|
+
normalizationActions: [],
|
|
611
|
+
mockStrategy,
|
|
612
|
+
classification: "blocked",
|
|
613
|
+
failureReason: `frontend prewrite gate blocked: openspec candidate paths frozen at generation time no longer exist on disk: ${missingCandidates.join(", ")}. 候选集在生成期冻结,这些候选在生成后被删除/改名,请修正后重新生成 DAG。`,
|
|
614
|
+
failureCode: "candidate-missing-drift",
|
|
615
|
+
openspecReadPaths: [],
|
|
616
|
+
openspecCandidatePaths: candidatePaths,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
let openspecReadPaths;
|
|
620
|
+
if (openspecPolicy === "cited") {
|
|
621
|
+
openspecReadPaths = await collectOpenspecReadPaths({
|
|
622
|
+
runDir: input.runDir,
|
|
623
|
+
planNodeId,
|
|
624
|
+
reviewNodeId,
|
|
625
|
+
repoRoot: candidateRoot,
|
|
626
|
+
});
|
|
627
|
+
if (candidatePaths.length > 0) {
|
|
628
|
+
const parsed = parseOpenspecCitations(planText, candidateRoot);
|
|
629
|
+
if (!parsed.blockPresent || parsed.unparseable) {
|
|
630
|
+
return finalizePrewrite(input, {
|
|
631
|
+
...basePending,
|
|
632
|
+
verdict,
|
|
633
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
634
|
+
normalizationActions: [],
|
|
635
|
+
mockStrategy,
|
|
636
|
+
classification: "retryable-invalid",
|
|
637
|
+
failureReason: `frontend prewrite gate: the effective plan output must end with a parseable \`\`\`openspec-citations fenced block (one JSON {"path","section","line"} per line). ${parsed.blockPresent
|
|
638
|
+
? "The block is present but contains unparseable lines."
|
|
639
|
+
: "No openspec-citations block was found."}`,
|
|
640
|
+
failureCode: "openspec-citation-block-unparseable",
|
|
641
|
+
openspecReadPaths,
|
|
642
|
+
openspecCandidatePaths: candidatePaths,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
const citedPaths = new Set(parsed.citations.map((citation) => citation.path));
|
|
646
|
+
const notCited = candidatePaths.filter((candidate) => !citedPaths.has(candidate));
|
|
647
|
+
if (notCited.length > 0) {
|
|
648
|
+
return finalizePrewrite(input, {
|
|
649
|
+
...basePending,
|
|
650
|
+
verdict,
|
|
651
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
652
|
+
normalizationActions: [],
|
|
653
|
+
mockStrategy,
|
|
654
|
+
classification: "retryable-invalid",
|
|
655
|
+
failureReason: `frontend prewrite gate: these openspec candidate paths were not cited in the plan openspec-citations block: ${notCited.join(", ")}`,
|
|
656
|
+
failureCode: "openspec-not-cited",
|
|
657
|
+
openspecReadPaths,
|
|
658
|
+
openspecCandidatePaths: candidatePaths,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
const readSet = new Set(openspecReadPaths);
|
|
662
|
+
const uncorroborated = [
|
|
663
|
+
...new Set(parsed.citations
|
|
664
|
+
.filter((citation) => !readSet.has(citation.path))
|
|
665
|
+
.map((citation) => citation.path)),
|
|
666
|
+
];
|
|
667
|
+
if (uncorroborated.length > 0) {
|
|
668
|
+
return finalizePrewrite(input, {
|
|
669
|
+
...basePending,
|
|
670
|
+
verdict,
|
|
671
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
672
|
+
normalizationActions: [],
|
|
673
|
+
mockStrategy,
|
|
674
|
+
classification: "retryable-invalid",
|
|
675
|
+
failureReason: `frontend prewrite gate: these openspec paths were cited in the plan but have no successful read event in the effective plan/review: ${uncorroborated.join(", ")}`,
|
|
676
|
+
failureCode: "openspec-citation-not-read",
|
|
677
|
+
openspecReadPaths,
|
|
678
|
+
openspecCandidatePaths: candidatePaths,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// cited with empty candidates: no read enforcement. The generation step
|
|
683
|
+
// emits an advisory instead of failing the gate.
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
openspecReadPaths = await checkOpenspecReadEvidence({
|
|
687
|
+
runDir: input.runDir,
|
|
688
|
+
candidatePaths,
|
|
689
|
+
planNodeId,
|
|
690
|
+
reviewNodeId,
|
|
691
|
+
repoRoot: candidateRoot,
|
|
692
|
+
});
|
|
693
|
+
if (candidatePaths.length > 0) {
|
|
694
|
+
const readSet = new Set(openspecReadPaths);
|
|
695
|
+
const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
|
|
696
|
+
if (unread.length > 0) {
|
|
697
|
+
const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
|
|
698
|
+
const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
|
|
699
|
+
return finalizePrewrite(input, {
|
|
700
|
+
...basePending,
|
|
701
|
+
verdict,
|
|
702
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
703
|
+
normalizationActions: [],
|
|
704
|
+
mockStrategy,
|
|
705
|
+
classification: "retryable-invalid",
|
|
706
|
+
failureReason: `frontend prewrite gate: effective plan/review did not read these openspec candidate paths: ${unread.join(", ")}. Read each candidate and report applicable rules plus hit path/section/line number, conflicts, and missing specifications, then rerun. 实际成功读取: ${openspecReadPaths.length > 0 ? openspecReadPaths.join(", ") : "(none)"}; 候选总数: ${candidatePaths.length}; 排除治理目录 ${governanceRoot} 下 ${excludedGovernanceCount} 个文件(运行期诊断口径); 候选集在生成期冻结,若候选与本任务无关属于生成期契约配置问题,应修正后重新生成 DAG。`,
|
|
707
|
+
failureCode: "openspec-not-read",
|
|
708
|
+
openspecReadPaths,
|
|
709
|
+
openspecCandidatePaths: candidatePaths,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
501
714
|
// Materialize the canonical contract only after every governance check passed.
|
|
502
715
|
const artifact = await writeFrontendImplementationContractArtifact({
|
|
503
716
|
runDir: input.runDir,
|
|
@@ -505,14 +718,6 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
505
718
|
artifactName: input.config.artifactName,
|
|
506
719
|
canonical: analysis.canonical,
|
|
507
720
|
});
|
|
508
|
-
const candidatePaths = input.config.openspecCandidatePaths ?? [];
|
|
509
|
-
const openspecReadPaths = await checkOpenspecReadEvidence({
|
|
510
|
-
runDir: input.runDir,
|
|
511
|
-
candidatePaths,
|
|
512
|
-
planNodeId,
|
|
513
|
-
reviewNodeId,
|
|
514
|
-
repoRoot: workspaceRoot ?? process.cwd(),
|
|
515
|
-
});
|
|
516
721
|
const classification = analysis.normalizationActions.length > 0 ? "accepted-normalized" : "accepted";
|
|
517
722
|
return finalizePrewrite(input, {
|
|
518
723
|
...basePending,
|
|
@@ -551,12 +756,20 @@ export function formatFrontendPrewriteGateStdout(result) {
|
|
|
551
756
|
lines.push(`SHA-256: ${result.artifact.sha256}`);
|
|
552
757
|
}
|
|
553
758
|
lines.push(`Prewrite result: ${result.prewriteResult.path}`);
|
|
759
|
+
lines.push(`Openspec policy: ${result.openspecPolicy}`);
|
|
554
760
|
if (result.failureReason)
|
|
555
761
|
lines.push(`Failure reason: ${result.failureReason}`);
|
|
556
762
|
if (result.openspecReadPaths.length > 0) {
|
|
557
763
|
lines.push(`openspec read: ${result.openspecReadPaths.join(", ")}`);
|
|
558
764
|
}
|
|
559
|
-
|
|
765
|
+
if (result.openspecCandidatePaths.length > 0) {
|
|
766
|
+
const readSet = new Set(result.openspecReadPaths);
|
|
767
|
+
const unread = result.openspecCandidatePaths.filter((candidate) => !readSet.has(candidate));
|
|
768
|
+
if (unread.length > 0) {
|
|
769
|
+
lines.push(`openspec unread: ${unread.join(", ")}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
560
773
|
lines.push("openspec: unavailable");
|
|
561
774
|
}
|
|
562
775
|
return lines.join("\n");
|
|
@@ -7,9 +7,10 @@ import { assertValidDagSpec } from "./validate.js";
|
|
|
7
7
|
import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1, DAG_RUNTIME_CONTRACT_SCHEMA_VERSION, DEFAULT_DAG_OUTPUT_LANGUAGE, DEFAULT_DAG_EXECUTOR_MODELS, parseDagSpec, } from "./types.js";
|
|
8
8
|
import { planMavenVerification, } from "../../verification/maven/index.js";
|
|
9
9
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
10
|
+
import { extractTaskSourceOpenspecPaths } from "../../shared/openspec-spec.js";
|
|
10
11
|
import { BASELINE_FORBIDDEN_PATHS } from "./governance-constants.js";
|
|
11
12
|
import { buildDecisionEnvelopePromptContract } from "./decision-envelope.js";
|
|
12
|
-
import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRUCTURED_REQUIRED_PI_RETRY_POLICY, BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY, WRITER_TRANSPORT_RETRY_POLICY, isSafeReadOnlyPiRetryCandidate, isWriterTransportRetryCandidate, } from "./retry-policy.js";
|
|
13
|
+
import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PLANNER_OUTPUT_LIMIT_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRUCTURED_REQUIRED_PI_RETRY_POLICY, BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY, WRITER_TRANSPORT_RETRY_POLICY, isSafeReadOnlyPiRetryCandidate, isWriterTransportRetryCandidate, } from "./retry-policy.js";
|
|
13
14
|
import { REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL, REVIEW_VERDICT_OUTPUT_PROTOCOL, } from "./output-protocol.js";
|
|
14
15
|
import { resolveAdapter } from "../../adapters/index.js";
|
|
15
16
|
import { loadHarnessManifest } from "../../governance/harness.js";
|
|
@@ -558,6 +559,39 @@ export function hasApiDependency(sources) {
|
|
|
558
559
|
.some((clause) => !negationPatterns.some((pattern) => pattern.test(clause)) &&
|
|
559
560
|
dependencyPatterns.some((pattern) => pattern.test(clause)));
|
|
560
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Generation-time heuristic: does the task source mention interface/API/Mock
|
|
564
|
+
* needs anywhere (requirement or constraints)?
|
|
565
|
+
*
|
|
566
|
+
* Mirrors hasApiDependency's clause-level negation filtering so explicit
|
|
567
|
+
* "not involved / not implementing" clauses and out-of-scope sections do not
|
|
568
|
+
* become positive evidence. Used to emit a machine-visible advisory when auto
|
|
569
|
+
* mode narrows Mock to not-needed despite the source mentioning such needs.
|
|
570
|
+
*/
|
|
571
|
+
export function frontendSourceMentionsMock(sources) {
|
|
572
|
+
const text = [sources.requirementMarkdown, sources.constraintMarkdown]
|
|
573
|
+
.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
574
|
+
.map((entry) => normalizeTaskRequirementText(entry).replace(/`[^`\n]*`/g, " "))
|
|
575
|
+
.join("\n");
|
|
576
|
+
const signalPatterns = [
|
|
577
|
+
/mock/i,
|
|
578
|
+
/模拟服务/,
|
|
579
|
+
/接口桩/,
|
|
580
|
+
/\bmsw\b/i,
|
|
581
|
+
/(?<![A-Za-z0-9_])API(?![A-Za-z0-9_])/i,
|
|
582
|
+
/接口/,
|
|
583
|
+
];
|
|
584
|
+
const negationPatterns = [
|
|
585
|
+
/(?:不涉及|无需|不需要|不依赖|不调用|不请求|不实现|没有|禁止|不得).{0,16}(?:接口|后端|服务端|远程数据|异步数据|API|mock|模拟|桩)/i,
|
|
586
|
+
/\b(?:no|without|does\s+not|do\s+not|must\s+not)\b.{0,24}\b(?:api|endpoint|request|backend|server|mock)\b/i,
|
|
587
|
+
];
|
|
588
|
+
return text
|
|
589
|
+
.split(/[。!?!?;;,,\r\n]+/)
|
|
590
|
+
.map((clause) => clause.trim())
|
|
591
|
+
.filter(Boolean)
|
|
592
|
+
.some((clause) => !negationPatterns.some((pattern) => pattern.test(clause)) &&
|
|
593
|
+
signalPatterns.some((pattern) => pattern.test(clause)));
|
|
594
|
+
}
|
|
561
595
|
/**
|
|
562
596
|
* Resolve frontend Mock mode from capability seed, task config, and interface dependency analysis.
|
|
563
597
|
*
|
|
@@ -2111,6 +2145,7 @@ function resolveFrontendMockContextBlock(sources) {
|
|
|
2111
2145
|
parts.push("Generation-time evidence does not require Mock. The assessment must still use contract/scout evidence: select not-needed when Mock is intentionally skipped, or select a safe Mock strategy if project evidence supports one.");
|
|
2112
2146
|
if (frontendMockStrategyMustBeNotNeeded(sources)) {
|
|
2113
2147
|
parts.push('Auto mode has no confirmed project Mock capability or no deterministic Mock verification command. The structured contract must set mockApi.strategy to "not-needed". Do not add Mock files or dependencies; keep the real request path as default and record any unproved backend behavior as Real Integration Gap.');
|
|
2148
|
+
parts.push('HARD CONSTRAINT (frozen at generation time): this DAG allows only mockApi.strategy "not-needed"; the prewrite gate rejects any other strategy. If project governance (openspec / ai_workspace / decision records, e.g. a DEC rule requiring native) demands Mock-backed verification, that is a generation-time contract gap, not a plan-revision defect: declare frontendMock.verifyCommands (or policy: "required") in task.json and regenerate the DAG. Do not emit any mockApi.strategy outside the allowlist and do not add Mock files or dependencies within this run.');
|
|
2114
2149
|
}
|
|
2115
2150
|
}
|
|
2116
2151
|
if (mode === "blocked") {
|
|
@@ -2129,6 +2164,32 @@ function frontendMockStrategyMustBeNotNeeded(sources) {
|
|
|
2129
2164
|
capabilityStatus === "ambiguous" ||
|
|
2130
2165
|
!hasDeterministicMockVerification));
|
|
2131
2166
|
}
|
|
2167
|
+
function resolveFrontendOpenspecGateConfig(sources) {
|
|
2168
|
+
const taskConfig = sources.taskConfig;
|
|
2169
|
+
const policy = taskConfig.frontendOpenspec?.policy ?? "cited";
|
|
2170
|
+
const declared = taskConfig.frontendOpenspec?.requiredReadPaths ?? [];
|
|
2171
|
+
const taskSourceCited = extractTaskSourceOpenspecPaths([sources.requirementMarkdown, sources.constraintMarkdown ?? ""].join("\n"));
|
|
2172
|
+
const scanStrict = sources.frontendProjectCapability?.designEvidence.normativePaths ?? [];
|
|
2173
|
+
const dedupeSorted = (paths) => [...new Set(paths)].sort();
|
|
2174
|
+
const openspecCandidateSources = {
|
|
2175
|
+
declared: dedupeSorted(declared),
|
|
2176
|
+
taskSourceCited: dedupeSorted(taskSourceCited),
|
|
2177
|
+
scanStrict: dedupeSorted(scanStrict),
|
|
2178
|
+
};
|
|
2179
|
+
const openspecCandidatePaths = policy === "cited"
|
|
2180
|
+
? dedupeSorted([...declared, ...taskSourceCited])
|
|
2181
|
+
: openspecCandidateSources.scanStrict;
|
|
2182
|
+
return {
|
|
2183
|
+
openspecPolicy: policy,
|
|
2184
|
+
openspecCandidatePaths,
|
|
2185
|
+
openspecCandidateSources,
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
const openspecCitationInstruction = [
|
|
2189
|
+
"OpenSpec 引用块(citation block):在 fenced json 契约块之后,追加**恰好一个** ```openspec-citations 围栏代码块(三反引号 + openspec-citations)。",
|
|
2190
|
+
'该块内每行一个 JSON 对象 {"path":"<repo 相对 openspec 路径>","section":"<命中章节或空串>","line":<int 或 null>},必须逐条列出你在本计划中实际读取并应用的每个 openspec 规范文件。',
|
|
2191
|
+
"prewrite gate 会用真实 read 事件核验每条引用:引用存在但无成功 read 事件 → openspec-citation-not-read;契约冻结的必读候选未被引用 → openspec-not-cited;两者都 fail-closed。不要引用未读取的路径。",
|
|
2192
|
+
].join("\n");
|
|
2132
2193
|
function resolveFrontendCapabilityContextBlock(sources) {
|
|
2133
2194
|
const risk = sources.frontendRisk;
|
|
2134
2195
|
const capability = sources.frontendProjectCapability;
|
|
@@ -2141,9 +2202,33 @@ function resolveFrontendCapabilityContextBlock(sources) {
|
|
|
2141
2202
|
}
|
|
2142
2203
|
if (capability) {
|
|
2143
2204
|
parts.push("", capability.adapterGuidance);
|
|
2144
|
-
|
|
2145
|
-
|
|
2205
|
+
const classified = capability.designEvidence.classified;
|
|
2206
|
+
const bucketLines = [];
|
|
2207
|
+
const pushBucket = (label, paths) => {
|
|
2208
|
+
if (paths.length > 0)
|
|
2209
|
+
bucketLines.push(`${label}: ${paths.join(", ")}`);
|
|
2210
|
+
};
|
|
2211
|
+
pushBucket("schemas", classified.schemas);
|
|
2212
|
+
pushBucket("code-template", classified.codeTemplate);
|
|
2213
|
+
pushBucket("rule.api", classified.rule.api);
|
|
2214
|
+
pushBucket("rule.mock", classified.rule.mock);
|
|
2215
|
+
pushBucket("rule.router", classified.rule.router);
|
|
2216
|
+
pushBucket("rule.hooks", classified.rule.hooks);
|
|
2217
|
+
pushBucket("rule.utils", classified.rule.utils);
|
|
2218
|
+
pushBucket("rule.components", classified.rule.components);
|
|
2219
|
+
pushBucket("rule.other", classified.rule.other);
|
|
2220
|
+
pushBucket("theme", classified.theme);
|
|
2221
|
+
pushBucket("component", classified.component);
|
|
2222
|
+
pushBucket("ui-other", classified.uiOther);
|
|
2223
|
+
pushBucket("advisory-other", classified.advisoryOther);
|
|
2224
|
+
parts.push("## Classified openspec specification paths (role semantics)");
|
|
2225
|
+
if (bucketLines.length > 0) {
|
|
2226
|
+
parts.push(...bucketLines);
|
|
2146
2227
|
}
|
|
2228
|
+
else {
|
|
2229
|
+
parts.push("(no openspec specification paths discovered — greenfield)");
|
|
2230
|
+
}
|
|
2231
|
+
parts.push("Each consuming node MUST report in its output: applicable rules, the hit path/section/line number for every applied specification, and any conflicts or missing specifications. Missing or conflicting required specifications must fail closed rather than silently substituting nearby repository conventions.");
|
|
2147
2232
|
parts.push(`A11y capability: ${capability.a11y.status}` +
|
|
2148
2233
|
(capability.a11y.tools.length
|
|
2149
2234
|
? ` (${capability.a11y.tools.join(", ")})`
|
|
@@ -2509,6 +2594,29 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2509
2594
|
`- Behavior command source: ${behaviorVerifyEvidence.commandSource}`,
|
|
2510
2595
|
...behaviorVerifyEvidence.commandLabels.map((command) => ` - ${JSON.stringify(command)}`),
|
|
2511
2596
|
].join("\n");
|
|
2597
|
+
const advisories = [];
|
|
2598
|
+
if (frontendSourceMentionsMock(frontendSources) &&
|
|
2599
|
+
frontendMockStrategyMustBeNotNeeded(frontendSources)) {
|
|
2600
|
+
advisories.push("auto 模式已将 Mock 策略收窄为 not-needed:任务源提到接口/API/Mock 需求,但仓库无确认 Mock 能力或无确定性 Mock 验证命令。若项目规范要求 Mock,请声明 frontendMock.verifyCommands 或 policy:required 后重新生成 DAG。");
|
|
2601
|
+
}
|
|
2602
|
+
const openspecGate = resolveFrontendOpenspecGateConfig(sources);
|
|
2603
|
+
if (openspecGate.openspecPolicy === "cited") {
|
|
2604
|
+
if (openspecGate.openspecCandidatePaths.length === 0) {
|
|
2605
|
+
advisories.push("openspec 策略 cited:契约声明的 requiredReadPaths 与任务源引用均为空,prewrite gate 不强制读取 openspec;如需增强规范门禁,请在 task.json.frontendOpenspec.requiredReadPaths 声明必读路径或在任务源中显式引用 openspec 文件。");
|
|
2606
|
+
}
|
|
2607
|
+
else {
|
|
2608
|
+
advisories.push(`openspec 策略 cited:候选 ${openspecGate.openspecCandidatePaths.length} 个(declared ${openspecGate.openspecCandidateSources.declared.length} / task-source-cited ${openspecGate.openspecCandidateSources.taskSourceCited.length}),plan/review 必须在 openspec-citations 引用块中逐条引用并真实读取。`);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
else {
|
|
2612
|
+
const openspecDiscovery = sources.frontendProjectCapability?.openspecDiscovery;
|
|
2613
|
+
if (openspecDiscovery) {
|
|
2614
|
+
advisories.push(`openspec 策略 scan-strict:候选来源 auto-discovered,共 ${openspecDiscovery.candidateCount} 个,排除治理目录 ${openspecDiscovery.governanceRoot} 下 ${openspecDiscovery.excludedGovernanceCount} 个文件`);
|
|
2615
|
+
if (openspecDiscovery.truncated) {
|
|
2616
|
+
advisories.push(`openspec 候选命中安全上限 ${openspecDiscovery.maxCandidates},已截断(非静默);请缩小 spec 范围或提高上限`);
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2512
2620
|
const spec = {
|
|
2513
2621
|
version: 3,
|
|
2514
2622
|
title: `Frontend implementation DAG: ${taskConfig.title}`,
|
|
@@ -2523,6 +2631,7 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2523
2631
|
skillsByRole: FRONTEND_SKILLS_BY_ROLE,
|
|
2524
2632
|
executorModels: sources.executorModelMatrix ?? DEFAULT_DAG_EXECUTOR_MODELS,
|
|
2525
2633
|
verifyStrategy: resolveDagVerifyStrategy(taskConfig),
|
|
2634
|
+
advisories: advisories.length > 0 ? advisories : undefined,
|
|
2526
2635
|
tasks: [
|
|
2527
2636
|
{
|
|
2528
2637
|
id: "frontend-contract-pi",
|
|
@@ -2595,6 +2704,7 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2595
2704
|
sourceContext,
|
|
2596
2705
|
mockContextBlock,
|
|
2597
2706
|
frontendContractSchemaBlock,
|
|
2707
|
+
openspecCitationInstruction,
|
|
2598
2708
|
].join("\n\n"),
|
|
2599
2709
|
},
|
|
2600
2710
|
{
|
|
@@ -2617,6 +2727,7 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2617
2727
|
"Read-only: do not modify repository files.",
|
|
2618
2728
|
fixedVerificationContext,
|
|
2619
2729
|
sourceContext,
|
|
2730
|
+
mockContextBlock,
|
|
2620
2731
|
].join("\n\n"),
|
|
2621
2732
|
},
|
|
2622
2733
|
{
|
|
@@ -2651,6 +2762,8 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2651
2762
|
fixedVerificationContext,
|
|
2652
2763
|
sourceContext,
|
|
2653
2764
|
frontendContractSchemaBlock,
|
|
2765
|
+
mockContextBlock,
|
|
2766
|
+
openspecCitationInstruction,
|
|
2654
2767
|
].join("\n\n"),
|
|
2655
2768
|
},
|
|
2656
2769
|
{
|
|
@@ -2681,6 +2794,7 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2681
2794
|
"Read-only: do not modify repository files.",
|
|
2682
2795
|
fixedVerificationContext,
|
|
2683
2796
|
sourceContext,
|
|
2797
|
+
mockContextBlock,
|
|
2684
2798
|
].join("\n\n"),
|
|
2685
2799
|
},
|
|
2686
2800
|
{
|
|
@@ -2725,8 +2839,13 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2725
2839
|
outputDir: "contracts",
|
|
2726
2840
|
requireSourceFreshness: true,
|
|
2727
2841
|
implementationWriteSet: implementPaths.writeSet,
|
|
2728
|
-
|
|
2729
|
-
|
|
2842
|
+
openspecPolicy: openspecGate.openspecPolicy,
|
|
2843
|
+
openspecCandidateSources: {
|
|
2844
|
+
declared: openspecGate.openspecCandidateSources.declared,
|
|
2845
|
+
taskSourceCited: openspecGate.openspecCandidateSources.taskSourceCited,
|
|
2846
|
+
scanStrict: openspecGate.openspecCandidateSources.scanStrict,
|
|
2847
|
+
},
|
|
2848
|
+
openspecCandidatePaths: openspecGate.openspecCandidatePaths,
|
|
2730
2849
|
},
|
|
2731
2850
|
cwd: ".",
|
|
2732
2851
|
timeoutMs: 60000,
|
|
@@ -5783,7 +5902,9 @@ function applyDefaultReadOnlyRetryPolicy(spec) {
|
|
|
5783
5902
|
if (isSafeReadOnlyPiRetryCandidate(task)) {
|
|
5784
5903
|
task.retryPolicy = task.outputProtocol
|
|
5785
5904
|
? PROTOCOL_AWARE_PI_RETRY_POLICY
|
|
5786
|
-
:
|
|
5905
|
+
: task.role === "planner"
|
|
5906
|
+
? PLANNER_OUTPUT_LIMIT_RETRY_POLICY
|
|
5907
|
+
: DEFAULT_READ_ONLY_PI_RETRY_POLICY;
|
|
5787
5908
|
continue;
|
|
5788
5909
|
}
|
|
5789
5910
|
if (isWriterTransportRetryCandidate(task)) {
|
|
@@ -743,6 +743,7 @@ function findDoctorFailureNode(state) {
|
|
|
743
743
|
nodeId: state.pausedByNodeId,
|
|
744
744
|
status: node?.status,
|
|
745
745
|
rawFailureCategory: node?.failureCategory,
|
|
746
|
+
skippedReason: node?.skippedReason,
|
|
746
747
|
};
|
|
747
748
|
}
|
|
748
749
|
const errorEntry = Object.entries(state.nodes).find(([, node]) => node.status === "ERROR");
|
|
@@ -758,6 +759,7 @@ function findDoctorFailureNode(state) {
|
|
|
758
759
|
nodeId: selected[0],
|
|
759
760
|
status: selected[1].status,
|
|
760
761
|
rawFailureCategory: selected[1].failureCategory,
|
|
762
|
+
skippedReason: selected[1].skippedReason,
|
|
761
763
|
};
|
|
762
764
|
}
|
|
763
765
|
async function readRunOwnedBackendTestClassification(runDir) {
|
|
@@ -786,6 +788,7 @@ async function resolveDoctorFailureRouting(input) {
|
|
|
786
788
|
normalizedFailureCategory: "unknown",
|
|
787
789
|
nodeId: input.nodeId,
|
|
788
790
|
productLineFailureCategory: classifiedCategory,
|
|
791
|
+
skippedReason: input.skippedReason,
|
|
789
792
|
});
|
|
790
793
|
}
|
|
791
794
|
return routeDagFailure(input);
|
|
@@ -807,6 +810,7 @@ async function formatDagDoctorMarkdown(repoRoot, runId) {
|
|
|
807
810
|
rawFailureCategory,
|
|
808
811
|
normalizedFailureCategory: normalizedCategory,
|
|
809
812
|
nodeId: failure.nodeId,
|
|
813
|
+
skippedReason: failure.skippedReason,
|
|
810
814
|
});
|
|
811
815
|
const evidence = failure.nodeId
|
|
812
816
|
? path.join(located.runDir, failure.nodeId, "result.summary.md")
|
|
@@ -216,18 +216,28 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
|
|
|
216
216
|
"</retry_instruction>",
|
|
217
217
|
].join("\n");
|
|
218
218
|
}
|
|
219
|
-
if (
|
|
220
|
-
previousFailureCategory !== "output-too-large") {
|
|
219
|
+
if (previousFailureCategory !== "output-too-large") {
|
|
221
220
|
return basePrompt;
|
|
222
221
|
}
|
|
222
|
+
if (task.outputMode === "structured-required") {
|
|
223
|
+
return [
|
|
224
|
+
basePrompt,
|
|
225
|
+
"",
|
|
226
|
+
"<retry_instruction>",
|
|
227
|
+
"Previous attempt exceeded the structured output size limit.",
|
|
228
|
+
"Return only the compact structured artifact required by this node's output contract.",
|
|
229
|
+
"Do not include explanatory prose, duplicated upstream context, long evidence excerpts, or additional markdown sections.",
|
|
230
|
+
"If a fenced JSON object is required, output exactly one fenced json block and nothing else.",
|
|
231
|
+
"</retry_instruction>",
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
223
234
|
return [
|
|
224
235
|
basePrompt,
|
|
225
236
|
"",
|
|
226
237
|
"<retry_instruction>",
|
|
227
|
-
"Previous attempt exceeded the
|
|
228
|
-
"Return
|
|
229
|
-
"
|
|
230
|
-
"If a fenced JSON object is required, output exactly one fenced json block and nothing else.",
|
|
238
|
+
"Previous attempt exceeded the output size limit.",
|
|
239
|
+
"Return a minimal plan: ordered steps, narrow writeSet boundaries, and verification commands.",
|
|
240
|
+
"Drop verbatim upstream quotes, long evidence excerpts, and repeated context.",
|
|
231
241
|
"</retry_instruction>",
|
|
232
242
|
].join("\n");
|
|
233
243
|
}
|
|
@@ -441,6 +441,7 @@ export async function buildDagRunReportEntry(input) {
|
|
|
441
441
|
normalizedFailureCategory,
|
|
442
442
|
nodeId,
|
|
443
443
|
executor: node.executor,
|
|
444
|
+
skippedReason: node.skippedReason,
|
|
444
445
|
});
|
|
445
446
|
const followUp = node.status === "ERROR" || node.status === "SKIPPED"
|
|
446
447
|
? recommendFollowUpForFailureCategory(node.failureCategory)
|
|
@@ -505,6 +506,7 @@ export async function buildDagRunReportEntry(input) {
|
|
|
505
506
|
normalizedFailureCategory: normalizeDagFailureCategory(pausedNode.failureCategory, pausedNode.status),
|
|
506
507
|
failureCategory: pausedNode.failureCategory,
|
|
507
508
|
nodeId: input.state.pausedByNodeId,
|
|
509
|
+
skippedReason: pausedNode.skippedReason,
|
|
508
510
|
}
|
|
509
511
|
: firstActionableNode
|
|
510
512
|
? {
|
|
@@ -513,6 +515,7 @@ export async function buildDagRunReportEntry(input) {
|
|
|
513
515
|
normalizeDagFailureCategory(firstActionableNode.failureCategory, firstActionableNode.status),
|
|
514
516
|
failureCategory: firstActionableNode.failureCategory,
|
|
515
517
|
nodeId: firstActionableNode.nodeId,
|
|
518
|
+
skippedReason: input.state.nodes[firstActionableNode.nodeId]?.skippedReason,
|
|
516
519
|
}
|
|
517
520
|
: {
|
|
518
521
|
status: input.state.status,
|
|
@@ -529,6 +532,9 @@ export async function buildDagRunReportEntry(input) {
|
|
|
529
532
|
rawFailureCategory: runRecoverySource.failureCategory,
|
|
530
533
|
normalizedFailureCategory: runRecoverySource.normalizedFailureCategory,
|
|
531
534
|
nodeId: "nodeId" in runRecoverySource ? runRecoverySource.nodeId : undefined,
|
|
535
|
+
skippedReason: "skippedReason" in runRecoverySource
|
|
536
|
+
? runRecoverySource.skippedReason
|
|
537
|
+
: undefined,
|
|
532
538
|
});
|
|
533
539
|
const runFollowUp = input.state.status === "failed" ||
|
|
534
540
|
input.state.status === "partial_failed"
|