@tea-agent/loop-agent 0.39.0-next.3 → 0.39.0-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  ### 新增
6
6
 
7
+ - 前端 plan 唯一机器契约化(JSON-only):`frontend-plan-pi` / `frontend-plan-revision-pi` 输出收敛为「一行导语 + 唯一 fenced `json` 契约 + 唯一 `openspec-citations` 引用块」,彻底移除「可选 Markdown 说明」;plan.md(`contracts/frontend-plan.md`)改由 prewrite gate 用纯函数 `renderFrontendPlanMarkdown` 确定性渲染(不新增 DAG 节点/不新增 Pi 调用)
8
+ - 契约 schema 新增 5 个可选字段(`implementationSteps` / `stylingStrategy` / `dependencyPolicy` / `residualRisks` / `realIntegrationGap`),`schemaVersion` 保持 1;`docs/templates/frontend-implementation-contract.schema.json` 同步
9
+ - revision 输出改为 RFC 7386 merge-patch delta + 引用块:gate 配置新增 `revisionPatch`,生效时在 `planFallbackFromNodeIds[0]` 原始契约上应用 `applyFrontendImplementationContractPatch` 后进入既有分析/规范化/物化管线;旧 DAG 缺字段按整契约处理
10
+ - canonical contract 序列化(UTF-8)超过 64KB → `retryable-invalid` + 新增 `contract-too-large` failureCode(体积守卫兜底)
11
+ - `design-review` / `final-design-review` 提示改为消费契约 JSON(注入契约字段摘要),不再要求读取 plan prose
12
+
7
13
  - `frontend-prewrite-gate` 的 OpenSpec 读取校验支持 `task.json.frontendOpenspec` 配置:`policy` 缺省 `cited`(候选 = `requiredReadPaths` ∪ 任务源显式引用的 openspec 路径),`scan-strict` 保留止血任务后的全量必读语义;`requiredReadPaths` 中非 openspec 支持路径在生成期确定性失败
8
14
  - 确定性引用块协议:生效 plan 输出在 fenced `json` 契约块后追加 `openspec-citations` fenced 块(每行一个 JSON `{"path","section","line"}`);gate 解析并与生效 plan/review 节点的成功 read 事件核验,新增 `openspec-citation-block-unparseable` / `openspec-not-cited` / `openspec-citation-not-read` 三个 retryable-invalid failureCode(防捏造、防遗漏)
9
15
  - `src/shared/openspec-spec.ts` 新增 `extractTaskSourceOpenspecPaths` 纯函数(反引号内联、markdown 链接目标、裸路径 token → 归一化 → spec 过滤 → 治理子树排除 → 去重排序)
@@ -11,6 +17,7 @@
11
17
 
12
18
  ### 文档
13
19
 
20
+ - 新增 `docs/decisions/0015-frontend-plan-json-only-contract.md`,并同步 `docs/runtime/frontend-implementation-workflow.md`、`docs/templates/frontend-implementation-contract.schema.json`、`skills/frontend-implementation/references/node-contracts.md`
14
21
  - 新增 `docs/decisions/0014-openspec-gate-claim-verification.md`,并同步 `docs/runtime/frontend-implementation-workflow.md`、`docs/templates/frontend-task-constraints.md`、`docs/templates/frontend-design-contract.md`、`skills/frontend-implementation/references/node-contracts.md`
15
22
 
16
23
  ## [0.36.4-beta.0] - 2026-08-16
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "version": "0.37.0",
4
- "gitSha": "944355b6e1204f12ebd4a7b0cc9c52fabff8ff97",
5
- "builtAt": "2026-08-17T09:28:57.068Z"
4
+ "gitSha": "5c445d9697e1ff1974fbea8999634ecc668fecc9",
5
+ "builtAt": "2026-08-17T11:38:52.355Z"
6
6
  }
@@ -1043,6 +1043,7 @@ export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
1043
1043
  sdkAttempted: result.sdkAttempted,
1044
1044
  tokensUsed: result.tokensUsed,
1045
1045
  parsedEvents: result.parsedEvents,
1046
+ stopReason: readWriterThinkingExhaustionEvidence(result).stopReason,
1046
1047
  };
1047
1048
  }
1048
1049
  function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
@@ -316,6 +316,11 @@ export const frontendImplementationContractSchema = z
316
316
  })
317
317
  .strict()).min(1),
318
318
  evidenceGaps: z.array(gap),
319
+ implementationSteps: z.array(z.string().min(1)).optional(),
320
+ stylingStrategy: z.string().min(1).optional(),
321
+ dependencyPolicy: z.string().min(1).optional(),
322
+ residualRisks: z.array(z.string().min(1)).optional(),
323
+ realIntegrationGap: z.string().min(1).optional(),
319
324
  })
320
325
  .strict()
321
326
  .superRefine((value, ctx) => {
@@ -426,6 +431,44 @@ export const frontendImplementationContractSchema = z
426
431
  });
427
432
  }
428
433
  }));
