@tea-agent/loop-agent 0.39.0-beta.0 → 0.39.0-beta.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.39.0-beta.0",
4
- "gitSha": "cf0cf7e8e996da8d1c727ccc7cb8f4c52f8d5319",
5
- "builtAt": "2026-08-21T03:47:18.913Z"
3
+ "version": "0.39.0-beta.2",
4
+ "gitSha": "cf766ed6e83a3498e13744ffd944ac10d7b1aeca",
5
+ "builtAt": "2026-08-21T07:43:32.268Z"
6
6
  }
@@ -41,9 +41,6 @@ export function serializeDeterministicJson(value) {
41
41
  export function deterministicSha256(value) {
42
42
  return sha256Hex(serializeDeterministicJson(value));
43
43
  }
44
- /** Provider-safe limits for the two planner outputs. Larger work must split. */
45
- export const FRONTEND_PLAN_RESPONSE_MAX_BYTES = 14 * 1024;
46
- export const FRONTEND_CANONICAL_CONTRACT_MAX_BYTES = 16 * 1024;
47
44
  /** A contract materialization failure carries its classification plus the
48
45
  * audit context collected up to the failure point, so the prewrite gate can
49
46
  * persist a complete frontend-prewrite-result-v1 even when the canonical
@@ -1718,8 +1715,13 @@ async function dropUnresolvedVerificationSymbols(input) {
1718
1715
  }
1719
1716
  return changed;
1720
1717
  }
1721
- function extractSingleOpenspecCitationsBlock(text) {
1722
- const blocks = text.match(/```openspec-citations[ \t]*\r?\n[\s\S]*?\r?\n```/g) ?? [];
1718
+ function extractSingleOpenspecCitationsBlock(text, citationsRequired = true) {
1719
+ const blocks = [...text.matchAll(/^```openspec-citations[ \t]*\r?\n([\s\S]*?)^```[ \t]*$/gim)].map((match) => match[0]);
1720
+ if (!citationsRequired) {
1721
+ if (blocks.length === 0)
1722
+ return "";
1723
+ throw new Error("frontend plan output must not include an openspec-citations block when the generation-frozen OpenSpec candidate set is empty");
1724
+ }
1723
1725
  if (blocks.length !== 1) {
1724
1726
  throw new Error(`frontend plan patch output must include exactly one fenced openspec-citations block (found ${blocks.length})`);
1725
1727
  }
@@ -1731,8 +1733,8 @@ function extractSingleOpenspecCitationsBlock(text) {
1731
1733
  * separate so a citation row can never become a competing contract candidate.
1732
1734
  * A bare JSON payload is retained only for historical node-output compatibility.
1733
1735
  */
1734
- export function extractFrontendPlanPayload(text) {
1735
- const citationsBlock = extractSingleOpenspecCitationsBlock(text);
1736
+ export function extractFrontendPlanPayload(text, options) {
1737
+ const citationsBlock = extractSingleOpenspecCitationsBlock(text, options?.citationsRequired !== false);
1736
1738
  return {
1737
1739
  payload: extractFrontendPlanPrimaryPayload(text.replace(citationsBlock, "")),
1738
1740
  citationsBlock,
@@ -1796,21 +1798,20 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1796
1798
  });
1797
1799
  };
1798
1800
  try {
1799
- if (Buffer.byteLength(input.text, "utf8") > FRONTEND_PLAN_RESPONSE_MAX_BYTES) {
1800
- throw new Error(`frontend plan response exceeds the ${FRONTEND_PLAN_RESPONSE_MAX_BYTES}-byte limit`);
1801
- }
1802
1801
  if (!input.sourceBinding)
1803
1802
  throw new Error("frontend plan patch validator requires DAG sourceBinding");
1804
1803
  const skeleton = input.structuredContractOutput?.skeleton;
1805
1804
  if (!skeleton)
1806
1805
  throw new Error("frontend plan patch validator requires a deterministic runtime skeleton");
1806
+ // Preserve the raw primary candidate even if the separately parsed
1807
+ // citation fence is malformed; the retry report is an audit artifact.
1807
1808
  const patch = extractFrontendPlanPrimaryPayload(input.text);
1808
1809
  if (!isPlainObject(patch))
1809
1810
  throw new Error("frontend plan patch must extract to one JSON object");
1810
1811
  extractedPatchPath = path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`);
1811
1812
  const extractedArtifact = await writeDeterministicJsonArtifact(input.runDir, extractedPatchPath, patch);
1812
1813
  extractedPatchSha256 = extractedArtifact.sha256;
1813
- const citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1814
+ const citationsBlock = extractSingleOpenspecCitationsBlock(input.text, input.structuredContractOutput?.citationsRequired !== false);
1814
1815
  const citationsArtifactPath = path.posix.join(candidateDir, `attempt-${attempt}.openspec-citations.md`);
1815
1816
  await writeTextArtifactFile(path.join(input.runDir, citationsArtifactPath), `${citationsBlock}\n`);
1816
1817
  const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
@@ -1826,10 +1827,6 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1826
1827
  rawContractText: serializeDeterministicJson(merged),
1827
1828
  sourceBinding: input.sourceBinding,
1828
1829
  });
1829
- if (Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8") >
1830
- FRONTEND_CANONICAL_CONTRACT_MAX_BYTES) {
1831
- throw new Error(`frontend canonical contract exceeds the ${FRONTEND_CANONICAL_CONTRACT_MAX_BYTES}-byte limit; split the task`);
1832
- }
1833
1830
  const normalizedArtifact = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.normalized.json`), analysis.canonical);
