@tea-agent/loop-agent 0.38.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/commands/init-upgrade.js +97 -19
  4. package/dist/commands/init.js +14 -2
  5. package/dist/executors/dag-pi-executor.js +1 -0
  6. package/dist/governance/manifest-types.js +2 -0
  7. package/dist/infrastructure/harness/atomic-write.js +2 -1
  8. package/dist/shared/openspec-spec.js +76 -0
  9. package/dist/shared/operator/capabilities.js +65 -8
  10. package/dist/task/config-types.js +21 -0
  11. package/dist/task/frontend-project-capability.js +292 -38
  12. package/dist/worker/console/chat/mcp-inventory.js +221 -0
  13. package/dist/worker/console/chat/pi-runtime.js +155 -78
  14. package/dist/worker/console/chat/repo-walk.js +10 -1
  15. package/dist/worker/console/chat/routes.js +19 -8
  16. package/dist/worker/console/chat/semantic-activity.js +17 -5
  17. package/dist/worker/console/chat/turn-process.js +17 -30
  18. package/dist/worker/console/harness-pi-model-matrix.js +146 -0
  19. package/dist/worker/console/operator-actions.js +123 -0
  20. package/dist/worker/console/operator-user-error.js +19 -0
  21. package/dist/worker/console/pi-readiness.js +7 -1
  22. package/dist/worker/console/security.js +20 -1
  23. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-Hfsot6rP.js} +1 -1
  24. package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-YGwEASin.js} +1 -1
  25. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-CmOM0F92.js} +1 -1
  26. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-DDLuaHC5.js} +1 -1
  27. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-B0Um43W4.js} +1 -1
  28. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +1 -0
  29. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-DvBN4JZc.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-B1UVgW4E.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-Cf0GgXzX.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-DbNA24sj.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-Bi_0Be06.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-C8pPGvRd.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-BitQY8Z-.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-U67GCiEo.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-IoJwXPBA.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-YDZ_UHgE.js} +1 -1
  39. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +1 -0
  40. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +1 -0
  41. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-CUZgsiRC.js} +1 -1
  42. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-COmCh4pT.js} +1 -1
  43. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-Ta6FSm68.js} +1 -1
  44. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-UdcR-VW9.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-DybcO3Et.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-wErDFWLw.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-DkVHZWqz.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-DdqWs7QN.js} +1 -1
  49. package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-C9dMVmDo.js} +1 -1
  50. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-B2T2WcBK.js} +1 -1
  51. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-Bt9rjDFq.js} +1 -1
  52. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-U8ic_tvi.js} +1 -1
  53. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-BfQJdQoO.js} +1 -1
  54. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-CpMftH4O.js} +1 -1
  55. package/dist/worker/console/static/assets/index-BA5-N5ER.css +1 -0
  56. package/dist/worker/console/static/assets/index-DKPXBrdc.js +325 -0
  57. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-DzqWXNO_.js} +1 -1
  58. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js} +1 -1
  59. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-ChPtKFLz.js} +1 -1
  60. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-PRe7wB5u.js} +1 -1
  61. package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-DpKX6QNp.js} +1 -1
  62. package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-Dnl76b7Y.js} +5 -5
  63. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-S9PCXc3J.js} +1 -1
  64. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Du8e-FSX.js} +1 -1
  65. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-CY6OmHTc.js} +1 -1
  66. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-C65GPhpj.js} +1 -1
  67. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-U6IUiTCL.js} +1 -1
  68. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-D-I_9Qhi.js} +1 -1
  69. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-B57Fhed0.js} +1 -1
  70. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CJOtiw21.js} +1 -1
  71. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-FyX8zxSh.js} +1 -1
  72. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-eF-pbs9t.js} +1 -1
  73. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +1 -0
  74. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-CcfkA-Dq.js} +2 -2
  75. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +8 -0
  76. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DH8FjnOK.js} +1 -1
  77. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-beoTagJg.js} +1 -1
  78. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-CQSfw4cK.js} +1 -1
  79. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-CQgzQ6jn.js} +1 -1
  80. package/dist/worker/console/static/index.html +2 -2
  81. package/dist/worker/console/static-src/app/console-types.js +51 -0
  82. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +44 -21
  83. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
  84. package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
  85. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +15 -0
  86. package/dist/worker/console/static-src/operator-chat/useChatStream.js +17 -4
  87. package/dist/worker/console/static-src/operator-chat/useChatThread.js +28 -22
  88. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
  89. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  90. package/dist/workflows/dag/failure-routing.js +9 -4
  91. package/dist/workflows/dag/frontend-implementation-contract.js +158 -0
  92. package/dist/workflows/dag/frontend-plan-render.js +90 -0
  93. package/dist/workflows/dag/frontend-prewrite-gate.js +411 -20
  94. package/dist/workflows/dag/init-hybrid.js +272 -27
  95. package/dist/workflows/dag/lifecycle.js +4 -0
  96. package/dist/workflows/dag/node-execution.js +51 -9
  97. package/dist/workflows/dag/report.js +6 -0
  98. package/dist/workflows/dag/retry-policy.js +15 -0
  99. package/dist/workflows/dag/types.js +50 -4
  100. package/dist/workflows/dag/validate.js +7 -2
  101. package/docs/templates/frontend-design-contract.md +40 -14
  102. package/docs/templates/frontend-implementation-contract.schema.json +9 -2
  103. package/docs/templates/frontend-task-constraints.md +40 -13
  104. package/docs/templates/frontend-task-requirement.md +54 -20
  105. package/docs/templates/harness.schema.json +8 -0
  106. package/harness.json +11 -2
  107. package/package.json +1 -1
  108. package/skills/frontend-design-review/references/review-checklist.md +2 -0
  109. package/skills/frontend-implementation/references/design-spec.md +16 -19
  110. package/skills/frontend-implementation/references/node-contracts.md +15 -11
  111. package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
  112. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
  113. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
  114. package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
  115. package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
  116. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
  117. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