434
+ function isPlainObject(value) {
435
+ return value !== null && typeof value === "object" && !Array.isArray(value);
436
+ }
437
+ /**
438
+ * Apply an RFC 7386 merge-patch on a target contract without mutating it.
439
+ * `null` removes the key; plain objects merge recursively when the target value
440
+ * is also a plain object; arrays and scalars replace the whole value. The
441
+ * result is re-validated by analyzeFrontendImplementationContract before it is
442
+ * ever materialized, so a patch that removes a required field still fails
443
+ * closed at the gate.
444
+ */
445
+ export function applyFrontendImplementationContractPatch(target, patch) {
446
+ if (!isPlainObject(patch)) {
447
+ throw new Error("frontend revision patch must be a JSON object");
448
+ }
449
+ const merge = (base, delta) => {
450
+ if (delta === null)
451
+ return undefined;
452
+ if (!isPlainObject(delta))
453
+ return delta;
454
+ const result = isPlainObject(base)
455
+ ? { ...base }
456
+ : {};
457
+ for (const [key, value] of Object.entries(delta)) {
458
+ if (value === null) {
459
+ delete result[key];
460
+ }
461
+ else if (isPlainObject(value) && isPlainObject(result[key])) {
462
+ result[key] = merge(result[key], value);
463
+ }
464
+ else {
465
+ result[key] = value;
466
+ }
467
+ }
468
+ return result;
469
+ };
470
+ return merge(target, patch);
471
+ }
429
472
  export async function assertFrontendSourceBindingFresh(input) {
430
473
  for (const source of input.binding.sources) {
431
474
  const absolute = resolveDagTaskSourcePath({
@@ -1331,6 +1374,11 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1331
1374
  },
1332
1375
  verificationTargets,
1333
1376
  evidenceGaps,
1377
+ implementationSteps: asStringArray(record.implementationSteps),
1378
+ stylingStrategy: asString(record.stylingStrategy) || undefined,
1379
+ dependencyPolicy: asString(record.dependencyPolicy) || undefined,
1380
+ residualRisks: asStringArray(record.residualRisks),
1381
+ realIntegrationGap: asString(record.realIntegrationGap) || undefined,
1334
1382
  };
1335
1383
  }
1336
1384
  /**
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Deterministic Markdown renderer for a canonical frontend implementation
3
+ * contract. The prewrite gate materializes this as `contracts/frontend-plan.md`
4
+ * next to the contract JSON, so plan prose is never produced by the model — it
5
+ * is derived from the single machine contract at materialization time.
6
+ *
7
+ * The function is pure: the same contract always renders byte-identical
8
+ * Markdown, and absent optional fields render as placeholders without throwing.
9
+ */
10
+ function bulletList(items) {
11
+ if (!items || items.length === 0)
12
+ return "";
13
+ return items.map((item) => `- ${item}`).join("\n");
14
+ }
15
+ function requirementsCoverage(contract) {
16
+ const lines = contract.requirements.map((requirement) => `- ${requirement.id}: ${requirement.expectedOutcome}`);
17
+ return lines.length > 0 ? lines.join("\n") : "_(no requirements)_";
18
+ }
19
+ function uiStateHandling(contract) {
20
+ const lines = contract.uiStates.map((state) => {
21
+ if (state.applicable) {
22
+ return `- ${state.name}: ${state.expectedBehavior ?? ""}`;
23
+ }
24
+ return `- ${state.name}: not applicable — ${state.notApplicableReason ?? ""}`;
25
+ });
26
+ return lines.length > 0 ? lines.join("\n") : "_(none)_";
27
+ }
28
+ function interactionNotes(contract) {
29
+ const lines = contract.interactions.map((interaction) => `- ${interaction.name}: ${interaction.trigger} → ${interaction.expectedBehavior}`);
30
+ return lines.length > 0 ? lines.join("\n") : "_(none)_";
31
+ }
32
+ function verificationPlan(contract) {
33
+ const lines = contract.verificationTargets.map((target) => `- ${target.id}: ${target.commandLabel} (${target.file})`);
34
+ return lines.length > 0 ? lines.join("\n") : "_(none)_";
35
+ }
36
+ export function renderFrontendPlanMarkdown(contract) {
37
+ return [
38
+ "# Frontend Implementation Plan",
39
+ "",
40
+ "## Requirement Coverage",
41
+ requirementsCoverage(contract),
42
+ "",
43
+ "## Implementation Steps",
44
+ bulletList(contract.implementationSteps) || "_(not specified)_",
45
+ "",
46
+ "## Target Files",
47
+ bulletList(contract.targets.files) || "_(none)_",
48
+ "",
49
+ "## UI State Handling",
50
+ uiStateHandling(contract),
51
+ "",
52
+ "## Styling & Component Strategy",
53
+ contract.stylingStrategy || "_(not specified)_",
54
+ "",
55
+ "## Interaction Notes",
56
+ interactionNotes(contract),
57
+ "",
58
+ "## Mock & API Strategy",
59
+ contract.mockApi.strategy,
60
+ "",
61
+ "## Dependency Policy",
62
+ contract.dependencyPolicy || "_(not specified)_",
63
+ "",
64
+ "## Verification Plan",
65
+ verificationPlan(contract),
66
+ "",
67
+ "## Real Integration Gap",
68
+ contract.realIntegrationGap || "_(not specified)_",
69
+ "",
70
+ "## Residual Risks",
71
+ bulletList(contract.residualRisks) || "_(none)_",
72
+ ].join("\n") + "\n";
73
+ }
@@ -2,12 +2,16 @@ 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 { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
5
6
  import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
6
- import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, 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
+ import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
7
9
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
8
10
  import { pathMatchesPattern } from "../../shared/git-progress.js";
9
11
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
10
12
  export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
13
+ /** Canonical contract serialization must stay at or below this byte size. */
14
+ export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
11
15
  /**
12
16
  * Machine-readable failure classification for blocked/retryable-invalid
13
17
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -31,6 +35,7 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
31
35
  "openspec-citation-block-unparseable",
32
36
  "openspec-not-cited",
33
37
  "openspec-citation-not-read",
38
+ "contract-too-large",
34
39
  ]);
35
40
  export const frontendPrewriteResultV1Schema = z
36
41
  .object({
@@ -442,7 +447,60 @@ export async function runFrontendPrewriteGate(input) {
442
447
  failureCode: "verdict-not-pass",
443
448
  });
444
449
  }
445
- const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
450
+ // revisionPatch mode: the effective plan node emits an RFC 7386 merge-patch
451
+ // delta against the original contract node (planFallbackFromNodeIds[0]).
452
+ // Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
453
+ const revisionPatchMode = input.config.revisionPatch === true &&
454
+ planNodeId === input.config.planFromNodeId &&
455
+ input.config.planFallbackFromNodeIds.length > 0;
456
+ let effectiveContractText = null;
457
+ if (revisionPatchMode) {
458
+ const originalNodeId = input.config.planFallbackFromNodeIds[0];
459
+ let originalText;
460
+ try {
461
+ originalText = await readNodeText(input.runDir, originalNodeId);
462
+ }
463
+ catch (error) {
464
+ return finalizePrewrite(input, {
465
+ ...basePending,
466
+ verdict,
467
+ classification: "retryable-invalid",
468
+ failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
469
+ failureCode: "contract-invalid",
470
+ });
471
+ }
472
+ let originalValue;
473
+ let patchValue;
474
+ try {
475
+ originalValue = extractFrontendImplementationJson(originalText);
476
+ patchValue = extractFrontendImplementationJson(planText);
477
+ }
478
+ catch (error) {
479
+ return finalizePrewrite(input, {
480
+ ...basePending,
481
+ verdict,
482
+ classification: "retryable-invalid",
483
+ failureReason: `frontend prewrite gate revisionPatch mode failed to parse contract/patch: ${error instanceof Error ? error.message : String(error)}`,
484
+ failureCode: "contract-invalid",
485
+ });
486
+ }
487
+ let merged;
488
+ try {
489
+ merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
490
+ }
491
+ catch (error) {
492
+ return finalizePrewrite(input, {
493
+ ...basePending,
494
+ verdict,
495
+ classification: "retryable-invalid",
496
+ failureReason: `frontend prewrite gate revisionPatch apply failed: ${error instanceof Error ? error.message : String(error)}`,
497
+ failureCode: "contract-invalid",
498
+ });
499
+ }
500
+ effectiveContractText = serializeDeterministicJson(merged);
501
+ }
502
+ const requirementIdSearchText = effectiveContractText ?? planText;
503
+ const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
446
504
  if (missingIds.length > 0) {
447
505
  return finalizePrewrite(input, {
448
506
  ...basePending,
@@ -495,7 +553,8 @@ export async function runFrontendPrewriteGate(input) {
495
553
  try {
496
554
  analysis = await analyzeFrontendImplementationContract({
497
555
  runDir: input.runDir,
498
- fromNodeId: planNodeId,
556
+ fromNodeId: effectiveContractText ? undefined : planNodeId,
557
+ rawContractText: effectiveContractText ?? undefined,
499
558
  sourceBinding: input.sourceBinding,
500
559
  });
501
560
  }
@@ -515,6 +574,21 @@ export async function runFrontendPrewriteGate(input) {
515
574
  }
516
575
  throw error;
517
576
  }
577
+ // Size guard: a canonical contract above 64KB is a retryable-invalid defect
578
+ // (compress the contract) rather than a writer authorization failure.
579
+ const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
580
+ if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
581
+ return finalizePrewrite(input, {
582
+ ...basePending,
583
+ verdict,
584
+ candidateJsonSha256: analysis.candidateJsonSha256,
585
+ normalizationActions: [],
586
+ mockStrategy: analysis.canonical.mockApi.strategy,
587
+ classification: "retryable-invalid",
588
+ failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
589
+ failureCode: "contract-too-large",
590
+ });
591
+ }
518
592
  const mockStrategy = analysis.canonical.mockApi.strategy;
519
593
  if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
520
594
  const quotedAllowed = input.config.allowedMockStrategies
@@ -711,6 +785,24 @@ export async function runFrontendPrewriteGate(input) {
711
785
  }
712
786
  }
713
787
  }
788
+ // Render the deterministic plan.md from the canonical contract (fail-closed
789
+ // before any artifact is written: no contract, no writer authorization).
790
+ let planMarkdown;
791
+ try {
792
+ planMarkdown = renderFrontendPlanMarkdown(analysis.canonical);
793
+ }
794
+ catch (error) {
795
+ return finalizePrewrite(input, {
796
+ ...basePending,
797
+ verdict,
798
+ candidateJsonSha256: analysis.candidateJsonSha256,
799
+ normalizationActions: [],
800
+ mockStrategy,
801
+ classification: "retryable-invalid",
802
+ failureReason: `frontend prewrite gate: failed to render ${input.config.planMdArtifactName ?? "frontend-plan.md"}: ${error instanceof Error ? error.message : String(error)}`,
803
+ failureCode: "contract-invalid",
804
+ });
805
+ }
714
806
  // Materialize the canonical contract only after every governance check passed.
715
807
  const artifact = await writeFrontendImplementationContractArtifact({
716
808
  runDir: input.runDir,
@@ -718,6 +810,8 @@ export async function runFrontendPrewriteGate(input) {
718
810
  artifactName: input.config.artifactName,
719
811
  canonical: analysis.canonical,
720
812
  });
813
+ // Materialize the rendered plan.md in the same outputDir as the contract.
814
+ await writeTextArtifactFile(path.join(input.runDir, path.posix.join(input.config.outputDir, input.config.planMdArtifactName ?? "frontend-plan.md")), planMarkdown);
721
815
  const classification = analysis.normalizationActions.length > 0 ? "accepted-normalized" : "accepted";
722
816
  return finalizePrewrite(input, {
723
817
  ...basePending,
@@ -20,6 +20,7 @@ import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-em
20
20
  import { discoverProjectGovernancePresence } from "./project-governance-context.js";
21
21
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
22
22
  import { materializeTaskReferenceDocs } from "../../task/source-references.js";
23
+ import { REQUIREMENT_FACT_ROLES } from "../../task/source-prepare/artifact-meta.js";
23
24
  import { observeTaskContract } from "../../task/contract/observe.js";
24
25
  import { dagHasWriterExecution } from "./task-contract-binding.js";
25
26
  import { DEFAULT_VERIFY_TIMEOUT_MS, resolveVerifyPreset, } from "../../executors/shell-verification.js";
@@ -1499,11 +1500,27 @@ function buildSourceContextBlock(sources) {
1499
1500
  .relative(path.join(sources.taskDir, "source"), reference.path)
1500
1501
  .replaceAll(path.sep, "/");
1501
1502
  const referenceRef = toDagSourcePath(sources, reference.path);
1502
- const referenceExcerpt = excerptMarkdown(reference.markdown, {
1503
- sourceRef: referenceRef,
1504
- });
1505
- boundReadPaths.push(`- reference ${relativePath}: ${referenceRef}`);
1506
- parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, referenceExcerpt.text);
1503
+ // Fact-source roles (requirement/acceptance) carry the fields contracts
1504
+ // depend on (AC/scope/columns). Inject them in full so a fixed excerpt
1505
+ // cannot silently drop definitions; archival roles stay excerpted.
1506
+ const isFactRole = reference.role !== undefined &&
1507
+ REQUIREMENT_FACT_ROLES.has(reference.role);
1508
+ const referenceExcerpt = isFactRole
1509
+ ? {
1510
+ text: reference.markdown.trim(),
1511
+ truncated: false,
1512
+ originalChars: reference.markdown.trim().length,
1513
+ maxChars: Number.POSITIVE_INFINITY,
1514
+ }
1515
+ : excerptMarkdown(reference.markdown, {
1516
+ sourceRef: referenceRef,
1517
+ });
1518
+ boundReadPaths.push(`- reference ${relativePath}${isFactRole ? " (full source)" : ""}: ${referenceRef}`);
1519
+ parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, ...(isFactRole
1520
+ ? [
1521
+ `Full source injected (role: ${reference.role}) — complete and authoritative; no excerpt truncation applied.`,
1522
+ ]
1523
+ : []), referenceExcerpt.text);
1507
1524
  }
1508
1525
  parts.push("## Bound source read paths", ...boundReadPaths, "Use these repository-readable paths for any Pi read-tool calls. Bound files under `.harness/tasks/<taskId>/source/**` are read-only inputs: reading them is allowed even though writing `.harness/**` is forbidden.", "Never resolve task-relative citations such as `source/需求.md` or `source/references/*` against the repository root, invent `source/<taskId>/...`, search for substitutes, or fall back to `docs/**` when a bound read fails.", "## Task config summary", `- taskId: ${sources.taskConfig.taskId}`, `- flow: ${sources.taskConfig.flow}`, `- complexity: ${sources.taskConfig.complexity}`, `- contextProfile: ${sources.taskConfig.contextProfile}`, `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none — review before execute)"}`, `- forbiddenPaths: ${sources.taskConfig.forbiddenPaths.join(", ") || "(none)"}`, '- Pi DAG nodes are read-only unless toolProfile="write" is explicitly selected for a bounded writer node.', "- Agent DAG read-only nodes must not write root artifacts/**; root artifacts/ is not a per-node scratchpad.", `- Derived execution contract and immutable references live under the Bound source read paths above (not as repo-root \`source/...\`).`);
1509
1526
  if (sources.taskConfig.hardConstraints.length > 0) {
@@ -1541,10 +1558,49 @@ async function loadMaterializedSourceReferences(sourceDir) {
1541
1558
  }
1542
1559
  await collect(referenceDir);
1543
1560
  referencePaths.sort((left, right) => left.localeCompare(right));
1544
- return Promise.all(referencePaths.map(async (filePath) => ({
1545
- path: filePath,
1546
- markdown: await readFile(filePath, "utf-8"),
1547
- })));
1561
+ const roleByPath = await loadReferenceRoles(sourceDir);
1562
+ return Promise.all(referencePaths.map(async (filePath) => {
1563
+ const relative = path
1564
+ .relative(sourceDir, filePath)
1565
+ .replaceAll(path.sep, "/");
1566
+ const role = roleByPath.get(relative);
1567
+ return {
1568
+ path: filePath,
1569
+ markdown: await readFile(filePath, "utf-8"),
1570
+ ...(role !== undefined ? { role } : {}),
1571
+ };
1572
+ }));
1573
+ }
1574
+ /**
1575
+ * Reads `source-manifest.json` into a materializedPath → role map so the DAG
1576
+ * generator can tell fact-source references (requirement/acceptance) apart
1577
+ * from archival ones (analysis/clarification/design). A missing or malformed
1578
+ * manifest yields an empty map; such references keep the bounded-excerpt
1579
+ * treatment instead of being injected in full.
1580
+ */
1581
+ async function loadReferenceRoles(sourceDir) {
1582
+ const roleByPath = new Map();
1583
+ const manifestPath = path.join(sourceDir, "source-manifest.json");
1584
+ let raw;
1585
+ try {
1586
+ raw = await readFile(manifestPath, "utf-8");
1587
+ }
1588
+ catch {
1589
+ return roleByPath;
1590
+ }
1591
+ try {
1592
+ const manifest = JSON.parse(raw);
1593
+ for (const document of manifest.documents ?? []) {
1594
+ if (typeof document.materializedPath === "string" &&
1595
+ typeof document.role === "string") {
1596
+ roleByPath.set(document.materializedPath.replaceAll(path.sep, "/"), document.role);
1597
+ }
1598
+ }
1599
+ }
1600
+ catch {
1601
+ // A malformed manifest must never break reference injection.
1602
+ }
1603
+ return roleByPath;
1548
1604
  }
1549
1605
  export async function loadTaskHybridSources(repoRoot, taskId) {
1550
1606
  const paths = getTaskPaths(repoRoot, taskId);
@@ -2283,6 +2339,7 @@ function pruneFrontendTasksForRisk(tasks, risk) {
2283
2339
  planFallbackFromNodeIds: [],
2284
2340
  reviewFromNodeId: "frontend-design-review-pi",
2285
2341
  reviewFallbackFromNodeIds: [],
2342
+ revisionPatch: false,
2286
2343
  },
2287
2344
  }
2288
2345
  : task.shell,
@@ -2429,8 +2486,27 @@ async function buildFrontendHybridDagFromTask(sources) {
2429
2486
  "",
2430
2487
  "### mockApi.endpoints - GOOD (strategy=native with complete endpoint):",
2431
2488
  '{"strategy":"native","productionDefaultOff":true,"activation":"VITE_ENABLE_MOCK=true","endpoints":[{"method":"GET","path":"/api/users","fixture":"mocks/fixtures/users.json","consumer":"src/api/users.ts"}]}',
2489
+ "",
2490
+ "### optional plan fields - GOOD (all optional; omit when absent):",
2491
+ '{"implementationSteps":["confirm contract","sync tests"],"stylingStrategy":"reuse existing design tokens","dependencyPolicy":"no new runtime deps","residualRisks":["browser a11y not-run"],"realIntegrationGap":"FE-TEST owns live HTTP"}',
2492
+ "",
2493
+ "### optional plan fields - BAD (present-but-empty strings are rejected):",
2494
+ '{"stylingStrategy":"","dependencyPolicy":""} <-- REJECTED: optional string fields must be non-empty when present; omit them instead',
2432
2495
  ].join("\n");
2433
2496
  })();