1834
1831
  await writeReport({
1835
1832
  classification: "accepted-normalized",
@@ -1907,9 +1904,10 @@ export async function validateFrontendContractNodeOutput(input) {
1907
1904
  /**
1908
1905
  * Node-output self-check for the plan revision node, which emits an RFC 7386
1909
1906
  * merge-patch delta against the original contract instead of a full contract.
1910
- * Deliberately loose and format-level: the output must contain exactly one
1911
- * ```openspec-citations fenced block and must extract (after the citations
1912
- * blocks are stripped) to exactly one JSON object. The delta carries no
1907
+ * Deliberately loose and format-level: when OpenSpec candidates exist, the
1908
+ * output contains exactly one ```openspec-citations fenced block; otherwise it
1909
+ * contains no citation fence. It must extract (after the citations block is
1910
+ * stripped) to exactly one JSON object. The delta carries no
1913
1911
  * schemaVersion/targets, so no full-contract schema/semantic checks run here;
1914
1912
  * content parsing, patch application, and merged-contract validation stay with
1915
1913
  * the prewrite gate, which remains the only authority.
@@ -1924,16 +1922,12 @@ export async function validateFrontendRevisionPatchNodeOutput(input) {
1924
1922
  attempt,
1925
1923
  text: input.text,
1926
1924
  });
1927
- if (Buffer.byteLength(input.text, "utf8") > FRONTEND_PLAN_RESPONSE_MAX_BYTES) {
1928
- return {
1929
- ok: false,
1930
- reason: `frontend revision response exceeds the ${FRONTEND_PLAN_RESPONSE_MAX_BYTES}-byte limit`,
1931
- };
1932
- }
1933
1925
  let citationsBlock;
1934
1926
  let patch;
1935
1927
  try {
1936
- const parsed = extractFrontendPlanPayload(input.text);
1928
+ const parsed = extractFrontendPlanPayload(input.text, {
1929
+ citationsRequired: input.structuredContractOutput?.citationsRequired,
1930
+ });
1937
1931
  citationsBlock = parsed.citationsBlock;
1938
1932
  patch = parsed.payload;
1939
1933
  }
@@ -4,15 +4,13 @@ import { z } from "zod";
4
4
  import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
5
  import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
6
6
  import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
7
- import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, FRONTEND_CANONICAL_CONTRACT_MAX_BYTES, applyFrontendImplementationContractPatch, extractFrontendImplementationJson, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
7
+ import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, applyFrontendImplementationContractPatch, extractFrontendImplementationJson, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
8
  import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
9
9
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
10
10
  import { pathMatchesPattern } from "../../shared/git-progress.js";
11
11
  import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
12
12
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
13
13
  export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
14
- /** Canonical contract serialization must stay at or below this byte size. */
15
- export const FRONTEND_CONTRACT_MAX_BYTES = FRONTEND_CANONICAL_CONTRACT_MAX_BYTES;
16
14
  /**
17
15
  * Machine-readable failure classification for blocked/retryable-invalid
18
16
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -39,7 +37,6 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
39
37
  "component-spec-reference-invalid",
40
38
  "component-spec-not-cited",
41
39
  "component-choices-missing",
42
- "contract-too-large",
43
40
  "revision-patch-unmergeable",
44
41
  ]);
45
42
  export const frontendPrewriteResultV1Schema = z
@@ -732,21 +729,6 @@ export async function runFrontendPrewriteGate(input) {
732
729
  }
733
730
  throw error;
734
731
  }
735
- // Size guard: a canonical contract above 64KB is a retryable-invalid defect
736
- // (compress the contract) rather than a writer authorization failure.
737
- const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
738
- if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
739
- return finalizePrewrite(input, {
740
- ...basePending,
741
- verdict,
742
- candidateJsonSha256: analysis.candidateJsonSha256,
743
- normalizationActions: [],
744
- mockStrategy: analysis.canonical.mockApi.strategy,
745
- classification: "retryable-invalid",
746
- failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
747
- failureCode: "contract-too-large",
748
- });
749
- }
750
732
  const mockStrategy = analysis.canonical.mockApi.strategy;
751
733
  if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
752
734
  const quotedAllowed = input.config.allowedMockStrategies
@@ -1530,6 +1530,21 @@ function buildSourceContextBlock(sources) {
1530
1530
  }
1531
1531
  return parts.join("\n\n");
1532
1532
  }
1533
+ /**
1534
+ * Planner/reviewer nodes already receive the contract/scout artifacts. They
1535
+ * only need stable task-source pointers for an unresolved or conflicting
1536
+ * field, not a second inline copy of every fact-source document.
1537
+ */
1538
+ function buildArtifactFirstSourceAccessBlock(sources) {
1539
+ const binding = buildDagSourceBinding(sources);
1540
+ return [
1541
+ "## Bound task-source access (read only when upstream artifacts are missing or conflict)",
1542
+ ...binding.sources.map((source) => `- ${source.kind}: ${source.path}`),
1543
+ "Use these exact repository-readable paths. Do not search for substitutes or read runtime implementation code to diagnose a format retry.",
1544
+ `- taskId: ${binding.taskId}`,
1545
+ `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none)"}`,
1546
+ ].join("\n");
1547
+ }
1533
1548
  async function loadMaterializedSourceReferences(sourceDir) {
1534
1549
  const referenceDir = path.join(sourceDir, REFERENCE_DIRECTORY);
1535
1550
  const referencePaths = [];
@@ -2523,7 +2538,8 @@ async function buildFrontendHybridDagFromTask(sources) {
2523
2538
  "Emit only editable fields: requirements[], implementationSteps[], targets.routes/publicApiChanges, uiStates[], interactions[], mockApi.strategy/activation/endpoints, verificationTargets[], designEvidence, evidenceGaps[], stylingStrategy, uiComponentChoices[], dependencyPolicy, residualRisks[], realIntegrationGap.",
2524
2539
  "Protected and forbidden: schemaVersion, sourceBinding, riskLevel, targets.files, mockApi.productionDefaultOff, schemaId, targetFiles, requirementCoverage.",
2525
2540
  "Invariants: every requirement has expectedOutcome; every interaction has trigger + expectedBehavior; applicable uiStates have expectedBehavior + implementationTargets + verificationTargetIds; non-applicable uiStates have notApplicableReason; commandLabel is frozen; mockApi production default remains off.",
2526
- "Enums: mock strategy native|browser-intercept|request-adapter|not-needed; uiComponentChoices decision specified|reuse-existing|new. Omit optional empty strings and unchanged revision fields.",
2541
+ "Each behavior belongs to one primary row: requirements cover acceptance, uiStates cover only distinct visible states, and interactions cover only distinct user triggers. Link with IDs; do not restate the same prose in all three. Steps are action plus files, not a prose mirror. Omit optional fields when upstream contract/scout facts already settle them, and omit unchanged revision fields.",
2542
+ "Enums: mock strategy native|browser-intercept|request-adapter|not-needed; uiComponentChoices decision specified|reuse-existing|new. Omit optional empty strings.",
2527
2543
  ].join("\n");
2528
2544
  const sourceContext = [
2529
2545
  buildSourceContextBlock(sources),
@@ -2531,24 +2547,20 @@ async function buildFrontendHybridDagFromTask(sources) {
2531
2547
  ]
2532
2548
  .filter(Boolean)
2533
2549
  .join("\n\n");
2550
+ const artifactFirstSourceContext = [
2551
+ buildArtifactFirstSourceAccessBlock(sources),
2552
+ capabilityContextBlock,
2553
+ ]
2554
+ .filter(Boolean)
2555
+ .join("\n\n");
2534
2556
  const hasMockVerifyCommands = (taskConfig.frontendMock?.verifyCommands.length ?? 0) > 0 ||
2535
2557
  mockCapability.verifyCommands.length > 0;
2536
2558
  const requirementIds = frontendSourceBinding.requirementIds;
2537
2559
  const requirementCoverageInstruction = requirementIds.length > 0
2538
2560
  ? [
2539
- `## Requirement Coverage (per-AC echo with bad/good examples)`,
2540
- `For each requirement ID below, echo the ID verbatim and confirm: expectedOutcome (user-observable or logic-observable), implementation targets (files), and verification targets (commandLabel + file).`,
2541
- `Do not skip any ID. Use the bad/good patterns below as reference for each field.`,
2542
- ``,
2543
- `Bad example (empty expectedOutcome, empty targets -- REJECTED at contract materialization):`,
2544
- `- AC-001: expectedOutcome="" implementationTargets=[] verificationTargets=[]`,
2545
- ``,
2546
- `Good example (concrete expectedOutcome, real files, real verification targets):`,
2547
- `- AC-001: expectedOutcome="TypeScript compilation exits with code 0 and produces no errors in dist/" implementationTargets=["src/app.tsx"] verificationTargets=["vt-typecheck":"npm run typecheck","tsconfig.json"]`,
2548
- ``,
2549
- ...requirementIds.map((id) => `- ${id}: [expectedOutcome] [implementation files] [verification targets]`),
2550
- ``,
2551
- `Every requirement MUST have a non-empty expectedOutcome. Every interaction MUST have non-empty trigger and expectedBehavior. UI states with applicable=true MUST have non-empty expectedBehavior. Empty strings or omitted fields for these will cause contract rejection.`,
2561
+ "## Requirement Coverage",
2562
+ `Cover each frozen ID exactly once in requirements[]: ${requirementIds.join(", ")}. Each item needs a concrete expectedOutcome, implementationTargets, and verificationTargetIds.`,
2563
+ "Interactions need trigger + expectedBehavior; applicable uiStates need expectedBehavior + implementationTargets + verificationTargetIds. Do not use empty strings.",
2552
2564
  ].join("\n")
2553
2565
  : "";
2554
2566
  const verificationTargetFileInstruction = [
@@ -2562,6 +2574,11 @@ async function buildFrontendHybridDagFromTask(sources) {
2562
2574
  "Before producing this contract/plan, use the Pi read tool to read the FULL bound source files (需求.md, 执行约束.md, and every `references/*` Bound readPath listed above) — the inline copies above may be truncated excerpts, and requirement/acceptance references are authoritative only in their full form.",
2563
2575
  "Do not drop scope fields, acceptance criteria, non-goals, UI states, or column/field definitions that exist in the full sources but are absent from the inline excerpts; if a field appears in the full source, it belongs in the contract.",
2564
2576
  ].join("\n");
2577
+ const plannerSourceReadInstruction = [
2578
+ "## Source-read policy",
2579
+ "Use frontend-contract-pi and frontend-scout-pi artifacts as primary evidence. Do not repeat reads that those facts already settle.",
2580
+ "Read a bound source only to resolve a missing or conflicting field; read every applicable OpenSpec candidate directly before citing it. Never infer omitted task fields from a preview.",
2581
+ ].join("\n");
2565
2582
  const strategy = resolveDagVerifyStrategy(taskConfig);
2566
2583
  const readOnlyPaths = taskConfig.allowedPaths.length > 0 ? taskConfig.allowedPaths : ["**"];
2567
2584
  const behaviorPaths = deriveFrontendBehaviorPaths(taskConfig);
@@ -2710,6 +2727,17 @@ async function buildFrontendHybridDagFromTask(sources) {
2710
2727
  ...(classified?.theme ?? []),
2711
2728
  ...(classified?.rule.components ?? []),
2712
2729
  ].filter((value, index, array) => array.indexOf(value) === index).sort();
2730
+ const frontendPlanCitationsRequired = openspecGate.openspecCandidatePaths.length > 0 ||
2731
+ componentSpecCandidatePaths.length > 0;
2732
+ const frontendPlanCitationInstruction = frontendPlanCitationsRequired
2733
+ ? openspecCitationInstruction
2734
+ : "The generation-frozen OpenSpec/component candidate set is empty. Do not read, cite, or emit an openspec-citations fence; output only the primary JSON patch. The runtime records an empty citation artifact deterministically.";
2735
+ const frontendPlanOutputContract = frontendPlanCitationsRequired
2736
+ ? "JSON-only patch output: exactly ONE fenced json object (```json ... ```) containing only the editable RFC 7386 plan patch for the runtime contract skeleton, immediately followed by exactly one ```openspec-citations``` fenced citation block. Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. The runtime applies the patch, validates it, and promotes canonical full-contract JSON for downstream review. Do NOT emit a full contract, Markdown plan explanation, raw JSON, or any other fenced block. No file writes."
2737
+ : "JSON-only patch output: exactly ONE fenced json object (```json ... ```) containing only the editable RFC 7386 plan patch for the runtime contract skeleton. The generation-frozen OpenSpec candidate set is empty, so do NOT emit an openspec-citations fence. Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. The runtime applies the patch, validates it, and promotes canonical full-contract JSON for downstream review. Do NOT emit a full contract, Markdown plan explanation, raw JSON, or any other fenced block. No file writes.";
2738
+ const frontendRevisionOutputContract = frontendPlanCitationsRequired
2739
+ ? "When the initial design review requests revision, return exactly ONE fenced json object (```json ... ```) containing an RFC 7386 merge-patch delta against the original frontend-implementation-contract-v1 (only the fields you change; null deletes a key; arrays and scalars replace; plain objects merge recursively), immediately followed by exactly one ```openspec-citations``` fenced citation block. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only; the gate applies the patch on the original contract and renders plan.md deterministically. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes."
2740
+ : "JSON-only output: when the initial design review requests revision, return exactly ONE fenced json object (```json ... ```) containing an RFC 7386 merge-patch delta against the original frontend-implementation-contract-v1 (only the fields you change; null deletes a key; arrays and scalars replace; plain objects merge recursively). The generation-frozen OpenSpec candidate set is empty, so do NOT emit an openspec-citations fence. Do NOT emit a full contract, Markdown explanation, prose, or another fenced block. No file writes.";
2713
2741
  if (openspecGate.openspecPolicy === "cited") {
2714
2742
  if (openspecGate.openspecCandidatePaths.length === 0) {
2715
2743
  advisories.push("openspec 策略 cited:契约声明的 requiredReadPaths 与任务源引用均为空,prewrite gate 不强制读取 openspec;如需增强规范门禁,请在 task.json.frontendOpenspec.requiredReadPaths 声明必读路径或在任务源中显式引用 openspec 文件。");
@@ -2795,11 +2823,12 @@ async function buildFrontendHybridDagFromTask(sources) {
2795
2823
  schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID,
2796
2824
  retryOnInvalid: true,
2797
2825
  skeleton: frontendContractSkeleton,
2826
+ citationsRequired: frontendPlanCitationsRequired,
2798
2827
  },
2799
2828
  allowedPaths: readOnlyPaths,
2800
2829
  forbiddenPaths,
2801
2830
  skills: [],
2802
- outputContract: "JSON-only patch output: one-line lead-in, then exactly ONE fenced json object (```json ... ```) containing only the editable RFC 7386 plan patch for the runtime contract skeleton. Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. Immediately after it, append exactly one ```openspec-citations``` fenced citation block. The runtime applies the patch, validates it, and promotes canonical full-contract JSON for downstream review. Do NOT emit a full contract, Markdown plan explanation, raw JSON, or any other fenced block. No file writes.",
2831
+ outputContract: frontendPlanOutputContract,
2803
2832
  subtask_prompt: [
2804
2833
  "Use frontend-contract-pi, frontend-scout-pi, task sources, and the generation-time Mock capability evidence to fill the runtime-owned frontend contract skeleton. Return JSON-only output containing only an editable RFC 7386 plan patch. The runtime already owns schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff; omit those protected paths even when their values look obvious.",
2805
2834
  "The patch fields become the complete implementation plan after deterministic merge. Do not produce a separate plan document, prose mirror, or full contract.",
@@ -2807,19 +2836,22 @@ async function buildFrontendHybridDagFromTask(sources) {
2807
2836
  "Encode ordered steps (implementationSteps), target files, UI state handling, styling/component strategy (stylingStrategy), interaction notes, Mock/API strategy, dependency policy (dependencyPolicy), deterministic verification entrypoints, Real Integration Gap (realIntegrationGap), and residual risks (residualRisks) into the contract JSON fields. Use only the fixed entrypoints below; implementation may add tests behind them but cannot replace them.",
2808
2837
  "Every target file and verification target must be selected from the current target workspace and task scope. Do not reuse paths or symbols from examples, prior tasks, or loop-agent itself; if the project uses app/, packages/, spec/, __tests__, or another layout, preserve that layout.",
2809
2838
  "Consume the Scout TARGET_SURFACE evidence before selecting files. Preserve the discovered existing entrypoint and data source. If implementationPaths or testPaths are outside task allowedPaths, record a blocking scope conflict; do not substitute a new page or silently broaden the writeSet.",
2810
- "Output in this exact order: (1) exactly one fenced json object containing the editable plan patch; (2) exactly one openspec-citations citation fenced block appended immediately after it. Do NOT emit protected skeleton fields, a full contract, Markdown plan explanation, raw JSON, or any other fenced block.",
2839
+ frontendPlanCitationsRequired
2840
+ ? "Output exactly two adjacent blocks: (1) one fenced json object containing the editable plan patch; (2) one openspec-citations citation block."
2841
+ : "Output exactly one fenced json object containing the editable plan patch; do not emit an openspec-citations block.",
2842
+ "Do NOT emit protected skeleton fields, a full contract, Markdown plan explanation, raw JSON, or any other fenced block.",
2811
2843
  "Each requirement must state its user-observable or logic-observable expectedOutcome. Each interaction must state its trigger and expectedBehavior. IDs plus file paths are not sufficient behavior semantics.",
2812
2844
  requirementCoverageInstruction,
2813
2845
  "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2814
2846
  verificationTargetFileInstruction,
2815
2847
  "Read-only: do not modify code, docs, artifacts, or repository files.",
2816
- mandatorySourceReadInstruction,
2848
+ plannerSourceReadInstruction,
2817
2849
  fixedVerificationContext,
2818
- sourceContext,
2850
+ artifactFirstSourceContext,
2819
2851
  mockContextBlock,
2820
2852
  frontendPlanFieldGuide,
2821
2853
  frontendComponentConformanceInstruction,
2822
- openspecCitationInstruction,
2854
+ frontendPlanCitationInstruction,
2823
2855
  ].join("\n\n"),
2824
2856
  },
2825
2857
  {
@@ -2843,7 +2875,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2843
2875
  "Component selection conformance is a hard blocking condition: VERDICT: request-revision when the frontend spec (component/theme/rule.components bucket) already defines a component for a purpose but the plan selects another or self-invents one without a declared deviation; when uiComponentChoices is missing/empty for UI-visible work while the frozen component/theme bucket is non-empty; or when any uiComponentChoices specReference.path is not cited in the openspec-citations block or has no successful read event.",
2844
2876
  "Read-only: do not modify repository files.",
2845
2877
  fixedVerificationContext,
2846
- sourceContext,
2878
+ artifactFirstSourceContext,
2847
2879
  frontendContractFieldSummary,
2848
2880
  mockContextBlock,
2849
2881
  ].join("\n\n"),
@@ -2862,28 +2894,32 @@ async function buildFrontendHybridDagFromTask(sources) {
2862
2894
  structuredContractOutput: {
2863
2895
  schemaId: "frontend-implementation-contract-revision-patch-v1",
2864
2896
  retryOnInvalid: true,
2897
+ citationsRequired: frontendPlanCitationsRequired,
2865
2898
  },
2866
2899
  allowedPaths: readOnlyPaths,
2867
2900
  forbiddenPaths,
2868
2901
  skills: [],
2869
- outputContract: "When the initial design review requests revision, return a one-line lead-in followed by exactly ONE fenced json object (```json ... ```) containing an RFC 7386 merge-patch delta against the original frontend-implementation-contract-v1 (only the fields you change; null deletes a key; arrays and scalars replace; plain objects merge recursively). Immediately after it, append exactly one ```openspec-citations``` fenced citation block. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only; the gate applies the patch on the original contract and renders plan.md deterministically. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes.",
2902
+ outputContract: frontendRevisionOutputContract,
2870
2903
  subtask_prompt: [
2871
- "Consume frontend-plan-pi (original contract JSON) and frontend-design-review-pi (first design review findings).",
2904
+ "Consume the hash-bound frontend-plan-pi contract artifact and frontend-design-review-pi findings. Read the artifact only for fields affected by a Required Plan Correction.",
2872
2905
  "This node runs only when frontend-design-review-pi emitted VERDICT: request-revision. Produce an RFC 7386 merge-patch delta against the original contract JSON that addresses every Required Plan Correction from the design findings.",
2873
2906
  "The patch delta may update editable contract fields such as requirements, implementationSteps, targets.routes/publicApiChanges, uiStates, interactions, mockApi.strategy/activation/endpoints, dependencyPolicy, stylingStrategy, uiComponentChoices, verificationTargets, evidenceGaps, residualRisks, and realIntegrationGap. It must not modify protected schemaVersion, sourceBinding, riskLevel, targets.files, or mockApi.productionDefaultOff. Only include fields you change; omit unchanged fields (the gate applies the patch on the original contract). null deletes a key; arrays and scalars replace; plain objects merge recursively.",
2874
- requirementCoverageInstruction,
2907
+ "Only include changed fields. For a changed requirement, preserve its expectedOutcome, implementationTargets, and verificationTargetIds; do not resend unchanged requirements.",
2875
2908
  "Do not turn MOCK_STRATEGY: blocked into an implementable strategy without new repository or contract evidence that resolves every blocker.",
2876
2909
  "Read-only: do not modify code, docs, artifacts, or repository files. This node revises the plan only.",
2877
- "Output in this exact order: (1) exactly one fenced json object containing the merge-patch delta — this is the only plan output the prewrite gate applies on the original contract; (2) exactly one openspec-citations citation fenced block appended immediately after it. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only. Do not emit any raw JSON or JSON objects in prose. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block. Do not include secrets or unsafe paths.",
2910
+ frontendPlanCitationsRequired
2911
+ ? "Output exactly two adjacent blocks: (1) one fenced json object containing the merge-patch delta; (2) one openspec-citations citation block."
2912
+ : "JSON-only: output exactly one fenced json object containing the merge-patch delta; do not emit an openspec-citations block.",
2913
+ "Do NOT emit a full contract, Markdown explanation, prose, raw JSON or JSON objects outside the fenced payload, secrets, or unsafe paths.",
2878
2914
  "Preserve each requirement expectedOutcome and each interaction trigger/expectedBehavior in the effective (merged) contract; do not reduce behavior semantics to IDs and paths.",
2879
2915
  "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2880
2916
  verificationTargetFileInstruction,
2881
2917
  fixedVerificationContext,
2882
- sourceContext,
2918
+ artifactFirstSourceContext,
2883
2919
  frontendPlanFieldGuide,
2884
2920
  mockContextBlock,
2885
2921
  frontendComponentConformanceInstruction,
2886
- openspecCitationInstruction,
2922
+ frontendPlanCitationInstruction,
2887
2923
  ].join("\n\n"),
2888
2924
  },
2889
2925
  {
@@ -2918,7 +2954,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2918
2954
  "Also request revision for component selection non-conformance: spec-defined components silently replaced or self-invented without a declared deviation, uiComponentChoices missing for UI-visible work, or a uiComponentChoices specReference.path not cited in the openspec-citations block / not actually read.",
2919
2955
  "Read-only: do not modify repository files.",
2920
2956
  fixedVerificationContext,
2921
- sourceContext,
2957
+ artifactFirstSourceContext,
2922
2958
  frontendContractFieldSummary,
2923
2959
  mockContextBlock,
2924
2960
  ].join("\n\n"),
@@ -129,17 +129,24 @@ export function buildNodePrompt(spec, task, upstream, options) {
129
129
  convergenceFeedback: options?.convergenceFeedback,
130
130
  });
131
131
  }
132
- function frontendStructuredArtifactRetryGuidance(schemaId) {
132
+ function frontendStructuredArtifactRetryGuidance(schemaId, citationsRequired = true) {
133
+ const format = citationsRequired
134
+ ? "one fenced json block immediately followed by one fenced openspec-citations block"
135
+ : "one fenced json block and no openspec-citations block";
133
136
  if (schemaId === "frontend-implementation-contract-plan-patch-v1") {
134
137
  return [
135
- "Return exactly this artifact and nothing else: one short lead-in line, then exactly one fenced json block containing only the editable RFC 7386 plan patch, immediately followed by exactly one fenced openspec-citations block.",
138
+ citationsRequired
139
+ ? "Return exactly one fenced json block containing only the editable RFC 7386 plan patch, immediately followed by one fenced openspec-citations block, and nothing else."
140
+ : `Return exactly ${format} and nothing else; the json block contains only the editable RFC 7386 plan patch.`,
136
141
  "The runtime applies your patch to its protected contract skeleton. Do not emit schemaVersion, sourceBinding, riskLevel, targets.files, or mockApi.productionDefaultOff; do not emit the full contract.",
137
142
  "Do not emit Markdown headings, bullets, plan prose, explanations, raw JSON, or any other fenced block.",
138
143
  ];
139
144
  }
140
145
  if (schemaId === "frontend-implementation-contract-revision-patch-v1") {
141
146
  return [
142
- "Return exactly this artifact and nothing else: one short lead-in line, then exactly one fenced json block containing the RFC 7386 merge-patch delta, immediately followed by exactly one fenced openspec-citations block.",
147
+ citationsRequired
148
+ ? "Return exactly one fenced json block containing the RFC 7386 merge-patch delta, immediately followed by one fenced openspec-citations block, and nothing else."
149
+ : `Return exactly ${format} and nothing else; the json block contains the RFC 7386 merge-patch delta.`,
143
150
  "The delta contains only the fields you change; null deletes a key, arrays and scalars replace, and plain objects merge recursively. Do not emit a full contract or a schemaVersion/targets section.",
144
151
  "Do not emit Markdown headings, bullets, plan prose, explanations, raw JSON, or any other fenced block.",
145
152
  ];
@@ -149,6 +156,27 @@ function frontendStructuredArtifactRetryGuidance(schemaId) {
149
156
  "The contract JSON fields are the complete implementation plan. Do not emit a separate plan document, Markdown headings, bullets, plan prose, explanations, raw JSON, or any other fenced block.",
150
157
  ];
151
158
  }
159
+ function isFrontendPlanArtifactSchema(schemaId) {
160
+ return (schemaId === "frontend-implementation-contract-plan-patch-v1" ||
161
+ schemaId === "frontend-implementation-contract-revision-patch-v1");
162
+ }
163
+ function buildFrontendPlanArtifactRecoveryPrompt(options) {
164
+ const cause = options.failure === "truncated"
165
+ ? "Previous response was truncated by the provider (stopReason=length)."
166
+ : options.failure === "too-large"
167
+ ? "Previous response could not be retained by the provider."
168
+ : "Previous response failed structured validation.";
169
+ return [
170
+ "<retry_instruction>",
171
+ cause,
172
+ options.reason ? `Validation error: ${options.reason}` : "",
173
+ ...frontendStructuredArtifactRetryGuidance(options.schemaId, options.citationsRequired),
174
+ "Use concise values and reference paths/IDs instead of copying source prose. Do not omit required semantics.",
175
+ "</retry_instruction>",
176
+ ]
177
+ .filter(Boolean)
178
+ .join("\n");
179
+ }
152
180
  function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics) {
153
181
  if (attemptNumber <= 1)
154
182
  return basePrompt;
@@ -164,6 +192,14 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
164
192
  if (previousFailureCategory === "invalid-output" &&
165
193
  task.structuredContractOutput &&
166
194
  previousProtocolReason) {
195
+ if (isFrontendPlanArtifactSchema(task.structuredContractOutput.schemaId)) {
196
+ return buildFrontendPlanArtifactRecoveryPrompt({
197
+ failure: "invalid",
198
+ reason: previousProtocolReason,
199
+ schemaId: task.structuredContractOutput.schemaId,
200
+ citationsRequired: task.structuredContractOutput.citationsRequired,
201
+ });
202
+ }
167
203
  return [
168
204
  basePrompt,
169
205
  "",
@@ -178,22 +214,13 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
178
214
  if (previousFailureCategory === "structured-output-truncated" &&
179
215
  task.structuredContractOutput) {
180
216
  const schemaId = task.structuredContractOutput.schemaId;
181
- if (schemaId === "frontend-implementation-contract-plan-patch-v1" ||
182
- schemaId === "frontend-implementation-contract-revision-patch-v1") {
183
- const payloadKind = schemaId === "frontend-implementation-contract-plan-patch-v1"
184
- ? "editable plan patch"
185
- : "RFC 7386 revision patch";
186
- return [
187
- "<retry_instruction>",
188
- "Previous response was truncated (stopReason=length).",
189
- previousProtocolReason ? `Validation error: ${previousProtocolReason}` : "",
190
- `Return one short lead-in, one complete fenced json ${payloadKind}, then one fenced openspec-citations block.`,
191
- "Protected: schemaVersion, sourceBinding, riskLevel, targets.files, mockApi.productionDefaultOff. Required semantics: requirement expectedOutcome; interaction trigger + expectedBehavior; frozen verification command labels.",
192
- "No prose, examples, schema copy, Markdown headings, raw JSON, or other fences.",
193
- "</retry_instruction>",
194
- ]
195
- .filter(Boolean)
196
- .join("\n");
217
+ if (isFrontendPlanArtifactSchema(schemaId)) {
218
+ return buildFrontendPlanArtifactRecoveryPrompt({
219
+ failure: "truncated",
220
+ reason: previousProtocolReason,
221
+ schemaId,
222
+ citationsRequired: task.structuredContractOutput.citationsRequired,
223
+ });
197
224
  }
198
225
  return [
199
226
  basePrompt,
@@ -271,6 +298,13 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
271
298
  }
272
299
  if (task.outputMode === "structured-required") {
273
300
  if (task.structuredContractOutput) {
301
+ if (isFrontendPlanArtifactSchema(task.structuredContractOutput.schemaId)) {
302
+ return buildFrontendPlanArtifactRecoveryPrompt({
303
+ failure: "too-large",
304
+ schemaId: task.structuredContractOutput.schemaId,
305
+ citationsRequired: task.structuredContractOutput.citationsRequired,
306
+ });
307
+ }
274
308
  return [
275
309
  basePrompt,
276
310
  "",
@@ -782,6 +782,10 @@ export const dagTaskSchema = z.object({ id: z.string().regex(/^[a-z][a-z0-9-]*$/
782
782
  * this run-owned skeleton before strict contract validation.
783
783
  */
784
784
  skeleton: z.record(z.string(), z.unknown()).optional(),
785
+ /** Frontend plan/revision citation fence is only needed for a
786
+ * generation-frozen OpenSpec candidate set. Undefined preserves the
787
+ * historical required-fence protocol. */
788
+ citationsRequired: z.boolean().optional(),
785
789
  })
786
790
  .strict()
787
791
  .superRefine((value, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.39.0-beta.0",
3
+ "version": "0.39.0-beta.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",