@@ -0,0 +1,90 @@
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
+ function componentSelection(contract) {
37
+ const choices = contract.uiComponentChoices ?? [];
38
+ if (choices.length === 0)
39
+ return "_(not specified)_";
40
+ return choices
41
+ .map((choice) => {
42
+ const specReference = choice.specReference;
43
+ const refText = specReference
44
+ ? ` ${specReference.path}${specReference.section ? `#${specReference.section}` : ""}${specReference.line != null ? `:${specReference.line}` : ""}`
45
+ : "";
46
+ return `- ${choice.purpose} → ${choice.component} (${choice.decision})${refText ? `:${refText}` : ""} — ${choice.rationale}`;
47
+ })
48
+ .join("\n");
49
+ }
50
+ export function renderFrontendPlanMarkdown(contract) {
51
+ return [
52
+ "# Frontend Implementation Plan",
53
+ "",
54
+ "## Requirement Coverage",
55
+ requirementsCoverage(contract),
56
+ "",
57
+ "## Implementation Steps",
58
+ bulletList(contract.implementationSteps) || "_(not specified)_",
59
+ "",
60
+ "## Target Files",
61
+ bulletList(contract.targets.files) || "_(none)_",
62
+ "",
63
+ "## UI State Handling",
64
+ uiStateHandling(contract),
65
+ "",
66
+ "## Styling & Component Strategy",
67
+ contract.stylingStrategy || "_(not specified)_",
68
+ "",
69
+ "## Component Selection (spec conformance)",
70
+ componentSelection(contract),
71
+ "",
72
+ "## Interaction Notes",
73
+ interactionNotes(contract),
74
+ "",
75
+ "## Mock & API Strategy",
76
+ contract.mockApi.strategy,
77
+ "",
78
+ "## Dependency Policy",
79
+ contract.dependencyPolicy || "_(not specified)_",
80
+ "",
81
+ "## Verification Plan",
82
+ verificationPlan(contract),
83
+ "",
84
+ "## Real Integration Gap",
85
+ contract.realIntegrationGap || "_(not specified)_",
86
+ "",
87
+ "## Residual Risks",
88
+ bulletList(contract.residualRisks) || "_(none)_",
89
+ ].join("\n") + "\n";
90
+ }
@@ -2,11 +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 { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
5
+ import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
6
+ import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.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";
6
9
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
7
10
  import { pathMatchesPattern } from "../../shared/git-progress.js";
8
11
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
9
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;
10
15
  /**
11
16
  * Machine-readable failure classification for blocked/retryable-invalid
12
17
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -25,6 +30,15 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
25
30
  "mock-strategy-no-verification-commands",
26
31
  "target-outside-write-set",
27
32
  "verification-target-outside-write-set",
33
+ "openspec-not-read",
34
+ "candidate-missing-drift",
35
+ "openspec-citation-block-unparseable",
36
+ "openspec-not-cited",
37
+ "openspec-citation-not-read",
38
+ "component-spec-reference-invalid",
39
+ "component-spec-not-cited",
40
+ "component-choices-missing",
41
+ "contract-too-large",
28
42
  ]);
29
43
  export const frontendPrewriteResultV1Schema = z
30
44
  .object({
@@ -196,14 +210,9 @@ function isFailedToolResult(event) {
196
210
  event.result?.error != null ||
197
211
  event.result?.ok === false);
198
212
  }
199
- async function checkOpenspecReadEvidence(input) {
200
- const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
201
- if (candidatePaths.length === 0)
202
- return [];
213
+ async function collectOpenspecReadPaths(input) {
214
+ const { runDir, planNodeId, reviewNodeId, repoRoot } = input;
203
215
  const matched = new Set();
204
- const normalizedCandidates = new Set(candidatePaths
205
- .map((candidate) => toRepoRelativePath(candidate, repoRoot))
206
- .filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
207
216
  for (const nodeId of [planNodeId, reviewNodeId]) {
208
217
  const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
209
218
  try {
@@ -237,7 +246,7 @@ async function checkOpenspecReadEvidence(input) {
237
246
  : null;
238
247
  if (relativePath &&
239
248
  !isFailedToolResult(event) &&
240
- normalizedCandidates.has(relativePath)) {
249
+ isOpenspecSpecFilePath(relativePath)) {
241
250
  matched.add(relativePath);
242
251
  }
243
252
  startedReads.delete(event.toolCallId);
@@ -252,6 +261,87 @@ async function checkOpenspecReadEvidence(input) {
252
261
  }
253
262
  return [...matched];
254
263
  }
264
+ async function checkOpenspecReadEvidence(input) {
265
+ const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
266
+ if (candidatePaths.length === 0)
267
+ return [];
268
+ const allReadPaths = await collectOpenspecReadPaths({
269
+ runDir,
270
+ planNodeId,
271
+ reviewNodeId,
272
+ repoRoot,
273
+ });
274
+ const normalizedCandidates = new Set(candidatePaths
275
+ .map((candidate) => toRepoRelativePath(candidate, repoRoot))
276
+ .filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
277
+ return allReadPaths.filter((readPath) => normalizedCandidates.has(readPath));
278
+ }
279
+ function normalizeCitationPath(rawPath, repoRoot) {
280
+ let candidate = rawPath.trim();
281
+ const hashIndex = candidate.indexOf("#");
282
+ if (hashIndex >= 0)
283
+ candidate = candidate.slice(0, hashIndex);
284
+ candidate = candidate.replace(/\\/g, "/");
285
+ if (!candidate)
286
+ return null;
287
+ if (repoRoot) {
288
+ const relative = toRepoRelativePath(candidate, repoRoot);
289
+ return relative ? relative.replace(/\\/g, "/") : null;
290
+ }
291
+ return candidate;
292
+ }
293
+ /**
294
+ * Parses the deterministic openspec-citations fence from the effective plan
295
+ * output. Each non-empty line must be one JSON object {"path","section","line"}.
296
+ * Rows whose normalized path is not a supported openspec spec file are ignored
297
+ * (out of scope); rows that are invalid JSON or lack a string path mark the
298
+ * block unparseable (fail-closed when candidates are non-empty).
299
+ */
300
+ export function parseOpenspecCitations(planText, repoRoot) {
301
+ const citations = [];
302
+ let blockPresent = false;
303
+ let unparseable = false;
304
+ const fenceRe = /```openspec-citations[ \t]*\r?\n([\s\S]*?)\r?\n```/g;
305
+ let match;
306
+ while ((match = fenceRe.exec(planText)) !== null) {
307
+ blockPresent = true;
308
+ const body = match[1] ?? "";
309
+ for (const rawLine of body.split(/\r?\n/)) {
310
+ const line = rawLine.trim();
311
+ if (!line)
312
+ continue;
313
+ let parsed;
314
+ try {
315
+ parsed = JSON.parse(line);
316
+ }
317
+ catch {
318
+ unparseable = true;
319
+ continue;
320
+ }
321
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
322
+ unparseable = true;
323
+ continue;
324
+ }
325
+ const rawPath = parsed.path;
326
+ if (typeof rawPath !== "string" || !rawPath.trim()) {
327
+ unparseable = true;
328
+ continue;
329
+ }
330
+ const normalized = normalizeCitationPath(rawPath, repoRoot);
331
+ if (!normalized || !isOpenspecSpecFilePath(normalized))
332
+ continue;
333
+ const section = typeof parsed.section === "string"
334
+ ? parsed.section
335
+ : "";
336
+ const lineValue = parsed.line;
337
+ const citationLine = typeof lineValue === "number" && Number.isInteger(lineValue)
338
+ ? lineValue
339
+ : null;
340
+ citations.push({ path: normalized, section, line: citationLine });
341
+ }
342
+ }
343
+ return { blockPresent, unparseable, citations };
344
+ }
255
345
  async function finalizePrewrite(input, pending) {
256
346
  const sourceBindingSha256 = input.sourceBinding
257
347
  ? deterministicSha256(input.sourceBinding)
@@ -298,6 +388,7 @@ async function finalizePrewrite(input, pending) {
298
388
  canonicalSha256: pending.canonicalSha256,
299
389
  openspecReadPaths: pending.openspecReadPaths,
300
390
  openspecCandidatePaths: pending.openspecCandidatePaths,
391
+ openspecPolicy: pending.openspecPolicy,
301
392
  };
302
393
  }