2497
+ const frontendContractFieldSummary = [
2498
+ "## Contract field summary (authoritative JSON; no plan prose)",
2499
+ "The plan/revision node emits only a fenced json contract — there is no Markdown plan explanation to read. Review these fields:",
2500
+ "- requirements[]: id, expectedOutcome, implementationTargets, verificationTargetIds, evidenceGap",
2501
+ "- uiStates[]: name, applicable, expectedBehavior, implementationTargets, verificationTargetIds, notApplicableReason",
2502
+ "- interactions[]: name, trigger, expectedBehavior, implementationTargets, verificationTargetIds",
2503
+ "- targets: files, routes, publicApiChanges",
2504
+ "- mockApi: strategy, productionDefaultOff, activation, endpoints[]",
2505
+ "- verificationTargets[]: id, type, commandLabel, file, symbol, requirementIds, uiStates",
2506
+ "- designEvidence: source, paths, conflicts; evidenceGaps[]",
2507
+ "- optional: implementationSteps[], stylingStrategy, dependencyPolicy, residualRisks[], realIntegrationGap",
2508
+ "Do not require or read a separate plan prose section; the contract JSON is the only plan surface.",
2509
+ ].join("\n");
2434
2510
  const sourceContext = [
2435
2511
  buildSourceContextBlock(sources),
2436
2512
  capabilityContextBlock,
@@ -2463,6 +2539,11 @@ async function buildFrontendHybridDagFromTask(sources) {
2463
2539
  `Non-static targets (type unit/component/integration/mock) MUST set file to a concrete code file inside the implementation writeSet (task allowedPaths); the prewrite gate rejects any non-static target whose file falls outside the writeSet.`,
2464
2540
  `Command-level checks that run project-wide (all tests, typecheck, build, governance) MUST use type "static" and must NOT be bound as non-static targets with file=package.json/tsconfig.json/vite.config.ts/scripts/*. Static targets are exempt from the writeSet containment check.`,
2465
2541
  ].join("\n");
2542
+ const mandatorySourceReadInstruction = [
2543
+ "## Mandatory full source read before contracting",
2544
+ "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.",
2545
+ "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.",
2546
+ ].join("\n");
2466
2547
  const strategy = resolveDagVerifyStrategy(taskConfig);
2467
2548
  const readOnlyPaths = taskConfig.allowedPaths.length > 0 ? taskConfig.allowedPaths : ["**"];
2468
2549
  const behaviorPaths = deriveFrontendBehaviorPaths(taskConfig);
@@ -2648,6 +2729,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2648
2729
  "Read task source and produce a concise frontend implementation contract.",
2649
2730
  "Cover scope, non-goals, acceptance criteria, UI states, target runtime environment, risks, and verification expectations.",
2650
2731
  "Read-only: do not modify code, docs, artifacts, or repository files.",
2732
+ mandatorySourceReadInstruction,
2651
2733
  sourceContext,
2652
2734
  ].join("\n\n"),
