@tea-agent/loop-agent 0.39.0-beta.2 → 0.39.0-beta.3
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/AGENTS.md +4 -1
- package/CHANGELOG.md +187 -92
- package/README.md +9 -5
- package/bin/loop-agent.js +7 -3
- package/dist/application/task-lifecycle/recommendations.js +13 -2
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +16 -5
- package/dist/cli/program.js +16 -2
- package/dist/cli/update/notifier.js +2 -2
- package/dist/cli.js +2 -1
- package/dist/commands/dag-rerun.js +55 -1
- package/dist/commands/init-model-catalog.js +464 -0
- package/dist/commands/init-upgrade.js +265 -97
- package/dist/commands/init.js +429 -28
- package/dist/commands/inspect-next.js +8 -0
- package/dist/executors/dag-pi-executor.js +1645 -29
- package/dist/executors/pi-extension-resolver.js +233 -0
- package/dist/executors/pi-playwright-cli-tool.js +51 -12
- package/dist/executors/pi-sdk-executor.js +180 -33
- package/dist/executors/shell-executor.js +1150 -171
- package/dist/executors/shell-write-guard.js +14 -0
- package/dist/shared/dag-prompt-override.js +28 -0
- package/dist/shared/openspec-spec.js +70 -4
- package/dist/shared/operator/capabilities.js +32 -0
- package/dist/shared/playwright-cli-command-policy.js +15 -0
- package/dist/shared/update/console-notifier.js +100 -0
- package/dist/{cli → shared}/update/npm-client.js +41 -8
- package/dist/{cli → shared}/update/state.js +41 -13
- package/dist/task/config-types.js +19 -5
- package/dist/task/contract/project.js +3 -0
- package/dist/task/contract/schema.js +2 -1
- package/dist/task/frontend-project-capability.js +42 -16
- package/dist/task/source-prepare/parse-intent.js +24 -4
- package/dist/task/source-prepare/prepare.js +36 -0
- package/dist/worker/cli.js +31 -1
- package/dist/worker/console/chat/pi-console-config.js +81 -10
- package/dist/worker/console/chat/routes.js +1 -0
- package/dist/worker/console/dag-execution-receipt.js +33 -0
- package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
- package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
- package/dist/worker/console/index.js +3 -0
- package/dist/worker/console/operation-store.js +226 -6
- package/dist/worker/console/operator-actions.js +263 -0
- package/dist/worker/console/operator-user-error.js +2 -2
- package/dist/worker/console/routes.js +43 -2
- package/dist/worker/console/security.js +63 -9
- package/dist/worker/console/server.js +151 -6
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-zsNyvGaH.js} +1 -1
- package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-BDjZ5kE1.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-BkgaujnN.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-CqxOJi0j.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-C2JHLdRi.js} +1 -1
- package/dist/worker/console/static/assets/channel-BuqaGKAT.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-DF_YSvOS.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-D3Ort5Vo.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-XfAfZeUC.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-SLBibhf5.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-Dz29Ye_f.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-DB2G4H8V.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-CIoGkxdu.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-CG78FGkn.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-ZDHAvXPd.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-BKkkC4VA.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DNdSHjH9.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DNdSHjH9.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-SposUZsO.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-BSCtQpD7.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-DgFHrw9l.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-BVBasYbj.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-6r_4rN_c.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-JmuaB41P.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-xXvAY3sk.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-D2B3lY2o.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-tK54tMEX.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-CNtELzV-.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-C1xKMKcW.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-CZ7dlPMz.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-BqGb42Xe.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-D_BlXEtt.js} +1 -1
- package/dist/worker/console/static/assets/{index-DTZOKgAn.js → index-D5zGX6fG.js} +68 -68
- package/dist/worker/console/static/assets/index-DBbhESQ_.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-Vce2vmIe.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-DDwVBAvI.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-y_Ew-wv4.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-CjXJFp1K.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-1itMst1Q.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-C-D3ZRUa.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-BiYywmZc.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-l2fydNWA.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-Dz0hNbYW.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-BujI_mLZ.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-pWe84beb.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-ByYu2frg.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-CoPnilFP.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-1Md2LnS0.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-DqqqAoSH.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-C4sGkf--.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8rmop_9.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-ByxJrUzW.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B0ky-kBM.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-D8MLgM0O.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-DnBis-wZ.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-uuyYT7UO.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-BjgabhwK.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/active-run-badge.js +17 -0
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
- package/dist/worker/loop-agent/loop-agent-client.js +7 -3
- package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
- package/dist/worker/observe/node-input.js +74 -3
- package/dist/worker/observe/node-process.js +377 -0
- package/dist/worker/observe/routes.js +36 -3
- package/dist/worker/observe/shell-handler-keys.js +34 -0
- package/dist/worker/observe/static/api.js +66 -0
- package/dist/worker/observe/static/constants.js +8 -1
- package/dist/worker/observe/static/dag-history-labels.js +4 -0
- package/dist/worker/observe/static/format.js +23 -0
- package/dist/worker/observe/static/markdown-render.js +20 -1
- package/dist/worker/observe/static/operator-chrome.js +4 -0
- package/dist/worker/observe/static/state.js +13 -2
- package/dist/worker/observe/static/styles.css +73 -0
- package/dist/worker/observe/static/views/dag-inspector.js +612 -8
- package/dist/worker/observe/static/views/dag.js +8 -1
- package/dist/worker/observe/static/views/failures.js +5 -2
- package/dist/worker/observe/static/views/session-timeline.js +78 -9
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +20 -2
- package/dist/workflows/dag/contract-output-registry.js +15 -0
- package/dist/workflows/dag/contract-validator-registrations.js +1 -2
- package/dist/workflows/dag/failure-category.js +12 -0
- package/dist/workflows/dag/frontend-closeout.js +221 -0
- package/dist/workflows/dag/frontend-design-policy.js +347 -0
- package/dist/workflows/dag/frontend-human-decision.js +182 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +378 -160
- package/dist/workflows/dag/frontend-prewrite-gate.js +258 -393
- package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
- package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
- package/dist/workflows/dag/frontend-recovery-controller.js +201 -0
- package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
- package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
- package/dist/workflows/dag/frontend-recovery-run.js +33 -20
- package/dist/workflows/dag/frontend-repair.js +7 -424
- package/dist/workflows/dag/frontend-review-context.js +195 -8
- package/dist/workflows/dag/frontend-risk.js +15 -2
- package/dist/workflows/dag/frontend-shadow-dual-write.js +665 -0
- package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
- package/dist/workflows/dag/frontend-shape-facts.js +409 -0
- package/dist/workflows/dag/frontend-shape.js +425 -0
- package/dist/workflows/dag/frontend-split-application-service.js +203 -0
- package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
- package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
- package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
- package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
- package/dist/workflows/dag/frontend-test-markdown.js +61 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +160 -28
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
- package/dist/workflows/dag/frontend-typed-event-store.js +438 -0
- package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
- package/dist/workflows/dag/frontend-verification-trace.js +52 -17
- package/dist/workflows/dag/frontend-worktree-diff.js +97 -18
- package/dist/workflows/dag/frontend-writer-admission.js +260 -0
- package/dist/workflows/dag/frontend-writer-status.js +244 -0
- package/dist/workflows/dag/init-hybrid.js +572 -500
- package/dist/workflows/dag/node-execution.js +307 -70
- package/dist/workflows/dag/prompt.js +10 -18
- package/dist/workflows/dag/rerun-plan.js +144 -5
- package/dist/workflows/dag/rerun-run.js +58 -6
- package/dist/workflows/dag/retry-policy.js +228 -4
- package/dist/workflows/dag/runner.js +314 -119
- package/dist/workflows/dag/scheduler.js +114 -16
- package/dist/workflows/dag/structured-output-repair.js +712 -0
- package/dist/workflows/dag/types.js +235 -23
- package/dist/workflows/dag/upstream-artifacts.js +0 -4
- package/dist/workflows/dag/validate.js +60 -10
- package/docs/init-surface.manifest.json +5 -0
- package/docs/operations/README.md +1 -1
- package/docs/templates/agent-dag.schema.json +44 -0
- package/docs/templates/frontend-test-dag.json +8 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/docs/templates/init-managed-agents.md +13 -3
- package/harness.json +3 -3
- package/package.json +5 -1
- package/scripts/next-info.mjs +356 -0
- package/scripts/next-publish-gate.mjs +238 -0
- package/scripts/release-source-binding.mjs +251 -0
- package/skills/codebase-scout/SKILL.md +1 -1
- package/skills/fe-test-ui-scout/SKILL.md +65 -0
- package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
- package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
- package/skills/frontend-implementation/SKILL.md +4 -4
- package/skills/frontend-implementation/references/design-spec.md +11 -7
- package/skills/frontend-implementation/references/node-contracts.md +20 -11
- package/skills/loop-agent/references/command-reference.md +11 -4
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/playwright-cli/SKILL.md +22 -0
- package/skills/playwright-cli-case-generator/SKILL.md +20 -1
- package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
- package/dist/worker/console/static/assets/index-Ya5FE7cD.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readFile, stat } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { z } from "zod";
|
|
4
|
-
import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
|
|
5
|
+
import { OPENSPEC_SPEC_EXT_RE, isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
|
|
5
6
|
import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
|
|
6
7
|
import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
|
|
7
|
-
import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson,
|
|
8
|
+
import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
|
|
8
9
|
import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
|
|
9
10
|
import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
|
|
10
11
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
11
12
|
import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
|
|
12
13
|
export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
|
|
13
14
|
export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
|
|
15
|
+
/** Canonical contract serialization must stay at or below this byte size. */
|
|
16
|
+
export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
|
|
14
17
|
/**
|
|
15
18
|
* Machine-readable failure classification for blocked/retryable-invalid
|
|
16
19
|
* prewrite outcomes. Kept alongside the human-readable failureReason so
|
|
@@ -31,12 +34,13 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
|
|
|
31
34
|
"verification-target-outside-write-set",
|
|
32
35
|
"openspec-not-read",
|
|
33
36
|
"candidate-missing-drift",
|
|
34
|
-
"
|
|
35
|
-
"openspec-
|
|
36
|
-
"openspec-citation-not-read",
|
|
37
|
+
"candidate-content-drift",
|
|
38
|
+
"openspec-selection-invalid",
|
|
37
39
|
"component-spec-reference-invalid",
|
|
38
40
|
"component-spec-not-cited",
|
|
41
|
+
"component-spec-choice-unbound",
|
|
39
42
|
"component-choices-missing",
|
|
43
|
+
"contract-too-large",
|
|
40
44
|
"revision-patch-unmergeable",
|
|
41
45
|
]);
|
|
42
46
|
export const frontendPrewriteResultV1Schema = z
|
|
@@ -125,7 +129,7 @@ export const frontendPrewriteResultV1Schema = z
|
|
|
125
129
|
export const frontendWriterAdmissionSchema = z
|
|
126
130
|
.object({
|
|
127
131
|
schemaVersion: z.literal(1),
|
|
128
|
-
writerNodeId: z.enum(["frontend-implement-pi"
|
|
132
|
+
writerNodeId: z.enum(["frontend-implement-pi"]),
|
|
129
133
|
decision: z.enum(["authorized", "denied"]),
|
|
130
134
|
sourceArtifact: z.string().min(1),
|
|
131
135
|
artifactHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
@@ -154,136 +158,68 @@ async function selectNode(runDir, primary, fallbacks) {
|
|
|
154
158
|
}
|
|
155
159
|
throw new Error(`frontend prewrite gate no FINISHED candidate: ${rejections.join("; ")}`);
|
|
156
160
|
}
|
|
157
|
-
async function
|
|
158
|
-
|
|
161
|
+
async function readFinishedNodeRecord(runDir, nodeId) {
|
|
162
|
+
let record;
|
|
163
|
+
try {
|
|
164
|
+
record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new Error(`frontend prewrite gate cannot parse node record ${nodeId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
}
|
|
159
169
|
if (record.status !== "FINISHED") {
|
|
160
170
|
throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
|
|
161
171
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// canonical plan appear as two complete JSON contracts to the prewrite gate.
|
|
172
|
+
return record;
|
|
173
|
+
}
|
|
174
|
+
async function readNodeText(runDir, nodeId) {
|
|
175
|
+
const record = await readFinishedNodeRecord(runDir, nodeId);
|
|
167
176
|
const text = record.assistantText?.trim() || record.stdout?.trim() || "";
|
|
168
177
|
if (!text)
|
|
169
178
|
throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
|
|
170
179
|
return text;
|
|
171
180
|
}
|
|
172
|
-
async function
|
|
173
|
-
const record =
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
async function readCanonicalContractFromNode(runDir, nodeId) {
|
|
182
|
+
const record = await readFinishedNodeRecord(runDir, nodeId);
|
|
183
|
+
if (!record.structuredArtifactPath) {
|
|
184
|
+
throw new Error(`frontend prewrite gate missing structured artifact for ${nodeId}`);
|
|
185
|
+
}
|
|
186
|
+
const artifactPath = path.isAbsolute(record.structuredArtifactPath)
|
|
187
|
+
? record.structuredArtifactPath
|
|
188
|
+
: path.join(runDir, record.structuredArtifactPath);
|
|
189
|
+
const resolvedRun = path.resolve(runDir);
|
|
190
|
+
const resolvedArtifact = path.resolve(artifactPath);
|
|
191
|
+
const relative = path.relative(resolvedRun, resolvedArtifact);
|
|
180
192
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
181
|
-
throw new Error(`frontend prewrite gate
|
|
182
|
-
}
|
|
183
|
-
return resolved;
|
|
184
|
-
}
|
|
185
|
-
async function readHashBoundArtifact(input) {
|
|
186
|
-
const resolved = resolveRunOwnedArtifact(input.runDir, input.artifactPath, input.label);
|
|
187
|
-
const text = await readFile(resolved, "utf8");
|
|
188
|
-
const actual = sha256Hex(text);
|
|
189
|
-
if (actual !== input.expectedSha256) {
|
|
190
|
-
throw new Error(`frontend prewrite gate ${input.label} SHA-256 mismatch (expected ${input.expectedSha256}, got ${actual})`);
|
|
191
|
-
}
|
|
192
|
-
return text;
|
|
193
|
-
}
|
|
194
|
-
async function readFrontendPlanNodeInput(runDir, nodeId) {
|
|
195
|
-
const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
|
|
196
|
-
if (record.status !== "FINISHED") {
|
|
197
|
-
throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
|
|
193
|
+
throw new Error(`frontend prewrite gate structured artifact escapes runDir: ${record.structuredArtifactPath}`);
|
|
198
194
|
}
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
artifactFirst: false,
|
|
206
|
-
payloadText,
|
|
207
|
-
citationsText: payloadText,
|
|
208
|
-
rawCandidateSha256: sha256Hex(record.assistantText ?? record.stdout ?? ""),
|
|
209
|
-
payloadKind: "canonical-contract",
|
|
210
|
-
};
|
|
195
|
+
const bytes = await readFile(resolvedArtifact);
|
|
196
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
197
|
+
if (record.structuredArtifactSha256 &&
|
|
198
|
+
record.structuredArtifactSha256 !== digest) {
|
|
199
|
+
throw new Error(`frontend prewrite gate structured artifact hash mismatch for ${nodeId}`);
|
|
211
200
|
}
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
!record.structuredArtifactSchemaId) {
|
|
216
|
-
throw new Error(`frontend prewrite gate ${nodeId} has incomplete artifact-first metadata`);
|
|
201
|
+
if (record.structuredArtifactSchemaId &&
|
|
202
|
+
record.structuredArtifactSchemaId !== FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID) {
|
|
203
|
+
throw new Error(`frontend prewrite gate structured artifact schema mismatch for ${nodeId}: ${record.structuredArtifactSchemaId}`);
|
|
217
204
|
}
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
205
|
+
const contractText = bytes.toString("utf8");
|
|
206
|
+
let citationsText = "";
|
|
207
|
+
try {
|
|
208
|
+
const citationsRaw = await readFile(path.join(path.dirname(resolvedArtifact), "openspec-citations.json"), "utf8");
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(citationsRaw);
|
|
211
|
+
citationsText =
|
|
212
|
+
typeof parsed.block === "string" ? parsed.block : citationsRaw;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
citationsText = citationsRaw;
|
|
216
|
+
}
|
|
223
217
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
artifactPath: record.structuredArtifactPath,
|
|
228
|
-
expectedSha256: record.structuredArtifactSha256,
|
|
229
|
-
label: `${nodeId} primary payload`,
|
|
230
|
-
}),
|
|
231
|
-
readHashBoundArtifact({
|
|
232
|
-
runDir,
|
|
233
|
-
artifactPath: frontend.citationsArtifactPath,
|
|
234
|
-
expectedSha256: frontend.citationsArtifactSha256,
|
|
235
|
-
label: `${nodeId} citations`,
|
|
236
|
-
}),
|
|
237
|
-
readHashBoundArtifact({
|
|
238
|
-
runDir,
|
|
239
|
-
artifactPath: frontend.rawCandidatePath,
|
|
240
|
-
expectedSha256: frontend.rawCandidateSha256,
|
|
241
|
-
label: `${nodeId} raw candidate`,
|
|
242
|
-
}).then(() => undefined),
|
|
243
|
-
]);
|
|
244
|
-
return {
|
|
245
|
-
artifactFirst: true,
|
|
246
|
-
payloadText,
|
|
247
|
-
citationsText,
|
|
248
|
-
rawCandidateSha256: frontend.rawCandidateSha256,
|
|
249
|
-
payloadKind: frontend.payloadKind,
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Same shape predicate as the internal `isContract` inside
|
|
254
|
-
* `extractFrontendImplementationJson` (frontend-implementation-contract.ts):
|
|
255
|
-
* schemaVersion 1 + a plain `targets` object + `requirements` and
|
|
256
|
-
* `verificationTargets` arrays. A merge-patch delta does not carry these
|
|
257
|
-
* fields, so this is the deterministic boundary between a full contract and a
|
|
258
|
-
* patch. Keep the two predicates in sync.
|
|
259
|
-
*/
|
|
260
|
-
function isFullContractShape(value) {
|
|
261
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
262
|
-
return false;
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (error.code !== "ENOENT")
|
|
220
|
+
throw error;
|
|
263
221
|
}
|
|
264
|
-
|
|
265
|
-
const targets = record.targets;
|
|
266
|
-
return (record.schemaVersion === 1 &&
|
|
267
|
-
targets !== null &&
|
|
268
|
-
typeof targets === "object" &&
|
|
269
|
-
!Array.isArray(targets) &&
|
|
270
|
-
Array.isArray(record.requirements) &&
|
|
271
|
-
Array.isArray(record.verificationTargets));
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Shared merge pipeline for revisionPatch mode and content-driven
|
|
275
|
-
* compatibility: read the original contract node, extract the original
|
|
276
|
-
* contract and the effective node's patch, apply the RFC 7386 merge-patch, and
|
|
277
|
-
* serialize deterministically. Throws on any failure; callers map the error to
|
|
278
|
-
* their own classification (retryable-invalid for explicit revisionPatch mode,
|
|
279
|
-
* blocked for content-driven fallback).
|
|
280
|
-
*/
|
|
281
|
-
async function mergeRevisionPatch(runDir, planText, originalNodeId) {
|
|
282
|
-
const originalText = await readNodeText(runDir, originalNodeId);
|
|
283
|
-
const originalValue = extractFrontendImplementationJson(originalText);
|
|
284
|
-
const patchValue = extractFrontendImplementationJson(planText);
|
|
285
|
-
const merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
|
|
286
|
-
return serializeDeterministicJson(merged);
|
|
222
|
+
return { contractText, citationsText, rawSha256: digest };
|
|
287
223
|
}
|
|
288
224
|
function firstNonEmptyVerdictLine(text) {
|
|
289
225
|
const lines = text
|
|
@@ -327,6 +263,7 @@ function isFailedToolResult(event) {
|
|
|
327
263
|
}
|
|
328
264
|
async function collectOpenspecReadPaths(input) {
|
|
329
265
|
const { runDir, planNodeId, reviewNodeId, repoRoot } = input;
|
|
266
|
+
const candidateSet = input.candidatePaths ? new Set(input.candidatePaths) : undefined;
|
|
330
267
|
const matched = new Set();
|
|
331
268
|
for (const nodeId of [planNodeId, reviewNodeId]) {
|
|
332
269
|
const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
|
|
@@ -361,7 +298,7 @@ async function collectOpenspecReadPaths(input) {
|
|
|
361
298
|
: null;
|
|
362
299
|
if (relativePath &&
|
|
363
300
|
!isFailedToolResult(event) &&
|
|
364
|
-
|
|
301
|
+
(candidateSet ? candidateSet.has(relativePath) : OPENSPEC_SPEC_EXT_RE.test(relativePath))) {
|
|
365
302
|
matched.add(relativePath);
|
|
366
303
|
}
|
|
367
304
|
startedReads.delete(event.toolCallId);
|
|
@@ -376,21 +313,6 @@ async function collectOpenspecReadPaths(input) {
|
|
|
376
313
|
}
|
|
377
314
|
return [...matched];
|
|
378
315
|
}
|
|
379
|
-
async function checkOpenspecReadEvidence(input) {
|
|
380
|
-
const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
|
|
381
|
-
if (candidatePaths.length === 0)
|
|
382
|
-
return [];
|
|
383
|
-
const allReadPaths = await collectOpenspecReadPaths({
|
|
384
|
-
runDir,
|
|
385
|
-
planNodeId,
|
|
386
|
-
reviewNodeId,
|
|
387
|
-
repoRoot,
|
|
388
|
-
});
|
|
389
|
-
const normalizedCandidates = new Set(candidatePaths
|
|
390
|
-
.map((candidate) => toRepoRelativePath(candidate, repoRoot))
|
|
391
|
-
.filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
|
|
392
|
-
return allReadPaths.filter((readPath) => normalizedCandidates.has(readPath));
|
|
393
|
-
}
|
|
394
316
|
function normalizeCitationPath(rawPath, repoRoot) {
|
|
395
317
|
let candidate = rawPath.trim();
|
|
396
318
|
const hashIndex = candidate.indexOf("#");
|
|
@@ -405,57 +327,45 @@ function normalizeCitationPath(rawPath, repoRoot) {
|
|
|
405
327
|
}
|
|
406
328
|
return candidate;
|
|
407
329
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
const rawPath = parsed.path;
|
|
441
|
-
if (typeof rawPath !== "string" || !rawPath.trim()) {
|
|
442
|
-
unparseable = true;
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
const normalized = normalizeCitationPath(rawPath, repoRoot);
|
|
446
|
-
if (!normalized || !isOpenspecSpecFilePath(normalized))
|
|
447
|
-
continue;
|
|
448
|
-
const section = typeof parsed.section === "string"
|
|
449
|
-
? parsed.section
|
|
450
|
-
: "";
|
|
451
|
-
const lineValue = parsed.line;
|
|
452
|
-
const citationLine = typeof lineValue === "number" && Number.isInteger(lineValue)
|
|
453
|
-
? lineValue
|
|
454
|
-
: null;
|
|
455
|
-
citations.push({ path: normalized, section, line: citationLine });
|
|
330
|
+
async function resolveRequiredOpenspecPaths(input) {
|
|
331
|
+
if (input.policy === "scan-strict")
|
|
332
|
+
return { ok: true, paths: input.candidatePaths };
|
|
333
|
+
// Legacy cited DAGs had no selector: preserve their full candidate must-read
|
|
334
|
+
// semantics instead of silently weakening an existing run.
|
|
335
|
+
if (!input.selectionNodeId)
|
|
336
|
+
return { ok: true, paths: input.candidatePaths };
|
|
337
|
+
let text;
|
|
338
|
+
try {
|
|
339
|
+
text = await readNodeText(input.runDir, input.selectionNodeId);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
return { ok: false, reason: `openspec selector output unreadable: ${error instanceof Error ? error.message : String(error)}` };
|
|
343
|
+
}
|
|
344
|
+
const fenced = /```json\s*\n([\s\S]*?)\n```/.exec(text);
|
|
345
|
+
if (!fenced)
|
|
346
|
+
return { ok: false, reason: "openspec selector must emit exactly one fenced JSON selection object" };
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(fenced[1] ?? "");
|
|
349
|
+
if (parsed.schemaVersion !== 1 || parsed.schemaId !== "frontend-openspec-selection-v1" || !Array.isArray(parsed.selections))
|
|
350
|
+
throw new Error("schemaVersion/schemaId/selections invalid");
|
|
351
|
+
const byPath = new Map();
|
|
352
|
+
for (const row of parsed.selections) {
|
|
353
|
+
if (typeof row.path !== "string" || typeof row.disposition !== "string" || typeof row.rationale !== "string")
|
|
354
|
+
throw new Error("selection row invalid");
|
|
355
|
+
if (!input.candidatePaths.includes(row.path) || !["required", "relevant", "irrelevant"].includes(row.disposition))
|
|
356
|
+
throw new Error(`invalid selection ${row.path}`);
|
|
357
|
+
if (byPath.has(row.path))
|
|
358
|
+
throw new Error(`duplicate selection ${row.path}`);
|
|
359
|
+
byPath.set(row.path, row.disposition);
|
|
456
360
|
}
|
|
361
|
+
const missing = input.candidatePaths.filter((candidate) => !byPath.has(candidate));
|
|
362
|
+
if (missing.length)
|
|
363
|
+
throw new Error(`missing classifications: ${missing.join(", ")}`);
|
|
364
|
+
return { ok: true, paths: [...new Set([...input.mandatoryPaths, ...input.candidatePaths.filter((candidate) => byPath.get(candidate) === "required")])].sort() };
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
return { ok: false, reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}` };
|
|
457
368
|
}
|
|
458
|
-
return { blockPresent, unparseable, citations };
|
|
459
369
|
}
|
|
460
370
|
async function finalizePrewrite(input, pending) {
|
|
461
371
|
const sourceBindingSha256 = input.sourceBinding
|
|
@@ -512,7 +422,30 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
512
422
|
// remains an ERROR and is intentionally not classified into prewrite-result.
|
|
513
423
|
const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
|
|
514
424
|
const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
|
|
515
|
-
|
|
425
|
+
let planArtifact;
|
|
426
|
+
try {
|
|
427
|
+
planArtifact = await readCanonicalContractFromNode(input.runDir, planNodeId);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
return finalizePrewrite(input, {
|
|
431
|
+
planNodeId,
|
|
432
|
+
reviewNodeId,
|
|
433
|
+
verdict: "",
|
|
434
|
+
candidateRawSha256: sha256Hex(""),
|
|
435
|
+
candidateJsonSha256: null,
|
|
436
|
+
canonicalSha256: null,
|
|
437
|
+
normalizationActions: [],
|
|
438
|
+
mockStrategy: undefined,
|
|
439
|
+
artifact: undefined,
|
|
440
|
+
failureReason: error instanceof Error ? error.message : String(error),
|
|
441
|
+
failureCode: "plan-text-unreadable",
|
|
442
|
+
classification: "retryable-invalid",
|
|
443
|
+
openspecReadPaths: [],
|
|
444
|
+
openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
|
|
445
|
+
openspecPolicy: input.config.openspecPolicy ?? "scan-strict",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
const candidateRawSha256 = planArtifact.rawSha256;
|
|
516
449
|
const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
|
|
517
450
|
const basePending = {
|
|
518
451
|
planNodeId,
|
|
@@ -531,21 +464,6 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
531
464
|
openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
|
|
532
465
|
openspecPolicy,
|
|
533
466
|
};
|
|
534
|
-
let planInput;
|
|
535
|
-
try {
|
|
536
|
-
planInput = await readFrontendPlanNodeInput(input.runDir, planNodeId);
|
|
537
|
-
}
|
|
538
|
-
catch (error) {
|
|
539
|
-
return finalizePrewrite(input, {
|
|
540
|
-
...basePending,
|
|
541
|
-
classification: "retryable-invalid",
|
|
542
|
-
failureReason: error instanceof Error ? error.message : String(error),
|
|
543
|
-
failureCode: "plan-text-unreadable",
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
const planText = planInput.payloadText;
|
|
547
|
-
const citationsText = planInput.citationsText;
|
|
548
|
-
basePending.candidateRawSha256 = planInput.rawCandidateSha256;
|
|
549
467
|
let reviewText;
|
|
550
468
|
try {
|
|
551
469
|
reviewText = await readNodeText(input.runDir, reviewNodeId);
|
|
@@ -568,90 +486,7 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
568
486
|
failureCode: "verdict-not-pass",
|
|
569
487
|
});
|
|
570
488
|
}
|
|
571
|
-
|
|
572
|
-
// delta against the original contract node (planFallbackFromNodeIds[0]).
|
|
573
|
-
// Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
|
|
574
|
-
const revisionPatchMode = input.config.revisionPatch === true &&
|
|
575
|
-
planNodeId === input.config.planFromNodeId &&
|
|
576
|
-
input.config.planFallbackFromNodeIds.length > 0;
|
|
577
|
-
let effectiveContractText = null;
|
|
578
|
-
if (revisionPatchMode) {
|
|
579
|
-
const originalNodeId = input.config.planFallbackFromNodeIds[0];
|
|
580
|
-
try {
|
|
581
|
-
const originalInput = await readFrontendPlanNodeInput(input.runDir, originalNodeId);
|
|
582
|
-
if (planInput.artifactFirst && !originalInput.artifactFirst) {
|
|
583
|
-
throw new Error(`artifact-first revision ${planNodeId} requires artifact-first original contract ${originalNodeId}`);
|
|
584
|
-
}
|
|
585
|
-
if (planInput.artifactFirst) {
|
|
586
|
-
if (planInput.payloadKind !== "merge-patch" ||
|
|
587
|
-
originalInput.payloadKind !== "canonical-contract") {
|
|
588
|
-
throw new Error("artifact-first revision/original payload kinds are invalid");
|
|
589
|
-
}
|
|
590
|
-
effectiveContractText = serializeDeterministicJson(applyFrontendImplementationContractPatch(JSON.parse(originalInput.payloadText), JSON.parse(planInput.payloadText)));
|
|
591
|
-
}
|
|
592
|
-
else {
|
|
593
|
-
effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
catch (error) {
|
|
597
|
-
return finalizePrewrite(input, {
|
|
598
|
-
...basePending,
|
|
599
|
-
verdict,
|
|
600
|
-
classification: "retryable-invalid",
|
|
601
|
-
failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
602
|
-
failureCode: "contract-invalid",
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
else {
|
|
607
|
-
// Content-driven compatibility (Bug 1): legacy DAGs without
|
|
608
|
-
// revisionPatch can still receive a merge-patch delta from the revision
|
|
609
|
-
// node. Detect the output shape instead of trusting the DAG flag alone.
|
|
610
|
-
// - Full contract shape → keep legacy full-contract semantics unchanged.
|
|
611
|
-
// - Patch shape with a usable original contract node → auto-merge and
|
|
612
|
-
// run the existing analysis/materialization pipeline.
|
|
613
|
-
// - Patch shape but unmergeable → blocked + revision-patch-unmergeable
|
|
614
|
-
// (no retryable loop).
|
|
615
|
-
let planJsonValue = null;
|
|
616
|
-
try {
|
|
617
|
-
planJsonValue = extractFrontendImplementationJson(planText);
|
|
618
|
-
}
|
|
619
|
-
catch {
|
|
620
|
-
// Not machine-readable JSON: fall back to the legacy full-contract
|
|
621
|
-
// path where analyzeFrontendImplementationContract surfaces the
|
|
622
|
-
// original diagnostic (fail-closed without a new failure mode).
|
|
623
|
-
}
|
|
624
|
-
if (planJsonValue !== null && !isFullContractShape(planJsonValue)) {
|
|
625
|
-
const originalNodeId = input.config.planFallbackFromNodeIds[0];
|
|
626
|
-
if (!originalNodeId || originalNodeId === planNodeId) {
|
|
627
|
-
return finalizePrewrite(input, {
|
|
628
|
-
...basePending,
|
|
629
|
-
verdict,
|
|
630
|
-
classification: "blocked",
|
|
631
|
-
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.",
|
|
632
|
-
failureCode: "revision-patch-unmergeable",
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
try {
|
|
636
|
-
const originalText = await readNodeText(input.runDir, originalNodeId);
|
|
637
|
-
const originalValue = extractFrontendImplementationJson(originalText);
|
|
638
|
-
if (!isFullContractShape(originalValue)) {
|
|
639
|
-
throw new Error(`original contract node ${originalNodeId} is not a full frontend implementation contract`);
|
|
640
|
-
}
|
|
641
|
-
effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
|
|
642
|
-
}
|
|
643
|
-
catch (error) {
|
|
644
|
-
return finalizePrewrite(input, {
|
|
645
|
-
...basePending,
|
|
646
|
-
verdict,
|
|
647
|
-
classification: "blocked",
|
|
648
|
-
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.`,
|
|
649
|
-
failureCode: "revision-patch-unmergeable",
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
const requirementIdSearchText = effectiveContractText ?? planText;
|
|
489
|
+
const requirementIdSearchText = planArtifact.contractText;
|
|
655
490
|
const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
|
|
656
491
|
if (missingIds.length > 0) {
|
|
657
492
|
return finalizePrewrite(input, {
|
|
@@ -699,17 +534,13 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
699
534
|
workspaceRoot,
|
|
700
535
|
});
|
|
701
536
|
}
|
|
702
|
-
// Candidate -> canonical conversion.
|
|
703
|
-
//
|
|
704
|
-
// here would silently fall back to its prose and defeat the integrity gate.
|
|
705
|
-
// Historical records intentionally retain their node-text compatibility path.
|
|
537
|
+
// Candidate -> canonical conversion. The gate consumes the producing node's
|
|
538
|
+
// hash-bound canonical artifact and never re-merges plan/revision stdout.
|
|
706
539
|
let analysis;
|
|
707
540
|
try {
|
|
708
|
-
const rawContractText = effectiveContractText ?? (planInput.artifactFirst ? planText : undefined);
|
|
709
541
|
analysis = await analyzeFrontendImplementationContract({
|
|
710
542
|
runDir: input.runDir,
|
|
711
|
-
|
|
712
|
-
rawContractText,
|
|
543
|
+
rawContractText: planArtifact.contractText,
|
|
713
544
|
sourceBinding: input.sourceBinding,
|
|
714
545
|
});
|
|
715
546
|
}
|
|
@@ -729,6 +560,21 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
729
560
|
}
|
|
730
561
|
throw error;
|
|
731
562
|
}
|
|
563
|
+
// Size guard: a canonical contract above 64KB is a retryable-invalid defect
|
|
564
|
+
// (compress the contract) rather than a writer authorization failure.
|
|
565
|
+
const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
|
|
566
|
+
if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
|
|
567
|
+
return finalizePrewrite(input, {
|
|
568
|
+
...basePending,
|
|
569
|
+
verdict,
|
|
570
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
571
|
+
normalizationActions: [],
|
|
572
|
+
mockStrategy: analysis.canonical.mockApi.strategy,
|
|
573
|
+
classification: "retryable-invalid",
|
|
574
|
+
failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
|
|
575
|
+
failureCode: "contract-too-large",
|
|
576
|
+
});
|
|
577
|
+
}
|
|
732
578
|
const mockStrategy = analysis.canonical.mockApi.strategy;
|
|
733
579
|
if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
|
|
734
580
|
const quotedAllowed = input.config.allowedMockStrategies
|
|
@@ -800,14 +646,31 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
800
646
|
// Effective plan/review must actually read the candidate openspec paths when
|
|
801
647
|
// any exist. This fail-closes BEFORE the canonical contract is materialized
|
|
802
648
|
// so a writer is never authorized on top of unread normative evidence.
|
|
803
|
-
const
|
|
649
|
+
const frozenCandidatePaths = input.config.openspecCandidatePaths ?? [];
|
|
650
|
+
const selection = await resolveRequiredOpenspecPaths({
|
|
651
|
+
runDir: input.runDir,
|
|
652
|
+
selectionNodeId: input.config.openspecSelectionNodeId,
|
|
653
|
+
candidatePaths: frozenCandidatePaths,
|
|
654
|
+
mandatoryPaths: input.config.openspecMandatoryPaths ?? [],
|
|
655
|
+
policy: openspecPolicy,
|
|
656
|
+
});
|
|
657
|
+
if (!selection.ok) {
|
|
658
|
+
return finalizePrewrite(input, {
|
|
659
|
+
...basePending, verdict, candidateJsonSha256: analysis.candidateJsonSha256,
|
|
660
|
+
normalizationActions: [], mockStrategy, classification: "retryable-invalid",
|
|
661
|
+
failureReason: `frontend prewrite gate: ${selection.reason}`,
|
|
662
|
+
failureCode: "openspec-selection-invalid",
|
|
663
|
+
openspecCandidatePaths: frozenCandidatePaths,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const candidatePaths = selection.paths;
|
|
804
667
|
const candidateRoot = workspaceRoot ?? process.cwd();
|
|
805
668
|
// Drift precheck: candidates are frozen at generation time. A candidate that
|
|
806
669
|
// no longer exists on disk was deleted/renamed after generation and is a
|
|
807
670
|
// generation-contract defect, not an "unread" failure.
|
|
808
671
|
const missingCandidates = [];
|
|
809
|
-
if (
|
|
810
|
-
for (const candidate of
|
|
672
|
+
if (frozenCandidatePaths.length > 0) {
|
|
673
|
+
for (const candidate of frozenCandidatePaths) {
|
|
811
674
|
try {
|
|
812
675
|
await stat(path.join(candidateRoot, candidate));
|
|
813
676
|
}
|
|
@@ -830,99 +693,78 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
830
693
|
openspecCandidatePaths: candidatePaths,
|
|
831
694
|
});
|
|
832
695
|
}
|
|
833
|
-
|
|
834
|
-
if (
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
if (!
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
...new Set(parsed.citations
|
|
878
|
-
.filter((citation) => !readSet.has(citation.path))
|
|
879
|
-
.map((citation) => citation.path)),
|
|
880
|
-
];
|
|
881
|
-
if (uncorroborated.length > 0) {
|
|
882
|
-
return finalizePrewrite(input, {
|
|
883
|
-
...basePending,
|
|
884
|
-
verdict,
|
|
885
|
-
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
886
|
-
normalizationActions: [],
|
|
887
|
-
mockStrategy,
|
|
888
|
-
classification: "retryable-invalid",
|
|
889
|
-
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(", ")}`,
|
|
890
|
-
failureCode: "openspec-citation-not-read",
|
|
891
|
-
openspecReadPaths,
|
|
892
|
-
openspecCandidatePaths: candidatePaths,
|
|
893
|
-
});
|
|
894
|
-
}
|
|
696
|
+
const frozenSnapshots = input.config.openspecCandidateSnapshots;
|
|
697
|
+
if (frozenSnapshots) {
|
|
698
|
+
const snapshotByPath = new Map(frozenSnapshots.map((snapshot) => [snapshot.path, snapshot.sha256]));
|
|
699
|
+
const incompleteSnapshots = frozenCandidatePaths.filter((candidate) => !snapshotByPath.has(candidate));
|
|
700
|
+
const unexpectedSnapshots = frozenSnapshots
|
|
701
|
+
.map((snapshot) => snapshot.path)
|
|
702
|
+
.filter((candidate) => !frozenCandidatePaths.includes(candidate));
|
|
703
|
+
const changedCandidates = [];
|
|
704
|
+
for (const candidate of frozenCandidatePaths) {
|
|
705
|
+
const expected = snapshotByPath.get(candidate);
|
|
706
|
+
if (!expected)
|
|
707
|
+
continue;
|
|
708
|
+
const actual = createHash("sha256")
|
|
709
|
+
.update(await readFile(path.join(candidateRoot, candidate)))
|
|
710
|
+
.digest("hex");
|
|
711
|
+
if (actual !== expected)
|
|
712
|
+
changedCandidates.push(candidate);
|
|
713
|
+
}
|
|
714
|
+
if (incompleteSnapshots.length > 0 ||
|
|
715
|
+
unexpectedSnapshots.length > 0 ||
|
|
716
|
+
changedCandidates.length > 0) {
|
|
717
|
+
const details = [
|
|
718
|
+
...(incompleteSnapshots.length > 0
|
|
719
|
+
? [`missing snapshots: ${incompleteSnapshots.join(", ")}`]
|
|
720
|
+
: []),
|
|
721
|
+
...(unexpectedSnapshots.length > 0
|
|
722
|
+
? [`unexpected snapshots: ${unexpectedSnapshots.join(", ")}`]
|
|
723
|
+
: []),
|
|
724
|
+
...(changedCandidates.length > 0
|
|
725
|
+
? [`content changed: ${changedCandidates.join(", ")}`]
|
|
726
|
+
: []),
|
|
727
|
+
].join("; ");
|
|
728
|
+
return finalizePrewrite(input, {
|
|
729
|
+
...basePending,
|
|
730
|
+
verdict,
|
|
731
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
732
|
+
normalizationActions: [],
|
|
733
|
+
mockStrategy,
|
|
734
|
+
classification: "blocked",
|
|
735
|
+
failureReason: `frontend prewrite gate blocked: the generation-frozen OpenSpec content snapshot no longer matches the workspace (${details}). Re-read the changed specification and regenerate the DAG.`,
|
|
736
|
+
failureCode: "candidate-content-drift",
|
|
737
|
+
openspecReadPaths: [],
|
|
738
|
+
openspecCandidatePaths: candidatePaths,
|
|
739
|
+
});
|
|
895
740
|
}
|
|
896
|
-
// cited with empty candidates: no read enforcement. The generation step
|
|
897
|
-
// emits an advisory instead of failing the gate.
|
|
898
741
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
742
|
+
let openspecReadPaths;
|
|
743
|
+
openspecReadPaths = await collectOpenspecReadPaths({
|
|
744
|
+
runDir: input.runDir,
|
|
745
|
+
planNodeId,
|
|
746
|
+
reviewNodeId,
|
|
747
|
+
repoRoot: candidateRoot,
|
|
748
|
+
...(candidatePaths.length > 0 ? { candidatePaths } : {}),
|
|
749
|
+
});
|
|
750
|
+
if (candidatePaths.length > 0) {
|
|
751
|
+
const readSet = new Set(openspecReadPaths);
|
|
752
|
+
const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
|
|
753
|
+
if (unread.length > 0) {
|
|
754
|
+
const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
|
|
755
|
+
const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
|
|
756
|
+
return finalizePrewrite(input, {
|
|
757
|
+
...basePending,
|
|
758
|
+
verdict,
|
|
759
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
760
|
+
normalizationActions: [],
|
|
761
|
+
mockStrategy,
|
|
762
|
+
classification: "retryable-invalid",
|
|
763
|
+
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。`,
|
|
764
|
+
failureCode: "openspec-not-read",
|
|
765
|
+
openspecReadPaths,
|
|
766
|
+
openspecCandidatePaths: candidatePaths,
|
|
767
|
+
});
|
|
926
768
|
}
|
|
927
769
|
}
|
|
928
770
|
// Component selection spec conformance (ADR 0016). Deterministic cross-check
|
|
@@ -958,11 +800,37 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
958
800
|
: null,
|
|
959
801
|
}))
|
|
960
802
|
.filter((ref) => ref.raw.length > 0);
|
|
803
|
+
if (componentSpecCandidates.length > 0 && hasUiVisibleWork) {
|
|
804
|
+
const componentCandidateSet = new Set(componentSpecCandidates);
|
|
805
|
+
const hasBoundSpecifiedChoice = componentChoices.some((choice) => {
|
|
806
|
+
if (choice.decision !== "specified" || !choice.specReference?.path) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
const normalized = normalizeCitationPath(choice.specReference.path, candidateRoot);
|
|
810
|
+
return Boolean(normalized && componentCandidateSet.has(normalized));
|
|
811
|
+
});
|
|
812
|
+
if (!hasBoundSpecifiedChoice) {
|
|
813
|
+
return finalizePrewrite(input, {
|
|
814
|
+
...basePending,
|
|
815
|
+
verdict,
|
|
816
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
817
|
+
normalizationActions: [],
|
|
818
|
+
mockStrategy,
|
|
819
|
+
classification: "retryable-invalid",
|
|
820
|
+
failureReason: "frontend prewrite gate: UI-visible work has a generation-frozen component/theme specification bucket, but no uiComponentChoices decision=specified is bound to a path in that bucket. At least one declared UI purpose must use and cite the applicable component/theme specification; reuse-existing/new alone cannot bypass the bucket.",
|
|
821
|
+
failureCode: "component-spec-choice-unbound",
|
|
822
|
+
openspecReadPaths,
|
|
823
|
+
openspecCandidatePaths: candidatePaths,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
961
827
|
if (componentSpecReferences.length > 0) {
|
|
962
828
|
const invalidRefs = [
|
|
963
829
|
...new Set(componentSpecReferences
|
|
964
830
|
.filter((ref) => !ref.normalized ||
|
|
965
|
-
!
|
|
831
|
+
!(candidatePaths.length > 0
|
|
832
|
+
? candidatePaths.includes(ref.normalized)
|
|
833
|
+
: isOpenspecSpecFilePath(ref.normalized)))
|
|
966
834
|
.map((ref) => ref.raw)),
|
|
967
835
|
];
|
|
968
836
|
if (invalidRefs.length > 0) {
|
|
@@ -980,13 +848,10 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
980
848
|
});
|
|
981
849
|
}
|
|
982
850
|
if (openspecPolicy === "cited") {
|
|
983
|
-
const parsedCitations = parseOpenspecCitations(citationsText, candidateRoot);
|
|
984
|
-
const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
|
|
985
851
|
const readSet = new Set(openspecReadPaths);
|
|
986
852
|
const uncorroborated = [
|
|
987
853
|
...new Set(componentSpecReferences
|
|
988
854
|
.filter((ref) => !ref.normalized ||
|
|
989
|
-
!citedPaths.has(ref.normalized) ||
|
|
990
855
|
!readSet.has(ref.normalized))
|
|
991
856
|
.map((ref) => ref.raw)),
|
|
992
857
|
];
|
|
@@ -998,7 +863,7 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
998
863
|
normalizationActions: [],
|
|
999
864
|
mockStrategy,
|
|
1000
865
|
classification: "retryable-invalid",
|
|
1001
|
-
failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths
|
|
866
|
+
failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths are missing a ledger specReference or lack a successful read event: ${uncorroborated.join(", ")}`,
|
|
1002
867
|
failureCode: "component-spec-not-cited",
|
|
1003
868
|
openspecReadPaths,
|
|
1004
869
|
openspecCandidatePaths: candidatePaths,
|