@tea-agent/loop-agent 0.38.0 → 0.38.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/dist/application/dag/generate-task-dag.js +9 -0
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/commands/init-upgrade.js +97 -19
  5. package/dist/executors/dag-pi-executor.js +42 -4
  6. package/dist/executors/shell-executor.js +269 -15
  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 +149 -27
  10. package/dist/task/config-types.js +43 -0
  11. package/dist/task/frontend-project-capability.js +292 -38
  12. package/dist/worker/console/chat/deferred-turn.js +91 -0
  13. package/dist/worker/console/chat/mcp-inventory.js +221 -0
  14. package/dist/worker/console/chat/pi-runtime.js +165 -83
  15. package/dist/worker/console/chat/repo-walk.js +10 -1
  16. package/dist/worker/console/chat/routes.js +477 -55
  17. package/dist/worker/console/chat/semantic-activity.js +20 -8
  18. package/dist/worker/console/chat/session-store.js +146 -2
  19. package/dist/worker/console/chat/turn-process.js +17 -30
  20. package/dist/worker/console/harness-pi-model-matrix.js +137 -0
  21. package/dist/worker/console/operator-actions.js +128 -4
  22. package/dist/worker/console/operator-user-error.js +19 -0
  23. package/dist/worker/console/pi-readiness.js +8 -1
  24. package/dist/worker/console/recovery-cta.js +4 -4
  25. package/dist/worker/console/recovery-error-copy.js +4 -4
  26. package/dist/worker/console/security.js +20 -1
  27. package/dist/worker/console/server.js +6 -1
  28. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
  29. package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-SAD7McOS.js} +1 -1
  30. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
  31. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
  32. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
  33. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
  34. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
  44. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
  45. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
  46. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
  47. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
  48. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
  49. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
  55. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
  56. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
  57. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
  58. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
  59. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
  60. package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
  61. package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
  62. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
  63. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
  64. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
  65. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
  66. package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-D9Py31Ld.js} +1 -1
  67. package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-D7Occzk_.js} +5 -5
  68. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
  69. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
  70. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
  71. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
  72. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
  73. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
  74. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
  75. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
  76. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
  77. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
  78. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
  79. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
  80. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
  81. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
  82. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
  83. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
  84. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
  85. package/dist/worker/console/static/index.html +2 -2
  86. package/dist/worker/console/static-src/app/console-types.js +39 -0
  87. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  88. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  89. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +82 -22
  90. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
  91. package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
  92. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  93. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  94. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +18 -0
  95. package/dist/worker/console/static-src/operator-chat/useChatStream.js +216 -10
  96. package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
  97. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
  98. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
  99. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  100. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  101. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  102. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  103. package/dist/workflows/dag/backend-test-layout.js +133 -0
  104. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  105. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  106. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  107. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  108. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  109. package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
  110. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  111. package/dist/workflows/dag/contract-validator-registrations.js +3 -1
  112. package/dist/workflows/dag/convergence/controller.js +141 -0
  113. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  114. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  115. package/dist/workflows/dag/failure-routing.js +9 -4
  116. package/dist/workflows/dag/frontend-implementation-contract.js +386 -0
  117. package/dist/workflows/dag/frontend-plan-render.js +90 -0
  118. package/dist/workflows/dag/frontend-prewrite-gate.js +479 -28
  119. package/dist/workflows/dag/init-hybrid.js +920 -110
  120. package/dist/workflows/dag/lifecycle.js +4 -0
  121. package/dist/workflows/dag/node-execution.js +77 -8
  122. package/dist/workflows/dag/prompt.js +45 -0
  123. package/dist/workflows/dag/report.js +6 -0
  124. package/dist/workflows/dag/rerun-feedback.js +212 -0
  125. package/dist/workflows/dag/rerun-plan.js +93 -27
  126. package/dist/workflows/dag/rerun-run.js +3 -0
  127. package/dist/workflows/dag/rerun-task.js +61 -2
  128. package/dist/workflows/dag/retry-policy.js +33 -0
  129. package/dist/workflows/dag/types.js +148 -4
  130. package/dist/workflows/dag/validate.js +7 -2
  131. package/docs/templates/backend-test-dag.json +60 -35
  132. package/docs/templates/frontend-design-contract.md +40 -14
  133. package/docs/templates/frontend-implementation-contract.schema.json +9 -2
  134. package/docs/templates/frontend-task-constraints.md +40 -13
  135. package/docs/templates/frontend-task-requirement.md +54 -20
  136. package/harness.json +3 -1
  137. package/package.json +1 -1
  138. package/skills/frontend-design-review/references/review-checklist.md +2 -0
  139. package/skills/frontend-implementation/references/design-spec.md +16 -19
  140. package/skills/frontend-implementation/references/node-contracts.md +15 -11
  141. package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
  142. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
  143. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
  144. package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
  145. package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
  146. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
  147. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