303
394
  export async function runFrontendPrewriteGate(input) {
@@ -307,6 +398,7 @@ export async function runFrontendPrewriteGate(input) {
307
398
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
308
399
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
309
400
  const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
401
+ const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
310
402
  const basePending = {
311
403
  planNodeId,
312
404
  reviewNodeId,
@@ -322,6 +414,7 @@ export async function runFrontendPrewriteGate(input) {
322
414
  classification: "accepted",
323
415
  openspecReadPaths: [],
324
416
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
417
+ openspecPolicy,
325
418
  };
326
419
  let planText;
327
420
  try {
@@ -357,7 +450,60 @@ export async function runFrontendPrewriteGate(input) {
357
450
  failureCode: "verdict-not-pass",
358
451
  });
359
452
  }
360
- const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
453
+ // revisionPatch mode: the effective plan node emits an RFC 7386 merge-patch
454
+ // delta against the original contract node (planFallbackFromNodeIds[0]).
455
+ // Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
456
+ const revisionPatchMode = input.config.revisionPatch === true &&
457
+ planNodeId === input.config.planFromNodeId &&
458
+ input.config.planFallbackFromNodeIds.length > 0;
459
+ let effectiveContractText = null;
460
+ if (revisionPatchMode) {
461
+ const originalNodeId = input.config.planFallbackFromNodeIds[0];
462
+ let originalText;
463
+ try {
464
+ originalText = await readNodeText(input.runDir, originalNodeId);
465
+ }
466
+ catch (error) {
467
+ return finalizePrewrite(input, {
468
+ ...basePending,
469
+ verdict,
470
+ classification: "retryable-invalid",
471
+ failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
472
+ failureCode: "contract-invalid",
473
+ });
474
+ }
475
+ let originalValue;
476
+ let patchValue;
477
+ try {
478
+ originalValue = extractFrontendImplementationJson(originalText);
479
+ patchValue = extractFrontendImplementationJson(planText);
480
+ }
481
+ catch (error) {
482
+ return finalizePrewrite(input, {
483
+ ...basePending,
484
+ verdict,
485
+ classification: "retryable-invalid",
486
+ failureReason: `frontend prewrite gate revisionPatch mode failed to parse contract/patch: ${error instanceof Error ? error.message : String(error)}`,
487
+ failureCode: "contract-invalid",
488
+ });
489
+ }
490
+ let merged;
491
+ try {
492
+ merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
493
+ }
494
+ catch (error) {
495
+ return finalizePrewrite(input, {
496
+ ...basePending,
497
+ verdict,
498
+ classification: "retryable-invalid",
499
+ failureReason: `frontend prewrite gate revisionPatch apply failed: ${error instanceof Error ? error.message : String(error)}`,
500
+ failureCode: "contract-invalid",
501
+ });
502
+ }
503
+ effectiveContractText = serializeDeterministicJson(merged);
504
+ }
505
+ const requirementIdSearchText = effectiveContractText ?? planText;
506
+ const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
361
507
  if (missingIds.length > 0) {
362
508
  return finalizePrewrite(input, {
363
509
  ...basePending,
@@ -410,7 +556,8 @@ export async function runFrontendPrewriteGate(input) {
410
556
  try {
411
557
  analysis = await analyzeFrontendImplementationContract({
412
558
  runDir: input.runDir,
413
- fromNodeId: planNodeId,
559
+ fromNodeId: effectiveContractText ? undefined : planNodeId,
560
+ rawContractText: effectiveContractText ?? undefined,
414
561
  sourceBinding: input.sourceBinding,
415
562
  });
416
563
  }
@@ -430,6 +577,21 @@ export async function runFrontendPrewriteGate(input) {
430
577
  }
431
578
  throw error;
432
579
  }
580
+ // Size guard: a canonical contract above 64KB is a retryable-invalid defect
581
+ // (compress the contract) rather than a writer authorization failure.
582
+ const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
583
+ if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
584
+ return finalizePrewrite(input, {
585
+ ...basePending,
586
+ verdict,
587
+ candidateJsonSha256: analysis.candidateJsonSha256,
588
+ normalizationActions: [],
589
+ mockStrategy: analysis.canonical.mockApi.strategy,
590
+ classification: "retryable-invalid",
591
+ failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
592
+ failureCode: "contract-too-large",
593
+ });
594
+ }
433
595
  const mockStrategy = analysis.canonical.mockApi.strategy;
434
596
  if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
435
597
  const quotedAllowed = input.config.allowedMockStrategies
@@ -498,6 +660,233 @@ export async function runFrontendPrewriteGate(input) {
498
660
  });
499
661
  }
500
662
  }
663
+ // Effective plan/review must actually read the candidate openspec paths when
664
+ // any exist. This fail-closes BEFORE the canonical contract is materialized
665
+ // so a writer is never authorized on top of unread normative evidence.
666
+ const candidatePaths = input.config.openspecCandidatePaths ?? [];
667
+ const candidateRoot = workspaceRoot ?? process.cwd();
668
+ // Drift precheck: candidates are frozen at generation time. A candidate that
669
+ // no longer exists on disk was deleted/renamed after generation and is a
670
+ // generation-contract defect, not an "unread" failure.
671
+ const missingCandidates = [];
672
+ if (candidatePaths.length > 0) {
673
+ for (const candidate of candidatePaths) {
674
+ try {
675
+ await stat(path.join(candidateRoot, candidate));
676
+ }
677
+ catch {
678
+ missingCandidates.push(candidate);
679
+ }
680
+ }
681
+ }
682
+ if (missingCandidates.length > 0) {
683
+ return finalizePrewrite(input, {
684
+ ...basePending,
685
+ verdict,
686
+ candidateJsonSha256: analysis.candidateJsonSha256,
687
+ normalizationActions: [],
688
+ mockStrategy,
689
+ classification: "blocked",
690
+ failureReason: `frontend prewrite gate blocked: openspec candidate paths frozen at generation time no longer exist on disk: ${missingCandidates.join(", ")}. 候选集在生成期冻结,这些候选在生成后被删除/改名,请修正后重新生成 DAG。`,
691
+ failureCode: "candidate-missing-drift",
692
+ openspecReadPaths: [],
693
+ openspecCandidatePaths: candidatePaths,
694
+ });
695
+ }
696
+ let openspecReadPaths;
697
+ if (openspecPolicy === "cited") {
698
+ openspecReadPaths = await collectOpenspecReadPaths({
699
+ runDir: input.runDir,
700
+ planNodeId,
701
+ reviewNodeId,
702
+ repoRoot: candidateRoot,
703
+ });
704
+ if (candidatePaths.length > 0) {
705
+ const parsed = parseOpenspecCitations(planText, candidateRoot);
706
+ if (!parsed.blockPresent || parsed.unparseable) {
707
+ return finalizePrewrite(input, {
708
+ ...basePending,
709
+ verdict,
710
+ candidateJsonSha256: analysis.candidateJsonSha256,
711
+ normalizationActions: [],
712
+ mockStrategy,
713
+ classification: "retryable-invalid",
714
+ failureReason: `frontend prewrite gate: the effective plan output must end with a parseable \`\`\`openspec-citations fenced block (one JSON {"path","section","line"} per line). ${parsed.blockPresent
715
+ ? "The block is present but contains unparseable lines."
716
+ : "No openspec-citations block was found."}`,
717
+ failureCode: "openspec-citation-block-unparseable",
718
+ openspecReadPaths,
719
+ openspecCandidatePaths: candidatePaths,
720
+ });
721
+ }
722
+ const citedPaths = new Set(parsed.citations.map((citation) => citation.path));
723
+ const notCited = candidatePaths.filter((candidate) => !citedPaths.has(candidate));
724
+ if (notCited.length > 0) {
725
+ return finalizePrewrite(input, {
726
+ ...basePending,
727
+ verdict,
728
+ candidateJsonSha256: analysis.candidateJsonSha256,
729
+ normalizationActions: [],
730
+ mockStrategy,
731
+ classification: "retryable-invalid",
732
+ failureReason: `frontend prewrite gate: these openspec candidate paths were not cited in the plan openspec-citations block: ${notCited.join(", ")}`,
733
+ failureCode: "openspec-not-cited",
734
+ openspecReadPaths,
735
+ openspecCandidatePaths: candidatePaths,
736
+ });
737
+ }
738
+ const readSet = new Set(openspecReadPaths);
739
+ const uncorroborated = [
740
+ ...new Set(parsed.citations
741
+ .filter((citation) => !readSet.has(citation.path))
742
+ .map((citation) => citation.path)),
743
+ ];
744
+ if (uncorroborated.length > 0) {
745
+ return finalizePrewrite(input, {
746
+ ...basePending,
747
+ verdict,
748
+ candidateJsonSha256: analysis.candidateJsonSha256,
749
+ normalizationActions: [],
750
+ mockStrategy,
751
+ classification: "retryable-invalid",
752
+ failureReason: `frontend prewrite gate: these openspec paths were cited in the plan but have no successful read event in the effective plan/review: ${uncorroborated.join(", ")}`,
753
+ failureCode: "openspec-citation-not-read",
754
+ openspecReadPaths,
755
+ openspecCandidatePaths: candidatePaths,
756
+ });
757
+ }
758
+ }
759
+ // cited with empty candidates: no read enforcement. The generation step
760
+ // emits an advisory instead of failing the gate.
761
+ }
762
+ else {
763
+ openspecReadPaths = await checkOpenspecReadEvidence({
764
+ runDir: input.runDir,
765
+ candidatePaths,
766
+ planNodeId,
767
+ reviewNodeId,
768
+ repoRoot: candidateRoot,
769
+ });
770
+ if (candidatePaths.length > 0) {
771
+ const readSet = new Set(openspecReadPaths);
772
+ const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
773
+ if (unread.length > 0) {
774
+ const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
775
+ const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
776
+ return finalizePrewrite(input, {
777
+ ...basePending,
778
+ verdict,
779
+ candidateJsonSha256: analysis.candidateJsonSha256,
780
+ normalizationActions: [],
781
+ mockStrategy,
782
+ classification: "retryable-invalid",
783
+ failureReason: `frontend prewrite gate: effective plan/review did not read these openspec candidate paths: ${unread.join(", ")}. Read each candidate and report applicable rules plus hit path/section/line number, conflicts, and missing specifications, then rerun. 实际成功读取: ${openspecReadPaths.length > 0 ? openspecReadPaths.join(", ") : "(none)"}; 候选总数: ${candidatePaths.length}; 排除治理目录 ${governanceRoot} 下 ${excludedGovernanceCount} 个文件(运行期诊断口径); 候选集在生成期冻结,若候选与本任务无关属于生成期契约配置问题,应修正后重新生成 DAG。`,
784
+ failureCode: "openspec-not-read",
785
+ openspecReadPaths,
786
+ openspecCandidatePaths: candidatePaths,
787
+ });
788
+ }
789
+ }
790
+ }
791
+ // Component selection spec conformance (ADR 0016). Deterministic cross-check
792
+ // that declared component/token selections trace to the frontend spec
793
+ // (openspec/ai_workspace component + theme) instead of a model's ad-hoc
794
+ // "better" choice. Runs after the openspec read gate so citation + read
795
+ // evidence are already resolved.
796
+ const componentChoices = analysis.canonical.uiComponentChoices ?? [];
797
+ const componentSpecCandidates = input.config.componentSpecCandidatePaths ?? [];
798
+ const hasUiVisibleWork = analysis.canonical.uiStates.some((state) => state.applicable) ||
799
+ analysis.canonical.interactions.length > 0;
800
+ if (componentSpecCandidates.length > 0 &&
801
+ hasUiVisibleWork &&
802
+ componentChoices.length === 0) {
803
+ return finalizePrewrite(input, {
804
+ ...basePending,
805
+ verdict,
806
+ candidateJsonSha256: analysis.candidateJsonSha256,
807
+ normalizationActions: [],
808
+ mockStrategy,
809
+ classification: "retryable-invalid",
810
+ failureReason: "frontend prewrite gate: the canonical contract has UI-visible work but uiComponentChoices is missing or empty while the generation-frozen component/theme specification bucket is non-empty. 为每个 UI 用途声明组件选型(decision: specified | reuse-existing | new),specified 必须给 specReference.path/section/line,new 必须给 rationale 声明偏差。",
811
+ failureCode: "component-choices-missing",
812
+ openspecReadPaths,
813
+ openspecCandidatePaths: candidatePaths,
814
+ });
815
+ }
816
+ const componentSpecReferences = componentChoices
817
+ .map((choice) => ({
818
+ raw: choice.specReference?.path ?? "",
819
+ normalized: choice.specReference?.path
820
+ ? normalizeCitationPath(choice.specReference.path, candidateRoot)
821
+ : null,
822
+ }))
823
+ .filter((ref) => ref.raw.length > 0);
824
+ if (componentSpecReferences.length > 0) {
825
+ const invalidRefs = [
826
+ ...new Set(componentSpecReferences
827
+ .filter((ref) => !ref.normalized ||
828
+ !isOpenspecSpecFilePath(ref.normalized))
829
+ .map((ref) => ref.raw)),
830
+ ];
831
+ if (invalidRefs.length > 0) {
832
+ return finalizePrewrite(input, {
833
+ ...basePending,
834
+ verdict,
835
+ candidateJsonSha256: analysis.candidateJsonSha256,
836
+ normalizationActions: [],
837
+ mockStrategy,
838
+ classification: "retryable-invalid",
839
+ failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths are not supported openspec/ai_workspace spec files: ${invalidRefs.join(", ")}`,
840
+ failureCode: "component-spec-reference-invalid",
841
+ openspecReadPaths,
842
+ openspecCandidatePaths: candidatePaths,
843
+ });
844
+ }
845
+ if (openspecPolicy === "cited") {
846
+ const parsedCitations = parseOpenspecCitations(planText, candidateRoot);
847
+ const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
848
+ const readSet = new Set(openspecReadPaths);
849
+ const uncorroborated = [
850
+ ...new Set(componentSpecReferences
851
+ .filter((ref) => !ref.normalized ||
852
+ !citedPaths.has(ref.normalized) ||
853
+ !readSet.has(ref.normalized))
854
+ .map((ref) => ref.raw)),
855
+ ];
856
+ if (uncorroborated.length > 0) {
857
+ return finalizePrewrite(input, {
858
+ ...basePending,
859
+ verdict,
860
+ candidateJsonSha256: analysis.candidateJsonSha256,
861
+ normalizationActions: [],
862
+ mockStrategy,
863
+ classification: "retryable-invalid",
864
+ failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths were not cited in the plan openspec-citations block or lack a successful read event: ${uncorroborated.join(", ")}`,
865
+ failureCode: "component-spec-not-cited",
866
+ openspecReadPaths,
867
+ openspecCandidatePaths: candidatePaths,
868
+ });
869
+ }
870
+ }
871
+ }
872
+ // Render the deterministic plan.md from the canonical contract (fail-closed
873
+ // before any artifact is written: no contract, no writer authorization).
874
+ let planMarkdown;
875
+ try {
876
+ planMarkdown = renderFrontendPlanMarkdown(analysis.canonical);
877
+ }
878
+ catch (error) {
879
+ return finalizePrewrite(input, {
880
+ ...basePending,
881
+ verdict,
882
+ candidateJsonSha256: analysis.candidateJsonSha256,
883
+ normalizationActions: [],
884
+ mockStrategy,
885
+ classification: "retryable-invalid",
886
+ failureReason: `frontend prewrite gate: failed to render ${input.config.planMdArtifactName ?? "frontend-plan.md"}: ${error instanceof Error ? error.message : String(error)}`,
887
+ failureCode: "contract-invalid",
888
+ });
889
+ }
501
890
  // Materialize the canonical contract only after every governance check passed.
