@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.
- package/CHANGELOG.md +191 -0
- package/dist/application/dag/generate-task-dag.js +9 -0
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init-upgrade.js +97 -19
- package/dist/executors/dag-pi-executor.js +42 -4
- package/dist/executors/shell-executor.js +269 -15
- package/dist/infrastructure/harness/atomic-write.js +2 -1
- package/dist/shared/openspec-spec.js +76 -0
- package/dist/shared/operator/capabilities.js +149 -27
- package/dist/task/config-types.js +43 -0
- package/dist/task/frontend-project-capability.js +292 -38
- package/dist/worker/console/chat/deferred-turn.js +91 -0
- package/dist/worker/console/chat/mcp-inventory.js +221 -0
- package/dist/worker/console/chat/pi-runtime.js +165 -83
- package/dist/worker/console/chat/repo-walk.js +10 -1
- package/dist/worker/console/chat/routes.js +477 -55
- package/dist/worker/console/chat/semantic-activity.js +20 -8
- package/dist/worker/console/chat/session-store.js +146 -2
- package/dist/worker/console/chat/turn-process.js +17 -30
- package/dist/worker/console/harness-pi-model-matrix.js +137 -0
- package/dist/worker/console/operator-actions.js +128 -4
- package/dist/worker/console/operator-user-error.js +19 -0
- package/dist/worker/console/pi-readiness.js +8 -1
- package/dist/worker/console/recovery-cta.js +4 -4
- package/dist/worker/console/recovery-error-copy.js +4 -4
- package/dist/worker/console/security.js +20 -1
- package/dist/worker/console/server.js +6 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-SAD7McOS.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
- package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
- package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
- package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
- package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-D9Py31Ld.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-D7Occzk_.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +39 -0
- package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +82 -22
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
- package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +18 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +216 -10
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
- package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
- package/dist/workflows/dag/backend-test-intake-context.js +32 -0
- package/dist/workflows/dag/backend-test-layout.js +133 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
- package/dist/workflows/dag/backend-test-result-contract.js +1 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
- package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
- package/dist/workflows/dag/contract-validator-registrations.js +3 -1
- package/dist/workflows/dag/convergence/controller.js +141 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
- package/dist/workflows/dag/failure-routing.js +9 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +386 -0
- package/dist/workflows/dag/frontend-plan-render.js +90 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +479 -28
- package/dist/workflows/dag/init-hybrid.js +920 -110
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +77 -8
- package/dist/workflows/dag/prompt.js +45 -0
- package/dist/workflows/dag/report.js +6 -0
- package/dist/workflows/dag/rerun-feedback.js +212 -0
- package/dist/workflows/dag/rerun-plan.js +93 -27
- package/dist/workflows/dag/rerun-run.js +3 -0
- package/dist/workflows/dag/rerun-task.js +61 -2
- package/dist/workflows/dag/retry-policy.js +33 -0
- package/dist/workflows/dag/types.js +148 -4
- package/dist/workflows/dag/validate.js +7 -2
- package/docs/templates/backend-test-dag.json +60 -35
- package/docs/templates/frontend-design-contract.md +40 -14
- package/docs/templates/frontend-implementation-contract.schema.json +9 -2
- package/docs/templates/frontend-task-constraints.md +40 -13
- package/docs/templates/frontend-task-requirement.md +54 -20
- package/harness.json +3 -1
- package/package.json +1 -1
- package/skills/frontend-design-review/references/review-checklist.md +2 -0
- package/skills/frontend-implementation/references/design-spec.md +16 -19
- package/skills/frontend-implementation/references/node-contracts.md +15 -11
- package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
- package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
- 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 {
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
200
|
-
const { runDir,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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");
|