@@ -2,11 +2,17 @@ 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";
11
+ import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
8
12
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
9
13
  export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
14
+ /** Canonical contract serialization must stay at or below this byte size. */
15
+ export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
10
16
  /**
11
17
  * Machine-readable failure classification for blocked/retryable-invalid
12
18
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -25,6 +31,16 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
25
31
  "mock-strategy-no-verification-commands",
26
32
  "target-outside-write-set",
27
33
  "verification-target-outside-write-set",
34
+ "openspec-not-read",
35
+ "candidate-missing-drift",
36
+ "openspec-citation-block-unparseable",
37
+ "openspec-not-cited",
38
+ "openspec-citation-not-read",
39
+ "component-spec-reference-invalid",
40
+ "component-spec-not-cited",
41
+ "component-choices-missing",
42
+ "contract-too-large",
43
+ "revision-patch-unmergeable",
28
44
  ]);
29
45
  export const frontendPrewriteResultV1Schema = z
30
46
  .object({
@@ -146,9 +162,12 @@ async function readNodeText(runDir, nodeId) {
146
162
  if (record.status !== "FINISHED") {
147
163
  throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
148
164
  }
149
- const text = [record.assistantText, record.stdout]
150
- .filter((value) => Boolean(value?.trim()))
151
- .join("\n");
165
+ // Pi execution mirrors a successful structured-contract normalization into
166
+ // both assistantText and stdout. Treat node output the same way as the DAG
167
+ // runtime's canonicalNodeOutput(): assistantText is authoritative and stdout
168
+ // is only a fallback. Concatenating the two duplicate fields makes a single
169
+ // canonical plan appear as two complete JSON contracts to the prewrite gate.
170
+ const text = record.assistantText?.trim() || record.stdout?.trim() || "";
152
171
  if (!text)
153
172
  throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
154
173
  return text;
@@ -157,16 +176,53 @@ async function readNodeRawText(runDir, nodeId) {
157
176
  const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
158
177
  return record.assistantText ?? record.stdout ?? "";
159
178
  }
179
+ /**
180
+ * Same shape predicate as the internal `isContract` inside
181
+ * `extractFrontendImplementationJson` (frontend-implementation-contract.ts):
182
+ * schemaVersion 1 + a plain `targets` object + `requirements` and
183
+ * `verificationTargets` arrays. A merge-patch delta does not carry these
184
+ * fields, so this is the deterministic boundary between a full contract and a
185
+ * patch. Keep the two predicates in sync.
186
+ */
187
+ function isFullContractShape(value) {
188
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
189
+ return false;
190
+ }
191
+ const record = value;
192
+ const targets = record.targets;
193
+ return (record.schemaVersion === 1 &&
194
+ targets !== null &&
195
+ typeof targets === "object" &&
196
+ !Array.isArray(targets) &&
197
+ Array.isArray(record.requirements) &&
198
+ Array.isArray(record.verificationTargets));
199
+ }
200
+ /**
201
+ * Shared merge pipeline for revisionPatch mode and content-driven
202
+ * compatibility: read the original contract node, extract the original
203
+ * contract and the effective node's patch, apply the RFC 7386 merge-patch, and
204
+ * serialize deterministically. Throws on any failure; callers map the error to
205
+ * their own classification (retryable-invalid for explicit revisionPatch mode,
206
+ * blocked for content-driven fallback).
207
+ */
208
+ async function mergeRevisionPatch(runDir, planText, originalNodeId) {
209
+ const originalText = await readNodeText(runDir, originalNodeId);
210
+ const originalValue = extractFrontendImplementationJson(originalText);
211
+ const patchValue = extractFrontendImplementationJson(planText);
212
+ const merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
213
+ return serializeDeterministicJson(merged);
214
+ }
160
215
  function firstNonEmptyVerdictLine(text) {
161
216
  const lines = text
162
217
  .split(/\r?\n/)
163
218
  .map((line) => line.trim())
164
219
  .filter(Boolean);
165
- const normalize = (line) => {
166
- const emphasized = line.match(/^(?:`{1,3}|\*{1,3})\s*(VERDICT:[^`*]+?)\s*(?:`{1,3}|\*{1,3})$/);
167
- return (emphasized?.[1] ?? line).trim();
168
- };
169
- const normalizedLines = lines.map(normalize);
220
+ // Emphasis stripping (backtick + asterisk) is shared with the runIf
221
+ // selector and node-level output-protocol validation via
222
+ // normalizeVerdictCandidateLine. The gate keeps its own loose
223
+ // pass/request-revision normalization below (prefix match + trailing
224
+ // prose tolerance) for deterministic fail-closed behavior.
225
+ const normalizedLines = lines.map(normalizeVerdictCandidateLine);
170
226
  const first = normalizedLines[0] ?? "";
171
227
  // The prompt requires VERDICT to be the first non-empty line, but model
172
228
  // output can still prepend a summary. Keep the protocol strict in the
@@ -196,14 +252,9 @@ function isFailedToolResult(event) {
196
252
  event.result?.error != null ||
197
253
  event.result?.ok === false);
198
254
  }
199
- async function checkOpenspecReadEvidence(input) {
200
- const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
201
- if (candidatePaths.length === 0)
202
- return [];
255
+ async function collectOpenspecReadPaths(input) {
256
+ const { runDir, planNodeId, reviewNodeId, repoRoot } = input;
203
257
  const matched = new Set();
204
- const normalizedCandidates = new Set(candidatePaths
205
- .map((candidate) => toRepoRelativePath(candidate, repoRoot))
206
- .filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
207
258
  for (const nodeId of [planNodeId, reviewNodeId]) {
208
259
  const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
209
260
  try {
@@ -237,7 +288,7 @@ async function checkOpenspecReadEvidence(input) {
237
288
  : null;
238
289
  if (relativePath &&
239
290
  !isFailedToolResult(event) &&
240
- normalizedCandidates.has(relativePath)) {
291
+ isOpenspecSpecFilePath(relativePath)) {
241
292
  matched.add(relativePath);
242
293
  }
243
294
  startedReads.delete(event.toolCallId);
@@ -252,6 +303,87 @@ async function checkOpenspecReadEvidence(input) {
252
303
  }
253
304
  return [...matched];
254
305
  }
306
+ async function checkOpenspecReadEvidence(input) {
307
+ const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
308
+ if (candidatePaths.length === 0)
309
+ return [];
310
+ const allReadPaths = await collectOpenspecReadPaths({
311
+ runDir,
312
+ planNodeId,
313
+ reviewNodeId,
314
+ repoRoot,
315
+ });
316
+ const normalizedCandidates = new Set(candidatePaths
317
+ .map((candidate) => toRepoRelativePath(candidate, repoRoot))
318
+ .filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
319
+ return allReadPaths.filter((readPath) => normalizedCandidates.has(readPath));
320
+ }
321
+ function normalizeCitationPath(rawPath, repoRoot) {
322
+ let candidate = rawPath.trim();
323
+ const hashIndex = candidate.indexOf("#");
324
+ if (hashIndex >= 0)
325
+ candidate = candidate.slice(0, hashIndex);
326
+ candidate = candidate.replace(/\\/g, "/");
327
+ if (!candidate)
328
+ return null;
329
+ if (repoRoot) {
330
+ const relative = toRepoRelativePath(candidate, repoRoot);
331
+ return relative ? relative.replace(/\\/g, "/") : null;
332
+ }
333
+ return candidate;
334
+ }
335
+ /**
336
+ * Parses the deterministic openspec-citations fence from the effective plan
337
+ * output. Each non-empty line must be one JSON object {"path","section","line"}.
338
+ * Rows whose normalized path is not a supported openspec spec file are ignored
339
+ * (out of scope); rows that are invalid JSON or lack a string path mark the
340
+ * block unparseable (fail-closed when candidates are non-empty).
341
+ */
342
+ export function parseOpenspecCitations(planText, repoRoot) {
343
+ const citations = [];
344
+ let blockPresent = false;
345
+ let unparseable = false;
346
+ const fenceRe = /```openspec-citations[ \t]*\r?\n([\s\S]*?)\r?\n```/g;
347
+ let match;
348
+ while ((match = fenceRe.exec(planText)) !== null) {
349
+ blockPresent = true;
350
+ const body = match[1] ?? "";
351
+ for (const rawLine of body.split(/\r?\n/)) {
352
+ const line = rawLine.trim();
353
+ if (!line)
354
+ continue;
355
+ let parsed;
356
+ try {
357
+ parsed = JSON.parse(line);
358
+ }
359
+ catch {
360
+ unparseable = true;
361
+ continue;
362
+ }
363
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
364
+ unparseable = true;
365
+ continue;
366
+ }
367
+ const rawPath = parsed.path;
368
+ if (typeof rawPath !== "string" || !rawPath.trim()) {
369
+ unparseable = true;
370
+ continue;
371
+ }
372
+ const normalized = normalizeCitationPath(rawPath, repoRoot);
373
+ if (!normalized || !isOpenspecSpecFilePath(normalized))
374
+ continue;
375
+ const section = typeof parsed.section === "string"
376
+ ? parsed.section
377
+ : "";
378
+ const lineValue = parsed.line;
379
+ const citationLine = typeof lineValue === "number" && Number.isInteger(lineValue)
380
+ ? lineValue
381
+ : null;
382
+ citations.push({ path: normalized, section, line: citationLine });
383
+ }
384
+ }
385
+ return { blockPresent, unparseable, citations };
386
+ }
255
387
  async function finalizePrewrite(input, pending) {
256
388
  const sourceBindingSha256 = input.sourceBinding
257
389
  ? deterministicSha256(input.sourceBinding)
@@ -298,6 +430,7 @@ async function finalizePrewrite(input, pending) {
298
430
  canonicalSha256: pending.canonicalSha256,
299
431
  openspecReadPaths: pending.openspecReadPaths,
300
432
  openspecCandidatePaths: pending.openspecCandidatePaths,
433
+ openspecPolicy: pending.openspecPolicy,
301
434
  };
302
435
  }
303
436
  export async function runFrontendPrewriteGate(input) {
@@ -307,6 +440,7 @@ export async function runFrontendPrewriteGate(input) {
307
440
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
308
441
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
309
442
  const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
443
+ const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
310
444
  const basePending = {
311
445
  planNodeId,
312
446
  reviewNodeId,
@@ -322,6 +456,7 @@ export async function runFrontendPrewriteGate(input) {
322
456
  classification: "accepted",
323
457
  openspecReadPaths: [],
324
458
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
459
+ openspecPolicy,
325
460
  };
326
461
  let planText;
327
462
  try {
@@ -357,7 +492,78 @@ export async function runFrontendPrewriteGate(input) {
357
492
  failureCode: "verdict-not-pass",
358
493
  });
359
494
  }
360
- const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
495
+ // revisionPatch mode: the effective plan node emits an RFC 7386 merge-patch
496
+ // delta against the original contract node (planFallbackFromNodeIds[0]).
497
+ // Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
498
+ const revisionPatchMode = input.config.revisionPatch === true &&
499
+ planNodeId === input.config.planFromNodeId &&
500
+ input.config.planFallbackFromNodeIds.length > 0;
501
+ let effectiveContractText = null;
502
+ if (revisionPatchMode) {
503
+ const originalNodeId = input.config.planFallbackFromNodeIds[0];
504
+ try {
505
+ effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
506
+ }
507
+ catch (error) {
508
+ return finalizePrewrite(input, {
509
+ ...basePending,
510
+ verdict,
511
+ classification: "retryable-invalid",
512
+ failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
513
+ failureCode: "contract-invalid",
514
+ });
515
+ }
516
+ }
517
+ else {
518
+ // Content-driven compatibility (Bug 1): legacy DAGs without
519
+ // revisionPatch can still receive a merge-patch delta from the revision
520
+ // node. Detect the output shape instead of trusting the DAG flag alone.
521
+ // - Full contract shape → keep legacy full-contract semantics unchanged.
522
+ // - Patch shape with a usable original contract node → auto-merge and
523
+ // run the existing analysis/materialization pipeline.
524
+ // - Patch shape but unmergeable → blocked + revision-patch-unmergeable
525
+ // (no retryable loop).
526
+ let planJsonValue = null;
527
+ try {
528
+ planJsonValue = extractFrontendImplementationJson(planText);
529
+ }
530
+ catch {
531
+ // Not machine-readable JSON: fall back to the legacy full-contract
532
+ // path where analyzeFrontendImplementationContract surfaces the
533
+ // original diagnostic (fail-closed without a new failure mode).
534
+ }
535
+ if (planJsonValue !== null && !isFullContractShape(planJsonValue)) {
536
+ const originalNodeId = input.config.planFallbackFromNodeIds[0];
537
+ if (!originalNodeId || originalNodeId === planNodeId) {
538
+ return finalizePrewrite(input, {
539
+ ...basePending,
540
+ verdict,
541
+ classification: "blocked",
542
+ failureReason: "frontend prewrite gate: the effective plan output is a merge-patch delta but no distinct original contract node is available. Check the DAG revisionPatch/planFallbackFromNodeIds configuration or regenerate the DAG.",
543
+ failureCode: "revision-patch-unmergeable",
544
+ });
545
+ }
546
+ try {
547
+ const originalText = await readNodeText(input.runDir, originalNodeId);
548
+ const originalValue = extractFrontendImplementationJson(originalText);
549
+ if (!isFullContractShape(originalValue)) {
550
+ throw new Error(`original contract node ${originalNodeId} is not a full frontend implementation contract`);
551
+ }
552
+ effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
553
+ }
554
+ catch (error) {
555
+ return finalizePrewrite(input, {
556
+ ...basePending,
557
+ verdict,
558
+ classification: "blocked",
559
+ failureReason: `frontend prewrite gate: the effective plan output is a merge-patch delta but it cannot be merged onto the original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}. Check the DAG revisionPatch/planFallbackFromNodeIds configuration or regenerate the DAG.`,
560
+ failureCode: "revision-patch-unmergeable",
561
+ });
562
+ }
563
+ }
564
+ }
565
+ const requirementIdSearchText = effectiveContractText ?? planText;
566
+ const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
361
567
  if (missingIds.length > 0) {
362
568
  return finalizePrewrite(input, {
363
569
  ...basePending,
@@ -410,7 +616,8 @@ export async function runFrontendPrewriteGate(input) {
410
616
  try {
411
617
  analysis = await analyzeFrontendImplementationContract({
412
618
  runDir: input.runDir,
413
- fromNodeId: planNodeId,
619
+ fromNodeId: effectiveContractText ? undefined : planNodeId,
620
+ rawContractText: effectiveContractText ?? undefined,
414
621
  sourceBinding: input.sourceBinding,
415
622
  });
416
623
  }
@@ -430,6 +637,21 @@ export async function runFrontendPrewriteGate(input) {
430
637
  }
431
638
  throw error;
432
639
  }
640
+ // Size guard: a canonical contract above 64KB is a retryable-invalid defect
641
+ // (compress the contract) rather than a writer authorization failure.
642
+ const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
643
+ if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
644
+ return finalizePrewrite(input, {
645
+ ...basePending,
646
+ verdict,
647
+ candidateJsonSha256: analysis.candidateJsonSha256,
648
+ normalizationActions: [],
649
+ mockStrategy: analysis.canonical.mockApi.strategy,
650
+ classification: "retryable-invalid",
651
+ failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
652
+ failureCode: "contract-too-large",
653
+ });
654
+ }
433
655
  const mockStrategy = analysis.canonical.mockApi.strategy;
434
656
  if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
435
657
  const quotedAllowed = input.config.allowedMockStrategies
@@ -498,6 +720,233 @@ export async function runFrontendPrewriteGate(input) {
498
720
  });
499
721
  }
500
722
  }
723
+ // Effective plan/review must actually read the candidate openspec paths when
724
+ // any exist. This fail-closes BEFORE the canonical contract is materialized
725
+ // so a writer is never authorized on top of unread normative evidence.
726
+ const candidatePaths = input.config.openspecCandidatePaths ?? [];
727
+ const candidateRoot = workspaceRoot ?? process.cwd();
728
+ // Drift precheck: candidates are frozen at generation time. A candidate that
729
+ // no longer exists on disk was deleted/renamed after generation and is a
730
+ // generation-contract defect, not an "unread" failure.
731
+ const missingCandidates = [];
732
+ if (candidatePaths.length > 0) {
733
+ for (const candidate of candidatePaths) {
734
+ try {
735
+ await stat(path.join(candidateRoot, candidate));
736
+ }
737
+ catch {
738
+ missingCandidates.push(candidate);
739
+ }
740
+ }
741
+ }
742
+ if (missingCandidates.length > 0) {
743
+ return finalizePrewrite(input, {
744
+ ...basePending,
745
+ verdict,
746
+ candidateJsonSha256: analysis.candidateJsonSha256,
747
+ normalizationActions: [],
748
+ mockStrategy,
749
+ classification: "blocked",
750
+ failureReason: `frontend prewrite gate blocked: openspec candidate paths frozen at generation time no longer exist on disk: ${missingCandidates.join(", ")}. 候选集在生成期冻结,这些候选在生成后被删除/改名,请修正后重新生成 DAG。`,
751
+ failureCode: "candidate-missing-drift",
752
+ openspecReadPaths: [],
753
+ openspecCandidatePaths: candidatePaths,
754
+ });
755
+ }
756
+ let openspecReadPaths;
757
+ if (openspecPolicy === "cited") {
758
+ openspecReadPaths = await collectOpenspecReadPaths({
759
+ runDir: input.runDir,
760
+ planNodeId,
761
+ reviewNodeId,
762
+ repoRoot: candidateRoot,
763
+ });
764
+ if (candidatePaths.length > 0) {
765
+ const parsed = parseOpenspecCitations(planText, candidateRoot);
766
+ if (!parsed.blockPresent || parsed.unparseable) {
767
+ return finalizePrewrite(input, {
768
+ ...basePending,
769
+ verdict,
770
+ candidateJsonSha256: analysis.candidateJsonSha256,
771
+ normalizationActions: [],
772
+ mockStrategy,
773
+ classification: "retryable-invalid",
774
+ 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
775
+ ? "The block is present but contains unparseable lines."
776
+ : "No openspec-citations block was found."}`,
777
+ failureCode: "openspec-citation-block-unparseable",
778
+ openspecReadPaths,
779
+ openspecCandidatePaths: candidatePaths,
780
+ });
781
+ }
782
+ const citedPaths = new Set(parsed.citations.map((citation) => citation.path));
783
+ const notCited = candidatePaths.filter((candidate) => !citedPaths.has(candidate));
784
+ if (notCited.length > 0) {
785
+ return finalizePrewrite(input, {
786
+ ...basePending,
787
+ verdict,
788
+ candidateJsonSha256: analysis.candidateJsonSha256,
789
+ normalizationActions: [],
790
+ mockStrategy,
791
+ classification: "retryable-invalid",
792
+ failureReason: `frontend prewrite gate: these openspec candidate paths were not cited in the plan openspec-citations block: ${notCited.join(", ")}`,
793
+ failureCode: "openspec-not-cited",
794
+ openspecReadPaths,
795
+ openspecCandidatePaths: candidatePaths,
796
+ });
797
+ }
798
+ const readSet = new Set(openspecReadPaths);
799
+ const uncorroborated = [
800
+ ...new Set(parsed.citations
801
+ .filter((citation) => !readSet.has(citation.path))
802
+ .map((citation) => citation.path)),
803
+ ];
804
+ if (uncorroborated.length > 0) {
805
+ return finalizePrewrite(input, {
806
+ ...basePending,
807
+ verdict,
808
+ candidateJsonSha256: analysis.candidateJsonSha256,
809
+ normalizationActions: [],
810
+ mockStrategy,
811
+ classification: "retryable-invalid",
812
+ 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(", ")}`,
813
+ failureCode: "openspec-citation-not-read",
814
+ openspecReadPaths,
815
+ openspecCandidatePaths: candidatePaths,
816
+ });
817
+ }
818
+ }
819
+ // cited with empty candidates: no read enforcement. The generation step
820
+ // emits an advisory instead of failing the gate.
821
+ }
822
+ else {
823
+ openspecReadPaths = await checkOpenspecReadEvidence({
824
+ runDir: input.runDir,
825
+ candidatePaths,
826
+ planNodeId,
827
+ reviewNodeId,
828
+ repoRoot: candidateRoot,
829
+ });
830
+ if (candidatePaths.length > 0) {
831
+ const readSet = new Set(openspecReadPaths);
832
+ const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
833
+ if (unread.length > 0) {
834
+ const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
835
+ const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
836
+ return finalizePrewrite(input, {
837
+ ...basePending,
838
+ verdict,
839
+ candidateJsonSha256: analysis.candidateJsonSha256,
840
+ normalizationActions: [],
841
+ mockStrategy,
842
+ classification: "retryable-invalid",
843
+ 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。`,
844
+ failureCode: "openspec-not-read",
845
+ openspecReadPaths,
846
+ openspecCandidatePaths: candidatePaths,
847
+ });
848
+ }
849
+ }
850
+ }
851
+ // Component selection spec conformance (ADR 0016). Deterministic cross-check
852
+ // that declared component/token selections trace to the frontend spec
853
+ // (openspec/ai_workspace component + theme) instead of a model's ad-hoc
854
+ // "better" choice. Runs after the openspec read gate so citation + read
855
+ // evidence are already resolved.
856
+ const componentChoices = analysis.canonical.uiComponentChoices ?? [];
857
+ const componentSpecCandidates = input.config.componentSpecCandidatePaths ?? [];
858
+ const hasUiVisibleWork = analysis.canonical.uiStates.some((state) => state.applicable) ||
859
+ analysis.canonical.interactions.length > 0;
860
+ if (componentSpecCandidates.length > 0 &&
861
+ hasUiVisibleWork &&
862
+ componentChoices.length === 0) {
863
+ return finalizePrewrite(input, {
864
+ ...basePending,
865
+ verdict,
866
+ candidateJsonSha256: analysis.candidateJsonSha256,
867
+ normalizationActions: [],
868
+ mockStrategy,
869
+ classification: "retryable-invalid",
870
+ 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 声明偏差。",
871
+ failureCode: "component-choices-missing",
872
+ openspecReadPaths,
873
+ openspecCandidatePaths: candidatePaths,
874
+ });
875
+ }
876
+ const componentSpecReferences = componentChoices
877
+ .map((choice) => ({
878
+ raw: choice.specReference?.path ?? "",
879
+ normalized: choice.specReference?.path
880
+ ? normalizeCitationPath(choice.specReference.path, candidateRoot)
881
+ : null,
882
+ }))
883
+ .filter((ref) => ref.raw.length > 0);
884
+ if (componentSpecReferences.length > 0) {
885
+ const invalidRefs = [
886
+ ...new Set(componentSpecReferences
887
+ .filter((ref) => !ref.normalized ||
888
+ !isOpenspecSpecFilePath(ref.normalized))
889
+ .map((ref) => ref.raw)),
890
+ ];
891
+ if (invalidRefs.length > 0) {
892
+ return finalizePrewrite(input, {
893
+ ...basePending,
894
+ verdict,
895
+ candidateJsonSha256: analysis.candidateJsonSha256,
896
+ normalizationActions: [],
897
+ mockStrategy,
898
+ classification: "retryable-invalid",
899
+ failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths are not supported openspec/ai_workspace spec files: ${invalidRefs.join(", ")}`,
900
+ failureCode: "component-spec-reference-invalid",
901
+ openspecReadPaths,
902
+ openspecCandidatePaths: candidatePaths,
903
+ });
904
+ }
905
+ if (openspecPolicy === "cited") {
906
+ const parsedCitations = parseOpenspecCitations(planText, candidateRoot);
907
+ const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
908
+ const readSet = new Set(openspecReadPaths);
909
+ const uncorroborated = [
910
+ ...new Set(componentSpecReferences
911
+ .filter((ref) => !ref.normalized ||
912
+ !citedPaths.has(ref.normalized) ||
913
+ !readSet.has(ref.normalized))
914
+ .map((ref) => ref.raw)),
915
+ ];
916
+ if (uncorroborated.length > 0) {
917
+ return finalizePrewrite(input, {
918
+ ...basePending,
919
+ verdict,
920
+ candidateJsonSha256: analysis.candidateJsonSha256,
921
+ normalizationActions: [],
922
+ mockStrategy,
923
+ classification: "retryable-invalid",
924
+ 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(", ")}`,
925
+ failureCode: "component-spec-not-cited",
926
+ openspecReadPaths,
927
+ openspecCandidatePaths: candidatePaths,
928
+ });
929
+ }
930
+ }
931
+ }
932
+ // Render the deterministic plan.md from the canonical contract (fail-closed
933
+ // before any artifact is written: no contract, no writer authorization).
934
+ let planMarkdown;
935
+ try {
936
+ planMarkdown = renderFrontendPlanMarkdown(analysis.canonical);
937
+ }
938
+ catch (error) {
939
+ return finalizePrewrite(input, {
940
+ ...basePending,
941
+ verdict,
942
+ candidateJsonSha256: analysis.candidateJsonSha256,
943
+ normalizationActions: [],
944
+ mockStrategy,
945
+ classification: "retryable-invalid",
946
+ failureReason: `frontend prewrite gate: failed to render ${input.config.planMdArtifactName ?? "frontend-plan.md"}: ${error instanceof Error ? error.message : String(error)}`,
947
+ failureCode: "contract-invalid",
948
+ });
949
+ }
501
950
  // Materialize the canonical contract only after every governance check passed.