502
891
  const artifact = await writeFrontendImplementationContractArtifact({
503
892
  runDir: input.runDir,
@@ -505,14 +894,8 @@ export async function runFrontendPrewriteGate(input) {
505
894
  artifactName: input.config.artifactName,
506
895
  canonical: analysis.canonical,
507
896
  });
508
- const candidatePaths = input.config.openspecCandidatePaths ?? [];
509
- const openspecReadPaths = await checkOpenspecReadEvidence({
510
- runDir: input.runDir,
511
- candidatePaths,
512
- planNodeId,
513
- reviewNodeId,
514
- repoRoot: workspaceRoot ?? process.cwd(),
515
- });
897
+ // Materialize the rendered plan.md in the same outputDir as the contract.
898
+ await writeTextArtifactFile(path.join(input.runDir, path.posix.join(input.config.outputDir, input.config.planMdArtifactName ?? "frontend-plan.md")), planMarkdown);
516
899
  const classification = analysis.normalizationActions.length > 0 ? "accepted-normalized" : "accepted";
517
900
  return finalizePrewrite(input, {
518
901
  ...basePending,
@@ -551,12 +934,20 @@ export function formatFrontendPrewriteGateStdout(result) {
551
934
  lines.push(`SHA-256: ${result.artifact.sha256}`);
552
935
  }
553
936
  lines.push(`Prewrite result: ${result.prewriteResult.path}`);
937
+ lines.push(`Openspec policy: ${result.openspecPolicy}`);
554
938
  if (result.failureReason)
555
939
  lines.push(`Failure reason: ${result.failureReason}`);
556
940
  if (result.openspecReadPaths.length > 0) {
557
941
  lines.push(`openspec read: ${result.openspecReadPaths.join(", ")}`);
558
942
  }
559
- else if (result.openspecCandidatePaths.length === 0) {
943
+ if (result.openspecCandidatePaths.length > 0) {
944
+ const readSet = new Set(result.openspecReadPaths);
945
+ const unread = result.openspecCandidatePaths.filter((candidate) => !readSet.has(candidate));
946
+ if (unread.length > 0) {
947
+ lines.push(`openspec unread: ${unread.join(", ")}`);
948
+ }
949
+ }
950
+ else {
560
951
  lines.push("openspec: unavailable");
561
952
  }
562
953
  return lines.join("\n");