2653
2735
  },
@@ -2687,19 +2769,20 @@ async function buildFrontendHybridDagFromTask(sources) {
2687
2769
  allowedPaths: readOnlyPaths,
2688
2770
  forbiddenPaths,
2689
2771
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
2690
- outputContract: "Markdown implementation plan with Requirement Coverage, Implementation Steps, Target Files, UI State Handling, Styling / Component Strategy, Interaction Notes, Mock / API Strategy, Dependency Policy, Verification Plan, Real Integration Gap, and Residual Risks, ending with exactly ONE fenced json object (```json ... ```) conforming to frontend-implementation-contract-v1. This fenced block is the single authoritative implementation contract the prewrite gate materializes; it must appear exactly once and must not contain or be followed by any raw JSON or extra fenced block. No file writes.",
2772
+ outputContract: "One-line lead-in, then exactly ONE fenced json object (```json ... ```) conforming to frontend-implementation-contract-v1. This fenced block is the single authoritative implementation contract the prewrite gate materializes; it must appear exactly once. Immediately after it, append exactly one ```openspec-citations``` fenced citation block. Do NOT emit a Markdown plan explanation or any prose after the contract JSON the output is JSON-only (the gate renders plan.md deterministically). Apart from the contract JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes.",
2691
2773
  subtask_prompt: [
2692
2774
  "Based on frontend-contract-pi, frontend-scout-pi, task sources, and the generation-time Mock capability evidence, return a minimal frontend implementation plan.",
2693
2775
  "Select the Mock / API strategy inside the plan and structured contract. Carry endpoint/fixture mapping, explicit activation, production-default-off rule, verification commands, and Real Integration Gap into both outputs.",
2694
- "Include ordered steps, target files, UI state handling, styling/component strategy, interaction notes, Mock/API strategy, dependency policy, deterministic verification entrypoints, and residual risks. Use only the fixed entrypoints below; implementation may add tests behind them but cannot replace them.",
2776
+ "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.",
2695
2777
  "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.",
2696
2778
  "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.",
2697
- "End with exactly one fenced json object conforming to frontend-implementation-contract-v1. This node is the single contract JSON producer: the fenced block is the authoritative contract the prewrite gate materializes. Do not emit any raw JSON, JSON in prose, or a second fenced block anywhere in the response; the plan text must not contain other balanced JSON objects.",
2779
+ "Output in this exact order: (1) exactly one fenced json object conforming to frontend-implementation-contract-v1 this fenced block is the single authoritative contract the prewrite gate materializes; (2) exactly one openspec-citations citation fenced block appended immediately after it. Do NOT emit a Markdown plan explanation or any prose after the contract JSON — the output is JSON-only. Do not emit any raw JSON or JSON objects in prose. Apart from the single contract JSON fenced block and the openspec-citations block, do not emit any other fenced block.",
2698
2780
  "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.",
2699
2781
  requirementCoverageInstruction,
2700
2782
  "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2701
2783
  verificationTargetFileInstruction,
2702
2784
  "Read-only: do not modify code, docs, artifacts, or repository files.",
2785
+ mandatorySourceReadInstruction,
2703
2786
  fixedVerificationContext,
2704
2787
  sourceContext,
2705
2788
  mockContextBlock,
@@ -2720,13 +2803,14 @@ async function buildFrontendHybridDagFromTask(sources) {
2720
2803
  outputProtocol: REVIEW_VERDICT_OUTPUT_PROTOCOL,
2721
2804
  outputContract: "Plain Markdown whose first non-empty line is VERDICT: pass or VERDICT: request-revision, followed by Findings, Required Plan Corrections, and Checked Items. No file writes.",
2722
2805
  subtask_prompt: [
2723
- "Audit the frontend plan before implementation.",
2806
+ "Audit the frontend plan before implementation. Consume the contract JSON (the single fenced json block from frontend-plan-pi); there is no separate plan prose.",
2724
2807
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
2725
2808
  "Request revision when the Mock strategy is MOCK_STRATEGY: blocked, missing, unsupported by repository evidence, inconsistent with the API contract, outside authorized paths/dependencies, unable to prove production-default-off behavior with the fixed production/default-real-path static check, or missing deterministic behavior verification for a declared behavior target or selected Mock strategy. Mock strategies require Mock-backed evidence. A static-only contract is allowed only when every verification target is static and maps to a declared static entrypoint. not-needed otherwise requires applicable real/no-remote behavior evidence unless auto mode explicitly skipped Mock because no project Mock capability exists; in that case the plan must preserve the real request path and record the Real Integration Gap.",
2726
2809
  "Also request revision for missing applicable UI states, unsupported dependency additions, design-system drift without reason, weak interaction coverage, broad scope, inline fake data, schema drift, or missing deterministic verification commands.",
2727
2810
  "Read-only: do not modify repository files.",
2728
2811
  fixedVerificationContext,
2729
2812
  sourceContext,
2813
+ frontendContractFieldSummary,
2730
2814
  mockContextBlock,
2731
2815
  ].join("\n\n"),
2732
2816
  },
@@ -2747,16 +2831,16 @@ async function buildFrontendHybridDagFromTask(sources) {
2747
2831
  allowedPaths: readOnlyPaths,
2748
2832
  forbiddenPaths,
2749
2833
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
2750
- outputContract: "When the initial design review requests revision, return a complete Markdown revision plan followed by exactly ONE fenced json object (```json ... ```) conforming to frontend-implementation-contract-v1. This fenced block is the single authoritative implementation contract the prewrite gate materializes; it must appear exactly once and must not contain or be followed by any raw JSON or extra fenced block. Do NOT include multiple fenced JSON blocks; only the single authoritative contract JSON block is accepted. No file writes.",
2834
+ 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.",
2751
2835
  subtask_prompt: [
2752
- "Consume frontend-plan-pi (original plan) and frontend-design-review-pi (first design review findings).",
2753
- "This node runs only when frontend-design-review-pi emitted VERDICT: request-revision. Produce a complete revised implementation plan that addresses every Required Plan Correction from the design findings.",
2754
- "The revised plan must include Requirement Coverage, Implementation Steps, Target Files, UI State Handling, Styling / Component Strategy, Interaction Notes, Mock / API Strategy, Dependency Policy, Verification Plan, Real Integration Gap, and Residual Risks.",
2836
+ "Consume frontend-plan-pi (original contract JSON) and frontend-design-review-pi (first design review findings).",
2837
+ "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.",
2838
+ "The patch delta may update any of these contract fields: requirements, implementationSteps, targets, uiStates, interactions, mockApi, dependencyPolicy, stylingStrategy, verificationTargets, evidenceGaps, residualRisks, realIntegrationGap. 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.",
2755
2839
  requirementCoverageInstruction,
2756
2840
  "Do not turn MOCK_STRATEGY: blocked into an implementable strategy without new repository or contract evidence that resolves every blocker.",
2757
2841
  "Read-only: do not modify code, docs, artifacts, or repository files. This node revises the plan only.",
2758
- "End the response with exactly one fenced json object conforming to frontend-implementation-contract-v1. This node is the single contract JSON producer when the design review requests revision: the fenced block is the authoritative contract the prewrite gate materializes. Do not emit any raw JSON, JSON in prose, or a second fenced block anywhere in the response. Bind it to the supplied task sources; map every requirement and applicable UI state to concrete implementation and verification targets or an explicit blocking evidence gap. Do not include secrets or unsafe paths.",
2759
- "Preserve each requirement expectedOutcome and each interaction trigger/expectedBehavior in the revised contract; do not reduce behavior semantics to IDs and paths.",
2842
+ "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.",
2843
+ "Preserve each requirement expectedOutcome and each interaction trigger/expectedBehavior in the effective (merged) contract; do not reduce behavior semantics to IDs and paths.",
2760
2844
  "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2761
2845
  verificationTargetFileInstruction,
2762
2846
  fixedVerificationContext,
@@ -2785,7 +2869,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2785
2869
  outputProtocol: REVIEW_VERDICT_OUTPUT_PROTOCOL,
2786
2870
  outputContract: "For the effective frontend plan, return plain Markdown whose first non-empty line is VERDICT: pass or VERDICT: request-revision, followed by Findings and Checked Items. No file writes.",
2787
2871
  subtask_prompt: [
2788
- "Audit the revised frontend plan before implementation. This node runs only after request-revision and consumes frontend-plan-revision-pi.",
2872
+ "Audit the revised frontend plan before implementation. This node runs only after request-revision and consumes the merge-patch delta from frontend-plan-revision-pi applied on the original frontend-plan-pi contract JSON — there is no separate plan prose.",
2789
2873
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
2790
2874
  "Verify that every Required Plan Correction from the initial design review has been fully addressed.",
2791
2875
  "Recheck the selected Mock / API strategy, contract-to-fixture mapping, authorized paths/dependencies, explicit activation, production-default-off behavior, behavior verification, and Real Integration Gap. MOCK_STRATEGY: blocked cannot receive VERDICT: pass.",
@@ -2794,6 +2878,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2794
2878
  "Read-only: do not modify repository files.",
2795
2879
  fixedVerificationContext,
2796
2880
  sourceContext,
2881
+ frontendContractFieldSummary,
2797
2882
  mockContextBlock,
2798
2883
  ].join("\n\n"),
2799
2884
  },
@@ -2837,6 +2922,8 @@ async function buildFrontendHybridDagFromTask(sources) {
2837
2922
  ],
2838
2923
  artifactName: "frontend-implementation-contract.json",
2839
2924
  outputDir: "contracts",
2925
+ revisionPatch: true,
2926
+ planMdArtifactName: "frontend-plan.md",
2840
2927
  requireSourceFreshness: true,
2841
2928
  implementationWriteSet: implementPaths.writeSet,
2842
2929
  openspecPolicy: openspecGate.openspecPolicy,
@@ -152,6 +152,20 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
152
152
  previousProtocolReason,
153
153
  "Return exactly one fenced json block conforming to the frontend-implementation-contract-v1 schema.",
154
154
  "Fix every reported field violation: do not emit null for optional fields, do not misspell field names, and match the required types exactly.",
155
+ "The Markdown explanation may be omitted; prioritize a complete contract.",
156
+ "</retry_instruction>",
157
+ ].join("\n");
158
+ }
159
+ if (previousFailureCategory === "structured-output-truncated" &&
160
+ task.structuredContractOutput) {
161
+ return [
162
+ basePrompt,
163
+ "",
164
+ "<retry_instruction>",
165
+ "Previous attempt was truncated by the provider (stopReason=length) before the JSON contract was completed.",
166
+ "This attempt: output ONLY the single fenced json contract block, immediately followed by exactly one openspec-citations block.",
167
+ "Do not emit any Markdown explanation, evidence excerpts, or duplicated upstream context.",
168
+ "The contract JSON must be complete; the trailing Markdown explanation may be omitted entirely.",
155
169
  "</retry_instruction>",
156
170
  ].join("\n");
157
171
  }
@@ -768,10 +782,16 @@ export async function executeDagNode(input) {
768
782
  sourceBinding: spec.sourceBinding,
769
783
  });
