@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.
@@ -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 checkOpenspecReadEvidence(input) {
200
- const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
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
- normalizedCandidates.has(relativePath)) {
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
- else if (result.openspecCandidatePaths.length === 0) {
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
- if (capability.designEvidence.normativePaths.length > 0) {
2145
- parts.push(`openspec normative candidates: ${capability.designEvidence.normativePaths.slice(0, 12).join(", ")}`);
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
- openspecCandidatePaths: sources.frontendProjectCapability?.designEvidence
2729
- .normativePaths ?? [],
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
- : DEFAULT_READ_ONLY_PI_RETRY_POLICY;
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 (task.outputMode !== "structured-required" ||
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 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.",
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"