502
951
  const artifact = await writeFrontendImplementationContractArtifact({
503
952
  runDir: input.runDir,
@@ -505,14 +954,8 @@ export async function runFrontendPrewriteGate(input) {
505
954
  artifactName: input.config.artifactName,
506
955
  canonical: analysis.canonical,
507
956
  });
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
- });
957
+ // Materialize the rendered plan.md in the same outputDir as the contract.
958
+ await writeTextArtifactFile(path.join(input.runDir, path.posix.join(input.config.outputDir, input.config.planMdArtifactName ?? "frontend-plan.md")), planMarkdown);
516
959
  const classification = analysis.normalizationActions.length > 0 ? "accepted-normalized" : "accepted";
517
960
  return finalizePrewrite(input, {
518
961
  ...basePending,
@@ -551,12 +994,20 @@ export function formatFrontendPrewriteGateStdout(result) {
551
994
  lines.push(`SHA-256: ${result.artifact.sha256}`);
552
995
  }
553
996
  lines.push(`Prewrite result: ${result.prewriteResult.path}`);
997
+ lines.push(`Openspec policy: ${result.openspecPolicy}`);
554
998
  if (result.failureReason)
555
999
  lines.push(`Failure reason: ${result.failureReason}`);
556
1000
  if (result.openspecReadPaths.length > 0) {
557
1001
  lines.push(`openspec read: ${result.openspecReadPaths.join(", ")}`);
558
1002
  }
559
- else if (result.openspecCandidatePaths.length === 0) {
1003
+ if (result.openspecCandidatePaths.length > 0) {
1004
+ const readSet = new Set(result.openspecReadPaths);
1005
+ const unread = result.openspecCandidatePaths.filter((candidate) => !readSet.has(candidate));
1006
+ if (unread.length > 0) {
1007
+ lines.push(`openspec unread: ${unread.join(", ")}`);
1008
+ }
1009
+ }
1010
+ else {
560
1011
  lines.push("openspec: unavailable");
561
1012
  }
562
1013
  return lines.join("\n");