770
784
  if (!contractCheck.ok) {
785
+ // A provider stopReason=length means the response was cut before the
786
+ // JSON contract could complete; separate it from an ordinary bad
787
+ // contract so the retry switches to a JSON-only output strategy.
788
+ const attemptStopReason = result.stopReason;
771
789
  result = {
772
790
  ...result,
773
791
  ok: false,
774
- failureCategory: "invalid-output",
792
+ failureCategory: attemptStopReason === "length"
793
+ ? "structured-output-truncated"
794
+ : "invalid-output",
775
795
  stderr: [result.stderr, contractCheck.reason]
776
796
  .filter(Boolean)
777
797
  .join("\n"),
@@ -21,6 +21,8 @@ export const STRUCTURED_OUTPUT_RETRY_CATEGORY = "output-too-large";
21
21
  export const PROTOCOL_INVALID_RETRY_CATEGORY = "protocol-invalid";
22
22
  /** Recoverable model artifact/schema formatting failure on read-only structured nodes. */
23
23
  export const STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY = "invalid-output";
24
+ /** Provider stopReason=length truncated the response before the JSON contract completed. */
25
+ export const STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY = "structured-output-truncated";
24
26
  /** Retry only a proven no-op from an explicitly opt-in bounded Pi writer. */
25
27
  export const WRITER_EMPTY_DIFF_RETRY_CATEGORY = "writer-empty-diff";
26
28
  /** Retry when a backend-test writer finished but Completeness Gate found missing/broken targets. */
@@ -36,6 +38,7 @@ export const STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES = [
36
38
  ...DEFAULT_DAG_RETRY_CATEGORIES,
37
39
  STRUCTURED_OUTPUT_RETRY_CATEGORY,
38
40
  STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
41
+ STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY,
39
42
  ];
40
43
  /** Categories allowed on nodes that declare a machine-readable outputProtocol. */
41
44
  export const PROTOCOL_AWARE_DAG_RETRY_CATEGORIES = [
@@ -48,6 +51,7 @@ export const ALL_DAG_RETRY_CATEGORIES = [
48
51
  STRUCTURED_OUTPUT_RETRY_CATEGORY,
49
52
  PROTOCOL_INVALID_RETRY_CATEGORY,
50
53
  STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
54
+ STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY,
51
55
  WRITER_EMPTY_DIFF_RETRY_CATEGORY,
52
56
  INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
53
57
  WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
@@ -199,6 +199,22 @@ export const dagFrontendPrewriteGateSchema = z.object({
199
199
  * preserved. Generation always writes it explicitly for new DAGs.
200
200
  */
201
201
  openspecPolicy: z.enum(["cited", "scan-strict"]).optional(),
202
+ /**
203
+ * When true, the effective plan node (planFromNodeId when it is selected
204
+ * over the fallback) emits an RFC 7386 merge-patch delta instead of a full
205
+ * contract. The gate applies it on the original contract node
206
+ * (planFallbackFromNodeIds[0]) before analysis/materialization. Legacy DAGs
207
+ * without this field keep full-contract semantics.
208
+ */
209
+ revisionPatch: z.boolean().optional(),
210
+ /**
211
+ * Deterministically rendered plan.md artifact name (same outputDir as the
212
+ * contract). Defaults to frontend-plan.md at runtime.
213
+ */
214
+ planMdArtifactName: z
215
+ .string()
216
+ .regex(/^[a-z0-9][a-z0-9._-]*\.md$/)
217
+ .optional(),
202
218
  /**
203
219
  * Generation-frozen candidate source metadata (diagnostic). cited mode
204
220
  * candidates are declared ∪ taskSourceCited; scan-strict candidates are
@@ -16,7 +16,12 @@
16
16
  "mockApi": { "type": "object", "additionalProperties": false, "required": ["strategy", "productionDefaultOff", "activation", "endpoints"], "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter", "not-needed"] }, "productionDefaultOff": { "const": true }, "activation": { "type": ["string", "null"], "minLength": 1 }, "endpoints": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["method", "path"], "properties": { "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] }, "path": { "type": "string", "pattern": "^/" }, "fixture": { "anyOf": [{ "$ref": "#/$defs/path" }, { "type": "null" }] }, "consumer": { "anyOf": [{ "$ref": "#/$defs/path" }, { "type": "null" }] } } } } } },
17
17
  "designEvidence": { "type": "object", "additionalProperties": false, "required": ["source", "paths", "conflicts"], "properties": { "source": { "type": "string", "minLength": 1 }, "paths": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "conflicts": { "type": "array", "items": { "type": "string", "minLength": 1 } } } },
18
18
  "verificationTargets": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "type", "commandLabel", "file", "requirementIds", "uiStates"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": ["static", "unit", "component", "integration", "mock"] }, "commandLabel": { "type": "string", "minLength": 1 }, "file": { "$ref": "#/$defs/path" }, "symbol": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, "requirementIds": { "type": "array", "items": { "$ref": "#/$defs/requirementId" } }, "uiStates": { "type": "array", "items": { "type": "string", "minLength": 1 } } } } },
19
- "evidenceGaps": { "type": "array", "items": { "$ref": "#/$defs/gap" } }
19
+ "evidenceGaps": { "type": "array", "items": { "$ref": "#/$defs/gap" } },
20
+ "implementationSteps": { "type": "array", "items": { "type": "string", "minLength": 1 } },
21
+ "stylingStrategy": { "type": "string", "minLength": 1 },
22
+ "dependencyPolicy": { "type": "string", "minLength": 1 },
23
+ "residualRisks": { "type": "array", "items": { "type": "string", "minLength": 1 } },
24
+ "realIntegrationGap": { "type": "string", "minLength": 1 }
20
25
  },
21
26
  "allOf": [{ "if": { "properties": { "mockApi": { "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter"] } } } } }, "then": { "properties": { "mockApi": { "properties": { "endpoints": { "minItems": 1, "items": { "required": ["method", "path", "fixture", "consumer"] } } } } } } }],
22
27
  "$defs": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.39.0-next.3",
3
+ "version": "0.39.0-next.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -6,11 +6,12 @@ Pre-write nodes are read-only. Preserve IDs, labels, commands, language, require
6
6
 
7
7
  - **`frontend-contract-pi`**: `Scope`, `Non-goals`, `Acceptance Criteria`, `UI States`, `Target Runtime Environment`, `Risks`, `Verification Expectations`; no guessed requirements.
8
8
  - **`frontend-scout-pi`**: routes, components, tokens, data/API/Mock, scripts, tests, assets; fact vs inference vs gap. Search+read `openspec/schemas/`, `openspec/project-specs/`, `ai_workspace/` first. Output stack, routes, components, styling, conventions, state/data, test entry points, reuse, risks.
9
- - **`frontend-plan-pi` + conditional design loop**: AC → observable `expectedOutcome`; interactions → `trigger`+`expectedBehavior`; steps, in-bound files, applicable UI states, reuse, deps, Mock/API strategy, activation/rollback, frozen verify entrypoints, real-integration gap. Markdown plans only; each ends with **exactly one** fenced `json` block (authoritative `frontend-implementation-contract-v1`), then exactly one fenced `openspec-citations` block listing each read openspec file. No raw JSON, JSON in prose, or 2nd fenced block. `frontend-contract-json-pi`/`frontend-contract-json-validate-shell` don't exist; prewrite gate materializes the contract from effective plan. IDs+paths aren't sufficient behavior semantics. Use `uiStates: []` for logic-only changes; don't invent UI states. Applicable states need behavior/implementation/verification; non-applicable need a reason, no empty placeholders. Non-`not-needed` needs frozen Mock verify commands (`frontendMock.verifyCommands`/`package.json` mock script/capability seed); `auto` with absent/ambiguous capability or no command → `not-needed` (real requests default, gap recorded); `required` without a command → generation-time blocked (no writer). Initial pass uses original plan; only exact `request-revision` runs read-only revision + final review. Small-risk: one design review.
9
+ - **`frontend-plan-pi` + conditional design loop**: AC → observable `expectedOutcome`; interactions → `trigger`+`expectedBehavior`; steps, in-bound files, applicable UI states, reuse, deps, Mock/API strategy, activation/rollback, frozen verify entrypoints, real-integration gap. **JSON-only**: one-line lead-in + **exactly one** fenced `json` block (`frontend-implementation-contract-v1`) + one `openspec-citations` block. No plan prose; plan.md is rendered by the prewrite gate. `frontend-contract-json-pi`/`frontend-contract-json-validate-shell` don't exist. IDs+paths aren't sufficient behavior semantics. Use `uiStates: []` for logic-only changes; don't invent UI states. Applicable states need behavior/implementation/verification; non-applicable need a reason. Non-`not-needed` needs frozen Mock verify commands (`frontendMock.verifyCommands`/`package.json` mock script/capability seed); `auto` with absent/ambiguous capability or no command → `not-needed`; `required` without a command → generation-time blocked. Initial pass uses original plan; only exact `request-revision` runs revision + final review. Small-risk: one design review.
10
10
  - **`frontend-prewrite-gate-shell`**: sole write authorization. Resolve effective plan (revised if revision ran, else original) + review; require exact pass; retain every REQ/BR/AC id; enforce Mock policy; validate schema/source binding+writeSet containment; materialize `contracts/frontend-implementation-contract.json` from the **single** fenced contract block (multiple candidates fail closed `invalid-output`). Fallback only when conditional primary absent; existing malformed primary fails closed. Generation-time blocked Mock yields one deterministic blocking shell node, no writer.
11
11
  - `verificationTarget.commandLabel` must be a **frozen command label** from `verifyEvidence.commandLabels`; others fail closed (`invalid-output`). Empty frozen set (no `run.json`) skips check.
12
12
  - `mockApi.strategy !== "not-needed"` fails closed when `mockCommandLabels` empty (`no authorized Mock verification commands`).
13
13
  - OpenSpec policy: `task.json.frontendOpenspec.policy` (default `cited`); candidates = `requiredReadPaths` ∪ task-source citations; effective plan must append one fenced `openspec-citations` block (one JSON `{"path","section","line"}` per line). Candidates non-empty but block missing/unparseable → `openspec-citation-block-unparseable`; candidate not cited → `openspec-not-cited`; cited path without successful read event in plan/review → `openspec-citation-not-read` (anti-fabrication). Empty candidates skip enforcement (generation-time advisory). `scan-strict` keeps auto-discovered full-read semantics (`openspec-not-read`); both modes keep `candidate-missing-drift`.
14
+ - revisionPatch mode: revision output is an RFC 7386 merge-patch delta applied on the original contract node (`planFallbackFromNodeIds[0]`); legacy DAGs keep full-contract semantics. Size guard: canonical serialization >64KB → `retryable-invalid` + `contract-too-large`.
14
15
  - **`frontend-implement-pi`**: sole regular exclusive writer; uses `frontend-bounded-implement` (not this skill). Stay in `writeSet`; real requests default-on. Atomic handler/intercept/adapter with consumer+tests. Stop on forbidden paths/guesses. First line `IMPLEMENTATION_OUTCOME: changed|already-satisfied|blocked`; runtime checks against attributed diff. Optional mock-verify when frozen; static+behavior always; behavior must prove page consumption. `not-needed` keeps real integration pending unless real backend has fresh evidence.
15
16
 
16
17
  ## Contract / trace / stages (M1–M2)