@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.
- package/CHANGELOG.md +95 -0
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init-upgrade.js +97 -19
- package/dist/commands/init.js +14 -2
- package/dist/executors/dag-pi-executor.js +1 -0
- package/dist/governance/manifest-types.js +2 -0
- package/dist/infrastructure/harness/atomic-write.js +2 -1
- package/dist/shared/openspec-spec.js +76 -0
- package/dist/shared/operator/capabilities.js +65 -8
- package/dist/task/config-types.js +21 -0
- package/dist/task/frontend-project-capability.js +292 -38
- package/dist/worker/console/chat/mcp-inventory.js +221 -0
- package/dist/worker/console/chat/pi-runtime.js +155 -78
- package/dist/worker/console/chat/repo-walk.js +10 -1
- package/dist/worker/console/chat/routes.js +19 -8
- package/dist/worker/console/chat/semantic-activity.js +17 -5
- package/dist/worker/console/chat/turn-process.js +17 -30
- package/dist/worker/console/harness-pi-model-matrix.js +146 -0
- package/dist/worker/console/operator-actions.js +123 -0
- package/dist/worker/console/operator-user-error.js +19 -0
- package/dist/worker/console/pi-readiness.js +7 -1
- package/dist/worker/console/security.js +20 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-Hfsot6rP.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-YGwEASin.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-CmOM0F92.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-DDLuaHC5.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-B0Um43W4.js} +1 -1
- package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-DvBN4JZc.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-B1UVgW4E.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-Cf0GgXzX.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-DbNA24sj.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-Bi_0Be06.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-C8pPGvRd.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-BitQY8Z-.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-U67GCiEo.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-IoJwXPBA.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-YDZ_UHgE.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-CUZgsiRC.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-COmCh4pT.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-Ta6FSm68.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-UdcR-VW9.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-DybcO3Et.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-wErDFWLw.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-DkVHZWqz.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-DdqWs7QN.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-C9dMVmDo.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-B2T2WcBK.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-Bt9rjDFq.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-U8ic_tvi.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-BfQJdQoO.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-CpMftH4O.js} +1 -1
- package/dist/worker/console/static/assets/index-BA5-N5ER.css +1 -0
- package/dist/worker/console/static/assets/index-DKPXBrdc.js +325 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-DzqWXNO_.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-ChPtKFLz.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-PRe7wB5u.js} +1 -1
- package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-DpKX6QNp.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-Dnl76b7Y.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-S9PCXc3J.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Du8e-FSX.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-CY6OmHTc.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-C65GPhpj.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-U6IUiTCL.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-D-I_9Qhi.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-B57Fhed0.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CJOtiw21.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-FyX8zxSh.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-eF-pbs9t.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-CcfkA-Dq.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DH8FjnOK.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-beoTagJg.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-CQSfw4cK.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-CQgzQ6jn.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +51 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +44 -21
- 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/useChatSessions.js +15 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +17 -4
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +28 -22
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
- package/dist/workflows/dag/contract-validator-registrations.js +2 -1
- package/dist/workflows/dag/failure-routing.js +9 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +158 -0
- package/dist/workflows/dag/frontend-plan-render.js +90 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +411 -20
- package/dist/workflows/dag/init-hybrid.js +272 -27
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +51 -9
- package/dist/workflows/dag/report.js +6 -0
- package/dist/workflows/dag/retry-policy.js +15 -0
- package/dist/workflows/dag/types.js +50 -4
- package/dist/workflows/dag/validate.js +7 -2
- 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/docs/templates/harness.schema.json +8 -0
- package/harness.json +11 -2
- 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
|
@@ -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 {
|
|
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
|
|
200
|
-
const { runDir,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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");
|